mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
The C++ implementation signs 70 bytes under "LUX/chain/vote/v1\0"; this one signs 226 under v2. Neither side could ever verify the other, and nothing in the repository said so. The rule was only ever expressed as Go, so the only way to check another language against it was to read it and retype it — which is how the two drifted. conformance/corpus.json is the standard as data: the signed message, the certificate wire form, both stake floors across the full u64 range, the ladder's authorizations and the committee parameters, all read from the live definitions rather than restated beside them. An implementation in another language is conformant when it reproduces that file. A golden file alone would be weak — it can be re-blessed with -update and the change vanishes. So the load-bearing facts are also stated outright, transcribed from the spec and sharing no code with the encoder: the message is 226 bytes, the tag is the one the network uses, the two-thirds floor recomputed in arbitrary precision, and every signed axis moved one at a time to prove it is bound. Re-blessing the golden does not get past those. CanonicalRejectMessage is exported alongside it. The engine has always signed reject votes, but only the accept half could be reached from outside the package, so only half the standard could be written down. Co-authored-by: Hanzo Dev <[email protected]>
314 lines
12 KiB
Go
314 lines
12 KiB
Go
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// corpus_test.go — two independent guards on the standard.
|
|
//
|
|
// The first is the golden file: the corpus this build produces must equal
|
|
// corpus.json byte for byte, so any edit to a finality rule shows up as a diff
|
|
// in the standard rather than as a foreign node that cannot verify our
|
|
// signatures.
|
|
//
|
|
// A golden file alone is weak — it can be re-blessed with -update and the
|
|
// change disappears. So the second guard states the load-bearing facts OUTRIGHT,
|
|
// transcribed from the spec rather than read from the encoder: the message is
|
|
// 226 bytes, the tag is "LUX/chain/vote/v2\0", the two-thirds floor is
|
|
// floor(2·total/3) computed in arbitrary precision, and the transport ids are
|
|
// absent from the signed bytes. Re-blessing the golden does not get past these.
|
|
package conformance
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"flag"
|
|
"math/big"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/luxfi/consensus/config"
|
|
"github.com/luxfi/consensus/engine/chain"
|
|
)
|
|
|
|
var update = flag.Bool("update", false, "rewrite corpus.json from the live definitions")
|
|
|
|
const goldenPath = "corpus.json"
|
|
|
|
func TestCorpusMatchesGolden(t *testing.T) {
|
|
got, err := Marshal(Build())
|
|
if err != nil {
|
|
t.Fatalf("marshal corpus: %v", err)
|
|
}
|
|
|
|
if *update {
|
|
if err := os.WriteFile(goldenPath, got, 0o644); err != nil {
|
|
t.Fatalf("write golden: %v", err)
|
|
}
|
|
t.Logf("wrote %s (%d bytes)", goldenPath, len(got))
|
|
return
|
|
}
|
|
|
|
want, err := os.ReadFile(goldenPath)
|
|
if err != nil {
|
|
t.Fatalf("read golden (run `go test ./conformance -update` to create it): %v", err)
|
|
}
|
|
if !bytes.Equal(got, want) {
|
|
t.Errorf("the corpus moved: a finality rule changed.\n"+
|
|
"Every other implementation of Lux consensus is now wrong against this build.\n"+
|
|
"If the change is intended, run `go test ./conformance -update`, review the diff as a\n"+
|
|
"protocol change, and re-capture luxfi/conformance from it.\ngot %d bytes, want %d",
|
|
len(got), len(want))
|
|
}
|
|
}
|
|
|
|
// TestCorpusIsDeterministic — two builds in one process must agree. A map
|
|
// iterated into the output, a clock, or a random id would show up here.
|
|
func TestCorpusIsDeterministic(t *testing.T) {
|
|
a, err := Marshal(Build())
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
b, err := Marshal(Build())
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
if !bytes.Equal(a, b) {
|
|
t.Fatal("two builds of the corpus differ; something in it is not deterministic")
|
|
}
|
|
}
|
|
|
|
// TestGoldenIsValidJSON — the committed file has to parse in the languages that
|
|
// read it, not only in the one that wrote it.
|
|
func TestGoldenIsValidJSON(t *testing.T) {
|
|
raw, err := os.ReadFile(goldenPath)
|
|
if err != nil {
|
|
t.Fatalf("read golden: %v", err)
|
|
}
|
|
var c Corpus
|
|
if err := json.Unmarshal(raw, &c); err != nil {
|
|
t.Fatalf("golden does not parse: %v", err)
|
|
}
|
|
if c.Version != chain.QuorumCertVersion {
|
|
t.Errorf("golden version %d, live QuorumCertVersion %d", c.Version, chain.QuorumCertVersion)
|
|
}
|
|
}
|
|
|
|
// TestSignedBytesStatedOutright transcribes the message layout from the spec and
|
|
// rebuilds the reference message by hand. It shares no code with the encoder, so
|
|
// a reordered field, a changed width, an edited tag or a dropped binding fails
|
|
// here even if the golden was re-blessed in the same commit.
|
|
func TestSignedBytesStatedOutright(t *testing.T) {
|
|
const tag = "LUX/chain/vote/v2\x00"
|
|
|
|
var want []byte
|
|
want = append(want, tag...)
|
|
want = append(want, 0x00, 0x03) // version 3, big-endian
|
|
want = append(want, 0x01) // qcType = finality
|
|
want = append(want, bytes.Repeat([]byte{0x11}, 32)...)
|
|
want = append(want, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08) // height
|
|
want = append(want, 0x0A, 0x0B, 0x0C, 0x0D) // round
|
|
want = append(want, bytes.Repeat([]byte{0x44}, 32)...) // canonical
|
|
want = append(want, bytes.Repeat([]byte{0x55}, 32)...) // parent canonical
|
|
want = append(want, bytes.Repeat([]byte{0x66}, 32)...) // execution state root
|
|
want = append(want, bytes.Repeat([]byte{0x77}, 32)...) // payload root
|
|
want = append(want, bytes.Repeat([]byte{0x88}, 32)...) // validator set root
|
|
want = append(want, 0x01) // accept
|
|
|
|
if len(want) != 226 {
|
|
t.Fatalf("the transcribed layout is %d bytes, the spec says 226", len(want))
|
|
}
|
|
|
|
got := chain.CanonicalVoteMessage(spec())
|
|
if !bytes.Equal(got, want) {
|
|
t.Fatalf("the signed message is not what the spec says.\n got %x\nwant %x", got, want)
|
|
}
|
|
|
|
// The corpus must carry exactly these bytes under "spec".
|
|
if hexOf(t, "spec") != hex.EncodeToString(want) {
|
|
t.Error("the corpus's spec case does not carry the transcribed message")
|
|
}
|
|
|
|
// Reject differs in the last byte alone — this is what stops an accept
|
|
// signature from being presented as a reject.
|
|
rej := chain.CanonicalRejectMessage(spec())
|
|
if len(rej) != len(got) {
|
|
t.Fatalf("reject message is %d bytes, accept is %d", len(rej), len(got))
|
|
}
|
|
if !bytes.Equal(rej[:len(rej)-1], got[:len(got)-1]) {
|
|
t.Error("accept and reject differ before the final byte")
|
|
}
|
|
if rej[len(rej)-1] != 0x00 || got[len(got)-1] != 0x01 {
|
|
t.Errorf("decision byte is reject=%#x accept=%#x, want 0x00 and 0x01", rej[len(rej)-1], got[len(got)-1])
|
|
}
|
|
}
|
|
|
|
// TestTransportIDsAreNotSigned holds the property the whole canonical/transport
|
|
// split exists for: two nodes that executed one inner block sign identical
|
|
// bytes however it was wrapped.
|
|
func TestTransportIDsAreNotSigned(t *testing.T) {
|
|
base := spec()
|
|
moved := base
|
|
moved.BlockID = fill(0xE1)
|
|
moved.ParentID = fill(0xE2)
|
|
|
|
if !bytes.Equal(chain.CanonicalVoteMessage(base), chain.CanonicalVoteMessage(moved)) {
|
|
t.Fatal("moving the transport ids moved the signed message; they are being signed")
|
|
}
|
|
if hexOf(t, "spec") != hexOf(t, "outer_moved") {
|
|
t.Error("the corpus records different messages for the same execution identity")
|
|
}
|
|
|
|
// Every signed axis, moved one at a time, must move the message. A field
|
|
// that is silently dropped from the encoder is caught here and nowhere else.
|
|
for _, m := range []struct {
|
|
name string
|
|
edit func(*chain.VotePosition)
|
|
}{
|
|
{"chainID", func(p *chain.VotePosition) { p.ChainID = fill(0xEE) }},
|
|
{"height", func(p *chain.VotePosition) { p.Height++ }},
|
|
{"round", func(p *chain.VotePosition) { p.Round++ }},
|
|
{"canonicalID", func(p *chain.VotePosition) { p.CanonicalID = fill(0xEE) }},
|
|
{"parentCanonicalID", func(p *chain.VotePosition) { p.ParentCanonicalID = fill(0xEE) }},
|
|
{"executionStateRoot", func(p *chain.VotePosition) { p.ExecutionStateRoot = fill(0xEE) }},
|
|
{"payloadRoot", func(p *chain.VotePosition) { p.PayloadRoot = fill(0xEE) }},
|
|
{"validatorSetRoot", func(p *chain.VotePosition) { p.ValidatorSetRoot = fill(0xEE) }},
|
|
} {
|
|
p := spec()
|
|
m.edit(&p)
|
|
if bytes.Equal(chain.CanonicalVoteMessage(p), chain.CanonicalVoteMessage(spec())) {
|
|
t.Errorf("%s is not bound into the signed message", m.name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestStakeFloorsStatedOutright recomputes both floors in arbitrary precision.
|
|
// The production functions are written to survive a total near 2^64 where 2·total
|
|
// overflows; big.Int has no such problem and therefore no shared mistake.
|
|
func TestStakeFloorsStatedOutright(t *testing.T) {
|
|
three := big.NewInt(3)
|
|
two := big.NewInt(2)
|
|
|
|
for _, row := range Build().Threshold.Stake {
|
|
total, err := strconv.ParseUint(row.Total, 10, 64)
|
|
if err != nil {
|
|
t.Fatalf("total %q: %v", row.Total, err)
|
|
}
|
|
|
|
bt := new(big.Int).SetUint64(total)
|
|
wantTwoThirds := new(big.Int).Div(new(big.Int).Mul(bt, two), three).String()
|
|
wantHalf := new(big.Int).Div(bt, two).String()
|
|
|
|
if row.TwoThirds != wantTwoThirds {
|
|
t.Errorf("total=%s: twoThirds=%s, floor(2·total/3)=%s", row.Total, row.TwoThirds, wantTwoThirds)
|
|
}
|
|
if row.Half != wantHalf {
|
|
t.Errorf("total=%s: half=%s, floor(total/2)=%s", row.Total, row.Half, wantHalf)
|
|
}
|
|
|
|
// The recorded need is the floor plus one — the predicate is STRICTLY
|
|
// greater than the floor, and "≥ floor" is the classic off-by-one that
|
|
// finalizes on exactly two thirds.
|
|
if row.QuasarNeed != new(big.Int).Add(new(big.Int).SetUint64(config.TwoThirdsStakeFloor(total)), big.NewInt(1)).String() {
|
|
t.Errorf("total=%s: quasarNeed=%s is not twoThirds+1", row.Total, row.QuasarNeed)
|
|
}
|
|
if row.NovaNeed != new(big.Int).Add(new(big.Int).SetUint64(config.HalfStakeFloor(total)), big.NewInt(1)).String() {
|
|
t.Errorf("total=%s: novaNeed=%s is not half+1", row.Total, row.NovaNeed)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestLadderBoundary states the invariant in words: nothing below Quasar may be
|
|
// exported, and Nova and brighter may drive local execution.
|
|
func TestLadderBoundary(t *testing.T) {
|
|
want := map[string][3]bool{
|
|
// local export irreversible
|
|
"photon": {false, false, false},
|
|
"wave": {false, false, false},
|
|
"nova": {true, false, false},
|
|
"quasar": {true, true, false},
|
|
"horizon": {true, true, true},
|
|
}
|
|
rungs := Build().Ladder
|
|
if len(rungs) != len(want) {
|
|
t.Fatalf("ladder has %d rungs, want %d", len(rungs), len(want))
|
|
}
|
|
for _, r := range rungs {
|
|
w, ok := want[r.Name]
|
|
if !ok {
|
|
t.Errorf("unknown rung %q on the ladder", r.Name)
|
|
continue
|
|
}
|
|
got := [3]bool{r.AuthorizesLocalExecution, r.AuthorizesExport, r.AuthorizesIrreversibleSettlement}
|
|
if got != w {
|
|
t.Errorf("%s authorizes %v, want %v", r.Name, got, w)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestNovaSitsBelowQuasar — the two rungs are distinct authorizations, so on any
|
|
// set where they can differ the local-execution gate must be the lower one. If
|
|
// they ever coincide, Nova has stopped being a bare majority and export has
|
|
// stopped being ⅔.
|
|
func TestNovaSitsBelowQuasar(t *testing.T) {
|
|
for _, row := range Build().Threshold.Stake {
|
|
total, err := strconv.ParseUint(row.Total, 10, 64)
|
|
if err != nil {
|
|
t.Fatalf("total %q: %v", row.Total, err)
|
|
}
|
|
half := config.HalfStakeFloor(total)
|
|
twoThirds := config.TwoThirdsStakeFloor(total)
|
|
if half > twoThirds {
|
|
t.Errorf("total=%d: the majority floor %d is above the supermajority floor %d", total, half, twoThirds)
|
|
}
|
|
if total >= 6 && half >= twoThirds {
|
|
t.Errorf("total=%d: majority floor %d does not sit below supermajority floor %d", total, half, twoThirds)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCertWireIsSortedByNodeID — assembly order is part of the standard. Two
|
|
// nodes holding one quorum must gossip one certificate, so the encoder emits
|
|
// votes ascending by node id whatever order they were collected in.
|
|
func TestCertWireIsSortedByNodeID(t *testing.T) {
|
|
for _, c := range Build().Cert.Cases {
|
|
var prev string
|
|
for i, v := range c.Votes {
|
|
if i > 0 && v.NodeID <= prev {
|
|
t.Errorf("%s: vote %d node id %s does not follow %s", c.Name, i, v.NodeID, prev)
|
|
}
|
|
prev = v.NodeID
|
|
}
|
|
}
|
|
}
|
|
|
|
// hexOf returns the recorded message for a named vote case.
|
|
func hexOf(t *testing.T, name string) string {
|
|
t.Helper()
|
|
for _, c := range Build().Vote.Cases {
|
|
if c.Name == name {
|
|
return c.Message
|
|
}
|
|
}
|
|
t.Fatalf("no vote case named %q in the corpus", name)
|
|
return ""
|
|
}
|
|
|
|
// TestEveryCaseIsFullWidth — a message that came out short means a field was
|
|
// dropped; every case must be the one fixed length the layout implies.
|
|
func TestEveryCaseIsFullWidth(t *testing.T) {
|
|
c := Build()
|
|
for _, vc := range c.Vote.Cases {
|
|
raw, err := hex.DecodeString(vc.Message)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", vc.Name, err)
|
|
}
|
|
if len(raw) != c.Vote.Length {
|
|
t.Errorf("%s: message is %d bytes, the standard is %d", vc.Name, len(raw), c.Vote.Length)
|
|
}
|
|
if !strings.HasPrefix(vc.Message, hex.EncodeToString([]byte(c.Vote.Tag))) {
|
|
t.Errorf("%s: message does not open with the domain tag", vc.Name)
|
|
}
|
|
}
|
|
}
|