mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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++ {
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user