Files
consensus/engine/chain/nova_stake_majority_test.go
zeekayandHanzo Dev 134d1b3b46 chain: Nova ignites on a majority of stake, not a majority of heads
Nova is the local-execution gate and stays a bare majority, deliberately below
the Quasar two-thirds export floor. It is now read in stake — the unit the chain
already weighs votes in — with config.HalfStakeFloor as the one definition, the
same shape TwoThirdsStakeFloor gives Quasar one rung up.

For a set of equal weights the two readings pick the identical quorum at every
size, so a uniform fleet is unchanged. They differ when weights do, and a
head-count is wrong in both directions there: registration is open at
minValidatorStake, so an entry that never votes still raises floor(n/2)+1 — a
five-validator fleet plus a minimum-stake sixth needs four of the five and
tolerates one loss where five alone tolerate two — and that same minimum stake
can cast one of the four. A majority of stake is neither purchasable nor
inflatable.

NovaSignerFloor keeps the guard the stake predicate cannot give: a validator
holding a stake majority alone must never self-ignite, so a cert also needs
NovaQuorum(minBFTCommittee) distinct signers, capped by the majority of the live
set so a small chain stays satisfiable. Assembly asks only for that floor and
VerifyWeighted is the authority; the gossip pre-filter reads the same floor so a
legitimate cert is not dropped before the stake check runs.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 22:29:04 -07:00

119 lines
5.4 KiB
Go

// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// nova_stake_majority_test.go — Nova ignition over a set whose weights are not uniform.
//
// The shape these tests pin is a five-validator fleet of equal weight plus a sixth entry
// registered at minValidatorStake that never votes. Read as a head-count, that sixth entry
// moves ⌊n/2⌋+1 from 3 to 4: the fleet now needs four of its five to agree and tolerates
// one loss where it should tolerate two, and the same entry can cast one of those four
// votes. Registration is open at the minimum stake, so a head-count gate is for sale.
//
// Read as a majority of STAKE, neither is possible, and on a uniform fleet the two readings
// pick the identical quorum at every set size — so nothing moves where weights are equal.
package chain
import (
"testing"
"time"
"github.com/luxfi/consensus/config"
"github.com/luxfi/constants"
"github.com/luxfi/ids"
)
const (
fleetWeight = 500_000_000_000_000_000 // 5e17 nLUX, the weight each fleet validator carries
minWeight = 2_000_000_000 // 2e9 nLUX = 2 LUX = minValidatorStake
)
// dyn6 sizes the committee to SIX registered validators, which is what effectiveCommittee
// produces for a six-entry set (bftCommittee clamps an oversized preset K down to the
// resolved count).
func dyn6() config.Parameters { return config.FeasibleParams(constants.LocalID, 6) }
// TestMinimumStakeEntryCannotRaiseNovaIgnition: six registered validators, five of them the
// fleet. THREE of the five sign — a majority of the stake (1.5e18 of 2.500000002e18) and the
// exact quorum the engine's own "survive 3 of 5" mandate promises. Read as a head-count this
// is 3 of 6, short of NovaQuorum(6)=4, and nothing accepts.
func TestMinimumStakeEntryCannotRaiseNovaIgnition(t *testing.T) {
vs := newTestValidatorSet(6)
// Index 5 carries the minimum stake and never votes.
stake := newStakeMap(vs, fleetWeight, fleetWeight, fleetWeight, fleetWeight, fleetWeight, minWeight)
blk := newTestBlock(1, ids.Empty, "minimum-stake-raises-the-bar")
e, _ := driveSignedAcceptsAt(t, dyn6(), vs, stake, &recordingGossiper{}, blk, []int{1, 2, 3})
mustFinalize(t, e, blk, 2*time.Second,
"3 of the 5 fleet validators hold 60% of stake — a minimum-stake entry must not raise the gate")
}
// TestMinimumStakeEntryCannotCastTheDecidingVote is the other half, and it is a safety
// property rather than a liveness one: the minimum-stake entry must not COMPLETE a quorum
// either. Two fleet validators (1e18) plus that entry are three distinct signers — a
// head-count clearing the signer floor — but 1.000000002e18 is not a majority of
// 2.500000002e18, so nothing may accept.
func TestMinimumStakeEntryCannotCastTheDecidingVote(t *testing.T) {
vs := newTestValidatorSet(6)
stake := newStakeMap(vs, fleetWeight, fleetWeight, fleetWeight, fleetWeight, fleetWeight, minWeight)
blk := newTestBlock(1, ids.Empty, "minimum-stake-buys-a-vote")
e, _ := driveSignedAcceptsAt(t, dyn6(), vs, stake, &recordingGossiper{}, blk, []int{1, 2, 5})
mustNotFinalize(t, e, blk, time.Second,
"two fleet validators plus a minimum-stake entry hold 40% of stake — not a majority")
}
// TestNovaSignerFloorHoldsAgainstAStakeMajorityOfOne keeps the 1085013 guard honest under
// the stake reading: one validator holding 96% of the stake is a stake majority by itself,
// and must still never self-ignite. Two signers do not clear NovaSignerFloor either; three
// do, and 96+1+1 is a majority, so the block accepts there.
func TestNovaSignerFloorHoldsAgainstAStakeMajorityOfOne(t *testing.T) {
// Each case gets its own validator set and engine; sharing one across engines in a
// single test crosses signer state between them.
skewed := func(t *testing.T, label string, voters []int) (*Transitive, *verifyOnceBlock) {
t.Helper()
vs := newTestValidatorSet(5)
blk := newTestBlock(1, ids.Empty, label)
e, _ := driveSignedAccepts(t, vs, newStakeMap(vs, 96, 1, 1, 1, 1), &recordingGossiper{}, blk, voters)
return e, blk
}
t.Run("lone heavy holder", func(t *testing.T) {
e, blk := skewed(t, "lone-heavy", []int{0})
mustNotFinalize(t, e, blk, time.Second, "a single validator holding 96% of stake self-igniting")
})
t.Run("two signers", func(t *testing.T) {
e, blk := skewed(t, "heavy-pair", []int{0, 1})
mustNotFinalize(t, e, blk, time.Second, "two signers, below the Nova signer floor")
})
t.Run("three signers", func(t *testing.T) {
e, blk := skewed(t, "heavy-trio", []int{0, 1, 2})
mustFinalize(t, e, blk, 2*time.Second, "three signers holding 98% of stake")
})
}
// TestNovaSignerFloorMatchesCountMajorityOnEqualStake is the no-op proof: for a uniform
// fleet the stake reading and the head-count reading pick the SAME quorum at every size,
// so nothing on any live Lux network moves. k·w > ⌊n·w/2⌋ ⟺ k > n/2.
func TestNovaSignerFloorMatchesCountMajorityOnEqualStake(t *testing.T) {
for n := 1; n <= 32; n++ {
total := uint64(n) * fleetWeight
half := config.HalfStakeFloor(total)
want := NovaQuorum(n)
got := 0
for k := 0; k <= n; k++ {
if uint64(k)*fleetWeight > half {
got = k
break
}
}
if got != want {
t.Fatalf("n=%d: stake majority needs %d signers, head-count majority needs %d", n, got, want)
}
if floor := NovaSignerFloor(n); floor > want {
t.Fatalf("n=%d: signer floor %d exceeds the count majority %d — it must only ever be lower", n, floor, want)
}
}
}