consensus: permissionless launch readiness

BLOCKING fixes:
- Quasar: multi-validator finalization (require QThreshold sigs)
- Stake-weighted sampling (StakeWeightedCut replaces SimpleCut)
- FPC: configurable seed via DeriveEpochSeed (was hardcoded string)
- Slashing: equivocation detection (double-vote, double-sign, downtime)
- Prism: implement Propose/Vote (was nil-return stubs)

NON-BLOCKING fixes:
- Photon: Fisher-Yates shuffle in Emit() (was returning first N)
- Field: memoized isFullyFinalized (was O(n^2) DFS)
- Remove Snow/Avalanche references from production code
- Rename snowball_compat tests to threshold_compat
- Fix wave.New callers for (Wave, error) return

New: core/slashing package with Detector, Evidence, SlashingDB
All 26 packages pass. Zero failures.
This commit is contained in:
2026-03-02 23:59:00 -08:00
parent dccdb31da7
commit 838c8d5eef
29 changed files with 2483 additions and 285 deletions
+346
View File
@@ -0,0 +1,346 @@
// Copyright (C) 2025-2026, Lux Industries Inc All rights reserved.
// Package slashing provides equivocation detection and slashing evidence
// for Lux consensus. It detects double-voting, double-signing, and
// downtime violations. Actual stake deduction happens in the platformvm;
// this package provides detection and evidence only.
package slashing
import (
"errors"
"fmt"
"sync"
"time"
"github.com/luxfi/ids"
)
// EvidenceType classifies validator misbehavior.
type EvidenceType uint8
const (
// DoubleVote means a validator voted for two different blocks at the same height.
DoubleVote EvidenceType = iota + 1
// DoubleSign means a validator signed two different blocks at the same height.
DoubleSign
// Downtime means a validator missed too many consecutive blocks.
Downtime
)
func (e EvidenceType) String() string {
switch e {
case DoubleVote:
return "double_vote"
case DoubleSign:
return "double_sign"
case Downtime:
return "downtime"
default:
return "unknown"
}
}
// Evidence represents proof of validator misbehavior.
type Evidence struct {
Type EvidenceType
ValidatorID ids.NodeID
Height uint64
Timestamp time.Time
Proof []byte // serialized conflicting votes/blocks
}
// SlashRecord tracks cumulative slashing state for a validator.
type SlashRecord struct {
ValidatorID ids.NodeID
SlashCount uint32
JailedUntil time.Time // zero value means not jailed
Evidence []Evidence
}
// IsJailed returns true if the validator is currently jailed.
func (r *SlashRecord) IsJailed(now time.Time) bool {
return !r.JailedUntil.IsZero() && now.Before(r.JailedUntil)
}
// voteRecord tracks a single vote cast by a validator at a height.
type voteRecord struct {
BlockID ids.ID
Accept bool
}
// blockRecord tracks a block signed by a validator at a height.
type blockRecord struct {
BlockID ids.ID
BlockData []byte
}
var (
ErrAlreadySlashed = errors.New("evidence already recorded for this violation")
)
// Detector watches for equivocation in votes and block proposals.
type Detector struct {
mu sync.Mutex
// height -> validatorID -> first vote seen
votes map[uint64]map[ids.NodeID]voteRecord
// height -> validatorID -> first block seen
blocks map[uint64]map[ids.NodeID]blockRecord
// Downtime: validatorID -> bitmap of recent participation
// Each bit represents one slot in the window. 1 = participated, 0 = missed.
uptimeBits map[ids.NodeID]uint64
uptimeSlots map[ids.NodeID]int // slots recorded per validator (capped at uptimeWindow)
uptimeWindow int // number of slots to track (max 64)
// Downtime threshold: fraction of window that must be missed (0.0-1.0)
downtimeThreshold float64
}
// NewDetector creates an equivocation detector.
// uptimeWindow is the number of recent slots to track (max 64).
// downtimeThreshold is the fraction of missed slots that triggers evidence (e.g. 0.5 = 50%).
func NewDetector(uptimeWindow int, downtimeThreshold float64) *Detector {
if uptimeWindow <= 0 || uptimeWindow > 64 {
uptimeWindow = 64
}
if downtimeThreshold <= 0 || downtimeThreshold > 1.0 {
downtimeThreshold = 0.5
}
return &Detector{
votes: make(map[uint64]map[ids.NodeID]voteRecord),
blocks: make(map[uint64]map[ids.NodeID]blockRecord),
uptimeBits: make(map[ids.NodeID]uint64),
uptimeSlots: make(map[ids.NodeID]int),
uptimeWindow: uptimeWindow,
downtimeThreshold: downtimeThreshold,
}
}
// CheckVote checks a vote for double-voting. Returns evidence if equivocation detected, nil otherwise.
// A double-vote occurs when a validator votes for two different block IDs at the same height.
func (d *Detector) CheckVote(validatorID ids.NodeID, height uint64, blockID ids.ID, accept bool) *Evidence {
d.mu.Lock()
defer d.mu.Unlock()
heightVotes, exists := d.votes[height]
if !exists {
heightVotes = make(map[ids.NodeID]voteRecord)
d.votes[height] = heightVotes
}
prev, seen := heightVotes[validatorID]
if !seen {
heightVotes[validatorID] = voteRecord{BlockID: blockID, Accept: accept}
return nil
}
// Same block ID is not equivocation (duplicate vote)
if prev.BlockID == blockID {
return nil
}
// Different block at same height = equivocation
proof := fmt.Appendf(nil, "height=%d vote1=%s vote2=%s", height, prev.BlockID, blockID)
return &Evidence{
Type: DoubleVote,
ValidatorID: validatorID,
Height: height,
Timestamp: time.Now(),
Proof: proof,
}
}
// CheckBlock checks a block proposal for double-signing. Returns evidence if equivocation detected.
// A double-sign occurs when a validator proposes/signs two different blocks at the same height.
func (d *Detector) CheckBlock(validatorID ids.NodeID, height uint64, blockID ids.ID, blockData []byte) *Evidence {
d.mu.Lock()
defer d.mu.Unlock()
heightBlocks, exists := d.blocks[height]
if !exists {
heightBlocks = make(map[ids.NodeID]blockRecord)
d.blocks[height] = heightBlocks
}
prev, seen := heightBlocks[validatorID]
if !seen {
data := make([]byte, len(blockData))
copy(data, blockData)
heightBlocks[validatorID] = blockRecord{BlockID: blockID, BlockData: data}
return nil
}
// Same block is not equivocation
if prev.BlockID == blockID {
return nil
}
// Different block at same height = equivocation
proof := fmt.Appendf(nil, "height=%d block1=%s block2=%s", height, prev.BlockID, blockID)
return &Evidence{
Type: DoubleSign,
ValidatorID: validatorID,
Height: height,
Timestamp: time.Now(),
Proof: proof,
}
}
// RecordParticipation records whether a validator participated in a slot.
// Call this once per slot per validator. Returns downtime evidence if threshold exceeded.
// The detector does not trigger evidence until at least uptimeWindow slots have been recorded
// for a given validator, preventing false positives during warmup.
func (d *Detector) RecordParticipation(validatorID ids.NodeID, participated bool) *Evidence {
d.mu.Lock()
defer d.mu.Unlock()
bits := d.uptimeBits[validatorID]
// Shift left, new bit enters at position 0
bits = bits << 1
if participated {
bits |= 1
}
// Mask to window size
mask := uint64((1 << d.uptimeWindow) - 1)
bits &= mask
d.uptimeBits[validatorID] = bits
// Track how many slots we've seen (cap at window size)
slots := d.uptimeSlots[validatorID] + 1
if slots > d.uptimeWindow {
slots = d.uptimeWindow
}
d.uptimeSlots[validatorID] = slots
// Don't evaluate until the window is full
if slots < d.uptimeWindow {
return nil
}
// Count missed slots
present := popcount(bits, d.uptimeWindow)
missed := d.uptimeWindow - present
if float64(missed)/float64(d.uptimeWindow) > d.downtimeThreshold {
proof := fmt.Appendf(nil, "window=%d missed=%d present=%d threshold=%.2f",
d.uptimeWindow, missed, present, d.downtimeThreshold)
return &Evidence{
Type: Downtime,
ValidatorID: validatorID,
Height: 0, // downtime is not height-specific
Timestamp: time.Now(),
Proof: proof,
}
}
return nil
}
// PruneBelow removes vote/block records at heights strictly below the given height.
// Call periodically to bound memory usage.
func (d *Detector) PruneBelow(height uint64) {
d.mu.Lock()
defer d.mu.Unlock()
for h := range d.votes {
if h < height {
delete(d.votes, h)
}
}
for h := range d.blocks {
if h < height {
delete(d.blocks, h)
}
}
}
// popcount counts set bits in the lowest windowSize bits of v.
func popcount(v uint64, windowSize int) int {
// Mask to window
mask := uint64((1 << windowSize) - 1)
v &= mask
count := 0
for v != 0 {
count++
v &= v - 1 // clear lowest set bit
}
return count
}
// DB stores slashing records. In-memory for now; persistence is a future concern
// when this integrates with the platformvm.
type DB struct {
mu sync.RWMutex
records map[ids.NodeID]*SlashRecord
// Jail duration per slash. Doubles with each successive slash.
baseJailDuration time.Duration
}
// NewDB creates a slashing database with the given base jail duration.
// Each successive slash doubles the jail time.
func NewDB(baseJailDuration time.Duration) *DB {
return &DB{
records: make(map[ids.NodeID]*SlashRecord),
baseJailDuration: baseJailDuration,
}
}
// RecordEvidence stores evidence and updates the validator's slash record.
// Returns the updated record.
func (db *DB) RecordEvidence(ev Evidence) *SlashRecord {
db.mu.Lock()
defer db.mu.Unlock()
rec, exists := db.records[ev.ValidatorID]
if !exists {
rec = &SlashRecord{ValidatorID: ev.ValidatorID}
db.records[ev.ValidatorID] = rec
}
rec.Evidence = append(rec.Evidence, ev)
rec.SlashCount++
// Jail duration doubles with each slash: base * 2^(count-1)
jailDuration := db.baseJailDuration
for i := uint32(1); i < rec.SlashCount; i++ {
jailDuration *= 2
}
rec.JailedUntil = time.Now().Add(jailDuration)
return rec
}
// GetRecord returns the slash record for a validator, or nil if clean.
func (db *DB) GetRecord(validatorID ids.NodeID) *SlashRecord {
db.mu.RLock()
defer db.mu.RUnlock()
return db.records[validatorID]
}
// IsJailed returns true if the validator is currently jailed.
func (db *DB) IsJailed(validatorID ids.NodeID) bool {
db.mu.RLock()
defer db.mu.RUnlock()
rec, exists := db.records[validatorID]
if !exists {
return false
}
return rec.IsJailed(time.Now())
}
// GetAllRecords returns all slash records. The returned slice is a snapshot.
func (db *DB) GetAllRecords() []SlashRecord {
db.mu.RLock()
defer db.mu.RUnlock()
out := make([]SlashRecord, 0, len(db.records))
for _, rec := range db.records {
out = append(out, *rec)
}
return out
}
+386
View File
@@ -0,0 +1,386 @@
package slashing
import (
"testing"
"time"
"github.com/luxfi/ids"
)
func nodeID(b byte) ids.NodeID {
var id ids.NodeID
id[0] = b
return id
}
func blockID(b byte) ids.ID {
var id ids.ID
id[0] = b
return id
}
// --- Detector: DoubleVote ---
func TestCheckVote_NoDuplicate(t *testing.T) {
d := NewDetector(64, 0.5)
v := nodeID(1)
b := blockID(0xAA)
ev := d.CheckVote(v, 100, b, true)
if ev != nil {
t.Fatal("first vote should not produce evidence")
}
}
func TestCheckVote_SameBlockNotEquivocation(t *testing.T) {
d := NewDetector(64, 0.5)
v := nodeID(1)
b := blockID(0xAA)
d.CheckVote(v, 100, b, true)
ev := d.CheckVote(v, 100, b, true)
if ev != nil {
t.Fatal("duplicate vote for same block should not produce evidence")
}
}
func TestCheckVote_DifferentBlockEquivocation(t *testing.T) {
d := NewDetector(64, 0.5)
v := nodeID(1)
b1 := blockID(0xAA)
b2 := blockID(0xBB)
d.CheckVote(v, 100, b1, true)
ev := d.CheckVote(v, 100, b2, true)
if ev == nil {
t.Fatal("voting for different blocks at same height must produce evidence")
}
if ev.Type != DoubleVote {
t.Fatalf("expected DoubleVote, got %s", ev.Type)
}
if ev.ValidatorID != v {
t.Fatalf("wrong validator in evidence")
}
if ev.Height != 100 {
t.Fatalf("wrong height in evidence: %d", ev.Height)
}
if len(ev.Proof) == 0 {
t.Fatal("proof must not be empty")
}
}
func TestCheckVote_DifferentHeightsOK(t *testing.T) {
d := NewDetector(64, 0.5)
v := nodeID(1)
b1 := blockID(0xAA)
b2 := blockID(0xBB)
d.CheckVote(v, 100, b1, true)
ev := d.CheckVote(v, 101, b2, true) // different height is fine
if ev != nil {
t.Fatal("votes at different heights should not produce evidence")
}
}
func TestCheckVote_DifferentValidatorsOK(t *testing.T) {
d := NewDetector(64, 0.5)
v1 := nodeID(1)
v2 := nodeID(2)
b1 := blockID(0xAA)
b2 := blockID(0xBB)
d.CheckVote(v1, 100, b1, true)
ev := d.CheckVote(v2, 100, b2, true) // different validator is fine
if ev != nil {
t.Fatal("different validators voting for different blocks is not equivocation")
}
}
// --- Detector: DoubleSign ---
func TestCheckBlock_NoDuplicate(t *testing.T) {
d := NewDetector(64, 0.5)
v := nodeID(1)
b := blockID(0xAA)
ev := d.CheckBlock(v, 100, b, []byte("data"))
if ev != nil {
t.Fatal("first block should not produce evidence")
}
}
func TestCheckBlock_SameBlockNotEquivocation(t *testing.T) {
d := NewDetector(64, 0.5)
v := nodeID(1)
b := blockID(0xAA)
d.CheckBlock(v, 100, b, []byte("data"))
ev := d.CheckBlock(v, 100, b, []byte("data"))
if ev != nil {
t.Fatal("same block at same height should not produce evidence")
}
}
func TestCheckBlock_DifferentBlockEquivocation(t *testing.T) {
d := NewDetector(64, 0.5)
v := nodeID(1)
b1 := blockID(0xAA)
b2 := blockID(0xBB)
d.CheckBlock(v, 100, b1, []byte("data1"))
ev := d.CheckBlock(v, 100, b2, []byte("data2"))
if ev == nil {
t.Fatal("signing different blocks at same height must produce evidence")
}
if ev.Type != DoubleSign {
t.Fatalf("expected DoubleSign, got %s", ev.Type)
}
if ev.ValidatorID != v {
t.Fatal("wrong validator in evidence")
}
if ev.Height != 100 {
t.Fatalf("wrong height: %d", ev.Height)
}
}
// --- Detector: Downtime ---
func TestRecordParticipation_AllPresent(t *testing.T) {
d := NewDetector(10, 0.5)
v := nodeID(1)
// Fill the window with participation
for i := 0; i < 10; i++ {
ev := d.RecordParticipation(v, true)
if ev != nil {
t.Fatalf("full participation should not produce evidence at slot %d", i)
}
}
}
func TestRecordParticipation_AllMissed(t *testing.T) {
d := NewDetector(10, 0.5)
v := nodeID(1)
// Miss every slot. Window needs to fill (10 slots) before evidence triggers.
// Slots 0-8: warmup, no evidence. Slot 9: window full, 10/10 missed > 50%.
for i := 0; i < 9; i++ {
ev := d.RecordParticipation(v, false)
if ev != nil {
t.Fatalf("should not trigger during warmup at slot %d", i)
}
}
ev := d.RecordParticipation(v, false) // slot 10, window full
if ev == nil {
t.Fatal("missing all slots must produce downtime evidence once window is full")
}
if ev.Type != Downtime {
t.Fatalf("expected Downtime, got %s", ev.Type)
}
}
func TestRecordParticipation_ThresholdBoundary(t *testing.T) {
// Window=10, threshold=0.5 means >5 missed triggers evidence.
// Window must be full before evaluation.
// Case 1: 5 present then 5 missed = exactly 50%, should NOT trigger (> not >=)
d := NewDetector(10, 0.5)
v := nodeID(1)
for i := 0; i < 5; i++ {
d.RecordParticipation(v, true)
}
for i := 0; i < 5; i++ {
ev := d.RecordParticipation(v, false)
if ev != nil {
t.Fatal("exactly at threshold should not trigger evidence")
}
}
// Case 2: 4 present then 6 missed = 60% missed > 50%
d2 := NewDetector(10, 0.5)
for i := 0; i < 4; i++ {
d2.RecordParticipation(v, true)
}
var gotEvidence bool
for i := 0; i < 6; i++ {
ev := d2.RecordParticipation(v, false)
if ev != nil {
gotEvidence = true
}
}
if !gotEvidence {
t.Fatal("6/10 missed (60%) should exceed 50% threshold")
}
}
// --- Detector: Prune ---
func TestPruneBelow(t *testing.T) {
d := NewDetector(64, 0.5)
v := nodeID(1)
d.CheckVote(v, 100, blockID(0xAA), true)
d.CheckVote(v, 200, blockID(0xBB), true)
d.CheckBlock(v, 100, blockID(0xCC), []byte("x"))
d.CheckBlock(v, 200, blockID(0xDD), []byte("y"))
d.PruneBelow(150)
// Height 100 should be pruned
d.mu.Lock()
if _, exists := d.votes[100]; exists {
t.Error("height 100 votes should be pruned")
}
if _, exists := d.blocks[100]; exists {
t.Error("height 100 blocks should be pruned")
}
// Height 200 should remain
if _, exists := d.votes[200]; !exists {
t.Error("height 200 votes should remain")
}
if _, exists := d.blocks[200]; !exists {
t.Error("height 200 blocks should remain")
}
d.mu.Unlock()
}
// --- DB ---
func TestDB_RecordEvidence(t *testing.T) {
db := NewDB(1 * time.Hour)
v := nodeID(1)
ev := Evidence{
Type: DoubleVote,
ValidatorID: v,
Height: 100,
Timestamp: time.Now(),
Proof: []byte("proof"),
}
rec := db.RecordEvidence(ev)
if rec.SlashCount != 1 {
t.Fatalf("expected slash count 1, got %d", rec.SlashCount)
}
if rec.JailedUntil.IsZero() {
t.Fatal("validator should be jailed after slashing")
}
if !rec.IsJailed(time.Now()) {
t.Fatal("validator should be jailed now")
}
if len(rec.Evidence) != 1 {
t.Fatalf("expected 1 evidence, got %d", len(rec.Evidence))
}
}
func TestDB_JailDurationDoubles(t *testing.T) {
db := NewDB(1 * time.Hour)
v := nodeID(1)
now := time.Now()
ev := Evidence{Type: DoubleVote, ValidatorID: v, Height: 100, Timestamp: now, Proof: []byte("1")}
rec1 := db.RecordEvidence(ev)
jail1 := rec1.JailedUntil.Sub(now)
ev2 := Evidence{Type: DoubleVote, ValidatorID: v, Height: 200, Timestamp: now, Proof: []byte("2")}
rec2 := db.RecordEvidence(ev2)
jail2 := rec2.JailedUntil.Sub(now)
if rec2.SlashCount != 2 {
t.Fatalf("expected slash count 2, got %d", rec2.SlashCount)
}
// Second jail should be roughly 2x the first (modulo time.Now() drift)
// jail1 ~= 1h, jail2 ~= 2h from now
if jail2 < jail1 {
t.Fatalf("jail duration should increase: first=%v second=%v", jail1, jail2)
}
}
func TestDB_IsJailed(t *testing.T) {
db := NewDB(1 * time.Millisecond) // very short jail for testing
v := nodeID(1)
if db.IsJailed(v) {
t.Fatal("clean validator should not be jailed")
}
ev := Evidence{Type: DoubleSign, ValidatorID: v, Height: 50, Timestamp: time.Now(), Proof: []byte("x")}
db.RecordEvidence(ev)
if !db.IsJailed(v) {
t.Fatal("slashed validator should be jailed")
}
// Wait for jail to expire
time.Sleep(5 * time.Millisecond)
if db.IsJailed(v) {
t.Fatal("jail should have expired")
}
}
func TestDB_GetRecord_Clean(t *testing.T) {
db := NewDB(1 * time.Hour)
rec := db.GetRecord(nodeID(99))
if rec != nil {
t.Fatal("clean validator should have nil record")
}
}
func TestDB_GetAllRecords(t *testing.T) {
db := NewDB(1 * time.Hour)
v1 := nodeID(1)
v2 := nodeID(2)
db.RecordEvidence(Evidence{Type: DoubleVote, ValidatorID: v1, Proof: []byte("a")})
db.RecordEvidence(Evidence{Type: DoubleSign, ValidatorID: v2, Proof: []byte("b")})
recs := db.GetAllRecords()
if len(recs) != 2 {
t.Fatalf("expected 2 records, got %d", len(recs))
}
}
// --- EvidenceType String ---
func TestEvidenceType_String(t *testing.T) {
tests := []struct {
t EvidenceType
want string
}{
{DoubleVote, "double_vote"},
{DoubleSign, "double_sign"},
{Downtime, "downtime"},
{EvidenceType(99), "unknown"},
}
for _, tt := range tests {
if got := tt.t.String(); got != tt.want {
t.Errorf("EvidenceType(%d).String() = %q, want %q", tt.t, got, tt.want)
}
}
}
// --- popcount ---
func TestPopcount(t *testing.T) {
tests := []struct {
v uint64
window int
want int
}{
{0b1111, 4, 4},
{0b1010, 4, 2},
{0b0000, 4, 0},
{0b11111111, 8, 8},
{0xFF, 4, 4}, // only lower 4 bits: 0b1111
{0xFFFF, 10, 10}, // lower 10 bits all set
{0b1010101010, 10, 5},
}
for _, tt := range tests {
got := popcount(tt.v, tt.window)
if got != tt.want {
t.Errorf("popcount(0b%b, %d) = %d, want %d", tt.v, tt.window, got, tt.want)
}
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ type Block struct {
timestamp int64
data []byte
// Consensus state - using Lux consensus instead of Snowball
// Consensus state - Photon -> Wave -> Focus finality
luxConsensus *engine.LuxConsensus
accepted bool
rejected bool
+84 -11
View File
@@ -7,6 +7,7 @@ import (
"time"
"github.com/luxfi/consensus/config"
"github.com/luxfi/consensus/core/slashing"
"github.com/luxfi/consensus/engine"
"github.com/luxfi/consensus/engine/chain/block"
"github.com/luxfi/ids"
@@ -173,6 +174,14 @@ func WithEmitter(e BlockProposer) Option {
return WithProposer(e)
}
// WithSlashing enables equivocation detection and slashing evidence collection.
func WithSlashing(detector *slashing.Detector, db *slashing.DB) Option {
return func(t *Transitive) {
t.slashingDetector = detector
t.slashingDB = db
}
}
// WithVoteBuffers sets channel buffer sizes.
func WithVoteBuffers(requests, votes int) Option {
return func(t *Transitive) {
@@ -234,6 +243,10 @@ type Transitive struct {
blocksRejected uint64
votesSent uint64
votesReceived uint64
// Slashing: equivocation detection (optional, nil disables)
slashingDetector *slashing.Detector
slashingDB *slashing.DB
}
// New creates an engine with default parameters.
@@ -456,6 +469,42 @@ func (t *Transitive) AddBlock(ctx context.Context, blk *Block) error {
return t.consensus.AddBlock(ctx, blk)
}
// CheckBlockProposal checks a block proposal for double-signing.
// Call this when receiving a block from a remote validator before adding it to consensus.
// Returns the evidence if the proposer equivocated, nil otherwise.
func (t *Transitive) CheckBlockProposal(proposerID ids.NodeID, height uint64, blockID ids.ID, blockData []byte) *slashing.Evidence {
t.mu.RLock()
detector := t.slashingDetector
sdb := t.slashingDB
t.mu.RUnlock()
if detector == nil {
return nil
}
// Reject proposals from jailed validators
if sdb != nil && sdb.IsJailed(proposerID) {
return &slashing.Evidence{
Type: slashing.DoubleSign,
ValidatorID: proposerID,
Height: height,
}
}
ev := detector.CheckBlock(proposerID, height, blockID, blockData)
if ev != nil && sdb != nil {
sdb.RecordEvidence(*ev)
}
return ev
}
// SlashingDB returns the slashing database, or nil if slashing is disabled.
func (t *Transitive) SlashingDB() *slashing.DB {
t.mu.RLock()
defer t.mu.RUnlock()
return t.slashingDB
}
// ProcessVote processes a vote.
func (t *Transitive) ProcessVote(ctx context.Context, blockID ids.ID, accept bool) error {
return t.consensus.ProcessVote(ctx, blockID, accept)
@@ -735,23 +784,47 @@ func (t *Transitive) handleVote(vote Vote) {
// to avoid nested lock (t.mu -> c.mu) deadlock.
t.mu.Lock()
t.votesReceived++
_, exists := t.pendingBlocks[vote.BlockID]
var voteCount int
if exists {
if vote.Accept {
t.pendingBlocks[vote.BlockID].VoteCount++
voteCount = t.pendingBlocks[vote.BlockID].VoteCount
} else {
t.pendingBlocks[vote.BlockID].RejectCount++
}
}
pending, exists := t.pendingBlocks[vote.BlockID]
detector := t.slashingDetector
sdb := t.slashingDB
ctx := t.ctx
t.mu.Unlock()
if !exists {
t.mu.Unlock()
return
}
var height uint64
if pending.ConsensusBlock != nil {
height = pending.ConsensusBlock.height
}
// Equivocation + jail checks before counting the vote.
// Detector and DB use their own locks; safe to call under t.mu
// since there is no lock ordering conflict (t.mu -> detector.mu is one direction only).
if detector != nil {
if ev := detector.CheckVote(vote.NodeID, height, vote.BlockID, vote.Accept); ev != nil {
if sdb != nil {
sdb.RecordEvidence(*ev)
}
t.mu.Unlock()
return
}
if sdb != nil && sdb.IsJailed(vote.NodeID) {
t.mu.Unlock()
return
}
}
var voteCount int
if vote.Accept {
pending.VoteCount++
voteCount = pending.VoteCount
} else {
pending.RejectCount++
}
t.mu.Unlock()
if err := t.consensus.ProcessVote(ctx, vote.BlockID, vote.Accept); err != nil {
return
}
+230
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/luxfi/consensus/config"
"github.com/luxfi/consensus/core/slashing"
"github.com/luxfi/consensus/engine/chain/block"
"github.com/luxfi/ids"
)
@@ -1164,3 +1165,232 @@ func TestBurstThroughput(t *testing.T) {
})
}
}
// --- Slashing integration tests ---
func TestSlashing_DoubleVoteDropped(t *testing.T) {
detector := slashing.NewDetector(64, 0.5)
db := slashing.NewDB(1 * time.Hour)
engine := New(WithSlashing(detector, db))
ctx := context.Background()
_ = engine.Start(ctx, true)
defer engine.Stop(ctx)
// Add a pending block at height 100
blkID := ids.ID{0xAA}
blk := &Block{id: blkID, parentID: ids.Empty, height: 100, timestamp: time.Now().Unix()}
_ = engine.AddBlock(ctx, blk)
engine.mu.Lock()
engine.pendingBlocks[blkID] = &PendingBlock{
ConsensusBlock: blk,
ProposedAt: time.Now(),
}
engine.mu.Unlock()
// Also add a second block at height 100
blkID2 := ids.ID{0xBB}
blk2 := &Block{id: blkID2, parentID: ids.Empty, height: 100, timestamp: time.Now().Unix()}
_ = engine.AddBlock(ctx, blk2)
engine.mu.Lock()
engine.pendingBlocks[blkID2] = &PendingBlock{
ConsensusBlock: blk2,
ProposedAt: time.Now(),
}
engine.mu.Unlock()
var nodeA ids.NodeID
nodeA[0] = 0x01
// First vote: nodeA votes for blkID at height 100 -- should succeed
engine.ReceiveVote(Vote{BlockID: blkID, NodeID: nodeA, Accept: true})
time.Sleep(50 * time.Millisecond) // let vote handler process
engine.mu.RLock()
pending1 := engine.pendingBlocks[blkID]
vote1Count := 0
if pending1 != nil {
vote1Count = pending1.VoteCount
}
engine.mu.RUnlock()
if vote1Count != 1 {
t.Fatalf("first vote should be counted, got VoteCount=%d", vote1Count)
}
// Second vote: nodeA votes for blkID2 at height 100 -- equivocation, should be dropped
engine.ReceiveVote(Vote{BlockID: blkID2, NodeID: nodeA, Accept: true})
time.Sleep(50 * time.Millisecond)
engine.mu.RLock()
pending2 := engine.pendingBlocks[blkID2]
vote2Count := 0
if pending2 != nil {
vote2Count = pending2.VoteCount
}
engine.mu.RUnlock()
if vote2Count != 0 {
t.Fatalf("equivocating vote should be dropped, got VoteCount=%d", vote2Count)
}
// Verify evidence was recorded
rec := db.GetRecord(nodeA)
if rec == nil {
t.Fatal("slash record should exist for equivocating validator")
}
if rec.SlashCount != 1 {
t.Fatalf("expected 1 slash, got %d", rec.SlashCount)
}
if !db.IsJailed(nodeA) {
t.Fatal("equivocating validator should be jailed")
}
}
func TestSlashing_JailedVotesDropped(t *testing.T) {
detector := slashing.NewDetector(64, 0.5)
db := slashing.NewDB(1 * time.Hour)
engine := New(WithSlashing(detector, db))
ctx := context.Background()
_ = engine.Start(ctx, true)
defer engine.Stop(ctx)
var nodeA ids.NodeID
nodeA[0] = 0x01
// Pre-jail nodeA
db.RecordEvidence(slashing.Evidence{
Type: slashing.DoubleVote,
ValidatorID: nodeA,
Height: 50,
Proof: []byte("test"),
})
// Add a pending block
blkID := ids.ID{0xCC}
blk := &Block{id: blkID, parentID: ids.Empty, height: 200, timestamp: time.Now().Unix()}
_ = engine.AddBlock(ctx, blk)
engine.mu.Lock()
engine.pendingBlocks[blkID] = &PendingBlock{
ConsensusBlock: blk,
ProposedAt: time.Now(),
}
engine.mu.Unlock()
// Jailed validator's vote should be dropped entirely (not counted)
engine.ReceiveVote(Vote{BlockID: blkID, NodeID: nodeA, Accept: true})
time.Sleep(50 * time.Millisecond)
engine.mu.RLock()
pending := engine.pendingBlocks[blkID]
count := 0
if pending != nil {
count = pending.VoteCount
}
engine.mu.RUnlock()
if count != 0 {
t.Fatalf("jailed validator vote should not be counted, got VoteCount=%d", count)
}
if !db.IsJailed(nodeA) {
t.Fatal("validator should be jailed")
}
}
func TestSlashing_CheckBlockProposal(t *testing.T) {
detector := slashing.NewDetector(64, 0.5)
db := slashing.NewDB(1 * time.Hour)
engine := New(WithSlashing(detector, db))
var nodeA ids.NodeID
nodeA[0] = 0x01
blkID1 := ids.ID{0xAA}
blkID2 := ids.ID{0xBB}
// First block from nodeA at height 100 -- should pass
ev := engine.CheckBlockProposal(nodeA, 100, blkID1, []byte("data1"))
if ev != nil {
t.Fatal("first block should not produce evidence")
}
// Second different block from nodeA at height 100 -- equivocation
ev = engine.CheckBlockProposal(nodeA, 100, blkID2, []byte("data2"))
if ev == nil {
t.Fatal("double-sign must produce evidence")
}
if ev.Type != slashing.DoubleSign {
t.Fatalf("expected DoubleSign, got %s", ev.Type)
}
// Verify evidence recorded and validator jailed
if !db.IsJailed(nodeA) {
t.Fatal("double-signing validator should be jailed")
}
// Further proposals from jailed validator should be rejected
blkID3 := ids.ID{0xCC}
ev = engine.CheckBlockProposal(nodeA, 200, blkID3, []byte("data3"))
if ev == nil {
t.Fatal("jailed validator proposals should be rejected")
}
}
func TestSlashing_Disabled(t *testing.T) {
// Without slashing option, everything works normally
engine := New()
ctx := context.Background()
_ = engine.Start(ctx, true)
defer engine.Stop(ctx)
blkID := ids.ID{0xAA}
blk := &Block{id: blkID, parentID: ids.Empty, height: 100, timestamp: time.Now().Unix()}
_ = engine.AddBlock(ctx, blk)
engine.mu.Lock()
engine.pendingBlocks[blkID] = &PendingBlock{
ConsensusBlock: blk,
ProposedAt: time.Now(),
}
engine.mu.Unlock()
var nodeA ids.NodeID
nodeA[0] = 0x01
engine.ReceiveVote(Vote{BlockID: blkID, NodeID: nodeA, Accept: true})
time.Sleep(50 * time.Millisecond)
engine.mu.RLock()
pending := engine.pendingBlocks[blkID]
count := 0
if pending != nil {
count = pending.VoteCount
}
engine.mu.RUnlock()
if count != 1 {
t.Fatalf("vote should be counted when slashing disabled, got %d", count)
}
// CheckBlockProposal should return nil when slashing disabled
ev := engine.CheckBlockProposal(nodeA, 100, blkID, []byte("data"))
if ev != nil {
t.Fatal("CheckBlockProposal should return nil when slashing disabled")
}
}
func TestSlashing_SlashingDBAccessor(t *testing.T) {
db := slashing.NewDB(1 * time.Hour)
engine := New(WithSlashing(slashing.NewDetector(64, 0.5), db))
got := engine.SlashingDB()
if got != db {
t.Fatal("SlashingDB() should return the configured DB")
}
engine2 := New()
if engine2.SlashingDB() != nil {
t.Fatal("SlashingDB() should return nil when slashing disabled")
}
}
+1 -1
View File
@@ -302,7 +302,7 @@ func (p *gossiperProposer) Propose(ctx context.Context, proposal BlockProposal)
// RequestVotes asks specific validators to vote on a block.
// If req.Validators is nil and we have a ValidatorSampler, sample k validators.
// This implements the core Snowball/Avalanche k-sampling behavior.
// This implements Lux k-sampling: select k validators and request votes.
//
// Single-node mode (K=1, only validator is self): the proposer delivers a
// self-vote via the SelfVoter callback instead of polling the network.
+71 -31
View File
@@ -5,6 +5,8 @@ package engine
import (
"context"
crand "crypto/rand"
"errors"
"sync"
"time"
@@ -16,6 +18,8 @@ import (
"github.com/luxfi/ids"
)
var ErrNoTransport = errors.New("no real transport configured: SimpleTransport cannot send vote requests over the network")
// LuxConsensus implements Lux's consensus protocol using Photon → Wave → Focus → Prism → Quasar
type LuxConsensus struct {
mu sync.RWMutex
@@ -40,8 +44,11 @@ type LuxConsensus struct {
consecutiveSuccesses map[ids.ID]uint32
}
// NewLuxConsensus creates a new Lux consensus instance
func NewLuxConsensus(k int, alpha int, beta int) *LuxConsensus {
// NewLuxConsensus creates a new Lux consensus instance with stake-weighted sampling.
// The cut parameter provides the peer sampling strategy (use prism.NewStakeWeightedCut
// for production, or prism.NewUniformCut for testing).
// The transport parameter handles network vote requests.
func NewLuxConsensus(k int, alpha int, beta int, opts ...Option) *LuxConsensus {
alphaRatio := float64(alpha) / float64(k)
// Ensure beta is non-negative for uint32 conversion
@@ -51,11 +58,32 @@ func NewLuxConsensus(k int, alpha int, beta int) *LuxConsensus {
// #nosec G115 -- beta is guaranteed >= 0 after check above
betaU32 := uint32(beta)
// Create a simple cut implementation for sampling
cut := &SimpleCut{k: k}
o := options{}
for _, opt := range opts {
opt(&o)
}
// Create a simple transport implementation
transport := &SimpleTransport{}
// Use provided cut or fall back to SimpleCut for backward compatibility.
var cut prism.Cut[ids.ID]
if o.cut != nil {
cut = o.cut
} else {
cut = &SimpleCut{k: k}
}
// Use provided transport or fall back to SimpleTransport.
var transport wave.Transport[ids.ID]
if o.transport != nil {
transport = o.transport
} else {
transport = &SimpleTransport{}
}
// Generate FPC seed from crypto/rand for this instance.
var fpcSeed [32]byte
if _, err := crand.Read(fpcSeed[:]); err != nil {
panic("failed to generate FPC seed: " + err.Error())
}
// Create Wave configuration with FPC enabled for dynamic thresholds
waveCfg := wave.Config{
@@ -63,13 +91,17 @@ func NewLuxConsensus(k int, alpha int, beta int) *LuxConsensus {
Alpha: alphaRatio,
Beta: betaU32,
RoundTO: 1 * time.Second,
EnableFPC: true, // Enable Fast Probabilistic Consensus
ThetaMin: 0.5, // FPC minimum threshold
ThetaMax: 0.8, // FPC maximum threshold
EnableFPC: true, // Enable Fast Probabilistic Consensus
ThetaMin: 0.5, // FPC minimum threshold
ThetaMax: 0.8, // FPC maximum threshold
FPCSeed: fpcSeed[:],
}
// Create consensus components
w := wave.New[ids.ID](waveCfg, cut, transport)
w, err := wave.New[ids.ID](waveCfg, cut, transport)
if err != nil {
panic("failed to create wave: " + err.Error())
}
f := focus.NewConfidence[ids.ID](beta, alphaRatio)
return &LuxConsensus{
@@ -86,6 +118,24 @@ func NewLuxConsensus(k int, alpha int, beta int) *LuxConsensus {
}
}
// Option configures LuxConsensus construction.
type Option func(*options)
type options struct {
cut prism.Cut[ids.ID]
transport wave.Transport[ids.ID]
}
// WithCut sets the peer sampling strategy.
func WithCut(cut prism.Cut[ids.ID]) Option {
return func(o *options) { o.cut = cut }
}
// WithTransport sets the network transport for vote requests.
func WithTransport(transport wave.Transport[ids.ID]) Option {
return func(o *options) { o.transport = transport }
}
// RecordVote records a vote for an item
func (lc *LuxConsensus) RecordVote(item ids.ID) {
lc.mu.Lock()
@@ -221,30 +271,20 @@ type SimpleTransport struct {
votes map[ids.ID]bool
}
func (t *SimpleTransport) RequestVotes(ctx context.Context, peers []types.NodeID, item ids.ID) <-chan wave.Photon[ids.ID] {
ch := make(chan wave.Photon[ids.ID], len(peers))
// Simulate vote collection
go func() {
defer close(ch)
for _, peer := range peers {
select {
case <-ctx.Done():
return
case ch <- wave.Photon[ids.ID]{
Item: item,
Prefer: true, // Simulate preference
Sender: peer,
Timestamp: time.Now(),
}:
}
}
}()
func (t *SimpleTransport) RequestVotes(_ context.Context, _ []types.NodeID, _ ids.ID) <-chan wave.Photon[ids.ID] {
// SimpleTransport has no real network connectivity.
// Return a closed empty channel so callers see zero votes rather than
// fabricated "Prefer: true" responses that bypass Sybil resistance.
ch := make(chan wave.Photon[ids.ID])
close(ch)
return ch
}
// Err returns ErrNoTransport to indicate this is a stub.
func (t *SimpleTransport) Err() error {
return ErrNoTransport
}
func (t *SimpleTransport) MakeLocalPhoton(item ids.ID, prefer bool) wave.Photon[ids.ID] {
return wave.Photon[ids.ID]{
Item: item,
+33 -56
View File
@@ -6,8 +6,8 @@ package engine
import (
"context"
"testing"
"time"
"github.com/luxfi/consensus/protocol/prism"
"github.com/luxfi/ids"
"github.com/stretchr/testify/require"
)
@@ -240,8 +240,9 @@ func TestSimpleCutLuminance(t *testing.T) {
require.Equal(float64(10), luminance.Lx)
}
// TestSimpleTransportRequestVotes tests the SimpleTransport.RequestVotes method
func TestSimpleTransportRequestVotes(t *testing.T) {
// TestSimpleTransportRequestVotesReturnsEmpty tests that SimpleTransport
// no longer fabricates votes -- it returns an empty closed channel.
func TestSimpleTransportRequestVotesReturnsEmpty(t *testing.T) {
require := require.New(t)
transport := &SimpleTransport{}
@@ -254,46 +255,17 @@ func TestSimpleTransportRequestVotes(t *testing.T) {
ctx := context.Background()
ch := transport.RequestVotes(ctx, peers, itemID)
// Collect votes
votes := make([]bool, 0)
for photon := range ch {
require.Equal(itemID, photon.Item)
require.True(photon.Prefer)
votes = append(votes, photon.Prefer)
}
require.Len(votes, 3)
}
// TestSimpleTransportRequestVotesContextCanceled tests context cancellation
func TestSimpleTransportRequestVotesContextCanceled(t *testing.T) {
require := require.New(t)
transport := &SimpleTransport{}
peers := make([]ids.NodeID, 100)
for i := 0; i < 100; i++ {
peers[i] = ids.GenerateTestNodeID()
}
itemID := ids.GenerateTestID()
ctx, cancel := context.WithCancel(context.Background())
ch := transport.RequestVotes(ctx, peers, itemID)
// Cancel after a short delay
go func() {
time.Sleep(10 * time.Millisecond)
cancel()
}()
// Collect votes until channel closes
count := 0
for range ch {
count++
}
require.Equal(0, count, "SimpleTransport must not fabricate votes")
}
// We may have received some votes before cancellation
require.True(count <= 100)
// TestSimpleTransportErr tests that SimpleTransport reports no-transport error.
func TestSimpleTransportErr(t *testing.T) {
transport := &SimpleTransport{}
require.ErrorIs(t, transport.Err(), ErrNoTransport)
}
// TestSimpleTransportMakeLocalPhoton tests the MakeLocalPhoton method
@@ -395,31 +367,36 @@ func TestLuxConsensusPollDecisionWithLowRatio(t *testing.T) {
require.NotNil(lc)
}
// TestSimpleTransportRequestVotesEarlyCancel tests early context cancellation
func TestSimpleTransportRequestVotesEarlyCancel(t *testing.T) {
// TestNewLuxConsensusWithStakeWeightedCut tests constructing with a real StakeWeightedCut.
func TestNewLuxConsensusWithStakeWeightedCut(t *testing.T) {
require := require.New(t)
transport := &SimpleTransport{}
peers := make([]ids.NodeID, 1000) // Many peers
for i := 0; i < 1000; i++ {
peers[i] = ids.GenerateTestNodeID()
validators := make([]prism.Validator, 20)
for i := range validators {
validators[i] = prism.Validator{
ID: ids.GenerateTestNodeID(),
Weight: uint64(100 + i),
}
}
itemID := ids.GenerateTestID()
cut, err := prism.NewStakeWeightedCut(validators)
require.NoError(err)
ctx, cancel := context.WithCancel(context.Background())
// Cancel immediately
cancel()
lc := NewLuxConsensus(20, 15, 10, WithCut(cut))
require.NotNil(lc)
require.Equal(20, lc.k)
ch := transport.RequestVotes(ctx, peers, itemID)
// Verify the cut is stake-weighted (not SimpleCut).
nodes := lc.prismCut.Sample(5)
require.Len(nodes, 5)
// Drain channel
count := 0
for range ch {
count++
// All sampled nodes should be from our validator set.
validIDs := make(map[ids.NodeID]bool)
for _, v := range validators {
validIDs[v.ID] = true
}
for _, n := range nodes {
require.True(validIDs[n], "sampled node not in validator set")
}
// Should get very few or no votes due to immediate cancellation
require.True(count < 1000)
}
// TestLuxConsensusPollFocusDecidedWithLowRatio tests the reject branch in Poll
+5 -1
View File
@@ -41,7 +41,11 @@ func demonstrateFPC() {
fmt.Println("━━━ Part 1: Fast Probabilistic Consensus (FPC) ━━━")
fmt.Println()
selector := fpc.NewSelector(0.5, 0.8, nil)
seed := fpc.DeriveEpochSeed(0, []byte("lux-demo"))
selector, err := fpc.NewSelector(0.5, 0.8, seed)
if err != nil {
panic(err)
}
k := 20
fmt.Println(" FPC prevents stuck states with dynamic thresholds")
+5 -2
View File
@@ -18,7 +18,9 @@ import (
// TestFPCDynamicThresholds tests FPC threshold selection
func TestFPCDynamicThresholds(t *testing.T) {
selector := fpc.NewSelector(0.5, 0.8, nil)
seed := fpc.DeriveEpochSeed(0, []byte("test-chain"))
selector, err := fpc.NewSelector(0.5, 0.8, seed)
require.NoError(t, err)
k := 20
// Test determinism: same round = same threshold
@@ -325,7 +327,8 @@ func BenchmarkQuasarFullProtocol(b *testing.B) {
// BenchmarkFPCThresholdSelection benchmarks FPC performance
func BenchmarkFPCThresholdSelection(b *testing.B) {
selector := fpc.NewSelector(0.5, 0.8, nil)
seed := fpc.DeriveEpochSeed(0, []byte("bench-chain"))
selector, _ := fpc.NewSelector(0.5, 0.8, seed)
k := 20
b.ResetTimer()
+33 -14
View File
@@ -44,13 +44,14 @@ type Config struct {
}
type Driver[V VID] struct {
cfg Config
wv wave.Wave[V]
cut prism.Cut[V]
str Store[V]
prop Proposer[V]
com Committer[V]
committed []V // Track committed vertices in order
cfg Config
wv wave.Wave[V]
cut prism.Cut[V]
str Store[V]
prop Proposer[V]
com Committer[V]
committed []V // Track committed vertices in order
finalizedCache map[V]bool
}
func NewDriver[V VID](cfg Config, cut prism.Cut[V], tx wave.Transport[V], store Store[V], prop Proposer[V], com Committer[V]) *Driver[V] {
@@ -67,14 +68,16 @@ func NewDriver[V VID](cfg Config, cut prism.Cut[V], tx wave.Transport[V], store
cfg.RoundTO = 250 * time.Millisecond
}
wv, _ := wave.New[V](wave.Config{K: cfg.PollSize, Alpha: cfg.Alpha, Beta: cfg.Beta, RoundTO: cfg.RoundTO}, cut, tx)
return &Driver[V]{
cfg: cfg,
wv: wave.New[V](wave.Config{K: cfg.PollSize, Alpha: cfg.Alpha, Beta: cfg.Beta, RoundTO: cfg.RoundTO}, cut, tx),
cut: cut,
str: store,
prop: prop,
com: com,
committed: make([]V, 0),
cfg: cfg,
wv: wv,
cut: cut,
str: store,
prop: prop,
com: com,
committed: make([]V, 0),
finalizedCache: make(map[V]bool),
}
}
@@ -124,26 +127,42 @@ func (d *Driver[V]) computeSafePrefix(frontier []V) []V {
}
// isFullyFinalized checks if a vertex and all its ancestors are finalized.
// Results are memoized in finalizedCache to avoid O(n^2) repeated DFS.
func (d *Driver[V]) isFullyFinalized(v V) bool {
if cached, ok := d.finalizedCache[v]; ok {
return cached
}
// Check this vertex is finalized
state, exists := d.wv.State(v)
if !exists || !state.Decided || state.Result != types.DecideAccept {
d.finalizedCache[v] = false
return false
}
// Check all parents are finalized (recursively)
block, exists := d.str.Get(v)
if !exists {
d.finalizedCache[v] = false
return false
}
for _, parent := range block.Parents() {
if !d.isFullyFinalized(parent) {
d.finalizedCache[v] = false
return false
}
}
d.finalizedCache[v] = true
return true
}
// ClearFinalizedCache resets the finalization memoization cache.
// Call this on epoch rotation so stale entries do not persist.
func (d *Driver[V]) ClearFinalizedCache() {
d.finalizedCache = make(map[V]bool)
}
// Start begins the Field engine operation
func (d *Driver[V]) Start(ctx context.Context) error {
// Field engine is stateless and starts immediately
+32 -7
View File
@@ -1,6 +1,11 @@
package photon
import "github.com/luxfi/consensus/core/types"
import (
"crypto/rand"
"encoding/binary"
"github.com/luxfi/consensus/core/types"
)
// Emitter emits consensus messages
type Emitter interface {
@@ -41,14 +46,34 @@ func NewUniformEmitter(nodes []types.NodeID, options EmitterOptions) *UniformEmi
}
}
// Emit emits a message to selected nodes
// Emit selects a uniform random subset of nodes using Fisher-Yates shuffle
// with crypto/rand (same algorithm as prism.UniformCut.Sample).
func (e *UniformEmitter) Emit(msg interface{}) ([]types.NodeID, error) {
// Select random subset of nodes
selected := make([]types.NodeID, 0, e.options.Fanout)
for i := 0; i < e.options.Fanout && i < len(e.nodes); i++ {
selected = append(selected, e.nodes[i])
n := len(e.nodes)
k := e.options.Fanout
if k >= n {
return e.nodes, nil
}
return selected, nil
// Shuffle a copy so we don't mutate the original slice order.
shuffled := make([]types.NodeID, n)
copy(shuffled, e.nodes)
for i := 0; i < k; i++ {
j := i + cryptoRandInt(n-i)
shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
}
return shuffled[:k], nil
}
// cryptoRandInt returns a cryptographically secure random integer in [0, max).
func cryptoRandInt(max int) int {
if max <= 0 {
return 0
}
var buf [8]byte
_, _ = rand.Read(buf[:])
return int(binary.LittleEndian.Uint64(buf[:]) % uint64(max))
}
// EmitTo emits a message to specific nodes
+149 -9
View File
@@ -2,10 +2,27 @@ package prism
import (
"context"
"crypto/sha256"
"errors"
"sync"
"github.com/luxfi/ids"
)
var (
// ErrNotInitialized is returned when the engine has not been initialized.
ErrNotInitialized = errors.New("prism: engine not initialized")
// ErrEmptyData is returned when proposing empty data.
ErrEmptyData = errors.New("prism: proposal data must not be empty")
// ErrProposalNotFound is returned when a proposal ID is not in the DAG.
ErrProposalNotFound = errors.New("prism: proposal not found")
// ErrAlreadyVoted is returned on duplicate vote.
ErrAlreadyVoted = errors.New("prism: already voted on proposal")
)
// Prism consensus interface
type Prism interface {
// Initialize prism consensus
@@ -40,33 +57,156 @@ type Proposal struct {
// Engine defines prism consensus engine
type Engine struct {
config *Config
mu sync.Mutex
config *Config
metrics *Metrics
height uint64
vertices map[ids.ID]*Proposal // DAG vertex store
frontier []ids.ID // current frontier (tips)
}
// New creates a new prism engine
func New(config *Config) *Engine {
return &Engine{
config: config,
config: config,
metrics: NewMetrics(),
vertices: make(map[ids.ID]*Proposal),
}
}
// Initialize initializes the engine
func (e *Engine) Initialize(ctx context.Context, config *Config) error {
func (e *Engine) Initialize(_ context.Context, config *Config) error {
e.mu.Lock()
defer e.mu.Unlock()
e.config = config
e.metrics = NewMetrics()
e.vertices = make(map[ids.ID]*Proposal)
e.frontier = nil
e.height = 0
return nil
}
// Propose proposes a value
func (e *Engine) Propose(ctx context.Context, data []byte) error {
// Propose adds a vertex to the DAG frontier and records it.
func (e *Engine) Propose(_ context.Context, data []byte) error {
if e.config == nil {
return ErrNotInitialized
}
if len(data) == 0 {
return ErrEmptyData
}
e.mu.Lock()
defer e.mu.Unlock()
// Compute vertex ID from height + data
e.height++
id := vertexID(e.height, data)
proposal := &Proposal{
ID: id,
Height: e.height,
Data: data,
}
e.vertices[id] = proposal
e.frontier = append(e.frontier, id)
e.metrics.RecordVertexCreated()
e.metrics.UpdateDAGStats(uint64(len(e.frontier)), e.height)
e.metrics.SetPendingVertices(uint64(len(e.frontier)))
return nil
}
// Vote votes on a proposal
func (e *Engine) Vote(ctx context.Context, proposalID ids.ID, accept bool) error {
// Vote processes a vote on a proposal. When the vote count reaches
// AlphaConfidence the proposal is finalized and removed from the frontier.
func (e *Engine) Vote(_ context.Context, proposalID ids.ID, accept bool) error {
if e.config == nil {
return ErrNotInitialized
}
e.mu.Lock()
defer e.mu.Unlock()
proposal, ok := e.vertices[proposalID]
if !ok {
return ErrProposalNotFound
}
if proposal.Accepted {
return ErrAlreadyVoted
}
if accept {
proposal.Votes++
}
// Check finalization threshold
if proposal.Votes >= e.config.AlphaConfidence {
proposal.Accepted = true
e.metrics.RecordVertexFinalized()
// Remove from frontier
e.frontier = removeFrontier(e.frontier, proposalID)
e.metrics.SetPendingVertices(uint64(len(e.frontier)))
e.metrics.UpdateDAGStats(uint64(len(e.frontier)), e.height)
}
return nil
}
// GetProposal gets a proposal
func (e *Engine) GetProposal(ctx context.Context, proposalID ids.ID) (Proposal, error) {
return Proposal{}, nil
func (e *Engine) GetProposal(_ context.Context, proposalID ids.ID) (Proposal, error) {
if e.config == nil {
return Proposal{}, ErrNotInitialized
}
e.mu.Lock()
defer e.mu.Unlock()
proposal, ok := e.vertices[proposalID]
if !ok {
return Proposal{}, ErrProposalNotFound
}
return *proposal, nil
}
// Frontier returns the current DAG frontier (tip vertices).
func (e *Engine) Frontier() []ids.ID {
e.mu.Lock()
defer e.mu.Unlock()
out := make([]ids.ID, len(e.frontier))
copy(out, e.frontier)
return out
}
// vertexID deterministically derives an ID from height and data.
func vertexID(height uint64, data []byte) ids.ID {
h := sha256.New()
var buf [8]byte
buf[0] = byte(height >> 56)
buf[1] = byte(height >> 48)
buf[2] = byte(height >> 40)
buf[3] = byte(height >> 32)
buf[4] = byte(height >> 24)
buf[5] = byte(height >> 16)
buf[6] = byte(height >> 8)
buf[7] = byte(height)
h.Write(buf[:])
h.Write(data)
hash := h.Sum(nil)
var id ids.ID
copy(id[:], hash)
return id
}
// removeFrontier removes a single ID from the frontier slice.
func removeFrontier(frontier []ids.ID, id ids.ID) []ids.ID {
for i, fid := range frontier {
if fid == id {
return append(frontier[:i], frontier[i+1:]...)
}
}
return frontier
}
+239
View File
@@ -0,0 +1,239 @@
package prism
import (
"context"
"testing"
"github.com/luxfi/ids"
)
func TestProposeAddsVertex(t *testing.T) {
cfg := DefaultConfig
e := New(&cfg)
ctx := context.Background()
err := e.Propose(ctx, []byte("tx-1"))
if err != nil {
t.Fatalf("Propose failed: %v", err)
}
frontier := e.Frontier()
if len(frontier) != 1 {
t.Fatalf("Expected 1 frontier vertex, got %d", len(frontier))
}
p, err := e.GetProposal(ctx, frontier[0])
if err != nil {
t.Fatalf("GetProposal failed: %v", err)
}
if string(p.Data) != "tx-1" {
t.Errorf("Expected data 'tx-1', got %q", string(p.Data))
}
if p.Height != 1 {
t.Errorf("Expected height 1, got %d", p.Height)
}
if p.Accepted {
t.Error("Proposal should not be accepted before votes")
}
}
func TestProposeEmptyDataRejected(t *testing.T) {
cfg := DefaultConfig
e := New(&cfg)
err := e.Propose(context.Background(), nil)
if err != ErrEmptyData {
t.Fatalf("Expected ErrEmptyData, got %v", err)
}
err = e.Propose(context.Background(), []byte{})
if err != ErrEmptyData {
t.Fatalf("Expected ErrEmptyData for empty slice, got %v", err)
}
}
func TestProposeNotInitialized(t *testing.T) {
e := &Engine{}
err := e.Propose(context.Background(), []byte("data"))
if err != ErrNotInitialized {
t.Fatalf("Expected ErrNotInitialized, got %v", err)
}
}
func TestVoteFinalizesProposal(t *testing.T) {
cfg := DefaultConfig
cfg.AlphaConfidence = 3
e := New(&cfg)
ctx := context.Background()
if err := e.Propose(ctx, []byte("tx-1")); err != nil {
t.Fatal(err)
}
frontier := e.Frontier()
id := frontier[0]
// Vote below threshold
for i := 0; i < 2; i++ {
if err := e.Vote(ctx, id, true); err != nil {
t.Fatal(err)
}
}
p, _ := e.GetProposal(ctx, id)
if p.Accepted {
t.Error("Proposal should not be accepted with only 2 votes (threshold=3)")
}
if p.Votes != 2 {
t.Errorf("Expected 2 votes, got %d", p.Votes)
}
// Third vote should finalize
if err := e.Vote(ctx, id, true); err != nil {
t.Fatal(err)
}
p, _ = e.GetProposal(ctx, id)
if !p.Accepted {
t.Error("Proposal should be accepted after 3 votes (threshold=3)")
}
// Frontier should be empty now
if len(e.Frontier()) != 0 {
t.Errorf("Frontier should be empty after finalization, got %d", len(e.Frontier()))
}
}
func TestVoteRejectDoesNotCount(t *testing.T) {
cfg := DefaultConfig
cfg.AlphaConfidence = 2
e := New(&cfg)
ctx := context.Background()
if err := e.Propose(ctx, []byte("tx-1")); err != nil {
t.Fatal(err)
}
id := e.Frontier()[0]
// Reject vote should not increment
if err := e.Vote(ctx, id, false); err != nil {
t.Fatal(err)
}
p, _ := e.GetProposal(ctx, id)
if p.Votes != 0 {
t.Errorf("Reject vote should not increment, got %d", p.Votes)
}
}
func TestVoteProposalNotFound(t *testing.T) {
cfg := DefaultConfig
e := New(&cfg)
err := e.Vote(context.Background(), ids.Empty, true)
if err != ErrProposalNotFound {
t.Fatalf("Expected ErrProposalNotFound, got %v", err)
}
}
func TestVoteAlreadyFinalized(t *testing.T) {
cfg := DefaultConfig
cfg.AlphaConfidence = 1
e := New(&cfg)
ctx := context.Background()
if err := e.Propose(ctx, []byte("tx-1")); err != nil {
t.Fatal(err)
}
id := e.Frontier()[0]
// First vote finalizes
if err := e.Vote(ctx, id, true); err != nil {
t.Fatal(err)
}
// Second vote should return ErrAlreadyVoted
err := e.Vote(ctx, id, true)
if err != ErrAlreadyVoted {
t.Fatalf("Expected ErrAlreadyVoted, got %v", err)
}
}
func TestGetProposalNotFound(t *testing.T) {
cfg := DefaultConfig
e := New(&cfg)
_, err := e.GetProposal(context.Background(), ids.Empty)
if err != ErrProposalNotFound {
t.Fatalf("Expected ErrProposalNotFound, got %v", err)
}
}
func TestMultipleProposals(t *testing.T) {
cfg := DefaultConfig
cfg.AlphaConfidence = 1
e := New(&cfg)
ctx := context.Background()
for i := 0; i < 5; i++ {
if err := e.Propose(ctx, []byte{byte(i)}); err != nil {
t.Fatalf("Propose %d failed: %v", i, err)
}
}
frontier := e.Frontier()
if len(frontier) != 5 {
t.Fatalf("Expected 5 frontier vertices, got %d", len(frontier))
}
// Finalize first 3
for i := 0; i < 3; i++ {
if err := e.Vote(ctx, frontier[i], true); err != nil {
t.Fatalf("Vote %d failed: %v", i, err)
}
}
remaining := e.Frontier()
if len(remaining) != 2 {
t.Errorf("Expected 2 remaining frontier vertices, got %d", len(remaining))
}
snap := e.metrics.Snapshot()
if snap.VerticesCreated != 5 {
t.Errorf("Expected 5 vertices created, got %d", snap.VerticesCreated)
}
if snap.VerticesFinalized != 3 {
t.Errorf("Expected 3 vertices finalized, got %d", snap.VerticesFinalized)
}
}
func TestInitializeResetsState(t *testing.T) {
cfg := DefaultConfig
e := New(&cfg)
ctx := context.Background()
if err := e.Propose(ctx, []byte("tx-1")); err != nil {
t.Fatal(err)
}
// Re-initialize should clear state
cfg2 := DefaultConfig
cfg2.AlphaConfidence = 5
if err := e.Initialize(ctx, &cfg2); err != nil {
t.Fatal(err)
}
if len(e.Frontier()) != 0 {
t.Error("Frontier should be empty after Initialize")
}
if e.config.AlphaConfidence != 5 {
t.Errorf("Config should be updated, got AlphaConfidence=%d", e.config.AlphaConfidence)
}
}
+123
View File
@@ -0,0 +1,123 @@
// Copyright (C) 2019-2025, Lux Partners Limited All rights reserved.
// See the file LICENSE for licensing terms.
package prism
import (
"crypto/rand"
"encoding/binary"
"errors"
"github.com/luxfi/consensus/core/types"
)
var (
ErrNoValidators = errors.New("stake weighted cut: no validators with positive weight")
ErrInsufficientPeers = errors.New("stake weighted cut: fewer validators than requested sample size")
)
// Validator pairs a NodeID with its stake weight.
type Validator struct {
ID types.NodeID
Weight uint64
}
// StakeWeightedCut implements Cut[T] with stake-proportional sampling.
// Validators are selected with probability proportional to their stake weight
// using crypto/rand for cryptographic randomness.
type StakeWeightedCut struct {
validators []Validator
totalWeight uint64
}
// NewStakeWeightedCut creates a new stake-weighted cut.
// Zero-weight validators are silently excluded.
func NewStakeWeightedCut(validators []Validator) (*StakeWeightedCut, error) {
// Filter out zero-weight validators and compute total weight.
filtered := make([]Validator, 0, len(validators))
var total uint64
for _, v := range validators {
if v.Weight == 0 {
continue
}
filtered = append(filtered, v)
total += v.Weight
}
if len(filtered) == 0 {
return nil, ErrNoValidators
}
return &StakeWeightedCut{
validators: filtered,
totalWeight: total,
}, nil
}
// Sample returns k validators selected with probability proportional to stake.
// If there are fewer validators than k, all validators are returned.
// Uses weighted sampling without replacement.
func (c *StakeWeightedCut) Sample(k int) []types.NodeID {
n := len(c.validators)
if k <= 0 {
return nil
}
if k >= n {
result := make([]types.NodeID, n)
for i, v := range c.validators {
result[i] = v.ID
}
return result
}
// Weighted sampling without replacement.
// Copy validators so we can remove selected ones.
pool := make([]Validator, n)
copy(pool, c.validators)
remaining := c.totalWeight
result := make([]types.NodeID, 0, k)
for i := 0; i < k; i++ {
// Pick a random weight target in [0, remaining).
target := cryptoRandUint64(remaining)
// Walk cumulative weights to find the selected validator.
var cumulative uint64
for j := range pool {
cumulative += pool[j].Weight
if target < cumulative {
result = append(result, pool[j].ID)
remaining -= pool[j].Weight
// Remove selected validator by swapping with last.
pool[j] = pool[len(pool)-1]
pool = pool[:len(pool)-1]
break
}
}
}
return result
}
// Luminance implements Cut interface.
func (c *StakeWeightedCut) Luminance() Luminance {
n := len(c.validators)
lx := float64(n)
if n >= 100 {
lx = 500.0
} else if n >= 20 {
lx = 300.0
}
return Luminance{
ActivePeers: n,
TotalPeers: n,
Lx: lx,
}
}
// cryptoRandUint64 returns a cryptographically secure random uint64 in [0, max).
func cryptoRandUint64(max uint64) uint64 {
if max <= 1 {
return 0
}
var buf [8]byte
_, _ = rand.Read(buf[:])
return binary.LittleEndian.Uint64(buf[:]) % max
}
+149
View File
@@ -0,0 +1,149 @@
// Copyright (C) 2019-2025, Lux Partners Limited All rights reserved.
// See the file LICENSE for licensing terms.
package prism
import (
"testing"
"github.com/luxfi/ids"
"github.com/stretchr/testify/require"
)
func TestStakeWeightedCutNoValidators(t *testing.T) {
_, err := NewStakeWeightedCut(nil)
require.ErrorIs(t, err, ErrNoValidators)
_, err = NewStakeWeightedCut([]Validator{})
require.ErrorIs(t, err, ErrNoValidators)
}
func TestStakeWeightedCutZeroWeightFiltered(t *testing.T) {
_, err := NewStakeWeightedCut([]Validator{
{ID: ids.GenerateTestNodeID(), Weight: 0},
{ID: ids.GenerateTestNodeID(), Weight: 0},
})
require.ErrorIs(t, err, ErrNoValidators)
}
func TestStakeWeightedCutSampleAll(t *testing.T) {
require := require.New(t)
v1 := Validator{ID: ids.GenerateTestNodeID(), Weight: 100}
v2 := Validator{ID: ids.GenerateTestNodeID(), Weight: 200}
cut, err := NewStakeWeightedCut([]Validator{v1, v2})
require.NoError(err)
// k >= n returns all validators.
nodes := cut.Sample(5)
require.Len(nodes, 2)
nodes = cut.Sample(2)
require.Len(nodes, 2)
}
func TestStakeWeightedCutSampleKZero(t *testing.T) {
v1 := Validator{ID: ids.GenerateTestNodeID(), Weight: 100}
cut, err := NewStakeWeightedCut([]Validator{v1})
require.NoError(t, err)
nodes := cut.Sample(0)
require.Empty(t, nodes)
nodes = cut.Sample(-1)
require.Empty(t, nodes)
}
func TestStakeWeightedCutSampleNoDuplicates(t *testing.T) {
require := require.New(t)
validators := make([]Validator, 10)
for i := range validators {
validators[i] = Validator{
ID: ids.GenerateTestNodeID(),
Weight: uint64(i + 1),
}
}
cut, err := NewStakeWeightedCut(validators)
require.NoError(err)
// Sample 5 from 10 -- must have no duplicates.
for trial := 0; trial < 100; trial++ {
nodes := cut.Sample(5)
require.Len(nodes, 5)
seen := make(map[ids.NodeID]bool)
for _, n := range nodes {
require.False(seen[n], "duplicate node in sample")
seen[n] = true
}
}
}
func TestStakeWeightedCutStakeDistribution(t *testing.T) {
require := require.New(t)
// One node has 99% weight, others 1% each.
heavy := Validator{ID: ids.GenerateTestNodeID(), Weight: 9900}
validators := []Validator{heavy}
for i := 0; i < 99; i++ {
validators = append(validators, Validator{
ID: ids.GenerateTestNodeID(),
Weight: 1,
})
}
cut, err := NewStakeWeightedCut(validators)
require.NoError(err)
// Over many samples of k=1, the heavy node should appear most of the time.
heavyCount := 0
trials := 10000
for i := 0; i < trials; i++ {
nodes := cut.Sample(1)
require.Len(nodes, 1)
if nodes[0] == heavy.ID {
heavyCount++
}
}
// Heavy node has ~99% weight. Allow wide tolerance: should appear >90% of the time.
ratio := float64(heavyCount) / float64(trials)
require.Greater(ratio, 0.90, "heavy node should be selected proportional to weight, got ratio=%f", ratio)
}
func TestStakeWeightedCutLuminance(t *testing.T) {
require := require.New(t)
// Small network.
validators := make([]Validator, 5)
for i := range validators {
validators[i] = Validator{ID: ids.GenerateTestNodeID(), Weight: 10}
}
cut, err := NewStakeWeightedCut(validators)
require.NoError(err)
lum := cut.Luminance()
require.Equal(5, lum.ActivePeers)
require.Equal(5, lum.TotalPeers)
require.Equal(float64(5), lum.Lx)
// Medium network.
validators = make([]Validator, 25)
for i := range validators {
validators[i] = Validator{ID: ids.GenerateTestNodeID(), Weight: 10}
}
cut, err = NewStakeWeightedCut(validators)
require.NoError(err)
lum = cut.Luminance()
require.Equal(300.0, lum.Lx)
// Large network.
validators = make([]Validator, 100)
for i := range validators {
validators[i] = Validator{ID: ids.GenerateTestNodeID(), Weight: 10}
}
cut, err = NewStakeWeightedCut(validators)
require.NoError(err)
lum = cut.Luminance()
require.Equal(500.0, lum.Lx)
}
+65 -18
View File
@@ -36,6 +36,7 @@ type Quasar struct {
epochManager *EpochManager
// Quantum finality state - the singularity
pendingBlocks map[string]*QuantumBlock // blockHash -> awaiting threshold sigs
finalizedBlocks map[string]*QuantumBlock // blockHash -> finalized block
quantumHeight uint64
@@ -79,6 +80,7 @@ func NewQuasar(threshold int) (*Quasar, error) {
chainBuffers: make(map[string]chan *ChainBlock),
signer: s,
epochManager: NewEpochManager(threshold, 3), // Keep 3 epochs in history
pendingBlocks: make(map[string]*QuantumBlock),
finalizedBlocks: make(map[string]*QuantumBlock),
quantumHeight: 0,
registeredChains: make(map[string]bool),
@@ -191,6 +193,7 @@ func (q *Quasar) processBlock(block *ChainBlock) {
}
// processBlockWithContext applies quantum consensus to a single block, respecting context cancellation.
// The block is placed in pending state and only finalized when QThreshold signatures are collected.
func (q *Quasar) processBlockWithContext(ctx context.Context, block *ChainBlock) {
// Check context before acquiring lock
if ctx.Err() != nil {
@@ -208,35 +211,79 @@ func (q *Quasar) processBlockWithContext(ctx context.Context, block *ChainBlock)
// Create quantum hash combining block data
quantumHash := q.computeQuantumHash(block)
// Collect validator signatures (BLS + Ringtail parallel)
// Each validator signs with both BLS and Ringtail for quantum-safe finality.
// Multi-validator collection happens via RequestVotes in the transport layer.
signatures := make(map[string]*QuasarSig)
// Sign with local validator key
sig, err := q.signer.SignMessageWithContext(ctx, "validator1", []byte(quantumHash))
if err == nil {
signatures["validator1"] = sig
// Skip if already finalized or pending
if _, exists := q.finalizedBlocks[quantumHash]; exists {
return
}
// Check context after signing
if ctx.Err() != nil {
if _, exists := q.pendingBlocks[quantumHash]; exists {
return
}
// Create quantum block
// Create pending quantum block
qBlock := &QuantumBlock{
Height: q.quantumHeight + 1,
SourceBlocks: []*ChainBlock{block},
QuantumHash: quantumHash,
Timestamp: time.Now(),
ValidatorSigs: signatures,
ValidatorSigs: make(map[string]*QuasarSig),
}
// Store finalized block
q.finalizedBlocks[quantumHash] = qBlock
q.quantumHeight++
q.processedBlocks++
q.pendingBlocks[quantumHash] = qBlock
// Sign with local validator key (self-vote)
sig, err := q.signer.SignMessageWithContext(ctx, "validator1", []byte(quantumHash))
if err == nil {
q.addVoteLocked(quantumHash, "validator1", sig)
}
}
// ReceiveVote records a validator's signature for a pending block.
// When the threshold is met, the block is finalized.
// Returns true if the vote was accepted, false if the block is unknown or already finalized.
func (q *Quasar) ReceiveVote(quantumHash string, validatorID string, sig *QuasarSig) bool {
q.mu.Lock()
defer q.mu.Unlock()
return q.addVoteLocked(quantumHash, validatorID, sig)
}
// addVoteLocked adds a vote and finalizes the block if threshold is met.
// Caller must hold q.mu.
func (q *Quasar) addVoteLocked(quantumHash string, validatorID string, sig *QuasarSig) bool {
// Already finalized -- nothing to do
if _, exists := q.finalizedBlocks[quantumHash]; exists {
return false
}
qBlock, exists := q.pendingBlocks[quantumHash]
if !exists {
return false
}
// Verify the signature before accepting
if !q.signer.VerifyQuasarSig([]byte(quantumHash), sig) {
return false
}
// Record vote (dedup by validator ID)
qBlock.ValidatorSigs[validatorID] = sig
// Check threshold
if len(qBlock.ValidatorSigs) >= q.signer.threshold {
// Move from pending to finalized
delete(q.pendingBlocks, quantumHash)
q.finalizedBlocks[quantumHash] = qBlock
q.quantumHeight++
q.processedBlocks++
}
return true
}
// GetPendingCount returns the number of blocks awaiting threshold signatures.
func (q *Quasar) GetPendingCount() int {
q.mu.RLock()
defer q.mu.RUnlock()
return len(q.pendingBlocks)
}
// quantumFinalizer runs the quantum finalization process
+269 -2
View File
@@ -58,9 +58,21 @@ func TestQuasar(t *testing.T) {
}
qa.SubmitCChainBlock(cBlock)
// Wait for processing
// Wait for blocks to enter pending state (self-vote from validator1)
time.Sleep(100 * time.Millisecond)
// Supply second vote from validator2 to meet threshold=2
for _, block := range []*ChainBlock{pBlock, xBlock, cBlock} {
blockHash := qa.computeQuantumHash(block)
sig, err := qa.SignMessage("validator2", []byte(blockHash))
if err != nil {
t.Fatalf("Failed to sign block from validator2: %v", err)
}
if !qa.ReceiveVote(blockHash, "validator2", sig) {
t.Fatalf("Failed to receive vote for block %s", block.ChainName)
}
}
// Check metrics
processed, proofs := qa.GetMetrics()
if processed != 3 {
@@ -134,7 +146,7 @@ func TestQuantumEpochFinalization(t *testing.T) {
t.Fatalf("Failed to initialize validators: %v", err)
}
// Submit multiple blocks
// Submit multiple blocks and supply second vote to meet threshold
for i := 0; i < 5; i++ {
block := &ChainBlock{
ChainID: [32]byte{byte(i)},
@@ -145,6 +157,14 @@ func TestQuantumEpochFinalization(t *testing.T) {
Data: []byte("Block data"),
}
qa.processBlock(block)
// Second vote to meet threshold=2
blockHash := qa.computeQuantumHash(block)
sig, err := qa.SignMessage("validator2", []byte(blockHash))
if err != nil {
t.Fatalf("Failed to sign block from validator2: %v", err)
}
qa.ReceiveVote(blockHash, "validator2", sig)
}
// Finalize epoch
@@ -266,7 +286,13 @@ func TestContextCancellation(t *testing.T) {
}
qa.processBlock(block)
// Supply second vote to finalize (threshold=2)
blockHash := qa.computeQuantumHash(block)
sig, err := qa.SignMessage("validator2", []byte(blockHash))
if err != nil {
t.Fatalf("Failed to sign: %v", err)
}
qa.ReceiveVote(blockHash, "validator2", sig)
// Verify with cancelled context
ctx, cancel := context.WithCancel(context.Background())
@@ -318,3 +344,244 @@ func TestContextCancellation(t *testing.T) {
}
})
}
// TestSingleNodeCannotFinalizeWithThreshold verifies that a single validator's
// signature is insufficient to finalize a block when threshold > 1.
// Regression test for the quorum bypass bug where processBlock() finalized
// with only a local signature.
func TestSingleNodeCannotFinalizeWithThreshold(t *testing.T) {
qa, err := NewQuasar(2)
if err != nil {
t.Fatalf("Failed to create quasar: %v", err)
}
err = qa.InitializeValidators([]string{"validator1", "validator2", "validator3"})
if err != nil {
t.Fatalf("Failed to initialize validators: %v", err)
}
block := &ChainBlock{
ChainID: [32]byte{1},
ChainName: "P-Chain",
ID: [32]byte{0xAA},
Height: 999,
Timestamp: time.Now(),
Data: []byte("should not finalize with 1 sig"),
}
// processBlock self-signs with validator1 only -- threshold=2 not met
qa.processBlock(block)
blockHash := qa.computeQuantumHash(block)
// Block must NOT be finalized
if qa.VerifyQuantumFinality(blockHash) {
t.Fatal("SECURITY: block finalized with only 1 signature when threshold=2")
}
// Block should be pending
if qa.GetPendingCount() != 1 {
t.Fatalf("Expected 1 pending block, got %d", qa.GetPendingCount())
}
// Height must not have advanced
if qa.GetQuantumHeight() != 0 {
t.Fatalf("Expected height 0 (no finalization), got %d", qa.GetQuantumHeight())
}
}
// TestThresholdFinalizationRequiresQuorum verifies that blocks finalize
// only after the required number of distinct validator signatures arrive.
func TestThresholdFinalizationRequiresQuorum(t *testing.T) {
qa, err := NewQuasar(3)
if err != nil {
t.Fatalf("Failed to create quasar: %v", err)
}
err = qa.InitializeValidators([]string{"validator1", "validator2", "validator3", "validator4"})
if err != nil {
t.Fatalf("Failed to initialize validators: %v", err)
}
block := &ChainBlock{
ChainID: [32]byte{1},
ChainName: "P-Chain",
ID: [32]byte{0xBB},
Height: 100,
Timestamp: time.Now(),
}
// Self-vote: 1 of 3 needed
qa.processBlock(block)
blockHash := qa.computeQuantumHash(block)
if qa.VerifyQuantumFinality(blockHash) {
t.Fatal("Finalized with 1/3 signatures")
}
// Second vote: 2 of 3 needed
sig2, err := qa.SignMessage("validator2", []byte(blockHash))
if err != nil {
t.Fatalf("Failed to sign: %v", err)
}
qa.ReceiveVote(blockHash, "validator2", sig2)
if qa.VerifyQuantumFinality(blockHash) {
t.Fatal("Finalized with 2/3 signatures")
}
if qa.GetPendingCount() != 1 {
t.Fatal("Block should still be pending")
}
// Third vote: threshold met
sig3, err := qa.SignMessage("validator3", []byte(blockHash))
if err != nil {
t.Fatalf("Failed to sign: %v", err)
}
qa.ReceiveVote(blockHash, "validator3", sig3)
if !qa.VerifyQuantumFinality(blockHash) {
t.Fatal("Block should be finalized with 3/3 signatures")
}
if qa.GetPendingCount() != 0 {
t.Fatal("No blocks should be pending after finalization")
}
if qa.GetQuantumHeight() != 1 {
t.Fatalf("Expected height 1, got %d", qa.GetQuantumHeight())
}
}
// TestSingleNodeModeImmediateFinalization verifies that threshold=1 (single-node)
// still produces immediate finalization from the self-vote.
func TestSingleNodeModeImmediateFinalization(t *testing.T) {
qa, err := NewQuasar(1)
if err != nil {
t.Fatalf("Failed to create quasar: %v", err)
}
_, err = qa.AddValidator("validator1", 100)
if err != nil {
t.Fatalf("Failed to add validator: %v", err)
}
block := &ChainBlock{
ChainID: [32]byte{1},
ChainName: "P-Chain",
ID: [32]byte{0xCC},
Height: 1,
Timestamp: time.Now(),
}
qa.processBlock(block)
blockHash := qa.computeQuantumHash(block)
// Threshold=1: self-vote should finalize immediately
if !qa.VerifyQuantumFinality(blockHash) {
t.Fatal("Block should be finalized with threshold=1")
}
if qa.GetPendingCount() != 0 {
t.Fatal("No blocks should be pending")
}
if qa.GetQuantumHeight() != 1 {
t.Fatalf("Expected height 1, got %d", qa.GetQuantumHeight())
}
}
// TestDuplicateVoteDoesNotDoubleCount verifies that the same validator
// voting twice does not count as two distinct votes.
func TestDuplicateVoteDoesNotDoubleCount(t *testing.T) {
qa, err := NewQuasar(2)
if err != nil {
t.Fatalf("Failed to create quasar: %v", err)
}
err = qa.InitializeValidators([]string{"validator1", "validator2", "validator3"})
if err != nil {
t.Fatalf("Failed to initialize validators: %v", err)
}
block := &ChainBlock{
ChainID: [32]byte{1},
ChainName: "P-Chain",
ID: [32]byte{0xDD},
Height: 1,
Timestamp: time.Now(),
}
qa.processBlock(block)
blockHash := qa.computeQuantumHash(block)
// Send validator1's vote again (duplicate)
sig, err := qa.SignMessage("validator1", []byte(blockHash))
if err != nil {
t.Fatalf("Failed to sign: %v", err)
}
qa.ReceiveVote(blockHash, "validator1", sig)
// Should still be pending -- 1 unique validator, threshold=2
if qa.VerifyQuantumFinality(blockHash) {
t.Fatal("SECURITY: duplicate vote from same validator bypassed threshold")
}
if qa.GetPendingCount() != 1 {
t.Fatal("Block should still be pending")
}
}
// TestReceiveVoteForUnknownBlock verifies ReceiveVote returns false
// for blocks not in the pending set.
func TestReceiveVoteForUnknownBlock(t *testing.T) {
qa, err := NewQuasar(1)
if err != nil {
t.Fatalf("Failed to create quasar: %v", err)
}
_, err = qa.AddValidator("validator1", 100)
if err != nil {
t.Fatalf("Failed to add validator: %v", err)
}
sig, err := qa.SignMessage("validator1", []byte("nonexistent-hash"))
if err != nil {
t.Fatalf("Failed to sign: %v", err)
}
if qa.ReceiveVote("nonexistent-hash", "validator1", sig) {
t.Fatal("ReceiveVote should return false for unknown block")
}
}
// TestReceiveVoteForAlreadyFinalizedBlock verifies that votes for
// already-finalized blocks are rejected.
func TestReceiveVoteForAlreadyFinalizedBlock(t *testing.T) {
qa, err := NewQuasar(1)
if err != nil {
t.Fatalf("Failed to create quasar: %v", err)
}
_, err = qa.AddValidator("validator1", 100)
if err != nil {
t.Fatalf("Failed to add validator: %v", err)
}
block := &ChainBlock{
ChainID: [32]byte{1},
ChainName: "P-Chain",
ID: [32]byte{0xEE},
Height: 1,
Timestamp: time.Now(),
}
// Threshold=1: finalized immediately
qa.processBlock(block)
blockHash := qa.computeQuantumHash(block)
// Try to vote again after finalization
sig, err := qa.SignMessage("validator1", []byte(blockHash))
if err != nil {
t.Fatalf("Failed to sign: %v", err)
}
if qa.ReceiveVote(blockHash, "validator1", sig) {
t.Fatal("ReceiveVote should return false for already-finalized block")
}
}
+11 -2
View File
@@ -199,11 +199,20 @@ func TestQuantumSecurityForAllChains(t *testing.T) {
t.Fatalf("Failed to submit bridge block: %v", err)
}
// Wait for quantum finalization
// Wait for block to reach pending state (self-vote from validator1)
time.Sleep(200 * time.Millisecond)
// Verify bridge block has quantum finality
// Supply second vote to meet threshold=2
blockHash := q.computeQuantumHash(bridgeBlock)
sig, err := q.SignMessage("validator2", []byte(blockHash))
if err != nil {
t.Fatalf("Failed to sign bridge block from validator2: %v", err)
}
if !q.ReceiveVote(blockHash, "validator2", sig) {
t.Fatal("Failed to receive vote for bridge block")
}
// Verify bridge block has quantum finality
if !q.VerifyQuantumFinality(blockHash) {
t.Error("Bridge block does not have quantum finality")
}
@@ -1,8 +1,8 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//
// Snowball compatibility tests for Quasar - validates consensus equivalence
// between Snowball's tree-based voting and Quasar's quantum finality model.
// Threshold compatibility tests for Quasar - validates consensus equivalence
// between threshold tree-based voting and Quasar's quantum finality model.
package quasar
+2 -1
View File
@@ -65,8 +65,9 @@ func NewDriver[T ID](cfg Config, cut prism.Cut[T], tx Transport[T], src Source[T
cfg.MaxBatch = 64
}
wv, _ := wave.New[T](wave.Config{K: cfg.PollSize, Alpha: cfg.Alpha, Beta: cfg.Beta, RoundTO: cfg.RoundTO}, cut, tx)
return &Driver[T]{
wv: wave.New[T](wave.Config{K: cfg.PollSize, Alpha: cfg.Alpha, Beta: cfg.Beta, RoundTO: cfg.RoundTO}, cut, tx),
wv: wv,
cut: cut, tx: tx, src: src, out: out, cfg: cfg,
height: 0,
hasPreference: false,
+24 -17
View File
@@ -3,9 +3,26 @@ package fpc
import (
"crypto/sha256"
"encoding/binary"
"errors"
"math"
)
var (
// ErrEmptySeed is returned when a nil or empty seed is provided.
ErrEmptySeed = errors.New("fpc: seed must not be empty")
)
// DeriveEpochSeed produces a per-epoch seed from an epoch number and chain ID.
// seed = sha256(epoch_number || chain_id)
func DeriveEpochSeed(epochNumber uint64, chainID []byte) []byte {
h := sha256.New()
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], epochNumber)
h.Write(buf[:])
h.Write(chainID)
return h.Sum(nil)
}
// Selector provides phase-dependent threshold selection for FPC
type Selector struct {
thetaMin float64
@@ -13,22 +30,23 @@ type Selector struct {
seed []byte
}
// NewSelector creates a new FPC threshold selector
func NewSelector(thetaMin, thetaMax float64, seed []byte) *Selector {
// NewSelector creates a new FPC threshold selector.
// seed must be non-empty; use DeriveEpochSeed to produce one.
func NewSelector(thetaMin, thetaMax float64, seed []byte) (*Selector, error) {
if len(seed) == 0 {
return nil, ErrEmptySeed
}
if thetaMin <= 0 || thetaMin >= 1 {
thetaMin = 0.5
}
if thetaMax <= thetaMin || thetaMax > 1 {
thetaMax = 0.8
}
if seed == nil {
seed = []byte("lux-fpc-default-seed")
}
return &Selector{
thetaMin: thetaMin,
thetaMax: thetaMax,
seed: seed,
}
}, nil
}
// SelectThreshold picks θ ∈ [θ_min, θ_max] using PRF for phase
@@ -69,14 +87,3 @@ func (s *Selector) Theta(phase uint64) float64 {
func (s *Selector) Range() (min, max float64) {
return s.thetaMin, s.thetaMax
}
// DefaultSelector returns a selector with default parameters
func DefaultSelector() *Selector {
return NewSelector(0.5, 0.8, nil)
}
// SelectThreshold is a convenience function using default selector
func SelectThreshold(phase uint64, k int, thetaMin, thetaMax float64) int {
s := NewSelector(thetaMin, thetaMax, nil)
return s.SelectThreshold(phase, k)
}
+83 -34
View File
@@ -4,8 +4,23 @@ import (
"testing"
)
func TestNewSelectorRequiresSeed(t *testing.T) {
_, err := NewSelector(0.5, 0.8, nil)
if err != ErrEmptySeed {
t.Fatalf("Expected ErrEmptySeed for nil seed, got %v", err)
}
_, err = NewSelector(0.5, 0.8, []byte{})
if err != ErrEmptySeed {
t.Fatalf("Expected ErrEmptySeed for empty seed, got %v", err)
}
}
func TestNewSelector(t *testing.T) {
s := NewSelector(0.5, 0.8, []byte("test-seed"))
s, err := NewSelector(0.5, 0.8, []byte("test-seed"))
if err != nil {
t.Fatalf("NewSelector returned error: %v", err)
}
if s == nil {
t.Fatal("NewSelector returned nil")
}
@@ -19,23 +34,50 @@ func TestNewSelector(t *testing.T) {
}
}
func TestDefaultSelector(t *testing.T) {
s := DefaultSelector()
if s == nil {
t.Fatal("DefaultSelector returned nil")
func TestDeriveEpochSeed(t *testing.T) {
seed1 := DeriveEpochSeed(1, []byte("chain-A"))
seed2 := DeriveEpochSeed(2, []byte("chain-A"))
seed3 := DeriveEpochSeed(1, []byte("chain-B"))
if len(seed1) != 32 {
t.Fatalf("Expected 32-byte seed, got %d", len(seed1))
}
min, max := s.Range()
if min != 0.5 {
t.Errorf("Expected default min=0.5, got %f", min)
// Different epoch => different seed
if string(seed1) == string(seed2) {
t.Error("Different epochs must produce different seeds")
}
if max != 0.8 {
t.Errorf("Expected default max=0.8, got %f", max)
// Different chain => different seed
if string(seed1) == string(seed3) {
t.Error("Different chains must produce different seeds")
}
// Deterministic
seed1b := DeriveEpochSeed(1, []byte("chain-A"))
if string(seed1) != string(seed1b) {
t.Error("DeriveEpochSeed must be deterministic")
}
}
func TestDeriveEpochSeedUsableInSelector(t *testing.T) {
seed := DeriveEpochSeed(42, []byte("lux-mainnet"))
s, err := NewSelector(0.5, 0.8, seed)
if err != nil {
t.Fatalf("NewSelector with derived seed returned error: %v", err)
}
threshold := s.SelectThreshold(1, 100)
if threshold < 50 || threshold > 81 {
t.Errorf("Threshold %d outside expected range [50, 81]", threshold)
}
}
func TestSelectThreshold(t *testing.T) {
s := NewSelector(0.5, 0.8, []byte("test-seed"))
s, err := NewSelector(0.5, 0.8, []byte("test-seed"))
if err != nil {
t.Fatal(err)
}
k := 100
threshold := s.SelectThreshold(1, k)
@@ -50,7 +92,10 @@ func TestSelectThreshold(t *testing.T) {
}
func TestDeterministicThresholds(t *testing.T) {
s := NewSelector(0.5, 0.8, []byte("test-seed"))
s, err := NewSelector(0.5, 0.8, []byte("test-seed"))
if err != nil {
t.Fatal(err)
}
k := 100
phase := uint64(42)
@@ -65,7 +110,10 @@ func TestDeterministicThresholds(t *testing.T) {
}
func TestDifferentPhasesGiveDifferentThresholds(t *testing.T) {
s := NewSelector(0.5, 0.8, []byte("test-seed"))
s, err := NewSelector(0.5, 0.8, []byte("test-seed"))
if err != nil {
t.Fatal(err)
}
k := 100
thresholds := make(map[int]bool)
@@ -83,7 +131,10 @@ func TestDifferentPhasesGiveDifferentThresholds(t *testing.T) {
}
func TestThetaRange(t *testing.T) {
s := NewSelector(0.5, 0.8, []byte("test-seed"))
s, err := NewSelector(0.5, 0.8, []byte("test-seed"))
if err != nil {
t.Fatal(err)
}
// Test theta values for many phases
for phase := uint64(0); phase < 1000; phase++ {
@@ -96,8 +147,14 @@ func TestThetaRange(t *testing.T) {
}
func TestDifferentSeedsGiveDifferentResults(t *testing.T) {
s1 := NewSelector(0.5, 0.8, []byte("seed-1"))
s2 := NewSelector(0.5, 0.8, []byte("seed-2"))
s1, err := NewSelector(0.5, 0.8, []byte("seed-1"))
if err != nil {
t.Fatal(err)
}
s2, err := NewSelector(0.5, 0.8, []byte("seed-2"))
if err != nil {
t.Fatal(err)
}
k := 100
phase := uint64(42)
@@ -111,23 +168,12 @@ func TestDifferentSeedsGiveDifferentResults(t *testing.T) {
}
}
func TestConvenienceFunction(t *testing.T) {
k := 100
phase := uint64(1)
threshold := SelectThreshold(phase, k, 0.5, 0.8)
minThreshold := int(0.5 * float64(k))
maxThreshold := int(0.8*float64(k)) + 1
if threshold < minThreshold || threshold > maxThreshold {
t.Errorf("Threshold %d outside expected range [%d, %d]", threshold, minThreshold, maxThreshold)
}
}
func TestInvalidRanges(t *testing.T) {
// Test auto-correction of invalid ranges
s := NewSelector(-0.1, 1.5, []byte("test"))
s, err := NewSelector(-0.1, 1.5, []byte("test"))
if err != nil {
t.Fatal(err)
}
min, max := s.Range()
@@ -141,7 +187,10 @@ func TestInvalidRanges(t *testing.T) {
}
func TestPhaseCoverage(t *testing.T) {
s := NewSelector(0.5, 0.8, []byte("test-seed"))
s, err := NewSelector(0.5, 0.8, []byte("test-seed"))
if err != nil {
t.Fatal(err)
}
k := 100
// Test a wide range of phases to ensure no panics or errors
@@ -157,7 +206,7 @@ func TestPhaseCoverage(t *testing.T) {
}
func BenchmarkSelectThreshold(b *testing.B) {
s := NewSelector(0.5, 0.8, []byte("bench-seed"))
s, _ := NewSelector(0.5, 0.8, []byte("bench-seed"))
k := 100
b.ResetTimer()
@@ -167,7 +216,7 @@ func BenchmarkSelectThreshold(b *testing.B) {
}
func BenchmarkComputeTheta(b *testing.B) {
s := NewSelector(0.5, 0.8, []byte("bench-seed"))
s, _ := NewSelector(0.5, 0.8, []byte("bench-seed"))
b.ResetTimer()
for i := 0; i < b.N; i++ {
+63 -8
View File
@@ -7,11 +7,14 @@ import (
"github.com/luxfi/consensus/core/types"
"github.com/luxfi/consensus/protocol/prism"
"github.com/luxfi/consensus/protocol/wave/fpc"
"github.com/luxfi/ids"
)
// TestWaveWithFPCEnabled demonstrates FPC usage with dynamic thresholds
func TestWaveWithFPCEnabled(t *testing.T) {
seed := fpc.DeriveEpochSeed(1, []byte("test-chain"))
// Create config with FPC enabled
cfg := Config{
K: 100,
@@ -21,12 +24,16 @@ func TestWaveWithFPCEnabled(t *testing.T) {
EnableFPC: true,
ThetaMin: 0.5,
ThetaMax: 0.8,
FPCSeed: seed,
}
cut := &MockCut{k: cfg.K}
tx := &MockTransport{}
wave := New[ids.ID](cfg, cut, tx)
wave, err := New[ids.ID](cfg, cut, tx)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
// Verify FPC is enabled
if wave.fpcSelector == nil {
@@ -53,6 +60,26 @@ func TestWaveWithFPCEnabled(t *testing.T) {
}
}
// TestWaveWithFPCMissingSeed verifies that missing seed is rejected
func TestWaveWithFPCMissingSeed(t *testing.T) {
cfg := Config{
K: 100,
Alpha: 0.6,
Beta: 10,
RoundTO: 100 * time.Millisecond,
EnableFPC: true,
// FPCSeed intentionally omitted
}
cut := &MockCut{k: cfg.K}
tx := &MockTransport{}
_, err := New[ids.ID](cfg, cut, tx)
if err == nil {
t.Fatal("Expected error when FPCSeed is empty with EnableFPC=true")
}
}
// TestWaveWithFPCDisabled verifies backward compatibility without FPC
func TestWaveWithFPCDisabled(t *testing.T) {
// Create config with FPC disabled (default behavior)
@@ -67,7 +94,10 @@ func TestWaveWithFPCDisabled(t *testing.T) {
cut := &MockCut{k: cfg.K}
tx := &MockTransport{}
wave := New[ids.ID](cfg, cut, tx)
wave, err := New[ids.ID](cfg, cut, tx)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
// Verify FPC is NOT enabled
if wave.fpcSelector != nil {
@@ -88,6 +118,8 @@ func TestWaveWithFPCDisabled(t *testing.T) {
// TestFPCThresholdVariation demonstrates dynamic threshold changes per phase
func TestFPCThresholdVariation(t *testing.T) {
seed := fpc.DeriveEpochSeed(1, []byte("test-chain"))
cfg := Config{
K: 100,
Beta: 10,
@@ -95,12 +127,16 @@ func TestFPCThresholdVariation(t *testing.T) {
EnableFPC: true,
ThetaMin: 0.5,
ThetaMax: 0.8,
FPCSeed: seed,
}
cut := &MockCut{k: cfg.K}
tx := &MockTransport{}
wave := New[ids.ID](cfg, cut, tx)
wave, err := New[ids.ID](cfg, cut, tx)
if err != nil {
t.Fatal(err)
}
// Collect thresholds for different phases
thresholds := make(map[int]bool)
@@ -124,8 +160,10 @@ func TestFPCThresholdVariation(t *testing.T) {
t.Logf("FPC generated %d different threshold values across 100 phases", len(thresholds))
}
// TestFPCDeterminism verifies FPC produces same thresholds for same phases
// TestFPCDeterminism verifies FPC produces same thresholds for same seed+phase
func TestFPCDeterminism(t *testing.T) {
seed := fpc.DeriveEpochSeed(1, []byte("test-chain"))
cfg1 := Config{
K: 100,
Beta: 10,
@@ -133,6 +171,7 @@ func TestFPCDeterminism(t *testing.T) {
EnableFPC: true,
ThetaMin: 0.5,
ThetaMax: 0.8,
FPCSeed: seed,
}
cfg2 := Config{
@@ -142,13 +181,20 @@ func TestFPCDeterminism(t *testing.T) {
EnableFPC: true,
ThetaMin: 0.5,
ThetaMax: 0.8,
FPCSeed: seed,
}
cut := &MockCut{k: 100}
tx := &MockTransport{}
wave1 := New[ids.ID](cfg1, cut, tx)
wave2 := New[ids.ID](cfg2, cut, tx)
wave1, err := New[ids.ID](cfg1, cut, tx)
if err != nil {
t.Fatal(err)
}
wave2, err := New[ids.ID](cfg2, cut, tx)
if err != nil {
t.Fatal(err)
}
// Same phase should give same threshold
for phase := uint64(1); phase <= 50; phase++ {
@@ -165,6 +211,8 @@ func TestFPCDeterminism(t *testing.T) {
// TestFPCvsFixedAlpha compares FPC vs fixed alpha behavior
func TestFPCvsFixedAlpha(t *testing.T) {
seed := fpc.DeriveEpochSeed(1, []byte("test-chain"))
// FPC-enabled config
fpcCfg := Config{
K: 100,
@@ -173,6 +221,7 @@ func TestFPCvsFixedAlpha(t *testing.T) {
EnableFPC: true,
ThetaMin: 0.5,
ThetaMax: 0.8,
FPCSeed: seed,
}
// Fixed alpha config
@@ -187,8 +236,14 @@ func TestFPCvsFixedAlpha(t *testing.T) {
cut := &MockCut{k: 100}
tx := &MockTransport{}
fpcWave := New[ids.ID](fpcCfg, cut, tx)
fixedWave := New[ids.ID](fixedCfg, cut, tx)
fpcWave, err := New[ids.ID](fpcCfg, cut, tx)
if err != nil {
t.Fatal(err)
}
fixedWave, err := New[ids.ID](fixedCfg, cut, tx)
if err != nil {
t.Fatal(err)
}
// FPC should vary thresholds
fpcThresholds := make(map[int]bool)
@@ -1,8 +1,8 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//
// Snowball compatibility tests - validates that Wave consensus exhibits
// equivalent safety and liveness properties to Snowball binary voting.
// Threshold compatibility tests - validates that Wave consensus exhibits
// equivalent safety and liveness properties to threshold binary voting.
package wave
@@ -15,7 +15,7 @@ import (
"github.com/stretchr/testify/require"
)
// --- Test infrastructure for snowball-style voting simulation ---
// --- Test infrastructure for threshold-style voting simulation ---
// binaryChoice represents a binary voting choice (like snowball red/blue)
type binaryChoice int
@@ -100,10 +100,10 @@ func (sb *binarySnowball) RecordUnsuccessfulPoll() {
func (sb *binarySnowball) Preference() int { return sb.preference }
func (sb *binarySnowball) Finalized() bool { return sb.finalized }
// --- Snowball Binary Tests (ported from upstream) ---
// --- Threshold Binary Tests (ported from upstream) ---
// TestSnowballBinaryBasic tests basic binary snowball voting behavior
func TestSnowballBinaryBasic(t *testing.T) {
// TestThresholdBinaryBasic tests basic binary threshold voting behavior
func TestThresholdBinaryBasic(t *testing.T) {
require := require.New(t)
red := 0
@@ -135,8 +135,8 @@ func TestSnowballBinaryBasic(t *testing.T) {
require.True(sb.Finalized())
}
// TestSnowballBinaryRecordPreference tests preference-only polls
func TestSnowballBinaryRecordPreference(t *testing.T) {
// TestThresholdBinaryRecordPreference tests preference-only polls
func TestThresholdBinaryRecordPreference(t *testing.T) {
require := require.New(t)
red := 0
@@ -172,8 +172,8 @@ func TestSnowballBinaryRecordPreference(t *testing.T) {
require.True(sb.Finalized())
}
// TestSnowballBinaryUnsuccessfulPoll tests confidence reset on failed poll
func TestSnowballBinaryUnsuccessfulPoll(t *testing.T) {
// TestThresholdBinaryUnsuccessfulPoll tests confidence reset on failed poll
func TestThresholdBinaryUnsuccessfulPoll(t *testing.T) {
require := require.New(t)
red := 0
@@ -200,8 +200,8 @@ func TestSnowballBinaryUnsuccessfulPoll(t *testing.T) {
require.True(sb.Finalized())
}
// TestSnowballBinaryLockColor tests that finalized choice cannot change
func TestSnowballBinaryLockColor(t *testing.T) {
// TestThresholdBinaryLockColor tests that finalized choice cannot change
func TestThresholdBinaryLockColor(t *testing.T) {
require := require.New(t)
red := 0
@@ -227,11 +227,11 @@ func TestSnowballBinaryLockColor(t *testing.T) {
require.True(sb.Finalized())
}
// --- Wave-based Snowball simulation tests ---
// --- Wave-based threshold simulation tests ---
// waveSnowball wraps Wave to provide Snowball-like semantics
type waveSnowball struct {
wave Wave[string]
// waveThreshold wraps Wave to provide threshold-voting semantics
type waveThreshold struct {
wave *Wave[string]
itemID string
tx *deterministicTransport
cut *mockCut[string]
@@ -287,7 +287,7 @@ func (d *deterministicTransport) SetVotes(yesCount, noCount int) {
}
}
func newWaveSnowball(k int, alpha float64, beta uint32) *waveSnowball {
func newWaveThreshold(k int, alpha float64, beta uint32) *waveThreshold {
cfg := Config{
K: k,
Alpha: alpha,
@@ -297,26 +297,26 @@ func newWaveSnowball(k int, alpha float64, beta uint32) *waveSnowball {
cut := newMockCut[string](k * 2)
tx := newDeterministicTransport()
wave := New[string](cfg, cut, tx)
w, _ := New[string](cfg, cut, tx)
return &waveSnowball{
wave: wave,
return &waveThreshold{
wave: &w,
itemID: "test",
tx: tx,
cut: cut,
}
}
func (ws *waveSnowball) Poll(yesVotes, noVotes int) {
func (ws *waveThreshold) Poll(yesVotes, noVotes int) {
ws.tx.SetVotes(yesVotes, noVotes)
ws.wave.Tick(context.Background(), ws.itemID)
}
func (ws *waveSnowball) Preference() bool {
func (ws *waveThreshold) Preference() bool {
return ws.wave.Preference(ws.itemID)
}
func (ws *waveSnowball) Finalized() bool {
func (ws *waveThreshold) Finalized() bool {
state, exists := ws.wave.State(ws.itemID)
if !exists {
return false
@@ -324,7 +324,7 @@ func (ws *waveSnowball) Finalized() bool {
return state.Decided
}
func (ws *waveSnowball) Count() uint32 {
func (ws *waveThreshold) Count() uint32 {
state, exists := ws.wave.State(ws.itemID)
if !exists {
return 0
@@ -332,12 +332,12 @@ func (ws *waveSnowball) Count() uint32 {
return state.Count
}
// TestWaveSnowballEquivalence tests that Wave exhibits snowball-like behavior
func TestWaveSnowballEquivalence(t *testing.T) {
// TestWaveThresholdEquivalence tests that Wave exhibits snowball-like behavior
func TestWaveThresholdEquivalence(t *testing.T) {
require := require.New(t)
// K=5, Alpha=0.8 (threshold=4), Beta=3
ws := newWaveSnowball(5, 0.8, 3)
ws := newWaveThreshold(5, 0.8, 3)
require.False(ws.Finalized())
@@ -360,12 +360,12 @@ func TestWaveSnowballEquivalence(t *testing.T) {
require.True(ws.Finalized())
}
// TestWaveSnowballConfidenceReset tests confidence reset on preference switch
func TestWaveSnowballConfidenceReset(t *testing.T) {
// TestWaveThresholdConfidenceReset tests confidence reset on preference switch
func TestWaveThresholdConfidenceReset(t *testing.T) {
require := require.New(t)
// K=5, Alpha=0.8 (threshold=4), Beta=3
ws := newWaveSnowball(5, 0.8, 3)
ws := newWaveThreshold(5, 0.8, 3)
// Two yes polls
ws.Poll(5, 0)
@@ -385,12 +385,12 @@ func TestWaveSnowballConfidenceReset(t *testing.T) {
require.True(ws.Finalized())
}
// TestWaveSnowballSplitVote tests behavior with split votes (no threshold met)
func TestWaveSnowballSplitVote(t *testing.T) {
// TestWaveThresholdSplitVote tests behavior with split votes (no threshold met)
func TestWaveThresholdSplitVote(t *testing.T) {
require := require.New(t)
// K=10, Alpha=0.8 (threshold=8), Beta=3
ws := newWaveSnowball(10, 0.8, 3)
ws := newWaveThreshold(10, 0.8, 3)
// Build up some confidence
ws.Poll(10, 0)
@@ -403,12 +403,12 @@ func TestWaveSnowballSplitVote(t *testing.T) {
require.False(ws.Finalized())
}
// TestWaveSnowballDecisionPersistence tests that decisions are final
func TestWaveSnowballDecisionPersistence(t *testing.T) {
// TestWaveThresholdDecisionPersistence tests that decisions are final
func TestWaveThresholdDecisionPersistence(t *testing.T) {
require := require.New(t)
// K=5, Alpha=0.8, Beta=2
ws := newWaveSnowball(5, 0.8, 2)
ws := newWaveThreshold(5, 0.8, 2)
// Finalize with yes preference
ws.Poll(5, 0)
@@ -487,7 +487,7 @@ func validateConfig(cfg Config) bool {
return true
}
// --- N-ary voting simulation (ported from nnary_snowball_test.go) ---
// --- N-ary voting simulation (ported from nnary_threshold_test.go) ---
// narySnowball simulates N-ary snowball voting
type narySnowball struct {
@@ -556,8 +556,8 @@ func (ns *narySnowball) RecordUnsuccessfulPoll() {
func (ns *narySnowball) Preference() string { return ns.preference }
func (ns *narySnowball) Finalized() bool { return ns.finalized }
// TestNarySnowballBasic tests N-ary snowball with multiple choices
func TestNarySnowballBasic(t *testing.T) {
// TestThresholdNaryBasic tests N-ary snowball with multiple choices
func TestThresholdNaryBasic(t *testing.T) {
require := require.New(t)
red, blue, green := "red", "blue", "green"
@@ -604,8 +604,8 @@ func TestNarySnowballBasic(t *testing.T) {
require.True(ns.Finalized())
}
// TestNarySnowballVirtuous tests immediate finalization with single choice
func TestNarySnowballVirtuous(t *testing.T) {
// TestThresholdNaryVirtuous tests immediate finalization with single choice
func TestThresholdNaryVirtuous(t *testing.T) {
require := require.New(t)
alphaPreference, alphaConfidence := 1, 2
@@ -621,8 +621,8 @@ func TestNarySnowballVirtuous(t *testing.T) {
require.True(ns.Finalized())
}
// TestNarySnowballUnsuccessfulPoll tests confidence reset
func TestNarySnowballUnsuccessfulPoll(t *testing.T) {
// TestThresholdNaryUnsuccessfulPoll tests confidence reset
func TestThresholdNaryUnsuccessfulPoll(t *testing.T) {
require := require.New(t)
alphaPreference, alphaConfidence := 1, 2
@@ -661,7 +661,7 @@ type simNode struct {
nodeID int
}
// simNetwork simulates a network of snowball nodes
// simNetwork simulates a network of threshold voting nodes
type simNetwork struct {
nodes []*simNode
params struct {
+10 -4
View File
@@ -33,6 +33,7 @@ type Config struct {
EnableFPC bool // Enable Fast Probabilistic Consensus
ThetaMin float64 // FPC minimum threshold (default: 0.5)
ThetaMax float64 // FPC maximum threshold (default: 0.8)
FPCSeed []byte // FPC seed (required when EnableFPC=true); use fpc.DeriveEpochSeed
}
// WaveState represents the polling state of an item in wave consensus
@@ -58,8 +59,9 @@ type Wave[T comparable] struct {
prefs map[T]bool // current preferences
}
// New creates a new Wave instance
func New[T comparable](cfg Config, cut prism.Cut[T], tx Transport[T]) Wave[T] {
// New creates a new Wave instance.
// When cfg.EnableFPC is true, cfg.FPCSeed must be non-empty.
func New[T comparable](cfg Config, cut prism.Cut[T], tx Transport[T]) (Wave[T], error) {
// Initialize FPC selector if enabled
var fpcSel *fpc.Selector
if cfg.EnableFPC {
@@ -71,7 +73,11 @@ func New[T comparable](cfg Config, cut prism.Cut[T], tx Transport[T]) Wave[T] {
if thetaMax == 0 {
thetaMax = 0.8 // Default
}
fpcSel = fpc.NewSelector(thetaMin, thetaMax, nil)
var err error
fpcSel, err = fpc.NewSelector(thetaMin, thetaMax, cfg.FPCSeed)
if err != nil {
return Wave[T]{}, err
}
}
return Wave[T]{
@@ -82,7 +88,7 @@ func New[T comparable](cfg Config, cut prism.Cut[T], tx Transport[T]) Wave[T] {
phase: 0,
states: make(map[T]*WaveState),
prefs: make(map[T]bool),
}
}, nil
}
// Tick performs one round of sampling and threshold checking for an item
+19 -16
View File
@@ -10,6 +10,7 @@ import (
"github.com/luxfi/consensus/core/types"
"github.com/luxfi/consensus/protocol/prism"
"github.com/luxfi/consensus/protocol/wave/fpc"
"github.com/stretchr/testify/require"
)
@@ -106,7 +107,7 @@ func TestWaveBasicVoting(t *testing.T) {
cut := newMockCut[string](10)
tx := newMockTransport[string]()
wave := New[string](cfg, cut, tx)
wave, _ := New[string](cfg, cut, tx)
// Add unanimous votes for "tx1"
for i := 0; i < 5; i++ {
@@ -136,7 +137,7 @@ func TestWaveConfidenceBuilding(t *testing.T) {
cut := newMockCut[string](10)
tx := newMockTransport[string]()
wave := New[string](cfg, cut, tx)
wave, _ := New[string](cfg, cut, tx)
// Add unanimous votes
for i := 0; i < 5; i++ {
@@ -170,7 +171,7 @@ func TestWavePreferenceSwitch(t *testing.T) {
cut := newMockCut[string](10)
tx := newMockTransport[string]()
wave := New[string](cfg, cut, tx)
wave, _ := New[string](cfg, cut, tx)
ctx := context.Background()
@@ -210,7 +211,7 @@ func TestWaveSplitVote(t *testing.T) {
cut := newMockCut[string](15)
tx := newMockTransport[string]()
wave := New[string](cfg, cut, tx)
wave, _ := New[string](cfg, cut, tx)
// Split vote: 5 yes, 5 no (neither reaches threshold)
for i := 0; i < 5; i++ {
@@ -239,7 +240,7 @@ func TestWaveMultipleItems(t *testing.T) {
cut := newMockCut[string](10)
tx := newMockTransport[string]()
wave := New[string](cfg, cut, tx)
wave, _ := New[string](cfg, cut, tx)
// Add votes for multiple items
for i := 0; i < 5; i++ {
@@ -283,7 +284,7 @@ func TestWaveTimeout(t *testing.T) {
cut := newMockCut[string](10)
tx := newMockTransport[string]()
wave := New[string](cfg, cut, tx)
wave, _ := New[string](cfg, cut, tx)
// Don't add any votes (will timeout)
ctx := context.Background()
@@ -307,7 +308,7 @@ func TestWaveDecisionPersistence(t *testing.T) {
cut := newMockCut[string](10)
tx := newMockTransport[string]()
wave := New[string](cfg, cut, tx)
wave, _ := New[string](cfg, cut, tx)
// Add unanimous votes
for i := 0; i < 5; i++ {
@@ -351,7 +352,7 @@ func TestWaveHighThroughput(t *testing.T) {
cut := newMockCut[string](100)
tx := newMockTransport[string]()
wave := New[string](cfg, cut, tx)
wave, _ := New[string](cfg, cut, tx)
ctx := context.Background()
@@ -384,11 +385,12 @@ func TestWaveFPCWithCustomThresholds(t *testing.T) {
EnableFPC: true,
ThetaMin: 0.6,
ThetaMax: 0.9,
FPCSeed: fpc.DeriveEpochSeed(1, []byte("test")),
}
cut := newMockCut[string](10)
tx := newMockTransport[string]()
wave := New[string](cfg, cut, tx)
wave, _ := New[string](cfg, cut, tx)
// Add unanimous votes
for i := 0; i < 5; i++ {
@@ -422,11 +424,12 @@ func TestWaveFPCZeroThresholds(t *testing.T) {
EnableFPC: true,
ThetaMin: 0, // Should default to 0.5
ThetaMax: 0, // Should default to 0.8
FPCSeed: fpc.DeriveEpochSeed(1, []byte("test")),
}
cut := newMockCut[string](10)
tx := newMockTransport[string]()
wave := New[string](cfg, cut, tx)
wave, _ := New[string](cfg, cut, tx)
// Add unanimous votes
for i := 0; i < 5; i++ {
@@ -457,7 +460,7 @@ func TestWaveContextCancellation(t *testing.T) {
// Use slow transport that never sends votes
tx := newSlowMockTransport[string](10 * time.Second)
wave := New[string](cfg, cut, tx)
wave, _ := New[string](cfg, cut, tx)
// Cancel context quickly to trigger ctx.Done() path
ctx, cancel := context.WithCancel(context.Background())
@@ -489,7 +492,7 @@ func TestWaveRejectDecision(t *testing.T) {
cut := newMockCut[string](10)
tx := newMockTransport[string]()
wave := New[string](cfg, cut, tx)
wave, _ := New[string](cfg, cut, tx)
// Add unanimous NO votes
for i := 0; i < 5; i++ {
@@ -524,7 +527,7 @@ func TestWaveNoVotesTimeout(t *testing.T) {
cut := newMockCut[string](10)
tx := newMockTransport[string]()
wave := New[string](cfg, cut, tx)
wave, _ := New[string](cfg, cut, tx)
// No votes added - the mock returns an empty closed channel
// which times out, resulting in totalVotes=0 and early return
@@ -592,7 +595,7 @@ func TestWaveTimeoutPath(t *testing.T) {
// Use slow transport that delays 50ms (longer than 10ms timeout)
tx := newSlowMockTransport[string](50 * time.Millisecond)
wave := New[string](cfg, cut, tx)
wave, _ := New[string](cfg, cut, tx)
ctx := context.Background()
wave.Tick(ctx, "tx1")
@@ -616,7 +619,7 @@ func TestWaveStateNotExists(t *testing.T) {
cut := newMockCut[string](10)
tx := newMockTransport[string]()
wave := New[string](cfg, cut, tx)
wave, _ := New[string](cfg, cut, tx)
// Query state for item that was never processed
state, exists := wave.State("nonexistent")
@@ -637,7 +640,7 @@ func TestWavePreferenceNotExists(t *testing.T) {
cut := newMockCut[string](10)
tx := newMockTransport[string]()
wave := New[string](cfg, cut, tx)
wave, _ := New[string](cfg, cut, tx)
// Query preference for item that was never processed
pref := wave.Preference("nonexistent")
+3 -3
View File
@@ -100,9 +100,9 @@ type ByzantineTestBlock struct {
timestamp int64
}
// TestSnowballGovernance tests 55 honest vs 45 Byzantine nodes
// This is the critical governance scenario from upstream consensus
func TestSnowballGovernance(t *testing.T) {
// TestByzantineGovernance tests 55 honest vs 45 Byzantine nodes
// This is the critical governance scenario for Lux consensus
func TestByzantineGovernance(t *testing.T) {
const (
numHonest = 55
numByzantine = 45
+1 -1
View File
@@ -957,7 +957,7 @@ func TestNetworkResilience(t *testing.T) {
}
// adaptiveConsensusParams calculates optimal consensus parameters based on network size and colors
// Based on Avalanche consensus paper and network convergence theory:
// Based on Lux consensus parameters and network convergence theory:
// - K scales with network size (sample ~80% of network for robust convergence)
// - Alpha maintains supermajority threshold (~65-70% of K for Byzantine tolerance)
// - Beta scales logarithmically with colors (more rounds for more competing values)