Files
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

83 lines
4.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// red_processpending_stake_bypass_test.go — RED adversarial repro.
//
// CLAIM UNDER ATTACK (Blue): "the cert is the binding finality gate; on a
// stake-weighted chain a coalition holding ≤⅔ stake can never finalize because
// assembleCertLocked.VerifyWeighted refuses." This is TRUE for the cert path
// (tryFinalizeBlock). It is FALSE for the count-only path: processPendingBlocks
// (the pollLoop's finalizer) Accepts a block purely on consensus.IsAccepted(),
// which ChainConsensus sets true on acceptVotes>=alpha — a raw COUNT, NO stake
// check, NO cert. So a low-stake/high-count coalition finalizes via the pollLoop
// even though the cert path correctly refused it.
//
// This mirrors the EXISTING TestWeighted_LowStakeCoalitionRejected, but instead
// of feeding a pre-assembled cert to HandleIncomingCert (the path that DOES check
// stake), it drives SIGNED VOTES through the normal handleVote→ProcessVote path
// (exactly what a real proposer node does when peers vote) and then lets the live
// engine's own pollLoop finalize. No forged anything: every vote is a real signed
// accept from a real validator.
package chain
import (
"context"
"testing"
"time"
"github.com/luxfi/ids"
)
// TestRED_ProcessPendingFinalizesSubTwoThirdsStake is the CRITICAL repro.
//
// Setup: 5 validators, EQUAL vote weight (so α=4 by count) but SKEWED stake
// {96,1,1,1,1} (total 100). The four low-stake validators {1,2,3,4} sign accepts:
// - count = 4 = α → ChainConsensus marks block.accepted=true (count gate)
// - stake = 4/100 → far below floor(2·100/3)=66; VerifyWeighted MUST refuse
//
// The proposer (vdr 0, holding 96 stake) does NOT vote, so no ⅔-stake quorum
// exists. Correct behavior: the block NEVER finalizes (VM.Accept never runs).
//
// Observed behavior: processPendingBlocks (pollLoop) calls VM.Accept on the block
// because IsAccepted()==true — a sub-⅔-stake, cert-less finalization. That is a
// consensus SAFETY violation: any chain with unequal stake can be force-finalized
// by a low-stake majority-of-COUNT coalition.
func TestRED_ProcessPendingFinalizesSubTwoThirdsStake(t *testing.T) {
vs := newTestValidatorSet(5)
// EQUAL vote weight → α=4; SKEWED stake → 4 low-stake voters hold only 4/100.
skew := newStakeMap(vs, 96, 1, 1, 1, 1)
rec := &recordingGossiper{}
// Wire the engine EXACTLY as the node does for a value/PoS chain: stake-weighted.
// K=5/α=4 via FeasibleParams (the shipped sizer).
e, chainID := newQuorumEngineOpts(t, dyn5(), vs, 0, rec, WithStakeWeighting(skew))
blk := newTestBlock(1, ids.Empty, "stake-bypass")
// Track as a VERIFIED, NON-OWN pending block (mirrors trackVerifiedBlock): no
// self-vote is auto-counted, so the ONLY accepts are the four low-stake peers.
// The heavy node (vdr 0, 96 stake) abstains — there is genuinely no ⅔-stake
// quorum. This is the strongest form of the attack.
cb := &Block{id: blk.id, parentID: blk.parentID, height: blk.height, timestamp: blk.timestamp.Unix(), data: blk.bytes}
_ = e.consensus.AddBlock(context.Background(), cb)
e.mu.Lock()
e.pendingBlocks[blk.id] = &PendingBlock{ConsensusBlock: cb, VMBlock: blk, ProposedAt: time.Now(), Round: 0}
e.mu.Unlock()
pos := VotePosition{ChainID: chainID, Height: blk.height, Round: 0, BlockID: blk.id, ParentID: blk.parentID}
// Four low-stake validators {1,2,3,4} sign real accepts. count=4=α, stake=4/100.
e.ReceiveVote(vs.signedVote(1, pos))
e.ReceiveVote(vs.signedVote(2, pos))
e.ReceiveVote(vs.signedVote(3, pos))
e.ReceiveVote(vs.signedVote(4, pos))
// The RED claim stands: a 4-of-5 head-count holding 4/100 of the stake finalizes nothing.
// Nova is a majority of STAKE (crash-fault-safe by majority intersection, and unbuyable),
// Quasar a ⅔ supermajority of it. The pollLoop reaches TryAccept on the count predicate and
// assembleCertLocked refuses, so VM.Accept never runs.
mustNotFinalize(t, e, blk, 2*time.Second, "4/100-stake coalition (Nova local accept)")
// SAFETY REQUIREMENT: the same coalition must NEVER reach Quasar — no bridge /
// DEX-settlement / cross-chain consumer can treat it as final.
mustNotQuasar(t, e, blk, 500*time.Millisecond, "4/100-stake coalition (export gate)")
}