mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
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]>
83 lines
4.2 KiB
Go
83 lines
4.2 KiB
Go
// 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)")
|
||
}
|