mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
Reading both floors over the signers let a set shrink to its signers, and a rung whose floors are read over n is satisfiable at every n. TwoThirdsCount(1) is 1, so one signature over a one-signer set clears the count floor and the stake floor together and mints export finality. Byzantine tolerance is f = ⌊(n−1)/3⌋, which is ZERO for n of one, two and three: such a certificate absorbs no fault at all, and one compromised key among its signers forges it outright. So the export rung refuses a signing set below minBFTCommittee — the same constant bftCommittee floors committee SELECTION at, read at the certificate instead of at the sampler, and reused rather than restated. It is a floor on the SET, stated beside the n<1 refusal because neither quorum floor implies it: both are read over n and both shrink with it. Nova has no such clause and must not grow one — it authorizes local execution the chain can still reorg away, so a small or partitioned chain has to be able to make progress. A three-validator chain now PRODUCES and never CERTIFIES; operators need n ≥ 4 to export with any fault tolerance. Rust mirrors it as CertError::MinCommittee, which the corpus vocabulary folds into Go's one belowThreshold class. The transcription guard was reading the wrong denominator. verdict_test computed n as len(c.Set), the membership ROLL, so a floor moved onto the roll would agree with the guard and be re-blessed by -update — the second line of defence agreeing with the regression it exists to catch. It now counts the seats that hold a key, on the Quasar branch and the Nova one, and transcribes the committee floor as a search for the least n whose fault budget reaches one rather than as the constant. Proven by construction: with the corpus source reporting the roll as its signer count and the golden re-blessed, the old guard passes and the new one fails. The corpus could not have caught it either. quasar_keyless_third had three signers on a roll of four, and ⌊2·3/3⌋+1 and ⌊2·4/3⌋+1 are both three, so its count half was vacuous. Two rows now separate the denominators one at a time: quasar_keyless_stake (six signers, a seventh member holding a third of the roll — the count floor is five either way, so only stake decides) and quasar_keyless_count (four signers, two members holding one unit each — the stake clears under either reading, so only the count decides). TestKeylessRowsBind- BothDenominators fails if either is lost or flattened. quasar_keyless_third itself FLIPS to a refusal, and it is the row that states the new clause: the recorded stake floor is 200 over the three hundred that can sign and not 333 over the five hundred the chain carries, so the denominator IS the signers — and the certificate is refused anyway, on neither quorum floor. Reading the floors over the signers stops a spectator stranding a chain; it is not a way for a chain with three signers to export. Every other frozen verdict is byte-identical, and the admission section and the signed vectors are untouched. StakeSource gains CarriedStake, and no floor may read it. SignerStake alone decides a certificate correctly and cannot show the set shrinking underneath one: a chain whose signable stake has fallen to a sliver of what it carries still certifies at ⅔ of the sliver, and ResponsiveStakePct — voted over signer — stays at one the whole way down. FinalityStatus.SignerStakePct is the number that falls, and the one to alarm on. Rust has carried(), C++ has total_stake(); Go had lost the projection when the denominator moved. Co-authored-by: Hanzo Dev <[email protected]>
227 lines
9.7 KiB
Go
227 lines
9.7 KiB
Go
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// quorum_epoch_binding_test.go — MEDIUM fix: stake-weighted finality must be
|
|
// evaluated against the validator set/stake AT THE CERT-POSITION EPOCH, and a
|
|
// cert must be cryptographically pinned to that epoch. Two properties:
|
|
//
|
|
// (1) EPOCH-PINNING (soundness): a cert assembled from votes cast under
|
|
// validator-set-root R verifies only against R. Re-presenting it as
|
|
// certifying under a different set-root R' (a cross-epoch laundering) fails
|
|
// clause-6 signature verification — every signature was over R.
|
|
// (2) NO-FLIP: an already-correct cert at (epoch height H, root R), whose voters
|
|
// held a ⅔-stake supermajority AT H, is NOT flipped by a later stake change.
|
|
// VerifyWeighted reads stake at the EPOCH HEIGHT passed by the caller (the
|
|
// block's P-chain height; here == the cert's position height), so a change at
|
|
// a different height cannot retroactively invalidate (or validate) the cert.
|
|
//
|
|
// Together these turn "the ⅔-by-stake predicate is measured at the cert-position
|
|
// epoch" from a Gate-D ASSUMPTION into an ENFORCED invariant.
|
|
package chain
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/luxfi/ids"
|
|
)
|
|
|
|
// epochStakeSource is a height-aware test StakeSource: it returns the weighted
|
|
// set for the epoch that contains a given height. It exists to PROVE the engine
|
|
// reads stake at the cert-position height (the node's single-epoch stub discards
|
|
// height; this test double does not, so it can witness which height is read).
|
|
type epochStakeSource struct {
|
|
// byHeightBoundary maps an inclusive lower-height boundary to a (weights,
|
|
// total) snapshot. The snapshot for height h is the one with the greatest
|
|
// boundary <= h.
|
|
boundaries []uint64
|
|
weights map[uint64]map[ids.NodeID]uint64
|
|
totals map[uint64]uint64
|
|
}
|
|
|
|
func (e *epochStakeSource) snapshotFor(height uint64) (map[ids.NodeID]uint64, uint64) {
|
|
var pick uint64
|
|
found := false
|
|
for _, b := range e.boundaries {
|
|
if b <= height && (!found || b > pick) {
|
|
pick, found = b, true
|
|
}
|
|
}
|
|
if !found {
|
|
return nil, 0
|
|
}
|
|
return e.weights[pick], e.totals[pick]
|
|
}
|
|
|
|
func (e *epochStakeSource) Weight(nodeID ids.NodeID, height uint64) uint64 {
|
|
w, _ := e.snapshotFor(height)
|
|
return w[nodeID]
|
|
}
|
|
|
|
func (e *epochStakeSource) SignerStake(height uint64) uint64 {
|
|
_, t := e.snapshotFor(height)
|
|
return t
|
|
}
|
|
|
|
func (e *epochStakeSource) SignerCount(height uint64) int {
|
|
w, _ := e.snapshotFor(height)
|
|
return len(w)
|
|
}
|
|
|
|
// Every seat in a snapshot holds a key, so the set carries exactly what its signers hold.
|
|
func (e *epochStakeSource) CarriedStake(height uint64) uint64 {
|
|
_, t := e.snapshotFor(height)
|
|
return t
|
|
}
|
|
|
|
// TestEpochBinding_CrossEpochCertRejected pins property (1): a cert is bound to
|
|
// the validator-set-root it was signed under; mutating the cert's position root
|
|
// to a different epoch's root makes every signature fail verification.
|
|
func TestEpochBinding_CrossEpochCertRejected(t *testing.T) {
|
|
vs := newTestValidatorSet(4)
|
|
chainID := ids.GenerateTestID()
|
|
blockID := ids.GenerateTestID()
|
|
|
|
rootEpochA := ids.GenerateTestID() // set-root at the cert's true epoch
|
|
rootEpochB := ids.GenerateTestID() // a DIFFERENT epoch's set-root
|
|
|
|
// Votes are cast under epoch-A's set-root.
|
|
posA := VotePosition{
|
|
ChainID: chainID, Height: 10, Round: 0, BlockID: blockID, ParentID: ids.Empty,
|
|
ValidatorSetRoot: rootEpochA,
|
|
}
|
|
certA, err := AssembleQuorumCert(posA, Quasar, 3, []SignedVote{
|
|
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, posA)},
|
|
{NodeID: vs.nodeID(1), Accept: true, Signature: vs.sign(1, posA)},
|
|
{NodeID: vs.nodeID(2), Accept: true, Signature: vs.sign(2, posA)},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("assemble epoch-A cert: %v", err)
|
|
}
|
|
|
|
// Sound: the cert verifies against its OWN (epoch-A) position.
|
|
if err := certA.Verify(vs, certA.Position.Height); err != nil {
|
|
t.Fatalf("epoch-A cert must verify against its own set-root: %v", err)
|
|
}
|
|
|
|
// Cross-epoch laundering: re-stamp the SAME signed votes under epoch-B's
|
|
// root. The signatures were over epoch-A's canonical message, so verifying
|
|
// against epoch-B's position MUST fail clause-6 (signature invalid).
|
|
certLaundered := &QuorumCert{
|
|
Version: certA.Version,
|
|
Type: certA.Type,
|
|
Tier: certA.Tier,
|
|
Position: VotePosition{ChainID: chainID, Height: 10, Round: 0, BlockID: blockID, ParentID: ids.Empty, ValidatorSetRoot: rootEpochB},
|
|
Threshold: certA.Threshold,
|
|
Votes: certA.Votes, // same signatures, different claimed epoch
|
|
}
|
|
// Name the clause. Verify's clauses are ordered, so a bare err != nil is
|
|
// satisfied by any earlier structural refusal and stops exercising the
|
|
// binding — carrying the real tier is what makes the signature clause run.
|
|
if err := certLaundered.Verify(vs, certLaundered.Position.Height); !errors.Is(err, ErrQCSigInvalid) {
|
|
t.Fatalf("MEDIUM: a cert re-presented under a DIFFERENT validator-set-root must fail the SIGNATURE clause (cross-epoch laundering), got: %v", err)
|
|
}
|
|
|
|
// And the canonical messages for the two epochs differ (the binding is real,
|
|
// not incidental) — a direct assertion that the root is in the signed bytes.
|
|
if string(CanonicalVoteMessage(posA)) == string(CanonicalVoteMessage(certLaundered.Position)) {
|
|
t.Fatal("MEDIUM: positions differing only in ValidatorSetRoot must produce DIFFERENT canonical messages")
|
|
}
|
|
}
|
|
|
|
// TestEpochBinding_StakeReadAtCertHeight pins property (2): VerifyWeighted reads
|
|
// stake at the cert's position height, and a stake change at a LATER height does
|
|
// not flip an already-correct cert.
|
|
func TestEpochBinding_StakeReadAtCertHeight(t *testing.T) {
|
|
vs := newTestValidatorSet(4)
|
|
chainID := ids.GenerateTestID()
|
|
blockID := ids.GenerateTestID()
|
|
root := ids.GenerateTestID()
|
|
|
|
// Epoch boundaries: at heights [0,99] node0 holds 97/100 (a ⅔+ supermajority
|
|
// with {0,1,2}); at heights [100,∞) node0 has UNBONDED to 1 and the stake is
|
|
// spread {1,1,1,97} so {0,1,2} hold only 3/100 (below ⅔).
|
|
ess := &epochStakeSource{
|
|
boundaries: []uint64{0, 100},
|
|
weights: map[uint64]map[ids.NodeID]uint64{
|
|
0: {vs.nodeID(0): 97, vs.nodeID(1): 1, vs.nodeID(2): 1, vs.nodeID(3): 1},
|
|
100: {vs.nodeID(0): 1, vs.nodeID(1): 1, vs.nodeID(2): 1, vs.nodeID(3): 97},
|
|
},
|
|
totals: map[uint64]uint64{0: 100, 100: 100},
|
|
}
|
|
|
|
// A cert finalized at height 10 (epoch [0,99]) by {0,1,2}: 99/100 stake → valid.
|
|
pos10 := VotePosition{ChainID: chainID, Height: 10, Round: 0, BlockID: blockID, ParentID: ids.Empty, ValidatorSetRoot: root}
|
|
cert10, err := AssembleQuorumCert(pos10, Quasar, 3, []SignedVote{
|
|
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos10)},
|
|
{NodeID: vs.nodeID(1), Accept: true, Signature: vs.sign(1, pos10)},
|
|
{NodeID: vs.nodeID(2), Accept: true, Signature: vs.sign(2, pos10)},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("assemble height-10 cert: %v", err)
|
|
}
|
|
|
|
// Correct at its OWN epoch: stake read at height 10 → 99/100 > ⅔ → accepted.
|
|
if err := cert10.VerifyWeighted(vs, ess, cert10.Position.Height); err != nil {
|
|
t.Fatalf("cert at height 10 must verify against height-10 stake (99/100): %v", err)
|
|
}
|
|
|
|
// NO-FLIP: the later stake change (node0 unbonds at height 100) does NOT
|
|
// affect this cert — its Position.Height is 10, so VerifyWeighted still reads
|
|
// the height-10 snapshot. Re-verify is stable.
|
|
if err := cert10.VerifyWeighted(vs, ess, cert10.Position.Height); err != nil {
|
|
t.Fatalf("a later-epoch stake change must NOT flip an already-correct height-10 cert: %v", err)
|
|
}
|
|
|
|
// Contrast — PROVE the source actually returns the worse snapshot at height
|
|
// 100, so the no-flip above is meaningful (not a vacuous test). A cert built
|
|
// at height 100 by {0,1,2} holds only 3/100 → MUST be rejected.
|
|
pos100 := VotePosition{ChainID: chainID, Height: 100, Round: 0, BlockID: ids.GenerateTestID(), ParentID: blockID, ValidatorSetRoot: ids.GenerateTestID()}
|
|
cert100, err := AssembleQuorumCert(pos100, Quasar, 3, []SignedVote{
|
|
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos100)},
|
|
{NodeID: vs.nodeID(1), Accept: true, Signature: vs.sign(1, pos100)},
|
|
{NodeID: vs.nodeID(2), Accept: true, Signature: vs.sign(2, pos100)},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("assemble height-100 cert: %v", err)
|
|
}
|
|
if err := cert100.VerifyWeighted(vs, ess, cert100.Position.Height); err == nil {
|
|
t.Fatal("a cert at height 100 by {0,1,2} holds only 3/100 of stake and MUST be rejected (proves height-100 snapshot is read)")
|
|
}
|
|
}
|
|
|
|
// TestEpochBinding_RoundTripPreservesRoot guards the wire codec: the new
|
|
// ValidatorSetRoot must survive Marshal→Unmarshal so a gossiped cert carries its
|
|
// epoch binding to followers.
|
|
func TestEpochBinding_RoundTripPreservesRoot(t *testing.T) {
|
|
vs := newTestValidatorSet(3)
|
|
root := ids.GenerateTestID()
|
|
pos := VotePosition{ChainID: ids.GenerateTestID(), Height: 7, Round: 2, BlockID: ids.GenerateTestID(), ParentID: ids.GenerateTestID(), ValidatorSetRoot: root}
|
|
cert, err := AssembleQuorumCert(pos, Quasar, 3, []SignedVote{
|
|
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)},
|
|
{NodeID: vs.nodeID(1), Accept: true, Signature: vs.sign(1, pos)},
|
|
{NodeID: vs.nodeID(2), Accept: true, Signature: vs.sign(2, pos)},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("assemble: %v", err)
|
|
}
|
|
b, err := cert.MarshalBinary()
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
got, err := UnmarshalQuorumCert(b)
|
|
if err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if got.Position.ValidatorSetRoot != root {
|
|
t.Fatalf("ValidatorSetRoot lost on the wire: got %s want %s", got.Position.ValidatorSetRoot, root)
|
|
}
|
|
if !cert.Equal(got) {
|
|
t.Fatal("cert did not round-trip through the wire codec with the set-root field")
|
|
}
|
|
// The decoded cert still verifies (sigs bound to the same root).
|
|
if err := got.Verify(vs, got.Position.Height); err != nil {
|
|
t.Fatalf("decoded cert must still verify: %v", err)
|
|
}
|
|
}
|