Skip to content

Commit 5ae6635

Browse files
committed
fix n=1 degenerate case for localnet tests
1 parent ae3e857 commit 5ae6635

3 files changed

Lines changed: 132 additions & 18 deletions

File tree

hotstuff2.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -892,7 +892,11 @@ func (hs *HotStuff2[H]) propose() {
892892
hs.logger.Error("failed to create leader vote", zap.Error(err))
893893
return
894894
}
895-
hs.ctx.AddVote(leaderVote)
895+
voteCount := hs.ctx.AddVote(leaderVote)
896+
897+
// Check if leader's vote alone forms quorum (e.g., n=1 single-node setup).
898+
// This also handles the case where votes arrived before the proposal.
899+
hs.tryFormQCIfQuorum(view, block.Hash(), voteCount)
896900
}
897901

898902
// proposeWithNewViewQuorum creates a proposal after collecting 2f+1 NEWVIEWs.
@@ -976,7 +980,11 @@ func (hs *HotStuff2[H]) proposeWithNewViewQuorum(view uint32) {
976980
hs.logger.Error("failed to create leader vote", zap.Error(err))
977981
return
978982
}
979-
hs.ctx.AddVote(leaderVote)
983+
voteCount := hs.ctx.AddVote(leaderVote)
984+
985+
// Check if leader's vote alone forms quorum (e.g., n=1 single-node setup).
986+
// This also handles the case where votes arrived before the proposal.
987+
hs.tryFormQCIfQuorum(view, block.Hash(), voteCount)
980988
}
981989

982990
// qcViewOrZero returns the QC view or 0 if nil (helper for logging).

integration_test.go

