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).
This commit is contained in:
zeekay
2026-06-22 22:21:55 -07:00
parent 576ab83224
commit 4c7bab464c
7 changed files with 1425 additions and 30 deletions
+112 -17
View File
@@ -305,6 +305,19 @@ func getValidatorState(state validators.State) validators.State {
return &noopValidatorState{} return &noopValidatorState{}
} }
// quorumValidatorStateLive reports whether a HEIGHT-INDEXED validator state has
// actually been published for a K>1 quorum chain. A nil state means the P-Chain
// never published its validators.State into the manager — getValidatorState would
// then supply the no-op State, whose GetValidatorSet is empty at every height, so
// the ⅔-by-stake tally is 0 and VerifyWeighted fails closed FOREVER (a silent
// permanent finality stall). This predicate is the fail-closed guard (CRITICAL-1
// (c)): a K>1 chain whose state is not live must REFUSE TO START loudly rather
// than run with the no-op State. It is a pure function so the guard is unit-
// testable without building a whole chain (quorum_guard_node_test.go).
func quorumValidatorStateLive(state validators.State) bool {
return state != nil
}
// createWarpSigner creates a warp.Signer from a bls.Signer // createWarpSigner creates a warp.Signer from a bls.Signer
func createWarpSigner(sk bls.Signer, networkID uint32, chainID ids.ID) warp.Signer { func createWarpSigner(sk bls.Signer, networkID uint32, chainID ids.ID) warp.Signer {
if sk == nil { if sk == nil {
@@ -1143,6 +1156,35 @@ func (m *manager) buildChain(chainParams ChainParameters, sb nets.Net) (*chainIn
} }
m.Log.Info("VM initialized successfully", log.Stringer("chainID", chainParams.ID)) m.Log.Info("VM initialized successfully", log.Stringer("chainID", chainParams.ID))
// CRITICAL-1(a): publish the P-Chain's HEIGHT-INDEXED validators.State so
// every K>1 chain built AFTER it (C-Chain, L1s, …) resolves the weighted
// validator set / per-voter pubkeys / stake at the block's P-chain epoch
// height. The platformvm VM instance IS a validators.State (it embeds the
// height-indexed pvalidators.Manager built from its own state DB, with the
// real GetValidatorSet(ctx, height, netID)). It only EXISTS after the
// P-Chain VM is initialized — which is here — and the P-Chain is created
// before any other primary-network chain (single serial chain-creator
// goroutine), so this assignment happens-before every later chain reads
// m.validatorState in the K>1 wiring block below. Without this the field
// stays nil → getValidatorState returns the no-op → empty set at every
// height → ⅔-stake tally is 0 → finality stalls forever on every K>1 chain
// (the bug the fail-closed guard now also catches). Plain assignment is
// race-free: chain creation is serialized (dispatchChainCreator).
if chainParams.ID == constants.PlatformChainID {
if vdrState, ok := vmImpl.(validators.State); ok {
m.validatorState = vdrState
m.Log.Info("published P-Chain height-indexed validator state to chain manager (MEDIUM-1/CRITICAL-1)",
log.Stringer("chainID", chainParams.ID))
} else {
// The P-Chain MUST be a validators.State; if it is not, every K>1
// chain (including the P-Chain itself) would stall. Fail loud.
return nil, fmt.Errorf(
"P-Chain VM %T does not implement validators.State — cannot publish the "+
"height-indexed validator set; every K>1 quorum chain would stall finality",
vmImpl)
}
}
// Transition VM to normal operation after initialization // Transition VM to normal operation after initialization
// For genesis-based networks with pre-configured validators, this is required // For genesis-based networks with pre-configured validators, this is required
// to make the VM APIs available immediately // to make the VM APIs available immediately
@@ -1234,27 +1276,68 @@ func (m *manager) buildChain(chainParams ChainParameters, sb nets.Net) (*chainIn
Params: &consensusParams, Params: &consensusParams,
} }
if consensusParams.K > 1 { if consensusParams.K > 1 {
netCfg.VoteVerifier = newBLSVoteVerifier(m.Validators, networkID) // CRITICAL-1 FAIL-CLOSED GUARD (c): a K>1 quorum chain finalizes ONLY
netCfg.VoteSigner = newBLSVoteSigner(m.StakingBLSKey) // on a ⅔-by-stake supermajority read from the HEIGHT-INDEXED validator
// state. If that state was never published (m.validatorState == nil →
// getValidatorState returns the no-op, whose GetValidatorSet is empty at
// every height), the stake tally is 0 → VerifyWeighted fails closed →
// NO block EVER finalizes. That is a SILENT permanent finality stall.
// Refuse to build the chain LOUDLY instead. The P-Chain publishes its
// own height-indexed State into m.validatorState the moment it is
// created (it is built before any other K>1 chain), so the P-Chain and
// every chain after it sees a live state here; only a genuine wiring
// regression trips this.
if !quorumValidatorStateLive(m.validatorState) {
return nil, fmt.Errorf(
"refusing to start K>1 quorum chain %s: height-indexed validator state is not wired "+
"(getValidatorState would supply the no-op State → zero stake at every height → "+
"VerifyWeighted fails closed → finality would stall permanently). The P-Chain must be "+
"created first and publish its validators.State; this is a wiring bug, not a runtime condition",
chainParams.ID)
}
// Height-indexed validator state is the SINGLE source of epoch truth for // 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 // ALL FOUR epoch-pinned reads (MEDIUM-1 / CRITICAL-1 / RESIDUAL-B):
// read at the cert-position height so every node computes the IDENTICAL // membership, per-voter PUBKEY (the verifier), the ⅔-by-stake tally, and
// set, weights, and root for a given value-chain height — independent of // the set-root commitment. They are all read at the block's P-CHAIN epoch
// async current-map skew during a P-chain / L1-staking change (reading the // height so every node computes the IDENTICAL set/pubkeys/weights/root for
// CURRENT map made the signer and the assembler disagree on the set-root // a given block — independent of async current-map skew during a P-chain /
// and stall finality at every staking change). getValidatorState supplies a // L1-staking change. (Reading the CURRENT map made the signer and the
// no-op State on non-staking nodes (Empty root / zero stake — the engine's // assembler disagree on the set-root, and dropped the votes of validators
// unbound default). // that had left the current map but were members at the epoch — stalling
// finality at every staking change.)
vdrState := getValidatorState(m.validatorState) vdrState := getValidatorState(m.validatorState)
// Vote verifier resolves the voter's pubkey from set@epoch (RESIDUAL-B),
// the SAME height-pinned source as the stake + set-root — NOT m.Validators
// (the current map).
netCfg.VoteVerifier = newBLSVoteVerifier(vdrState, networkID)
netCfg.VoteSigner = newBLSVoteSigner(m.StakingBLSKey)
// Stake-weighted finality (HIGH-3): require a ⅔-of-stake supermajority, // Stake-weighted finality (HIGH-3): require a ⅔-of-stake supermajority,
// not just the α-of-K count, so a low-stake coalition cannot finalize. // not just the α-of-K count, so a low-stake coalition cannot finalize.
netCfg.StakeSource = newValidatorStakeSource(vdrState, networkID) netCfg.StakeSource = newValidatorStakeSource(vdrState, networkID)
// Epoch binding (MEDIUM-1): pin every vote/cert to the weighted validator // 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 // set IN FORCE AT the block's P-chain epoch height so the ⅔-by-stake
// enforced at that epoch — a cert gathered under one set cannot be // predicate is enforced at that epoch — a cert gathered under one set
// re-verified against another (its signatures were over this height-pinned // cannot be re-verified against another (its signatures were over this
// root). // height-pinned root).
netCfg.ValidatorSetRoot = newValidatorSetRootSource(vdrState, networkID) netCfg.ValidatorSetRoot = newValidatorSetRootSource(vdrState, networkID)
// b2: deliver the REAL P-chain epoch height to the engine. The four reads
// above are height-pinned but the engine reads that height off the VM
// block (pChainHeightOf) — and a bare plugin block exposes none, so the
// engine would resolve the set at P-chain height 0 (the GENESIS set),
// freezing the epoch and dropping every post-genesis validator's vote.
// Wrap the BlockBuilder so the block the engine builds/parses carries the
// proposer's live P-chain height (max(GetCurrentHeight, parentH)), stamped
// into the gossiped bytes so every follower adopts the IDENTICAL height —
// the set-root/stake/pubkey reads then track the LIVE set at that height.
// Installed ONLY here (K>1): a K==1 chain has no cert and no epoch, so the
// stamp is inert and the inner VM is used directly.
if blockBuilder != nil {
blockBuilder = newPChainHeightVM(blockBuilder, vdrState, networkID)
netCfg.VM = blockBuilder
m.Log.Info("wired P-chain epoch height into consensus block builder (b2)",
log.Stringer("chainID", chainParams.ID),
log.Stringer("networkID", networkID))
}
} }
consensusEngine := consensuschain.NewRuntime(netCfg) consensusEngine := consensuschain.NewRuntime(netCfg)
@@ -1334,7 +1417,11 @@ func (m *manager) buildChain(chainParams ChainParameters, sb nets.Net) (*chainIn
Runtime: chainRuntime, Runtime: chainRuntime,
VM: vmTyped, // Use the real VM directly VM: vmTyped, // Use the real VM directly
Engine: consensusEngine, // Use real consensus engine directly Engine: consensusEngine, // Use real consensus engine directly
Handler: newBlockHandler(vmTyped, m.Log, consensusEngine, m.Net, m.MsgCreator, chainParams.ID, networkID), // Handler parses inbound blocks through the SAME builder the engine emits
// through (blockBuilder == the P-chain-height wrapper on K>1, the inner VM
// on K==1), so the container bytes it parses match the bytes the engine
// framed — one codec, no raw-vs-wrapped split.
Handler: newBlockHandler(blockBuilder, m.Log, consensusEngine, m.Net, m.MsgCreator, chainParams.ID, networkID),
} }
default: default:
return nil, fmt.Errorf("unsupported VM type: %T", vmImpl) return nil, fmt.Errorf("unsupported VM type: %T", vmImpl)
@@ -2193,7 +2280,15 @@ func (e *emptyValidatorManager) GetCurrentValidators(ctx context.Context, height
// blockHandler implements handler.Handler interface and processes incoming blocks // blockHandler implements handler.Handler interface and processes incoming blocks
// This enables block propagation between validators // This enables block propagation between validators
type blockHandler struct { type blockHandler struct {
vm chain.ChainVM // vm is the SAME BlockBuilder the consensus engine builds/parses through — the
// P-chain-height-stamping wrapper on K>1 chains (so ParseBlock unwraps the
// transport envelope the engine emits and recovers the proposer's epoch
// height), the inner VM directly on K==1. The handler only needs the
// BlockBuilder subset (GetBlock / ParseBlock / LastAccepted); typing it as the
// builder — not chain.ChainVM — keeps the engine and the handler on ONE block
// codec, so inbound P2P container bytes are parsed by the same code that framed
// them (no raw-vs-wrapped mismatch).
vm consensuschain.BlockBuilder
logger log.Logger logger log.Logger
engine *consensuschain.Runtime // Consensus engine for proper block handling engine *consensuschain.Runtime // Consensus engine for proper block handling
net network.Network // Network for sending Qbit responses net network.Network // Network for sending Qbit responses
@@ -2232,7 +2327,7 @@ type contextRequest struct {
timestamp time.Time timestamp time.Time
} }
func newBlockHandler(vm chain.ChainVM, logger log.Logger, engine *consensuschain.Runtime, net network.Network, msgCreator message.OutboundMsgBuilder, chainID ids.ID, networkID ids.ID) *blockHandler { func newBlockHandler(vm consensuschain.BlockBuilder, logger log.Logger, engine *consensuschain.Runtime, net network.Network, msgCreator message.OutboundMsgBuilder, chainID ids.ID, networkID ids.ID) *blockHandler {
return &blockHandler{ return &blockHandler{
vm: vm, vm: vm,
logger: logger, logger: logger,
+591
View File
@@ -0,0 +1,591 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// pchain_height_finality_test.go — the b2 load-bearing proof the prior round
// lacked: that the node delivers the REAL P-chain epoch height to the chain
// engine, so a K>1 quorum chain finalizes against the LIVE validator set — not
// the frozen genesis set.
//
// The consensus-layer TestPChainEpochFinality_RealWiring proved the engine reads
// the RIGHT height GIVEN a block that already exposes one; it fed a synthetic
// block carrying PChainHeight directly, BYPASSING the boundary. The boundary —
// where a bare plugin block yields pChainHeightOf==0 — was exactly what shipped
// broken (set@0 = genesis). These tests drive the REAL boundary:
//
// inner VM (bare block, no PChainHeight)
// └─ pChainHeightVM (the b2 wrapper, backed by a real validators.State)
// └─ consensus engine (real α-of-K cert finality, node BLS sources)
//
// and prove three properties end to end:
//
// (1) pChainHeightOf(realBlock) returns the wrapper's stamped P-chain height
// (NOT 0) — at BuildBlock AND after a ParseBlock round-trip of the gossiped
// bytes (the determinism guarantee: every node recovers the same height).
// (2) K>1 FINALIZES at genesis (set@H0).
// (3) K>1 FINALIZES AFTER a staking change — validators that JOINED post-genesis
// cast the deciding votes+stake. This is the case that STALLS on the set@0
// path (the joiners are absent from the genesis set), so finalizing proves
// the real height is load-bearing.
//
// CGO-free: the node BLS sources use the pure-Go BLS path under CGO_ENABLED=0, so
// this runs the ACTUAL production quorum sources (blsVoteVerifier / blsVoteSigner
// / validatorStakeSource / validatorSetRootSource) — not an ed25519 stand-in.
package chains
import (
"context"
"sync"
"testing"
"time"
consensusconfig "github.com/luxfi/consensus/config"
consensuschain "github.com/luxfi/consensus/engine/chain"
"github.com/luxfi/consensus/engine/chain/block"
"github.com/luxfi/crypto/bls"
"github.com/luxfi/ids"
validators "github.com/luxfi/validators"
"github.com/luxfi/validators/validatorstest"
)
// --- a bare inner VM whose blocks carry NO P-chain height --------------------
// fakeInnerBlock is a minimal chain block: it satisfies block.Block but does NOT
// expose PChainHeight() — exactly like the plugin VM blocks (C-Chain EVM, dexvm)
// the node runs. Its Bytes() is its own opaque encoding; the wrapper frames a
// P-chain height AROUND these bytes for transport.
type fakeInnerBlock struct {
id ids.ID
parentID ids.ID
height uint64
bytes []byte
timestamp time.Time
mu sync.Mutex
acceptCalled int
}
func (b *fakeInnerBlock) ID() ids.ID { return b.id }
func (b *fakeInnerBlock) Parent() ids.ID { return b.parentID }
func (b *fakeInnerBlock) ParentID() ids.ID { return b.parentID }
func (b *fakeInnerBlock) Height() uint64 { return b.height }
func (b *fakeInnerBlock) Timestamp() time.Time { return b.timestamp }
func (b *fakeInnerBlock) Status() uint8 { return 0 }
func (b *fakeInnerBlock) Bytes() []byte { return b.bytes }
func (b *fakeInnerBlock) Verify(context.Context) error { return nil }
func (b *fakeInnerBlock) Reject(context.Context) error { return nil }
func (b *fakeInnerBlock) Accept(context.Context) error {
b.mu.Lock()
b.acceptCalled++
b.mu.Unlock()
return nil
}
func (b *fakeInnerBlock) accepted() int {
b.mu.Lock()
defer b.mu.Unlock()
return b.acceptCalled
}
// fakeInnerVM is a BlockBuilder over fakeInnerBlocks, keyed by id and by bytes so
// ParseBlock(bytes) reconstructs the SAME inner block on a follower. It builds one
// block on demand (set via stage) so the test controls the proposed block.
type fakeInnerVM struct {
mu sync.Mutex
byID map[ids.ID]*fakeInnerBlock
byBytes map[string]*fakeInnerBlock
staged *fakeInnerBlock // returned by the next BuildBlock
lastAcc ids.ID
}
func newFakeInnerVM() *fakeInnerVM {
return &fakeInnerVM{
byID: make(map[ids.ID]*fakeInnerBlock),
byBytes: make(map[string]*fakeInnerBlock),
}
}
func (vm *fakeInnerVM) register(b *fakeInnerBlock) {
vm.mu.Lock()
vm.byID[b.id] = b
vm.byBytes[string(b.bytes)] = b
vm.mu.Unlock()
}
// stage sets the block the next BuildBlock returns (and registers it for Get/Parse).
func (vm *fakeInnerVM) stage(b *fakeInnerBlock) {
vm.register(b)
vm.mu.Lock()
vm.staged = b
vm.mu.Unlock()
}
func (vm *fakeInnerVM) BuildBlock(context.Context) (block.Block, error) {
vm.mu.Lock()
defer vm.mu.Unlock()
return vm.staged, nil
}
func (vm *fakeInnerVM) ParseBlock(_ context.Context, b []byte) (block.Block, error) {
vm.mu.Lock()
defer vm.mu.Unlock()
if blk, ok := vm.byBytes[string(b)]; ok {
return blk, nil
}
// Unknown bytes: synthesize a block so a follower can still parse. Real VMs
// decode deterministically; the test pre-registers every block it gossips, so
// this path is only a safety net.
return nil, errUnknownInnerBytes
}
func (vm *fakeInnerVM) GetBlock(_ context.Context, id ids.ID) (block.Block, error) {
vm.mu.Lock()
defer vm.mu.Unlock()
if blk, ok := vm.byID[id]; ok {
return blk, nil
}
return nil, errUnknownInnerBytes
}
func (vm *fakeInnerVM) LastAccepted(context.Context) (ids.ID, error) {
vm.mu.Lock()
defer vm.mu.Unlock()
return vm.lastAcc, nil
}
func (vm *fakeInnerVM) SetPreference(_ context.Context, id ids.ID) error {
vm.mu.Lock()
vm.lastAcc = id
vm.mu.Unlock()
return nil
}
var errUnknownInnerBytes = errInnerBytes{}
type errInnerBytes struct{}
func (errInnerBytes) Error() string { return "fakeInnerVM: unknown block bytes" }
// --- BLS validator material (pure-Go BLS under CGO_ENABLED=0) ----------------
type blsValidator struct {
nodeID ids.NodeID
sk *bls.SecretKey
pkComp []byte
light uint64
}
func newBLSValidator(t *testing.T, weight uint64) blsValidator {
t.Helper()
sk, err := bls.NewSecretKey()
if err != nil {
t.Fatalf("bls.NewSecretKey: %v", err)
}
return blsValidator{
nodeID: ids.GenerateTestNodeID(),
sk: sk,
pkComp: bls.PublicKeyToCompressedBytes(sk.PublicKey()),
light: weight,
}
}
func (v blsValidator) out() *validators.GetValidatorOutput {
return &validators.GetValidatorOutput{
NodeID: v.nodeID,
PublicKey: v.pkComp,
Light: v.light,
Weight: v.light,
}
}
// stateByHeight builds a height-indexed validators.State reporting the given sets
// per height (empty for unknown heights / wrong net), with GetCurrentHeight fixed
// to `current` so the wrapper stamps that height onto built blocks.
func stateByHeight(netID ids.ID, current uint64, byHeight map[uint64][]blsValidator) *validatorstest.TestState {
s := validatorstest.NewTestState()
s.GetCurrentHeightF = func(context.Context) (uint64, error) { return current, nil }
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] = v.out()
}
return out, nil
}
return s
}
// --- a recording cert gossiper (engine CertGossiper) -------------------------
type recordingCertGossiper struct {
mu sync.Mutex
certs [][]byte
}
func (g *recordingCertGossiper) GossipCert(_ ids.ID, _ ids.ID, certBytes []byte) error {
g.mu.Lock()
g.certs = append(g.certs, append([]byte(nil), certBytes...))
g.mu.Unlock()
return nil
}
func (g *recordingCertGossiper) count() int {
g.mu.Lock()
defer g.mu.Unlock()
return len(g.certs)
}
var _ consensuschain.CertGossiper = (*recordingCertGossiper)(nil)
// --- helpers -----------------------------------------------------------------
func params5() consensusconfig.Parameters {
return consensusconfig.Parameters{K: 5, AlphaPreference: 3, AlphaConfidence: 3, Beta: 2}
}
func waitForCond(d time.Duration, cond func() bool) bool {
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
if cond() {
return true
}
time.Sleep(5 * time.Millisecond)
}
return cond()
}
// signBLSVote produces validator v's signed accept Vote for a position (the same
// canonical message the node's blsVoteSigner/Verifier use).
func signBLSVote(t *testing.T, v blsValidator, pos consensuschain.VotePosition) consensuschain.Vote {
t.Helper()
sig, err := v.sk.Sign(consensuschain.CanonicalVoteMessage(pos))
if err != nil {
t.Fatalf("sign vote: %v", err)
}
return consensuschain.Vote{
BlockID: pos.BlockID,
NodeID: v.nodeID,
Accept: true,
SignedAt: time.Now(),
Signature: bls.SignatureToBytes(sig),
ParentID: pos.ParentID,
Round: pos.Round,
}
}
// quorumEngineFixture wires a real consensus engine with the node's production
// BLS quorum sources, all height-pinned to a height-indexed validators.State,
// driving blocks through the b2 pChainHeightVM wrapper over a bare inner VM.
type quorumEngineFixture struct {
engine *consensuschain.Transitive
wrapper *pChainHeightVM
inner *fakeInnerVM
chainID ids.ID
netID ids.ID
proposer blsValidator
certs *recordingCertGossiper
}
func newQuorumEngineFixture(t *testing.T, netID ids.ID, state validators.State, proposer blsValidator, byHeight map[uint64][]blsValidator) *quorumEngineFixture {
t.Helper()
inner := newFakeInnerVM()
wrapper := newPChainHeightVM(inner, state, netID)
chainID := ids.GenerateTestID()
certs := &recordingCertGossiper{}
vdrState := state
engine := consensuschain.NewWithConfig(
consensuschain.Config{Params: params5(), VM: wrapper},
consensuschain.WithQuorumCert(chainID, proposer.nodeID, newBLSVoteVerifier(vdrState, netID), certs, newBLSVoteSigner(proposer.sk)),
consensuschain.WithStakeWeighting(newValidatorStakeSource(vdrState, netID)),
consensuschain.WithValidatorSetRoot(newValidatorSetRootSource(vdrState, netID)),
)
if err := engine.Start(context.Background(), true); err != nil {
t.Fatalf("engine.Start: %v", err)
}
t.Cleanup(func() { _ = engine.Stop(context.Background()) })
return &quorumEngineFixture{
engine: engine,
wrapper: wrapper,
inner: inner,
chainID: chainID,
netID: netID,
proposer: proposer,
certs: certs,
}
}
// proposeViaWrapper builds the staged inner block THROUGH the wrapper (stamping
// the live P-chain height), tracks it as the engine's own verified proposal with
// the wrapper-delivered epoch, records the proposer's self-vote, and returns the
// wrapped block + the canonical vote position followers must sign.
func (f *quorumEngineFixture) proposeViaWrapper(t *testing.T, inner *fakeInnerBlock) (block.Block, consensuschain.VotePosition) {
t.Helper()
f.inner.stage(inner)
wrapped, err := f.wrapper.BuildBlock(context.Background())
if err != nil {
t.Fatalf("wrapper.BuildBlock: %v", err)
}
pos := f.engine.TrackOwnProposalForTest(context.Background(), wrapped, 0)
return wrapped, pos
}
// newFakeInner is a small constructor for an inner block at value height h with a
// unique opaque encoding (tag-derived, so byBytes keys never collide).
func newFakeInner(h uint64, parent ids.ID, tag string) *fakeInnerBlock {
return &fakeInnerBlock{
id: ids.GenerateTestID(),
parentID: parent,
height: h,
bytes: []byte("inner:" + tag),
timestamp: time.Now(),
}
}
// --- (1) the boundary delivers the REAL height (not 0), build + parse ---------
// TestPChainHeightVM_DeliversRealHeight is the direct b2 boundary proof: the
// wrapper stamps the proposer's live P-chain height onto the block the engine
// sees, so pChainHeightOf(realBlock) returns that height — NOT 0 — at BuildBlock,
// AND a follower recovers the IDENTICAL height by parsing the gossiped bytes (the
// determinism guarantee H rides the bytes, never recomputed from a skewing view).
//
// This is the assertion the whole fix turns on. On the broken path
// pChainHeightOf(any real plugin block)==0 → set@0 (genesis) forever.
func TestPChainHeightVM_DeliversRealHeight(t *testing.T) {
const epoch = uint64(7) // the live P-chain height the proposer stamps
netID := ids.GenerateTestID()
v := newBLSValidator(t, 20)
// A state whose GetCurrentHeight is 7 (so BuildBlock stamps 7); the set content
// is irrelevant to the stamping itself, but we register it at 7 for symmetry.
state := stateByHeight(netID, epoch, map[uint64][]blsValidator{epoch: {v}})
wrapper := newPChainHeightVM(newFakeInnerVM(), state, netID)
inner := newFakeInner(10_000_000, ids.Empty, "real-height") // value height races ahead
wrapper.inner.(*fakeInnerVM).stage(inner)
wrapped, err := wrapper.BuildBlock(context.Background())
if err != nil {
t.Fatalf("BuildBlock: %v", err)
}
// (1a) the engine's boundary read on the BUILT block returns the stamped height.
if got := consensuschain.PChainHeightOfForTest(wrapped); got != epoch {
t.Fatalf("pChainHeightOf(built block) = %d, want %d — the boundary still delivers the wrong height "+
"(0 would mean the genesis-set freeze the b2 fix removes)", got, epoch)
}
// Sanity: the inner block itself exposes no PChainHeight, so without the wrapper
// the engine would read 0. This pins WHY the wrapper is load-bearing.
if got := consensuschain.PChainHeightOfForTest(inner); got != 0 {
t.Fatalf("bare inner block must expose no P-chain height (pChainHeightOf=0), got %d", got)
}
// (1b) determinism: a follower parsing the GOSSIPED bytes recovers the same H.
parsed, err := wrapper.ParseBlock(context.Background(), wrapped.Bytes())
if err != nil {
t.Fatalf("ParseBlock(gossiped bytes): %v", err)
}
if got := consensuschain.PChainHeightOfForTest(parsed); got != epoch {
t.Fatalf("pChainHeightOf(parsed block) = %d, want %d — a follower recomputed/lost the height; "+
"finality would stall on a dynamic set (worse than the genesis fallback)", got, epoch)
}
if parsed.ID() != wrapped.ID() {
t.Fatalf("parsed block ID %s != built block ID %s — the inner identity must be preserved across the envelope", parsed.ID(), wrapped.ID())
}
}
// --- (2) K>1 finalizes at genesis (set@H0) -----------------------------------
// TestPChainHeightVM_FinalizesAtGenesis proves the fix UNBRICKS finality in the
// base case: a K>1 quorum chain whose validator set is the genesis set (current
// P-chain height 0) finalizes through the real BLS quorum sources. This is the
// safe floor the genesis fallback guarantees — even here the height is delivered
// honestly (0), and the set@0 is non-empty so a ⅔ quorum finalizes.
func TestPChainHeightVM_FinalizesAtGenesis(t *testing.T) {
const genesis = uint64(0)
netID := constantsPrimaryNetworkID()
g := make([]blsValidator, 5)
for i := range g {
g[i] = newBLSValidator(t, 20) // equal stake, total 100
}
state := stateByHeight(netID, genesis, map[uint64][]blsValidator{genesis: g})
f := newQuorumEngineFixture(t, netID, state, g[0], map[uint64][]blsValidator{genesis: g})
inner := newFakeInner(42, ids.Empty, "genesis-finalize") // value height advanced past genesis
wrapped, pos := f.proposeViaWrapper(t, inner)
// The block was stamped at the genesis height (0); the position binds set@0.
if got := consensuschain.PChainHeightOfForTest(wrapped); got != genesis {
t.Fatalf("expected genesis stamp %d, got %d", genesis, got)
}
if pos.ValidatorSetRoot == ids.Empty {
t.Fatal("genesis set-root must be non-Empty (the genesis set is non-empty)")
}
// proposer g[0] self-voted; drive 3 more signed accepts → 4/5 = 80/100 stake > ⅔.
f.engine.ReceiveVote(signBLSVote(t, g[1], pos))
f.engine.ReceiveVote(signBLSVote(t, g[2], pos))
f.engine.ReceiveVote(signBLSVote(t, g[3], pos))
if !waitForCond(2*time.Second, func() bool { return inner.accepted() == 1 }) {
t.Fatalf("UNBRICK: a K>1 block must finalize against the genesis set (VM.Accept=%d)", inner.accepted())
}
if f.certs.count() == 0 {
t.Fatal("a verified quorum cert must be assembled + gossiped at finality")
}
}
// --- (3) K>1 finalizes AFTER a staking change (THE b2 proof) ------------------
// TestPChainHeightVM_FinalizesAfterStakingChange is the load-bearing b2 proof:
// validators that JOINED after genesis cast the deciding votes + stake, and the
// block finalizes — which is IMPOSSIBLE on the broken set@0 path, where the
// joiners are absent from the genesis set so their votes are dropped and their
// stake is uncounted.
//
// The set@epoch (height 7) holds {g0, j1, j2, j3, j4}: only g0 overlaps genesis;
// j1..j4 JOINED at 7. The genesis set@0 holds five DIFFERENT validators with only
// g0 in common. A 4-voter cert {g0, j1, j2, j3} = 80/100 stake-at-7 > ⅔.
//
// - With the b2 fix: the block is stamped 7, the verifier resolves j1..j3 at
// set@7 (present) and the ⅔ tally is measured at 7 → the cert verifies →
// FINALIZES.
// - On the broken set@0 path: j1..j3 are unknown at height 0 → their signed
// votes are dropped → only g0 (20/100) verifies < ⅔ → STALLS FOREVER.
//
// The test asserts BOTH directly: (a) the block finalizes, and (b) the production
// verifier itself rejects a joiner's vote at height 0 but accepts it at height 7 —
// pinning the exact mechanism that would stall the frozen-set path.
func TestPChainHeightVM_FinalizesAfterStakingChange(t *testing.T) {
const (
genesis = uint64(0)
epoch = uint64(7) // staking change landed here; current P-chain height = 7
)
netID := constantsPrimaryNetworkID()
// g0 is a validator present at BOTH epochs (so the fixture proposer key resolves
// at the stamped epoch). j1..j4 JOINED at epoch 7. gen1..gen4 are the OTHER four
// genesis validators (present only at 0) — they make set@0 a genuinely different
// set so "the joiners decide" is unambiguous.
g0 := newBLSValidator(t, 20)
j1 := newBLSValidator(t, 20)
j2 := newBLSValidator(t, 20)
j3 := newBLSValidator(t, 20)
j4 := newBLSValidator(t, 20)
gen1 := newBLSValidator(t, 20)
gen2 := newBLSValidator(t, 20)
gen3 := newBLSValidator(t, 20)
gen4 := newBLSValidator(t, 20)
genesisSet := []blsValidator{g0, gen1, gen2, gen3, gen4} // total 100 at height 0
epochSet := []blsValidator{g0, j1, j2, j3, j4} // total 100 at height 7
state := stateByHeight(netID, epoch, map[uint64][]blsValidator{
genesis: genesisSet,
epoch: epochSet,
})
// (b) PIN THE MECHANISM that makes the frozen path stall: the production verifier
// rejects a joiner at height 0 (frozen/genesis) and accepts it at height 7.
verifier := newBLSVoteVerifier(state, netID)
// Build a position to sign (any height-7-bound position works for this probe).
probeBlk := newFakeInner(123, ids.Empty, "probe")
probeWrapper := newPChainHeightVM(newFakeInnerVM(), state, netID)
probeWrapper.inner.(*fakeInnerVM).stage(probeBlk)
probeWrapped, _ := probeWrapper.BuildBlock(context.Background())
probePos := consensuschain.VotePosition{
ChainID: ids.GenerateTestID(),
Height: probeWrapped.Height(),
BlockID: probeWrapped.ID(),
ParentID: ids.Empty,
ValidatorSetRoot: newValidatorSetRootSource(state, netID).ValidatorSetRoot(epoch),
}
probeMsg := consensuschain.CanonicalVoteMessage(probePos)
j1Sig, err := j1.sk.Sign(probeMsg)
if err != nil {
t.Fatalf("sign probe: %v", err)
}
j1SigBytes := bls.SignatureToBytes(j1Sig)
if verifier.VerifyVote(j1.nodeID, probeMsg, j1SigBytes, genesis) {
t.Fatal("frozen-path mechanism broken: a post-genesis joiner must NOT verify at height 0 " +
"(if it does, the genesis-set path would not actually stall and the test is vacuous)")
}
if !verifier.VerifyVote(j1.nodeID, probeMsg, j1SigBytes, epoch) {
t.Fatal("a joiner present at the epoch MUST verify at the epoch height — the b2 read is broken")
}
// (a) THE END-TO-END FINALIZATION: drive the real engine with the joiners.
f := newQuorumEngineFixture(t, netID, state, g0, map[uint64][]blsValidator{
genesis: genesisSet,
epoch: epochSet,
})
inner := newFakeInner(10_000_000, ids.Empty, "post-staking-change") // value height far ahead
wrapped, pos := f.proposeViaWrapper(t, inner)
// The block carries the LIVE epoch height (7), and the position binds set@7.
if got := consensuschain.PChainHeightOfForTest(wrapped); got != epoch {
t.Fatalf("block must carry the live epoch height %d, got %d", epoch, got)
}
if pos.ValidatorSetRoot != newValidatorSetRootSource(state, netID).ValidatorSetRoot(epoch) {
t.Fatal("position must bind the set-root at the epoch height (set@7), not another height")
}
if pos.ValidatorSetRoot == newValidatorSetRootSource(state, netID).ValidatorSetRoot(genesis) {
t.Fatal("test vacuous: set@7 root must differ from set@0 root (the sets must genuinely differ)")
}
// proposer g0 self-voted; the JOINERS j1,j2,j3 cast the deciding votes.
// 4 distinct accepts {g0,j1,j2,j3} = 80/100 stake-at-7 > ⅔ → MUST finalize.
f.engine.ReceiveVote(signBLSVote(t, j1, pos))
f.engine.ReceiveVote(signBLSVote(t, j2, pos))
f.engine.ReceiveVote(signBLSVote(t, j3, pos))
if !waitForCond(3*time.Second, func() bool { return inner.accepted() == 1 }) {
t.Fatalf("b2: a block whose ⅔ quorum is post-genesis JOINERS must finalize against the LIVE set@%d "+
"(VM.Accept=%d). On the broken set@0 path the joiners are unknown → votes dropped → permanent stall.",
epoch, inner.accepted())
}
// The gossiped cert must verify stake-weighted AT THE EPOCH, and must FAIL at
// genesis (where the joiners are absent) — proving the height is load-bearing.
if f.certs.count() == 0 {
t.Fatal("a verified quorum cert must be assembled + gossiped at finality")
}
f.certs.mu.Lock()
lastCert := f.certs.certs[len(f.certs.certs)-1]
f.certs.mu.Unlock()
cert, err := consensuschain.UnmarshalQuorumCert(lastCert)
if err != nil {
t.Fatalf("decode gossiped cert: %v", err)
}
stake := newValidatorStakeSource(state, netID)
if err := cert.VerifyWeighted(verifier, stake, epoch); err != nil {
t.Fatalf("cert must verify stake-weighted at the epoch height %d: %v", epoch, err)
}
if err := cert.VerifyWeighted(verifier, stake, genesis); err == nil {
t.Fatal("b2: cert must NOT verify at the genesis height (joiners absent / below ⅔ there) — " +
"if it does, the epoch height is not actually being used")
}
// The cert must contain at least one joiner — proving a post-genesis validator's
// vote was counted (the exact case the frozen-set path drops).
var hasJoiner bool
for i := range cert.Votes {
if cert.Votes[i].NodeID == j1.nodeID || cert.Votes[i].NodeID == j2.nodeID || cert.Votes[i].NodeID == j3.nodeID {
hasJoiner = true
break
}
}
if !hasJoiner {
t.Fatal("the finality cert must include a post-genesis joiner's vote (it was the deciding quorum)")
}
}
// constantsPrimaryNetworkID returns the primary-network ID the node uses for
// native-chain validator lookups (ids.Empty). Declared here so the test reads the
// SAME net the production wiring resolves native chains against, without importing
// the constants package for one value.
func constantsPrimaryNetworkID() ids.ID { return ids.Empty }
+327
View File
@@ -0,0 +1,327 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// pchain_height_vm.go — the node-layer boundary that delivers the REAL P-CHAIN
// EPOCH HEIGHT to the consensus chain engine (the b2 wiring; the LAST
// QUORUM_FINALITY blocker).
//
// THE BUG IT FIXES. The chain engine pins a block's weighted validator set to a
// P-CHAIN height (engine/chain: pChainHeightOf → epochHeightLocked → the SINGLE
// height the set-root commitment, the ⅔-by-stake tally, AND the per-voter pubkey
// resolution are read at). It reads that height by asserting the VM block to a
// `PChainHeight() uint64` (consensus block.SignedBlock). A bare VM block — every
// block the node's plugin VMs (C-Chain EVM, dexvm, …) produce — does NOT expose
// one, so pChainHeightOf returns 0 and the engine resolves the set at P-chain
// height 0: the GENESIS set. That is SAFE (non-empty, identical on every node,
// ≤ current) and UNBRICKS finality, but it FREEZES the epoch at genesis: a
// validator that JOINED after genesis is absent from set@0, so its vote is
// dropped and its stake is not counted — finality cannot track a DYNAMIC
// validator set (a departed genesis majority could even collude). This is the
// frozen-set caveat Gate D must sign away.
//
// THE FIX. pChainHeightVM wraps the chain's BlockBuilder (the VM the engine
// builds/parses blocks through) and makes the block the engine sees carry the
// proposer's P-chain epoch height — WITHOUT changing the inner VM's block format,
// IDs, or ledger state:
//
// - BuildBlock (proposer): build the inner block, then stamp the proposer's
// live P-chain epoch height H = max(GetCurrentHeight, parentH) (the
// proposervm selectChildPChainHeight rule — monotone, ≤ current). Return a
// pChainHeightBlock whose Bytes() are [magic|H|innerBytes] and whose
// PChainHeight() is H. Every other method (ID, ParentID, Height, Verify,
// Accept, …) delegates to the inner block — so the block's IDENTITY and the
// VM's state are the inner VM's, unchanged.
// - ParseBlock (follower): split [magic|H|innerBytes], parse the inner bytes
// through the inner VM, and re-attach H. Bytes WITHOUT the magic (a raw inner
// block from a pre-wrapper peer, GetAncestors, or genesis) parse with H=0 →
// the SAFE genesis-set fallback — never worse than today.
//
// DETERMINISM is the whole point and is why H must travel IN the gossiped bytes.
// The engine gossips only blk.Bytes(); a follower recovers the block solely via
// ParseBlock(those bytes). The proposer STAMPS H; every follower ADOPTS the
// identical H from the envelope — it never recomputes H from its own (skewing)
// current P-chain view. So every honest node derives the SAME epoch height from
// the SAME signed block, which is the invariant the engine's cert verifier
// requires (engine/chain HandleIncomingCert cross-checks the cert's set-root
// against the set-root WE recompute at OUR epoch height for the block: equal H ⟹
// equal root ⟹ the cert verifies; a post-genesis validator's vote+stake now
// count). A build-time-only stamp would give the proposer a real H but leave
// followers computing a DIFFERENT root from their own height → the cert is
// dropped → finality STALLS on a dynamic set (strictly worse than the genesis
// fallback). That is why the height is carried, not recomputed.
//
// This is a consensus-TRANSPORT framing (an 8-byte height + 4-byte magic prefix
// on the gossiped bytes), NOT a chain/ledger fork: the inner VM's bytes, block
// IDs, and execution state are byte-identical, so there is no re-genesis — only a
// coordinated node upgrade (the whole validator set ships together).
package chains
import (
"context"
"encoding/binary"
"sync"
"time"
consensuschain "github.com/luxfi/consensus/engine/chain"
"github.com/luxfi/consensus/engine/chain/block"
"github.com/luxfi/ids"
validators "github.com/luxfi/validators"
)
// pChainHeightMagic tags a transport-wrapped block so ParseBlock can tell a
// wrapped block ([magic|H:8|innerBytes]) from a raw inner block (no magic →
// H=0, the genesis-set fallback). Distinct, fixed, version-pinned: a future
// envelope change bumps the last byte and is non-malleable. "LXP" = Lux P-chain.
var pChainHeightMagic = [4]byte{'L', 'X', 'P', 0x01}
// pChainHeightHeaderLen is the fixed transport-header width: magic(4) + height(8).
const pChainHeightHeaderLen = 4 + 8
// pChainHeightVM wraps a chain BlockBuilder so the block the consensus engine
// sees carries the proposer's P-chain epoch height. It is the ONLY thing that
// changes between the engine and the inner VM; everything else is the inner VM,
// verbatim.
//
// It is installed ONLY on K>1 (quorum) chains: a K==1 chain finalizes on its
// sole validator's self-vote with no cert and no validator-set epoch, so the
// stamp is inert there and the wrapper is not installed (the inner VM is used
// directly — one obvious path per mode).
type pChainHeightVM struct {
inner consensuschain.BlockBuilder
state validators.State
networkID ids.ID
// heights memoises blockID → stamped P-chain height for blocks this node has
// built or parsed, so GetBlock can re-attach the height a block was stamped
// with (the inner VM stores only the inner bytes, which carry no height).
// Best-effort: a cache miss yields H=0 (the genesis-set fallback). GetBlock is
// NOT on the finality-capture path — the engine records a block's epoch height
// the first time it BUILDS or PARSES the block (where the height is always
// present) and reads it back from its own pending-block record, never by
// re-fetching via GetBlock — so a miss here cannot drop a live block's epoch.
mu sync.Mutex
heights map[ids.ID]uint64
}
// newPChainHeightVM wraps inner with P-chain-epoch-height stamping backed by the
// height-indexed validators.State. networkID is the set the height is resolved
// against (PrimaryNetworkID for native chains; the L1's set ID otherwise) — it is
// recorded for symmetry with the engine's epoch reads, though GetCurrentHeight is
// a P-chain-global height. A nil state disables stamping (BuildBlock falls back to
// H=0): callers install this ONLY for K>1 chains, which the manager already
// guards to have a live height-indexed state, so the nil path is a defensive
// fail-soft, not a runtime mode.
func newPChainHeightVM(inner consensuschain.BlockBuilder, state validators.State, networkID ids.ID) *pChainHeightVM {
return &pChainHeightVM{
inner: inner,
state: state,
networkID: networkID,
heights: make(map[ids.ID]uint64),
}
}
var _ consensuschain.BlockBuilder = (*pChainHeightVM)(nil)
// remember records the height a block was stamped with so GetBlock can re-attach
// it. Bounded by nothing here on purpose — the engine prunes finalized blocks and
// a node's live working set is small; if this ever needs eviction it should
// mirror the engine's finalized-height watermark, not a blind LRU (dropping a
// still-pending block's height would silently reset its epoch to 0). For the b2
// scope the live set is small and short-lived.
func (vm *pChainHeightVM) remember(id ids.ID, h uint64) {
vm.mu.Lock()
vm.heights[id] = h
vm.mu.Unlock()
}
func (vm *pChainHeightVM) recall(id ids.ID) (uint64, bool) {
vm.mu.Lock()
h, ok := vm.heights[id]
vm.mu.Unlock()
return h, ok
}
// currentPChainHeight returns the proposer's live P-chain height, the upper end
// of the proposervm selectChildPChainHeight rule. A nil state or a read error
// yields 0 (the genesis-set fallback): a height the proposer cannot resolve must
// not be stamped onto a block, since a follower could not resolve the set there
// either. A read error is symmetric across nodes for a committed P-chain, so the
// fallback is uniform.
func (vm *pChainHeightVM) currentPChainHeight(ctx context.Context) uint64 {
if vm.state == nil {
return 0
}
h, err := vm.state.GetCurrentHeight(ctx)
if err != nil {
return 0
}
return h
}
// parentHeight returns the P-chain epoch height stamped on parentID, or 0 if it
// is unknown — used to keep a child's stamped height monotone ≥ its parent's
// (selectChildPChainHeight). An unknown parent (0) cannot LOWER the child below
// the current height (the max below), so monotonicity is preserved fail-soft.
func (vm *pChainHeightVM) parentHeight(parentID ids.ID) uint64 {
h, _ := vm.recall(parentID)
return h
}
// BuildBlock builds the inner block and stamps it with the proposer's P-chain
// epoch height H = max(currentPChainHeight, parentH). This is the proposer side
// of the epoch: the one node building this block chooses H from its own live
// P-chain view, and that H rides the gossiped bytes to every follower (which
// adopt it, never recompute it). Monotone ≥ parent so a chain's epoch never goes
// backwards across blocks (a cert at a lower epoch than its parent would bind a
// stale set).
func (vm *pChainHeightVM) BuildBlock(ctx context.Context) (block.Block, error) {
inner, err := vm.inner.BuildBlock(ctx)
if err != nil {
return nil, err
}
h := vm.currentPChainHeight(ctx)
if ph := vm.parentHeight(inner.ParentID()); ph > h {
h = ph
}
vm.remember(inner.ID(), h)
return newPChainHeightBlock(inner, h), nil
}
// ParseBlock recovers a block from transport bytes. Wrapped bytes
// ([magic|H|innerBytes]) yield a block whose PChainHeight() is the EXACT H the
// proposer stamped — the determinism guarantee: every node parsing the same
// gossiped bytes recovers the identical epoch height. Bytes without the magic are
// a raw inner block (a pre-wrapper peer, GetAncestors, genesis): parsed straight
// through with H=0, the SAFE genesis-set fallback.
func (vm *pChainHeightVM) ParseBlock(ctx context.Context, b []byte) (block.Block, error) {
innerBytes, h, wrapped := unwrapPChainHeight(b)
inner, err := vm.inner.ParseBlock(ctx, innerBytes)
if err != nil {
return nil, err
}
if wrapped {
vm.remember(inner.ID(), h)
return newPChainHeightBlock(inner, h), nil
}
// Raw inner block: no proposer height available → genesis-set fallback. Still
// wrap (uniform block type to the engine) but with H=0 and a passthrough
// Bytes() so re-gossip of a raw block stays raw.
return newRawPChainHeightBlock(inner), nil
}
// GetBlock fetches a block from the inner VM and re-attaches the P-chain height
// it was stamped with (recalled from build/parse). A miss yields H=0 — acceptable
// because GetBlock is not on the engine's finality-capture path (see the heights
// field doc). The returned block's Bytes() is the inner bytes (the inner VM's
// canonical at-rest form); a stamped height is re-attached for the engine's
// in-memory use only.
func (vm *pChainHeightVM) GetBlock(ctx context.Context, id ids.ID) (block.Block, error) {
inner, err := vm.inner.GetBlock(ctx, id)
if err != nil {
return nil, err
}
if h, ok := vm.recall(id); ok {
return newRawPChainHeightBlockWithHeight(inner, h), nil
}
return newRawPChainHeightBlock(inner), nil
}
// LastAccepted delegates to the inner VM.
func (vm *pChainHeightVM) LastAccepted(ctx context.Context) (ids.ID, error) {
return vm.inner.LastAccepted(ctx)
}
// SetPreference delegates to the inner VM.
func (vm *pChainHeightVM) SetPreference(ctx context.Context, id ids.ID) error {
return vm.inner.SetPreference(ctx, id)
}
// wrapPChainHeight frames inner bytes with the proposer's P-chain height:
// magic(4) || height(8,BE) || innerBytes. The header is fixed-width so unwrap is
// a constant-time prefix read.
func wrapPChainHeight(innerBytes []byte, height uint64) []byte {
out := make([]byte, pChainHeightHeaderLen+len(innerBytes))
copy(out[0:4], pChainHeightMagic[:])
binary.BigEndian.PutUint64(out[4:12], height)
copy(out[pChainHeightHeaderLen:], innerBytes)
return out
}
// unwrapPChainHeight splits transport bytes. Returns (innerBytes, height, true)
// for a wrapped block, or (b, 0, false) for raw bytes (no/!magic). A wrapped
// frame must be at least the header width AND carry the magic; anything else is
// treated as raw (fail-soft — a malformed prefix can never be mistaken for a
// height, it simply parses as an inner block which then fails its own decode).
func unwrapPChainHeight(b []byte) (innerBytes []byte, height uint64, wrapped bool) {
if len(b) < pChainHeightHeaderLen ||
b[0] != pChainHeightMagic[0] || b[1] != pChainHeightMagic[1] ||
b[2] != pChainHeightMagic[2] || b[3] != pChainHeightMagic[3] {
return b, 0, false
}
height = binary.BigEndian.Uint64(b[4:12])
return b[pChainHeightHeaderLen:], height, true
}
// --- the wrapped block -------------------------------------------------------
// pChainHeightBlock is a chain block that carries a P-chain epoch height for the
// engine while delegating identity, verification, and acceptance to the inner VM
// block. It satisfies consensus block.SignedBlock's PChainHeight() (the subset
// the engine reads via pChainHeightOf), so the engine records the real epoch
// height — every other behaviour is the inner VM's.
//
// `bytes` is what the engine gossips. For a proposer-built / wrapped-parsed block
// it is the framed [magic|H|innerBytes] so a follower recovers H; for a raw block
// (genesis-set fallback) it is the inner bytes verbatim (passthrough) so re-gossip
// stays raw.
type pChainHeightBlock struct {
inner block.Block
pChainHeight uint64
bytes []byte
}
// newPChainHeightBlock wraps inner with height h and a FRAMED Bytes()
// ([magic|h|inner]) — the proposer/parse path, where H must travel to followers.
func newPChainHeightBlock(inner block.Block, h uint64) *pChainHeightBlock {
return &pChainHeightBlock{
inner: inner,
pChainHeight: h,
bytes: wrapPChainHeight(inner.Bytes(), h),
}
}
// newRawPChainHeightBlock wraps inner with H=0 and a PASSTHROUGH Bytes() (the
// inner bytes verbatim) — the genesis-set fallback for a raw inner block.
func newRawPChainHeightBlock(inner block.Block) *pChainHeightBlock {
return &pChainHeightBlock{inner: inner, pChainHeight: 0, bytes: inner.Bytes()}
}
// newRawPChainHeightBlockWithHeight re-attaches a recalled height to an inner
// block fetched via GetBlock while keeping a PASSTHROUGH Bytes() (the inner VM's
// at-rest bytes). Used off the finality-capture path; the height is for the
// engine's in-memory epoch read, not for re-gossip framing.
func newRawPChainHeightBlockWithHeight(inner block.Block, h uint64) *pChainHeightBlock {
return &pChainHeightBlock{inner: inner, pChainHeight: h, bytes: inner.Bytes()}
}
func (b *pChainHeightBlock) ID() ids.ID { return b.inner.ID() }
func (b *pChainHeightBlock) Parent() ids.ID { return b.inner.Parent() }
func (b *pChainHeightBlock) ParentID() ids.ID { return b.inner.ParentID() }
func (b *pChainHeightBlock) Height() uint64 { return b.inner.Height() }
func (b *pChainHeightBlock) Timestamp() time.Time { return b.inner.Timestamp() }
func (b *pChainHeightBlock) Status() uint8 { return b.inner.Status() }
func (b *pChainHeightBlock) Bytes() []byte { return b.bytes }
// PChainHeight is the method the engine reads via pChainHeightOf — the SOLE
// reason this wrapper exists. The inner block does not expose it; this does.
func (b *pChainHeightBlock) PChainHeight() uint64 { return b.pChainHeight }
// Verify/Accept/Reject delegate to the inner VM block: the wrapper changes the
// epoch the engine SEES, never the block's validity or state transition.
func (b *pChainHeightBlock) Verify(ctx context.Context) error { return b.inner.Verify(ctx) }
func (b *pChainHeightBlock) Accept(ctx context.Context) error { return b.inner.Accept(ctx) }
func (b *pChainHeightBlock) Reject(ctx context.Context) error { return b.inner.Reject(ctx) }
// Unwrap exposes the inner block for code that must reach the underlying VM block
// (e.g. a missing-context reparse). Kept narrow on purpose.
func (b *pChainHeightBlock) Unwrap() block.Block { return b.inner }
+26 -13
View File
@@ -65,25 +65,38 @@ func selectConsensusParams(sybilProtection bool, networkID uint32) consensusconf
// --- BLS vote verifier ------------------------------------------------------- // --- BLS vote verifier -------------------------------------------------------
// blsVoteVerifier verifies a validator's BLS signature over the canonical vote // blsVoteVerifier verifies a validator's BLS signature over the canonical vote
// message. The validator's BLS public key is looked up in the chain's validator // message. The validator's BLS public key is resolved FROM THE HEIGHT-INDEXED
// set; an unknown validator, a validator with no BLS key, or a bad signature all // validators.State AT THE BLOCK'S P-CHAIN EPOCH HEIGHT (RESIDUAL-B) — the SAME
// yield false (never an error/panic) — a cert with such a voter is simply // height-pinned source the set-root and the ⅔-by-stake tally read from
// invalid, the fail-closed contract the engine requires. // (validatorSetAtHeight). It is NOT resolved from the CURRENT validator map.
//
// Why the epoch, not the current map: during an async validator-set change a
// validator can be present in the set@H (it legitimately signed the block being
// voted on at height H) yet ALREADY GONE from the current map. Resolving its
// pubkey from the current map drops its valid vote, and if that validator holds
// >⅓ of the stake-at-H the stable set falls below ⅔ and block H NEVER finalizes
// (this is not self-healing for that block — the skew is permanent for H). Pinning
// pubkey resolution to set@H — alongside membership, set-root, and stake — makes
// the cert internally consistent at exactly one epoch.
//
// An unknown validator at the epoch, a validator with no BLS key, or a bad
// signature all yield false (never an error/panic) — a cert with such a voter is
// simply invalid, the fail-closed contract the engine requires. A nil state (the
// no-op on a non-quorum node) yields false for every voter; a K>1 chain is
// guarded against ever reaching here with a nil/no-op state (manager.go).
type blsVoteVerifier struct { type blsVoteVerifier struct {
vdrs validators.Manager state validators.State
networkID ids.ID networkID ids.ID
} }
func newBLSVoteVerifier(vdrs validators.Manager, networkID ids.ID) *blsVoteVerifier { func newBLSVoteVerifier(state validators.State, networkID ids.ID) *blsVoteVerifier {
return &blsVoteVerifier{vdrs: vdrs, networkID: networkID} return &blsVoteVerifier{state: state, networkID: networkID}
} }
// VerifyVote implements consensuschain.VoteVerifier. // VerifyVote implements consensuschain.VoteVerifier. epochHeight is the block's
func (v *blsVoteVerifier) VerifyVote(nodeID ids.NodeID, message []byte, sig []byte) bool { // P-chain height; the voter's pubkey is read from the set IN FORCE AT that height.
if v.vdrs == nil { func (v *blsVoteVerifier) VerifyVote(nodeID ids.NodeID, message []byte, sig []byte, epochHeight uint64) bool {
return false out, ok := validatorSetAtHeight(v.state, v.networkID, epochHeight)[nodeID]
}
out, ok := v.vdrs.GetValidator(v.networkID, nodeID)
if !ok || out == nil || len(out.PublicKey) == 0 { if !ok || out == nil || len(out.PublicKey) == 0 {
return false return false
} }
+109
View File
@@ -0,0 +1,109 @@
// 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)")
}
}
+95
View File
@@ -0,0 +1,95 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// quorum_params_test.go — CRITICAL-2 node-layer regression: the node must NEVER
// wire a non-BFT consensus param set for a multi-validator (sybil-protected)
// chain. The round-1 hole was manager.go selecting LocalParams() (K=3/α=2 → f=0,
// CFT) for ALL multi-node nets — a single Byzantine validator forks K=3/α=2.
// These tests pin selectConsensusParams to a BFT-safe set for every multi-node
// network and prove the value-network backstop (ValidateForValueNetwork) accepts
// the selected params.
package chains
import (
"testing"
consensusconfig "github.com/luxfi/consensus/config"
"github.com/luxfi/constants"
)
// TestSelectConsensusParams_MultiNodeIsBFT proves that for EVERY multi-validator
// (sybilProtection==true) network the node selects a Byzantine-fault-tolerant
// param set (f≥1, i.e. K≥4) that also clears the value-network validator — and
// that it is NEVER LocalParams (K=3) or any K<4 set. This is the node half of
// CRITICAL-2 (the consensus half is config.ValidateForValueNetwork, tested in
// the consensus module).
func TestSelectConsensusParams_MultiNodeIsBFT(t *testing.T) {
local := consensusconfig.LocalParams()
cases := []struct {
name string
networkID uint32
}{
{"mainnet", constants.MainnetID},
{"testnet", constants.TestnetID},
{"localnet-multinode", constants.LocalID},
{"unittest-multinode", constants.UnitTestID},
{"arbitrary-multinode", 424242},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p := selectConsensusParams(true /* sybilProtection */, tc.networkID)
// MUST be Byzantine-fault-tolerant: f≥1 ⟹ K≥4.
if p.ByzantineFaultTolerance() < 1 {
t.Fatalf("multi-node net %q got non-BFT params K=%d f=%d (CRITICAL-2: a single faulty validator forks)",
tc.name, p.K, p.ByzantineFaultTolerance())
}
// MUST NOT be the CFT LocalParams (K=3/α=2) that was the round-1 hole.
if p.K == local.K && p.AlphaPreference == local.AlphaPreference && p.K == 3 {
t.Fatalf("multi-node net %q selected LocalParams (K=3/α=2) — the CRITICAL-2 fork config", tc.name)
}
// The selected params MUST themselves pass Valid() (the BFT α-floor:
// 2·AlphaPreference K ≥ f+1).
if err := p.Valid(); err != nil {
t.Fatalf("multi-node net %q selected params fail Valid(): %v (K=%d α=%d)",
tc.name, err, p.K, p.AlphaPreference)
}
})
}
}
// TestSelectConsensusParams_SingleNodeIsK1 proves --dev / sybil-disabled selects
// the K=1 single-validator regime (the sole validator's accept is the 1-of-1
// quorum) — and NOT a multi-node BFT set.
func TestSelectConsensusParams_SingleNodeIsK1(t *testing.T) {
p := selectConsensusParams(false /* sybilProtection */, constants.LocalID)
if p.K != 1 {
t.Fatalf("sybil-disabled (single-node) must select K=1, got K=%d", p.K)
}
}
// TestSelectConsensusParams_ValueBackstop proves the params selected for a
// multi-node net pass the STRICTER value-network validator for that net — the
// fail-closed backstop asserted at the manager call site before starting the
// engine. (Mainnet enforces K≥11, so MainnetParams K=21 passes; Default K=20
// passes for an arbitrary value net.)
func TestSelectConsensusParams_ValueBackstop(t *testing.T) {
cases := []struct {
name string
networkID uint32
}{
{"mainnet", constants.MainnetID},
{"arbitrary-value-net", 909090},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p := selectConsensusParams(true, tc.networkID)
if err := p.ValidateForValueNetwork(tc.networkID); err != nil {
t.Fatalf("selected params for value net %q must pass the value backstop, got %v (K=%d)",
tc.name, err, p.K)
}
})
}
}
+165
View File
@@ -0,0 +1,165 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// quorum_verifier_height_test.go — node-layer regression for RESIDUAL-B and the
// CRITICAL-1 wiring: the BLS vote verifier resolves the voter's public key from
// the HEIGHT-INDEXED validators.State AT THE BLOCK'S P-CHAIN EPOCH HEIGHT — the
// SAME height-pinned source as the set-root and the ⅔-by-stake tally — NOT from
// the current validator map.
//
// The round-1 fix left the verifier reading the CURRENT map (m.Validators):
// a validator present in set@H (it legitimately signed block H) but already gone
// from the current map during async staking skew had its vote DROPPED, and if it
// held >⅓ of the stake-at-H the block never finalized. These tests prove the
// verifier now reads set@H, so such a vote verifies at H (and a vote keyed to the
// wrong height does not).
package chains
import (
"context"
"testing"
"github.com/luxfi/consensus/engine/chain"
"github.com/luxfi/crypto/bls"
"github.com/luxfi/ids"
validators "github.com/luxfi/validators"
"github.com/luxfi/validators/validatorstest"
)
// blsKey is a test validator's BLS key material.
type blsKey struct {
nodeID ids.NodeID
sk *bls.SecretKey
pkComp []byte
}
func newBLSKey(t *testing.T) blsKey {
t.Helper()
sk, err := bls.NewSecretKey()
if err != nil {
t.Fatalf("NewSecretKey: %v", err)
}
return blsKey{
nodeID: ids.GenerateTestNodeID(),
sk: sk,
pkComp: bls.PublicKeyToCompressedBytes(sk.PublicKey()),
}
}
// stateWithBLSByHeight builds a height-indexed validators.State that reports the
// given BLS validators at each height (empty for unknown heights / wrong net).
func stateWithBLSByHeight(netID ids.ID, byHeight map[uint64][]blsKey) *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 _, k := range byHeight[height] {
out[k.nodeID] = &validators.GetValidatorOutput{
NodeID: k.nodeID,
PublicKey: k.pkComp,
Light: 1,
Weight: 1,
}
}
return out, nil
}
return s
}
// TestBLSVoteVerifier_ResolvesPubkeyAtEpochHeight is the RESIDUAL-B core: a
// validator that is in set@H but has LEFT the set at a later height still has its
// vote verified at H (the verifier reads set@H, not the current map).
func TestBLSVoteVerifier_ResolvesPubkeyAtEpochHeight(t *testing.T) {
netID := ids.GenerateTestID()
keep := newBLSKey(t) // stays in the set across epochs
leaver := newBLSKey(t) // in set@10, GONE from set@11 (departed the current set)
const H = uint64(10)
state := stateWithBLSByHeight(netID, map[uint64][]blsKey{
10: {keep, leaver},
11: {keep}, // leaver has departed by height 11 (the "current" epoch)
})
v := newBLSVoteVerifier(state, netID)
// The leaver signs a message; its vote MUST verify at epoch H=10 (it was a
// member then), proving pubkey resolution is at the epoch, not the current set.
msg := []byte("LUX/chain/vote/v1\x00 — epoch-pinned verify")
sig, err := leaver.sk.Sign(msg)
if err != nil {
t.Fatalf("sign: %v", err)
}
sigBytes := bls.SignatureToBytes(sig)
if !v.VerifyVote(leaver.nodeID, msg, sigBytes, H) {
t.Fatal("RESIDUAL-B: a validator in set@H must verify at H even after leaving the current set " +
"(verifier read the current map instead of set@H)")
}
// At height 11 the leaver is NOT a member → its vote MUST NOT verify there.
// This proves the resolution is genuinely height-pinned (not height-agnostic).
if v.VerifyVote(leaver.nodeID, msg, sigBytes, 11) {
t.Fatal("a validator absent from set@11 must NOT verify at 11 (height pinning is not in effect)")
}
// The keeper verifies at both heights (it is in both sets) — sanity that the
// epoch read is not rejecting valid current members.
keepSig, _ := keep.sk.Sign(msg)
keepBytes := bls.SignatureToBytes(keepSig)
if !v.VerifyVote(keep.nodeID, msg, keepBytes, 10) || !v.VerifyVote(keep.nodeID, msg, keepBytes, 11) {
t.Fatal("a validator present at both epochs must verify at both")
}
}
// TestBLSVoteVerifier_FailClosed proves the verifier never panics and returns
// false for every fail-soft case: nil state, unknown voter at the epoch, wrong
// network, an unknown height (empty set), a wrong-length signature, and the
// HIGH-1 malformed-infinity signature (0x40||zeros) that used to PANIC the purego
// BLS path — here it must be a clean false, not a crash.
func TestBLSVoteVerifier_FailClosed(t *testing.T) {
netID := ids.GenerateTestID()
k := newBLSKey(t)
state := stateWithBLSByHeight(netID, map[uint64][]blsKey{10: {k}})
msg := []byte("msg")
sig, _ := k.sk.Sign(msg)
good := bls.SignatureToBytes(sig)
// nil state → false for any voter.
if newBLSVoteVerifier(nil, netID).VerifyVote(k.nodeID, msg, good, 10) {
t.Fatal("nil state must yield false")
}
v := newBLSVoteVerifier(state, netID)
// unknown voter at a known epoch.
if v.VerifyVote(ids.GenerateTestNodeID(), msg, good, 10) {
t.Fatal("unknown voter at the epoch must yield false")
}
// known voter at an UNKNOWN epoch (empty set) → false.
if v.VerifyVote(k.nodeID, msg, good, 999) {
t.Fatal("known voter at an unknown epoch (empty set) must yield false")
}
// wrong network → empty set → false.
if newBLSVoteVerifier(state, ids.GenerateTestID()).VerifyVote(k.nodeID, msg, good, 10) {
t.Fatal("wrong network must yield false")
}
// wrong-length signature → false (no panic).
if v.VerifyVote(k.nodeID, msg, good[:len(good)-1], 10) {
t.Fatal("wrong-length signature must yield false")
}
// HIGH-1 malformed infinity sig (0x40||zeros) → clean false, NOT a panic.
mal := make([]byte, bls.SignatureLen)
mal[0] = 0x40
if v.VerifyVote(k.nodeID, msg, mal, 10) {
t.Fatal("malformed-infinity signature must yield false")
}
// A WRONG signature (valid form, wrong key) → false.
other := newBLSKey(t)
otherSig, _ := other.sk.Sign(msg)
if v.VerifyVote(k.nodeID, msg, bls.SignatureToBytes(otherSig), 10) {
t.Fatal("a signature by a different key must yield false")
}
}
// ensure the node verifier still satisfies the (now height-aware) engine interface.
var _ chain.VoteVerifier = (*blsVoteVerifier)(nil)