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