Lines changed: 108 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -516,7 +516,11 @@ func TestIntegration_SevenNodes(t *testing.T) {
516516
sharedNetwork := NewSharedNetwork[TestHash](N)
517517

518518
nodes := make([]*HotStuff2[TestHash], N)
519-
committed := make([][]Block[TestHash], N)
519+
// Track committed blocks by height for each node to handle out-of-order commits
520+
committed := make([]map[uint32]Block[TestHash], N)
521+
for i := range N {
522+
committed[i] = make(map[uint32]Block[TestHash])
523+
}
520524
var commitMu sync.Mutex
521525

522526
for i := range N {
@@ -539,7 +543,7 @@ func TestIntegration_SevenNodes(t *testing.T) {
539543
idx := i
540544
onCommit := func(block Block[TestHash]) {
541545
commitMu.Lock()
542-
committed[idx] = append(committed[idx], block)
546+
committed[idx][block.Height()] = block
543547
commitMu.Unlock()
544548
}
545549

@@ -591,6 +595,7 @@ done:
591595
commitMu.Lock()
592596
defer commitMu.Unlock()
593597

598+
// Find minimum committed count
594599
minCommitted := len(committed[0])
595600
for i := 1; i < N; i++ {
596601
if len(committed[i]) < minCommitted {
@@ -601,13 +606,31 @@ done:
601606
t.Logf("All %d nodes committed at least %d blocks", N, minCommitted)
602607
assert.GreaterOrEqual(t, minCommitted, TARGET_BLOCKS)
603608

604-
// Verify chain consistency
605-
for i := 1; i < N; i++ {
606-
for j := range minCommitted {
607-
assert.True(t, committed[i][j].Hash().Equals(committed[0][j].Hash()),
608-
"node %d block %d should match node 0", i, j)
609+
// Find common heights across all nodes
610+
commonHeights := make([]uint32, 0)
611+
for height := range committed[0] {
612+
allHave := true
613+
for i := 1; i < N; i++ {
614+
if _, ok := committed[i][height]; !ok {
615+
allHave = false
616+
break
617+
}
618+
}
619+
if allHave {
620+
commonHeights = append(commonHeights, height)
609621
}
610622
}
623+
624+
// Verify chain consistency: all nodes must have the same block at each height
625+
for _, height := range commonHeights {
626+
refBlock := committed[0][height]
627+
for i := 1; i < N; i++ {
628+
assert.True(t, committed[i][height].Hash().Equals(refBlock.Hash()),
629+
"node %d block at height %d should match node 0", i, height)
630+
}
631+
}
632+
633+
t.Logf("Verified %d common heights across all nodes", len(commonHeights))
611634
}
612635

613636
// TestValidatorSet256 implements ValidatorSet for testing with TestHash256.
@@ -667,3 +690,81 @@ func (vs *TestValidatorSet256) GetLeader(view uint32) uint16 {
667690
func (vs *TestValidatorSet256) F() int {
668691
return (vs.n - 1) / 3
669692
}
693+
694+
// TestIntegration_SingleNode tests consensus with a single node (n=1).
695+
// This is a degenerate case where quorum=1, so the leader's own vote
696+
// should be sufficient to form a QC and make progress.
697+
func TestIntegration_SingleNode(t *testing.T) {
698+
const N = 1
699+
const TARGET_BLOCKS = 5
700+
701+
validators, privKeys := NewTestValidatorSetWithKeys(N)
702+
703+
// Single node network (messages go nowhere, but that's fine for n=1)
704+
sharedNetwork := NewSharedNetwork[TestHash](N)
705+
706+
storage := NewTestStorage()
707+
executor := NewTestExecutor()
708+
mockTimer := timer.NewMockTimer()
709+
710+
cfg := &Config[TestHash]{
711+
Logger: zap.NewNop(),
712+
Timer: mockTimer,
713+
Validators: validators,
714+
MyIndex: 0,
715+
PrivateKey: privKeys[0],
716+
CryptoScheme: "ed25519",
717+
Storage: storage,
718+
Network: sharedNetwork.NodeNetwork(0),
719+
Executor: executor,
720+
}
721+
722+
var committed []Block[TestHash]
723+
var commitMu sync.Mutex
724+
725+
onCommit := func(block Block[TestHash]) {
726+
commitMu.Lock()
727+
committed = append(committed, block)
728+
commitMu.Unlock()
729+
}
730+
731+
node, err := NewHotStuff2(cfg, onCommit)
732+
require.NoError(t, err)
733+
734+
require.NoError(t, node.Start())
735+
736+
// Wait for blocks to be committed (timeout after 5 seconds)
737+
timeout := time.After(5 * time.Second)
738+
ticker := time.NewTicker(50 * time.Millisecond)
739+
defer ticker.Stop()
740+
741+
for {
742+
select {
743+
case <-timeout:
744+
commitMu.Lock()
745+
count := len(committed)
746+
commitMu.Unlock()
747+
t.Fatalf("timeout waiting for blocks to commit, only got %d blocks", count)
748+
749+
case <-ticker.C:
750+
commitMu.Lock()
751+
count := len(committed)
752+
commitMu.Unlock()
753+
754+
if count >= TARGET_BLOCKS {
755+
goto done
756+
}
757+
}
758+
}
759+
760+
done:
761+
node.Stop()
762+
time.Sleep(50 * time.Millisecond)
763+
sharedNetwork.Close()
764+
765+
commitMu.Lock()
766+
defer commitMu.Unlock()
767+
768+
t.Logf("Single node committed %d blocks", len(committed))
769+
assert.GreaterOrEqual(t, len(committed), TARGET_BLOCKS, "single node should commit at least %d blocks", TARGET_BLOCKS)
770+
}

vote.go

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -143,17 +143,22 @@ func (v *Vote[H]) BLSDigest() []byte {
143143
}
144144

145145
// Verify verifies the vote signature with the given public key.
146-
// Also validates timestamp is within acceptable window.
146+
// Also validates timestamp is within acceptable window (Ed25519 only).
147+
// BLS votes use timestamp=0 and skip timestamp validation since all validators
148+
// sign the same message (view + nodeHash only) for aggregation.
147149
func (v *Vote[H]) Verify(publicKey PublicKey) error {
148150
// Check timestamp is within acceptable window (replay protection)
149-
now := uint64(time.Now().UnixMilli())
150-
timeDiff := int64(now) - int64(v.timestamp)
151-
if timeDiff < 0 {
152-
timeDiff = -timeDiff
153-
}
154-
155-
if uint64(timeDiff) > VoteTimestampWindow {
156-
return fmt.Errorf("vote timestamp outside acceptable window: %d ms", timeDiff)
151+
// Skip for BLS votes which use timestamp=0 for signature aggregation
152+
if v.timestamp != 0 {
153+
now := uint64(time.Now().UnixMilli())
154+
timeDiff := int64(now) - int64(v.timestamp)
155+
if timeDiff < 0 {
156+
timeDiff = -timeDiff
157+
}
158+
159+
if uint64(timeDiff) > VoteTimestampWindow {
160+
return fmt.Errorf("vote timestamp outside acceptable window: %d ms", timeDiff)
161+
}
157162
}
158163

159164
// Verify signature

0 commit comments

Comments
 (0)