mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
ProcessVote(ctx, blockID, accept) did not take the voter, so the α predicate
could not tell one validator answering four times from four validators
answering once. block.acceptVotes was a bare counter incremented per arrival.
With K=5/α=4 one validator replaying its own valid signed vote drove the tally
to 4 and the block was accepted — measured, not theorised:
before: one voter, four arrivals -> {voteCount:1 acceptVotes:4 accepted:true}
after: one voter, four arrivals -> {voteCount:1 acceptVotes:1 accepted:false}
No malice needed: polls are re-solicited and peers do answer twice.
- voter is now a REQUIRED parameter of ProcessVote (origin as a parameter, the
same shape the vote-signature gate needs). The 4 production call sites pass
vote.NodeID or t.nodeID (self-vote).
- acceptVoters/rejectVoters are SETS and acceptVotes()/rejectVotes() derive from
them, so a count can never drift from the identities it summarises.
- driver.RecordVote is gated on the α predicate. β counts consecutive polls that
EACH reached α; advancing it per vote collapses βα agreements to β. Latent
today (the Nova cert road decides first) — hardening, not a behaviour change.
- PendingBlock.VoteCount/RejectCount deduped per NodeID at the engine tally.
Tests drive the real handleVote path with real signatures, and each fix is
sabotage-controlled (reverting it fails the test).
TestRED_INT_DoubleCount_SelfLowStake explicitly relied on the double-count to
exercise the stake road ("self's auto-vote + node1's replays push acceptVotes
well past α=3"). Replays can no longer inflate the count at all, which is the
stronger property, so it now asserts that directly.
MEASURED AND NOT FIXED HERE: with K=5/α=4/β=2, VM.Accept fires at THREE distinct
signed accepts — the Nova cert quorum ⌊n/2⌋+1=3 decides below α. That is the
known nova-majority-cert-safety-hole (intersection 2α−n = 1, not > f=1),
reproduced mechanically for the first time. Owner-gated, fix order C→A→B.
Co-authored-by: Hanzo Dev <[email protected]>
281 lines
8.2 KiB
Go
281 lines
8.2 KiB
Go
package chain
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/luxfi/ids"
|
|
)
|
|
|
|
// nValidatorSampler returns n distinct validators including self.
|
|
type nValidatorSampler struct {
|
|
self ids.NodeID
|
|
peers []ids.NodeID
|
|
}
|
|
|
|
func makeValidators(n int) (ids.NodeID, *nValidatorSampler) {
|
|
self := ids.GenerateTestNodeID()
|
|
peers := make([]ids.NodeID, 0, n-1)
|
|
for i := 1; i < n; i++ {
|
|
peers = append(peers, ids.GenerateTestNodeID())
|
|
}
|
|
return self, &nValidatorSampler{self: self, peers: peers}
|
|
}
|
|
|
|
func (s *nValidatorSampler) Sample(_ ids.ID, k int) ([]ids.NodeID, error) {
|
|
all := append([]ids.NodeID{s.self}, s.peers...)
|
|
if k > len(all) {
|
|
k = len(all)
|
|
}
|
|
return all[:k], nil
|
|
}
|
|
|
|
func (s *nValidatorSampler) Count(_ ids.ID) int { return 1 + len(s.peers) }
|
|
|
|
// consensusConfig describes a test configuration.
|
|
type consensusConfig struct {
|
|
name string
|
|
k int // sample size
|
|
alpha int // acceptance threshold
|
|
totalValidators int // total validators in network
|
|
acceptVotes int // votes that accept
|
|
rejectVotes int // votes that reject
|
|
expectSelfVote bool // should self-voter fire
|
|
expectNetworkPoll bool // should send to network
|
|
}
|
|
|
|
// TestConsensusConfigurations tests various K/Alpha/validator configurations.
|
|
// Covers: 1/1, 1/2, 2/3, 3/5, 14/21 (67%), 15/20 (75%), 69/100 (69%).
|
|
func TestConsensusConfigurations(t *testing.T) {
|
|
tests := []consensusConfig{
|
|
// K=1: single-node --dev mode. Self-vote, no network poll.
|
|
{
|
|
name: "K=1 single-node (--dev mode)",
|
|
k: 1,
|
|
alpha: 1,
|
|
totalValidators: 1,
|
|
expectSelfVote: true,
|
|
expectNetworkPoll: false,
|
|
},
|
|
// K=2: minimum multi-node. 1 peer needed.
|
|
{
|
|
name: "K=2 two-node (1 peer)",
|
|
k: 2,
|
|
alpha: 2,
|
|
totalValidators: 2,
|
|
expectSelfVote: false,
|
|
expectNetworkPoll: true,
|
|
},
|
|
// K=3, Alpha=2: 2/3 threshold (standard small network).
|
|
{
|
|
name: "K=3 Alpha=2 (2/3 threshold)",
|
|
k: 3,
|
|
alpha: 2,
|
|
totalValidators: 3,
|
|
expectSelfVote: false,
|
|
expectNetworkPoll: true,
|
|
},
|
|
// K=5, Alpha=3: 3/5 threshold.
|
|
{
|
|
name: "K=5 Alpha=3 (3/5 threshold)",
|
|
k: 5,
|
|
alpha: 3,
|
|
totalValidators: 5,
|
|
expectSelfVote: false,
|
|
expectNetworkPoll: true,
|
|
},
|
|
// K=21, Alpha=14: 67% threshold (mainnet minimum).
|
|
{
|
|
name: "K=21 Alpha=14 (67% mainnet min)",
|
|
k: 21,
|
|
alpha: 14,
|
|
totalValidators: 21,
|
|
expectSelfVote: false,
|
|
expectNetworkPoll: true,
|
|
},
|
|
// K=20, Alpha=15: 75% threshold (production default).
|
|
{
|
|
name: "K=20 Alpha=15 (75% production)",
|
|
k: 20,
|
|
alpha: 15,
|
|
totalValidators: 20,
|
|
expectSelfVote: false,
|
|
expectNetworkPoll: true,
|
|
},
|
|
// K=100, Alpha=69: 69% threshold (large validator set).
|
|
{
|
|
name: "K=100 Alpha=69 (69% large set)",
|
|
k: 100,
|
|
alpha: 69,
|
|
totalValidators: 100,
|
|
expectSelfVote: false,
|
|
expectNetworkPoll: true,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
nodeID, sampler := makeValidators(tc.totalValidators)
|
|
gossiper := &testGossiper{}
|
|
|
|
// DECOMPLECTED: there is no single-node self-voter. A K==1 proposer polls NOBODY (no
|
|
// peers) and finalizes via the inline build-path finalizer; a K>1 proposer polls the
|
|
// network. RequestVotes never self-votes.
|
|
proposer := &gossiperProposer{
|
|
gossiper: gossiper,
|
|
chainID: ids.GenerateTestID(),
|
|
networkID: ids.GenerateTestID(),
|
|
validators: sampler,
|
|
nodeID: nodeID,
|
|
k: tc.k,
|
|
}
|
|
|
|
blockID := ids.GenerateTestID()
|
|
err := proposer.RequestVotes(context.Background(), VoteRequest{
|
|
BlockID: blockID,
|
|
BlockData: []byte("test-block"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RequestVotes failed: %v", err)
|
|
}
|
|
|
|
_ = tc.expectSelfVote // self-vote path removed; field retained as documentation only
|
|
if tc.expectNetworkPoll && gossiper.pushQueries == 0 {
|
|
t.Error("expected network poll but no PushQuery sent")
|
|
}
|
|
if !tc.expectNetworkPoll && gossiper.pushQueries > 0 {
|
|
t.Errorf("unexpected network poll: %d PushQuery(s) sent", gossiper.pushQueries)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestConsensusAcceptanceThresholds verifies that blocks are accepted only
|
|
// when the acceptance threshold (Alpha) is reached, for various K values.
|
|
func TestConsensusAcceptanceThresholds(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
k int
|
|
alpha int
|
|
beta int
|
|
votes int // number of accept votes
|
|
shouldFinal bool // should reach finality
|
|
}{
|
|
// K=1: 1 vote = accept.
|
|
{"K=1 1-vote accept", 1, 1, 1, 1, true},
|
|
// K=3: need 2 votes.
|
|
{"K=3 1-vote no-accept", 3, 2, 1, 1, false},
|
|
{"K=3 2-vote accept", 3, 2, 1, 2, true},
|
|
{"K=3 3-vote accept", 3, 2, 1, 3, true},
|
|
// K=5: need 3 votes.
|
|
{"K=5 2-vote no-accept", 5, 3, 1, 2, false},
|
|
{"K=5 3-vote accept", 5, 3, 1, 3, true},
|
|
// K=21: need 14 votes (67%).
|
|
{"K=21 13-vote no-accept", 21, 14, 1, 13, false},
|
|
{"K=21 14-vote accept", 21, 14, 1, 14, true},
|
|
// K=20: need 15 votes (75%).
|
|
{"K=20 14-vote no-accept", 20, 15, 1, 14, false},
|
|
{"K=20 15-vote accept", 20, 15, 1, 15, true},
|
|
// K=100: need 69 votes (69%).
|
|
{"K=100 68-vote no-accept", 100, 69, 1, 68, false},
|
|
{"K=100 69-vote accept", 100, 69, 1, 69, true},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
consensus := NewChainConsensus(tc.k, tc.alpha, tc.beta)
|
|
blockID := ids.GenerateTestID()
|
|
parentID := ids.GenerateTestID()
|
|
ctx := context.Background()
|
|
|
|
block := &Block{
|
|
id: blockID,
|
|
parentID: parentID,
|
|
height: 1,
|
|
}
|
|
|
|
if err := consensus.AddBlock(ctx, block); err != nil {
|
|
t.Fatalf("AddBlock failed: %v", err)
|
|
}
|
|
|
|
// Submit votes.
|
|
for i := 0; i < tc.votes; i++ {
|
|
if err := consensus.ProcessVote(ctx, blockID, ids.GenerateTestNodeID(), true); err != nil {
|
|
t.Fatalf("ProcessVote %d failed: %v", i, err)
|
|
}
|
|
}
|
|
|
|
// Poll to trigger finalization check.
|
|
if err := consensus.Poll(ctx, map[ids.ID]int{blockID: tc.votes}); err != nil {
|
|
t.Fatalf("Poll failed: %v", err)
|
|
}
|
|
|
|
accepted := consensus.IsAccepted(blockID)
|
|
if tc.shouldFinal && !accepted {
|
|
t.Errorf("expected block accepted with %d/%d votes (alpha=%d) but not accepted",
|
|
tc.votes, tc.k, tc.alpha)
|
|
}
|
|
if !tc.shouldFinal && accepted {
|
|
t.Errorf("block accepted with %d/%d votes (alpha=%d) but should NOT be accepted",
|
|
tc.votes, tc.k, tc.alpha)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestConsensusRejection verifies that blocks are rejected when reject
|
|
// votes reach the alpha threshold.
|
|
func TestConsensusRejection(t *testing.T) {
|
|
consensus := NewChainConsensus(5, 3, 1)
|
|
blockID := ids.GenerateTestID()
|
|
ctx := context.Background()
|
|
|
|
block := &Block{id: blockID, parentID: ids.GenerateTestID(), height: 1}
|
|
if err := consensus.AddBlock(ctx, block); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// 3 reject votes = alpha threshold.
|
|
for i := 0; i < 3; i++ {
|
|
consensus.ProcessVote(ctx, blockID, ids.GenerateTestNodeID(), false)
|
|
}
|
|
consensus.Poll(ctx, map[ids.ID]int{blockID: 0})
|
|
|
|
if consensus.IsAccepted(blockID) {
|
|
t.Error("block should NOT be accepted")
|
|
}
|
|
if !consensus.IsRejected(blockID) {
|
|
t.Error("block should be rejected with 3 reject votes (alpha=3)")
|
|
}
|
|
}
|
|
|
|
// TestSelfVoteOnlyForK1 ensures self-voting is ONLY created for K=1.
|
|
// This is critical — multi-node networks MUST use network consensus.
|
|
func TestSelfVoteOnlyForK1(t *testing.T) {
|
|
for _, k := range []int{2, 3, 5, 10, 20, 21, 100} {
|
|
t.Run("K="+string(rune('0'+k/10))+string(rune('0'+k%10)), func(t *testing.T) {
|
|
nodeID, sampler := makeValidators(k)
|
|
gossiper := &testGossiper{}
|
|
|
|
// DECOMPLECTED: no self-voter exists. A K>1 proposer polls the network.
|
|
proposer := &gossiperProposer{
|
|
gossiper: gossiper,
|
|
chainID: ids.GenerateTestID(),
|
|
networkID: ids.GenerateTestID(),
|
|
validators: sampler,
|
|
nodeID: nodeID,
|
|
k: k,
|
|
}
|
|
|
|
_ = proposer.RequestVotes(context.Background(), VoteRequest{
|
|
BlockID: ids.GenerateTestID(),
|
|
BlockData: []byte("block"),
|
|
})
|
|
|
|
if gossiper.pushQueries == 0 {
|
|
t.Fatalf("K=%d should send network queries", k)
|
|
}
|
|
})
|
|
}
|
|
}
|