mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
QuorumCert.Verify returns a different typed error for each clause of the finality
predicate, precisely so one refusal can be told from another. Fourteen are
declared. Four were ever named by a test — the two the R5 floors turn on, plus
ordering and a bad signature — and the other ten had nothing asserted on them at
all: nil cert, nil verifier, version, role, unknown tier, zero threshold, no
votes, a non-accept vote, the nova stake floor. Every one of those clauses would
have passed with its body replaced by `return errors.New("no")`, which is the
shape of test that measures nothing.
So every clause now has a row: a certificate proven valid by the control above
it, one field changed, and the error that clause owes. Wrong version, wrong role,
a tier that is neither rung, a threshold of zero, no votes, a duplicate voter,
voters out of order, a reject inside a finality cert, a signature over another
position, a voter nothing can resolve, a quorum larger than the votes carried —
and on the weighted side, an unresolved set, a set below the minimum Byzantine
committee, signable stake of zero, the seats without the stake and the stake
without the seats. The same table exists in Rust and in C++; the three are meant
to be read side by side, because three implementations refusing one certificate
for three reasons is a fork waiting for the cert they disagree about.
The fifteenth name, ErrQCVotePosition, is returned by nothing. The var block's
comment says each maps 1:1 onto a clause; that one maps onto no clause, because a
vote that signed another position fails as a bad signature — the message is
rebuilt from the CERT's position and never from the vote's. It is left alone here
and reported rather than covered, since no input reaches it.
The proof-of-possession rejects follow the frozen corpus and go past it: the
class is asserted, not merely that something failed. Encoding before possession
is what keeps a pairing off a point that was never a point, and a test that only
checked for non-nil would pass with the order reversed. The corpus has no
49-byte-key case; this side carries one, because the clause is `!=` and a
verifier guarding only the short side would check a proof against a PREFIX of the
published key.
Elsewhere: the parameter doors (every clause, and zero still meaning unset),
the corpus's own stake seam (three projections over the signers, the fourth over
the roll), the two arithmetic doors that must not wrap, the warp filter/sum/
aggregate read as the step a forged message would attack, the vote frame's
refusals including a four-gigabyte signature that is not there, and the durable
guard: what survives a restart, what a floor that could not be written must not
report, and every shape of file that is present and does not decode.
engine/chain 87.2 → 89.7 · validator 84.7 → 98.3 · pop 85.3 → 91.2
config 99.0 → 100 · conformance 93.8 → 97.4
Of the named core, cert/finality/quorum/quasar/descent/guard/verified_cert are
whole; what remains there is four statements no caller can reach, written down in
LLM.md so the next reader does not spend an afternoon each on them. The rest of
the gap is engine.go and the VM/network integration surface, which wants
multi-node harnesses rather than unit tests.
Tests only — no consensus logic moved.
Co-authored-by: Hanzo Dev <[email protected]>
238 lines
8.4 KiB
Go
238 lines
8.4 KiB
Go
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// warp_ordering_test.go — the three functions a warp message is verified
|
|
// through, exercised through THIS package's names.
|
|
//
|
|
// They are re-exports, and a re-export is exactly the thing that drifts without
|
|
// saying so: a bump that changes an argument order, a name bound to the wrong
|
|
// function, a behaviour that quietly stops refusing something. Nothing in this
|
|
// repository calls them, so nothing else would notice.
|
|
//
|
|
// What a warp verifier does with them is one sentence: take the canonical set
|
|
// and the signers' bit indices off the wire, FILTER the set to those indices,
|
|
// SUM their weight against the threshold, AGGREGATE their keys, and check the
|
|
// aggregate signature against that aggregate key. The three steps share one
|
|
// ordering — the canonical order of the set — and a signature verifies only if
|
|
// every step read the same seats. So the properties tested here are the ones a
|
|
// forged warp message would need broken: that the indices select exactly the
|
|
// seats they name, that a weight cannot wrap past the threshold, and that the
|
|
// aggregate key is the aggregate of exactly the selected seats.
|
|
package validators
|
|
|
|
import (
|
|
"errors"
|
|
"math"
|
|
"testing"
|
|
|
|
"github.com/luxfi/crypto/bls"
|
|
"github.com/luxfi/ids"
|
|
"github.com/luxfi/math/set"
|
|
)
|
|
|
|
// canonical builds a set of n proven validators in this package's canonical
|
|
// order, which is the order the three functions below index into.
|
|
func canonical(t *testing.T, n int, weight uint64) CanonicalValidatorSet {
|
|
t.Helper()
|
|
rs := make([]Registration, 0, n)
|
|
for i := 0; i < n; i++ {
|
|
rs = append(rs, registration(t, byte(0x80+i), byte(0x10+i), weight))
|
|
}
|
|
set, err := Register(rs)
|
|
if err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
return set
|
|
}
|
|
|
|
// TestFilterSelectsExactlyTheSeatsNamed holds the step a forged message would
|
|
// attack first: the indices must select the seats they name and no others, in
|
|
// the canonical order, so the key the aggregate is checked against belongs to
|
|
// the validators the message claims signed it.
|
|
func TestFilterSelectsExactlyTheSeatsNamed(t *testing.T) {
|
|
vdrs := canonical(t, 4, 10).Validators
|
|
|
|
bits := set.NewBits()
|
|
bits.Add(0)
|
|
bits.Add(2)
|
|
|
|
got, err := FilterValidators(bits, vdrs)
|
|
if err != nil {
|
|
t.Fatalf("FilterValidators: %v", err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("filtered to %d validators, want 2", len(got))
|
|
}
|
|
if got[0] != vdrs[0] || got[1] != vdrs[2] {
|
|
t.Fatal("the filter did not return the seats the indices name, in canonical order")
|
|
}
|
|
|
|
// No indices selects no seats — an empty signer set is not the whole set.
|
|
empty, err := FilterValidators(set.NewBits(), vdrs)
|
|
if err != nil {
|
|
t.Fatalf("FilterValidators over no indices: %v", err)
|
|
}
|
|
if len(empty) != 0 {
|
|
t.Fatalf("no indices selected %d validators", len(empty))
|
|
}
|
|
}
|
|
|
|
// TestFilterRefusesAnIndexTheSetDoesNotHave is the fail-closed half. A message
|
|
// naming a seat past the end of the set is a message about a validator that does
|
|
// not exist, and admitting it would mean verifying a signature against a shorter
|
|
// set than the one the indices were built for.
|
|
func TestFilterRefusesAnIndexTheSetDoesNotHave(t *testing.T) {
|
|
vdrs := canonical(t, 3, 10).Validators
|
|
|
|
bits := set.NewBits()
|
|
bits.Add(3) // the fourth seat of a three-seat set
|
|
|
|
got, err := FilterValidators(bits, vdrs)
|
|
if !errors.Is(err, ErrUnknownValidator) {
|
|
t.Fatalf("want the unknown-validator refusal, got %v", err)
|
|
}
|
|
if got != nil {
|
|
t.Fatal("a refused filter returned a validator list")
|
|
}
|
|
}
|
|
|
|
// TestSumWeightRefusesToWrap is the threshold's arithmetic. A wrapped sum does
|
|
// not fail, it returns a different number, and the number a warp threshold is
|
|
// compared against must be the sum it claims to be.
|
|
func TestSumWeightRefusesToWrap(t *testing.T) {
|
|
got, err := SumWeight(canonical(t, 3, 10).Validators)
|
|
if err != nil {
|
|
t.Fatalf("SumWeight: %v", err)
|
|
}
|
|
if got != 30 {
|
|
t.Fatalf("SumWeight = %d, want 30", got)
|
|
}
|
|
if empty, err := SumWeight(nil); err != nil || empty != 0 {
|
|
t.Fatalf("SumWeight over no validators = (%d, %v), want (0, nil)", empty, err)
|
|
}
|
|
|
|
// Built by hand rather than through Register, which refuses the same total
|
|
// at admission. SumWeight is handed a list that came off the wire through
|
|
// the filter, so it does its own arithmetic and must refuse independently:
|
|
// two weights that wrap to two would otherwise clear any threshold below it.
|
|
huge := []*CanonicalValidator{
|
|
{Weight: math.MaxUint64 - 1},
|
|
{Weight: 3},
|
|
}
|
|
if _, err := SumWeight(huge); !errors.Is(err, ErrWeightOverflow) {
|
|
t.Fatalf("a total past MaxUint64 must be refused, not wrapped: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestTheAggregateKeyIsTheKeyTheSignatureVerifiesUnder is the step the other two
|
|
// exist to serve, checked the only way that means anything: sign the same
|
|
// message with each selected validator's secret, aggregate the signatures, and
|
|
// verify the aggregate against the key AggregatePublicKeys derives. A seat left
|
|
// out of either aggregate makes it fail, which is what binds the filtered set to
|
|
// the signature.
|
|
func TestTheAggregateKeyIsTheKeyTheSignatureVerifiesUnder(t *testing.T) {
|
|
// Build the set and keep the secrets, in the SAME canonical order the
|
|
// filter indexes into, by looking each validator's key up by its bytes.
|
|
const n = 4
|
|
secrets := make(map[string]*bls.SecretKey, n)
|
|
rs := make([]Registration, 0, n)
|
|
for i := 0; i < n; i++ {
|
|
sk := secret(t, byte(0x90+i))
|
|
secrets[string(bls.PublicKeyToCompressedBytes(sk.PublicKey()))] = sk
|
|
rs = append(rs, registration(t, byte(0x90+i), byte(0x20+i), 10))
|
|
}
|
|
set0, err := Register(rs)
|
|
if err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
|
|
bits := set.NewBits()
|
|
bits.Add(1)
|
|
bits.Add(3)
|
|
signers, err := FilterValidators(bits, set0.Validators)
|
|
if err != nil {
|
|
t.Fatalf("FilterValidators: %v", err)
|
|
}
|
|
|
|
message := []byte("a warp message the selected seats agree on")
|
|
sigs := make([]*bls.Signature, 0, len(signers))
|
|
for _, v := range signers {
|
|
sk, ok := secrets[string(v.PublicKeyBytes)]
|
|
if !ok {
|
|
t.Fatal("a filtered validator has no secret — the canonical order was not preserved")
|
|
}
|
|
sig, err := sk.Sign(message)
|
|
if err != nil {
|
|
t.Fatalf("Sign: %v", err)
|
|
}
|
|
sigs = append(sigs, sig)
|
|
}
|
|
agg, err := bls.AggregateSignatures(sigs)
|
|
if err != nil {
|
|
t.Fatalf("AggregateSignatures: %v", err)
|
|
}
|
|
|
|
key, err := AggregatePublicKeys(signers)
|
|
if err != nil {
|
|
t.Fatalf("AggregatePublicKeys: %v", err)
|
|
}
|
|
if !bls.Verify(key, agg, message) {
|
|
t.Fatal("the aggregate signature does not verify under the aggregate of the seats that made it")
|
|
}
|
|
|
|
// The binding, stated the other way: the aggregate of a DIFFERENT selection
|
|
// does not verify it. Without this the row above would pass for a function
|
|
// that returned any key at all.
|
|
otherBits := set.NewBits()
|
|
otherBits.Add(0)
|
|
otherBits.Add(2)
|
|
others, err := FilterValidators(otherBits, set0.Validators)
|
|
if err != nil {
|
|
t.Fatalf("FilterValidators: %v", err)
|
|
}
|
|
otherKey, err := AggregatePublicKeys(others)
|
|
if err != nil {
|
|
t.Fatalf("AggregatePublicKeys: %v", err)
|
|
}
|
|
if bls.Verify(otherKey, agg, message) {
|
|
t.Fatal("a signature verified under the aggregate of seats that did not sign it")
|
|
}
|
|
}
|
|
|
|
// TestNewManagerReturnsALiveManager holds the constructor to what a caller needs
|
|
// from it: a manager that is empty to start with and remembers what it is given,
|
|
// per network. A nil interface, or one bound to a shared instance, would both
|
|
// pass a test that only checked for non-nil.
|
|
func TestNewManagerReturnsALiveManager(t *testing.T) {
|
|
m := NewManager()
|
|
if m == nil {
|
|
t.Fatal("NewManager returned nothing")
|
|
}
|
|
|
|
netID, other := ids.GenerateTestID(), ids.GenerateTestID()
|
|
node := ids.GenerateTestNodeID()
|
|
if got := m.Count(netID); got != 0 {
|
|
t.Fatalf("a fresh manager holds %d validators, want 0", got)
|
|
}
|
|
|
|
sk := secret(t, 0xA0)
|
|
if err := m.AddStaker(netID, node, bls.PublicKeyToCompressedBytes(sk.PublicKey()), ids.GenerateTestID(), 42); err != nil {
|
|
t.Fatalf("AddStaker: %v", err)
|
|
}
|
|
if got := m.Count(netID); got != 1 {
|
|
t.Fatalf("the manager holds %d validators, want 1", got)
|
|
}
|
|
if got := m.GetWeight(netID, node); got != 42 {
|
|
t.Fatalf("weight = %d, want 42", got)
|
|
}
|
|
// The network is part of the key: a staker on one network is not on another.
|
|
if got := m.Count(other); got != 0 {
|
|
t.Fatalf("another network holds %d validators, want 0", got)
|
|
}
|
|
|
|
// And a second manager is a second manager.
|
|
if got := NewManager().Count(netID); got != 0 {
|
|
t.Fatalf("a new manager already holds %d validators", got)
|
|
}
|
|
}
|