mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
A validator set may carry a member with no signing key. It holds stake and no verifier will ever accept a signature from it — it is a spectator, not a party to the vote. Both quorum floors were nonetheless read over the whole membership: StakeSource.TotalStake summed its weight and ValidatorCount counted its seat. That is a category error with an arithmetic consequence. Once keyless stake reaches a third of what the chain carries, floor(2/3 * carried) is above the whole of the signable stake, so the export rung cannot be reached even with every signer in the set agreeing — permanently, with no misbehaviour anywhere. Past a half, Nova goes the same way. The count floor strands the same way in seats: two thirds of the roll can exceed the number of signatures the set is able to produce. So the seam says what it means. TotalStake becomes SignerStake and ValidatorCount becomes SignerCount, both read over the validators that can actually sign, and Weight answers 0 for a keyless member as it already did for a stranger. The rename is deliberate rather than a doc change: an implementer of the interface has to confront the question, instead of silently keeping a denominator that strands its own chain. This is the two-thirds rule read over the right set, not a weakening of it. Quorum intersection is untouched — two disjoint quorums each past 2/3 of the signer stake would sum past the whole of it — and the fault budget simply stops being spent on parties known in advance to cast no vote. A key absent at the epoch is a known non-participant; BFT's third is a budget for the unknown ones. Rust follows: ValidatorSet is both the verifier and the source, and its insert_unkeyed door made this reachable through a public API. It now keeps `signable` beside `total` — total still bounds admission, because a set whose weights cannot be represented is refused whether or not the seat that overflows it can sign, and signable is what a quorum is measured against. carried() names the first for what it is. C++ needed no change and is the reason to believe the shape: a Validator there IS a public key and the engine's set is keyed by one, so a spectator has never had a slot to occupy and total_stake_ was always the signer stake. Corpus: quasar_keyless_third, the case the R5 TODO could not state. Three seats of a hundred hold keys, a fourth holds two hundred and none; all three signers sign and the export rung admits it on 300 of 300, where the roll denominator would refuse it against floor(2*500/3)=333 forever. Every existing verdict is byte-identical — the seat gains an omitempty keyless flag, so a set of ordinary seats encodes exactly as before. Co-authored-by: Hanzo Dev <[email protected]>
107 lines
5.0 KiB
Go
107 lines
5.0 KiB
Go
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||
// See the file LICENSE for licensing terms.
|
||
|
||
// multinode_mainnet_committee_test.go — the 2026-07-09 mainnet C-Chain finality-freeze
|
||
// regression: view-change must size its BFT committee from the LIVE validator set, not the
|
||
// oversized K-sample preset.
|
||
//
|
||
// EXACT mainnet condition reproduced: MainnetParams presets the sample at K=21/α=15
|
||
// (sized for a ≥21-validator network), but only 5 validators are live. The harness leaves
|
||
// cfg.Validators nil, so the construction-time bftCommittee clamp is skipped and consensus.K()
|
||
// stays 21 / Alpha() stays 15 — identical to the mainnet node whose current-map validator Manager
|
||
// read 0 at construction (the same empty set that gives the proposervm windower pChainHeight=0 /
|
||
// anyone-can-propose). The round-scoped view-change POL and precommit count DISTINCT validators, so
|
||
// α=15 affirmative prevotes are UNREACHABLE from 5 nodes: no POL ever forms → finality FREEZES
|
||
// (all agree on the tip, no fork — the live incident signature).
|
||
//
|
||
// The fix (integration.go viewCommittee): size the view-change committee from the SAME populated
|
||
// ⅔-by-stake StakeSource the cert reads — n = SignerCount (5), α = bftAlpha(5) = 4 — so a POL
|
||
// forms at 4 prevotes → precommit → one ⅔-by-stake cert → finalize. The sample K=21 is left untouched
|
||
// (the chain still boots past the mainnet K≥11 floor); only the round machine's committee shrinks.
|
||
package chain
|
||
|
||
import (
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/luxfi/consensus/config"
|
||
"github.com/luxfi/ids"
|
||
)
|
||
|
||
// mainnetStormParams5VC is the storm-tuned 5-node harness base with the REAL MainnetParams sample
|
||
// committee (K=21/α=15) and the round-scoped view-change ON — the exact live-mainnet config on a
|
||
// 5-validator set.
|
||
func mainnetStormParams5VC() config.Parameters {
|
||
p := stormParams5()
|
||
p.K = 21 // MainnetParams sample size (sized for ≥21 validators)
|
||
p.AlphaPreference = 15 // MainnetParams α — 15 DISTINCT prevotes, unreachable from 5 nodes
|
||
p.AlphaConfidence = 15
|
||
return p
|
||
}
|
||
|
||
func TestViewChange_MainnetParams_5Validators_CommitteeFromStakeSource(t *testing.T) {
|
||
// A symmetric sibling storm at height 1: ALL five validators each build a distinct competing
|
||
// block on the same parent (the anyone-can-propose condition). The deterministic lowest-canonical
|
||
// winner is the view-change target every honest node prevotes; whether the height finalizes then
|
||
// turns ENTIRELY on the POL threshold α — which is the committee-sizing bug under test.
|
||
buildStorm := func(net *simNet) map[ids.ID]*simBlock {
|
||
blocks := make(map[ids.ID]*simBlock, 5)
|
||
for i := 0; i < 5; i++ {
|
||
blk := newHonestBlock(ids.Empty, simGenesisRoot(), 1, "mainnet-sib-"+itoa(i))
|
||
blocks[blk.ID()] = blk
|
||
net.build(i, blk)
|
||
}
|
||
return blocks
|
||
}
|
||
|
||
// CONTROL — the mainnet freeze reproduced. SignerCount unresolved (0), as on the node whose
|
||
// current-map Manager read 0 at construction: viewCommittee cannot shrink the sample preset, so
|
||
// the round machine runs at α=15. Five validators can cast at most five distinct prevotes, so no
|
||
// POL(winner) can ever form → NOTHING finalizes. Safety holds (no fork) — this is a pure liveness
|
||
// freeze, exactly the live incident.
|
||
t.Run("frozen_when_validator_count_unresolved", func(t *testing.T) {
|
||
net := newSimNet(t, 5, mainnetStormParams5VC())
|
||
net.vs.setSimulatedValidatorCount(0)
|
||
buildStorm(net)
|
||
|
||
finalizedSomething := waitFor(3*time.Second, func() bool {
|
||
return len(net.headsAtHeight(1)) > 0
|
||
})
|
||
if finalizedSomething {
|
||
t.Fatalf("CONTROL: with the view-change committee stuck at α=15 and only 5 validators, "+
|
||
"no POL can form — height 1 MUST freeze, got finalized heads=%v", net.headsAtHeight(1))
|
||
}
|
||
})
|
||
|
||
// FIX — committee sized to the live validator set. Default SignerCount == len(ids) == 5, so
|
||
// viewCommittee shrinks 21→5 and α = bftAlpha(5) = 4. The storm's lowest-canonical winner now
|
||
// gathers ≥4 prevotes → POL → precommit → one ⅔-by-stake cert → finalize. Must converge ALL five
|
||
// live nodes on a SINGLE head (no double-finalization).
|
||
t.Run("finalizes_when_committee_sized_to_live_set", func(t *testing.T) {
|
||
net := newSimNet(t, 5, mainnetStormParams5VC())
|
||
blocks := buildStorm(net)
|
||
|
||
deadline := time.Now().Add(stormTO)
|
||
for time.Now().Before(deadline) {
|
||
heads := net.headsAtHeight(1)
|
||
if len(heads) > 1 {
|
||
t.Fatalf("FIX: DOUBLE-FINALIZATION at height 1 (safety break): %v", heads)
|
||
}
|
||
if len(heads) == 1 {
|
||
for id, c := range heads {
|
||
if c >= net.upCount() {
|
||
if _, ok := blocks[id]; !ok {
|
||
t.Fatalf("FIX: finalized head %s is not one of the built siblings", id)
|
||
}
|
||
return // converged: one head, finalized by every live node
|
||
}
|
||
}
|
||
}
|
||
time.Sleep(15 * time.Millisecond)
|
||
}
|
||
t.Fatalf("FIX: view-change committee sized from the StakeSource (n=5, α=4) must converge the "+
|
||
"sibling storm to one finalized head across all 5 nodes (heads=%v, up=%d)",
|
||
net.headsAtHeight(1), net.upCount())
|
||
})
|
||
}
|