Files
consensus/engine/chain/epoch_finality_test.go
zeekayandHanzo Dev ac10e53f84 cert: give the export rung a floor on the SET, so a shrinking denominator cannot shrink past a committee
Reading both floors over the signers let a set shrink to its signers, and a rung
whose floors are read over n is satisfiable at every n. TwoThirdsCount(1) is 1, so
one signature over a one-signer set clears the count floor and the stake floor
together and mints export finality. Byzantine tolerance is f = ⌊(n−1)/3⌋, which is
ZERO for n of one, two and three: such a certificate absorbs no fault at all, and
one compromised key among its signers forges it outright.

So the export rung refuses a signing set below minBFTCommittee — the same constant
bftCommittee floors committee SELECTION at, read at the certificate instead of at
the sampler, and reused rather than restated. It is a floor on the SET, stated
beside the n<1 refusal because neither quorum floor implies it: both are read over
n and both shrink with it. Nova has no such clause and must not grow one — it
authorizes local execution the chain can still reorg away, so a small or
partitioned chain has to be able to make progress. A three-validator chain now
PRODUCES and never CERTIFIES; operators need n ≥ 4 to export with any fault
tolerance. Rust mirrors it as CertError::MinCommittee, which the corpus vocabulary
folds into Go's one belowThreshold class.

The transcription guard was reading the wrong denominator. verdict_test computed
n as len(c.Set), the membership ROLL, so a floor moved onto the roll would agree
with the guard and be re-blessed by -update — the second line of defence agreeing
with the regression it exists to catch. It now counts the seats that hold a key,
on the Quasar branch and the Nova one, and transcribes the committee floor as a
search for the least n whose fault budget reaches one rather than as the constant.
Proven by construction: with the corpus source reporting the roll as its signer
count and the golden re-blessed, the old guard passes and the new one fails.

The corpus could not have caught it either. quasar_keyless_third had three signers
on a roll of four, and ⌊2·3/3⌋+1 and ⌊2·4/3⌋+1 are both three, so its count half
was vacuous. Two rows now separate the denominators one at a time:
quasar_keyless_stake (six signers, a seventh member holding a third of the roll —
the count floor is five either way, so only stake decides) and
quasar_keyless_count (four signers, two members holding one unit each — the stake
clears under either reading, so only the count decides). TestKeylessRowsBind-
BothDenominators fails if either is lost or flattened.

quasar_keyless_third itself FLIPS to a refusal, and it is the row that states the
new clause: the recorded stake floor is 200 over the three hundred that can sign
and not 333 over the five hundred the chain carries, so the denominator IS the
signers — and the certificate is refused anyway, on neither quorum floor. Reading
the floors over the signers stops a spectator stranding a chain; it is not a way
for a chain with three signers to export. Every other frozen verdict is
byte-identical, and the admission section and the signed vectors are untouched.

StakeSource gains CarriedStake, and no floor may read it. SignerStake alone
decides a certificate correctly and cannot show the set shrinking underneath one:
a chain whose signable stake has fallen to a sliver of what it carries still
certifies at ⅔ of the sliver, and ResponsiveStakePct — voted over signer — stays
at one the whole way down. FinalityStatus.SignerStakePct is the number that falls,
and the one to alarm on. Rust has carried(), C++ has total_stake(); Go had lost
the projection when the denominator moved.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 20:52:28 -07:00

491 lines
20 KiB
Go

// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// quorum_pchain_epoch_finality_test.go — the LOAD-BEARING integration test the
// MEDIUM-1 round lacked (CRITICAL-1). The earlier set-root/stake tests injected a
// validators.State directly into the source constructors and asserted the source
// math; they NEVER drove the full engine vote→assemble→verify→finalize path, and
// they NEVER distinguished the value-chain height from the P-chain epoch height.
// That is exactly the "test bypasses the wiring" trap: the sources were correct,
// but nothing proved the engine reads the RIGHT height, nor that a block actually
// finalizes.
//
// This test wires the engine the way production does — a height-indexed
// validator-set source (set-root + stake) and a height-pinned vote verifier, all
// keyed off the block's P-CHAIN epoch height — and proves four properties end to
// end:
//
// (A) A multi-validator block FINALIZES through the real source path: votes are
// verified against the set@epoch, the cert assembles, VerifyWeighted clears
// the ⅔-by-stake supermajority, and VM.Accept runs once.
// (B) CRITICAL-1(b): the engine reads the validator set at the block's P-CHAIN
// height, NOT its value-chain height. The block's value height is enormous
// (10_000_000) while its P-chain epoch is small (7); the set is known ONLY at
// epoch 7. If the engine (wrongly) read the value height, the set would be
// empty → SignerStake 0 → ErrQCStakeBelowSupermajority → STALL. Finalizing
// proves the epoch height is used.
// (C) RESIDUAL-B: a validator that is in the set@epoch but ABSENT from the
// "current" map still has its vote verified (pubkey resolved at the epoch),
// so a >⅓-stake validator departing the current map during async skew does
// not stall the block it legitimately signed.
// (D) Fail-closed: with the SAME wiring but an EMPTY set at the block's epoch,
// the block does NOT finalize (no set ⟹ no cert ⟹ no Accept). This is the
// behavior the node-layer guard turns into a loud refuse-to-start; here we
// pin that the engine itself never finalizes off an empty epoch.
package chain
import (
"context"
"crypto/ed25519"
"sync"
"testing"
"time"
"github.com/luxfi/ids"
)
// --- a block that carries a P-chain height (a proposervm SignedBlock would) ---
// pChainBlock is a verifyOnceBlock-equivalent that ALSO exposes PChainHeight, so
// the engine's pChainHeightOf boundary assertion records the epoch height onto
// the consensus Block. Its value-chain Height and its PChainHeight are
// DELIBERATELY different so the test can tell which one the engine reads.
type pChainBlock struct {
id ids.ID
parentID ids.ID
height uint64 // value-chain height (huge)
pChainHeight uint64 // P-chain epoch height (small)
timestamp time.Time
bytes []byte
acceptCalled int64
mu sync.Mutex
}
func (b *pChainBlock) ID() ids.ID { return b.id }
func (b *pChainBlock) Parent() ids.ID { return b.parentID }
func (b *pChainBlock) ParentID() ids.ID { return b.parentID }
func (b *pChainBlock) Height() uint64 { return b.height }
func (b *pChainBlock) PChainHeight() uint64 { return b.pChainHeight } // the boundary hook
func (b *pChainBlock) Timestamp() time.Time { return b.timestamp }
func (b *pChainBlock) Status() uint8 { return 0 }
func (b *pChainBlock) Verify(context.Context) error { return nil }
func (b *pChainBlock) Accept(context.Context) error {
b.mu.Lock()
b.acceptCalled++
b.mu.Unlock()
return nil
}
func (b *pChainBlock) Reject(context.Context) error { return nil }
func (b *pChainBlock) Bytes() []byte { return b.bytes }
func (b *pChainBlock) AcceptCalled() int64 {
b.mu.Lock()
defer b.mu.Unlock()
return b.acceptCalled
}
// --- a height-indexed validator-set source (set-root + stake + verifier) ------
// epochValidatorSet models the production wiring: a single height-indexed source
// of epoch truth. Membership, BLS-equivalent pubkeys, stake weights, AND the
// set-root are ALL read at a given epoch height. The set is registered per epoch
// height; an unknown epoch yields the empty set (the fail-soft the engine folds
// into Empty root / zero stake). A SEPARATE "current" map models the live
// validator manager — which, during a staking change, can DISAGREE with a past
// epoch (RESIDUAL-B).
type epochValidatorSet struct {
byEpoch map[uint64]map[ids.NodeID]ed25519.PublicKey
stake map[uint64]map[ids.NodeID]uint64
// current is the live map; a node id present at epoch H but absent here models
// a validator that left the current set after H (async skew).
current map[ids.NodeID]struct{}
}
// at returns the (pubkeys, stake) registered for epoch height H.
func (s *epochValidatorSet) at(h uint64) (map[ids.NodeID]ed25519.PublicKey, map[ids.NodeID]uint64) {
return s.byEpoch[h], s.stake[h]
}
// VerifyVote resolves the voter's pubkey FROM THE SET@epochHeight (RESIDUAL-B) —
// NOT from the current map. A voter unknown at the epoch is rejected; a voter
// known at the epoch verifies even if it has since left the current map.
func (s *epochValidatorSet) VerifyVote(nodeID ids.NodeID, message, sig []byte, epochHeight uint64) bool {
pubs, _ := s.at(epochHeight)
pub, ok := pubs[nodeID]
if !ok {
return false
}
return ed25519.Verify(pub, message, sig)
}
func (s *epochValidatorSet) Weight(nodeID ids.NodeID, epochHeight uint64) uint64 {
_, st := s.at(epochHeight)
return st[nodeID]
}
func (s *epochValidatorSet) SignerStake(epochHeight uint64) uint64 {
_, st := s.at(epochHeight)
var total uint64
for _, w := range st {
total += w
}
return total
}
func (s *epochValidatorSet) SignerCount(epochHeight uint64) int {
_, st := s.at(epochHeight)
return len(st)
}
// Every member at an epoch holds a key here, so carried and signer stake coincide.
func (s *epochValidatorSet) CarriedStake(epochHeight uint64) uint64 {
return s.SignerStake(epochHeight)
}
// ValidatorSetRoot is a deterministic commitment to the set@epoch — distinct per
// epoch, Empty for an unknown/empty epoch. (A simple length+nodeid+stake fold;
// the node's production source uses SHA-256, but any deterministic function of
// the set works for the engine-level epoch-binding properties under test.)
func (s *epochValidatorSet) ValidatorSetRoot(epochHeight uint64) ids.ID {
pubs, st := s.at(epochHeight)
if len(pubs) == 0 {
return ids.Empty
}
var root ids.ID
root[0] = byte(len(pubs))
root[1] = byte(epochHeight)
for id := range pubs {
for i := 0; i < ids.NodeIDLen && i < len(root)-2; i++ {
root[2+i] ^= id[i]
}
root[31] ^= byte(st[id])
}
return root
}
// epochSigners holds the per-node ed25519 keys so the test can sign votes.
type epochSigners struct {
keys map[ids.NodeID]ed25519.PrivateKey
ids []ids.NodeID
}
func newEpochSigners(n int) *epochSigners {
es := &epochSigners{keys: make(map[ids.NodeID]ed25519.PrivateKey, n)}
for i := 0; i < n; i++ {
id := ids.GenerateTestNodeID()
pub, priv, err := ed25519.GenerateKey(nil)
_ = pub
if err != nil {
panic(err)
}
es.keys[id] = priv
es.ids = append(es.ids, id)
}
return es
}
func (es *epochSigners) pub(i int) ed25519.PublicKey {
return es.keys[es.ids[i]].Public().(ed25519.PublicKey)
}
func (es *epochSigners) signerFor(i int) VoteSigner {
priv := es.keys[es.ids[i]]
return voteSignerFunc(func(message []byte) ([]byte, error) { return ed25519.Sign(priv, message), nil })
}
// trackPChainProposal inserts a verified own-proposal pending block carrying the
// block's P-CHAIN epoch height (as production buildBlocksLocked does via
// pChainHeightOf), so votes drive the real finality path. Returns the position
// votes must bind to (its set-root is stamped at the epoch height).
func trackPChainProposal(e *Transitive, chainID ids.ID, blk *pChainBlock, round uint32) VotePosition {
cb := &Block{
id: blk.id,
parentID: blk.parentID,
height: blk.height,
timestamp: blk.timestamp.Unix(),
data: blk.bytes,
pChainHeight: pChainHeightOf(blk), // the boundary capture under test
}
_ = e.consensus.AddBlock(context.Background(), cb)
_ = e.consensus.ProcessVote(context.Background(), blk.id, ids.GenerateTestNodeID(), true)
e.mu.Lock()
pb := &PendingBlock{
ConsensusBlock: cb,
VMBlock: blk,
ProposedAt: time.Now(),
VoteCount: 1,
Round: round,
Decided: false,
IsOwnProposal: true,
}
e.pendingBlocks[blk.id] = pb
e.recordOwnVoteLocked(pb, blk.id)
e.mu.Unlock()
return e.blockPositionLockedForTest(pb, blk.id)
}
// blockPositionLockedForTest exposes the engine's position builder to the test
// (it is the value votes bind to, with the set-root stamped at the epoch height).
func (t *Transitive) blockPositionLockedForTest(pending *PendingBlock, blockID ids.ID) VotePosition {
t.mu.RLock()
defer t.mu.RUnlock()
return t.blockPositionLocked(pending, blockID)
}
// signEpochVote produces validator i's signed accept Vote for a position.
func signEpochVote(es *epochSigners, i int, pos VotePosition) Vote {
priv := es.keys[es.ids[i]]
return Vote{
BlockID: pos.BlockID,
NodeID: es.ids[i],
Accept: true,
SignedAt: time.Now(),
Signature: ed25519.Sign(priv, CanonicalVoteMessage(pos)),
ParentID: pos.ParentID,
Round: pos.Round,
}
}
// TestPChainEpochFinality_RealWiring is the CRITICAL-1 load-bearing test.
func TestPChainEpochFinality_RealWiring(t *testing.T) {
const (
valueHeight = uint64(10_000_000) // value-chain height races far ahead
epochHeight = uint64(7) // the P-chain height the set is known at
)
es := newEpochSigners(5)
// The set is known ONLY at the P-chain epoch (7). Node 4 is in the epoch set
// (a >⅓-stake validator) but has DEPARTED the current map (RESIDUAL-B). All 5
// carry equal stake (20 each, total 100) so a 4-voter cert is 80/100 > ⅔.
src := &epochValidatorSet{
byEpoch: map[uint64]map[ids.NodeID]ed25519.PublicKey{
epochHeight: {
es.ids[0]: es.pub(0), es.ids[1]: es.pub(1), es.ids[2]: es.pub(2),
es.ids[3]: es.pub(3), es.ids[4]: es.pub(4),
},
},
stake: map[uint64]map[ids.NodeID]uint64{
epochHeight: {es.ids[0]: 20, es.ids[1]: 20, es.ids[2]: 20, es.ids[3]: 20, es.ids[4]: 20},
},
current: map[ids.NodeID]struct{}{ // node 4 NOT present → departed current map
es.ids[0]: {}, es.ids[1]: {}, es.ids[2]: {}, es.ids[3]: {},
},
}
chainID := ids.GenerateTestID()
rec := &recordingGossiper{}
e := NewWithConfig(Config{Params: params5()},
WithQuorumCert(chainID, es.ids[0], src, rec, es.signerFor(0)),
WithStakeWeighting(src), // ⅔-by-stake, read at the epoch height
WithValidatorSetRoot(src), // set-root stamped at the epoch height
)
if err := e.Start(context.Background(), true); err != nil {
t.Fatalf("Start: %v", err)
}
t.Cleanup(func() { _ = e.Stop(context.Background()) })
blk := &pChainBlock{
id: ids.GenerateTestID(),
parentID: ids.Empty,
height: valueHeight, // huge value height
pChainHeight: epochHeight, // small epoch height — the set is known HERE
timestamp: time.Now(),
bytes: []byte("pchain-epoch-finality"),
}
pos := trackPChainProposal(e, chainID, blk, 0)
// (B) sanity: the position the engine built stamped the set-root at the EPOCH
// height, not the value height. A root computed at the value height would be
// Empty (no set there); the epoch root is non-Empty.
if pos.ValidatorSetRoot == ids.Empty {
t.Fatal("CRITICAL-1(b): position set-root is Empty — engine read the (empty) value height, not the epoch height")
}
if pos.ValidatorSetRoot != src.ValidatorSetRoot(epochHeight) {
t.Fatal("CRITICAL-1(b): position set-root does not match the set@epoch root")
}
if pos.ValidatorSetRoot == src.ValidatorSetRoot(valueHeight) {
t.Fatal("test vacuous: epoch root must differ from the (Empty) value-height root")
}
// (A)+(C): drive 3 peer signed accepts (proposer 0 already self-voted) — INCLUDING
// node 4, which is in set@epoch but NOT in the current map. With proposer(0)+{1,2,4}
// that is 4 distinct accepts = 80/100 stake > ⅔ → MUST finalize.
e.ReceiveVote(signEpochVote(es, 1, pos))
e.ReceiveVote(signEpochVote(es, 2, pos))
e.ReceiveVote(signEpochVote(es, 4, pos)) // departed-from-current voter
// Wait on the TRUE finalization signal — VM.Accept — not merely the count
// flip (IsAccepted). ReceiveVote is async (channel-fed), and the cert-driven
// VMBlock.Accept lags the count quorum, so asserting AcceptCalled immediately
// after an IsAccepted poll races. AcceptCalled==1 subsumes finality.
if !waitFor(2*time.Second, func() bool { return blk.AcceptCalled() == 1 }) {
t.Fatalf("CRITICAL-1: a multi-validator block with a ⅔-stake epoch quorum did NOT finalize "+
"(VM.Accept=%d; the real-wiring finality the MEDIUM-1 round never tested)", blk.AcceptCalled())
}
if !e.IsAccepted(blk.id) {
t.Fatal("block VM.Accepted but consensus does not report it accepted")
}
// The EXPORT (Quasar, ⅔-by-stake) cert — the witness a bridge / settlement consumes — must
// form. The ⅔-th stake vote (node 4) necessarily TRAILS the bare-majority Nova accept, so it
// completes the export cert via the trailing-attestation path. (The gossiped Nova accept cert
// is the bare majority; it is NOT the export witness and need not carry node 4.)
if !waitFor(2*time.Second, func() bool { qh, ok := e.QuasarHeight(); return ok && qh >= blk.height }) {
t.Fatal("a ⅔-stake EXPORT (Quasar) cert must form — the epoch-resolved ⅔ quorum did not export")
}
cert, ok := e.QuasarCertAt(blk.height)
if !ok {
t.Fatal("QuasarCertAt must return the export cert at the accepted height")
}
if cert.Tier != Quasar {
t.Fatalf("export cert must be the Quasar tier, got %s", cert.Tier)
}
// (C) explicit: node 4's vote IS in the export cert, proving the departed-from-current
// validator's legit vote was verified at the epoch (RESIDUAL-B).
var has4 bool
for i := range cert.Votes {
if cert.Votes[i].NodeID == es.ids[4] {
has4 = true
}
// none of the cert's voters may be the departed node IF we resolved from the
// current map — but they are not; this asserts the positive case.
if _, inCurrent := src.current[cert.Votes[i].NodeID]; !inCurrent && cert.Votes[i].NodeID != es.ids[4] {
t.Fatalf("cert carries a voter %s that is neither in the current map nor the expected epoch-only node", cert.Votes[i].NodeID)
}
}
if !has4 {
t.Fatal("RESIDUAL-B: the epoch-only voter (departed from the current map) must be in the EXPORT cert " +
"— its vote was wrongly dropped (verifier read the current map, not set@epoch)")
}
// The export cert verifies under the stake-weighted predicate at the EPOCH height.
if err := cert.VerifyWeighted(src, src, epochHeight); err != nil {
t.Fatalf("export cert must verify stake-weighted at the epoch height: %v", err)
}
// And it must FAIL at the value height (where the set is empty / total stake 0)
// — proving the height genuinely matters and the epoch read is load-bearing.
if err := cert.VerifyWeighted(src, src, valueHeight); err == nil {
t.Fatal("CRITICAL-1(b): cert must NOT verify at the value height (empty set there) — " +
"if it does, the height is not actually being used")
}
}
// TestPChainEpochFinality_GenesisEpochFallbackIsLive pins the CURRENT in-process
// behavior (the remaining (b2) reality): when the VM block does NOT carry a
// PChainHeight (no proposervm at the engine boundary), pChainHeightOf yields 0,
// so the engine reads the validator set at P-chain height 0 — the GENESIS set.
// This MUST still finalize (it is non-empty, ≤ current P-chain height, and
// identical on every node), proving the fix UNBRICKS finality even before the
// real PChainHeight is threaded. Contrast with the pre-fix bug, where the
// value-chain height was used → errUnfinalizedHeight → empty → permanent stall.
func TestPChainEpochFinality_GenesisEpochFallbackIsLive(t *testing.T) {
es := newEpochSigners(5)
const genesisEpoch = uint64(0) // the set is known at the genesis P-chain height
src := &epochValidatorSet{
byEpoch: map[uint64]map[ids.NodeID]ed25519.PublicKey{
genesisEpoch: {
es.ids[0]: es.pub(0), es.ids[1]: es.pub(1), es.ids[2]: es.pub(2),
es.ids[3]: es.pub(3), es.ids[4]: es.pub(4),
},
},
stake: map[uint64]map[ids.NodeID]uint64{
genesisEpoch: {es.ids[0]: 20, es.ids[1]: 20, es.ids[2]: 20, es.ids[3]: 20, es.ids[4]: 20},
},
}
chainID := ids.GenerateTestID()
rec := &recordingGossiper{}
e := NewWithConfig(Config{Params: params5()},
WithQuorumCert(chainID, es.ids[0], src, rec, es.signerFor(0)),
WithStakeWeighting(src),
WithValidatorSetRoot(src))
if err := e.Start(context.Background(), true); err != nil {
t.Fatalf("Start: %v", err)
}
t.Cleanup(func() { _ = e.Stop(context.Background()) })
// A block with a value height > 0 but NO PChainHeight (pChainHeight stays 0):
// this is exactly the in-process VM case. pChainHeightOf(blk)==0 → set@0.
blk := &pChainBlock{
id: ids.GenerateTestID(),
parentID: ids.Empty,
height: 42, // value height advanced past genesis
pChainHeight: 0, // NO proposervm height delivered (the current reality)
timestamp: time.Now(),
bytes: []byte("genesis-epoch-fallback"),
}
pos := trackPChainProposal(e, chainID, blk, 0)
if pos.ValidatorSetRoot == ids.Empty {
t.Fatal("genesis-epoch set-root must be non-Empty (the genesis set is non-empty)")
}
e.ReceiveVote(signEpochVote(es, 1, pos))
e.ReceiveVote(signEpochVote(es, 2, pos))
e.ReceiveVote(signEpochVote(es, 3, pos))
if !waitFor(2*time.Second, func() bool { return blk.AcceptCalled() == 1 }) {
t.Fatalf("UNBRICK: a block with no PChainHeight must still finalize against the genesis set "+
"(VM.Accept=%d) — the fix must not stall finality in the in-process VM case", blk.AcceptCalled())
}
}
// TestPChainEpochFinality_EmptyEpochFailsClosed is property (D): the SAME real
// wiring, but the block's epoch has NO registered set, must NOT finalize. This is
// the engine-level half of the fail-closed guard (the node layer turns this into
// a loud refuse-to-start; the engine must never finalize off an empty epoch).
func TestPChainEpochFinality_EmptyEpochFailsClosed(t *testing.T) {
es := newEpochSigners(5)
const knownEpoch = uint64(7)
const blockEpoch = uint64(9) // the block points at an epoch with NO set
src := &epochValidatorSet{
byEpoch: map[uint64]map[ids.NodeID]ed25519.PublicKey{
knownEpoch: { // set known at 7, but the block's epoch is 9 → empty
es.ids[0]: es.pub(0), es.ids[1]: es.pub(1), es.ids[2]: es.pub(2),
es.ids[3]: es.pub(3), es.ids[4]: es.pub(4),
},
},
stake: map[uint64]map[ids.NodeID]uint64{
knownEpoch: {es.ids[0]: 20, es.ids[1]: 20, es.ids[2]: 20, es.ids[3]: 20, es.ids[4]: 20},
},
}
chainID := ids.GenerateTestID()
rec := &recordingGossiper{}
e := NewWithConfig(Config{Params: params5()},
WithQuorumCert(chainID, es.ids[0], src, rec, es.signerFor(0)),
WithStakeWeighting(src),
WithValidatorSetRoot(src))
if err := e.Start(context.Background(), true); err != nil {
t.Fatalf("Start: %v", err)
}
t.Cleanup(func() { _ = e.Stop(context.Background()) })
blk := &pChainBlock{
id: ids.GenerateTestID(),
parentID: ids.Empty,
height: 5,
pChainHeight: blockEpoch, // empty epoch
timestamp: time.Now(),
bytes: []byte("empty-epoch"),
}
pos := trackPChainProposal(e, chainID, blk, 0)
// The set-root at an empty epoch is Empty (the explicit "unbound" answer).
if pos.ValidatorSetRoot != ids.Empty {
t.Fatalf("an empty epoch must commit to Empty set-root, got %s", pos.ValidatorSetRoot)
}
// Even if peers "sign" (their pubkeys are NOT in the block's epoch, so the
// verifier rejects), the block MUST NOT finalize.
e.ReceiveVote(signEpochVote(es, 1, pos))
e.ReceiveVote(signEpochVote(es, 2, pos))
e.ReceiveVote(signEpochVote(es, 3, pos))
e.ReceiveVote(signEpochVote(es, 4, pos))
if waitFor(500*time.Millisecond, func() bool { return e.IsAccepted(blk.id) }) {
t.Fatal("CRITICAL-1(D): a block whose epoch has NO validator set must NOT finalize (fail-closed)")
}
if blk.AcceptCalled() != 0 {
t.Fatalf("VM.Accept must NOT run for an empty-epoch block, got %d", blk.AcceptCalled())
}
}