merge: Go tally checked-add + Flatten zero-weight refusal (hardening)

votedStake() routes both weighted-tally loops through math.Add64 -> ErrWeightOverflow
(parity with Rust's checked voted_stake and C++'s construction guard; closes a
false-ACCEPT on a wrapping StakeSource). FlattenValidatorSet now hard-refuses a
keyed zero-weight seat (ErrZeroWeight), matching Register and Rust's insert;
keyless-zero stays tolerated. Corpus freezes the Flatten-door zero-weight case.
Red GO. No production caller of the changed Flatten function today.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-09-01 16:57:10 -07:00
co-authored by Hanzo Dev
8 changed files with 414 additions and 8 deletions
+11
View File
@@ -1206,6 +1206,17 @@
"admitted": false,
"refusal": "zeroWeight"
},
{
"name": "zero_weight",
"note": "the same weight vector at the OTHER door. A set the chain already admitted is read here, not admitted here, so this door forgives what it cannot check — a key it cannot decode is skipped rather than refused. It does not forgive a key with no stake behind it. That seat is a phantom signer: it raises the count of distinct signers the Nova floor is read against and adds nothing to the stake the same certificate is weighed by, which is the disagreement between how many signed and how much signed that both floors exist to prevent. A door that admitted it would leave the floor meaning one thing at registration and another here",
"door": "FlattenValidatorSet",
"weights": [
"0",
"5"
],
"admitted": false,
"refusal": "zeroWeight"
},
{
"name": "weight_overflow",
"note": "two seats whose stake sums past 2^64 are refused: a total that wrapped would make every floor read off it meaningless. Checked at the already-admitted door because Register demands possession of every key BEFORE it reaches its weight clause, so that clause cannot be reached without a key ceremony",
+19 -1
View File
@@ -261,11 +261,18 @@ func register(name, note string, ws []uint64) AdmissionCase {
// flatten offers a weight vector at the already-admitted door and records what it
// decides. The map is an input to a function that sorts its keys internally, so
// nothing about the output depends on iteration order.
//
// Every seat carries the same shaped key the registration door's cases carry, so
// the two doors are offered the same material and the weight vector is the only
// thing that varies. It has to be present: the clause that refuses a seat with
// no stake is about a seat that could otherwise SIGN, and a keyless seat is
// skipped before it can. It does not have to be a point — the weight clauses are
// reached before any key is decoded, at both doors.
func flatten(name, note string, ws []uint64) AdmissionCase {
set := make(map[ids.NodeID]*validators.GetValidatorOutput, len(ws))
for i, w := range ws {
id := seat(i + 1)
set[id] = &validators.GetValidatorOutput{NodeID: id, Weight: w}
set[id] = &validators.GetValidatorOutput{NodeID: id, PublicKey: keyShaped, Weight: w}
}
_, err := validators.FlattenValidatorSet(set)
return AdmissionCase{
@@ -370,6 +377,17 @@ func verdicts() Verdict {
"counterpart here: past the zero-weight clause the door asks for possession, "+
"which is the proof-of-possession vector's question and not this one",
[]uint64{0, 5}),
flatten("zero_weight",
"the same weight vector at the OTHER door. A set the chain already admitted is "+
"read here, not admitted here, so this door forgives what it cannot check — a "+
"key it cannot decode is skipped rather than refused. It does not forgive a "+
"key with no stake behind it. That seat is a phantom signer: it raises the "+
"count of distinct signers the Nova floor is read against and adds nothing to "+
"the stake the same certificate is weighed by, which is the disagreement "+
"between how many signed and how much signed that both floors exist to "+
"prevent. A door that admitted it would leave the floor meaning one thing at "+
"registration and another here",
[]uint64{0, 5}),
flatten("weight_overflow",
"two seats whose stake sums past 2^64 are refused: a total that wrapped would make "+
"every floor read off it meaningless. Checked at the already-admitted door "+
+1 -1
View File
@@ -247,7 +247,7 @@ func TestVerdictSectionIsPopulated(t *testing.T) {
if got, want := len(v.Finality), 8; got != want {
t.Errorf("%d finality cases, want %d — adding one is deliberate, losing one is not", got, want)
}
if got, want := len(v.Admission), 3; got != want {
if got, want := len(v.Admission), 4; got != want {
t.Errorf("%d admission cases, want %d", got, want)
}
if _, err := strconv.ParseUint(v.Epoch, 10, 64); err != nil {
+41 -6
View File
@@ -47,7 +47,9 @@ import (
"sort"
"github.com/luxfi/consensus/config"
validators "github.com/luxfi/consensus/validator"
"github.com/luxfi/ids"
"github.com/luxfi/math"
)
// QuorumCertVersion is the wire/struct version of the engine-level quorum
@@ -460,9 +462,9 @@ func (c *QuorumCert) verifyNovaMajority(stake StakeSource, epochHeight uint64) e
return fmt.Errorf("%w: total stake is zero at epoch height %d (value-height %d)",
ErrQCStakeBelowMajority, epochHeight, c.Position.Height)
}
var voted uint64
for i := range c.Votes {
voted += stake.Weight(c.Votes[i].NodeID, epochHeight)
voted, err := c.votedStake(stake, epochHeight)
if err != nil {
return err
}
halfFloor := config.HalfStakeFloor(total)
if voted <= halfFloor {
@@ -486,9 +488,9 @@ func (c *QuorumCert) verifyQuasarSupermajority(stake StakeSource, epochHeight ui
// No known stake at this epoch — cannot assert a supermajority. Fail closed.
return fmt.Errorf("%w: total stake is zero at epoch height %d (value-height %d)", ErrQCStakeBelowSupermajority, epochHeight, c.Position.Height)
}
var voted uint64
for i := range c.Votes {
voted += stake.Weight(c.Votes[i].NodeID, epochHeight)
voted, err := c.votedStake(stake, epochHeight)
if err != nil {
return err
}
// Strict supermajority by stake: accept iff voted > floor(2·total/3). The floor
// has one definition, config.TwoThirdsStakeFloor — the same function the live-set
@@ -503,6 +505,39 @@ func (c *QuorumCert) verifyQuasarSupermajority(stake StakeSource, epochHeight ui
return nil
}
// votedStake is the summed stake of the certificate's voters — checked, and
// refused rather than clamped.
//
// One function, because both rungs read the same tally and a floor is only as
// honest as the number it is read against. The voters are a subset of the
// members, so against any set Register or FlattenValidatorSet admitted this sum
// is bounded by a total those doors already proved representable, and the
// overflow is unreachable. Reaching it is therefore evidence about the
// StakeSource, not about the votes: it says the source is reporting weights no
// admitted set could hold, and a source that cannot state its own total is not
// one a threshold can be read against.
//
// It must not wrap, and wrapping is what an unchecked loop does: Go's + is
// modular, so a sum past 2^64 does not fail — it returns a DIFFERENT number and
// the comparison proceeds as if nothing happened. The wrapped value can land
// anywhere, above the floor included. Three voters at 2^63 wrap to 2^63, which
// clears floor(total/2) for a total of 2^64−1 by one, and the certificate is
// accepted on arithmetic rather than on votes. Monotone is not the property
// that matters here; being the sum it claims to be is. Rust holds the same
// clause in Cert::voted_stake, with checked_add over the same votes.
func (c *QuorumCert) votedStake(stake StakeSource, epochHeight uint64) (uint64, error) {
var voted uint64
for i := range c.Votes {
sum, err := math.Add64(voted, stake.Weight(c.Votes[i].NodeID, epochHeight))
if err != nil {
return 0, fmt.Errorf("%w: cert tally at epoch %d: %w",
validators.ErrWeightOverflow, epochHeight, err)
}
voted = sum
}
return voted, nil
}
// AuthorizesExport reports whether this cert is export-grade finality (Quasar or
// brighter) — the safety boundary a bridge, DEX settlement or cross-chain consumer
// admits on. A Nova cert (local execution) returns false even though it is a valid,
+158
View File
@@ -0,0 +1,158 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// tally_overflow_test.go — the tally has to be the sum it claims to be.
//
// Both rungs decide by comparing a summed stake against a floor read off the
// total. Go's + is modular, so an unchecked loop does not fail when the weights
// a StakeSource reports run past 2^64 — it returns a DIFFERENT number and the
// comparison proceeds as if nothing happened. That is the fail-open direction:
// the wrapped value can sit above the floor while the stake it claims to
// represent does not exist, and the cert is accepted on arithmetic rather than
// on votes.
//
// Each case here states the wrap outright — the value the old loop produced,
// and the floor it cleared — and then asserts the predicate refuses. Stating
// the wrap is what makes these tests bite: without it they would pass against
// an implementation that simply refused everything.
package chain
import (
"errors"
"math"
"testing"
"github.com/luxfi/consensus/config"
validators "github.com/luxfi/consensus/validator"
"github.com/luxfi/ids"
)
// wrappingStake reports per-voter weights that sum past 2^64 against a total
// that does not. No admitted set can hold these numbers — Register and
// FlattenValidatorSet both refuse a set whose weights overflow — so reaching
// this is evidence about the SOURCE, not about the votes.
func wrappingStake(vs *testValidatorSet, n int, weights map[int]uint64) *stakeMap {
s := &stakeMap{w: make(map[ids.NodeID]uint64, n), total: math.MaxUint64}
for i := 0; i < n; i++ {
s.w[vs.nodeID(i)] = weights[i]
}
return s
}
// TestNovaTallyRefusesAWrappingStakeSource — three voters at 2^63 sum to 2^64 +
// 2^63, which wraps to 2^63 and clears floor(total/2) = 2^63 − 1 by exactly one.
// The signer floor is met, so nothing else stands in the way: before the tally
// was checked this cert was ACCEPTED at the local-execution rung.
func TestNovaTallyRefusesAWrappingStakeSource(t *testing.T) {
vs := newTestValidatorSet(5)
pos := VotePosition{ChainID: ids.GenerateTestID(), Height: 1, BlockID: ids.GenerateTestID()}
const epoch = uint64(1) // == pos.Height; the stake map is height-independent
const half = uint64(1) << 63
src := wrappingStake(vs, 5, map[int]uint64{0: half, 1: half, 2: half})
votes := make([]SignedVote, 0, 3)
for _, i := range []int{0, 1, 2} {
votes = append(votes, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: vs.sign(i, pos)})
}
cert, err := AssembleQuorumCert(pos, Nova, 3, votes)
if err != nil {
t.Fatalf("assemble: %v", err)
}
// The count clause must be satisfied, or the case would be refused before
// the tally is ever read and would prove nothing about it.
if floor := NovaSignerFloor(src.ValidatorCount(epoch)); cert.VoterCount() < floor {
t.Fatalf("this set does not reach the signer floor (%d of %d), so the tally is never read",
cert.VoterCount(), floor)
}
// The wrap, stated outright: what the unchecked loop computed, and the floor
// it cleared. If either stops holding, this case no longer demonstrates the
// fail-open and the assertion below would pass for the wrong reason.
var wrapped uint64
for i := range cert.Votes {
wrapped += src.Weight(cert.Votes[i].NodeID, epoch)
}
if wrapped != half {
t.Fatalf("the tally no longer wraps to 2^63 (got %d) — reread this case", wrapped)
}
if got := config.HalfStakeFloor(src.TotalStake(epoch)); wrapped <= got {
t.Fatalf("the wrapped tally %d no longer clears floor(total/2)=%d, so there is no "+
"fail-open left to demonstrate", wrapped, got)
}
// And the predicate refuses it, on the tally rather than on the floor.
err = cert.VerifyWeighted(vs, src, epoch)
if !errors.Is(err, validators.ErrWeightOverflow) {
t.Fatalf("a wrapping tally cleared the Nova majority: %v", err)
}
}
// TestQuasarTallyRefusesAWrappingStakeSource — the same hole at the EXPORT rung,
// which is the one a bridge admits on. Two voters at 2^63 + 7·10^18 sum past
// 2^64 and wrap to 1.4·10^19, above floor(2·total/3). Quasar carries no signer
// floor, so the tally is the only thing between this cert and export-grade
// finality.
func TestQuasarTallyRefusesAWrappingStakeSource(t *testing.T) {
vs := newTestValidatorSet(5)
pos := VotePosition{ChainID: ids.GenerateTestID(), Height: 1, BlockID: ids.GenerateTestID()}
const epoch = uint64(1)
const w = uint64(1)<<63 + 7_000_000_000_000_000_000
src := wrappingStake(vs, 5, map[int]uint64{0: w, 1: w})
votes := make([]SignedVote, 0, 2)
for _, i := range []int{0, 1} {
votes = append(votes, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: vs.sign(i, pos)})
}
cert, err := AssembleQuorumCert(pos, Quasar, 2, votes)
if err != nil {
t.Fatalf("assemble: %v", err)
}
var wrapped uint64
for i := range cert.Votes {
wrapped += src.Weight(cert.Votes[i].NodeID, epoch)
}
if got := config.TwoThirdsStakeFloor(src.TotalStake(epoch)); wrapped <= got {
t.Fatalf("the wrapped tally %d no longer clears floor(2*total/3)=%d, so there is no "+
"fail-open left to demonstrate", wrapped, got)
}
err = cert.VerifyWeighted(vs, src, epoch)
if !errors.Is(err, validators.ErrWeightOverflow) {
t.Fatalf("a wrapping tally cleared the export supermajority: %v", err)
}
}
// TestTallyStillSumsASetThatFits — the guard must not have made the honest path
// stricter. A set whose voters sum to exactly the widest representable total is
// still tallied and still decided on its merits.
func TestTallyStillSumsASetThatFits(t *testing.T) {
vs := newTestValidatorSet(3)
pos := VotePosition{ChainID: ids.GenerateTestID(), Height: 1, BlockID: ids.GenerateTestID()}
const epoch = uint64(1)
const half = uint64(1) << 63
src := &stakeMap{
w: map[ids.NodeID]uint64{
vs.nodeID(0): half,
vs.nodeID(1): half - 1,
vs.nodeID(2): 0,
},
total: math.MaxUint64, // == half + (half-1), the widest total that fits
}
votes := make([]SignedVote, 0, 2)
for _, i := range []int{0, 1} {
votes = append(votes, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: vs.sign(i, pos)})
}
cert, err := AssembleQuorumCert(pos, Quasar, 2, votes)
if err != nil {
t.Fatalf("assemble: %v", err)
}
if err := cert.VerifyWeighted(vs, src, epoch); err != nil {
t.Fatalf("a set that fits was refused: %v", err)
}
}
+83
View File
@@ -279,3 +279,86 @@ fn the_admission_decision_is_the_one_go_made() {
"expected 1 row at this crate's door, checked {checked}"
);
}
/// The weight clause both of Go's doors enforce, checked at this crate's door.
///
/// Go has two doors and this crate has one. `Register` admits a fresh set and
/// demands possession of every key; `FlattenValidatorSet` reads a set the chain
/// already admitted, has no proof to check, and forgives a key it cannot decode.
/// `ValidatorSet::insert` is the door here, and it demands possession — so most
/// of what the corpus records at the already-admitted door is not answerable
/// from this side, and the harness above skips it.
///
/// This row is the exception, for a precise reason: both doors refuse a keyed
/// seat carrying no stake BEFORE any key material is read — Go checks it after
/// the key is known to be present and before it is decoded, and `insert` checks
/// it after `NoKey` and before `pop::verify`. A shaped key is therefore enough
/// to reach the clause on either side, and the verdict Go froze is one this
/// crate can be held to rather than one it merely skips.
///
/// The clause itself is the phantom signer: a seat that can sign and holds no
/// stake raises the count of distinct signers a floor is read against and adds
/// nothing to the weight the same certificate is weighed by.
#[test]
fn a_keyed_seat_with_no_stake_is_refused_at_either_door() {
let c = corpus();
let cases = c["verdict"]["admission"]
.as_array()
.expect("corpus has no verdict.admission section");
let mut checked = 0;
for case in cases {
if case["door"].as_str() != Some("FlattenValidatorSet")
|| case["refusal"].as_str() != Some("zeroWeight")
{
continue;
}
let name = case["name"].as_str().expect("case has no name");
// Go refused the set; the row must say so, or this test would be
// holding the crate to an expectation the corpus does not state.
assert!(
!case["admitted"].as_bool().expect("case has no admitted"),
"{name}: a zeroWeight refusal that admitted the set"
);
let weights: Vec<u64> = case["weights"]
.as_array()
.unwrap_or_else(|| panic!("{name}: no weights"))
.iter()
.map(|w| w.as_str().expect("weight is not a decimal").parse().unwrap())
.collect();
assert!(
weights.contains(&0),
"{name}: a zeroWeight row with no weightless seat"
);
// Every weightless seat is refused on the weight clause, whichever
// position it holds — the seat, not the ordering, is what the clause is
// about. A shaped key reaches it: the clause runs before the key is read.
for (i, w) in weights.iter().enumerate() {
if *w != 0 {
continue;
}
let mut id = [0u8; 20];
id[16..].copy_from_slice(&((i as u32) + 1).to_be_bytes());
let mut set = ValidatorSet::new();
match set.insert(id, *w, &[0xAB; 48], &[0xAB; 96]) {
Ok(()) => panic!("{name}: seat {} carries no stake and was admitted", i + 1),
Err(e) => assert_eq!(
refusal("", &e),
"zeroWeight",
"{name}: seat {} refused on the wrong clause ({e})",
i + 1
),
}
}
checked += 1;
}
assert_eq!(
checked, 1,
"expected 1 weightless-seat row at the already-admitted door, checked {checked}"
);
}
+24
View File
@@ -175,6 +175,14 @@ func Register(rs []Registration) (CanonicalValidatorSet, error) {
// An undecodable key is skipped rather than refused, exactly as upstream: this
// function reads a set it did not admit, and refusing the whole set on one bad
// key would trade a signer for a halt. Strictness belongs at admission.
//
// A key with NO STAKE behind it is refused, not skipped, and that is not the
// same trade. Skipping a bad key costs a signer the set was going to lose
// anyway; a keyed seat carrying zero weight is a signer the set gains for free
// — it counts toward the distinct-signer floor and contributes nothing to the
// stake floor, which is precisely the disagreement between "how many signed" and
// "how much signed" that the two floors exist to prevent. Register refuses it
// with the same ErrZeroWeight, so the two doors agree on the clause they share.
func FlattenValidatorSet(vdrSet map[ids.NodeID]*GetValidatorOutput) (CanonicalValidatorSet, error) {
// Iterate in node-id order. The upstream ranged over the map and sorted at the
// end, which is fine for a total but not for an error: which duplicate pair a
@@ -203,6 +211,22 @@ func FlattenValidatorSet(vdrSet map[ids.NodeID]*GetValidatorOutput) (CanonicalVa
if len(vdr.PublicKey) == 0 {
continue
}
// ZERO WEIGHT, on a validator that CAN sign. The same clause Register
// runs, at the same point in the order — after the key is known to be
// present and before it is decoded — because it is the same phantom: an
// entry that raises the count of distinct signers a floor is read
// against without raising the weight. Both doors have to refuse it or
// the floor means one thing at admission and another here.
//
// A KEYLESS member with no stake is not that phantom and is not refused:
// it was skipped above, so it never becomes a signer, and it neither
// signs nor weighs. That is the one leniency this door keeps, and it is
// deliberate — it is the door for a set the chain already admitted, and
// a member this node holds no key for is a fact about the node's view,
// not a defect in the set. A key with no stake behind it is a defect.
if vdr.Weight == 0 {
return CanonicalValidatorSet{}, fmt.Errorf("%w: %s", ErrZeroWeight, nodeID)
}
pk, err := bls.PublicKeyFromCompressedBytes(vdr.PublicKey)
if err != nil {
continue
+77
View File
@@ -404,3 +404,80 @@ func TestFlattenAndRegisterAgree(t *testing.T) {
}
}
}
// TestFlattenRefusesAKeyedSeatWithNoStake — the phantom signer, at the second
// door. A seat that CAN sign and carries no stake raises the count of distinct
// signers a floor is read against and contributes nothing to the weight the same
// certificate is weighed by. Register has always refused it. This door admitted
// it, so the clause the two doors are supposed to share meant one thing at
// registration and another here.
func TestFlattenRefusesAKeyedSeatWithNoStake(t *testing.T) {
in := map[ids.NodeID]*GetValidatorOutput{}
phantom, o := output(0x90, secret(t, 41), 0)
in[phantom] = o
n, o := output(0x91, secret(t, 42), 7)
in[n] = o
// What the unfixed door did with it, recorded so the change is visible: the
// upstream flatten is the behaviour this one carried, and it admits the
// phantom as a signer slot holding no stake.
loose, err := validators.FlattenValidatorSet(in)
if err != nil {
t.Fatalf("upstream flatten: %v", err)
}
var seen bool
for _, v := range loose.Validators {
if v.NodeIDs[0] == phantom {
seen = true
if v.Weight != 0 {
t.Fatalf("the phantom is not weightless upstream (%d) — reread this test", v.Weight)
}
}
}
if !seen {
t.Fatal("upstream no longer admits a keyed seat with no stake — reread this test")
}
if loose.TotalWeight != 7 {
t.Fatalf("upstream total %d, want 7: the phantom adds a signer and no weight", loose.TotalWeight)
}
// Ours refuses it, on the same clause and with the same sentinel Register
// returns, so a caller cannot tell the two doors apart by the error.
set, err := FlattenValidatorSet(in)
if !errors.Is(err, ErrZeroWeight) {
t.Fatalf("a keyed seat with no stake was admitted: %v", err)
}
// Refused whole, never trimmed: a set that silently loses a seat is a set
// whose weight no longer describes it.
if len(set.Validators) != 0 || set.TotalWeight != 0 {
t.Fatalf("a refused set leaked a partial result: %d validators, total %d",
len(set.Validators), set.TotalWeight)
}
}
// TestFlattenKeepsAKeylessSeatWithNoStake — the leniency this door deliberately
// keeps. A seat with no key is skipped before the weight clause and never
// becomes a signer, so a weightless one is not the phantom: it neither signs nor
// weighs. Refusing it would halt a node over a member it merely holds no key
// for, which is a fact about this node's view and not a defect in the set.
func TestFlattenKeepsAKeylessSeatWithNoStake(t *testing.T) {
in := map[ids.NodeID]*GetValidatorOutput{}
n, o := output(0xA0, secret(t, 43), 11)
in[n] = o
keyless, o := output(0xA1, nil, 0)
in[keyless] = o
set, err := FlattenValidatorSet(in)
if err != nil {
t.Fatalf("a keyless seat with no stake was refused: %v", err)
}
if len(set.Validators) != 1 {
t.Fatalf("%d validators, want 1: a keyless seat is never a signer", len(set.Validators))
}
if set.Validators[0].NodeIDs[0] == keyless {
t.Fatal("a keyless seat became a signer")
}
if set.TotalWeight != 11 {
t.Fatalf("total weight %d, want 11", set.TotalWeight)
}
}