From 3c6c7059e569bd095725a5cb9edb050c0f9011f8 Mon Sep 17 00:00:00 2001 From: frozen <355847+Frozen@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:35:53 -0400 Subject: [PATCH 01/13] consensus: add chained HotStuff core spike --- consensus/hotstuff/committee.go | 70 ++++++++++++++++++++ consensus/hotstuff/core.go | 102 +++++++++++++++++++++++++++++ consensus/hotstuff/core_test.go | 111 ++++++++++++++++++++++++++++++++ consensus/hotstuff/doc.go | 7 ++ consensus/hotstuff/types.go | 35 ++++++++++ consensus/hotstuff/votes.go | 60 +++++++++++++++++ 6 files changed, 385 insertions(+) create mode 100644 consensus/hotstuff/committee.go create mode 100644 consensus/hotstuff/core.go create mode 100644 consensus/hotstuff/core_test.go create mode 100644 consensus/hotstuff/doc.go create mode 100644 consensus/hotstuff/types.go create mode 100644 consensus/hotstuff/votes.go diff --git a/consensus/hotstuff/committee.go b/consensus/hotstuff/committee.go new file mode 100644 index 0000000000..273ec857c8 --- /dev/null +++ b/consensus/hotstuff/committee.go @@ -0,0 +1,70 @@ +package hotstuff + +import ( + "errors" + "math" +) + +var ( + ErrEmptyCommittee = errors.New("hotstuff committee is empty") + ErrInvalidMember = errors.New("hotstuff committee member is invalid") + ErrDuplicateMember = errors.New("hotstuff committee member is duplicated") + ErrVotingPowerOverflow = errors.New("hotstuff committee voting power overflows uint64") +) + +// Member is a validator identity and its integer voting power. +type Member struct { + ID MemberID + Power uint64 +} + +// Committee is an ordered, weighted validator set. Its order defines the +// deterministic round-robin leader schedule. +type Committee struct { + members []Member + byID map[MemberID]Member + total uint64 +} + +func NewCommittee(members []Member) (*Committee, error) { + if len(members) == 0 { + return nil, ErrEmptyCommittee + } + + committee := &Committee{ + members: append([]Member(nil), members...), + byID: make(map[MemberID]Member, len(members)), + } + for _, member := range members { + if member.ID == "" || member.Power == 0 { + return nil, ErrInvalidMember + } + if _, exists := committee.byID[member.ID]; exists { + return nil, ErrDuplicateMember + } + if committee.total > math.MaxUint64-member.Power { + return nil, ErrVotingPowerOverflow + } + committee.byID[member.ID] = member + committee.total += member.Power + } + return committee, nil +} + +func (c *Committee) Members() []Member { + return append([]Member(nil), c.members...) +} + +// Leader rotates on every view. View one starts with the first committee +// member; view zero is reserved for genesis. +func (c *Committee) Leader(view View) MemberID { + if view == 0 { + return c.members[0].ID + } + return c.members[(uint64(view)-1)%uint64(len(c.members))].ID +} + +func (c *Committee) quorumPower() uint64 { + // The smallest integer voting power strictly greater than two thirds. + return c.total - (c.total-1)/3 +} diff --git a/consensus/hotstuff/core.go b/consensus/hotstuff/core.go new file mode 100644 index 0000000000..ec33d8b09b --- /dev/null +++ b/consensus/hotstuff/core.go @@ -0,0 +1,102 @@ +package hotstuff + +import "errors" + +var ( + ErrInvalidGenesis = errors.New("hotstuff genesis block is invalid") + ErrDuplicateBlock = errors.New("hotstuff block already exists") + ErrUnknownParent = errors.New("hotstuff block parent is unknown") + ErrUnknownQCBlock = errors.New("hotstuff QC block is unknown") + ErrQCDoesNotJustifyParent = errors.New("hotstuff QC does not justify the proposal parent") + ErrInvalidView = errors.New("hotstuff proposal view does not advance its parent") + ErrQCViewMismatch = errors.New("hotstuff QC view does not match its block") +) + +// Core is a transport- and cryptography-independent chained HotStuff spike. It +// accepts only direct parent/QC links so the three-chain commit rule is +// explicit and easy to test before adding skipped views. +type Core struct { + blocks map[BlockID]Block + committed BlockID +} + +func NewCore(genesis Block) *Core { + core := &Core{blocks: make(map[BlockID]Block)} + if genesis.ID != "" && genesis.Parent == "" { + core.blocks[genesis.ID] = genesis + core.committed = genesis.ID + } + return core +} + +func (c *Core) Committed() BlockID { + return c.committed +} + +// Accept validates the proposal's structural QC and applies the direct +// three-chain commit rule. Returned IDs are newly committed in chain order. +func (c *Core) Accept(block Block) ([]BlockID, error) { + if len(c.blocks) == 0 { + return nil, ErrInvalidGenesis + } + if block.ID == "" { + return nil, ErrUnknownParent + } + if _, exists := c.blocks[block.ID]; exists { + return nil, ErrDuplicateBlock + } + + parent, exists := c.blocks[block.Parent] + if !exists { + return nil, ErrUnknownParent + } + qcBlock, exists := c.blocks[block.Justify.Block] + if !exists { + return nil, ErrUnknownQCBlock + } + if block.Justify.Block != block.Parent { + return nil, ErrQCDoesNotJustifyParent + } + if block.Justify.View != qcBlock.View { + return nil, ErrQCViewMismatch + } + if block.View <= parent.View { + return nil, ErrInvalidView + } + + c.blocks[block.ID] = block + + one := parent + two, ok := c.blocks[one.Parent] + if !ok || one.Justify.Block != two.ID { + return nil, nil + } + three, ok := c.blocks[two.Parent] + if !ok || two.Justify.Block != three.ID { + return nil, nil + } + + return c.commitThrough(three.ID), nil +} + +func (c *Core) commitThrough(target BlockID) []BlockID { + if target == c.committed { + return nil + } + + path := make([]BlockID, 0) + for current := target; current != c.committed; { + block, exists := c.blocks[current] + if !exists { + return nil + } + path = append(path, current) + current = block.Parent + } + + for left, right := 0, len(path)-1; left < right; left, right = left+1, right-1 { + path[left], path[right] = path[right], path[left] + } + c.committed = target + return path +} diff --git a/consensus/hotstuff/core_test.go b/consensus/hotstuff/core_test.go new file mode 100644 index 0000000000..35378bcf16 --- /dev/null +++ b/consensus/hotstuff/core_test.go @@ -0,0 +1,111 @@ +package hotstuff + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRoundRobinLeaderChangesEveryView(t *testing.T) { + committee, err := NewCommittee([]Member{ + {ID: "alice", Power: 1}, + {ID: "bob", Power: 1}, + {ID: "carol", Power: 1}, + }) + require.NoError(t, err) + + require.Equal(t, MemberID("alice"), committee.Leader(1)) + require.Equal(t, MemberID("bob"), committee.Leader(2)) + require.Equal(t, MemberID("carol"), committee.Leader(3)) + require.Equal(t, MemberID("alice"), committee.Leader(4)) +} + +func TestBroadcastVotesProduceSameQCAtEveryReplica(t *testing.T) { + committee, err := NewCommittee([]Member{ + {ID: "alice", Power: 1}, + {ID: "bob", Power: 1}, + {ID: "carol", Power: 1}, + {ID: "dave", Power: 1}, + }) + require.NoError(t, err) + + votes := []Vote{ + {Voter: "alice", Block: "b1", View: 1}, + {Voter: "bob", Block: "b1", View: 1}, + {Voter: "carol", Block: "b1", View: 1}, + } + + var want QC + for replica := range committee.Members() { + collector := NewVoteSet(committee, "b1", 1) + for _, vote := range votes { + require.NoError(t, collector.Add(vote)) + } + + got, ok := collector.QC() + require.Truef(t, ok, "replica %d did not form a QC", replica) + if replica == 0 { + want = got + } + require.Equal(t, want, got) + } +} + +func TestVoteSetRejectsDuplicateVoter(t *testing.T) { + committee, err := NewCommittee([]Member{ + {ID: "alice", Power: 2}, + {ID: "bob", Power: 1}, + {ID: "carol", Power: 1}, + }) + require.NoError(t, err) + + collector := NewVoteSet(committee, "b1", 1) + vote := Vote{Voter: "alice", Block: "b1", View: 1} + require.NoError(t, collector.Add(vote)) + require.ErrorIs(t, collector.Add(vote), ErrDuplicateVote) + + _, ok := collector.QC() + require.False(t, ok, "a duplicate vote must not increase voting power") +} + +func TestDirectThreeChainCommitsGreatGrandparent(t *testing.T) { + core := NewCore(Block{ID: "genesis", View: 0}) + + chain := []Block{ + {ID: "b1", Parent: "genesis", View: 1, Justify: QC{Block: "genesis", View: 0}}, + {ID: "b2", Parent: "b1", View: 2, Justify: QC{Block: "b1", View: 1}}, + {ID: "b3", Parent: "b2", View: 3, Justify: QC{Block: "b2", View: 2}}, + {ID: "b4", Parent: "b3", View: 4, Justify: QC{Block: "b3", View: 3}}, + } + + for _, block := range chain[:3] { + committed, err := core.Accept(block) + require.NoError(t, err) + require.Empty(t, committed) + } + + committed, err := core.Accept(chain[3]) + require.NoError(t, err) + require.Equal(t, []BlockID{"b1"}, committed) + require.Equal(t, BlockID("b1"), core.Committed()) +} + +func TestCoreRejectsProposalWhoseQCDoesNotJustifyParent(t *testing.T) { + core := NewCore(Block{ID: "genesis", View: 0}) + require.NoError(t, acceptWithoutCommit(core, + Block{ID: "b1", Parent: "genesis", View: 1, Justify: QC{Block: "genesis", View: 0}}, + )) + + _, err := core.Accept(Block{ + ID: "bad", + Parent: "b1", + View: 2, + Justify: QC{Block: "genesis", View: 0}, + }) + require.ErrorIs(t, err, ErrQCDoesNotJustifyParent) +} + +func acceptWithoutCommit(core *Core, block Block) error { + _, err := core.Accept(block) + return err +} diff --git a/consensus/hotstuff/doc.go b/consensus/hotstuff/doc.go new file mode 100644 index 0000000000..bad27f7a5c --- /dev/null +++ b/consensus/hotstuff/doc.go @@ -0,0 +1,7 @@ +// Package hotstuff contains an isolated chained HotStuff research spike. +// +// It is not wired into Harmony consensus, networking, persistence, block +// verification, or BLS signatures. The package currently validates leader +// rotation, broadcast vote aggregation, structural QCs, and the direct +// three-chain commit rule. +package hotstuff diff --git a/consensus/hotstuff/types.go b/consensus/hotstuff/types.go new file mode 100644 index 0000000000..2825800d3d --- /dev/null +++ b/consensus/hotstuff/types.go @@ -0,0 +1,35 @@ +package hotstuff + +// View identifies a HotStuff consensus round. A successful view produces at +// most one block and has exactly one scheduled leader. +type View uint64 + +// MemberID identifies a committee member. The production adapter will map this +// value to a Harmony validator identity and its BLS keys. +type MemberID string + +// BlockID identifies a proposal. The production adapter will use a block hash. +type BlockID string + +// QC is the structural part of a quorum certificate. Signatures are +// intentionally left to the Harmony BLS adapter. +type QC struct { + Block BlockID + View View + Signers []MemberID +} + +// Block is the minimum chained HotStuff proposal needed by the spike. +type Block struct { + ID BlockID + Parent BlockID + View View + Justify QC +} + +// Vote is broadcast to the committee in the first transport experiment. +type Vote struct { + Voter MemberID + Block BlockID + View View +} diff --git a/consensus/hotstuff/votes.go b/consensus/hotstuff/votes.go new file mode 100644 index 0000000000..17ce535cb7 --- /dev/null +++ b/consensus/hotstuff/votes.go @@ -0,0 +1,60 @@ +package hotstuff + +import "errors" + +var ( + ErrUnknownVoter = errors.New("hotstuff vote is from an unknown committee member") + ErrWrongVote = errors.New("hotstuff vote targets a different block or view") + ErrDuplicateVote = errors.New("hotstuff voter already voted for this block and view") +) + +// VoteSet collects broadcast votes for one proposal. Every replica may build +// the same QC; a later transport adapter may instead collect only at the next +// leader without changing this type. +type VoteSet struct { + committee *Committee + block BlockID + view View + voters map[MemberID]struct{} + power uint64 +} + +func NewVoteSet(committee *Committee, block BlockID, view View) *VoteSet { + return &VoteSet{ + committee: committee, + block: block, + view: view, + voters: make(map[MemberID]struct{}), + } +} + +func (s *VoteSet) Add(vote Vote) error { + member, exists := s.committee.byID[vote.Voter] + if !exists { + return ErrUnknownVoter + } + if vote.Block != s.block || vote.View != s.view { + return ErrWrongVote + } + if _, exists := s.voters[vote.Voter]; exists { + return ErrDuplicateVote + } + + s.voters[vote.Voter] = struct{}{} + s.power += member.Power + return nil +} + +func (s *VoteSet) QC() (QC, bool) { + if s.power < s.committee.quorumPower() { + return QC{}, false + } + + signers := make([]MemberID, 0, len(s.voters)) + for _, member := range s.committee.members { + if _, voted := s.voters[member.ID]; voted { + signers = append(signers, member.ID) + } + } + return QC{Block: s.block, View: s.view, Signers: signers}, true +} From 6de828043a3b9666ee56ea135720bf38649978c4 Mon Sep 17 00:00:00 2001 From: frozen <355847+Frozen@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:52:03 -0400 Subject: [PATCH 02/13] consensus: add HotStuff safety voting rules --- consensus/hotstuff/core.go | 59 ++++++- consensus/hotstuff/safety.go | 97 ++++++++++ consensus/hotstuff/safety_test.go | 283 ++++++++++++++++++++++++++++++ 3 files changed, 436 insertions(+), 3 deletions(-) create mode 100644 consensus/hotstuff/safety.go create mode 100644 consensus/hotstuff/safety_test.go diff --git a/consensus/hotstuff/core.go b/consensus/hotstuff/core.go index ec33d8b09b..39e07bb69c 100644 --- a/consensus/hotstuff/core.go +++ b/consensus/hotstuff/core.go @@ -1,6 +1,9 @@ package hotstuff -import "errors" +import ( + "errors" + "sync" +) var ( ErrInvalidGenesis = errors.New("hotstuff genesis block is invalid") @@ -16,6 +19,7 @@ var ( // accepts only direct parent/QC links so the three-chain commit rule is // explicit and easy to test before adding skipped views. type Core struct { + mu sync.RWMutex blocks map[BlockID]Block committed BlockID } @@ -23,19 +27,63 @@ type Core struct { func NewCore(genesis Block) *Core { core := &Core{blocks: make(map[BlockID]Block)} if genesis.ID != "" && genesis.Parent == "" { - core.blocks[genesis.ID] = genesis + core.blocks[genesis.ID] = cloneBlock(genesis) core.committed = genesis.ID } return core } func (c *Core) Committed() BlockID { + c.mu.RLock() + defer c.mu.RUnlock() return c.committed } +func (c *Core) block(id BlockID) (Block, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + block, exists := c.blocks[id] + return cloneBlock(block), exists +} + +// Extends reports whether descendant is on the branch rooted at ancestor. +func (c *Core) Extends(descendant, ancestor BlockID) bool { + c.mu.RLock() + defer c.mu.RUnlock() + for descendant != "" { + if descendant == ancestor { + return true + } + block, exists := c.blocks[descendant] + if !exists { + return false + } + descendant = block.Parent + } + return false +} + +// lockQC returns the QC that becomes locked when proposal completes a direct +// two-chain. The caller is responsible for retaining the highest lock. +func (c *Core) lockQC(proposal Block) (QC, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + parent, exists := c.blocks[proposal.Parent] + if !exists || proposal.Justify.Block != parent.ID { + return QC{}, false + } + grandparent, exists := c.blocks[parent.Parent] + if !exists || parent.Justify.Block != grandparent.ID { + return QC{}, false + } + return cloneQC(parent.Justify), true +} + // Accept validates the proposal's structural QC and applies the direct // three-chain commit rule. Returned IDs are newly committed in chain order. func (c *Core) Accept(block Block) ([]BlockID, error) { + c.mu.Lock() + defer c.mu.Unlock() if len(c.blocks) == 0 { return nil, ErrInvalidGenesis } @@ -64,7 +112,7 @@ func (c *Core) Accept(block Block) ([]BlockID, error) { return nil, ErrInvalidView } - c.blocks[block.ID] = block + c.blocks[block.ID] = cloneBlock(block) one := parent two, ok := c.blocks[one.Parent] @@ -100,3 +148,8 @@ func (c *Core) commitThrough(target BlockID) []BlockID { c.committed = target return path } + +func cloneBlock(block Block) Block { + block.Justify = cloneQC(block.Justify) + return block +} diff --git a/consensus/hotstuff/safety.go b/consensus/hotstuff/safety.go new file mode 100644 index 0000000000..dc9830c4fe --- /dev/null +++ b/consensus/hotstuff/safety.go @@ -0,0 +1,97 @@ +package hotstuff + +import ( + "errors" + "sync" +) + +var ( + ErrAlreadyVoted = errors.New("hotstuff replica already voted in this or a higher view") + ErrUnsafeProposal = errors.New("hotstuff proposal neither extends the lock nor carries a higher QC") + ErrProposalMismatch = errors.New("hotstuff proposal does not match the accepted block") + ErrMissingPersister = errors.New("hotstuff safety state persister is missing") +) + +// SafetyState is the minimum state that must survive a validator restart. +// Persisting it before emitting a vote prevents crash-recovery double voting. +type SafetyState struct { + LastVotedView View + LockedQC QC +} + +// PersistSafetyState must durably store the complete state before returning. +type PersistSafetyState func(SafetyState) error + +// SafetyRules implements HotStuff's last-voted and locked-QC voting rules. +// It expects proposals to pass Core.Accept before Vote is called. +type SafetyRules struct { + mu sync.Mutex + core *Core + state SafetyState + persist PersistSafetyState +} + +func NewSafetyRules(core *Core, initial SafetyState, persist PersistSafetyState) *SafetyRules { + initial = cloneSafetyState(initial) + return &SafetyRules{core: core, state: initial, persist: persist} +} + +func (r *SafetyRules) State() SafetyState { + r.mu.Lock() + defer r.mu.Unlock() + return cloneSafetyState(r.state) +} + +// Vote applies the safe-node rule and durably advances safety state before it +// returns a vote that may be broadcast. +func (r *SafetyRules) Vote(voter MemberID, proposal Block) (Vote, error) { + r.mu.Lock() + defer r.mu.Unlock() + accepted, exists := r.core.block(proposal.ID) + if !exists { + return Vote{}, ErrUnknownParent + } + if !sameProposal(accepted, proposal) { + return Vote{}, ErrProposalMismatch + } + if proposal.View <= r.state.LastVotedView { + return Vote{}, ErrAlreadyVoted + } + if !r.core.Extends(proposal.ID, r.state.LockedQC.Block) && + proposal.Justify.View <= r.state.LockedQC.View { + return Vote{}, ErrUnsafeProposal + } + if r.persist == nil { + return Vote{}, ErrMissingPersister + } + + next := cloneSafetyState(r.state) + next.LastVotedView = proposal.View + if lock, ok := r.core.lockQC(proposal); ok && lock.View > next.LockedQC.View { + next.LockedQC = lock + } + if err := r.persist(cloneSafetyState(next)); err != nil { + return Vote{}, err + } + r.state = next + + return Vote{Voter: voter, Block: proposal.ID, View: proposal.View}, nil +} + +func sameProposal(left, right Block) bool { + return left.ID == right.ID && + left.Parent == right.Parent && + left.View == right.View && + left.Justify.Block == right.Justify.Block && + left.Justify.View == right.Justify.View +} + +func cloneQC(qc QC) QC { + qc.Signers = append([]MemberID(nil), qc.Signers...) + return qc +} + +func cloneSafetyState(state SafetyState) SafetyState { + state.LockedQC = cloneQC(state.LockedQC) + return state +} diff --git a/consensus/hotstuff/safety_test.go b/consensus/hotstuff/safety_test.go new file mode 100644 index 0000000000..02310cfa68 --- /dev/null +++ b/consensus/hotstuff/safety_test.go @@ -0,0 +1,283 @@ +package hotstuff + +import ( + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestSafetyRulesRejectsSecondVoteInSameView(t *testing.T) { + core := NewCore(Block{ID: "genesis", View: 0}) + persist := &recordingPersister{} + rules := NewSafetyRules(core, SafetyState{ + LockedQC: QC{Block: "genesis", View: 0}, + }, persist.Save) + + b1 := Block{ID: "b1", Parent: "genesis", View: 1, Justify: QC{Block: "genesis", View: 0}} + require.NoError(t, acceptWithoutCommit(core, b1)) + _, err := rules.Vote("alice", b1) + require.NoError(t, err) + + fork := Block{ID: "fork", Parent: "genesis", View: 1, Justify: QC{Block: "genesis", View: 0}} + require.NoError(t, acceptWithoutCommit(core, fork)) + _, err = rules.Vote("alice", fork) + require.ErrorIs(t, err, ErrAlreadyVoted) + require.Len(t, persist.states, 1) +} + +func TestSafetyRulesVotesForDescendantOfLockedBlock(t *testing.T) { + core := NewCore(Block{ID: "genesis", View: 0}) + b1 := Block{ID: "b1", Parent: "genesis", View: 1, Justify: QC{Block: "genesis", View: 0}} + b2 := Block{ID: "b2", Parent: "b1", View: 2, Justify: QC{Block: "b1", View: 1}} + require.NoError(t, acceptWithoutCommit(core, b1)) + require.NoError(t, acceptWithoutCommit(core, b2)) + + persist := &recordingPersister{} + rules := NewSafetyRules(core, SafetyState{ + LastVotedView: 2, + LockedQC: QC{Block: "genesis", View: 0}, + }, persist.Save) + + b3 := Block{ID: "b3", Parent: "b2", View: 3, Justify: QC{Block: "b2", View: 2}} + require.NoError(t, acceptWithoutCommit(core, b3)) + vote, err := rules.Vote("alice", b3) + require.NoError(t, err) + require.Equal(t, Vote{Voter: "alice", Block: "b3", View: 3}, vote) + require.Equal(t, View(3), rules.State().LastVotedView) + require.Equal(t, QC{Block: "b1", View: 1}, rules.State().LockedQC) + require.Equal(t, rules.State(), persist.states[0]) +} + +func TestSafetyRulesUnlocksForHigherQCOnConflictingBranch(t *testing.T) { + core := NewCore(Block{ID: "genesis", View: 0}) + locked := Block{ID: "locked", Parent: "genesis", View: 1, Justify: QC{Block: "genesis", View: 0}} + fork1 := Block{ID: "fork1", Parent: "genesis", View: 2, Justify: QC{Block: "genesis", View: 0}} + fork2 := Block{ID: "fork2", Parent: "fork1", View: 3, Justify: QC{Block: "fork1", View: 2}} + for _, block := range []Block{locked, fork1, fork2} { + require.NoError(t, acceptWithoutCommit(core, block)) + } + + persist := &recordingPersister{} + rules := NewSafetyRules(core, SafetyState{ + LastVotedView: 1, + LockedQC: QC{Block: "locked", View: 1}, + }, persist.Save) + + vote, err := rules.Vote("alice", fork2) + require.NoError(t, err) + require.Equal(t, Vote{Voter: "alice", Block: "fork2", View: 3}, vote) +} + +func TestSafetyRulesRejectsConflictingBranchWithoutHigherQC(t *testing.T) { + core := NewCore(Block{ID: "genesis", View: 0}) + locked := Block{ID: "locked", Parent: "genesis", View: 2, Justify: QC{Block: "genesis", View: 0}} + fork := Block{ID: "fork", Parent: "genesis", View: 3, Justify: QC{Block: "genesis", View: 0}} + for _, block := range []Block{locked, fork} { + require.NoError(t, acceptWithoutCommit(core, block)) + } + + persist := &recordingPersister{} + rules := NewSafetyRules(core, SafetyState{ + LastVotedView: 2, + LockedQC: QC{Block: "locked", View: 2}, + }, persist.Save) + + _, err := rules.Vote("alice", fork) + require.ErrorIs(t, err, ErrUnsafeProposal) + require.Empty(t, persist.states) +} + +func TestSafetyRulesRejectsConflictingBranchWithEqualQC(t *testing.T) { + core := NewCore(Block{ID: "genesis", View: 0}) + locked := Block{ID: "locked", Parent: "genesis", View: 2, Justify: QC{Block: "genesis", View: 0}} + fork := Block{ID: "fork", Parent: "genesis", View: 2, Justify: QC{Block: "genesis", View: 0}} + proposal := Block{ID: "proposal", Parent: "fork", View: 3, Justify: QC{Block: "fork", View: 2}} + for _, block := range []Block{locked, fork, proposal} { + require.NoError(t, acceptWithoutCommit(core, block)) + } + + rules := NewSafetyRules(core, SafetyState{ + LastVotedView: 2, + LockedQC: QC{Block: "locked", View: 2}, + }, func(SafetyState) error { return nil }) + + _, err := rules.Vote("alice", proposal) + require.ErrorIs(t, err, ErrUnsafeProposal) +} + +func TestSafetyRulesRestoresLastVoteAcrossRestart(t *testing.T) { + core := NewCore(Block{ID: "genesis", View: 0}) + left := Block{ID: "left", Parent: "genesis", View: 1, Justify: QC{Block: "genesis", View: 0}} + right := Block{ID: "right", Parent: "genesis", View: 1, Justify: QC{Block: "genesis", View: 0}} + require.NoError(t, acceptWithoutCommit(core, left)) + require.NoError(t, acceptWithoutCommit(core, right)) + + var persisted SafetyState + rules := NewSafetyRules(core, SafetyState{ + LockedQC: QC{Block: "genesis", View: 0}, + }, func(state SafetyState) error { + persisted = state + return nil + }) + _, err := rules.Vote("alice", left) + require.NoError(t, err) + + restarted := NewSafetyRules(core, persisted, func(SafetyState) error { return nil }) + _, err = restarted.Vote("alice", right) + require.ErrorIs(t, err, ErrAlreadyVoted) +} + +func TestSafetyRulesRetainsHigherLock(t *testing.T) { + core := NewCore(Block{ID: "genesis", View: 0}) + low := Block{ID: "low", Parent: "genesis", View: 1, Justify: QC{Block: "genesis", View: 0}} + locked := Block{ID: "locked", Parent: "genesis", View: 5, Justify: QC{Block: "genesis", View: 0}} + parent := Block{ID: "parent", Parent: "low", View: 6, Justify: QC{Block: "low", View: 1}} + proposal := Block{ID: "proposal", Parent: "parent", View: 7, Justify: QC{Block: "parent", View: 6}} + for _, block := range []Block{low, locked, parent, proposal} { + require.NoError(t, acceptWithoutCommit(core, block)) + } + + rules := NewSafetyRules(core, SafetyState{ + LastVotedView: 5, + LockedQC: QC{Block: "locked", View: 5}, + }, func(SafetyState) error { return nil }) + + _, err := rules.Vote("alice", proposal) + require.NoError(t, err) + require.Equal(t, QC{Block: "locked", View: 5}, rules.State().LockedQC) +} + +func TestSafetyRulesPersistsBeforeReturningVote(t *testing.T) { + core := NewCore(Block{ID: "genesis", View: 0}) + b1 := Block{ID: "b1", Parent: "genesis", View: 1, Justify: QC{Block: "genesis", View: 0}} + require.NoError(t, acceptWithoutCommit(core, b1)) + + persistErr := errors.New("disk unavailable") + rules := NewSafetyRules(core, SafetyState{ + LockedQC: QC{Block: "genesis", View: 0}, + }, func(SafetyState) error { + return persistErr + }) + + _, err := rules.Vote("alice", b1) + require.ErrorIs(t, err, persistErr) + require.Equal(t, View(0), rules.State().LastVotedView) +} + +func TestSafetyRulesDoesNotShareStateWithPersister(t *testing.T) { + core := NewCore(Block{ID: "genesis", View: 0}) + b1 := Block{ + ID: "b1", + Parent: "genesis", + View: 1, + Justify: QC{Block: "genesis", View: 0, Signers: []MemberID{"alice"}}, + } + b2 := Block{ + ID: "b2", + Parent: "b1", + View: 2, + Justify: QC{Block: "b1", View: 1, Signers: []MemberID{"alice"}}, + } + b3 := Block{ + ID: "b3", + Parent: "b2", + View: 3, + Justify: QC{Block: "b2", View: 2, Signers: []MemberID{"alice"}}, + } + for _, block := range []Block{b1, b2, b3} { + require.NoError(t, acceptWithoutCommit(core, block)) + } + + rules := NewSafetyRules(core, SafetyState{ + LockedQC: QC{Block: "genesis", View: 0}, + }, func(state SafetyState) error { + state.LockedQC.Signers[0] = "mallory" + return nil + }) + + _, err := rules.Vote("alice", b3) + require.NoError(t, err) + require.Equal(t, []MemberID{"alice"}, rules.State().LockedQC.Signers) +} + +func TestSafetyRulesSerializesConflictingVotes(t *testing.T) { + core := NewCore(Block{ID: "genesis", View: 0}) + left := Block{ID: "left", Parent: "genesis", View: 1, Justify: QC{Block: "genesis", View: 0}} + right := Block{ID: "right", Parent: "genesis", View: 1, Justify: QC{Block: "genesis", View: 0}} + require.NoError(t, acceptWithoutCommit(core, left)) + require.NoError(t, acceptWithoutCommit(core, right)) + + entered := make(chan struct{}, 2) + release := make(chan struct{}) + rules := NewSafetyRules(core, SafetyState{ + LockedQC: QC{Block: "genesis", View: 0}, + }, func(SafetyState) error { + entered <- struct{}{} + <-release + return nil + }) + + results := make(chan error, 2) + var started sync.WaitGroup + started.Add(2) + for _, proposal := range []Block{left, right} { + go func(proposal Block) { + started.Done() + started.Wait() + _, err := rules.Vote("alice", proposal) + results <- err + }(proposal) + } + + <-entered + select { + case <-entered: + close(release) + case <-time.After(50 * time.Millisecond): + close(release) + } + + successes := 0 + for range 2 { + if err := <-results; err == nil { + successes++ + } + } + require.Equal(t, 1, successes, "two conflicting votes escaped in one view") +} + +func TestCoreOwnsAcceptedQCEvidence(t *testing.T) { + core := NewCore(Block{ID: "genesis", View: 0}) + b1 := Block{ID: "b1", Parent: "genesis", View: 1, Justify: QC{Block: "genesis", View: 0}} + b2 := Block{ + ID: "b2", + Parent: "b1", + View: 2, + Justify: QC{Block: "b1", View: 1, Signers: []MemberID{"alice"}}, + } + require.NoError(t, acceptWithoutCommit(core, b1)) + require.NoError(t, acceptWithoutCommit(core, b2)) + b2.Justify.Signers[0] = "mallory" + + b3 := Block{ID: "b3", Parent: "b2", View: 3, Justify: QC{Block: "b2", View: 2}} + require.NoError(t, acceptWithoutCommit(core, b3)) + rules := NewSafetyRules(core, SafetyState{ + LockedQC: QC{Block: "genesis", View: 0}, + }, func(SafetyState) error { return nil }) + + _, err := rules.Vote("alice", b3) + require.NoError(t, err) + require.Equal(t, []MemberID{"alice"}, rules.State().LockedQC.Signers) +} + +type recordingPersister struct { + states []SafetyState +} + +func (p *recordingPersister) Save(state SafetyState) error { + p.states = append(p.states, state) + return nil +} From 34c87b9bf1dc6845c707668be775530386284170 Mon Sep 17 00:00:00 2001 From: frozen <355847+Frozen@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:54:02 -0400 Subject: [PATCH 03/13] consensus: serialize HotStuff vote aggregation --- consensus/hotstuff/core_test.go | 36 +++++++++++++++++++++++++++++++++ consensus/hotstuff/votes.go | 10 ++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/consensus/hotstuff/core_test.go b/consensus/hotstuff/core_test.go index 35378bcf16..f979abbcf5 100644 --- a/consensus/hotstuff/core_test.go +++ b/consensus/hotstuff/core_test.go @@ -1,6 +1,7 @@ package hotstuff import ( + "sync" "testing" "github.com/stretchr/testify/require" @@ -68,6 +69,41 @@ func TestVoteSetRejectsDuplicateVoter(t *testing.T) { require.False(t, ok, "a duplicate vote must not increase voting power") } +func TestVoteSetSerializesConcurrentDuplicateVoter(t *testing.T) { + committee, err := NewCommittee([]Member{ + {ID: "alice", Power: 1}, + {ID: "bob", Power: 1}, + {ID: "carol", Power: 1}, + }) + require.NoError(t, err) + collector := NewVoteSet(committee, "b1", 1) + + const attempts = 32 + start := make(chan struct{}) + results := make(chan error, attempts) + var ready sync.WaitGroup + ready.Add(attempts) + for range attempts { + go func() { + ready.Done() + <-start + results <- collector.Add(Vote{Voter: "alice", Block: "b1", View: 1}) + }() + } + ready.Wait() + close(start) + + successes := 0 + for range attempts { + if err := <-results; err == nil { + successes++ + } + } + require.Equal(t, 1, successes) + _, formed := collector.QC() + require.False(t, formed) +} + func TestDirectThreeChainCommitsGreatGrandparent(t *testing.T) { core := NewCore(Block{ID: "genesis", View: 0}) diff --git a/consensus/hotstuff/votes.go b/consensus/hotstuff/votes.go index 17ce535cb7..2bcb961139 100644 --- a/consensus/hotstuff/votes.go +++ b/consensus/hotstuff/votes.go @@ -1,6 +1,9 @@ package hotstuff -import "errors" +import ( + "errors" + "sync" +) var ( ErrUnknownVoter = errors.New("hotstuff vote is from an unknown committee member") @@ -12,6 +15,7 @@ var ( // the same QC; a later transport adapter may instead collect only at the next // leader without changing this type. type VoteSet struct { + mu sync.Mutex committee *Committee block BlockID view View @@ -29,6 +33,8 @@ func NewVoteSet(committee *Committee, block BlockID, view View) *VoteSet { } func (s *VoteSet) Add(vote Vote) error { + s.mu.Lock() + defer s.mu.Unlock() member, exists := s.committee.byID[vote.Voter] if !exists { return ErrUnknownVoter @@ -46,6 +52,8 @@ func (s *VoteSet) Add(vote Vote) error { } func (s *VoteSet) QC() (QC, bool) { + s.mu.Lock() + defer s.mu.Unlock() if s.power < s.committee.quorumPower() { return QC{}, false } From 36dc31a3b3dd508432db054af9cba098724488f4 Mon Sep 17 00:00:00 2001 From: frozen <355847+Frozen@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:22:01 -0400 Subject: [PATCH 04/13] consensus: add HotStuff pacemaker --- consensus/hotstuff/pacemaker.go | 242 +++++++++++++++++++++++++++ consensus/hotstuff/pacemaker_test.go | 160 ++++++++++++++++++ 2 files changed, 402 insertions(+) create mode 100644 consensus/hotstuff/pacemaker.go create mode 100644 consensus/hotstuff/pacemaker_test.go diff --git a/consensus/hotstuff/pacemaker.go b/consensus/hotstuff/pacemaker.go new file mode 100644 index 0000000000..b7f5feb919 --- /dev/null +++ b/consensus/hotstuff/pacemaker.go @@ -0,0 +1,242 @@ +package hotstuff + +import ( + "errors" + "math" + "sync" +) + +var ( + ErrDuplicateTimeout = errors.New("hotstuff voter already timed out this view") + ErrWrongTimeoutView = errors.New("hotstuff timeout targets a different view") + ErrFutureQC = errors.New("hotstuff timeout carries a QC from a future view") + ErrStaleCertificate = errors.New("hotstuff certificate does not advance the current view") + ErrWrongCertificateView = errors.New("hotstuff timeout certificate does not target the current view") + ErrInsufficientVotingPower = errors.New("hotstuff certificate has insufficient voting power") + ErrDuplicateSigner = errors.New("hotstuff certificate contains a duplicate signer") + ErrInvalidQC = errors.New("hotstuff QC has no certified block") + ErrViewOverflow = errors.New("hotstuff view overflows uint64") +) + +// Timeout is broadcast when a replica cannot make progress in a view. HighQC +// lets every prospective next leader learn the safest certified branch. +type Timeout struct { + Voter MemberID + View View + HighQC QC +} + +// TimeoutCertificate proves that a weighted quorum abandoned one view. +type TimeoutCertificate struct { + View View + HighQC QC + Signers []MemberID +} + +// TimeoutSet collects broadcast timeout messages for one view. +type TimeoutSet struct { + mu sync.Mutex + committee *Committee + view View + voters map[MemberID]struct{} + power uint64 + highQC QC +} + +func NewTimeoutSet(committee *Committee, view View) *TimeoutSet { + return &TimeoutSet{ + committee: committee, + view: view, + voters: make(map[MemberID]struct{}), + } +} + +func (s *TimeoutSet) Add(timeout Timeout) error { + s.mu.Lock() + defer s.mu.Unlock() + + member, exists := s.committee.byID[timeout.Voter] + if !exists { + return ErrUnknownVoter + } + if timeout.View != s.view { + return ErrWrongTimeoutView + } + if timeout.HighQC.View > timeout.View { + return ErrFutureQC + } + if err := s.committee.requireQC(timeout.HighQC); err != nil { + return err + } + if _, exists := s.voters[timeout.Voter]; exists { + return ErrDuplicateTimeout + } + + s.voters[timeout.Voter] = struct{}{} + s.power += member.Power + canonicalHighQC := s.committee.canonicalQC(timeout.HighQC) + if higherQC(canonicalHighQC, s.highQC) { + s.highQC = canonicalHighQC + } + return nil +} + +func (s *TimeoutSet) Certificate() (TimeoutCertificate, bool) { + s.mu.Lock() + defer s.mu.Unlock() + if s.power < s.committee.quorumPower() { + return TimeoutCertificate{}, false + } + + signers := make([]MemberID, 0, len(s.voters)) + for _, member := range s.committee.members { + if _, exists := s.voters[member.ID]; exists { + signers = append(signers, member.ID) + } + } + return TimeoutCertificate{ + View: s.view, + HighQC: cloneQC(s.highQC), + Signers: signers, + }, true +} + +// Pacemaker advances views after either a successful QC or a timeout +// certificate. Advancing a timed-out view does not require producing a block. +type Pacemaker struct { + mu sync.Mutex + committee *Committee + view View + highQC QC +} + +func NewPacemaker(committee *Committee, initial View) *Pacemaker { + return &Pacemaker{committee: committee, view: initial} +} + +func (p *Pacemaker) CurrentView() View { + p.mu.Lock() + defer p.mu.Unlock() + return p.view +} + +func (p *Pacemaker) Leader() MemberID { + p.mu.Lock() + defer p.mu.Unlock() + return p.committee.Leader(p.view) +} + +func (p *Pacemaker) HighQC() QC { + p.mu.Lock() + defer p.mu.Unlock() + return cloneQC(p.highQC) +} + +func (p *Pacemaker) AdvanceTimeout(certificate TimeoutCertificate) error { + p.mu.Lock() + defer p.mu.Unlock() + if certificate.View < p.view { + return ErrStaleCertificate + } + if certificate.View > p.view { + return ErrWrongCertificateView + } + if certificate.HighQC.View > certificate.View { + return ErrFutureQC + } + if err := p.committee.requireQuorum(certificate.Signers); err != nil { + return err + } + if err := p.committee.requireQC(certificate.HighQC); err != nil { + return err + } + if err := p.advanceAfter(certificate.View); err != nil { + return err + } + if higherQC(certificate.HighQC, p.highQC) { + p.highQC = p.committee.canonicalQC(certificate.HighQC) + } + return nil +} + +func (p *Pacemaker) AdvanceQC(qc QC) error { + p.mu.Lock() + defer p.mu.Unlock() + if qc.View < p.view { + return ErrStaleCertificate + } + if err := p.committee.requireQC(qc); err != nil { + return err + } + if err := p.advanceAfter(qc.View); err != nil { + return err + } + if higherQC(qc, p.highQC) { + p.highQC = p.committee.canonicalQC(qc) + } + return nil +} + +func (p *Pacemaker) advanceAfter(certifiedView View) error { + if certifiedView == View(math.MaxUint64) { + return ErrViewOverflow + } + next := certifiedView + 1 + if next <= p.view { + return ErrStaleCertificate + } + p.view = next + return nil +} + +func (c *Committee) requireQuorum(signers []MemberID) error { + seen := make(map[MemberID]struct{}, len(signers)) + var power uint64 + for _, signer := range signers { + if _, exists := seen[signer]; exists { + return ErrDuplicateSigner + } + member, exists := c.byID[signer] + if !exists { + return ErrUnknownVoter + } + seen[signer] = struct{}{} + power += member.Power + } + if power < c.quorumPower() { + return ErrInsufficientVotingPower + } + return nil +} + +func (c *Committee) requireQC(qc QC) error { + // View zero is the externally configured trust root and does not require a + // quorum witness. Every later QC must certify a concrete block. + if qc.View == 0 && len(qc.Signers) == 0 { + return nil + } + if qc.Block == "" { + return ErrInvalidQC + } + return c.requireQuorum(qc.Signers) +} + +func (c *Committee) canonicalQC(qc QC) QC { + signerSet := make(map[MemberID]struct{}, len(qc.Signers)) + for _, signer := range qc.Signers { + signerSet[signer] = struct{}{} + } + signers := make([]MemberID, 0, len(signerSet)) + for _, member := range c.members { + if _, exists := signerSet[member.ID]; exists { + signers = append(signers, member.ID) + } + } + return QC{Block: qc.Block, View: qc.View, Signers: signers} +} + +func higherQC(left, right QC) bool { + // Signer lists are proof witnesses, not part of the logical QC ordering. + return left.View > right.View || + (left.View == right.View && left.Block > right.Block) +} diff --git a/consensus/hotstuff/pacemaker_test.go b/consensus/hotstuff/pacemaker_test.go new file mode 100644 index 0000000000..3560b98e3f --- /dev/null +++ b/consensus/hotstuff/pacemaker_test.go @@ -0,0 +1,160 @@ +package hotstuff + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBroadcastTimeoutsProduceEquivalentCertificatesAtEveryReplica(t *testing.T) { + committee := testCommittee(t, "alice", "bob", "carol", "dave") + timeouts := map[MemberID]Timeout{ + "alice": {Voter: "alice", View: 3, HighQC: certifiedQC("b1", 1, "alice", "bob", "carol")}, + "bob": {Voter: "bob", View: 3, HighQC: certifiedQC("b2", 2, "carol", "bob", "alice")}, + "carol": {Voter: "carol", View: 3, HighQC: certifiedQC("b1", 1, "alice", "bob", "carol")}, + "dave": {Voter: "dave", View: 3, HighQC: certifiedQC("b2", 2, "alice", "bob", "carol")}, + } + quorums := [][]MemberID{ + {"alice", "bob", "carol"}, + {"bob", "carol", "dave"}, + } + + certificates := make([]TimeoutCertificate, 0, len(quorums)) + for _, quorum := range quorums { + set := NewTimeoutSet(committee, 3) + for _, voter := range quorum { + require.NoError(t, set.Add(timeouts[voter])) + } + certificate, ok := set.Certificate() + require.True(t, ok) + require.NoError(t, committee.requireQuorum(certificate.Signers)) + certificates = append(certificates, certificate) + } + + require.Equal(t, View(3), certificates[0].View) + require.Equal(t, certificates[0].View, certificates[1].View) + require.Equal(t, certifiedQC("b2", 2, "alice", "bob", "carol"), certificates[0].HighQC) + require.Equal(t, certificates[0].HighQC, certificates[1].HighQC) + require.NotEqual(t, certificates[0].Signers, certificates[1].Signers, + "valid certificates may carry different quorum witnesses") +} + +func TestPacemakerTimeoutChangesLeaderWithoutProducingBlock(t *testing.T) { + committee := testCommittee(t, "alice", "bob", "carol") + pacemaker := NewPacemaker(committee, 3) + require.Equal(t, MemberID("carol"), pacemaker.Leader()) + + err := pacemaker.AdvanceTimeout(TimeoutCertificate{ + View: 3, + HighQC: certifiedQC("b2", 2, "alice", "bob", "carol"), + Signers: []MemberID{"alice", "bob", "carol"}, + }) + require.NoError(t, err) + require.Equal(t, View(4), pacemaker.CurrentView()) + require.Equal(t, MemberID("alice"), pacemaker.Leader()) + require.Equal(t, certifiedQC("b2", 2, "alice", "bob", "carol"), pacemaker.HighQC()) +} + +func TestPacemakerQCAdvancesToNextLeader(t *testing.T) { + committee := testCommittee(t, "alice", "bob", "carol") + pacemaker := NewPacemaker(committee, 1) + + err := pacemaker.AdvanceQC(certifiedQC("b1", 1, "carol", "alice", "bob")) + require.NoError(t, err) + require.Equal(t, View(2), pacemaker.CurrentView()) + require.Equal(t, MemberID("bob"), pacemaker.Leader()) + require.Equal(t, certifiedQC("b1", 1, "alice", "bob", "carol"), pacemaker.HighQC()) +} + +func TestPacemakerRejectsStaleAndUnderpoweredCertificates(t *testing.T) { + committee := testCommittee(t, "alice", "bob", "carol", "dave") + pacemaker := NewPacemaker(committee, 4) + + err := pacemaker.AdvanceTimeout(TimeoutCertificate{ + View: 3, + Signers: []MemberID{"alice", "bob", "carol"}, + }) + require.ErrorIs(t, err, ErrStaleCertificate) + + err = pacemaker.AdvanceTimeout(TimeoutCertificate{ + View: 4, + Signers: []MemberID{"alice", "bob"}, + }) + require.ErrorIs(t, err, ErrInsufficientVotingPower) + require.Equal(t, View(4), pacemaker.CurrentView()) +} + +func TestPacemakerRejectsFutureTimeoutCertificate(t *testing.T) { + committee := testCommittee(t, "alice", "bob", "carol") + pacemaker := NewPacemaker(committee, 4) + + err := pacemaker.AdvanceTimeout(TimeoutCertificate{ + View: 6, + HighQC: certifiedQC("b5", 5, "alice", "bob", "carol"), + Signers: []MemberID{"alice", "bob", "carol"}, + }) + require.ErrorIs(t, err, ErrWrongCertificateView) + require.Equal(t, View(4), pacemaker.CurrentView()) +} + +func TestTimeoutPathRejectsUnderpoweredHighQC(t *testing.T) { + committee := testCommittee(t, "alice", "bob", "carol", "dave") + underpowered := certifiedQC("b2", 2, "alice", "bob") + + set := NewTimeoutSet(committee, 3) + err := set.Add(Timeout{Voter: "alice", View: 3, HighQC: underpowered}) + require.ErrorIs(t, err, ErrInsufficientVotingPower) + + pacemaker := NewPacemaker(committee, 3) + err = pacemaker.AdvanceTimeout(TimeoutCertificate{ + View: 3, + HighQC: underpowered, + Signers: []MemberID{"alice", "bob", "carol"}, + }) + require.ErrorIs(t, err, ErrInsufficientVotingPower) + require.Equal(t, View(3), pacemaker.CurrentView()) + require.Equal(t, QC{}, pacemaker.HighQC()) +} + +func TestPacemakerOwnsHighQCEvidence(t *testing.T) { + committee := testCommittee(t, "alice", "bob", "carol") + pacemaker := NewPacemaker(committee, 1) + qc := certifiedQC("b1", 1, "alice", "bob", "carol") + + require.NoError(t, pacemaker.AdvanceQC(qc)) + qc.Signers[0] = "mallory" + state := pacemaker.HighQC() + state.Signers[0] = "mallory" + + require.Equal(t, certifiedQC("b1", 1, "alice", "bob", "carol"), pacemaker.HighQC()) +} + +func TestTimeoutSetRejectsDuplicateAndWrongView(t *testing.T) { + committee := testCommittee(t, "alice", "bob", "carol") + set := NewTimeoutSet(committee, 2) + timeout := Timeout{ + Voter: "alice", + View: 2, + HighQC: certifiedQC("b1", 1, "alice", "bob", "carol"), + } + + require.NoError(t, set.Add(timeout)) + require.ErrorIs(t, set.Add(timeout), ErrDuplicateTimeout) + require.ErrorIs(t, set.Add(Timeout{Voter: "bob", View: 3}), ErrWrongTimeoutView) + require.ErrorIs(t, set.Add(Timeout{Voter: "mallory", View: 2}), ErrUnknownVoter) +} + +func testCommittee(t *testing.T, ids ...MemberID) *Committee { + t.Helper() + members := make([]Member, 0, len(ids)) + for _, id := range ids { + members = append(members, Member{ID: id, Power: 1}) + } + committee, err := NewCommittee(members) + require.NoError(t, err) + return committee +} + +func certifiedQC(block BlockID, view View, signers ...MemberID) QC { + return QC{Block: block, View: view, Signers: signers} +} From 5c03319b5552bf146160658a1ccc914bc2a33e7b Mon Sep 17 00:00:00 2001 From: frozen <355847+Frozen@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:43:03 -0400 Subject: [PATCH 05/13] consensus: add BLS-backed HotStuff QCs --- consensus/hotstuff/bls.go | 275 +++++++++++++++++++++++++++++++++ consensus/hotstuff/bls_test.go | 143 +++++++++++++++++ consensus/hotstuff/doc.go | 7 +- 3 files changed, 422 insertions(+), 3 deletions(-) create mode 100644 consensus/hotstuff/bls.go create mode 100644 consensus/hotstuff/bls_test.go diff --git a/consensus/hotstuff/bls.go b/consensus/hotstuff/bls.go new file mode 100644 index 0000000000..9f3b5a6947 --- /dev/null +++ b/consensus/hotstuff/bls.go @@ -0,0 +1,275 @@ +package hotstuff + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "errors" + "math" + "sync" + + hmybls "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" +) + +const hotStuffVoteDomain = "harmony/hotstuff/vote/v1" + +var ( + ErrDuplicateBLSPublicKey = errors.New("hotstuff committee contains a duplicate BLS public key") + ErrInvalidBLSPublicKey = errors.New("hotstuff committee contains an invalid BLS public key") + ErrInvalidVoteSignature = errors.New("hotstuff vote has an invalid BLS signature") + ErrInvalidQCSignature = errors.New("hotstuff QC has an invalid aggregate BLS signature") + ErrInvalidQCBitmap = errors.New("hotstuff QC bitmap does not match its signers") + ErrNonCanonicalQCSigners = errors.New("hotstuff QC signers are not in canonical committee order") + ErrNilBLSSecretKey = errors.New("hotstuff cannot sign with a nil BLS secret key") + ErrBlockIDTooLong = errors.New("hotstuff block ID is too long to sign") +) + +// VoteDomain prevents a HotStuff vote from being replayed across chains, +// shards, epochs, or other consensus message types. +type VoteDomain struct { + ChainID uint32 + ShardID uint32 + Epoch uint64 +} + +// BLSMember must contain a key already admitted by Harmony's validator-key +// registry after proof-of-possession validation. +type BLSMember struct { + Member + PublicKey hmybls.PublicKeyWrapper +} + +// BLSCommittee binds the structural voting-power committee to Harmony BLS +// public keys in the same canonical member order. +type BLSCommittee struct { + committee *Committee + members []BLSMember + byID map[MemberID]BLSMember + publics []hmybls.PublicKeyWrapper +} + +// NewBLSCommitteeFromValidatedKeys constructs a committee from keys that have +// already passed staking/types.VerifyBLSKey (or an equivalent registry check). +// It must not be called directly on public keys supplied by a network peer. +func NewBLSCommitteeFromValidatedKeys(members []BLSMember) (*BLSCommittee, error) { + structuralMembers := make([]Member, 0, len(members)) + for _, member := range members { + structuralMembers = append(structuralMembers, member.Member) + } + committee, err := NewCommittee(structuralMembers) + if err != nil { + return nil, err + } + + result := &BLSCommittee{ + committee: committee, + members: make([]BLSMember, 0, len(members)), + byID: make(map[MemberID]BLSMember, len(members)), + publics: make([]hmybls.PublicKeyWrapper, 0, len(members)), + } + seenKeys := make(map[hmybls.SerializedPublicKey]struct{}, len(members)) + for _, member := range members { + if member.PublicKey.Bytes.IsEmpty() { + return nil, ErrInvalidBLSPublicKey + } + if _, exists := seenKeys[member.PublicKey.Bytes]; exists { + return nil, ErrDuplicateBLSPublicKey + } + + public := &bls_core.PublicKey{} + if err := public.Deserialize(member.PublicKey.Bytes.Bytes()); err != nil { + return nil, ErrInvalidBLSPublicKey + } + owned := BLSMember{ + Member: member.Member, + PublicKey: hmybls.PublicKeyWrapper{ + Bytes: member.PublicKey.Bytes, + Object: public, + }, + } + seenKeys[owned.PublicKey.Bytes] = struct{}{} + result.members = append(result.members, owned) + result.byID[owned.ID] = owned + result.publics = append(result.publics, owned.PublicKey) + } + return result, nil +} + +type SignedVote struct { + Vote Vote + Signature []byte +} + +type BLSQC struct { + QC QC + Signature []byte + Bitmap []byte +} + +func SignVote(domain VoteDomain, vote Vote, secret *bls_core.SecretKey) (SignedVote, error) { + if secret == nil { + return SignedVote{}, ErrNilBLSSecretKey + } + digest, err := voteDigest(domain, vote) + if err != nil { + return SignedVote{}, err + } + signature := secret.SignHash(digest[:]) + if signature == nil { + return SignedVote{}, ErrInvalidVoteSignature + } + return SignedVote{ + Vote: vote, + Signature: append([]byte(nil), signature.Serialize()...), + }, nil +} + +// BLSVoteSet verifies individual broadcast votes before passing their voting +// power to the structural collector and aggregates a QC at quorum. +type BLSVoteSet struct { + mu sync.Mutex + committee *BLSCommittee + votes *VoteSet + domain VoteDomain + signatures map[MemberID]*bls_core.Sign +} + +func NewBLSVoteSet(committee *BLSCommittee, block BlockID, view View, domain VoteDomain) *BLSVoteSet { + return &BLSVoteSet{ + committee: committee, + votes: NewVoteSet(committee.committee, block, view), + domain: domain, + signatures: make(map[MemberID]*bls_core.Sign), + } +} + +func (s *BLSVoteSet) Add(vote SignedVote) error { + s.mu.Lock() + defer s.mu.Unlock() + + member, exists := s.committee.byID[vote.Vote.Voter] + if !exists { + return ErrUnknownVoter + } + signature, err := deserializeSignature(vote.Signature, ErrInvalidVoteSignature) + if err != nil { + return err + } + digest, err := voteDigest(s.domain, vote.Vote) + if err != nil { + return err + } + if !signature.VerifyHash(member.PublicKey.Object, digest[:]) { + return ErrInvalidVoteSignature + } + if err := s.votes.Add(vote.Vote); err != nil { + return err + } + s.signatures[vote.Vote.Voter] = signature + return nil +} + +func (s *BLSVoteSet) QC() (BLSQC, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + qc, formed := s.votes.QC() + if !formed { + return BLSQC{}, false + } + signatures := make([]*bls_core.Sign, 0, len(qc.Signers)) + mask := hmybls.NewMask(s.committee.publics) + for _, signer := range qc.Signers { + signatures = append(signatures, s.signatures[signer]) + member := s.committee.byID[signer] + if err := mask.SetKey(member.PublicKey.Bytes, true); err != nil { + return BLSQC{}, false + } + } + aggregate := hmybls.AggregateSig(signatures) + return BLSQC{ + QC: qc, + Signature: append([]byte(nil), aggregate.Serialize()...), + Bitmap: mask.Mask(), + }, true +} + +func (c *BLSCommittee) VerifyQC(domain VoteDomain, qc BLSQC) error { + if err := c.committee.requireQC(qc.QC); err != nil { + return err + } + canonical := c.committee.canonicalQC(qc.QC) + if !equalMemberIDs(qc.QC.Signers, canonical.Signers) { + return ErrNonCanonicalQCSigners + } + mask := hmybls.NewMask(c.publics) + for _, signer := range qc.QC.Signers { + member := c.byID[signer] + if err := mask.SetKey(member.PublicKey.Bytes, true); err != nil { + return ErrInvalidQCBitmap + } + } + if !bytes.Equal(mask.Mask(), qc.Bitmap) { + return ErrInvalidQCBitmap + } + signature, err := deserializeSignature(qc.Signature, ErrInvalidQCSignature) + if err != nil { + return err + } + digest, err := voteDigest(domain, Vote{Block: qc.QC.Block, View: qc.QC.View}) + if err != nil { + return err + } + if !signature.VerifyHash(mask.AggregatePublic, digest[:]) { + return ErrInvalidQCSignature + } + return nil +} + +func equalMemberIDs(left, right []MemberID) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i] != right[i] { + return false + } + } + return true +} + +func deserializeSignature(serialized []byte, invalid error) (*bls_core.Sign, error) { + if len(serialized) != hmybls.BLSSignatureSizeInBytes { + return nil, invalid + } + signature := &bls_core.Sign{} + if err := signature.Deserialize(append([]byte(nil), serialized...)); err != nil { + return nil, invalid + } + return signature, nil +} + +func voteDigest(domain VoteDomain, vote Vote) ([sha256.Size]byte, error) { + if uint64(len(vote.Block)) > uint64(math.MaxUint32) { + return [sha256.Size]byte{}, ErrBlockIDTooLong + } + hasher := sha256.New() + _, _ = hasher.Write([]byte(hotStuffVoteDomain)) + var fixed [8]byte + binary.BigEndian.PutUint32(fixed[:4], domain.ChainID) + _, _ = hasher.Write(fixed[:4]) + binary.BigEndian.PutUint32(fixed[:4], domain.ShardID) + _, _ = hasher.Write(fixed[:4]) + binary.BigEndian.PutUint64(fixed[:], domain.Epoch) + _, _ = hasher.Write(fixed[:]) + binary.BigEndian.PutUint64(fixed[:], uint64(vote.View)) + _, _ = hasher.Write(fixed[:]) + binary.BigEndian.PutUint32(fixed[:4], uint32(len(vote.Block))) + _, _ = hasher.Write(fixed[:4]) + _, _ = hasher.Write([]byte(vote.Block)) + + var digest [sha256.Size]byte + copy(digest[:], hasher.Sum(nil)) + return digest, nil +} diff --git a/consensus/hotstuff/bls_test.go b/consensus/hotstuff/bls_test.go new file mode 100644 index 0000000000..1b2be15948 --- /dev/null +++ b/consensus/hotstuff/bls_test.go @@ -0,0 +1,143 @@ +package hotstuff + +import ( + "testing" + + hmybls "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" + "github.com/stretchr/testify/require" +) + +func TestBLSSignedBroadcastVotesFormVerifiableQC(t *testing.T) { + committee, secrets := testBLSCommittee(t, + Member{ID: "alice", Power: 1}, + Member{ID: "bob", Power: 1}, + Member{ID: "carol", Power: 1}, + Member{ID: "dave", Power: 1}, + ) + domain := VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42} + set := NewBLSVoteSet(committee, "b7", 7, domain) + + for _, voter := range []MemberID{"carol", "alice", "bob"} { + signed, err := SignVote(domain, Vote{Voter: voter, Block: "b7", View: 7}, secrets[voter]) + require.NoError(t, err) + require.NoError(t, set.Add(signed)) + } + + qc, formed := set.QC() + require.True(t, formed) + require.Equal(t, QC{ + Block: "b7", + View: 7, + Signers: []MemberID{"alice", "bob", "carol"}, + }, qc.QC) + require.Len(t, qc.Signature, hmybls.BLSSignatureSizeInBytes) + require.Equal(t, []byte{0b00000111}, qc.Bitmap) + require.NoError(t, committee.VerifyQC(domain, qc)) +} + +func TestBLSSignedVoteCannotBeReplayedAcrossDomains(t *testing.T) { + committee, secrets := testBLSCommittee(t, + Member{ID: "alice", Power: 1}, + Member{ID: "bob", Power: 1}, + Member{ID: "carol", Power: 1}, + ) + vote := Vote{Voter: "alice", Block: "b7", View: 7} + signed, err := SignVote(VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42}, vote, secrets["alice"]) + require.NoError(t, err) + + otherEpoch := NewBLSVoteSet( + committee, "b7", 7, + VoteDomain{ChainID: 1, ShardID: 0, Epoch: 43}, + ) + require.ErrorIs(t, otherEpoch.Add(signed), ErrInvalidVoteSignature) +} + +func TestBLSQCVerificationRejectsTampering(t *testing.T) { + committee, secrets := testBLSCommittee(t, + Member{ID: "alice", Power: 1}, + Member{ID: "bob", Power: 1}, + Member{ID: "carol", Power: 1}, + ) + domain := VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42} + set := NewBLSVoteSet(committee, "b7", 7, domain) + for _, voter := range []MemberID{"alice", "bob", "carol"} { + signed, err := SignVote(domain, Vote{Voter: voter, Block: "b7", View: 7}, secrets[voter]) + require.NoError(t, err) + require.NoError(t, set.Add(signed)) + } + qc, formed := set.QC() + require.True(t, formed) + + tamperedBlock := cloneBLSQC(qc) + tamperedBlock.QC.Block = "mallory" + require.ErrorIs(t, committee.VerifyQC(domain, tamperedBlock), ErrInvalidQCSignature) + + tamperedBitmap := cloneBLSQC(qc) + tamperedBitmap.Bitmap[0] ^= 1 << 3 + require.ErrorIs(t, committee.VerifyQC(domain, tamperedBitmap), ErrInvalidQCBitmap) + + reorderedSigners := cloneBLSQC(qc) + reorderedSigners.QC.Signers[0], reorderedSigners.QC.Signers[1] = + reorderedSigners.QC.Signers[1], reorderedSigners.QC.Signers[0] + require.ErrorIs(t, committee.VerifyQC(domain, reorderedSigners), ErrNonCanonicalQCSigners) +} + +func TestBLSVoteSetUsesWeightedQuorum(t *testing.T) { + committee, secrets := testBLSCommittee(t, + Member{ID: "alice", Power: 3}, + Member{ID: "bob", Power: 1}, + Member{ID: "carol", Power: 1}, + Member{ID: "dave", Power: 1}, + ) + domain := VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42} + set := NewBLSVoteSet(committee, "b7", 7, domain) + + for _, voter := range []MemberID{"alice", "bob"} { + signed, err := SignVote(domain, Vote{Voter: voter, Block: "b7", View: 7}, secrets[voter]) + require.NoError(t, err) + require.NoError(t, set.Add(signed)) + } + _, formed := set.QC() + require.False(t, formed, "four of six voting power is not strictly above two thirds") + + signed, err := SignVote(domain, Vote{Voter: "carol", Block: "b7", View: 7}, secrets["carol"]) + require.NoError(t, err) + require.NoError(t, set.Add(signed)) + qc, formed := set.QC() + require.True(t, formed) + require.NoError(t, committee.VerifyQC(domain, qc)) +} + +func TestBLSCommitteeRejectsDuplicatePublicKey(t *testing.T) { + secret := hmybls.RandPrivateKey() + wrapper := hmybls.WrapperFromPrivateKey(secret) + _, err := NewBLSCommitteeFromValidatedKeys([]BLSMember{ + {Member: Member{ID: "alice", Power: 1}, PublicKey: *wrapper.Pub}, + {Member: Member{ID: "bob", Power: 1}, PublicKey: *wrapper.Pub}, + }) + require.ErrorIs(t, err, ErrDuplicateBLSPublicKey) +} + +func cloneBLSQC(qc BLSQC) BLSQC { + return BLSQC{ + QC: cloneQC(qc.QC), + Signature: append([]byte(nil), qc.Signature...), + Bitmap: append([]byte(nil), qc.Bitmap...), + } +} + +func testBLSCommittee(t *testing.T, members ...Member) (*BLSCommittee, map[MemberID]*bls_core.SecretKey) { + t.Helper() + blsMembers := make([]BLSMember, 0, len(members)) + secrets := make(map[MemberID]*bls_core.SecretKey, len(members)) + for _, member := range members { + secret := hmybls.RandPrivateKey() + wrapper := hmybls.WrapperFromPrivateKey(secret) + blsMembers = append(blsMembers, BLSMember{Member: member, PublicKey: *wrapper.Pub}) + secrets[member.ID] = secret + } + committee, err := NewBLSCommitteeFromValidatedKeys(blsMembers) + require.NoError(t, err) + return committee, secrets +} diff --git a/consensus/hotstuff/doc.go b/consensus/hotstuff/doc.go index bad27f7a5c..bef5cf837d 100644 --- a/consensus/hotstuff/doc.go +++ b/consensus/hotstuff/doc.go @@ -1,7 +1,8 @@ // Package hotstuff contains an isolated chained HotStuff research spike. // -// It is not wired into Harmony consensus, networking, persistence, block -// verification, or BLS signatures. The package currently validates leader -// rotation, broadcast vote aggregation, structural QCs, and the direct +// It is not wired into Harmony consensus, networking, durable storage, block +// execution, wire formats, or production activation. The package currently +// validates leader rotation, safety rules, pacemaker timeouts, broadcast vote +// aggregation with Harmony BLS signatures, structural QCs, and the direct // three-chain commit rule. package hotstuff From 6edb900dbd978f54b1392b69fc177850135835db Mon Sep 17 00:00:00 2001 From: frozen <355847+Frozen@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:35:28 -0400 Subject: [PATCH 06/13] consensus: gate HotStuff state on verified QCs --- consensus/hotstuff/api_test.go | 44 ++++++ consensus/hotstuff/bls.go | 11 +- consensus/hotstuff/bls_test.go | 16 +- consensus/hotstuff/certified.go | 124 +++++++++++++++ consensus/hotstuff/certified_test.go | 226 +++++++++++++++++++++++++++ consensus/hotstuff/core.go | 14 +- consensus/hotstuff/core_test.go | 12 +- consensus/hotstuff/doc.go | 4 +- consensus/hotstuff/pacemaker.go | 7 +- consensus/hotstuff/pacemaker_test.go | 26 +-- consensus/hotstuff/safety_test.go | 22 +-- 11 files changed, 458 insertions(+), 48 deletions(-) create mode 100644 consensus/hotstuff/api_test.go create mode 100644 consensus/hotstuff/certified.go create mode 100644 consensus/hotstuff/certified_test.go diff --git a/consensus/hotstuff/api_test.go b/consensus/hotstuff/api_test.go new file mode 100644 index 0000000000..622a1640fd --- /dev/null +++ b/consensus/hotstuff/api_test.go @@ -0,0 +1,44 @@ +package hotstuff_test + +import ( + "testing" + + "github.com/harmony-one/harmony/consensus/hotstuff" + hmybls "github.com/harmony-one/harmony/crypto/bls" + "github.com/stretchr/testify/require" +) + +func TestAuthorityConstructsUsableExternalStateMachines(t *testing.T) { + members := make([]hotstuff.BLSMember, 0, 3) + for _, id := range []hotstuff.MemberID{"alice", "bob", "carol"} { + wrapper := hmybls.WrapperFromPrivateKey(hmybls.RandPrivateKey()) + members = append(members, hotstuff.BLSMember{ + Member: hotstuff.Member{ID: id, Power: 1}, + PublicKey: *wrapper.Pub, + }) + } + committee, err := hotstuff.NewBLSCommitteeFromValidatedKeys(members) + require.NoError(t, err) + authority := hotstuff.NewQCAuthority( + committee, + hotstuff.VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"}, + ) + core, genesis, err := authority.NewCore(hotstuff.Block{ID: "genesis", View: 0}) + require.NoError(t, err) + _, err = authority.Accept(core, hotstuff.Block{ + ID: "b1", + Parent: "genesis", + View: 1, + Justify: genesis.QC(), + }, genesis) + require.NoError(t, err) + + pacemaker, err := authority.NewPacemaker(1) + require.NoError(t, err) + require.Equal(t, hotstuff.View(1), pacemaker.CurrentView()) + require.Equal(t, hotstuff.BlockID("genesis"), pacemaker.HighQC().Block) + + _, _, err = authority.NewCore(hotstuff.Block{ID: "attacker-chosen", View: 0}) + require.ErrorIs(t, err, hotstuff.ErrGenesisRootMismatch) + require.Equal(t, hotstuff.BlockID("genesis"), pacemaker.HighQC().Block) +} diff --git a/consensus/hotstuff/bls.go b/consensus/hotstuff/bls.go index 9f3b5a6947..e856bfa0ac 100644 --- a/consensus/hotstuff/bls.go +++ b/consensus/hotstuff/bls.go @@ -12,7 +12,7 @@ import ( bls_core "github.com/harmony-one/harmony/crypto/bls/core" ) -const hotStuffVoteDomain = "harmony/hotstuff/vote/v1" +const hotStuffVoteDomain = "harmony/hotstuff/vote/v2" var ( ErrDuplicateBLSPublicKey = errors.New("hotstuff committee contains a duplicate BLS public key") @@ -26,11 +26,12 @@ var ( ) // VoteDomain prevents a HotStuff vote from being replayed across chains, -// shards, epochs, or other consensus message types. +// shards, epochs, genesis roots, or other consensus message types. type VoteDomain struct { ChainID uint32 ShardID uint32 Epoch uint64 + Genesis BlockID } // BLSMember must contain a key already admitted by Harmony's validator-key @@ -251,7 +252,8 @@ func deserializeSignature(serialized []byte, invalid error) (*bls_core.Sign, err } func voteDigest(domain VoteDomain, vote Vote) ([sha256.Size]byte, error) { - if uint64(len(vote.Block)) > uint64(math.MaxUint32) { + if uint64(len(domain.Genesis)) > uint64(math.MaxUint32) || + uint64(len(vote.Block)) > uint64(math.MaxUint32) { return [sha256.Size]byte{}, ErrBlockIDTooLong } hasher := sha256.New() @@ -263,6 +265,9 @@ func voteDigest(domain VoteDomain, vote Vote) ([sha256.Size]byte, error) { _, _ = hasher.Write(fixed[:4]) binary.BigEndian.PutUint64(fixed[:], domain.Epoch) _, _ = hasher.Write(fixed[:]) + binary.BigEndian.PutUint32(fixed[:4], uint32(len(domain.Genesis))) + _, _ = hasher.Write(fixed[:4]) + _, _ = hasher.Write([]byte(domain.Genesis)) binary.BigEndian.PutUint64(fixed[:], uint64(vote.View)) _, _ = hasher.Write(fixed[:]) binary.BigEndian.PutUint32(fixed[:4], uint32(len(vote.Block))) diff --git a/consensus/hotstuff/bls_test.go b/consensus/hotstuff/bls_test.go index 1b2be15948..fc129f543f 100644 --- a/consensus/hotstuff/bls_test.go +++ b/consensus/hotstuff/bls_test.go @@ -15,7 +15,7 @@ func TestBLSSignedBroadcastVotesFormVerifiableQC(t *testing.T) { Member{ID: "carol", Power: 1}, Member{ID: "dave", Power: 1}, ) - domain := VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42} + domain := VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"} set := NewBLSVoteSet(committee, "b7", 7, domain) for _, voter := range []MemberID{"carol", "alice", "bob"} { @@ -43,14 +43,20 @@ func TestBLSSignedVoteCannotBeReplayedAcrossDomains(t *testing.T) { Member{ID: "carol", Power: 1}, ) vote := Vote{Voter: "alice", Block: "b7", View: 7} - signed, err := SignVote(VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42}, vote, secrets["alice"]) + signed, err := SignVote(VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"}, vote, secrets["alice"]) require.NoError(t, err) otherEpoch := NewBLSVoteSet( committee, "b7", 7, - VoteDomain{ChainID: 1, ShardID: 0, Epoch: 43}, + VoteDomain{ChainID: 1, ShardID: 0, Epoch: 43, Genesis: "genesis"}, ) require.ErrorIs(t, otherEpoch.Add(signed), ErrInvalidVoteSignature) + + otherGenesis := NewBLSVoteSet( + committee, "b7", 7, + VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "foreign-genesis"}, + ) + require.ErrorIs(t, otherGenesis.Add(signed), ErrInvalidVoteSignature) } func TestBLSQCVerificationRejectsTampering(t *testing.T) { @@ -59,7 +65,7 @@ func TestBLSQCVerificationRejectsTampering(t *testing.T) { Member{ID: "bob", Power: 1}, Member{ID: "carol", Power: 1}, ) - domain := VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42} + domain := VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"} set := NewBLSVoteSet(committee, "b7", 7, domain) for _, voter := range []MemberID{"alice", "bob", "carol"} { signed, err := SignVote(domain, Vote{Voter: voter, Block: "b7", View: 7}, secrets[voter]) @@ -90,7 +96,7 @@ func TestBLSVoteSetUsesWeightedQuorum(t *testing.T) { Member{ID: "carol", Power: 1}, Member{ID: "dave", Power: 1}, ) - domain := VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42} + domain := VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"} set := NewBLSVoteSet(committee, "b7", 7, domain) for _, voter := range []MemberID{"alice", "bob"} { diff --git a/consensus/hotstuff/certified.go b/consensus/hotstuff/certified.go new file mode 100644 index 0000000000..38da0052a5 --- /dev/null +++ b/consensus/hotstuff/certified.go @@ -0,0 +1,124 @@ +package hotstuff + +import ( + "errors" + "sync" +) + +var ( + ErrCertifiedQCMismatch = errors.New("hotstuff verified QC does not match the proposal justify") + ErrWrongQCAuthority = errors.New("hotstuff object belongs to a different committee or domain authority") + ErrGenesisRootMismatch = errors.New("hotstuff authority is already bound to a different genesis root") + ErrMissingGenesisRoot = errors.New("hotstuff authority has no configured genesis root") + ErrInvalidInitialView = errors.New("hotstuff initial view must be after the genesis view") +) + +// QCAuthority binds certificate verification and structural state machines to +// one committee and vote domain. A node creates one authority for its active +// epoch and shard, then constructs its Core and Pacemaker through that object. +type QCAuthority struct { + committee *BLSCommittee + domain VoteDomain + mu sync.Mutex + genesis QC + configured bool +} + +func NewQCAuthority(committee *BLSCommittee, domain VoteDomain) *QCAuthority { + return &QCAuthority{committee: committee, domain: domain} +} + +// VerifiedQC is an immutable capability issued by one QCAuthority after BLS +// verification or for that authority's configured genesis trust root. +type VerifiedQC struct { + qc QC + authority *QCAuthority +} + +// QC returns an owned copy of the verified structural certificate. +func (v VerifiedQC) QC() QC { + return cloneQC(v.qc) +} + +// Verify validates aggregate BLS evidence in this authority's committee and +// domain, then mints the capability accepted by its state machines. +func (a *QCAuthority) Verify(certificate BLSQC) (VerifiedQC, error) { + if err := a.committee.VerifyQC(a.domain, certificate); err != nil { + return VerifiedQC{}, err + } + return VerifiedQC{qc: cloneQC(certificate.QC), authority: a}, nil +} + +// NewCore validates and binds a genesis trust root to this authority. Returning +// the genesis capability here prevents another authority from minting one for +// an already configured Core. +func (a *QCAuthority) NewCore(genesis Block) (*Core, VerifiedQC, error) { + core := newCore(genesis) + if len(core.blocks) == 0 { + return nil, VerifiedQC{}, ErrInvalidGenesis + } + if a.domain.Genesis == "" || genesis.ID != a.domain.Genesis { + return nil, VerifiedQC{}, ErrGenesisRootMismatch + } + a.mu.Lock() + defer a.mu.Unlock() + genesisQC := QC{Block: genesis.ID, View: genesis.View} + if a.configured && !sameStructuralQC(a.genesis, genesisQC) { + return nil, VerifiedQC{}, ErrGenesisRootMismatch + } + if !a.configured { + a.genesis = cloneQC(genesisQC) + a.configured = true + } + core.authority = a + verifiedGenesis := VerifiedQC{ + qc: cloneQC(a.genesis), + authority: a, + } + return core, verifiedGenesis, nil +} + +// NewPacemaker binds all successful-view transitions to this authority. The +// structural timeout transition remains private until timeout messages carry +// verifiable BLS evidence as well. +func (a *QCAuthority) NewPacemaker(initial View) (*Pacemaker, error) { + a.mu.Lock() + defer a.mu.Unlock() + if !a.configured { + return nil, ErrMissingGenesisRoot + } + if initial <= a.genesis.View { + return nil, ErrInvalidInitialView + } + pacemaker := newPacemaker(a.committee.committee, initial) + pacemaker.authority = a + pacemaker.highQC = cloneQC(a.genesis) + return pacemaker, nil +} + +// Accept is the certificate-gated proposal ingress. The crypto-independent +// Core keeps accept private so network adapters cannot submit structural QCs. +func (a *QCAuthority) Accept(core *Core, block Block, justify VerifiedQC) ([]BlockID, error) { + block = cloneBlock(block) + if core.authority != a || justify.authority != a { + return nil, ErrWrongQCAuthority + } + if !sameStructuralQC(block.Justify, justify.qc) { + return nil, ErrCertifiedQCMismatch + } + return core.accept(block) +} + +// Advance is the certificate-gated successful-view transition. +func (a *QCAuthority) Advance(pacemaker *Pacemaker, qc VerifiedQC) error { + if pacemaker.authority != a || qc.authority != a { + return ErrWrongQCAuthority + } + return pacemaker.advanceQC(qc.qc) +} + +func sameStructuralQC(left, right QC) bool { + return left.Block == right.Block && + left.View == right.View && + equalMemberIDs(left.Signers, right.Signers) +} diff --git a/consensus/hotstuff/certified_test.go b/consensus/hotstuff/certified_test.go new file mode 100644 index 0000000000..fdd01879ad --- /dev/null +++ b/consensus/hotstuff/certified_test.go @@ -0,0 +1,226 @@ +package hotstuff + +import ( + "testing" + + bls_core "github.com/harmony-one/harmony/crypto/bls/core" + "github.com/stretchr/testify/require" +) + +func TestVerifiedQCGatesCoreAndPacemaker(t *testing.T) { + members := []Member{ + {ID: "alice", Power: 1}, + {ID: "bob", Power: 1}, + {ID: "carol", Power: 1}, + {ID: "dave", Power: 1}, + } + committee, secrets := testBLSCommittee(t, members...) + domain := VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"} + authority := NewQCAuthority(committee, domain) + core, genesis, err := authority.NewCore(Block{ID: "genesis", View: 0}) + require.NoError(t, err) + + b1 := Block{ID: "b1", Parent: "genesis", View: 1, Justify: genesis.QC()} + _, err = authority.Accept(core, b1, genesis) + require.NoError(t, err) + + certificate := formBLSQC(t, committee, secrets, domain, "b1", 1) + verified, err := authority.Verify(certificate) + require.NoError(t, err) + + pacemaker, err := authority.NewPacemaker(1) + require.NoError(t, err) + require.NoError(t, authority.Advance(pacemaker, verified)) + require.Equal(t, View(2), pacemaker.CurrentView()) + require.Equal(t, QC{Block: "b1", View: 1, Signers: []MemberID{"alice", "bob", "carol"}}, pacemaker.HighQC()) + + b2 := Block{ID: "b2", Parent: "b1", View: 2, Justify: verified.QC()} + _, err = authority.Accept(core, b2, verified) + require.NoError(t, err) +} + +func TestAcceptCertifiedRejectsProofForDifferentQC(t *testing.T) { + committee, secrets := testBLSCommittee(t, + Member{ID: "alice", Power: 1}, + Member{ID: "bob", Power: 1}, + Member{ID: "carol", Power: 1}, + ) + domain := VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"} + authority := NewQCAuthority(committee, domain) + core, genesis, err := authority.NewCore(Block{ID: "genesis", View: 0}) + require.NoError(t, err) + _, err = authority.Accept( + core, + Block{ID: "b1", Parent: "genesis", View: 1, Justify: genesis.QC()}, + genesis, + ) + require.NoError(t, err) + + certificate := formBLSQC(t, committee, secrets, domain, "b1", 1) + verified, err := authority.Verify(certificate) + require.NoError(t, err) + mismatch := Block{ID: "b2", Parent: "b1", View: 2, Justify: QC{Block: "mallory", View: 1}} + + _, err = authority.Accept(core, mismatch, verified) + require.ErrorIs(t, err, ErrCertifiedQCMismatch) +} + +func TestForgedBLSQCCannotBecomeVerifiedQC(t *testing.T) { + committee, secrets := testBLSCommittee(t, + Member{ID: "alice", Power: 1}, + Member{ID: "bob", Power: 1}, + Member{ID: "carol", Power: 1}, + ) + domain := VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"} + authority := NewQCAuthority(committee, domain) + certificate := formBLSQC(t, committee, secrets, domain, "b1", 1) + certificate.Signature[0] ^= 0xff + _, _, err := authority.NewCore(Block{ID: "genesis", View: 0}) + require.NoError(t, err) + pacemaker, err := authority.NewPacemaker(1) + require.NoError(t, err) + + _, err = authority.Verify(certificate) + require.Error(t, err) + require.Equal(t, View(1), pacemaker.CurrentView()) + require.Equal(t, QC{Block: "genesis", View: 0}, pacemaker.HighQC()) +} + +func TestAuthorityCannotDriveStateMachinesBoundToAnotherDomain(t *testing.T) { + committee, secrets := testBLSCommittee(t, + Member{ID: "alice", Power: 1}, + Member{ID: "bob", Power: 1}, + Member{ID: "carol", Power: 1}, + ) + domainA := VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"} + domainB := VoteDomain{ChainID: 1, ShardID: 0, Epoch: 43, Genesis: "genesis"} + authorityA := NewQCAuthority(committee, domainA) + authorityB := NewQCAuthority(committee, domainB) + + certificateB := formBLSQC(t, committee, secrets, domainB, "b1", 1) + verifiedB, err := authorityB.Verify(certificateB) + require.NoError(t, err) + _, _, err = authorityA.NewCore(Block{ID: "genesis", View: 0}) + require.NoError(t, err) + pacemakerA, err := authorityA.NewPacemaker(1) + require.NoError(t, err) + require.ErrorIs(t, authorityB.Advance(pacemakerA, verifiedB), ErrWrongQCAuthority) + require.Equal(t, View(1), pacemakerA.CurrentView()) + + coreA, _, err := authorityA.NewCore(Block{ID: "genesis", View: 0}) + require.NoError(t, err) + _, genesisB, err := authorityB.NewCore(Block{ID: "genesis", View: 0}) + require.NoError(t, err) + _, err = authorityB.Accept( + coreA, + Block{ID: "b1", Parent: "genesis", View: 1, Justify: genesisB.QC()}, + genesisB, + ) + require.ErrorIs(t, err, ErrWrongQCAuthority) + require.Equal(t, BlockID("genesis"), coreA.Committed()) +} + +func TestVerifiedQCOwnsCertificateEvidence(t *testing.T) { + committee, secrets := testBLSCommittee(t, + Member{ID: "alice", Power: 1}, + Member{ID: "bob", Power: 1}, + Member{ID: "carol", Power: 1}, + ) + domain := VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"} + authority := NewQCAuthority(committee, domain) + certificate := formBLSQC(t, committee, secrets, domain, "b1", 1) + verified, err := authority.Verify(certificate) + require.NoError(t, err) + + certificate.QC.Signers[0] = "mallory" + first := verified.QC() + require.Equal(t, []MemberID{"alice", "bob", "carol"}, first.Signers) + first.Signers[0] = "mallory" + require.Equal(t, []MemberID{"alice", "bob", "carol"}, verified.QC().Signers) +} + +func TestQCAuthorityDoesNotTrustNonzeroViewGenesis(t *testing.T) { + committee, _ := testBLSCommittee(t, + Member{ID: "alice", Power: 1}, + Member{ID: "bob", Power: 1}, + Member{ID: "carol", Power: 1}, + ) + authority := NewQCAuthority(committee, VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"}) + _, _, err := authority.NewCore(Block{ID: "fake-genesis", View: 7}) + require.ErrorIs(t, err, ErrInvalidGenesis) +} + +func TestQCAuthorityRejectsSecondGenesisRoot(t *testing.T) { + committee, _ := testBLSCommittee(t, + Member{ID: "alice", Power: 1}, + Member{ID: "bob", Power: 1}, + Member{ID: "carol", Power: 1}, + ) + authority := NewQCAuthority(committee, VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"}) + _, genesis, err := authority.NewCore(Block{ID: "genesis", View: 0}) + require.NoError(t, err) + pacemaker, err := authority.NewPacemaker(1) + require.NoError(t, err) + require.Equal(t, genesis.QC(), pacemaker.HighQC()) + + _, _, err = authority.NewCore(Block{ID: "attacker-chosen", View: 0}) + require.ErrorIs(t, err, ErrGenesisRootMismatch) + require.Equal(t, BlockID("genesis"), pacemaker.HighQC().Block) +} + +func TestQCAuthorityRequiresGenesisBeforePacemaker(t *testing.T) { + committee, _ := testBLSCommittee(t, + Member{ID: "alice", Power: 1}, + Member{ID: "bob", Power: 1}, + Member{ID: "carol", Power: 1}, + ) + authority := NewQCAuthority(committee, VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"}) + _, err := authority.NewPacemaker(1) + require.ErrorIs(t, err, ErrMissingGenesisRoot) + + _, _, err = authority.NewCore(Block{ID: "genesis", View: 0}) + require.NoError(t, err) + _, err = authority.NewPacemaker(0) + require.ErrorIs(t, err, ErrInvalidInitialView) +} + +func TestPacemakerRejectsQCFromForeignGenesis(t *testing.T) { + committee, secrets := testBLSCommittee(t, + Member{ID: "alice", Power: 1}, + Member{ID: "bob", Power: 1}, + Member{ID: "carol", Power: 1}, + ) + domain := VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"} + authority := NewQCAuthority(committee, domain) + _, _, err := authority.NewCore(Block{ID: "genesis", View: 0}) + require.NoError(t, err) + pacemaker, err := authority.NewPacemaker(1) + require.NoError(t, err) + + foreignDomain := domain + foreignDomain.Genesis = "foreign-genesis" + foreignQC := formBLSQC(t, committee, secrets, foreignDomain, "foreign-root-block", 1) + _, err = authority.Verify(foreignQC) + require.ErrorIs(t, err, ErrInvalidQCSignature) + require.Equal(t, QC{Block: "genesis", View: 0}, pacemaker.HighQC()) +} + +func formBLSQC( + t *testing.T, + committee *BLSCommittee, + secrets map[MemberID]*bls_core.SecretKey, + domain VoteDomain, + block BlockID, + view View, +) BLSQC { + t.Helper() + set := NewBLSVoteSet(committee, block, view, domain) + for _, voter := range []MemberID{"carol", "alice", "bob"} { + signed, err := SignVote(domain, Vote{Voter: voter, Block: block, View: view}, secrets[voter]) + require.NoError(t, err) + require.NoError(t, set.Add(signed)) + } + qc, formed := set.QC() + require.True(t, formed) + return qc +} diff --git a/consensus/hotstuff/core.go b/consensus/hotstuff/core.go index 39e07bb69c..d393b7b019 100644 --- a/consensus/hotstuff/core.go +++ b/consensus/hotstuff/core.go @@ -21,13 +21,16 @@ var ( type Core struct { mu sync.RWMutex blocks map[BlockID]Block + genesis BlockID committed BlockID + authority *QCAuthority } -func NewCore(genesis Block) *Core { +func newCore(genesis Block) *Core { core := &Core{blocks: make(map[BlockID]Block)} - if genesis.ID != "" && genesis.Parent == "" { + if genesis.ID != "" && genesis.Parent == "" && genesis.View == 0 { core.blocks[genesis.ID] = cloneBlock(genesis) + core.genesis = genesis.ID core.committed = genesis.ID } return core @@ -79,9 +82,10 @@ func (c *Core) lockQC(proposal Block) (QC, bool) { return cloneQC(parent.Justify), true } -// Accept validates the proposal's structural QC and applies the direct -// three-chain commit rule. Returned IDs are newly committed in chain order. -func (c *Core) Accept(block Block) ([]BlockID, error) { +// accept validates the proposal's structural QC and applies the direct +// three-chain commit rule after the caller has verified its certificate. +// Returned IDs are newly committed in chain order. +func (c *Core) accept(block Block) ([]BlockID, error) { c.mu.Lock() defer c.mu.Unlock() if len(c.blocks) == 0 { diff --git a/consensus/hotstuff/core_test.go b/consensus/hotstuff/core_test.go index f979abbcf5..b8a0fcdbb1 100644 --- a/consensus/hotstuff/core_test.go +++ b/consensus/hotstuff/core_test.go @@ -105,7 +105,7 @@ func TestVoteSetSerializesConcurrentDuplicateVoter(t *testing.T) { } func TestDirectThreeChainCommitsGreatGrandparent(t *testing.T) { - core := NewCore(Block{ID: "genesis", View: 0}) + core := newCore(Block{ID: "genesis", View: 0}) chain := []Block{ {ID: "b1", Parent: "genesis", View: 1, Justify: QC{Block: "genesis", View: 0}}, @@ -115,24 +115,24 @@ func TestDirectThreeChainCommitsGreatGrandparent(t *testing.T) { } for _, block := range chain[:3] { - committed, err := core.Accept(block) + committed, err := core.accept(block) require.NoError(t, err) require.Empty(t, committed) } - committed, err := core.Accept(chain[3]) + committed, err := core.accept(chain[3]) require.NoError(t, err) require.Equal(t, []BlockID{"b1"}, committed) require.Equal(t, BlockID("b1"), core.Committed()) } func TestCoreRejectsProposalWhoseQCDoesNotJustifyParent(t *testing.T) { - core := NewCore(Block{ID: "genesis", View: 0}) + core := newCore(Block{ID: "genesis", View: 0}) require.NoError(t, acceptWithoutCommit(core, Block{ID: "b1", Parent: "genesis", View: 1, Justify: QC{Block: "genesis", View: 0}}, )) - _, err := core.Accept(Block{ + _, err := core.accept(Block{ ID: "bad", Parent: "b1", View: 2, @@ -142,6 +142,6 @@ func TestCoreRejectsProposalWhoseQCDoesNotJustifyParent(t *testing.T) { } func acceptWithoutCommit(core *Core, block Block) error { - _, err := core.Accept(block) + _, err := core.accept(block) return err } diff --git a/consensus/hotstuff/doc.go b/consensus/hotstuff/doc.go index bef5cf837d..aea00c6625 100644 --- a/consensus/hotstuff/doc.go +++ b/consensus/hotstuff/doc.go @@ -3,6 +3,6 @@ // It is not wired into Harmony consensus, networking, durable storage, block // execution, wire formats, or production activation. The package currently // validates leader rotation, safety rules, pacemaker timeouts, broadcast vote -// aggregation with Harmony BLS signatures, structural QCs, and the direct -// three-chain commit rule. +// aggregation with Harmony BLS signatures, authority-bound verified QCs, +// structural block processing, and the direct three-chain commit rule. package hotstuff diff --git a/consensus/hotstuff/pacemaker.go b/consensus/hotstuff/pacemaker.go index b7f5feb919..d282a73a12 100644 --- a/consensus/hotstuff/pacemaker.go +++ b/consensus/hotstuff/pacemaker.go @@ -108,9 +108,10 @@ type Pacemaker struct { committee *Committee view View highQC QC + authority *QCAuthority } -func NewPacemaker(committee *Committee, initial View) *Pacemaker { +func newPacemaker(committee *Committee, initial View) *Pacemaker { return &Pacemaker{committee: committee, view: initial} } @@ -132,7 +133,7 @@ func (p *Pacemaker) HighQC() QC { return cloneQC(p.highQC) } -func (p *Pacemaker) AdvanceTimeout(certificate TimeoutCertificate) error { +func (p *Pacemaker) advanceTimeout(certificate TimeoutCertificate) error { p.mu.Lock() defer p.mu.Unlock() if certificate.View < p.view { @@ -159,7 +160,7 @@ func (p *Pacemaker) AdvanceTimeout(certificate TimeoutCertificate) error { return nil } -func (p *Pacemaker) AdvanceQC(qc QC) error { +func (p *Pacemaker) advanceQC(qc QC) error { p.mu.Lock() defer p.mu.Unlock() if qc.View < p.view { diff --git a/consensus/hotstuff/pacemaker_test.go b/consensus/hotstuff/pacemaker_test.go index 3560b98e3f..e6f333bccf 100644 --- a/consensus/hotstuff/pacemaker_test.go +++ b/consensus/hotstuff/pacemaker_test.go @@ -41,10 +41,10 @@ func TestBroadcastTimeoutsProduceEquivalentCertificatesAtEveryReplica(t *testing func TestPacemakerTimeoutChangesLeaderWithoutProducingBlock(t *testing.T) { committee := testCommittee(t, "alice", "bob", "carol") - pacemaker := NewPacemaker(committee, 3) + pacemaker := newPacemaker(committee, 3) require.Equal(t, MemberID("carol"), pacemaker.Leader()) - err := pacemaker.AdvanceTimeout(TimeoutCertificate{ + err := pacemaker.advanceTimeout(TimeoutCertificate{ View: 3, HighQC: certifiedQC("b2", 2, "alice", "bob", "carol"), Signers: []MemberID{"alice", "bob", "carol"}, @@ -57,9 +57,9 @@ func TestPacemakerTimeoutChangesLeaderWithoutProducingBlock(t *testing.T) { func TestPacemakerQCAdvancesToNextLeader(t *testing.T) { committee := testCommittee(t, "alice", "bob", "carol") - pacemaker := NewPacemaker(committee, 1) + pacemaker := newPacemaker(committee, 1) - err := pacemaker.AdvanceQC(certifiedQC("b1", 1, "carol", "alice", "bob")) + err := pacemaker.advanceQC(certifiedQC("b1", 1, "carol", "alice", "bob")) require.NoError(t, err) require.Equal(t, View(2), pacemaker.CurrentView()) require.Equal(t, MemberID("bob"), pacemaker.Leader()) @@ -68,15 +68,15 @@ func TestPacemakerQCAdvancesToNextLeader(t *testing.T) { func TestPacemakerRejectsStaleAndUnderpoweredCertificates(t *testing.T) { committee := testCommittee(t, "alice", "bob", "carol", "dave") - pacemaker := NewPacemaker(committee, 4) + pacemaker := newPacemaker(committee, 4) - err := pacemaker.AdvanceTimeout(TimeoutCertificate{ + err := pacemaker.advanceTimeout(TimeoutCertificate{ View: 3, Signers: []MemberID{"alice", "bob", "carol"}, }) require.ErrorIs(t, err, ErrStaleCertificate) - err = pacemaker.AdvanceTimeout(TimeoutCertificate{ + err = pacemaker.advanceTimeout(TimeoutCertificate{ View: 4, Signers: []MemberID{"alice", "bob"}, }) @@ -86,9 +86,9 @@ func TestPacemakerRejectsStaleAndUnderpoweredCertificates(t *testing.T) { func TestPacemakerRejectsFutureTimeoutCertificate(t *testing.T) { committee := testCommittee(t, "alice", "bob", "carol") - pacemaker := NewPacemaker(committee, 4) + pacemaker := newPacemaker(committee, 4) - err := pacemaker.AdvanceTimeout(TimeoutCertificate{ + err := pacemaker.advanceTimeout(TimeoutCertificate{ View: 6, HighQC: certifiedQC("b5", 5, "alice", "bob", "carol"), Signers: []MemberID{"alice", "bob", "carol"}, @@ -105,8 +105,8 @@ func TestTimeoutPathRejectsUnderpoweredHighQC(t *testing.T) { err := set.Add(Timeout{Voter: "alice", View: 3, HighQC: underpowered}) require.ErrorIs(t, err, ErrInsufficientVotingPower) - pacemaker := NewPacemaker(committee, 3) - err = pacemaker.AdvanceTimeout(TimeoutCertificate{ + pacemaker := newPacemaker(committee, 3) + err = pacemaker.advanceTimeout(TimeoutCertificate{ View: 3, HighQC: underpowered, Signers: []MemberID{"alice", "bob", "carol"}, @@ -118,10 +118,10 @@ func TestTimeoutPathRejectsUnderpoweredHighQC(t *testing.T) { func TestPacemakerOwnsHighQCEvidence(t *testing.T) { committee := testCommittee(t, "alice", "bob", "carol") - pacemaker := NewPacemaker(committee, 1) + pacemaker := newPacemaker(committee, 1) qc := certifiedQC("b1", 1, "alice", "bob", "carol") - require.NoError(t, pacemaker.AdvanceQC(qc)) + require.NoError(t, pacemaker.advanceQC(qc)) qc.Signers[0] = "mallory" state := pacemaker.HighQC() state.Signers[0] = "mallory" diff --git a/consensus/hotstuff/safety_test.go b/consensus/hotstuff/safety_test.go index 02310cfa68..0003f910f8 100644 --- a/consensus/hotstuff/safety_test.go +++ b/consensus/hotstuff/safety_test.go @@ -10,7 +10,7 @@ import ( ) func TestSafetyRulesRejectsSecondVoteInSameView(t *testing.T) { - core := NewCore(Block{ID: "genesis", View: 0}) + core := newCore(Block{ID: "genesis", View: 0}) persist := &recordingPersister{} rules := NewSafetyRules(core, SafetyState{ LockedQC: QC{Block: "genesis", View: 0}, @@ -29,7 +29,7 @@ func TestSafetyRulesRejectsSecondVoteInSameView(t *testing.T) { } func TestSafetyRulesVotesForDescendantOfLockedBlock(t *testing.T) { - core := NewCore(Block{ID: "genesis", View: 0}) + core := newCore(Block{ID: "genesis", View: 0}) b1 := Block{ID: "b1", Parent: "genesis", View: 1, Justify: QC{Block: "genesis", View: 0}} b2 := Block{ID: "b2", Parent: "b1", View: 2, Justify: QC{Block: "b1", View: 1}} require.NoError(t, acceptWithoutCommit(core, b1)) @@ -52,7 +52,7 @@ func TestSafetyRulesVotesForDescendantOfLockedBlock(t *testing.T) { } func TestSafetyRulesUnlocksForHigherQCOnConflictingBranch(t *testing.T) { - core := NewCore(Block{ID: "genesis", View: 0}) + core := newCore(Block{ID: "genesis", View: 0}) locked := Block{ID: "locked", Parent: "genesis", View: 1, Justify: QC{Block: "genesis", View: 0}} fork1 := Block{ID: "fork1", Parent: "genesis", View: 2, Justify: QC{Block: "genesis", View: 0}} fork2 := Block{ID: "fork2", Parent: "fork1", View: 3, Justify: QC{Block: "fork1", View: 2}} @@ -72,7 +72,7 @@ func TestSafetyRulesUnlocksForHigherQCOnConflictingBranch(t *testing.T) { } func TestSafetyRulesRejectsConflictingBranchWithoutHigherQC(t *testing.T) { - core := NewCore(Block{ID: "genesis", View: 0}) + core := newCore(Block{ID: "genesis", View: 0}) locked := Block{ID: "locked", Parent: "genesis", View: 2, Justify: QC{Block: "genesis", View: 0}} fork := Block{ID: "fork", Parent: "genesis", View: 3, Justify: QC{Block: "genesis", View: 0}} for _, block := range []Block{locked, fork} { @@ -91,7 +91,7 @@ func TestSafetyRulesRejectsConflictingBranchWithoutHigherQC(t *testing.T) { } func TestSafetyRulesRejectsConflictingBranchWithEqualQC(t *testing.T) { - core := NewCore(Block{ID: "genesis", View: 0}) + core := newCore(Block{ID: "genesis", View: 0}) locked := Block{ID: "locked", Parent: "genesis", View: 2, Justify: QC{Block: "genesis", View: 0}} fork := Block{ID: "fork", Parent: "genesis", View: 2, Justify: QC{Block: "genesis", View: 0}} proposal := Block{ID: "proposal", Parent: "fork", View: 3, Justify: QC{Block: "fork", View: 2}} @@ -109,7 +109,7 @@ func TestSafetyRulesRejectsConflictingBranchWithEqualQC(t *testing.T) { } func TestSafetyRulesRestoresLastVoteAcrossRestart(t *testing.T) { - core := NewCore(Block{ID: "genesis", View: 0}) + core := newCore(Block{ID: "genesis", View: 0}) left := Block{ID: "left", Parent: "genesis", View: 1, Justify: QC{Block: "genesis", View: 0}} right := Block{ID: "right", Parent: "genesis", View: 1, Justify: QC{Block: "genesis", View: 0}} require.NoError(t, acceptWithoutCommit(core, left)) @@ -131,7 +131,7 @@ func TestSafetyRulesRestoresLastVoteAcrossRestart(t *testing.T) { } func TestSafetyRulesRetainsHigherLock(t *testing.T) { - core := NewCore(Block{ID: "genesis", View: 0}) + core := newCore(Block{ID: "genesis", View: 0}) low := Block{ID: "low", Parent: "genesis", View: 1, Justify: QC{Block: "genesis", View: 0}} locked := Block{ID: "locked", Parent: "genesis", View: 5, Justify: QC{Block: "genesis", View: 0}} parent := Block{ID: "parent", Parent: "low", View: 6, Justify: QC{Block: "low", View: 1}} @@ -151,7 +151,7 @@ func TestSafetyRulesRetainsHigherLock(t *testing.T) { } func TestSafetyRulesPersistsBeforeReturningVote(t *testing.T) { - core := NewCore(Block{ID: "genesis", View: 0}) + core := newCore(Block{ID: "genesis", View: 0}) b1 := Block{ID: "b1", Parent: "genesis", View: 1, Justify: QC{Block: "genesis", View: 0}} require.NoError(t, acceptWithoutCommit(core, b1)) @@ -168,7 +168,7 @@ func TestSafetyRulesPersistsBeforeReturningVote(t *testing.T) { } func TestSafetyRulesDoesNotShareStateWithPersister(t *testing.T) { - core := NewCore(Block{ID: "genesis", View: 0}) + core := newCore(Block{ID: "genesis", View: 0}) b1 := Block{ ID: "b1", Parent: "genesis", @@ -204,7 +204,7 @@ func TestSafetyRulesDoesNotShareStateWithPersister(t *testing.T) { } func TestSafetyRulesSerializesConflictingVotes(t *testing.T) { - core := NewCore(Block{ID: "genesis", View: 0}) + core := newCore(Block{ID: "genesis", View: 0}) left := Block{ID: "left", Parent: "genesis", View: 1, Justify: QC{Block: "genesis", View: 0}} right := Block{ID: "right", Parent: "genesis", View: 1, Justify: QC{Block: "genesis", View: 0}} require.NoError(t, acceptWithoutCommit(core, left)) @@ -250,7 +250,7 @@ func TestSafetyRulesSerializesConflictingVotes(t *testing.T) { } func TestCoreOwnsAcceptedQCEvidence(t *testing.T) { - core := NewCore(Block{ID: "genesis", View: 0}) + core := newCore(Block{ID: "genesis", View: 0}) b1 := Block{ID: "b1", Parent: "genesis", View: 1, Justify: QC{Block: "genesis", View: 0}} b2 := Block{ ID: "b2", From 8a2574328713a3206c49d71d1dd45b0f82925ec3 Mon Sep 17 00:00:00 2001 From: frozen <355847+Frozen@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:02:44 -0400 Subject: [PATCH 07/13] consensus: add verified HotStuff timeout certificates --- consensus/hotstuff/bls_timeout.go | 384 +++++++++++++++++++++++++ consensus/hotstuff/bls_timeout_test.go | 275 ++++++++++++++++++ consensus/hotstuff/doc.go | 5 +- 3 files changed, 662 insertions(+), 2 deletions(-) create mode 100644 consensus/hotstuff/bls_timeout.go create mode 100644 consensus/hotstuff/bls_timeout_test.go diff --git a/consensus/hotstuff/bls_timeout.go b/consensus/hotstuff/bls_timeout.go new file mode 100644 index 0000000000..b47d3deff1 --- /dev/null +++ b/consensus/hotstuff/bls_timeout.go @@ -0,0 +1,384 @@ +package hotstuff + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "errors" + "math" + "sync" + + hmybls "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" +) + +const ( + hotStuffTimeoutDomain = "harmony/hotstuff/timeout/v1" + hotStuffTimeoutHighQCDomain = "harmony/hotstuff/timeout-high-qc/v1" +) + +var ( + ErrInvalidTimeoutSignature = errors.New("hotstuff timeout has an invalid BLS signature") + ErrInvalidTimeoutHighQCSig = errors.New("hotstuff timeout HighQC binding has an invalid BLS signature") + ErrInvalidTCSignature = errors.New("hotstuff TC has an invalid aggregate BLS signature") + ErrInvalidTCBitmap = errors.New("hotstuff TC bitmap does not match its signers") + ErrNonCanonicalTCSigners = errors.New("hotstuff TC signers are not in canonical committee order") + ErrTimeoutHighQCMismatch = errors.New("hotstuff timeout HighQC does not match its cryptographic evidence") + ErrInvalidTCReports = errors.New("hotstuff TC reports do not match its signers") + ErrConflictingHighQC = errors.New("hotstuff TC reports conflicting QCs at the same view") +) + +// SignedTimeout has a compact view-abandonment signature for aggregation and a +// second signature binding this sender to its independently certified HighQC. +type SignedTimeout struct { + Timeout + Signature []byte + HighQCSignature []byte +} + +// BLSTimeoutReport preserves one signer's authenticated HighQC claim. +type BLSTimeoutReport struct { + Voter MemberID + HighQC BLSQC + Signature []byte +} + +// BLSTimeoutCertificate proves weighted abandonment of View and preserves the +// signed reports from which every verifier independently selects HighQC. +type BLSTimeoutCertificate struct { + View View + HighQC BLSQC + Signers []MemberID + Reports []BLSTimeoutReport + Signature []byte + Bitmap []byte +} + +// VerifiedTC is an opaque, authority-bound timeout transition capability. +type VerifiedTC struct { + certificate TimeoutCertificate + authority *QCAuthority +} + +func SignTimeout(domain VoteDomain, timeout Timeout, secret *bls_core.SecretKey) (SignedTimeout, error) { + if secret == nil { + return SignedTimeout{}, ErrNilBLSSecretKey + } + if timeout.HighQC.View > timeout.View { + return SignedTimeout{}, ErrFutureQC + } + viewDigest, err := timeoutDigest(domain, timeout.View) + if err != nil { + return SignedTimeout{}, err + } + viewSignature := secret.SignHash(viewDigest[:]) + if viewSignature == nil { + return SignedTimeout{}, ErrInvalidTimeoutSignature + } + highQCDigest, err := timeoutHighQCDigest(domain, timeout) + if err != nil { + return SignedTimeout{}, err + } + highQCSignature := secret.SignHash(highQCDigest[:]) + if highQCSignature == nil { + return SignedTimeout{}, ErrInvalidTimeoutHighQCSig + } + return SignedTimeout{ + Timeout: Timeout{ + Voter: timeout.Voter, + View: timeout.View, + HighQC: cloneQC(timeout.HighQC), + }, + Signature: append([]byte(nil), viewSignature.Serialize()...), + HighQCSignature: append([]byte(nil), highQCSignature.Serialize()...), + }, nil +} + +// BLSTimeoutSet verifies timeout signatures and carried QC evidence before +// admitting voting power to the structural timeout collector. +type BLSTimeoutSet struct { + mu sync.Mutex + authority *QCAuthority + timeouts *TimeoutSet + signatures map[MemberID]*bls_core.Sign + reports map[MemberID]BLSTimeoutReport + highQC BLSQC +} + +func NewBLSTimeoutSet(authority *QCAuthority, view View) *BLSTimeoutSet { + return &BLSTimeoutSet{ + authority: authority, + timeouts: NewTimeoutSet(authority.committee.committee, view), + signatures: make(map[MemberID]*bls_core.Sign), + reports: make(map[MemberID]BLSTimeoutReport), + } +} + +func (s *BLSTimeoutSet) Add(timeout SignedTimeout, evidence BLSQC) error { + s.mu.Lock() + defer s.mu.Unlock() + + verifiedHighQC, err := s.authority.verifyQCEvidence(evidence) + if err != nil { + return err + } + if !sameStructuralQC(timeout.HighQC, verifiedHighQC.qc) { + return ErrTimeoutHighQCMismatch + } + member, exists := s.authority.committee.byID[timeout.Voter] + if !exists { + return ErrUnknownVoter + } + viewSignature, err := deserializeSignature(timeout.Signature, ErrInvalidTimeoutSignature) + if err != nil { + return err + } + viewDigest, err := timeoutDigest(s.authority.domain, timeout.View) + if err != nil { + return err + } + if !viewSignature.VerifyHash(member.PublicKey.Object, viewDigest[:]) { + return ErrInvalidTimeoutSignature + } + highQCSignature, err := deserializeSignature(timeout.HighQCSignature, ErrInvalidTimeoutHighQCSig) + if err != nil { + return err + } + highQCDigest, err := timeoutHighQCDigest(s.authority.domain, timeout.Timeout) + if err != nil { + return err + } + if !highQCSignature.VerifyHash(member.PublicKey.Object, highQCDigest[:]) { + return ErrInvalidTimeoutHighQCSig + } + if err := s.timeouts.Add(timeout.Timeout); err != nil { + return err + } + s.signatures[timeout.Voter] = viewSignature + s.reports[timeout.Voter] = BLSTimeoutReport{ + Voter: timeout.Voter, + HighQC: cloneBLSQCEvidence(evidence), + Signature: append([]byte(nil), timeout.HighQCSignature...), + } + if s.highQC.QC.Block == "" || higherQC(verifiedHighQC.qc, s.highQC.QC) { + s.highQC = cloneBLSQCEvidence(evidence) + } + return nil +} + +func (s *BLSTimeoutSet) Certificate() (BLSTimeoutCertificate, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + certificate, formed := s.timeouts.Certificate() + if !formed { + return BLSTimeoutCertificate{}, false + } + signatures := make([]*bls_core.Sign, 0, len(certificate.Signers)) + reports := make([]BLSTimeoutReport, 0, len(certificate.Signers)) + mask := hmybls.NewMask(s.authority.committee.publics) + for _, signer := range certificate.Signers { + signatures = append(signatures, s.signatures[signer]) + reports = append(reports, cloneBLSTimeoutReport(s.reports[signer])) + member := s.authority.committee.byID[signer] + if err := mask.SetKey(member.PublicKey.Bytes, true); err != nil { + return BLSTimeoutCertificate{}, false + } + } + aggregate := hmybls.AggregateSig(signatures) + return BLSTimeoutCertificate{ + View: certificate.View, + HighQC: cloneBLSQCEvidence(s.highQC), + Signers: append([]MemberID(nil), certificate.Signers...), + Reports: reports, + Signature: append([]byte(nil), aggregate.Serialize()...), + Bitmap: mask.Mask(), + }, true +} + +func (a *QCAuthority) VerifyTC(certificate BLSTimeoutCertificate) (VerifiedTC, error) { + if err := a.committee.committee.requireQuorum(certificate.Signers); err != nil { + return VerifiedTC{}, err + } + canonical := a.committee.committee.canonicalQC(QC{Signers: certificate.Signers}) + if !equalMemberIDs(certificate.Signers, canonical.Signers) { + return VerifiedTC{}, ErrNonCanonicalTCSigners + } + if len(certificate.Reports) != len(certificate.Signers) { + return VerifiedTC{}, ErrInvalidTCReports + } + + mask := hmybls.NewMask(a.committee.publics) + var selected BLSQC + blocksByView := make(map[View]BlockID, len(certificate.Reports)) + for index, signer := range certificate.Signers { + member := a.committee.byID[signer] + if err := mask.SetKey(member.PublicKey.Bytes, true); err != nil { + return VerifiedTC{}, ErrInvalidTCBitmap + } + report := certificate.Reports[index] + if report.Voter != signer { + return VerifiedTC{}, ErrInvalidTCReports + } + verifiedHighQC, err := a.verifyQCEvidence(report.HighQC) + if err != nil { + return VerifiedTC{}, err + } + if verifiedHighQC.qc.View > certificate.View { + return VerifiedTC{}, ErrFutureQC + } + reportSignature, err := deserializeSignature(report.Signature, ErrInvalidTimeoutHighQCSig) + if err != nil { + return VerifiedTC{}, err + } + reportDigest, err := timeoutHighQCDigest(a.domain, Timeout{ + Voter: signer, View: certificate.View, HighQC: verifiedHighQC.qc, + }) + if err != nil { + return VerifiedTC{}, err + } + if !reportSignature.VerifyHash(member.PublicKey.Object, reportDigest[:]) { + return VerifiedTC{}, ErrInvalidTimeoutHighQCSig + } + if block, exists := blocksByView[verifiedHighQC.qc.View]; exists && + block != verifiedHighQC.qc.Block { + return VerifiedTC{}, ErrConflictingHighQC + } + blocksByView[verifiedHighQC.qc.View] = verifiedHighQC.qc.Block + if selected.QC.Block == "" || higherQC(verifiedHighQC.qc, selected.QC) { + selected = cloneBLSQCEvidence(report.HighQC) + } + } + if !bytes.Equal(mask.Mask(), certificate.Bitmap) { + return VerifiedTC{}, ErrInvalidTCBitmap + } + viewSignature, err := deserializeSignature(certificate.Signature, ErrInvalidTCSignature) + if err != nil { + return VerifiedTC{}, err + } + viewDigest, err := timeoutDigest(a.domain, certificate.View) + if err != nil { + return VerifiedTC{}, err + } + if !viewSignature.VerifyHash(mask.AggregatePublic, viewDigest[:]) { + return VerifiedTC{}, ErrInvalidTCSignature + } + if !sameStructuralQC(certificate.HighQC.QC, selected.QC) { + return VerifiedTC{}, ErrTimeoutHighQCMismatch + } + selectedVerified, err := a.verifyQCEvidence(selected) + if err != nil { + return VerifiedTC{}, err + } + return VerifiedTC{ + certificate: TimeoutCertificate{ + View: certificate.View, + HighQC: cloneQC(selectedVerified.qc), + Signers: append([]MemberID(nil), certificate.Signers...), + }, + authority: a, + }, nil +} + +func (a *QCAuthority) AdvanceTimeout(pacemaker *Pacemaker, certificate VerifiedTC) error { + if pacemaker.authority != a || certificate.authority != a { + return ErrWrongQCAuthority + } + return pacemaker.advanceTimeout(certificate.certificate) +} + +func (a *QCAuthority) verifyQCEvidence(evidence BLSQC) (VerifiedQC, error) { + a.mu.Lock() + if !a.configured { + a.mu.Unlock() + return VerifiedQC{}, ErrMissingGenesisRoot + } + genesis := cloneQC(a.genesis) + a.mu.Unlock() + + if evidence.QC.View == 0 { + if !sameStructuralQC(evidence.QC, genesis) || len(evidence.Signature) != 0 || len(evidence.Bitmap) != 0 { + return VerifiedQC{}, ErrGenesisRootMismatch + } + return VerifiedQC{qc: genesis, authority: a}, nil + } + return a.Verify(evidence) +} + +func cloneBLSQCEvidence(evidence BLSQC) BLSQC { + return BLSQC{ + QC: cloneQC(evidence.QC), + Signature: append([]byte(nil), evidence.Signature...), + Bitmap: append([]byte(nil), evidence.Bitmap...), + } +} + +func cloneBLSTimeoutReport(report BLSTimeoutReport) BLSTimeoutReport { + return BLSTimeoutReport{ + Voter: report.Voter, + HighQC: cloneBLSQCEvidence(report.HighQC), + Signature: append([]byte(nil), report.Signature...), + } +} + +func timeoutDigest(domain VoteDomain, view View) ([sha256.Size]byte, error) { + return timeoutDomainDigest(hotStuffTimeoutDomain, domain, view, nil) +} + +func timeoutHighQCDigest(domain VoteDomain, timeout Timeout) ([sha256.Size]byte, error) { + if uint64(len(timeout.Voter)) > uint64(math.MaxUint32) || + uint64(len(timeout.HighQC.Block)) > uint64(math.MaxUint32) || + uint64(len(timeout.HighQC.Signers)) > uint64(math.MaxUint32) { + return [sha256.Size]byte{}, ErrBlockIDTooLong + } + extra := make([]byte, 0) + extra = appendUint32String(extra, string(timeout.Voter)) + var fixed [8]byte + binary.BigEndian.PutUint64(fixed[:], uint64(timeout.HighQC.View)) + extra = append(extra, fixed[:]...) + extra = appendUint32String(extra, string(timeout.HighQC.Block)) + binary.BigEndian.PutUint32(fixed[:4], uint32(len(timeout.HighQC.Signers))) + extra = append(extra, fixed[:4]...) + for _, signer := range timeout.HighQC.Signers { + if uint64(len(signer)) > uint64(math.MaxUint32) { + return [sha256.Size]byte{}, ErrBlockIDTooLong + } + extra = appendUint32String(extra, string(signer)) + } + return timeoutDomainDigest(hotStuffTimeoutHighQCDomain, domain, timeout.View, extra) +} + +func timeoutDomainDigest(tag string, domain VoteDomain, view View, extra []byte) ([sha256.Size]byte, error) { + if domain.Genesis == "" { + return [sha256.Size]byte{}, ErrMissingGenesisRoot + } + if uint64(len(domain.Genesis)) > uint64(math.MaxUint32) { + return [sha256.Size]byte{}, ErrBlockIDTooLong + } + hasher := sha256.New() + _, _ = hasher.Write([]byte(tag)) + var fixed [8]byte + binary.BigEndian.PutUint32(fixed[:4], domain.ChainID) + _, _ = hasher.Write(fixed[:4]) + binary.BigEndian.PutUint32(fixed[:4], domain.ShardID) + _, _ = hasher.Write(fixed[:4]) + binary.BigEndian.PutUint64(fixed[:], domain.Epoch) + _, _ = hasher.Write(fixed[:]) + binary.BigEndian.PutUint32(fixed[:4], uint32(len(domain.Genesis))) + _, _ = hasher.Write(fixed[:4]) + _, _ = hasher.Write([]byte(domain.Genesis)) + binary.BigEndian.PutUint64(fixed[:], uint64(view)) + _, _ = hasher.Write(fixed[:]) + _, _ = hasher.Write(extra) + + var digest [sha256.Size]byte + copy(digest[:], hasher.Sum(nil)) + return digest, nil +} + +func appendUint32String(target []byte, value string) []byte { + var length [4]byte + binary.BigEndian.PutUint32(length[:], uint32(len(value))) + target = append(target, length[:]...) + return append(target, value...) +} diff --git a/consensus/hotstuff/bls_timeout_test.go b/consensus/hotstuff/bls_timeout_test.go new file mode 100644 index 0000000000..8f66e232b0 --- /dev/null +++ b/consensus/hotstuff/bls_timeout_test.go @@ -0,0 +1,275 @@ +package hotstuff + +import ( + "testing" + + bls_core "github.com/harmony-one/harmony/crypto/bls/core" + "github.com/stretchr/testify/require" +) + +func TestBLSSignedTimeoutsFormVerifiableTCAndAdvancePacemaker(t *testing.T) { + committee, secrets := testBLSCommittee(t, + Member{ID: "alice", Power: 1}, + Member{ID: "bob", Power: 1}, + Member{ID: "carol", Power: 1}, + Member{ID: "dave", Power: 1}, + ) + domain := VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"} + authority := NewQCAuthority(committee, domain) + _, genesis, err := authority.NewCore(Block{ID: "genesis", View: 0}) + require.NoError(t, err) + pacemaker, err := authority.NewPacemaker(1) + require.NoError(t, err) + + set := NewBLSTimeoutSet(authority, 1) + for index, voter := range []MemberID{"carol", "alice", "bob"} { + signed, err := SignTimeout( + domain, + Timeout{Voter: voter, View: 1, HighQC: genesis.QC()}, + secrets[voter], + ) + require.NoError(t, err) + require.NoError(t, set.Add(signed, BLSQC{QC: genesis.QC()})) + if index == 1 { + _, formed := set.Certificate() + require.False(t, formed) + } + } + + certificate, formed := set.Certificate() + require.True(t, formed) + require.Equal(t, []MemberID{"alice", "bob", "carol"}, certificate.Signers) + require.Equal(t, []byte{0b00000111}, certificate.Bitmap) + verified, err := authority.VerifyTC(certificate) + require.NoError(t, err) + require.NoError(t, authority.AdvanceTimeout(pacemaker, verified)) + require.Equal(t, View(2), pacemaker.CurrentView()) + require.Equal(t, genesis.QC(), pacemaker.HighQC()) +} + +func TestBLSTimeoutRejectsForeignGenesisAndForgedSignature(t *testing.T) { + committee, secrets := testBLSCommittee(t, + Member{ID: "alice", Power: 1}, + Member{ID: "bob", Power: 1}, + Member{ID: "carol", Power: 1}, + ) + domain := VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"} + authority := NewQCAuthority(committee, domain) + _, genesis, err := authority.NewCore(Block{ID: "genesis", View: 0}) + require.NoError(t, err) + + signed, err := SignTimeout( + domain, + Timeout{Voter: "alice", View: 1, HighQC: genesis.QC()}, + secrets["alice"], + ) + require.NoError(t, err) + forged := signed + forged.Signature = append([]byte(nil), signed.Signature...) + forged.Signature[0] ^= 0xff + set := NewBLSTimeoutSet(authority, 1) + require.ErrorIs(t, set.Add(forged, BLSQC{QC: genesis.QC()}), ErrInvalidTimeoutSignature) + + foreign := BLSQC{QC: QC{Block: "foreign-genesis", View: 0}} + require.ErrorIs(t, set.Add(signed, foreign), ErrGenesisRootMismatch) +} + +func TestBLSTimeoutCertificateRejectsTamperingAndWrongAuthority(t *testing.T) { + committee, secrets := testBLSCommittee(t, + Member{ID: "alice", Power: 1}, + Member{ID: "bob", Power: 1}, + Member{ID: "carol", Power: 1}, + ) + domain := VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"} + authority := NewQCAuthority(committee, domain) + _, genesis, err := authority.NewCore(Block{ID: "genesis", View: 0}) + require.NoError(t, err) + certificate := formBLSTC(t, authority, secrets, domain, 1, BLSQC{QC: genesis.QC()}) + + badSignature := cloneBLSTC(certificate) + badSignature.Signature[0] ^= 0xff + _, err = authority.VerifyTC(badSignature) + require.ErrorIs(t, err, ErrInvalidTCSignature) + + badBitmap := cloneBLSTC(certificate) + badBitmap.Bitmap[0] ^= 1 << 3 + _, err = authority.VerifyTC(badBitmap) + require.ErrorIs(t, err, ErrInvalidTCBitmap) + + reordered := cloneBLSTC(certificate) + reordered.Signers[0], reordered.Signers[1] = reordered.Signers[1], reordered.Signers[0] + _, err = authority.VerifyTC(reordered) + require.ErrorIs(t, err, ErrNonCanonicalTCSigners) + + badReport := cloneBLSTC(certificate) + badReport.Reports[0].Signature[0] ^= 0xff + _, err = authority.VerifyTC(badReport) + require.ErrorIs(t, err, ErrInvalidTimeoutHighQCSig) + + otherDomain := domain + otherDomain.Epoch++ + otherAuthority := NewQCAuthority(committee, otherDomain) + _, _, err = otherAuthority.NewCore(Block{ID: "genesis", View: 0}) + require.NoError(t, err) + _, err = otherAuthority.VerifyTC(certificate) + require.ErrorIs(t, err, ErrInvalidTimeoutHighQCSig) + + verified, err := authority.VerifyTC(certificate) + require.NoError(t, err) + pacemaker, err := authority.NewPacemaker(1) + require.NoError(t, err) + certificate.Signers[0] = "mallory" + certificate.HighQC.QC.Block = "mallory" + require.NoError(t, authority.AdvanceTimeout(pacemaker, verified)) + require.Equal(t, View(2), pacemaker.CurrentView()) +} + +func TestBLSTimeoutCarriesVerifiedNonGenesisHighQC(t *testing.T) { + committee, secrets := testBLSCommittee(t, + Member{ID: "alice", Power: 1}, + Member{ID: "bob", Power: 1}, + Member{ID: "carol", Power: 1}, + ) + domain := VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"} + authority := NewQCAuthority(committee, domain) + _, _, err := authority.NewCore(Block{ID: "genesis", View: 0}) + require.NoError(t, err) + highQC := formBLSQC(t, committee, secrets, domain, "b1", 1) + verifiedHighQC, err := authority.Verify(highQC) + require.NoError(t, err) + pacemaker, err := authority.NewPacemaker(1) + require.NoError(t, err) + require.NoError(t, authority.Advance(pacemaker, verifiedHighQC)) + + certificate := formBLSTC(t, authority, secrets, domain, 2, highQC) + verifiedTC, err := authority.VerifyTC(certificate) + require.NoError(t, err) + require.NoError(t, authority.AdvanceTimeout(pacemaker, verifiedTC)) + require.Equal(t, View(3), pacemaker.CurrentView()) + require.Equal(t, highQC.QC, pacemaker.HighQC()) +} + +func TestBLSTimeoutCertificateRejectsHighQCDowngrade(t *testing.T) { + committee, secrets := testBLSCommittee(t, + Member{ID: "alice", Power: 1}, + Member{ID: "bob", Power: 1}, + Member{ID: "carol", Power: 1}, + ) + domain := VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"} + authority := NewQCAuthority(committee, domain) + _, _, err := authority.NewCore(Block{ID: "genesis", View: 0}) + require.NoError(t, err) + lowerQC := formBLSQC(t, committee, secrets, domain, "b1", 1) + higherQC := formBLSQC(t, committee, secrets, domain, "b2", 2) + certificate := formBLSTC(t, authority, secrets, domain, 3, higherQC) + + certificate.HighQC = lowerQC + _, err = authority.VerifyTC(certificate) + require.ErrorIs(t, err, ErrTimeoutHighQCMismatch) +} + +func TestBLSTimeoutCertificateRejectsConflictingSameViewQCs(t *testing.T) { + committee, secrets := testBLSCommittee(t, + Member{ID: "alice", Power: 1}, + Member{ID: "bob", Power: 1}, + Member{ID: "carol", Power: 1}, + ) + domain := VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"} + authority := NewQCAuthority(committee, domain) + _, _, err := authority.NewCore(Block{ID: "genesis", View: 0}) + require.NoError(t, err) + left := formBLSQC(t, committee, secrets, domain, "left", 1) + right := formBLSQC(t, committee, secrets, domain, "right", 1) + set := NewBLSTimeoutSet(authority, 2) + + for voter, highQC := range map[MemberID]BLSQC{ + "alice": left, + "bob": right, + "carol": left, + } { + signed, err := SignTimeout( + domain, + Timeout{Voter: voter, View: 2, HighQC: highQC.QC}, + secrets[voter], + ) + require.NoError(t, err) + require.NoError(t, set.Add(signed, highQC)) + } + certificate, formed := set.Certificate() + require.True(t, formed) + _, err = authority.VerifyTC(certificate) + require.ErrorIs(t, err, ErrConflictingHighQC) +} + +func TestBLSTimeoutCertificateRejectsNonAdjacentSameViewConflict(t *testing.T) { + committee, secrets := testBLSCommittee(t, + Member{ID: "alice", Power: 1}, + Member{ID: "bob", Power: 1}, + Member{ID: "carol", Power: 1}, + ) + domain := VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"} + authority := NewQCAuthority(committee, domain) + _, _, err := authority.NewCore(Block{ID: "genesis", View: 0}) + require.NoError(t, err) + lowLeft := formBLSQC(t, committee, secrets, domain, "low-left", 1) + high := formBLSQC(t, committee, secrets, domain, "high", 2) + lowRight := formBLSQC(t, committee, secrets, domain, "low-right", 1) + set := NewBLSTimeoutSet(authority, 3) + + for voter, highQC := range map[MemberID]BLSQC{ + "alice": lowLeft, + "bob": high, + "carol": lowRight, + } { + signed, err := SignTimeout( + domain, + Timeout{Voter: voter, View: 3, HighQC: highQC.QC}, + secrets[voter], + ) + require.NoError(t, err) + require.NoError(t, set.Add(signed, highQC)) + } + certificate, formed := set.Certificate() + require.True(t, formed) + _, err = authority.VerifyTC(certificate) + require.ErrorIs(t, err, ErrConflictingHighQC) +} + +func formBLSTC( + t *testing.T, + authority *QCAuthority, + secrets map[MemberID]*bls_core.SecretKey, + domain VoteDomain, + view View, + highQC BLSQC, +) BLSTimeoutCertificate { + t.Helper() + set := NewBLSTimeoutSet(authority, view) + for _, voter := range []MemberID{"carol", "alice", "bob"} { + signed, err := SignTimeout( + domain, + Timeout{Voter: voter, View: view, HighQC: highQC.QC}, + secrets[voter], + ) + require.NoError(t, err) + require.NoError(t, set.Add(signed, highQC)) + } + certificate, formed := set.Certificate() + require.True(t, formed) + return certificate +} + +func cloneBLSTC(certificate BLSTimeoutCertificate) BLSTimeoutCertificate { + reports := make([]BLSTimeoutReport, 0, len(certificate.Reports)) + for _, report := range certificate.Reports { + reports = append(reports, cloneBLSTimeoutReport(report)) + } + return BLSTimeoutCertificate{ + View: certificate.View, + HighQC: cloneBLSQCEvidence(certificate.HighQC), + Signers: append([]MemberID(nil), certificate.Signers...), + Reports: reports, + Signature: append([]byte(nil), certificate.Signature...), + Bitmap: append([]byte(nil), certificate.Bitmap...), + } +} diff --git a/consensus/hotstuff/doc.go b/consensus/hotstuff/doc.go index aea00c6625..90425b151d 100644 --- a/consensus/hotstuff/doc.go +++ b/consensus/hotstuff/doc.go @@ -3,6 +3,7 @@ // It is not wired into Harmony consensus, networking, durable storage, block // execution, wire formats, or production activation. The package currently // validates leader rotation, safety rules, pacemaker timeouts, broadcast vote -// aggregation with Harmony BLS signatures, authority-bound verified QCs, -// structural block processing, and the direct three-chain commit rule. +// aggregation with Harmony BLS signatures, authority-bound verified QCs and +// timeout certificates, structural block processing, and the direct +// three-chain commit rule. package hotstuff From 6fc57609cbfd77da5e5b1aa8cd404b2d8d9ae4ff Mon Sep 17 00:00:00 2001 From: frozen <355847+Frozen@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:47:24 -0400 Subject: [PATCH 08/13] consensus: add Harmony HotStuff adapters --- consensus/hotstuff/harmony/block.go | 39 ++++++++++ consensus/hotstuff/harmony/block_test.go | 62 ++++++++++++++++ consensus/hotstuff/harmony/committee.go | 76 ++++++++++++++++++++ consensus/hotstuff/harmony/committee_test.go | 76 ++++++++++++++++++++ consensus/hotstuff/harmony/doc.go | 3 + 5 files changed, 256 insertions(+) create mode 100644 consensus/hotstuff/harmony/block.go create mode 100644 consensus/hotstuff/harmony/block_test.go create mode 100644 consensus/hotstuff/harmony/committee.go create mode 100644 consensus/hotstuff/harmony/committee_test.go create mode 100644 consensus/hotstuff/harmony/doc.go diff --git a/consensus/hotstuff/harmony/block.go b/consensus/hotstuff/harmony/block.go new file mode 100644 index 0000000000..72640b3b13 --- /dev/null +++ b/consensus/hotstuff/harmony/block.go @@ -0,0 +1,39 @@ +package harmony + +import ( + "errors" + + "github.com/harmony-one/harmony/consensus/hotstuff" + coretypes "github.com/harmony-one/harmony/core/types" +) + +var ( + // ErrNilBlock indicates that no Harmony block was supplied. + ErrNilBlock = errors.New("hotstuff Harmony block is nil") + // ErrNilBlockHeader indicates that the supplied Harmony block has no header. + ErrNilBlockHeader = errors.New("hotstuff Harmony block header is nil") + // ErrViewOverflow indicates that the Harmony view cannot be represented by the HotStuff core. + ErrViewOverflow = errors.New("hotstuff Harmony block view does not fit uint64") +) + +// NewBlock maps a Harmony block and authority-verified justify QC into the +// immutable structural value accepted by the HotStuff core. +func NewBlock(source *coretypes.Block, verified hotstuff.VerifiedQC) (hotstuff.Block, error) { + if source == nil { + return hotstuff.Block{}, ErrNilBlock + } + header := source.Header() + if header == nil { + return hotstuff.Block{}, ErrNilBlockHeader + } + viewID := header.ViewID() + if !viewID.IsUint64() { + return hotstuff.Block{}, ErrViewOverflow + } + return hotstuff.Block{ + ID: hotstuff.BlockID(source.Hash().Hex()), + Parent: hotstuff.BlockID(source.ParentHash().Hex()), + View: hotstuff.View(viewID.Uint64()), + Justify: verified.QC(), + }, nil +} diff --git a/consensus/hotstuff/harmony/block_test.go b/consensus/hotstuff/harmony/block_test.go new file mode 100644 index 0000000000..da017a9466 --- /dev/null +++ b/consensus/hotstuff/harmony/block_test.go @@ -0,0 +1,62 @@ +package harmony + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + blockfactory "github.com/harmony-one/harmony/block/factory" + "github.com/harmony-one/harmony/consensus/hotstuff" + coretypes "github.com/harmony-one/harmony/core/types" + hmybls "github.com/harmony-one/harmony/crypto/bls" + "github.com/stretchr/testify/require" +) + +func TestNewBlockMapsHarmonyHeaderFromVerifiedQC(t *testing.T) { + parentHash := common.HexToHash("0x1234") + header := blockfactory.NewTestHeader() + header.SetParentHash(parentHash) + header.SetViewID(big.NewInt(7)) + block := coretypes.NewBlockWithHeader(header) + verified := verifiedQC(t, hotstuff.BlockID(parentHash.Hex()), 0) + justify := verified.QC() + + proposal, err := NewBlock(block, verified) + require.NoError(t, err) + require.Equal(t, hotstuff.BlockID(block.Hash().Hex()), proposal.ID) + require.Equal(t, hotstuff.BlockID(parentHash.Hex()), proposal.Parent) + require.Equal(t, hotstuff.View(7), proposal.View) + require.Equal(t, justify, proposal.Justify) +} + +func TestNewBlockRejectsNilHarmonyBlock(t *testing.T) { + _, err := NewBlock(nil, hotstuff.VerifiedQC{}) + require.ErrorIs(t, err, ErrNilBlock) +} + +func TestNewBlockRejectsMissingHarmonyHeader(t *testing.T) { + _, err := NewBlock(new(coretypes.Block), hotstuff.VerifiedQC{}) + require.ErrorIs(t, err, ErrNilBlockHeader) +} + +func TestNewBlockRejectsViewOutsideUint64(t *testing.T) { + header := blockfactory.NewTestHeader() + header.SetViewID(new(big.Int).Lsh(big.NewInt(1), 64)) + + _, err := NewBlock(coretypes.NewBlockWithHeader(header), hotstuff.VerifiedQC{}) + require.ErrorIs(t, err, ErrViewOverflow) +} + +func verifiedQC(t *testing.T, blockID hotstuff.BlockID, view hotstuff.View) hotstuff.VerifiedQC { + t.Helper() + key := hmybls.WrapperFromPrivateKey(hmybls.RandPrivateKey()) + committee, err := hotstuff.NewBLSCommitteeFromValidatedKeys([]hotstuff.BLSMember{{ + Member: hotstuff.Member{ID: "validator-a", Power: 1}, + PublicKey: *key.Pub, + }}) + require.NoError(t, err) + authority := hotstuff.NewQCAuthority(committee, hotstuff.VoteDomain{Genesis: blockID}) + _, verified, err := authority.NewCore(hotstuff.Block{ID: blockID, View: view}) + require.NoError(t, err) + return verified +} diff --git a/consensus/hotstuff/harmony/committee.go b/consensus/hotstuff/harmony/committee.go new file mode 100644 index 0000000000..4dd11ad151 --- /dev/null +++ b/consensus/hotstuff/harmony/committee.go @@ -0,0 +1,76 @@ +package harmony + +import ( + "errors" + + "github.com/harmony-one/harmony/consensus/hotstuff" + hmybls "github.com/harmony-one/harmony/crypto/bls" + "github.com/harmony-one/harmony/shard" +) + +// ErrDuplicateBLSKeyOwner indicates that one BLS key is assigned to different validators. +var ErrDuplicateBLSKeyOwner = errors.New("hotstuff Harmony committee BLS key has multiple validator owners") + +// ValidatorSchedule derives a validator-level leader order from Harmony's +// BLS-slot committee. Multiple BLS keys owned by one ECDSA address occupy one +// position in the schedule. +type ValidatorSchedule struct { + // committee is used only for canonical member order and leader rotation. + // Its uniform power must not be used as Harmony quorum voting power. + committee *hotstuff.Committee + keyOwners map[hmybls.SerializedPublicKey]hotstuff.MemberID +} + +// NewValidatorSchedule groups Harmony BLS slots by ECDSA validator identity in +// first-slot order. The source must be canonical chain committee state. +func NewValidatorSchedule(source *shard.Committee) (*ValidatorSchedule, error) { + if source == nil { + return nil, shard.ErrSubCommitteeNil + } + + members := make([]hotstuff.Member, 0, len(source.Slots)) + keyOwners := make(map[hmybls.SerializedPublicKey]hotstuff.MemberID, len(source.Slots)) + seenValidators := make(map[hotstuff.MemberID]struct{}, len(source.Slots)) + for _, slot := range source.Slots { + memberID := hotstuff.MemberID(slot.EcdsaAddress.Hex()) + if owner, exists := keyOwners[slot.BLSPublicKey]; exists { + if owner != memberID { + return nil, ErrDuplicateBLSKeyOwner + } + return nil, hotstuff.ErrDuplicateBLSPublicKey + } + keyOwners[slot.BLSPublicKey] = memberID + if _, exists := seenValidators[memberID]; exists { + continue + } + seenValidators[memberID] = struct{}{} + members = append(members, hotstuff.Member{ID: memberID, Power: 1}) + } + + committee, err := hotstuff.NewCommittee(members) + if err != nil { + return nil, err + } + return &ValidatorSchedule{committee: committee, keyOwners: keyOwners}, nil +} + +// Members returns validator identities in canonical leader order. +func (s *ValidatorSchedule) Members() []hotstuff.MemberID { + members := s.committee.Members() + ids := make([]hotstuff.MemberID, len(members)) + for i, member := range members { + ids[i] = member.ID + } + return ids +} + +// Leader returns the validator assigned to the given view. +func (s *ValidatorSchedule) Leader(view hotstuff.View) hotstuff.MemberID { + return s.committee.Leader(view) +} + +// ValidatorForKey returns the validator that owns a Harmony BLS slot key. +func (s *ValidatorSchedule) ValidatorForKey(key hmybls.SerializedPublicKey) (hotstuff.MemberID, bool) { + memberID, exists := s.keyOwners[key] + return memberID, exists +} diff --git a/consensus/hotstuff/harmony/committee_test.go b/consensus/hotstuff/harmony/committee_test.go new file mode 100644 index 0000000000..a10cf01bc4 --- /dev/null +++ b/consensus/hotstuff/harmony/committee_test.go @@ -0,0 +1,76 @@ +package harmony + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/harmony-one/harmony/consensus/hotstuff" + hmybls "github.com/harmony-one/harmony/crypto/bls" + "github.com/harmony-one/harmony/shard" + "github.com/stretchr/testify/require" +) + +func TestValidatorScheduleRotatesByValidatorAcrossMultipleBLSKeys(t *testing.T) { + validatorA := common.HexToAddress("0x1") + validatorB := common.HexToAddress("0x2") + validatorC := common.HexToAddress("0x3") + keyA1 := serializedKey(1) + keyA2 := serializedKey(2) + keyB := serializedKey(3) + keyC := serializedKey(4) + + schedule, err := NewValidatorSchedule(&shard.Committee{ + ShardID: 0, + Slots: shard.SlotList{ + {EcdsaAddress: validatorA, BLSPublicKey: keyA1}, + {EcdsaAddress: validatorA, BLSPublicKey: keyA2}, + {EcdsaAddress: validatorB, BLSPublicKey: keyB}, + {EcdsaAddress: validatorC, BLSPublicKey: keyC}, + }, + }) + require.NoError(t, err) + + validatorAID := hotstuff.MemberID(validatorA.Hex()) + validatorBID := hotstuff.MemberID(validatorB.Hex()) + validatorCID := hotstuff.MemberID(validatorC.Hex()) + require.Equal(t, []hotstuff.MemberID{validatorAID, validatorBID, validatorCID}, schedule.Members()) + require.Equal(t, validatorAID, schedule.Leader(1)) + require.Equal(t, validatorBID, schedule.Leader(2)) + require.Equal(t, validatorCID, schedule.Leader(3)) + require.Equal(t, validatorAID, schedule.Leader(4)) + + owner, found := schedule.ValidatorForKey(keyA2) + require.True(t, found) + require.Equal(t, validatorAID, owner) +} + +func TestValidatorScheduleRejectsBLSKeyOwnedByDifferentValidators(t *testing.T) { + key := serializedKey(1) + _, err := NewValidatorSchedule(&shard.Committee{ + ShardID: 0, + Slots: shard.SlotList{ + {EcdsaAddress: common.HexToAddress("0x1"), BLSPublicKey: key}, + {EcdsaAddress: common.HexToAddress("0x2"), BLSPublicKey: key}, + }, + }) + require.ErrorIs(t, err, ErrDuplicateBLSKeyOwner) +} + +func TestValidatorScheduleRejectsDuplicateBLSKeyForOneValidator(t *testing.T) { + validator := common.HexToAddress("0x1") + key := serializedKey(1) + _, err := NewValidatorSchedule(&shard.Committee{ + ShardID: 0, + Slots: shard.SlotList{ + {EcdsaAddress: validator, BLSPublicKey: key}, + {EcdsaAddress: validator, BLSPublicKey: key}, + }, + }) + require.ErrorIs(t, err, hotstuff.ErrDuplicateBLSPublicKey) +} + +func serializedKey(marker byte) hmybls.SerializedPublicKey { + var key hmybls.SerializedPublicKey + key[0] = marker + return key +} diff --git a/consensus/hotstuff/harmony/doc.go b/consensus/hotstuff/harmony/doc.go new file mode 100644 index 0000000000..3af6b06059 --- /dev/null +++ b/consensus/hotstuff/harmony/doc.go @@ -0,0 +1,3 @@ +// Package harmony adapts canonical Harmony blocks and committees to the +// isolated HotStuff core without changing Harmony quorum semantics. +package harmony From 0bcbd6e9640226ab1560700b5d310da3748adb54 Mon Sep 17 00:00:00 2001 From: frozen <355847+Frozen@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:39:29 -0400 Subject: [PATCH 09/13] consensus: add durable HotStuff safety store --- consensus/hotstuff/harmony/doc.go | 5 +- consensus/hotstuff/harmony/safety_store.go | 346 ++++++++ .../hotstuff/harmony/safety_store_test.go | 751 ++++++++++++++++++ 3 files changed, 1100 insertions(+), 2 deletions(-) create mode 100644 consensus/hotstuff/harmony/safety_store.go create mode 100644 consensus/hotstuff/harmony/safety_store_test.go diff --git a/consensus/hotstuff/harmony/doc.go b/consensus/hotstuff/harmony/doc.go index 3af6b06059..87c2d8dcf6 100644 --- a/consensus/hotstuff/harmony/doc.go +++ b/consensus/hotstuff/harmony/doc.go @@ -1,3 +1,4 @@ -// Package harmony adapts canonical Harmony blocks and committees to the -// isolated HotStuff core without changing Harmony quorum semantics. +// Package harmony adapts canonical Harmony blocks, validator leader schedules, +// and durable per-key safety state to the isolated HotStuff core without +// changing Harmony quorum semantics. package harmony diff --git a/consensus/hotstuff/harmony/safety_store.go b/consensus/hotstuff/harmony/safety_store.go new file mode 100644 index 0000000000..3498051e27 --- /dev/null +++ b/consensus/hotstuff/harmony/safety_store.go @@ -0,0 +1,346 @@ +package harmony + +import ( + "bytes" + "crypto/sha256" + "errors" + "fmt" + "sync" + + "github.com/ethereum/go-ethereum/rlp" + "github.com/harmony-one/harmony/consensus/hotstuff" + hmybls "github.com/harmony-one/harmony/crypto/bls" + "github.com/syndtr/goleveldb/leveldb" + "github.com/syndtr/goleveldb/leveldb/opt" +) + +const ( + safetyStoreVersion uint64 = 1 + safetyStoreKeyPrefix = "harmony-hotstuff-safety-" +) + +var ( + // ErrNilSafetyStoreDatabase rejects a store without a persistence backend. + ErrNilSafetyStoreDatabase = errors.New("hotstuff safety store database is nil") + // ErrInvalidSafetyStoreScope rejects an incomplete authority or signer scope. + ErrInvalidSafetyStoreScope = errors.New("hotstuff safety store scope is invalid") + // ErrCorruptSafetyStoreRecord prevents voting from damaged persisted state. + ErrCorruptSafetyStoreRecord = errors.New("hotstuff safety store record is corrupt") + // ErrSafetyStoreDomainMismatch prevents state reuse across authorities. + ErrSafetyStoreDomainMismatch = errors.New("hotstuff safety store record belongs to another domain") + // ErrUnsupportedSafetyStoreVersion prevents an unknown record from looking like first run. + ErrUnsupportedSafetyStoreVersion = errors.New("hotstuff safety store record version is unsupported") + // ErrInvalidSafetyStorePath rejects an empty dedicated LevelDB path. + ErrInvalidSafetyStorePath = errors.New("hotstuff safety store path is invalid") + // ErrSafetyStoreStateRegression rejects duplicate votes and lock rollback across writers. + ErrSafetyStoreStateRegression = errors.New("hotstuff safety store transition regresses durable state") +) + +type levelDBDatabase interface { + Has(key []byte, options *opt.ReadOptions) (bool, error) + Get(key []byte, options *opt.ReadOptions) ([]byte, error) + Put(key, value []byte, options *opt.WriteOptions) error +} + +// LevelDBSafetyStoreDatabase makes each acknowledged safety-state write reach +// stable storage before a vote can be returned. +type LevelDBSafetyStoreDatabase struct { + db levelDBDatabase + state *levelDBSafetyStoreDatabaseState +} + +type levelDBSafetyStoreDatabaseState struct { + mu sync.Mutex + close func() error + closed bool +} + +type safetyStoreDatabase interface { + Has(key []byte) (bool, error) + Get(key []byte) ([]byte, error) + updateSync(key, value []byte, validate func(current []byte, found bool) error) error +} + +// OpenLevelDBSafetyStoreDatabase opens and owns a dedicated LevelDB safety +// database. Global NoSync mode is deliberately disabled so synchronous writes cannot be +// downgraded by caller-supplied database options. +func OpenLevelDBSafetyStoreDatabase(path string) (*LevelDBSafetyStoreDatabase, error) { + if path == "" { + return nil, ErrInvalidSafetyStorePath + } + rawDB, err := leveldb.OpenFile(path, &opt.Options{NoSync: false}) + if err != nil { + return nil, fmt.Errorf("open hotstuff safety store database: %w", err) + } + return &LevelDBSafetyStoreDatabase{ + db: rawDB, + state: &levelDBSafetyStoreDatabaseState{ + close: rawDB.Close, + }, + }, nil +} + +func newLevelDBSafetyStoreDatabase(db levelDBDatabase) *LevelDBSafetyStoreDatabase { + return &LevelDBSafetyStoreDatabase{ + db: db, + state: &levelDBSafetyStoreDatabaseState{}, + } +} + +// Close releases the dedicated LevelDB safety database. +func (db *LevelDBSafetyStoreDatabase) Close() error { + if db == nil || db.state == nil { + return ErrNilSafetyStoreDatabase + } + db.state.mu.Lock() + defer db.state.mu.Unlock() + if db.state.closed { + return nil + } + db.state.closed = true + if db.state.close == nil { + return nil + } + return db.state.close() +} + +// Has reports whether a safety record exists. +func (db *LevelDBSafetyStoreDatabase) Has(key []byte) (bool, error) { + db.state.mu.Lock() + defer db.state.mu.Unlock() + if db.state.closed { + return false, leveldb.ErrClosed + } + return db.db.Has(key, nil) +} + +// Get reads a safety record. +func (db *LevelDBSafetyStoreDatabase) Get(key []byte) ([]byte, error) { + db.state.mu.Lock() + defer db.state.mu.Unlock() + if db.state.closed { + return nil, leveldb.ErrClosed + } + return db.db.Get(key, nil) +} + +func (db *LevelDBSafetyStoreDatabase) updateSync( + key []byte, + value []byte, + validate func(current []byte, found bool) error, +) error { + db.state.mu.Lock() + defer db.state.mu.Unlock() + if db.state.closed { + return leveldb.ErrClosed + } + + found, err := db.db.Has(key, nil) + if err != nil { + return err + } + var current []byte + if found { + current, err = db.db.Get(key, nil) + if err != nil { + return err + } + } + if validate != nil { + if err := validate(current, found); err != nil { + return err + } + } + return db.db.Put(key, value, &opt.WriteOptions{Sync: true}) +} + +type safetyStoreScope struct { + ChainID uint32 + ShardID uint32 + Epoch uint64 + Genesis string + Signer []byte +} + +type safetyStoreRecord struct { + Version uint64 + Scope safetyStoreScope + LastVotedView uint64 + LockedBlock string + LockedView uint64 + SignersNil bool + LockedSigners []string + Checksum []byte +} + +// SafetyStore persists the minimum HotStuff state required to prevent a +// validator from voting unsafely after a machine restart. +type SafetyStore struct { + db safetyStoreDatabase + key []byte + scope safetyStoreScope +} + +// NewSafetyStore binds a safety store to one HotStuff authority domain and one +// local BLS voting key. The consensus lifecycle must maintain exactly one +// active SafetyStore and SafetyRules writer for each domain and signer. +func NewSafetyStore( + db *LevelDBSafetyStoreDatabase, + domain hotstuff.VoteDomain, + signer hmybls.SerializedPublicKey, +) (*SafetyStore, error) { + if db == nil || db.db == nil || db.state == nil { + return nil, ErrNilSafetyStoreDatabase + } + return newSafetyStore(db, domain, signer) +} + +func newSafetyStore( + db safetyStoreDatabase, + domain hotstuff.VoteDomain, + signer hmybls.SerializedPublicKey, +) (*SafetyStore, error) { + if domain.Genesis == "" || signer.IsEmpty() { + return nil, ErrInvalidSafetyStoreScope + } + scope := safetyStoreScope{ + ChainID: domain.ChainID, + ShardID: domain.ShardID, + Epoch: domain.Epoch, + Genesis: string(domain.Genesis), + Signer: append([]byte(nil), signer.Bytes()...), + } + encoded, err := rlp.EncodeToBytes(scope) + if err != nil { + return nil, fmt.Errorf("encode hotstuff safety store scope: %w", err) + } + digest := sha256.Sum256(encoded) + key := append([]byte(safetyStoreKeyPrefix), digest[:]...) + return &SafetyStore{db: db, key: key, scope: scope}, nil +} + +// Save atomically replaces the complete safety state for this store's domain. +func (s *SafetyStore) Save(state hotstuff.SafetyState) error { + record := safetyStoreRecord{ + Version: safetyStoreVersion, + Scope: s.scope, + LastVotedView: uint64(state.LastVotedView), + LockedBlock: string(state.LockedQC.Block), + LockedView: uint64(state.LockedQC.View), + SignersNil: state.LockedQC.Signers == nil, + LockedSigners: make([]string, len(state.LockedQC.Signers)), + } + for index, signer := range state.LockedQC.Signers { + record.LockedSigners[index] = string(signer) + } + checksum, err := safetyStoreChecksum(record) + if err != nil { + return fmt.Errorf("checksum hotstuff safety state: %w", err) + } + record.Checksum = checksum + encoded, err := rlp.EncodeToBytes(record) + if err != nil { + return fmt.Errorf("encode hotstuff safety state: %w", err) + } + validate := func(current []byte, found bool) error { + if !found { + return nil + } + previous, err := s.decode(current) + if err != nil { + return err + } + if state.LastVotedView <= previous.LastVotedView || + state.LockedQC.View < previous.LockedQC.View || + (state.LockedQC.View == previous.LockedQC.View && + state.LockedQC.Block != previous.LockedQC.Block) { + return ErrSafetyStoreStateRegression + } + return nil + } + if err := s.db.updateSync(s.key, encoded, validate); err != nil { + return fmt.Errorf("persist hotstuff safety state: %w", err) + } + return nil +} + +// Load returns the durable safety state. A false found result means this voting +// key has no state in this authority domain yet. +func (s *SafetyStore) Load() (hotstuff.SafetyState, bool, error) { + exists, err := s.db.Has(s.key) + if err != nil { + return hotstuff.SafetyState{}, false, fmt.Errorf("check hotstuff safety state: %w", err) + } + if !exists { + return hotstuff.SafetyState{}, false, nil + } + encoded, err := s.db.Get(s.key) + if err != nil { + return hotstuff.SafetyState{}, false, fmt.Errorf("read hotstuff safety state: %w", err) + } + state, err := s.decode(encoded) + if err != nil { + return hotstuff.SafetyState{}, false, err + } + return state, true, nil +} + +func (s *SafetyStore) decode(encoded []byte) (hotstuff.SafetyState, error) { + var record safetyStoreRecord + if err := rlp.DecodeBytes(encoded, &record); err != nil { + return hotstuff.SafetyState{}, fmt.Errorf("%w: %v", ErrCorruptSafetyStoreRecord, err) + } + if record.Version != safetyStoreVersion { + return hotstuff.SafetyState{}, fmt.Errorf("%w: %d", ErrUnsupportedSafetyStoreVersion, record.Version) + } + if !sameSafetyStoreScope(record.Scope, s.scope) { + return hotstuff.SafetyState{}, ErrSafetyStoreDomainMismatch + } + checksum, err := safetyStoreChecksum(record) + if err != nil || !bytes.Equal(record.Checksum, checksum) { + return hotstuff.SafetyState{}, ErrCorruptSafetyStoreRecord + } + if record.SignersNil && len(record.LockedSigners) != 0 { + return hotstuff.SafetyState{}, ErrCorruptSafetyStoreRecord + } + state := hotstuff.SafetyState{ + LastVotedView: hotstuff.View(record.LastVotedView), + LockedQC: hotstuff.QC{ + Block: hotstuff.BlockID(record.LockedBlock), + View: hotstuff.View(record.LockedView), + }, + } + if !record.SignersNil { + state.LockedQC.Signers = make([]hotstuff.MemberID, len(record.LockedSigners)) + } + for index, signer := range record.LockedSigners { + state.LockedQC.Signers[index] = hotstuff.MemberID(signer) + } + return state, nil +} + +func safetyStoreChecksum(record safetyStoreRecord) ([]byte, error) { + record.Checksum = nil + encoded, err := rlp.EncodeToBytes(record) + if err != nil { + return nil, err + } + digest := sha256.Sum256(encoded) + return digest[:], nil +} + +func sameSafetyStoreScope(left, right safetyStoreScope) bool { + if left.ChainID != right.ChainID || + left.ShardID != right.ShardID || + left.Epoch != right.Epoch || + left.Genesis != right.Genesis || + len(left.Signer) != len(right.Signer) { + return false + } + for index := range left.Signer { + if left.Signer[index] != right.Signer[index] { + return false + } + } + return true +} diff --git a/consensus/hotstuff/harmony/safety_store_test.go b/consensus/hotstuff/harmony/safety_store_test.go new file mode 100644 index 0000000000..fb950f85be --- /dev/null +++ b/consensus/hotstuff/harmony/safety_store_test.go @@ -0,0 +1,751 @@ +package harmony + +import ( + "encoding/hex" + "errors" + "os" + "os/exec" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/ethereum/go-ethereum/rlp" + "github.com/harmony-one/harmony/consensus/hotstuff" + hmybls "github.com/harmony-one/harmony/crypto/bls" + "github.com/stretchr/testify/require" + "github.com/syndtr/goleveldb/leveldb/opt" +) + +func TestLevelDBSafetyStoreDatabaseUsesSyncWrites(t *testing.T) { + db := &recordingLevelDB{} + backend := newLevelDBSafetyStoreDatabase(db) + + require.NoError(t, backend.updateSync([]byte("key"), []byte("value"), nil)) + require.NotNil(t, db.writeOptions) + require.True(t, db.writeOptions.Sync) +} + +func TestOpenLevelDBSafetyStoreDatabase(t *testing.T) { + db, err := OpenLevelDBSafetyStoreDatabase(filepath.Join(t.TempDir(), "safety")) + require.NoError(t, err) + require.NoError(t, db.Close()) +} + +func TestCopiedLevelDBSafetyStoreDatabaseSharesCloseLifecycle(t *testing.T) { + db, err := OpenLevelDBSafetyStoreDatabase(filepath.Join(t.TempDir(), "safety")) + require.NoError(t, err) + copy := *db + + require.NoError(t, db.Close()) + require.NoError(t, copy.Close()) + _, err = copy.Has([]byte("key")) + require.Error(t, err) +} + +func TestSafetyStorePersistsThroughSynchronousBackend(t *testing.T) { + db := &recordingLevelDB{} + backend := newLevelDBSafetyStoreDatabase(db) + domain := hotstuff.VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"} + key := hmybls.WrapperFromPrivateKey(hmybls.RandPrivateKey()) + store, err := NewSafetyStore(backend, domain, key.Pub.Bytes) + require.NoError(t, err) + + require.NoError(t, store.Save(hotstuff.SafetyState{LastVotedView: 1})) + require.NotNil(t, db.writeOptions) + require.True(t, db.writeOptions.Sync) +} + +func TestSafetyStoreRejectsTypedNilDatabase(t *testing.T) { + var db *LevelDBSafetyStoreDatabase + domain := hotstuff.VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"} + key := hmybls.WrapperFromPrivateKey(hmybls.RandPrivateKey()) + + store, err := NewSafetyStore(db, domain, key.Pub.Bytes) + require.Nil(t, store) + require.ErrorIs(t, err, ErrNilSafetyStoreDatabase) +} + +func TestSafetyStoreRejectsZeroValueDatabase(t *testing.T) { + db := &LevelDBSafetyStoreDatabase{} + domain := hotstuff.VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"} + key := hmybls.WrapperFromPrivateKey(hmybls.RandPrivateKey()) + + store, err := NewSafetyStore(db, domain, key.Pub.Bytes) + require.Nil(t, store) + require.ErrorIs(t, err, ErrNilSafetyStoreDatabase) +} + +func TestSafetyStoreRejectsConflictingMultiOwnerVote(t *testing.T) { + db := newTestSafetyDatabase(t) + domain := hotstuff.VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"} + key := hmybls.WrapperFromPrivateKey(hmybls.RandPrivateKey()) + firstStore, err := NewSafetyStore(db, domain, key.Pub.Bytes) + require.NoError(t, err) + secondStore, err := NewSafetyStore(db, domain, key.Pub.Bytes) + require.NoError(t, err) + + _, firstAuthority, firstCore, firstGenesis := safetyAuthority(t, domain, *key.Pub) + firstBlock := hotstuff.Block{ID: "first", Parent: domain.Genesis, View: 1, Justify: firstGenesis.QC()} + _, err = firstAuthority.Accept(firstCore, firstBlock, firstGenesis) + require.NoError(t, err) + firstRules := hotstuff.NewSafetyRules( + firstCore, + hotstuff.SafetyState{LockedQC: firstGenesis.QC()}, + firstStore.Save, + ) + + _, secondAuthority, secondCore, secondGenesis := safetyAuthority(t, domain, *key.Pub) + secondBlock := hotstuff.Block{ID: "second", Parent: domain.Genesis, View: 1, Justify: secondGenesis.QC()} + _, err = secondAuthority.Accept(secondCore, secondBlock, secondGenesis) + require.NoError(t, err) + secondInitial := hotstuff.SafetyState{LockedQC: secondGenesis.QC()} + secondRules := hotstuff.NewSafetyRules(secondCore, secondInitial, secondStore.Save) + + _, err = firstRules.Vote("validator-a", firstBlock) + require.NoError(t, err) + _, err = secondRules.Vote("validator-a", secondBlock) + require.ErrorIs(t, err, ErrSafetyStoreStateRegression) + require.Equal(t, secondInitial, secondRules.State()) +} + +func TestCopiedLevelDBSafetyStoreDatabaseRejectsSameViewDoubleWrite(t *testing.T) { + rawDB := newCoordinatedLevelDB() + firstDB := newLevelDBSafetyStoreDatabase(rawDB) + secondDB := *firstDB + domain := hotstuff.VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"} + key := hmybls.WrapperFromPrivateKey(hmybls.RandPrivateKey()) + first, err := NewSafetyStore(firstDB, domain, key.Pub.Bytes) + require.NoError(t, err) + second, err := NewSafetyStore(&secondDB, domain, key.Pub.Bytes) + require.NoError(t, err) + + start := make(chan struct{}) + results := make(chan error, 2) + for _, save := range []func(hotstuff.SafetyState) error{first.Save, second.Save} { + go func(save func(hotstuff.SafetyState) error) { + <-start + results <- save(hotstuff.SafetyState{ + LastVotedView: 1, + LockedQC: hotstuff.QC{Block: domain.Genesis, View: 0}, + }) + }(save) + } + close(start) + + var succeeded, rejected int + for range 2 { + err := <-results + switch { + case err == nil: + succeeded++ + case errors.Is(err, ErrSafetyStoreStateRegression): + rejected++ + default: + require.NoError(t, err) + } + } + require.Equal(t, 1, succeeded) + require.Equal(t, 1, rejected) +} + +func TestSafetyStoreAllowsMonotonicTransition(t *testing.T) { + db := newTestSafetyDatabase(t) + domain := hotstuff.VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"} + key := hmybls.WrapperFromPrivateKey(hmybls.RandPrivateKey()) + store, err := NewSafetyStore(db, domain, key.Pub.Bytes) + require.NoError(t, err) + + require.NoError(t, store.Save(hotstuff.SafetyState{ + LastVotedView: 1, + LockedQC: hotstuff.QC{Block: domain.Genesis, View: 0}, + })) + want := hotstuff.SafetyState{ + LastVotedView: 2, + LockedQC: hotstuff.QC{Block: "b1", View: 1}, + } + require.NoError(t, store.Save(want)) + + got, found, err := store.Load() + require.NoError(t, err) + require.True(t, found) + require.Equal(t, want, got) +} + +func TestSafetyStoreRejectsNonMonotonicTransition(t *testing.T) { + initial := hotstuff.SafetyState{ + LastVotedView: 2, + LockedQC: hotstuff.QC{Block: "b1", View: 1}, + } + tests := []struct { + name string + candidate hotstuff.SafetyState + }{ + {name: "equal vote view", candidate: initial}, + {name: "regressing vote view", candidate: hotstuff.SafetyState{ + LastVotedView: 1, LockedQC: hotstuff.QC{Block: "b1", View: 1}, + }}, + {name: "regressing lock view", candidate: hotstuff.SafetyState{ + LastVotedView: 3, LockedQC: hotstuff.QC{Block: "genesis", View: 0}, + }}, + {name: "conflicting lock block", candidate: hotstuff.SafetyState{ + LastVotedView: 3, LockedQC: hotstuff.QC{Block: "fork", View: 1}, + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + db := newTestSafetyDatabase(t) + domain := hotstuff.VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"} + key := hmybls.WrapperFromPrivateKey(hmybls.RandPrivateKey()) + store, err := NewSafetyStore(db, domain, key.Pub.Bytes) + require.NoError(t, err) + require.NoError(t, store.Save(initial)) + + err = store.Save(test.candidate) + require.ErrorIs(t, err, ErrSafetyStoreStateRegression) + got, found, err := store.Load() + require.NoError(t, err) + require.True(t, found) + require.Equal(t, initial, got) + }) + } +} + +func TestSafetyStoreSurvivesAbruptProcessExit(t *testing.T) { + const helperEnvironment = "HARMONY_HOTSTUFF_SAFETY_CRASH_HELPER" + path := filepath.Join(t.TempDir(), "crash-db") + domain := hotstuff.VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"} + var signer hmybls.SerializedPublicKey + signer[0] = 1 + want := hotstuff.SafetyState{ + LastVotedView: 7, + LockedQC: hotstuff.QC{Block: "locked", View: 6}, + } + + if os.Getenv(helperEnvironment) == "1" { + db, err := OpenLevelDBSafetyStoreDatabase(os.Getenv("HARMONY_HOTSTUFF_SAFETY_CRASH_PATH")) + if err != nil { + os.Exit(2) + } + store, err := NewSafetyStore(db, domain, signer) + if err != nil { + os.Exit(3) + } + if err := store.Save(want); err != nil { + os.Exit(4) + } + os.Exit(0) + } + + command := exec.Command(os.Args[0], "-test.run=^TestSafetyStoreSurvivesAbruptProcessExit$") + command.Env = append(os.Environ(), + helperEnvironment+"=1", + "HARMONY_HOTSTUFF_SAFETY_CRASH_PATH="+path, + ) + output, err := command.CombinedOutput() + require.NoError(t, err, string(output)) + + db := openTestSafetyDatabase(t, path) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + store, err := NewSafetyStore(db, domain, signer) + require.NoError(t, err) + got, found, err := store.Load() + require.NoError(t, err) + require.True(t, found) + require.Equal(t, want, got) +} + +func TestSafetyStoreSurvivesLevelDBReopen(t *testing.T) { + path := filepath.Join(t.TempDir(), "chaindata") + domain := hotstuff.VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"} + key := hmybls.WrapperFromPrivateKey(hmybls.RandPrivateKey()) + + db := openTestSafetyDatabase(t, path) + store, err := NewSafetyStore(db, domain, key.Pub.Bytes) + require.NoError(t, err) + core, genesis, proposal := safetyRuntime(t, domain, *key.Pub) + rules := hotstuff.NewSafetyRules(core, hotstuff.SafetyState{LockedQC: genesis.QC()}, store.Save) + + _, err = rules.Vote("validator-a", proposal) + require.NoError(t, err) + persisted := rules.State() + require.NoError(t, db.Close()) + + db = openTestSafetyDatabase(t, path) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + store, err = NewSafetyStore(db, domain, key.Pub.Bytes) + require.NoError(t, err) + restored, found, err := store.Load() + require.NoError(t, err) + require.True(t, found) + require.Equal(t, persisted, restored) + + core, _, proposal = safetyRuntime(t, domain, *key.Pub) + restarted := hotstuff.NewSafetyRules(core, restored, store.Save) + _, err = restarted.Vote("validator-a", proposal) + require.ErrorIs(t, err, hotstuff.ErrAlreadyVoted) +} + +func TestSafetyStoreRestoresLockedQCAcrossLevelDBReopen(t *testing.T) { + path := filepath.Join(t.TempDir(), "chaindata") + domain := hotstuff.VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"} + key := hmybls.WrapperFromPrivateKey(hmybls.RandPrivateKey()) + db := openTestSafetyDatabase(t, path) + store, err := NewSafetyStore(db, domain, key.Pub.Bytes) + require.NoError(t, err) + committee, authority, core, genesis := safetyAuthority(t, domain, *key.Pub) + b1 := hotstuff.Block{ID: "b1", Parent: domain.Genesis, View: 1, Justify: genesis.QC()} + qc1 := acceptAndCertify(t, committee, authority, core, domain, key, b1, genesis) + b2 := hotstuff.Block{ID: "b2", Parent: b1.ID, View: 2, Justify: qc1.QC()} + qc2 := acceptAndCertify(t, committee, authority, core, domain, key, b2, qc1) + b3 := hotstuff.Block{ID: "b3", Parent: b2.ID, View: 3, Justify: qc2.QC()} + _, err = authority.Accept(core, b3, qc2) + require.NoError(t, err) + rules := hotstuff.NewSafetyRules(core, hotstuff.SafetyState{LockedQC: genesis.QC()}, store.Save) + _, err = rules.Vote("validator-a", b3) + require.NoError(t, err) + require.Equal(t, qc1.QC(), rules.State().LockedQC) + require.NoError(t, db.Close()) + + db = openTestSafetyDatabase(t, path) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + store, err = NewSafetyStore(db, domain, key.Pub.Bytes) + require.NoError(t, err) + restored, found, err := store.Load() + require.NoError(t, err) + require.True(t, found) + require.Equal(t, qc1.QC(), restored.LockedQC) + + committee, authority, core, genesis = safetyAuthority(t, domain, *key.Pub) + b1.Justify = genesis.QC() + qc1 = acceptAndCertify(t, committee, authority, core, domain, key, b1, genesis) + b2.Justify = qc1.QC() + qc2 = acceptAndCertify(t, committee, authority, core, domain, key, b2, qc1) + b3.Justify = qc2.QC() + _, err = authority.Accept(core, b3, qc2) + require.NoError(t, err) + fork := hotstuff.Block{ID: "fork", Parent: domain.Genesis, View: 1, Justify: genesis.QC()} + forkQC := acceptAndCertify(t, committee, authority, core, domain, key, fork, genesis) + conflict := hotstuff.Block{ID: "conflict", Parent: fork.ID, View: 4, Justify: forkQC.QC()} + _, err = authority.Accept(core, conflict, forkQC) + require.NoError(t, err) + + restarted := hotstuff.NewSafetyRules(core, restored, store.Save) + _, err = restarted.Vote("validator-a", conflict) + require.ErrorIs(t, err, hotstuff.ErrUnsafeProposal) +} + +func TestSafetyStoreRoundTripsLockedQCSigners(t *testing.T) { + db := newTestSafetyDatabase(t) + domain := hotstuff.VoteDomain{ChainID: 1, ShardID: 2, Epoch: 42, Genesis: "genesis"} + key := hmybls.WrapperFromPrivateKey(hmybls.RandPrivateKey()) + store, err := NewSafetyStore(db, domain, key.Pub.Bytes) + require.NoError(t, err) + want := hotstuff.SafetyState{ + LastVotedView: 9, + LockedQC: hotstuff.QC{ + Block: "locked-block", + View: 8, + Signers: []hotstuff.MemberID{"validator-a", "validator-b"}, + }, + } + + require.NoError(t, store.Save(want)) + got, found, err := store.Load() + require.NoError(t, err) + require.True(t, found) + require.Equal(t, want, got) +} + +func TestSafetyStoreV1EncodingFixture(t *testing.T) { + const ( + wantKey = "6861726d6f6e792d686f7473747566662d7361666574792deba7d4b1b5f3e111705fd37118e9b2ebc0213703f18a3278a7970d08b3a601c4" + wantRecord = "f88301f83c01022a8767656e65736973b00102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f3009866c6f636b65640880d88b76616c696461746f722d618b76616c696461746f722d62a04e27f385f90ec0fad0ee46ddb294a2e6a3f2edf7e2dbddea81ac1c2ae29da093" + ) + db := newTestSafetyDatabase(t) + var signer hmybls.SerializedPublicKey + for index := range signer { + signer[index] = byte(index + 1) + } + store, err := NewSafetyStore(db, hotstuff.VoteDomain{ + ChainID: 1, ShardID: 2, Epoch: 42, Genesis: "genesis", + }, signer) + require.NoError(t, err) + require.Equal(t, wantKey, hex.EncodeToString(store.key)) + require.NoError(t, store.Save(hotstuff.SafetyState{ + LastVotedView: 9, + LockedQC: hotstuff.QC{ + Block: "locked", + View: 8, + Signers: []hotstuff.MemberID{"validator-a", "validator-b"}, + }, + })) + record, err := db.Get(store.key) + require.NoError(t, err) + require.Equal(t, wantRecord, hex.EncodeToString(record)) +} + +func TestSafetyStoreReportsMissingRecordAsFirstRun(t *testing.T) { + db := newTestSafetyDatabase(t) + store := newTestSafetyStore(t, db, hotstuff.VoteDomain{ + ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis", + }) + + state, found, err := store.Load() + require.NoError(t, err) + require.False(t, found) + require.Equal(t, hotstuff.SafetyState{}, state) +} + +func TestSafetyStoreRejectsCorruptRecord(t *testing.T) { + db := newTestSafetyDatabase(t) + store := newTestSafetyStore(t, db, hotstuff.VoteDomain{ + ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis", + }) + require.NoError(t, db.updateSync(store.key, []byte{0xff}, nil)) + + _, found, err := store.Load() + require.False(t, found) + require.ErrorIs(t, err, ErrCorruptSafetyStoreRecord) +} + +func TestSafetyStoreRejectsTamperedRecord(t *testing.T) { + db := newTestSafetyDatabase(t) + store := newTestSafetyStore(t, db, hotstuff.VoteDomain{ + ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis", + }) + require.NoError(t, store.Save(hotstuff.SafetyState{LastVotedView: 7})) + encoded, err := db.Get(store.key) + require.NoError(t, err) + var record safetyStoreRecord + require.NoError(t, rlp.DecodeBytes(encoded, &record)) + record.LastVotedView++ + encoded, err = rlp.EncodeToBytes(record) + require.NoError(t, err) + require.NoError(t, db.updateSync(store.key, encoded, nil)) + + _, found, err := store.Load() + require.False(t, found) + require.ErrorIs(t, err, ErrCorruptSafetyStoreRecord) +} + +func TestSafetyStoreRejectsUnsupportedVersion(t *testing.T) { + db := newTestSafetyDatabase(t) + store := newTestSafetyStore(t, db, hotstuff.VoteDomain{ + ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis", + }) + require.NoError(t, store.Save(hotstuff.SafetyState{LastVotedView: 7})) + encoded, err := db.Get(store.key) + require.NoError(t, err) + var record safetyStoreRecord + require.NoError(t, rlp.DecodeBytes(encoded, &record)) + record.Version++ + record.Checksum, err = safetyStoreChecksum(record) + require.NoError(t, err) + encoded, err = rlp.EncodeToBytes(record) + require.NoError(t, err) + require.NoError(t, db.updateSync(store.key, encoded, nil)) + + _, found, err := store.Load() + require.False(t, found) + require.ErrorIs(t, err, ErrUnsupportedSafetyStoreVersion) +} + +func TestSafetyStoreRejectsMalformedSignerEncoding(t *testing.T) { + db := newTestSafetyDatabase(t) + store := newTestSafetyStore(t, db, hotstuff.VoteDomain{ + ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis", + }) + require.NoError(t, store.Save(hotstuff.SafetyState{LockedQC: hotstuff.QC{ + Block: "locked-block", View: 6, Signers: []hotstuff.MemberID{"validator-a"}, + }})) + encoded, err := db.Get(store.key) + require.NoError(t, err) + var record safetyStoreRecord + require.NoError(t, rlp.DecodeBytes(encoded, &record)) + record.SignersNil = true + record.Checksum, err = safetyStoreChecksum(record) + require.NoError(t, err) + encoded, err = rlp.EncodeToBytes(record) + require.NoError(t, err) + require.NoError(t, db.updateSync(store.key, encoded, nil)) + + _, found, err := store.Load() + require.False(t, found) + require.ErrorIs(t, err, ErrCorruptSafetyStoreRecord) +} + +func TestSafetyStoreRejectsDomainMismatch(t *testing.T) { + baseDomain := hotstuff.VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"} + baseKey := hmybls.WrapperFromPrivateKey(hmybls.RandPrivateKey()).Pub.Bytes + otherKey := hmybls.WrapperFromPrivateKey(hmybls.RandPrivateKey()).Pub.Bytes + tests := []struct { + name string + domain hotstuff.VoteDomain + signer hmybls.SerializedPublicKey + }{ + {name: "chain", domain: hotstuff.VoteDomain{ChainID: 2, ShardID: 0, Epoch: 42, Genesis: "genesis"}, signer: baseKey}, + {name: "shard", domain: hotstuff.VoteDomain{ChainID: 1, ShardID: 1, Epoch: 42, Genesis: "genesis"}, signer: baseKey}, + {name: "epoch", domain: hotstuff.VoteDomain{ChainID: 1, ShardID: 0, Epoch: 43, Genesis: "genesis"}, signer: baseKey}, + {name: "genesis", domain: hotstuff.VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "other-genesis"}, signer: baseKey}, + {name: "signer", domain: baseDomain, signer: otherKey}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + db := newTestSafetyDatabase(t) + first, err := NewSafetyStore(db, baseDomain, baseKey) + require.NoError(t, err) + second, err := NewSafetyStore(db, test.domain, test.signer) + require.NoError(t, err) + require.NoError(t, first.Save(hotstuff.SafetyState{LastVotedView: 7})) + record, err := db.Get(first.key) + require.NoError(t, err) + require.NoError(t, db.updateSync(second.key, record, nil)) + + _, found, err := second.Load() + require.False(t, found) + require.ErrorIs(t, err, ErrSafetyStoreDomainMismatch) + }) + } +} + +func TestSafetyStoreWriteFailureDoesNotAdvanceSafetyState(t *testing.T) { + injected := errors.New("injected safety store write failure") + db := &failingPutDatabase{ + safetyStoreDatabase: newTestSafetyDatabase(t), + err: injected, + } + domain := hotstuff.VoteDomain{ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis"} + key := hmybls.WrapperFromPrivateKey(hmybls.RandPrivateKey()) + store, err := newSafetyStore(db, domain, key.Pub.Bytes) + require.NoError(t, err) + core, genesis, proposal := safetyRuntime(t, domain, *key.Pub) + initial := hotstuff.SafetyState{LockedQC: genesis.QC()} + rules := hotstuff.NewSafetyRules(core, initial, store.Save) + + _, err = rules.Vote("validator-a", proposal) + require.ErrorIs(t, err, injected) + require.Equal(t, initial, rules.State()) +} + +func TestSafetyStoreReadFailureIsNotFirstRun(t *testing.T) { + injected := errors.New("injected safety store read failure") + db := &failingHasDatabase{ + safetyStoreDatabase: newTestSafetyDatabase(t), + err: injected, + } + store := newTestSafetyStore(t, db, hotstuff.VoteDomain{ + ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis", + }) + + _, found, err := store.Load() + require.False(t, found) + require.ErrorIs(t, err, injected) +} + +func TestSafetyStoreGetFailureIsNotFirstRun(t *testing.T) { + injected := errors.New("injected safety store get failure") + db := &failingGetDatabase{ + safetyStoreDatabase: newTestSafetyDatabase(t), + err: injected, + } + store := newTestSafetyStore(t, db, hotstuff.VoteDomain{ + ChainID: 1, ShardID: 0, Epoch: 42, Genesis: "genesis", + }) + + _, found, err := store.Load() + require.False(t, found) + require.ErrorIs(t, err, injected) +} + +func newTestSafetyStore( + t *testing.T, + db safetyStoreDatabase, + domain hotstuff.VoteDomain, +) *SafetyStore { + t.Helper() + key := hmybls.WrapperFromPrivateKey(hmybls.RandPrivateKey()) + store, err := newSafetyStore(db, domain, key.Pub.Bytes) + require.NoError(t, err) + return store +} + +type failingPutDatabase struct { + safetyStoreDatabase + err error +} + +func (db *failingPutDatabase) updateSync( + []byte, + []byte, + func(current []byte, found bool) error, +) error { + return db.err +} + +type failingHasDatabase struct { + safetyStoreDatabase + err error +} + +func (db *failingHasDatabase) Has([]byte) (bool, error) { + return false, db.err +} + +type failingGetDatabase struct { + safetyStoreDatabase + err error +} + +func (db *failingGetDatabase) Has([]byte) (bool, error) { + return true, nil +} + +func (db *failingGetDatabase) Get([]byte) ([]byte, error) { + return nil, db.err +} + +func openTestSafetyDatabase( + t *testing.T, + path string, +) *LevelDBSafetyStoreDatabase { + t.Helper() + db, err := OpenLevelDBSafetyStoreDatabase(path) + require.NoError(t, err) + return db +} + +func newTestSafetyDatabase(t *testing.T) *LevelDBSafetyStoreDatabase { + t.Helper() + db := openTestSafetyDatabase(t, filepath.Join(t.TempDir(), "safety")) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + return db +} + +type recordingLevelDB struct { + writeOptions *opt.WriteOptions +} + +func (db *recordingLevelDB) Has([]byte, *opt.ReadOptions) (bool, error) { + return false, nil +} + +func (db *recordingLevelDB) Get([]byte, *opt.ReadOptions) ([]byte, error) { + return nil, nil +} + +func (db *recordingLevelDB) Put(_ []byte, _ []byte, options *opt.WriteOptions) error { + db.writeOptions = options + return nil +} + +type coordinatedLevelDB struct { + mu sync.Mutex + values map[string][]byte + hasCalls int + secondHas chan struct{} +} + +func newCoordinatedLevelDB() *coordinatedLevelDB { + return &coordinatedLevelDB{ + values: map[string][]byte{}, + secondHas: make(chan struct{}), + } +} + +func (db *coordinatedLevelDB) Has(key []byte, _ *opt.ReadOptions) (bool, error) { + db.mu.Lock() + _, found := db.values[string(key)] + if found { + db.mu.Unlock() + return true, nil + } + db.hasCalls++ + if db.hasCalls == 2 { + close(db.secondHas) + } + secondHas := db.secondHas + db.mu.Unlock() + + select { + case <-secondHas: + case <-time.After(50 * time.Millisecond): + } + return false, nil +} + +func (db *coordinatedLevelDB) Get(key []byte, _ *opt.ReadOptions) ([]byte, error) { + db.mu.Lock() + defer db.mu.Unlock() + return append([]byte(nil), db.values[string(key)]...), nil +} + +func (db *coordinatedLevelDB) Put(key, value []byte, _ *opt.WriteOptions) error { + db.mu.Lock() + defer db.mu.Unlock() + db.values[string(key)] = append([]byte(nil), value...) + return nil +} + +func safetyRuntime( + t *testing.T, + domain hotstuff.VoteDomain, + publicKey hmybls.PublicKeyWrapper, +) (*hotstuff.Core, hotstuff.VerifiedQC, hotstuff.Block) { + t.Helper() + _, authority, core, genesis := safetyAuthority(t, domain, publicKey) + proposal := hotstuff.Block{ + ID: "block-1", + Parent: domain.Genesis, + View: 1, + Justify: genesis.QC(), + } + _, err := authority.Accept(core, proposal, genesis) + require.NoError(t, err) + return core, genesis, proposal +} + +func safetyAuthority( + t *testing.T, + domain hotstuff.VoteDomain, + publicKey hmybls.PublicKeyWrapper, +) (*hotstuff.BLSCommittee, *hotstuff.QCAuthority, *hotstuff.Core, hotstuff.VerifiedQC) { + t.Helper() + committee, err := hotstuff.NewBLSCommitteeFromValidatedKeys([]hotstuff.BLSMember{{ + Member: hotstuff.Member{ID: "validator-a", Power: 1}, + PublicKey: publicKey, + }}) + require.NoError(t, err) + authority := hotstuff.NewQCAuthority(committee, domain) + core, genesis, err := authority.NewCore(hotstuff.Block{ID: domain.Genesis, View: 0}) + require.NoError(t, err) + return committee, authority, core, genesis +} + +func acceptAndCertify( + t *testing.T, + committee *hotstuff.BLSCommittee, + authority *hotstuff.QCAuthority, + core *hotstuff.Core, + domain hotstuff.VoteDomain, + key hmybls.PrivateKeyWrapper, + block hotstuff.Block, + justify hotstuff.VerifiedQC, +) hotstuff.VerifiedQC { + t.Helper() + _, err := authority.Accept(core, block, justify) + require.NoError(t, err) + vote, err := hotstuff.SignVote(domain, hotstuff.Vote{ + Voter: "validator-a", Block: block.ID, View: block.View, + }, key.Pri) + require.NoError(t, err) + votes := hotstuff.NewBLSVoteSet(committee, block.ID, block.View, domain) + require.NoError(t, votes.Add(vote)) + certificate, formed := votes.QC() + require.True(t, formed) + verified, err := authority.Verify(certificate) + require.NoError(t, err) + return verified +} From cd0e8864f8398925fdef93cf36d5037fbcab3125 Mon Sep 17 00:00:00 2001 From: frozen <355847+Frozen@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:07:34 -0400 Subject: [PATCH 10/13] consensus: add Harmony HotStuff quorum roster --- consensus/hotstuff/harmony/doc.go | 8 +- consensus/hotstuff/harmony/quorum.go | 220 +++++++++++++ consensus/hotstuff/harmony/quorum_test.go | 384 ++++++++++++++++++++++ 3 files changed, 609 insertions(+), 3 deletions(-) create mode 100644 consensus/hotstuff/harmony/quorum.go create mode 100644 consensus/hotstuff/harmony/quorum_test.go diff --git a/consensus/hotstuff/harmony/doc.go b/consensus/hotstuff/harmony/doc.go index 87c2d8dcf6..a649814c6e 100644 --- a/consensus/hotstuff/harmony/doc.go +++ b/consensus/hotstuff/harmony/doc.go @@ -1,4 +1,6 @@ -// Package harmony adapts canonical Harmony blocks, validator leader schedules, -// and durable per-key safety state to the isolated HotStuff core without -// changing Harmony quorum semantics. +// Package harmony provides canonical Harmony adapters for isolated HotStuff +// integration work. Leader identities remain validator-level while the +// staking-era reference quorum roster preserves BLS-slot identities, exact +// decimal power, and canonical bitmaps. The reference roster does not yet +// govern HotStuff QC or timeout-certificate formation or verification. package harmony diff --git a/consensus/hotstuff/harmony/quorum.go b/consensus/hotstuff/harmony/quorum.go new file mode 100644 index 0000000000..bf5cb14a7f --- /dev/null +++ b/consensus/hotstuff/harmony/quorum.go @@ -0,0 +1,220 @@ +package harmony + +import ( + "errors" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/harmony-one/harmony/consensus/hotstuff" + "github.com/harmony-one/harmony/consensus/votepower" + hmybls "github.com/harmony-one/harmony/crypto/bls" + "github.com/harmony-one/harmony/numeric" + "github.com/harmony-one/harmony/shard" +) + +var ( + ErrNilQuorumEpoch = errors.New("hotstuff Harmony quorum epoch is nil") + ErrInvalidQuorumEpoch = errors.New("hotstuff Harmony quorum epoch is negative") + ErrInvalidQuorumVotingPower = errors.New("hotstuff Harmony quorum has invalid voting power") + ErrInvalidQuorumBitmap = errors.New("hotstuff Harmony quorum bitmap has invalid length") + ErrNonCanonicalQuorumBitmap = errors.New("hotstuff Harmony quorum bitmap sets unused committee bits") + ErrMissingQuorumVotingPower = errors.New("hotstuff Harmony quorum slot is missing voting power") +) + +var harmonyQuorumThreshold = numeric.NewDec(2).Quo(numeric.NewDec(3)) + +// HarmonyQuorumSlot is one canonical Harmony BLS voting identity. Validator is +// informational ownership metadata; every BLS slot retains its own bitmap +// position and exact EPoS voting power. +type HarmonyQuorumSlot struct { + ID hotstuff.MemberID + Validator common.Address + PublicKey hmybls.SerializedPublicKey + VotingPower numeric.Dec +} + +// HarmonyQuorum is an isolated staking-era reference adapter for Harmony's +// canonical BLS-slot roster and decimal EPoS voting power. It does not yet +// govern the HotStuff core's QC or timeout-certificate paths, and it must not +// reuse the validator-level leader schedule's uniform structural power. +type HarmonyQuorum struct { + slots []HarmonyQuorumSlot + byID map[hotstuff.MemberID]int +} + +// NewHarmonyQuorum derives an immutable staking-era slot-level quorum roster +// from canonical chain committee state, never peer-supplied keys. Its voting +// powers match consensus/votepower.Compute. +func NewHarmonyQuorum(source *shard.Committee, epoch *big.Int) (*HarmonyQuorum, error) { + if source == nil { + return nil, shard.ErrSubCommitteeNil + } + if epoch == nil { + return nil, ErrNilQuorumEpoch + } + if epoch.Sign() < 0 { + return nil, ErrInvalidQuorumEpoch + } + if len(source.Slots) == 0 { + return nil, hotstuff.ErrEmptyCommittee + } + + owned := &shard.Committee{ + ShardID: source.ShardID, + Slots: make(shard.SlotList, len(source.Slots)), + } + keyOwners := make(map[hmybls.SerializedPublicKey]hotstuff.MemberID, len(source.Slots)) + for index, slot := range source.Slots { + if slot.BLSPublicKey.IsEmpty() { + return nil, fmt.Errorf("slot %d: %w", index, hotstuff.ErrInvalidBLSPublicKey) + } + if _, err := hmybls.BytesToBLSPublicKey(slot.BLSPublicKey.Bytes()); err != nil { + return nil, fmt.Errorf("slot %d: %w", index, hotstuff.ErrInvalidBLSPublicKey) + } + if owner, exists := keyOwners[slot.BLSPublicKey]; exists { + if owner != hotstuff.MemberID(slot.EcdsaAddress.Hex()) { + return nil, ErrDuplicateBLSKeyOwner + } + return nil, hotstuff.ErrDuplicateBLSPublicKey + } + keyOwners[slot.BLSPublicKey] = hotstuff.MemberID(slot.EcdsaAddress.Hex()) + + owned.Slots[index] = slot + if slot.EffectiveStake != nil { + if slot.EffectiveStake.IsNil() || !slot.EffectiveStake.IsPositive() { + return nil, fmt.Errorf("slot %d: %w", index, ErrInvalidQuorumVotingPower) + } + stake := slot.EffectiveStake.Copy() + owned.Slots[index].EffectiveStake = &stake + } + } + + roster, err := votepower.Compute(owned, new(big.Int).Set(epoch)) + if err != nil { + return nil, fmt.Errorf("compute Harmony quorum voting power: %w", err) + } + + result := &HarmonyQuorum{ + slots: make([]HarmonyQuorumSlot, 0, len(owned.Slots)), + byID: make(map[hotstuff.MemberID]int, len(owned.Slots)), + } + total := numeric.ZeroDec() + for index, slot := range owned.Slots { + voter, exists := roster.Voters[slot.BLSPublicKey] + if !exists || voter == nil || voter.OverallPercent.IsNil() { + return nil, fmt.Errorf("slot %d: %w", index, ErrMissingQuorumVotingPower) + } + if voter.OverallPercent.IsNegative() { + return nil, fmt.Errorf("slot %d: %w", index, ErrInvalidQuorumVotingPower) + } + memberID := HarmonyQuorumMemberID(slot.BLSPublicKey) + result.byID[memberID] = index + power := voter.OverallPercent.Copy() + result.slots = append(result.slots, HarmonyQuorumSlot{ + ID: memberID, + Validator: slot.EcdsaAddress, + PublicKey: slot.BLSPublicKey, + VotingPower: power, + }) + total = total.Add(power) + } + if !total.Equal(numeric.OneDec()) { + return nil, fmt.Errorf("total %s: %w", total.String(), ErrInvalidQuorumVotingPower) + } + return result, nil +} + +// HarmonyQuorumMemberID returns the stable HotStuff signer identity for one +// Harmony BLS committee slot. +func HarmonyQuorumMemberID(key hmybls.SerializedPublicKey) hotstuff.MemberID { + return hotstuff.MemberID("bls:" + key.Hex()) +} + +// Slots returns the canonical BLS-slot roster with owned decimal values. +func (q *HarmonyQuorum) Slots() []HarmonyQuorumSlot { + slots := make([]HarmonyQuorumSlot, len(q.slots)) + for index, slot := range q.slots { + slots[index] = slot + slots[index].VotingPower = slot.VotingPower.Copy() + } + return slots +} + +// Threshold returns Harmony's strict two-thirds comparison threshold. +func (q *HarmonyQuorum) Threshold() numeric.Dec { + return harmonyQuorumThreshold.Copy() +} + +// Bitmap returns the canonical little-endian committee bitmap for signers. +func (q *HarmonyQuorum) Bitmap(signers []hotstuff.MemberID) ([]byte, error) { + bitmap := make([]byte, q.bitmapLength()) + seen := make(map[hotstuff.MemberID]struct{}, len(signers)) + for _, signer := range signers { + if _, exists := seen[signer]; exists { + return nil, hotstuff.ErrDuplicateSigner + } + index, exists := q.byID[signer] + if !exists { + return nil, hotstuff.ErrUnknownVoter + } + seen[signer] = struct{}{} + bitmap[index>>3] |= byte(1) << uint(index&7) + } + return bitmap, nil +} + +// Signers decodes a canonical bitmap into canonical BLS-slot order. +func (q *HarmonyQuorum) Signers(bitmap []byte) ([]hotstuff.MemberID, error) { + if err := q.validateBitmap(bitmap); err != nil { + return nil, err + } + signers := make([]hotstuff.MemberID, 0, len(q.slots)) + for index, slot := range q.slots { + if bitmap[index>>3]&(byte(1)<>3]&(byte(1)<> 3 +} + +func (q *HarmonyQuorum) validateBitmap(bitmap []byte) error { + if len(bitmap) != q.bitmapLength() { + return ErrInvalidQuorumBitmap + } + if remainder := len(q.slots) & 7; remainder != 0 { + unused := ^byte((1 << uint(remainder)) - 1) + if bitmap[len(bitmap)-1]&unused != 0 { + return ErrNonCanonicalQuorumBitmap + } + } + return nil +} diff --git a/consensus/hotstuff/harmony/quorum_test.go b/consensus/hotstuff/harmony/quorum_test.go new file mode 100644 index 0000000000..fce7bf38fa --- /dev/null +++ b/consensus/hotstuff/harmony/quorum_test.go @@ -0,0 +1,384 @@ +package harmony + +import ( + "fmt" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/harmony-one/harmony/consensus/hotstuff" + hmyquorum "github.com/harmony-one/harmony/consensus/quorum" + "github.com/harmony-one/harmony/consensus/votepower" + hmybls "github.com/harmony-one/harmony/crypto/bls" + shardingconfig "github.com/harmony-one/harmony/internal/configs/sharding" + "github.com/harmony-one/harmony/numeric" + "github.com/harmony-one/harmony/shard" + "github.com/stretchr/testify/require" +) + +func TestHarmonyQuorumPreservesCanonicalBLSSlotOrderAndBitmap(t *testing.T) { + useHarmonyQuorumSchedule(t, numeric.ZeroDec()) + + stake := numeric.OneDec() + validatorA := common.HexToAddress("0x1") + validatorB := common.HexToAddress("0x2") + keyA1 := quorumTestKey() + keyB := quorumTestKey() + keyA2 := quorumTestKey() + committee := &shard.Committee{ + ShardID: 1, + Slots: shard.SlotList{ + {EcdsaAddress: validatorA, BLSPublicKey: keyA1, EffectiveStake: &stake}, + {EcdsaAddress: validatorB, BLSPublicKey: keyB, EffectiveStake: &stake}, + {EcdsaAddress: validatorA, BLSPublicKey: keyA2, EffectiveStake: &stake}, + }, + } + + quorum, err := NewHarmonyQuorum(committee, big.NewInt(1)) + require.NoError(t, err) + + slots := quorum.Slots() + require.Equal(t, []hotstuff.MemberID{ + HarmonyQuorumMemberID(keyA1), + HarmonyQuorumMemberID(keyB), + HarmonyQuorumMemberID(keyA2), + }, []hotstuff.MemberID{slots[0].ID, slots[1].ID, slots[2].ID}) + require.Equal(t, []common.Address{validatorA, validatorB, validatorA}, []common.Address{ + slots[0].Validator, slots[1].Validator, slots[2].Validator, + }) + require.Equal(t, []hmybls.SerializedPublicKey{keyA1, keyB, keyA2}, []hmybls.SerializedPublicKey{ + slots[0].PublicKey, slots[1].PublicKey, slots[2].PublicKey, + }) + + bitmap, err := quorum.Bitmap([]hotstuff.MemberID{slots[2].ID, slots[0].ID}) + require.NoError(t, err) + require.Equal(t, []byte{0b00000101}, bitmap) + + signers, err := quorum.Signers(bitmap) + require.NoError(t, err) + require.Equal(t, []hotstuff.MemberID{slots[0].ID, slots[2].ID}, signers) +} + +func TestHarmonyQuorumUsesExactDecimalVotingPower(t *testing.T) { + useHarmonyQuorumSchedule(t, numeric.ZeroDec()) + + stake := numeric.OneDec() + committee := &shard.Committee{Slots: shard.SlotList{ + {BLSPublicKey: quorumTestKey(), EffectiveStake: &stake}, + {BLSPublicKey: quorumTestKey(), EffectiveStake: &stake}, + {BLSPublicKey: quorumTestKey(), EffectiveStake: &stake}, + }} + quorum, err := NewHarmonyQuorum(committee, big.NewInt(1)) + require.NoError(t, err) + + threshold := numeric.NewDec(2).Quo(numeric.NewDec(3)) + slots := quorum.Slots() + require.True(t, slots[0].VotingPower.Equal(numeric.MustNewDecFromStr("0.333333333333333333"))) + require.True(t, slots[1].VotingPower.Equal(numeric.MustNewDecFromStr("0.333333333333333333"))) + require.True(t, slots[2].VotingPower.Equal(numeric.MustNewDecFromStr("0.333333333333333334"))) + require.True(t, quorum.Threshold().Equal(threshold)) + + exactThreshold, err := quorum.Bitmap([]hotstuff.MemberID{slots[0].ID, slots[1].ID}) + require.NoError(t, err) + power, err := quorum.VotingPower(exactThreshold) + require.NoError(t, err) + require.True(t, power.Equal(numeric.MustNewDecFromStr("0.666666666666666666"))) + formed, err := quorum.IsQuorum(exactThreshold) + require.NoError(t, err) + require.False(t, formed, "Harmony requires voting power strictly greater than two thirds") + + aboveThreshold, err := quorum.Bitmap([]hotstuff.MemberID{slots[0].ID, slots[2].ID}) + require.NoError(t, err) + power, err = quorum.VotingPower(aboveThreshold) + require.NoError(t, err) + require.True(t, power.Equal(numeric.MustNewDecFromStr("0.666666666666666667"))) + formed, err = quorum.IsQuorum(aboveThreshold) + require.NoError(t, err) + require.False(t, formed, "power equal to Harmony's rounded threshold is not quorum") + + allSlots, err := quorum.Bitmap([]hotstuff.MemberID{slots[0].ID, slots[1].ID, slots[2].ID}) + require.NoError(t, err) + formed, err = quorum.IsQuorum(allSlots) + require.NoError(t, err) + require.True(t, formed) + + oneAtomAboveStake := numeric.NewDecFromBigInt(big.NewInt(666666666666666668)) + remainderStake := numeric.NewDecFromBigInt(big.NewInt(333333333333333332)) + oneAtomAbove, err := NewHarmonyQuorum(&shard.Committee{Slots: shard.SlotList{ + {BLSPublicKey: quorumTestKey(), EffectiveStake: &oneAtomAboveStake}, + {BLSPublicKey: quorumTestKey(), EffectiveStake: &remainderStake}, + }}, big.NewInt(1)) + require.NoError(t, err) + oneAtomAboveSlots := oneAtomAbove.Slots() + oneSigner, err := oneAtomAbove.Bitmap([]hotstuff.MemberID{oneAtomAboveSlots[0].ID}) + require.NoError(t, err) + power, err = oneAtomAbove.VotingPower(oneSigner) + require.NoError(t, err) + require.True(t, power.Equal(numeric.MustNewDecFromStr("0.666666666666666668"))) + formed, err = oneAtomAbove.IsQuorum(oneSigner) + require.NoError(t, err) + require.True(t, formed) +} + +func TestHarmonyQuorumPreservesHarmonyAndEPoSSlotWeights(t *testing.T) { + useHarmonyQuorumSchedule(t, numeric.MustNewDecFromStr("0.4")) + + one := numeric.NewDec(1) + two := numeric.NewDec(2) + three := numeric.NewDec(3) + committee := &shard.Committee{Slots: shard.SlotList{ + {BLSPublicKey: quorumTestKey(), EffectiveStake: nil}, + {BLSPublicKey: quorumTestKey(), EffectiveStake: nil}, + {BLSPublicKey: quorumTestKey(), EffectiveStake: &one}, + {BLSPublicKey: quorumTestKey(), EffectiveStake: &two}, + {BLSPublicKey: quorumTestKey(), EffectiveStake: &three}, + }} + quorum, err := NewHarmonyQuorum(committee, big.NewInt(1)) + require.NoError(t, err) + + slots := quorum.Slots() + expected := []numeric.Dec{ + numeric.MustNewDecFromStr("0.2"), + numeric.MustNewDecFromStr("0.2"), + numeric.MustNewDecFromStr("0.1"), + numeric.MustNewDecFromStr("0.2"), + numeric.MustNewDecFromStr("0.3"), + } + for index := range expected { + require.True(t, slots[index].VotingPower.Equal(expected[index]), "slot %d", index) + } + + bitmap, err := quorum.Bitmap([]hotstuff.MemberID{slots[0].ID, slots[1].ID, slots[4].ID}) + require.NoError(t, err) + formed, err := quorum.IsQuorum(bitmap) + require.NoError(t, err) + require.True(t, formed) + + externalOnly, err := quorum.Bitmap([]hotstuff.MemberID{slots[2].ID, slots[3].ID, slots[4].ID}) + require.NoError(t, err) + formed, err = quorum.IsQuorum(externalOnly) + require.NoError(t, err) + require.False(t, formed, "three of five slots have only 0.6 EPoS voting power") +} + +func TestHarmonyQuorumRejectsInvalidCommitteeInputs(t *testing.T) { + useHarmonyQuorumSchedule(t, numeric.ZeroDec()) + + _, err := NewHarmonyQuorum(nil, big.NewInt(1)) + require.ErrorIs(t, err, shard.ErrSubCommitteeNil) + _, err = NewHarmonyQuorum(&shard.Committee{Slots: shard.SlotList{{BLSPublicKey: quorumTestKey()}}}, nil) + require.ErrorIs(t, err, ErrNilQuorumEpoch) + _, err = NewHarmonyQuorum(&shard.Committee{}, big.NewInt(1)) + require.ErrorIs(t, err, hotstuff.ErrEmptyCommittee) + + key := quorumTestKey() + stake := numeric.OneDec() + _, err = NewHarmonyQuorum(&shard.Committee{Slots: shard.SlotList{ + {EcdsaAddress: common.HexToAddress("0x1"), BLSPublicKey: key, EffectiveStake: &stake}, + {EcdsaAddress: common.HexToAddress("0x1"), BLSPublicKey: key, EffectiveStake: &stake}, + }}, big.NewInt(1)) + require.ErrorIs(t, err, hotstuff.ErrDuplicateBLSPublicKey) + _, err = NewHarmonyQuorum(&shard.Committee{Slots: shard.SlotList{ + {EcdsaAddress: common.HexToAddress("0x1"), BLSPublicKey: key, EffectiveStake: &stake}, + {EcdsaAddress: common.HexToAddress("0x2"), BLSPublicKey: key, EffectiveStake: &stake}, + }}, big.NewInt(1)) + require.ErrorIs(t, err, ErrDuplicateBLSKeyOwner) + + _, err = NewHarmonyQuorum(&shard.Committee{Slots: shard.SlotList{ + {BLSPublicKey: hmybls.SerializedPublicKey{}, EffectiveStake: &stake}, + }}, big.NewInt(1)) + require.ErrorIs(t, err, hotstuff.ErrInvalidBLSPublicKey) + malformedKey := hmybls.SerializedPublicKey{} + for index := range malformedKey { + malformedKey[index] = 0xff + } + _, err = NewHarmonyQuorum(&shard.Committee{Slots: shard.SlotList{ + {BLSPublicKey: malformedKey, EffectiveStake: &stake}, + }}, big.NewInt(1)) + require.ErrorIs(t, err, hotstuff.ErrInvalidBLSPublicKey) + + zero := numeric.ZeroDec() + _, err = NewHarmonyQuorum(&shard.Committee{Slots: shard.SlotList{ + {BLSPublicKey: quorumTestKey(), EffectiveStake: &zero}, + }}, big.NewInt(1)) + require.ErrorIs(t, err, ErrInvalidQuorumVotingPower) +} + +func TestHarmonyQuorumRejectsNonCanonicalBitmapAndSigners(t *testing.T) { + useHarmonyQuorumSchedule(t, numeric.ZeroDec()) + + stake := numeric.OneDec() + committee := &shard.Committee{Slots: make(shard.SlotList, 9)} + for index := range committee.Slots { + committee.Slots[index] = shard.Slot{BLSPublicKey: quorumTestKey(), EffectiveStake: &stake} + } + quorum, err := NewHarmonyQuorum(committee, big.NewInt(1)) + require.NoError(t, err) + slots := quorum.Slots() + + _, err = quorum.Bitmap([]hotstuff.MemberID{slots[0].ID, slots[0].ID}) + require.ErrorIs(t, err, hotstuff.ErrDuplicateSigner) + _, err = quorum.Bitmap([]hotstuff.MemberID{"unknown"}) + require.ErrorIs(t, err, hotstuff.ErrUnknownVoter) + + _, err = quorum.Signers([]byte{1}) + require.ErrorIs(t, err, ErrInvalidQuorumBitmap) + _, err = quorum.Signers([]byte{0, 0b00000010}) + require.ErrorIs(t, err, ErrNonCanonicalQuorumBitmap) + _, err = quorum.VotingPower([]byte{0, 0b10000000}) + require.ErrorIs(t, err, ErrNonCanonicalQuorumBitmap) +} + +func TestHarmonyQuorumValidatesBitmapBoundaries(t *testing.T) { + useHarmonyQuorumSchedule(t, numeric.ZeroDec()) + + for _, slotCount := range []int{1, 7, 8, 9} { + t.Run(fmt.Sprintf("slots-%d", slotCount), func(t *testing.T) { + stake := numeric.OneDec() + committee := &shard.Committee{Slots: make(shard.SlotList, slotCount)} + for index := range committee.Slots { + committee.Slots[index] = shard.Slot{BLSPublicKey: quorumTestKey(), EffectiveStake: &stake} + } + bridge, err := NewHarmonyQuorum(committee, big.NewInt(1)) + require.NoError(t, err) + + bitmapLength := (slotCount + 7) >> 3 + canonical := make([]byte, bitmapLength) + if slotCount&7 == 0 { + canonical[bitmapLength-1] = 0xff + } else { + canonical[bitmapLength-1] = byte((1 << uint(slotCount&7)) - 1) + } + _, err = bridge.Signers(canonical) + require.NoError(t, err) + + for bit := slotCount; bit < bitmapLength*8; bit++ { + nonCanonical := append([]byte(nil), canonical...) + nonCanonical[bit>>3] |= byte(1) << uint(bit&7) + _, err = bridge.Signers(nonCanonical) + require.ErrorIs(t, err, ErrNonCanonicalQuorumBitmap, "bit %d", bit) + } + }) + } +} + +func TestHarmonyQuorumOwnsReturnedVotingPower(t *testing.T) { + useHarmonyQuorumSchedule(t, numeric.ZeroDec()) + + stake := numeric.OneDec() + quorum, err := NewHarmonyQuorum(&shard.Committee{Slots: shard.SlotList{ + {BLSPublicKey: quorumTestKey(), EffectiveStake: &stake}, + {BLSPublicKey: quorumTestKey(), EffectiveStake: &stake}, + {BLSPublicKey: quorumTestKey(), EffectiveStake: &stake}, + }}, big.NewInt(1)) + require.NoError(t, err) + + first := quorum.Slots() + first[0].VotingPower.Int.SetInt64(0) + second := quorum.Slots() + require.True(t, second[0].VotingPower.IsPositive()) + + threshold := quorum.Threshold() + threshold.Int.SetInt64(0) + require.True(t, quorum.Threshold().IsPositive()) +} + +func TestHarmonyQuorumMatchesHarmonyRosterAndVerifier(t *testing.T) { + useHarmonyQuorumSchedule(t, numeric.MustNewDecFromStr("0.4")) + + one := numeric.NewDec(1) + two := numeric.NewDec(2) + three := numeric.NewDec(3) + committee := &shard.Committee{Slots: shard.SlotList{ + {BLSPublicKey: quorumTestKey()}, + {BLSPublicKey: quorumTestKey(), EffectiveStake: &one}, + {BLSPublicKey: quorumTestKey(), EffectiveStake: &two}, + {BLSPublicKey: quorumTestKey(), EffectiveStake: &three}, + }} + epoch := big.NewInt(1) + bridge, err := NewHarmonyQuorum(committee, epoch) + require.NoError(t, err) + roster, err := votepower.Compute(committee, epoch) + require.NoError(t, err) + verifier, err := hmyquorum.NewVerifier(committee, epoch, true) + require.NoError(t, err) + + publics := make([]hmybls.PublicKeyWrapper, len(committee.Slots)) + for index, slot := range committee.Slots { + public, err := hmybls.BytesToBLSPublicKey(slot.BLSPublicKey.Bytes()) + require.NoError(t, err) + publics[index] = hmybls.PublicKeyWrapper{Bytes: slot.BLSPublicKey, Object: public} + } + for raw := byte(0); raw < 1< Date: Tue, 4 Aug 2026 20:42:14 -0400 Subject: [PATCH 11/13] consensus: wire Harmony HotStuff certificate quorum --- consensus/hotstuff/api_test.go | 46 +++ consensus/hotstuff/bls.go | 53 ++- consensus/hotstuff/bls_test.go | 12 +- consensus/hotstuff/bls_timeout.go | 13 +- consensus/hotstuff/bls_timeout_test.go | 15 +- consensus/hotstuff/certified.go | 30 +- consensus/hotstuff/certified_test.go | 3 +- consensus/hotstuff/committee.go | 50 ++- consensus/hotstuff/core_test.go | 9 +- consensus/hotstuff/harmony/authority.go | 65 +++ consensus/hotstuff/harmony/authority_test.go | 383 ++++++++++++++++++ consensus/hotstuff/harmony/doc.go | 7 +- consensus/hotstuff/harmony/quorum.go | 15 +- .../hotstuff/harmony/safety_store_test.go | 3 +- consensus/hotstuff/pacemaker.go | 57 ++- consensus/hotstuff/pacemaker_test.go | 3 +- consensus/hotstuff/quorum_policy_test.go | 204 ++++++++++ consensus/hotstuff/votes.go | 19 +- 18 files changed, 919 insertions(+), 68 deletions(-) create mode 100644 consensus/hotstuff/harmony/authority.go create mode 100644 consensus/hotstuff/harmony/authority_test.go create mode 100644 consensus/hotstuff/quorum_policy_test.go diff --git a/consensus/hotstuff/api_test.go b/consensus/hotstuff/api_test.go index 622a1640fd..00f1ce5e9b 100644 --- a/consensus/hotstuff/api_test.go +++ b/consensus/hotstuff/api_test.go @@ -8,6 +8,18 @@ import ( "github.com/stretchr/testify/require" ) +type externalCertificateQuorum struct{} + +func (externalCertificateQuorum) HasQuorum(signers []hotstuff.MemberID) (bool, error) { + return len(signers) >= 1, nil +} + +type externalLeaderSchedule struct{} + +func (externalLeaderSchedule) Leader(hotstuff.View) hotstuff.MemberID { + return "validator" +} + func TestAuthorityConstructsUsableExternalStateMachines(t *testing.T) { members := make([]hotstuff.BLSMember, 0, 3) for _, id := range []hotstuff.MemberID{"alice", "bob", "carol"} { @@ -42,3 +54,37 @@ func TestAuthorityConstructsUsableExternalStateMachines(t *testing.T) { require.ErrorIs(t, err, hotstuff.ErrGenesisRootMismatch) require.Equal(t, hotstuff.BlockID("genesis"), pacemaker.HighQC().Block) } + +func TestExternalAuthorityUsesPluggableQuorumAndLeaderSchedule(t *testing.T) { + secret := hmybls.RandPrivateKey() + committee, err := hotstuff.NewBLSCommitteeFromValidatedKeysWithQuorum( + []hotstuff.BLSMember{{ + Member: hotstuff.Member{ID: "slot", Power: 1}, + PublicKey: *hmybls.WrapperFromPrivateKey(secret).Pub, + }}, + externalCertificateQuorum{}, + ) + require.NoError(t, err) + domain := hotstuff.VoteDomain{Genesis: "genesis"} + authority, err := hotstuff.NewQCAuthorityWithLeaderSchedule( + committee, domain, externalLeaderSchedule{}, + ) + require.NoError(t, err) + _, _, err = authority.NewCore(hotstuff.Block{ID: "genesis", View: 0}) + require.NoError(t, err) + pacemaker, err := authority.NewPacemaker(1) + require.NoError(t, err) + require.Equal(t, hotstuff.MemberID("validator"), pacemaker.Leader()) + + votes := authority.NewVoteSet("b1", 1) + signed, err := hotstuff.SignVote( + domain, hotstuff.Vote{Voter: "slot", Block: "b1", View: 1}, secret, + ) + require.NoError(t, err) + require.NoError(t, votes.Add(signed)) + certificate, formed, err := votes.QC() + require.NoError(t, err) + require.True(t, formed) + _, err = authority.Verify(certificate) + require.NoError(t, err) +} diff --git a/consensus/hotstuff/bls.go b/consensus/hotstuff/bls.go index e856bfa0ac..2fa02389fc 100644 --- a/consensus/hotstuff/bls.go +++ b/consensus/hotstuff/bls.go @@ -41,8 +41,9 @@ type BLSMember struct { PublicKey hmybls.PublicKeyWrapper } -// BLSCommittee binds the structural voting-power committee to Harmony BLS -// public keys in the same canonical member order. +// BLSCommittee binds a certificate committee to Harmony BLS public keys in the +// same canonical member order. Its quorum may use either structural integer +// power or an externally supplied exact policy. type BLSCommittee struct { committee *Committee members []BLSMember @@ -54,6 +55,27 @@ type BLSCommittee struct { // already passed staking/types.VerifyBLSKey (or an equivalent registry check). // It must not be called directly on public keys supplied by a network peer. func NewBLSCommitteeFromValidatedKeys(members []BLSMember) (*BLSCommittee, error) { + return newBLSCommitteeFromValidatedKeys(members, nil) +} + +// NewBLSCommitteeFromValidatedKeysWithQuorum constructs a BLS committee whose +// QC and TC paths use an external exact certificate-quorum policy. The policy +// must describe the same member IDs as members and remain immutable for the +// lifetime of every authority that uses the returned committee. +func NewBLSCommitteeFromValidatedKeysWithQuorum( + members []BLSMember, + quorum CertificateQuorum, +) (*BLSCommittee, error) { + if isNilInterface(quorum) { + return nil, ErrNilCertificateQuorum + } + return newBLSCommitteeFromValidatedKeys(members, quorum) +} + +func newBLSCommitteeFromValidatedKeys( + members []BLSMember, + quorum CertificateQuorum, +) (*BLSCommittee, error) { structuralMembers := make([]Member, 0, len(members)) for _, member := range members { structuralMembers = append(structuralMembers, member.Member) @@ -62,6 +84,20 @@ func NewBLSCommitteeFromValidatedKeys(members []BLSMember) (*BLSCommittee, error if err != nil { return nil, err } + if quorum != nil { + allSigners := make([]MemberID, len(structuralMembers)) + for index, member := range structuralMembers { + allSigners[index] = member.ID + } + hasQuorum, err := quorum.HasQuorum(allSigners) + if err != nil { + return nil, err + } + if !hasQuorum { + return nil, ErrCertificateQuorumRosterMismatch + } + } + committee.certificateQuorum = quorum result := &BLSCommittee{ committee: committee, @@ -171,13 +207,16 @@ func (s *BLSVoteSet) Add(vote SignedVote) error { return nil } -func (s *BLSVoteSet) QC() (BLSQC, bool) { +func (s *BLSVoteSet) QC() (BLSQC, bool, error) { s.mu.Lock() defer s.mu.Unlock() - qc, formed := s.votes.QC() + qc, formed, err := s.votes.QC() + if err != nil { + return BLSQC{}, false, err + } if !formed { - return BLSQC{}, false + return BLSQC{}, false, nil } signatures := make([]*bls_core.Sign, 0, len(qc.Signers)) mask := hmybls.NewMask(s.committee.publics) @@ -185,7 +224,7 @@ func (s *BLSVoteSet) QC() (BLSQC, bool) { signatures = append(signatures, s.signatures[signer]) member := s.committee.byID[signer] if err := mask.SetKey(member.PublicKey.Bytes, true); err != nil { - return BLSQC{}, false + return BLSQC{}, false, err } } aggregate := hmybls.AggregateSig(signatures) @@ -193,7 +232,7 @@ func (s *BLSVoteSet) QC() (BLSQC, bool) { QC: qc, Signature: append([]byte(nil), aggregate.Serialize()...), Bitmap: mask.Mask(), - }, true + }, true, nil } func (c *BLSCommittee) VerifyQC(domain VoteDomain, qc BLSQC) error { diff --git a/consensus/hotstuff/bls_test.go b/consensus/hotstuff/bls_test.go index fc129f543f..0d84a28b49 100644 --- a/consensus/hotstuff/bls_test.go +++ b/consensus/hotstuff/bls_test.go @@ -24,7 +24,8 @@ func TestBLSSignedBroadcastVotesFormVerifiableQC(t *testing.T) { require.NoError(t, set.Add(signed)) } - qc, formed := set.QC() + qc, formed, err := set.QC() + require.NoError(t, err) require.True(t, formed) require.Equal(t, QC{ Block: "b7", @@ -72,7 +73,8 @@ func TestBLSQCVerificationRejectsTampering(t *testing.T) { require.NoError(t, err) require.NoError(t, set.Add(signed)) } - qc, formed := set.QC() + qc, formed, err := set.QC() + require.NoError(t, err) require.True(t, formed) tamperedBlock := cloneBLSQC(qc) @@ -104,13 +106,15 @@ func TestBLSVoteSetUsesWeightedQuorum(t *testing.T) { require.NoError(t, err) require.NoError(t, set.Add(signed)) } - _, formed := set.QC() + _, formed, err := set.QC() + require.NoError(t, err) require.False(t, formed, "four of six voting power is not strictly above two thirds") signed, err := SignVote(domain, Vote{Voter: "carol", Block: "b7", View: 7}, secrets["carol"]) require.NoError(t, err) require.NoError(t, set.Add(signed)) - qc, formed := set.QC() + qc, formed, err := set.QC() + require.NoError(t, err) require.True(t, formed) require.NoError(t, committee.VerifyQC(domain, qc)) } diff --git a/consensus/hotstuff/bls_timeout.go b/consensus/hotstuff/bls_timeout.go index b47d3deff1..da2316c5b9 100644 --- a/consensus/hotstuff/bls_timeout.go +++ b/consensus/hotstuff/bls_timeout.go @@ -166,13 +166,16 @@ func (s *BLSTimeoutSet) Add(timeout SignedTimeout, evidence BLSQC) error { return nil } -func (s *BLSTimeoutSet) Certificate() (BLSTimeoutCertificate, bool) { +func (s *BLSTimeoutSet) Certificate() (BLSTimeoutCertificate, bool, error) { s.mu.Lock() defer s.mu.Unlock() - certificate, formed := s.timeouts.Certificate() + certificate, formed, err := s.timeouts.Certificate() + if err != nil { + return BLSTimeoutCertificate{}, false, err + } if !formed { - return BLSTimeoutCertificate{}, false + return BLSTimeoutCertificate{}, false, nil } signatures := make([]*bls_core.Sign, 0, len(certificate.Signers)) reports := make([]BLSTimeoutReport, 0, len(certificate.Signers)) @@ -182,7 +185,7 @@ func (s *BLSTimeoutSet) Certificate() (BLSTimeoutCertificate, bool) { reports = append(reports, cloneBLSTimeoutReport(s.reports[signer])) member := s.authority.committee.byID[signer] if err := mask.SetKey(member.PublicKey.Bytes, true); err != nil { - return BLSTimeoutCertificate{}, false + return BLSTimeoutCertificate{}, false, err } } aggregate := hmybls.AggregateSig(signatures) @@ -193,7 +196,7 @@ func (s *BLSTimeoutSet) Certificate() (BLSTimeoutCertificate, bool) { Reports: reports, Signature: append([]byte(nil), aggregate.Serialize()...), Bitmap: mask.Mask(), - }, true + }, true, nil } func (a *QCAuthority) VerifyTC(certificate BLSTimeoutCertificate) (VerifiedTC, error) { diff --git a/consensus/hotstuff/bls_timeout_test.go b/consensus/hotstuff/bls_timeout_test.go index 8f66e232b0..6d12a7840b 100644 --- a/consensus/hotstuff/bls_timeout_test.go +++ b/consensus/hotstuff/bls_timeout_test.go @@ -31,12 +31,14 @@ func TestBLSSignedTimeoutsFormVerifiableTCAndAdvancePacemaker(t *testing.T) { require.NoError(t, err) require.NoError(t, set.Add(signed, BLSQC{QC: genesis.QC()})) if index == 1 { - _, formed := set.Certificate() + _, formed, err := set.Certificate() + require.NoError(t, err) require.False(t, formed) } } - certificate, formed := set.Certificate() + certificate, formed, err := set.Certificate() + require.NoError(t, err) require.True(t, formed) require.Equal(t, []MemberID{"alice", "bob", "carol"}, certificate.Signers) require.Equal(t, []byte{0b00000111}, certificate.Bitmap) @@ -195,7 +197,8 @@ func TestBLSTimeoutCertificateRejectsConflictingSameViewQCs(t *testing.T) { require.NoError(t, err) require.NoError(t, set.Add(signed, highQC)) } - certificate, formed := set.Certificate() + certificate, formed, err := set.Certificate() + require.NoError(t, err) require.True(t, formed) _, err = authority.VerifyTC(certificate) require.ErrorIs(t, err, ErrConflictingHighQC) @@ -229,7 +232,8 @@ func TestBLSTimeoutCertificateRejectsNonAdjacentSameViewConflict(t *testing.T) { require.NoError(t, err) require.NoError(t, set.Add(signed, highQC)) } - certificate, formed := set.Certificate() + certificate, formed, err := set.Certificate() + require.NoError(t, err) require.True(t, formed) _, err = authority.VerifyTC(certificate) require.ErrorIs(t, err, ErrConflictingHighQC) @@ -254,7 +258,8 @@ func formBLSTC( require.NoError(t, err) require.NoError(t, set.Add(signed, highQC)) } - certificate, formed := set.Certificate() + certificate, formed, err := set.Certificate() + require.NoError(t, err) require.True(t, formed) return certificate } diff --git a/consensus/hotstuff/certified.go b/consensus/hotstuff/certified.go index 38da0052a5..f645e8b107 100644 --- a/consensus/hotstuff/certified.go +++ b/consensus/hotstuff/certified.go @@ -11,6 +11,7 @@ var ( ErrGenesisRootMismatch = errors.New("hotstuff authority is already bound to a different genesis root") ErrMissingGenesisRoot = errors.New("hotstuff authority has no configured genesis root") ErrInvalidInitialView = errors.New("hotstuff initial view must be after the genesis view") + ErrNilBLSCommittee = errors.New("hotstuff BLS committee is nil") ) // QCAuthority binds certificate verification and structural state machines to @@ -18,6 +19,7 @@ var ( // epoch and shard, then constructs its Core and Pacemaker through that object. type QCAuthority struct { committee *BLSCommittee + leaders LeaderSchedule domain VoteDomain mu sync.Mutex genesis QC @@ -28,6 +30,28 @@ func NewQCAuthority(committee *BLSCommittee, domain VoteDomain) *QCAuthority { return &QCAuthority{committee: committee, domain: domain} } +// NewQCAuthorityWithLeaderSchedule separates validator-level leader rotation +// from the BLS-slot committee used for QC and TC verification. +func NewQCAuthorityWithLeaderSchedule( + committee *BLSCommittee, + domain VoteDomain, + leaders LeaderSchedule, +) (*QCAuthority, error) { + if committee == nil { + return nil, ErrNilBLSCommittee + } + if isNilInterface(leaders) { + return nil, ErrNilLeaderSchedule + } + return &QCAuthority{committee: committee, leaders: leaders, domain: domain}, nil +} + +// NewVoteSet constructs a vote collector bound to this authority's committee +// and domain. +func (a *QCAuthority) NewVoteSet(block BlockID, view View) *BLSVoteSet { + return NewBLSVoteSet(a.committee, block, view, a.domain) +} + // VerifiedQC is an immutable capability issued by one QCAuthority after BLS // verification or for that authority's configured genesis trust root. type VerifiedQC struct { @@ -90,7 +114,11 @@ func (a *QCAuthority) NewPacemaker(initial View) (*Pacemaker, error) { if initial <= a.genesis.View { return nil, ErrInvalidInitialView } - pacemaker := newPacemaker(a.committee.committee, initial) + leaders := a.leaders + if leaders == nil { + leaders = a.committee.committee + } + pacemaker := newPacemakerWithLeaderSchedule(a.committee.committee, leaders, initial) pacemaker.authority = a pacemaker.highQC = cloneQC(a.genesis) return pacemaker, nil diff --git a/consensus/hotstuff/certified_test.go b/consensus/hotstuff/certified_test.go index fdd01879ad..9615d436dd 100644 --- a/consensus/hotstuff/certified_test.go +++ b/consensus/hotstuff/certified_test.go @@ -220,7 +220,8 @@ func formBLSQC( require.NoError(t, err) require.NoError(t, set.Add(signed)) } - qc, formed := set.QC() + qc, formed, err := set.QC() + require.NoError(t, err) require.True(t, formed) return qc } diff --git a/consensus/hotstuff/committee.go b/consensus/hotstuff/committee.go index 273ec857c8..62f1cc3b48 100644 --- a/consensus/hotstuff/committee.go +++ b/consensus/hotstuff/committee.go @@ -3,27 +3,46 @@ package hotstuff import ( "errors" "math" + "reflect" ) var ( - ErrEmptyCommittee = errors.New("hotstuff committee is empty") - ErrInvalidMember = errors.New("hotstuff committee member is invalid") - ErrDuplicateMember = errors.New("hotstuff committee member is duplicated") - ErrVotingPowerOverflow = errors.New("hotstuff committee voting power overflows uint64") + ErrEmptyCommittee = errors.New("hotstuff committee is empty") + ErrInvalidMember = errors.New("hotstuff committee member is invalid") + ErrDuplicateMember = errors.New("hotstuff committee member is duplicated") + ErrVotingPowerOverflow = errors.New("hotstuff committee voting power overflows uint64") + ErrNilCertificateQuorum = errors.New("hotstuff certificate quorum policy is nil") + ErrCertificateQuorumRosterMismatch = errors.New("hotstuff certificate quorum policy does not match the full committee roster") + ErrNilLeaderSchedule = errors.New("hotstuff leader schedule is nil") ) +// CertificateQuorum decides whether a validated set of unique committee +// signers has certificate quorum. Implementations must be immutable, +// deterministic, and safe for concurrent use for one authority epoch. +type CertificateQuorum interface { + HasQuorum(signers []MemberID) (bool, error) +} + +// LeaderSchedule selects one validator-level leader for a HotStuff view. It is +// deliberately independent from certificate signer granularity and must be +// immutable, deterministic, and safe for concurrent use by one authority. +type LeaderSchedule interface { + Leader(view View) MemberID +} + // Member is a validator identity and its integer voting power. type Member struct { ID MemberID Power uint64 } -// Committee is an ordered, weighted validator set. Its order defines the -// deterministic round-robin leader schedule. +// Committee is an ordered, weighted validator set. Its order defines canonical +// certificate signer order and the default round-robin leader schedule. type Committee struct { - members []Member - byID map[MemberID]Member - total uint64 + members []Member + byID map[MemberID]Member + total uint64 + certificateQuorum CertificateQuorum } func NewCommittee(members []Member) (*Committee, error) { @@ -68,3 +87,16 @@ func (c *Committee) quorumPower() uint64 { // The smallest integer voting power strictly greater than two thirds. return c.total - (c.total-1)/3 } + +func isNilInterface(value interface{}) bool { + if value == nil { + return true + } + reflected := reflect.ValueOf(value) + switch reflected.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice: + return reflected.IsNil() + default: + return false + } +} diff --git a/consensus/hotstuff/core_test.go b/consensus/hotstuff/core_test.go index b8a0fcdbb1..4a1865c06a 100644 --- a/consensus/hotstuff/core_test.go +++ b/consensus/hotstuff/core_test.go @@ -43,7 +43,8 @@ func TestBroadcastVotesProduceSameQCAtEveryReplica(t *testing.T) { require.NoError(t, collector.Add(vote)) } - got, ok := collector.QC() + got, ok, err := collector.QC() + require.NoError(t, err) require.Truef(t, ok, "replica %d did not form a QC", replica) if replica == 0 { want = got @@ -65,7 +66,8 @@ func TestVoteSetRejectsDuplicateVoter(t *testing.T) { require.NoError(t, collector.Add(vote)) require.ErrorIs(t, collector.Add(vote), ErrDuplicateVote) - _, ok := collector.QC() + _, ok, err := collector.QC() + require.NoError(t, err) require.False(t, ok, "a duplicate vote must not increase voting power") } @@ -100,7 +102,8 @@ func TestVoteSetSerializesConcurrentDuplicateVoter(t *testing.T) { } } require.Equal(t, 1, successes) - _, formed := collector.QC() + _, formed, err := collector.QC() + require.NoError(t, err) require.False(t, formed) } diff --git a/consensus/hotstuff/harmony/authority.go b/consensus/hotstuff/harmony/authority.go new file mode 100644 index 0000000000..fa2f6b77ea --- /dev/null +++ b/consensus/hotstuff/harmony/authority.go @@ -0,0 +1,65 @@ +package harmony + +import ( + "errors" + "math/big" + + "github.com/harmony-one/harmony/consensus/hotstuff" + hmybls "github.com/harmony-one/harmony/crypto/bls" + "github.com/harmony-one/harmony/shard" +) + +// ErrQuorumDomainMismatch indicates that the authority vote domain does not +// identify the shard committee epoch used to derive its quorum roster. +var ErrQuorumDomainMismatch = errors.New("hotstuff Harmony quorum domain does not match committee epoch or shard") + +// NewStakingQCAuthority binds one canonical staking-era Harmony committee to +// validator-level leader rotation and exact BLS-slot QC/TC quorum semantics. +// Activation callers must still gate this constructor with +// ChainConfig.IsStaking(epoch). +func NewStakingQCAuthority( + source *shard.Committee, + epoch *big.Int, + domain hotstuff.VoteDomain, +) (*hotstuff.QCAuthority, error) { + owned := cloneAuthorityCommittee(source) + quorum, err := NewHarmonyQuorum(owned, epoch) + if err != nil { + return nil, err + } + if epoch.BitLen() > 64 || domain.Epoch != epoch.Uint64() || domain.ShardID != owned.ShardID { + return nil, ErrQuorumDomainMismatch + } + leaders, err := NewValidatorSchedule(owned) + if err != nil { + return nil, err + } + + slots := quorum.Slots() + members := make([]hotstuff.BLSMember, len(slots)) + for index, slot := range slots { + members[index] = hotstuff.BLSMember{ + Member: hotstuff.Member{ID: slot.ID, Power: 1}, + PublicKey: hmybls.PublicKeyWrapper{ + Bytes: slot.PublicKey, + }, + } + } + committee, err := hotstuff.NewBLSCommitteeFromValidatedKeysWithQuorum(members, quorum) + if err != nil { + return nil, err + } + return hotstuff.NewQCAuthorityWithLeaderSchedule(committee, domain, leaders) +} + +func cloneAuthorityCommittee(source *shard.Committee) *shard.Committee { + if source == nil { + return nil + } + owned := &shard.Committee{ + ShardID: source.ShardID, + Slots: make(shard.SlotList, len(source.Slots)), + } + copy(owned.Slots, source.Slots) + return owned +} diff --git a/consensus/hotstuff/harmony/authority_test.go b/consensus/hotstuff/harmony/authority_test.go new file mode 100644 index 0000000000..0ebafefe4c --- /dev/null +++ b/consensus/hotstuff/harmony/authority_test.go @@ -0,0 +1,383 @@ +package harmony + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/harmony-one/harmony/consensus/hotstuff" + hmybls "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" + "github.com/harmony-one/harmony/numeric" + "github.com/harmony-one/harmony/shard" + "github.com/stretchr/testify/require" +) + +func TestStakingQCAuthorityUsesExactHarmonyQuorumForQCFormationAndVerification(t *testing.T) { + useHarmonyQuorumSchedule(t, numeric.MustNewDecFromStr("0.4")) + keys, secrets := authorityTestKeys(t, 4) + stakeOne := numeric.NewDec(1) + stakeTwo := numeric.NewDec(2) + stakeThree := numeric.NewDec(3) + source := &shard.Committee{ShardID: 2, Slots: shard.SlotList{ + {EcdsaAddress: common.HexToAddress("0x01"), BLSPublicKey: keys[0]}, + {EcdsaAddress: common.HexToAddress("0x02"), BLSPublicKey: keys[1], EffectiveStake: &stakeOne}, + {EcdsaAddress: common.HexToAddress("0x03"), BLSPublicKey: keys[2], EffectiveStake: &stakeTwo}, + {EcdsaAddress: common.HexToAddress("0x04"), BLSPublicKey: keys[3], EffectiveStake: &stakeThree}, + }} + domain := hotstuff.VoteDomain{ChainID: 7, ShardID: 2, Epoch: 1, Genesis: "genesis"} + authority, err := NewStakingQCAuthority(source, big.NewInt(1), domain) + require.NoError(t, err) + ids := authorityTestMemberIDs(keys) + + lowStake := authority.NewVoteSet("low-stake", 1) + for _, index := range []int{1, 2, 3} { + addAuthorityTestVote(t, lowStake, domain, ids[index], "low-stake", 1, secrets[index]) + } + _, formed, err := lowStake.QC() + require.NoError(t, err) + require.False(t, formed, "three of four slots have only 0.6 EPoS power") + + highStake := authority.NewVoteSet("high-stake", 1) + for _, index := range []int{0, 3} { + addAuthorityTestVote(t, highStake, domain, ids[index], "high-stake", 1, secrets[index]) + } + qc, formed, err := highStake.QC() + require.NoError(t, err) + require.True(t, formed, "two of four slots have 0.7 EPoS power") + require.Equal(t, []hotstuff.MemberID{ids[0], ids[3]}, qc.QC.Signers) + require.Equal(t, []byte{0b00001001}, qc.Bitmap) + verifiedQC, err := authority.Verify(qc) + require.NoError(t, err) + _, _, err = authority.NewCore(hotstuff.Block{ID: "genesis", View: 0}) + require.NoError(t, err) + pacemaker, err := authority.NewPacemaker(1) + require.NoError(t, err) + require.NoError(t, authority.Advance(pacemaker, verifiedQC)) + require.Equal(t, hotstuff.View(2), pacemaker.CurrentView()) + + timeouts := hotstuff.NewBLSTimeoutSet(authority, 2) + for _, index := range []int{0, 3} { + addAuthorityTestTimeout( + t, timeouts, domain, ids[index], 2, qc.QC, + qc, secrets[index], + ) + } + timeoutCertificate, formed, err := timeouts.Certificate() + require.NoError(t, err) + require.True(t, formed) + verifiedTC, err := authority.VerifyTC(timeoutCertificate) + require.NoError(t, err) + require.NoError(t, authority.AdvanceTimeout(pacemaker, verifiedTC)) + require.Equal(t, hotstuff.View(3), pacemaker.CurrentView()) + require.Equal(t, qc.QC, pacemaker.HighQC()) + badBitmap := qc + badBitmap.Bitmap = append([]byte(nil), qc.Bitmap...) + badBitmap.Bitmap[0] |= 1 << 7 + _, err = authority.Verify(badBitmap) + require.ErrorIs(t, err, hotstuff.ErrInvalidQCBitmap) + + tampered := qc + tampered.QC.Signers = append([]hotstuff.MemberID(nil), ids[1], ids[2], ids[3]) + tampered.Bitmap = []byte{0b00001110} + _, err = authority.Verify(tampered) + require.ErrorIs(t, err, hotstuff.ErrInsufficientVotingPower) +} + +func TestStakingQCAuthorityAcceptsOneDecimalAtomAboveThreshold(t *testing.T) { + useHarmonyQuorumSchedule(t, numeric.ZeroDec()) + keys, secrets := authorityTestKeys(t, 2) + above := numeric.NewDecFromBigInt(big.NewInt(666666666666666668)) + below := numeric.NewDecFromBigInt(big.NewInt(333333333333333332)) + source := &shard.Committee{ShardID: 1, Slots: shard.SlotList{ + {EcdsaAddress: common.HexToAddress("0x11"), BLSPublicKey: keys[0], EffectiveStake: &above}, + {EcdsaAddress: common.HexToAddress("0x12"), BLSPublicKey: keys[1], EffectiveStake: &below}, + }} + domain := hotstuff.VoteDomain{ChainID: 7, ShardID: 1, Epoch: 1, Genesis: "genesis"} + authority, err := NewStakingQCAuthority(source, big.NewInt(1), domain) + require.NoError(t, err) + ids := authorityTestMemberIDs(keys) + + set := authority.NewVoteSet("one-atom", 1) + addAuthorityTestVote(t, set, domain, ids[0], "one-atom", 1, secrets[0]) + qc, formed, err := set.QC() + require.NoError(t, err) + require.True(t, formed) + require.Equal(t, []hotstuff.MemberID{ids[0]}, qc.QC.Signers) + _, err = authority.Verify(qc) + require.NoError(t, err) +} + +func TestStakingQCAuthorityRejectsRoundedThresholdAcceptedByIntegerQuorum(t *testing.T) { + useHarmonyQuorumSchedule(t, numeric.ZeroDec()) + keys, secrets := authorityTestKeys(t, 2) + threshold := numeric.NewDecFromBigInt(big.NewInt(666666666666666667)) + remainder := numeric.NewDecFromBigInt(big.NewInt(333333333333333333)) + source := &shard.Committee{ShardID: 1, Slots: shard.SlotList{ + {EcdsaAddress: common.HexToAddress("0x13"), BLSPublicKey: keys[0], EffectiveStake: &threshold}, + {EcdsaAddress: common.HexToAddress("0x14"), BLSPublicKey: keys[1], EffectiveStake: &remainder}, + }} + domain := hotstuff.VoteDomain{ChainID: 7, ShardID: 1, Epoch: 1, Genesis: "genesis"} + authority, err := NewStakingQCAuthority(source, big.NewInt(1), domain) + require.NoError(t, err) + ids := authorityTestMemberIDs(keys) + + exact := authority.NewVoteSet("threshold", 1) + addAuthorityTestVote(t, exact, domain, ids[0], "threshold", 1, secrets[0]) + _, formed, err := exact.QC() + require.NoError(t, err) + require.False(t, formed) + + integerCommittee, err := hotstuff.NewBLSCommitteeFromValidatedKeys([]hotstuff.BLSMember{ + { + Member: hotstuff.Member{ID: ids[0], Power: 666666666666666667}, + PublicKey: hmybls.PublicKeyWrapper{Bytes: keys[0]}, + }, + { + Member: hotstuff.Member{ID: ids[1], Power: 333333333333333333}, + PublicKey: hmybls.PublicKeyWrapper{Bytes: keys[1]}, + }, + }) + require.NoError(t, err) + integer := hotstuff.NewBLSVoteSet(integerCommittee, "threshold", 1, domain) + addAuthorityTestVote(t, integer, domain, ids[0], "threshold", 1, secrets[0]) + certificate, formed, err := integer.QC() + require.NoError(t, err) + require.True(t, formed, "integer quorum rounds the same atoms differently") + _, err = authority.Verify(certificate) + require.ErrorIs(t, err, hotstuff.ErrInsufficientVotingPower) +} + +func TestStakingQCAuthorityUsesOneAtomBoundaryForTCFormationAndVerification(t *testing.T) { + useHarmonyQuorumSchedule(t, numeric.ZeroDec()) + keys, secrets := authorityTestKeys(t, 2) + above := numeric.NewDecFromBigInt(big.NewInt(666666666666666668)) + aboveRemainder := numeric.NewDecFromBigInt(big.NewInt(333333333333333332)) + threshold := numeric.NewDecFromBigInt(big.NewInt(666666666666666667)) + thresholdRemainder := numeric.NewDecFromBigInt(big.NewInt(333333333333333333)) + domain := hotstuff.VoteDomain{ChainID: 7, ShardID: 1, Epoch: 1, Genesis: "genesis"} + aboveSource := &shard.Committee{ShardID: 1, Slots: shard.SlotList{ + {EcdsaAddress: common.HexToAddress("0x15"), BLSPublicKey: keys[0], EffectiveStake: &above}, + {EcdsaAddress: common.HexToAddress("0x16"), BLSPublicKey: keys[1], EffectiveStake: &aboveRemainder}, + }} + thresholdSource := &shard.Committee{ShardID: 1, Slots: shard.SlotList{ + {EcdsaAddress: common.HexToAddress("0x15"), BLSPublicKey: keys[0], EffectiveStake: &threshold}, + {EcdsaAddress: common.HexToAddress("0x16"), BLSPublicKey: keys[1], EffectiveStake: &thresholdRemainder}, + }} + aboveAuthority, err := NewStakingQCAuthority(aboveSource, big.NewInt(1), domain) + require.NoError(t, err) + thresholdAuthority, err := NewStakingQCAuthority(thresholdSource, big.NewInt(1), domain) + require.NoError(t, err) + _, aboveGenesis, err := aboveAuthority.NewCore(hotstuff.Block{ID: "genesis", View: 0}) + require.NoError(t, err) + _, thresholdGenesis, err := thresholdAuthority.NewCore(hotstuff.Block{ID: "genesis", View: 0}) + require.NoError(t, err) + id := HarmonyQuorumMemberID(keys[0]) + + aboveSet := hotstuff.NewBLSTimeoutSet(aboveAuthority, 1) + addAuthorityTestTimeout( + t, aboveSet, domain, id, 1, aboveGenesis.QC(), + hotstuff.BLSQC{QC: aboveGenesis.QC()}, secrets[0], + ) + certificate, formed, err := aboveSet.Certificate() + require.NoError(t, err) + require.True(t, formed) + _, err = aboveAuthority.VerifyTC(certificate) + require.NoError(t, err) + + thresholdSet := hotstuff.NewBLSTimeoutSet(thresholdAuthority, 1) + addAuthorityTestTimeout( + t, thresholdSet, domain, id, 1, thresholdGenesis.QC(), + hotstuff.BLSQC{QC: thresholdGenesis.QC()}, secrets[0], + ) + _, formed, err = thresholdSet.Certificate() + require.NoError(t, err) + require.False(t, formed) + _, err = thresholdAuthority.VerifyTC(certificate) + require.ErrorIs(t, err, hotstuff.ErrInsufficientVotingPower) +} + +func TestStakingQCAuthorityUsesExactHarmonyQuorumForTCFormationAndVerification(t *testing.T) { + useHarmonyQuorumSchedule(t, numeric.MustNewDecFromStr("0.4")) + keys, secrets := authorityTestKeys(t, 4) + stakeOne := numeric.NewDec(1) + stakeTwo := numeric.NewDec(2) + stakeThree := numeric.NewDec(3) + source := &shard.Committee{ShardID: 2, Slots: shard.SlotList{ + {EcdsaAddress: common.HexToAddress("0x21"), BLSPublicKey: keys[0]}, + {EcdsaAddress: common.HexToAddress("0x22"), BLSPublicKey: keys[1], EffectiveStake: &stakeOne}, + {EcdsaAddress: common.HexToAddress("0x23"), BLSPublicKey: keys[2], EffectiveStake: &stakeTwo}, + {EcdsaAddress: common.HexToAddress("0x24"), BLSPublicKey: keys[3], EffectiveStake: &stakeThree}, + }} + domain := hotstuff.VoteDomain{ChainID: 7, ShardID: 2, Epoch: 1, Genesis: "genesis"} + authority, err := NewStakingQCAuthority(source, big.NewInt(1), domain) + require.NoError(t, err) + _, genesis, err := authority.NewCore(hotstuff.Block{ID: "genesis", View: 0}) + require.NoError(t, err) + ids := authorityTestMemberIDs(keys) + evidence := hotstuff.BLSQC{QC: genesis.QC()} + + lowStake := hotstuff.NewBLSTimeoutSet(authority, 1) + for _, index := range []int{1, 2, 3} { + addAuthorityTestTimeout(t, lowStake, domain, ids[index], 1, genesis.QC(), evidence, secrets[index]) + } + _, formed, err := lowStake.Certificate() + require.NoError(t, err) + require.False(t, formed, "three of four slots have only 0.6 EPoS power") + + highStake := hotstuff.NewBLSTimeoutSet(authority, 1) + for _, index := range []int{0, 3} { + addAuthorityTestTimeout(t, highStake, domain, ids[index], 1, genesis.QC(), evidence, secrets[index]) + } + certificate, formed, err := highStake.Certificate() + require.NoError(t, err) + require.True(t, formed, "two of four slots have 0.7 EPoS power") + require.Equal(t, []hotstuff.MemberID{ids[0], ids[3]}, certificate.Signers) + require.Equal(t, []byte{0b00001001}, certificate.Bitmap) + verified, err := authority.VerifyTC(certificate) + require.NoError(t, err) + badBitmap := certificate + badBitmap.Bitmap = append([]byte(nil), certificate.Bitmap...) + badBitmap.Bitmap[0] |= 1 << 7 + _, err = authority.VerifyTC(badBitmap) + require.ErrorIs(t, err, hotstuff.ErrInvalidTCBitmap) + tampered := certificate + tampered.Signers = append([]hotstuff.MemberID(nil), ids[1], ids[2], ids[3]) + tampered.Bitmap = []byte{0b00001110} + _, err = authority.VerifyTC(tampered) + require.ErrorIs(t, err, hotstuff.ErrInsufficientVotingPower) + pacemaker, err := authority.NewPacemaker(1) + require.NoError(t, err) + require.NoError(t, authority.AdvanceTimeout(pacemaker, verified)) + require.Equal(t, hotstuff.View(2), pacemaker.CurrentView()) +} + +func TestStakingQCAuthorityKeepsValidatorLeaderScheduleSeparateFromBLSQuorumSlots(t *testing.T) { + useHarmonyQuorumSchedule(t, numeric.ZeroDec()) + keys, _ := authorityTestKeys(t, 3) + stake := numeric.NewDec(1) + validatorA := common.HexToAddress("0xa1") + validatorB := common.HexToAddress("0xb2") + source := &shard.Committee{ShardID: 3, Slots: shard.SlotList{ + {EcdsaAddress: validatorA, BLSPublicKey: keys[0], EffectiveStake: &stake}, + {EcdsaAddress: validatorA, BLSPublicKey: keys[1], EffectiveStake: &stake}, + {EcdsaAddress: validatorB, BLSPublicKey: keys[2], EffectiveStake: &stake}, + }} + domain := hotstuff.VoteDomain{ChainID: 7, ShardID: 3, Epoch: 1, Genesis: "genesis"} + authority, err := NewStakingQCAuthority(source, big.NewInt(1), domain) + require.NoError(t, err) + source.Slots[0].EcdsaAddress = validatorB + source.Slots[0].BLSPublicKey = hmybls.SerializedPublicKey{} + source.Slots[1].EcdsaAddress = validatorB + source.Slots[2].EcdsaAddress = validatorA + _, _, err = authority.NewCore(hotstuff.Block{ID: "genesis", View: 0}) + require.NoError(t, err) + + for _, test := range []struct { + view hotstuff.View + leader hotstuff.MemberID + }{ + {view: 1, leader: hotstuff.MemberID(validatorA.Hex())}, + {view: 2, leader: hotstuff.MemberID(validatorB.Hex())}, + {view: 3, leader: hotstuff.MemberID(validatorA.Hex())}, + {view: 4, leader: hotstuff.MemberID(validatorB.Hex())}, + } { + pacemaker, err := authority.NewPacemaker(test.view) + require.NoError(t, err) + require.Equal(t, test.leader, pacemaker.Leader()) + } +} + +func TestStakingQCAuthorityRejectsDomainMismatch(t *testing.T) { + useHarmonyQuorumSchedule(t, numeric.ZeroDec()) + keys, _ := authorityTestKeys(t, 1) + stake := numeric.NewDec(1) + source := &shard.Committee{ShardID: 2, Slots: shard.SlotList{{ + EcdsaAddress: common.HexToAddress("0x31"), BLSPublicKey: keys[0], EffectiveStake: &stake, + }}} + + _, err := NewStakingQCAuthority( + source, + big.NewInt(1), + hotstuff.VoteDomain{ChainID: 7, ShardID: 3, Epoch: 1, Genesis: "genesis"}, + ) + require.ErrorIs(t, err, ErrQuorumDomainMismatch) + _, err = NewStakingQCAuthority( + source, + big.NewInt(1), + hotstuff.VoteDomain{ChainID: 7, ShardID: 2, Epoch: 2, Genesis: "genesis"}, + ) + require.ErrorIs(t, err, ErrQuorumDomainMismatch) +} + +func TestStakingQCAuthorityRejectsMalformedStakeWithoutPanicking(t *testing.T) { + useHarmonyQuorumSchedule(t, numeric.ZeroDec()) + keys, _ := authorityTestKeys(t, 1) + malformed := numeric.Dec{} + source := &shard.Committee{ShardID: 2, Slots: shard.SlotList{{ + EcdsaAddress: common.HexToAddress("0x41"), BLSPublicKey: keys[0], EffectiveStake: &malformed, + }}} + + _, err := NewStakingQCAuthority( + source, + big.NewInt(1), + hotstuff.VoteDomain{ChainID: 7, ShardID: 2, Epoch: 1, Genesis: "genesis"}, + ) + require.ErrorIs(t, err, ErrInvalidQuorumVotingPower) +} + +func authorityTestKeys(t *testing.T, count int) ([]hmybls.SerializedPublicKey, []*bls_core.SecretKey) { + t.Helper() + keys := make([]hmybls.SerializedPublicKey, count) + secrets := make([]*bls_core.SecretKey, count) + for index := range keys { + secret := hmybls.RandPrivateKey() + wrapper := hmybls.WrapperFromPrivateKey(secret) + keys[index] = wrapper.Pub.Bytes + secrets[index] = secret + } + return keys, secrets +} + +func authorityTestMemberIDs(keys []hmybls.SerializedPublicKey) []hotstuff.MemberID { + ids := make([]hotstuff.MemberID, len(keys)) + for index, key := range keys { + ids[index] = HarmonyQuorumMemberID(key) + } + return ids +} + +func addAuthorityTestVote( + t *testing.T, + set *hotstuff.BLSVoteSet, + domain hotstuff.VoteDomain, + voter hotstuff.MemberID, + block hotstuff.BlockID, + view hotstuff.View, + secret *bls_core.SecretKey, +) { + t.Helper() + signed, err := hotstuff.SignVote(domain, hotstuff.Vote{Voter: voter, Block: block, View: view}, secret) + require.NoError(t, err) + require.NoError(t, set.Add(signed)) +} + +func addAuthorityTestTimeout( + t *testing.T, + set *hotstuff.BLSTimeoutSet, + domain hotstuff.VoteDomain, + voter hotstuff.MemberID, + view hotstuff.View, + highQC hotstuff.QC, + evidence hotstuff.BLSQC, + secret *bls_core.SecretKey, +) { + t.Helper() + signed, err := hotstuff.SignTimeout( + domain, + hotstuff.Timeout{Voter: voter, View: view, HighQC: highQC}, + secret, + ) + require.NoError(t, err) + require.NoError(t, set.Add(signed, evidence)) +} diff --git a/consensus/hotstuff/harmony/doc.go b/consensus/hotstuff/harmony/doc.go index a649814c6e..15dba40d2f 100644 --- a/consensus/hotstuff/harmony/doc.go +++ b/consensus/hotstuff/harmony/doc.go @@ -1,6 +1,7 @@ // Package harmony provides canonical Harmony adapters for isolated HotStuff // integration work. Leader identities remain validator-level while the -// staking-era reference quorum roster preserves BLS-slot identities, exact -// decimal power, and canonical bitmaps. The reference roster does not yet -// govern HotStuff QC or timeout-certificate formation or verification. +// staking-era quorum roster preserves BLS-slot identities, exact decimal +// power, and canonical bitmaps. NewStakingQCAuthority binds both adapters to +// QC and timeout-certificate formation and verification. Protocol activation, +// networking, and canonical block writing remain outside this package. package harmony diff --git a/consensus/hotstuff/harmony/quorum.go b/consensus/hotstuff/harmony/quorum.go index bf5cb14a7f..ca015d0a64 100644 --- a/consensus/hotstuff/harmony/quorum.go +++ b/consensus/hotstuff/harmony/quorum.go @@ -34,9 +34,8 @@ type HarmonyQuorumSlot struct { VotingPower numeric.Dec } -// HarmonyQuorum is an isolated staking-era reference adapter for Harmony's -// canonical BLS-slot roster and decimal EPoS voting power. It does not yet -// govern the HotStuff core's QC or timeout-certificate paths, and it must not +// HarmonyQuorum is an immutable staking-era certificate policy for Harmony's +// canonical BLS-slot roster and exact decimal EPoS voting power. It must not // reuse the validator-level leader schedule's uniform structural power. type HarmonyQuorum struct { slots []HarmonyQuorumSlot @@ -202,6 +201,16 @@ func (q *HarmonyQuorum) IsQuorum(bitmap []byte) (bool, error) { return power.GT(harmonyQuorumThreshold), nil } +// HasQuorum implements hotstuff.CertificateQuorum using canonical BLS-slot +// identities and exact Harmony decimal voting power. +func (q *HarmonyQuorum) HasQuorum(signers []hotstuff.MemberID) (bool, error) { + bitmap, err := q.Bitmap(signers) + if err != nil { + return false, err + } + return q.IsQuorum(bitmap) +} + func (q *HarmonyQuorum) bitmapLength() int { return (len(q.slots) + 7) >> 3 } diff --git a/consensus/hotstuff/harmony/safety_store_test.go b/consensus/hotstuff/harmony/safety_store_test.go index fb950f85be..d4e4f6d70b 100644 --- a/consensus/hotstuff/harmony/safety_store_test.go +++ b/consensus/hotstuff/harmony/safety_store_test.go @@ -743,7 +743,8 @@ func acceptAndCertify( require.NoError(t, err) votes := hotstuff.NewBLSVoteSet(committee, block.ID, block.View, domain) require.NoError(t, votes.Add(vote)) - certificate, formed := votes.QC() + certificate, formed, err := votes.QC() + require.NoError(t, err) require.True(t, formed) verified, err := authority.Verify(certificate) require.NoError(t, err) diff --git a/consensus/hotstuff/pacemaker.go b/consensus/hotstuff/pacemaker.go index d282a73a12..d56b18d958 100644 --- a/consensus/hotstuff/pacemaker.go +++ b/consensus/hotstuff/pacemaker.go @@ -39,7 +39,6 @@ type TimeoutSet struct { committee *Committee view View voters map[MemberID]struct{} - power uint64 highQC QC } @@ -55,7 +54,7 @@ func (s *TimeoutSet) Add(timeout Timeout) error { s.mu.Lock() defer s.mu.Unlock() - member, exists := s.committee.byID[timeout.Voter] + _, exists := s.committee.byID[timeout.Voter] if !exists { return ErrUnknownVoter } @@ -73,7 +72,6 @@ func (s *TimeoutSet) Add(timeout Timeout) error { } s.voters[timeout.Voter] = struct{}{} - s.power += member.Power canonicalHighQC := s.committee.canonicalQC(timeout.HighQC) if higherQC(canonicalHighQC, s.highQC) { s.highQC = canonicalHighQC @@ -81,24 +79,27 @@ func (s *TimeoutSet) Add(timeout Timeout) error { return nil } -func (s *TimeoutSet) Certificate() (TimeoutCertificate, bool) { +func (s *TimeoutSet) Certificate() (TimeoutCertificate, bool, error) { s.mu.Lock() defer s.mu.Unlock() - if s.power < s.committee.quorumPower() { - return TimeoutCertificate{}, false - } - signers := make([]MemberID, 0, len(s.voters)) for _, member := range s.committee.members { if _, exists := s.voters[member.ID]; exists { signers = append(signers, member.ID) } } + hasQuorum, err := s.committee.hasQuorum(signers) + if err != nil { + return TimeoutCertificate{}, false, err + } + if !hasQuorum { + return TimeoutCertificate{}, false, nil + } return TimeoutCertificate{ View: s.view, HighQC: cloneQC(s.highQC), Signers: signers, - }, true + }, true, nil } // Pacemaker advances views after either a successful QC or a timeout @@ -106,13 +107,22 @@ func (s *TimeoutSet) Certificate() (TimeoutCertificate, bool) { type Pacemaker struct { mu sync.Mutex committee *Committee + leaders LeaderSchedule view View highQC QC authority *QCAuthority } func newPacemaker(committee *Committee, initial View) *Pacemaker { - return &Pacemaker{committee: committee, view: initial} + return newPacemakerWithLeaderSchedule(committee, committee, initial) +} + +func newPacemakerWithLeaderSchedule( + committee *Committee, + leaders LeaderSchedule, + initial View, +) *Pacemaker { + return &Pacemaker{committee: committee, leaders: leaders, view: initial} } func (p *Pacemaker) CurrentView() View { @@ -124,7 +134,7 @@ func (p *Pacemaker) CurrentView() View { func (p *Pacemaker) Leader() MemberID { p.mu.Lock() defer p.mu.Unlock() - return p.committee.Leader(p.view) + return p.leaders.Leader(p.view) } func (p *Pacemaker) HighQC() QC { @@ -191,23 +201,38 @@ func (p *Pacemaker) advanceAfter(certifiedView View) error { } func (c *Committee) requireQuorum(signers []MemberID) error { + hasQuorum, err := c.hasQuorum(signers) + if err != nil { + return err + } + if !hasQuorum { + return ErrInsufficientVotingPower + } + return nil +} + +func (c *Committee) hasQuorum(signers []MemberID) (bool, error) { seen := make(map[MemberID]struct{}, len(signers)) var power uint64 for _, signer := range signers { if _, exists := seen[signer]; exists { - return ErrDuplicateSigner + return false, ErrDuplicateSigner } member, exists := c.byID[signer] if !exists { - return ErrUnknownVoter + return false, ErrUnknownVoter } seen[signer] = struct{}{} power += member.Power } - if power < c.quorumPower() { - return ErrInsufficientVotingPower + if c.certificateQuorum != nil { + hasQuorum, err := c.certificateQuorum.HasQuorum(append([]MemberID(nil), signers...)) + if err != nil { + return false, err + } + return hasQuorum, nil } - return nil + return power >= c.quorumPower(), nil } func (c *Committee) requireQC(qc QC) error { diff --git a/consensus/hotstuff/pacemaker_test.go b/consensus/hotstuff/pacemaker_test.go index e6f333bccf..7e62279154 100644 --- a/consensus/hotstuff/pacemaker_test.go +++ b/consensus/hotstuff/pacemaker_test.go @@ -25,7 +25,8 @@ func TestBroadcastTimeoutsProduceEquivalentCertificatesAtEveryReplica(t *testing for _, voter := range quorum { require.NoError(t, set.Add(timeouts[voter])) } - certificate, ok := set.Certificate() + certificate, ok, err := set.Certificate() + require.NoError(t, err) require.True(t, ok) require.NoError(t, committee.requireQuorum(certificate.Signers)) certificates = append(certificates, certificate) diff --git a/consensus/hotstuff/quorum_policy_test.go b/consensus/hotstuff/quorum_policy_test.go new file mode 100644 index 0000000000..95309ecd63 --- /dev/null +++ b/consensus/hotstuff/quorum_policy_test.go @@ -0,0 +1,204 @@ +package hotstuff + +import ( + "errors" + "testing" + + hmybls "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" + "github.com/stretchr/testify/require" +) + +type testCertificateQuorum struct { + hasQuorum bool + err error + mutate bool + setupSize int +} + +func (q *testCertificateQuorum) HasQuorum(signers []MemberID) (bool, error) { + if q.setupSize > 0 && len(signers) == q.setupSize { + return true, nil + } + if q.mutate && len(signers) > 0 { + signers[0] = "mutated" + } + return q.hasQuorum, q.err +} + +type testLeaderSchedule struct { + leader MemberID +} + +func (s *testLeaderSchedule) Leader(View) MemberID { + return s.leader +} + +func TestBLSCommitteeRejectsNilCertificateQuorum(t *testing.T) { + members := testValidatedBLSMembers(t, Member{ID: "alice", Power: 1}) + + _, err := NewBLSCommitteeFromValidatedKeysWithQuorum(members, nil) + require.ErrorIs(t, err, ErrNilCertificateQuorum) + var typedNil *testCertificateQuorum + _, err = NewBLSCommitteeFromValidatedKeysWithQuorum(members, typedNil) + require.ErrorIs(t, err, ErrNilCertificateQuorum) +} + +func TestBLSCommitteeRejectsCertificateQuorumRosterMismatch(t *testing.T) { + members := testValidatedBLSMembers(t, + Member{ID: "alice", Power: 1}, + Member{ID: "bob", Power: 1}, + ) + _, err := NewBLSCommitteeFromValidatedKeysWithQuorum( + members, &testCertificateQuorum{hasQuorum: false}, + ) + require.ErrorIs(t, err, ErrCertificateQuorumRosterMismatch) + + policyErr := errors.New("test roster mismatch") + _, err = NewBLSCommitteeFromValidatedKeysWithQuorum( + members, &testCertificateQuorum{err: policyErr}, + ) + require.ErrorIs(t, err, policyErr) +} + +func TestQCAuthorityRejectsNilLeaderSchedule(t *testing.T) { + committee, _ := testBLSCommittee(t, Member{ID: "alice", Power: 1}) + domain := VoteDomain{Genesis: "genesis"} + + _, err := NewQCAuthorityWithLeaderSchedule(nil, domain, committee.committee) + require.ErrorIs(t, err, ErrNilBLSCommittee) + _, err = NewQCAuthorityWithLeaderSchedule(committee, domain, nil) + require.ErrorIs(t, err, ErrNilLeaderSchedule) + var typedNil *testLeaderSchedule + _, err = NewQCAuthorityWithLeaderSchedule(committee, domain, typedNil) + require.ErrorIs(t, err, ErrNilLeaderSchedule) +} + +func TestCertificateQuorumPolicyErrorsFailClosedAndPropagate(t *testing.T) { + policyErr := errors.New("test certificate quorum failure") + policy := &testCertificateQuorum{err: policyErr, setupSize: 3} + members, secrets := testValidatedBLSMembersAndSecrets(t, + Member{ID: "alice", Power: 1}, + Member{ID: "bob", Power: 1}, + Member{ID: "carol", Power: 1}, + ) + committee, err := NewBLSCommitteeFromValidatedKeysWithQuorum(members, policy) + require.NoError(t, err) + + votes := NewVoteSet(committee.committee, "b1", 1) + require.NoError(t, votes.Add(Vote{Voter: "alice", Block: "b1", View: 1})) + _, formed, err := votes.QC() + require.ErrorIs(t, err, policyErr) + require.False(t, formed) + + timeouts := NewTimeoutSet(committee.committee, 1) + require.NoError(t, timeouts.Add(Timeout{ + Voter: "alice", View: 1, HighQC: QC{Block: "genesis", View: 0}, + })) + _, formed, err = timeouts.Certificate() + require.ErrorIs(t, err, policyErr) + require.False(t, formed) + + require.ErrorIs(t, committee.committee.requireQC(QC{ + Block: "b1", View: 1, Signers: []MemberID{"alice"}, + }), policyErr) + pacemaker := newPacemaker(committee.committee, 1) + require.ErrorIs(t, pacemaker.advanceQC(QC{ + Block: "b1", View: 1, Signers: []MemberID{"alice"}, + }), policyErr) + + domain := VoteDomain{Genesis: "genesis"} + blsVotes := NewBLSVoteSet(committee, "b1", 1, domain) + signedVote, err := SignVote( + domain, Vote{Voter: "alice", Block: "b1", View: 1}, secrets["alice"], + ) + require.NoError(t, err) + require.NoError(t, blsVotes.Add(signedVote)) + _, formed, err = blsVotes.QC() + require.ErrorIs(t, err, policyErr) + require.False(t, formed) + + authority := NewQCAuthority(committee, domain) + _, genesis, err := authority.NewCore(Block{ID: "genesis", View: 0}) + require.NoError(t, err) + blsTimeouts := NewBLSTimeoutSet(authority, 1) + signedTimeout, err := SignTimeout( + domain, + Timeout{Voter: "alice", View: 1, HighQC: genesis.QC()}, + secrets["alice"], + ) + require.NoError(t, err) + require.NoError(t, blsTimeouts.Add(signedTimeout, BLSQC{QC: genesis.QC()})) + _, formed, err = blsTimeouts.Certificate() + require.ErrorIs(t, err, policyErr) + require.False(t, formed) +} + +func TestCertificateQuorumCannotMutateCanonicalSignerOutput(t *testing.T) { + policy := &testCertificateQuorum{hasQuorum: true, mutate: true} + members := testValidatedBLSMembers(t, + Member{ID: "alice", Power: 1}, + Member{ID: "bob", Power: 1}, + ) + committee, err := NewBLSCommitteeFromValidatedKeysWithQuorum(members, policy) + require.NoError(t, err) + votes := NewVoteSet(committee.committee, "b1", 1) + require.NoError(t, votes.Add(Vote{Voter: "alice", Block: "b1", View: 1})) + + qc, formed, err := votes.QC() + require.NoError(t, err) + require.True(t, formed) + require.Equal(t, []MemberID{"alice"}, qc.Signers) + + input := []MemberID{"alice"} + require.NoError(t, committee.committee.requireQuorum(input)) + require.Equal(t, []MemberID{"alice"}, input) +} + +func TestQCAuthorityUsesSeparateLeaderScheduleWithoutChangingDefault(t *testing.T) { + committee, _ := testBLSCommittee(t, + Member{ID: "slot-a", Power: 1}, + Member{ID: "slot-b", Power: 1}, + ) + domain := VoteDomain{Genesis: "genesis"} + custom, err := NewQCAuthorityWithLeaderSchedule( + committee, domain, &testLeaderSchedule{leader: "validator"}, + ) + require.NoError(t, err) + _, _, err = custom.NewCore(Block{ID: "genesis", View: 0}) + require.NoError(t, err) + pacemaker, err := custom.NewPacemaker(1) + require.NoError(t, err) + require.Equal(t, MemberID("validator"), pacemaker.Leader()) + + legacy := NewQCAuthority(committee, domain) + _, _, err = legacy.NewCore(Block{ID: "genesis", View: 0}) + require.NoError(t, err) + pacemaker, err = legacy.NewPacemaker(1) + require.NoError(t, err) + require.Equal(t, MemberID("slot-a"), pacemaker.Leader()) +} + +func testValidatedBLSMembers(t *testing.T, members ...Member) []BLSMember { + t.Helper() + result, _ := testValidatedBLSMembersAndSecrets(t, members...) + return result +} + +func testValidatedBLSMembersAndSecrets( + t *testing.T, + members ...Member, +) ([]BLSMember, map[MemberID]*bls_core.SecretKey) { + t.Helper() + result := make([]BLSMember, len(members)) + secrets := make(map[MemberID]*bls_core.SecretKey, len(members)) + for index, member := range members { + secret := hmybls.RandPrivateKey() + secrets[member.ID] = secret + result[index] = BLSMember{ + Member: member, + PublicKey: *hmybls.WrapperFromPrivateKey(secret).Pub, + } + } + return result, secrets +} diff --git a/consensus/hotstuff/votes.go b/consensus/hotstuff/votes.go index 2bcb961139..adde188b33 100644 --- a/consensus/hotstuff/votes.go +++ b/consensus/hotstuff/votes.go @@ -20,7 +20,6 @@ type VoteSet struct { block BlockID view View voters map[MemberID]struct{} - power uint64 } func NewVoteSet(committee *Committee, block BlockID, view View) *VoteSet { @@ -35,7 +34,7 @@ func NewVoteSet(committee *Committee, block BlockID, view View) *VoteSet { func (s *VoteSet) Add(vote Vote) error { s.mu.Lock() defer s.mu.Unlock() - member, exists := s.committee.byID[vote.Voter] + _, exists := s.committee.byID[vote.Voter] if !exists { return ErrUnknownVoter } @@ -47,22 +46,24 @@ func (s *VoteSet) Add(vote Vote) error { } s.voters[vote.Voter] = struct{}{} - s.power += member.Power return nil } -func (s *VoteSet) QC() (QC, bool) { +func (s *VoteSet) QC() (QC, bool, error) { s.mu.Lock() defer s.mu.Unlock() - if s.power < s.committee.quorumPower() { - return QC{}, false - } - signers := make([]MemberID, 0, len(s.voters)) for _, member := range s.committee.members { if _, voted := s.voters[member.ID]; voted { signers = append(signers, member.ID) } } - return QC{Block: s.block, View: s.view, Signers: signers}, true + hasQuorum, err := s.committee.hasQuorum(signers) + if err != nil { + return QC{}, false, err + } + if !hasQuorum { + return QC{}, false, nil + } + return QC{Block: s.block, View: s.view, Signers: signers}, true, nil } From 168b5a7449d9b42c85f0badb0c97a871830dd368 Mon Sep 17 00:00:00 2001 From: frozen <355847+Frozen@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:51:26 -0400 Subject: [PATCH 12/13] consensus: add shadow HotStuff wire ingress --- api/proto/common.go | 10 + api/proto/hotstuff/gen.sh | 3 + api/proto/hotstuff/hotstuff.pb.go | 566 ++++++++++++++++++++++++ api/proto/hotstuff/hotstuff.proto | 39 ++ api/proto/hotstuff/message.go | 3 + consensus/hotstuff/harmony/wire.go | 319 +++++++++++++ consensus/hotstuff/harmony/wire_test.go | 269 +++++++++++ node/harmony/hotstuff_shadow.go | 86 ++++ node/harmony/hotstuff_shadow_test.go | 128 ++++++ node/harmony/node.go | 28 +- 10 files changed, 1449 insertions(+), 2 deletions(-) create mode 100755 api/proto/hotstuff/gen.sh create mode 100644 api/proto/hotstuff/hotstuff.pb.go create mode 100644 api/proto/hotstuff/hotstuff.proto create mode 100644 api/proto/hotstuff/message.go create mode 100644 consensus/hotstuff/harmony/wire.go create mode 100644 consensus/hotstuff/harmony/wire_test.go create mode 100644 node/harmony/hotstuff_shadow.go create mode 100644 node/harmony/hotstuff_shadow_test.go diff --git a/api/proto/common.go b/api/proto/common.go index 5c5a67a8a3..fd646d0c66 100644 --- a/api/proto/common.go +++ b/api/proto/common.go @@ -31,6 +31,7 @@ const ( Node Client // deprecated DRand // not used + HotStuff ) const ( @@ -80,3 +81,12 @@ func ConstructConsensusMessage(payload []byte) []byte { byteBuffer.Write(payload) return byteBuffer.Bytes() } + +// ConstructHotStuffMessage creates a message in the dedicated HotStuff wire +// category. Its payload is a versioned HotStuff envelope, not a legacy FBFT +// ConsensusRequest. +func ConstructHotStuffMessage(payload []byte) []byte { + byteBuffer := bytes.NewBuffer([]byte{byte(HotStuff)}) + byteBuffer.Write(payload) + return byteBuffer.Bytes() +} diff --git a/api/proto/hotstuff/gen.sh b/api/proto/hotstuff/gen.sh new file mode 100755 index 0000000000..578caee713 --- /dev/null +++ b/api/proto/hotstuff/gen.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +docker run -v ${PWD}:/tmp ${PROTOC_IMAGE} /tmp/hotstuff.proto diff --git a/api/proto/hotstuff/hotstuff.pb.go b/api/proto/hotstuff/hotstuff.pb.go new file mode 100644 index 0000000000..000a88d5e0 --- /dev/null +++ b/api/proto/hotstuff/hotstuff.pb.go @@ -0,0 +1,566 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.30.0 +// protoc v3.12.4 +// source: hotstuff.proto + +package hotstuffwire + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Domain struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ChainId uint32 `protobuf:"varint,1,opt,name=chain_id,json=chainId,proto3" json:"chain_id,omitempty"` + ShardId uint32 `protobuf:"varint,2,opt,name=shard_id,json=shardId,proto3" json:"shard_id,omitempty"` + Epoch uint64 `protobuf:"varint,3,opt,name=epoch,proto3" json:"epoch,omitempty"` + Genesis []byte `protobuf:"bytes,4,opt,name=genesis,proto3" json:"genesis,omitempty"` +} + +func (x *Domain) Reset() { + *x = Domain{} + if protoimpl.UnsafeEnabled { + mi := &file_hotstuff_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Domain) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Domain) ProtoMessage() {} + +func (x *Domain) ProtoReflect() protoreflect.Message { + mi := &file_hotstuff_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Domain.ProtoReflect.Descriptor instead. +func (*Domain) Descriptor() ([]byte, []int) { + return file_hotstuff_proto_rawDescGZIP(), []int{0} +} + +func (x *Domain) GetChainId() uint32 { + if x != nil { + return x.ChainId + } + return 0 +} + +func (x *Domain) GetShardId() uint32 { + if x != nil { + return x.ShardId + } + return 0 +} + +func (x *Domain) GetEpoch() uint64 { + if x != nil { + return x.Epoch + } + return 0 +} + +func (x *Domain) GetGenesis() []byte { + if x != nil { + return x.Genesis + } + return nil +} + +type Vote struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Voter []byte `protobuf:"bytes,1,opt,name=voter,proto3" json:"voter,omitempty"` + Block []byte `protobuf:"bytes,2,opt,name=block,proto3" json:"block,omitempty"` + View uint64 `protobuf:"varint,3,opt,name=view,proto3" json:"view,omitempty"` + Signature []byte `protobuf:"bytes,4,opt,name=signature,proto3" json:"signature,omitempty"` +} + +func (x *Vote) Reset() { + *x = Vote{} + if protoimpl.UnsafeEnabled { + mi := &file_hotstuff_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Vote) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Vote) ProtoMessage() {} + +func (x *Vote) ProtoReflect() protoreflect.Message { + mi := &file_hotstuff_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Vote.ProtoReflect.Descriptor instead. +func (*Vote) Descriptor() ([]byte, []int) { + return file_hotstuff_proto_rawDescGZIP(), []int{1} +} + +func (x *Vote) GetVoter() []byte { + if x != nil { + return x.Voter + } + return nil +} + +func (x *Vote) GetBlock() []byte { + if x != nil { + return x.Block + } + return nil +} + +func (x *Vote) GetView() uint64 { + if x != nil { + return x.View + } + return 0 +} + +func (x *Vote) GetSignature() []byte { + if x != nil { + return x.Signature + } + return nil +} + +type QuorumCertificate struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Block []byte `protobuf:"bytes,1,opt,name=block,proto3" json:"block,omitempty"` + View uint64 `protobuf:"varint,2,opt,name=view,proto3" json:"view,omitempty"` + Signers []byte `protobuf:"bytes,3,opt,name=signers,proto3" json:"signers,omitempty"` + Signature []byte `protobuf:"bytes,4,opt,name=signature,proto3" json:"signature,omitempty"` + Bitmap []byte `protobuf:"bytes,5,opt,name=bitmap,proto3" json:"bitmap,omitempty"` +} + +func (x *QuorumCertificate) Reset() { + *x = QuorumCertificate{} + if protoimpl.UnsafeEnabled { + mi := &file_hotstuff_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *QuorumCertificate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QuorumCertificate) ProtoMessage() {} + +func (x *QuorumCertificate) ProtoReflect() protoreflect.Message { + mi := &file_hotstuff_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QuorumCertificate.ProtoReflect.Descriptor instead. +func (*QuorumCertificate) Descriptor() ([]byte, []int) { + return file_hotstuff_proto_rawDescGZIP(), []int{2} +} + +func (x *QuorumCertificate) GetBlock() []byte { + if x != nil { + return x.Block + } + return nil +} + +func (x *QuorumCertificate) GetView() uint64 { + if x != nil { + return x.View + } + return 0 +} + +func (x *QuorumCertificate) GetSigners() []byte { + if x != nil { + return x.Signers + } + return nil +} + +func (x *QuorumCertificate) GetSignature() []byte { + if x != nil { + return x.Signature + } + return nil +} + +func (x *QuorumCertificate) GetBitmap() []byte { + if x != nil { + return x.Bitmap + } + return nil +} + +type Proposal struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Block []byte `protobuf:"bytes,1,opt,name=block,proto3" json:"block,omitempty"` + Justify *QuorumCertificate `protobuf:"bytes,2,opt,name=justify,proto3" json:"justify,omitempty"` +} + +func (x *Proposal) Reset() { + *x = Proposal{} + if protoimpl.UnsafeEnabled { + mi := &file_hotstuff_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Proposal) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Proposal) ProtoMessage() {} + +func (x *Proposal) ProtoReflect() protoreflect.Message { + mi := &file_hotstuff_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Proposal.ProtoReflect.Descriptor instead. +func (*Proposal) Descriptor() ([]byte, []int) { + return file_hotstuff_proto_rawDescGZIP(), []int{3} +} + +func (x *Proposal) GetBlock() []byte { + if x != nil { + return x.Block + } + return nil +} + +func (x *Proposal) GetJustify() *QuorumCertificate { + if x != nil { + return x.Justify + } + return nil +} + +type Envelope struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Version uint32 `protobuf:"varint,1,opt,name=version,proto3" json:"version,omitempty"` + Domain *Domain `protobuf:"bytes,2,opt,name=domain,proto3" json:"domain,omitempty"` + // Types that are assignable to Message: + // + // *Envelope_Vote + // *Envelope_Proposal + Message isEnvelope_Message `protobuf_oneof:"message"` +} + +func (x *Envelope) Reset() { + *x = Envelope{} + if protoimpl.UnsafeEnabled { + mi := &file_hotstuff_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Envelope) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Envelope) ProtoMessage() {} + +func (x *Envelope) ProtoReflect() protoreflect.Message { + mi := &file_hotstuff_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Envelope.ProtoReflect.Descriptor instead. +func (*Envelope) Descriptor() ([]byte, []int) { + return file_hotstuff_proto_rawDescGZIP(), []int{4} +} + +func (x *Envelope) GetVersion() uint32 { + if x != nil { + return x.Version + } + return 0 +} + +func (x *Envelope) GetDomain() *Domain { + if x != nil { + return x.Domain + } + return nil +} + +func (m *Envelope) GetMessage() isEnvelope_Message { + if m != nil { + return m.Message + } + return nil +} + +func (x *Envelope) GetVote() *Vote { + if x, ok := x.GetMessage().(*Envelope_Vote); ok { + return x.Vote + } + return nil +} + +func (x *Envelope) GetProposal() *Proposal { + if x, ok := x.GetMessage().(*Envelope_Proposal); ok { + return x.Proposal + } + return nil +} + +type isEnvelope_Message interface { + isEnvelope_Message() +} + +type Envelope_Vote struct { + Vote *Vote `protobuf:"bytes,3,opt,name=vote,proto3,oneof"` +} + +type Envelope_Proposal struct { + Proposal *Proposal `protobuf:"bytes,4,opt,name=proposal,proto3,oneof"` +} + +func (*Envelope_Vote) isEnvelope_Message() {} + +func (*Envelope_Proposal) isEnvelope_Message() {} + +var File_hotstuff_proto protoreflect.FileDescriptor + +var file_hotstuff_proto_rawDesc = []byte{ + 0x0a, 0x0e, 0x68, 0x6f, 0x74, 0x73, 0x74, 0x75, 0x66, 0x66, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x12, 0x0c, 0x68, 0x6f, 0x74, 0x73, 0x74, 0x75, 0x66, 0x66, 0x77, 0x69, 0x72, 0x65, 0x22, 0x6e, + 0x0a, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x19, 0x0a, 0x08, 0x63, 0x68, 0x61, 0x69, + 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x07, 0x63, 0x68, 0x61, 0x69, + 0x6e, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x73, 0x68, 0x61, 0x72, 0x64, 0x5f, 0x69, 0x64, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x07, 0x73, 0x68, 0x61, 0x72, 0x64, 0x49, 0x64, 0x12, 0x14, + 0x0a, 0x05, 0x65, 0x70, 0x6f, 0x63, 0x68, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x65, + 0x70, 0x6f, 0x63, 0x68, 0x12, 0x18, 0x0a, 0x07, 0x67, 0x65, 0x6e, 0x65, 0x73, 0x69, 0x73, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x67, 0x65, 0x6e, 0x65, 0x73, 0x69, 0x73, 0x22, 0x64, + 0x0a, 0x04, 0x56, 0x6f, 0x74, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x6f, 0x74, 0x65, 0x72, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x6f, 0x74, 0x65, 0x72, 0x12, 0x14, 0x0a, 0x05, + 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x62, 0x6c, 0x6f, + 0x63, 0x6b, 0x12, 0x12, 0x0a, 0x04, 0x76, 0x69, 0x65, 0x77, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, + 0x52, 0x04, 0x76, 0x69, 0x65, 0x77, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, + 0x75, 0x72, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, + 0x74, 0x75, 0x72, 0x65, 0x22, 0x8d, 0x01, 0x0a, 0x11, 0x51, 0x75, 0x6f, 0x72, 0x75, 0x6d, 0x43, + 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x62, 0x6c, + 0x6f, 0x63, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x62, 0x6c, 0x6f, 0x63, 0x6b, + 0x12, 0x12, 0x0a, 0x04, 0x76, 0x69, 0x65, 0x77, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x04, + 0x76, 0x69, 0x65, 0x77, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x72, 0x73, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x72, 0x73, 0x12, 0x1c, + 0x0a, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, 0x16, 0x0a, 0x06, + 0x62, 0x69, 0x74, 0x6d, 0x61, 0x70, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x62, 0x69, + 0x74, 0x6d, 0x61, 0x70, 0x22, 0x5b, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x61, 0x6c, + 0x12, 0x14, 0x0a, 0x05, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, + 0x05, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x12, 0x39, 0x0a, 0x07, 0x6a, 0x75, 0x73, 0x74, 0x69, 0x66, + 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x68, 0x6f, 0x74, 0x73, 0x74, 0x75, + 0x66, 0x66, 0x77, 0x69, 0x72, 0x65, 0x2e, 0x51, 0x75, 0x6f, 0x72, 0x75, 0x6d, 0x43, 0x65, 0x72, + 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x07, 0x6a, 0x75, 0x73, 0x74, 0x69, 0x66, + 0x79, 0x22, 0xbd, 0x01, 0x0a, 0x08, 0x45, 0x6e, 0x76, 0x65, 0x6c, 0x6f, 0x70, 0x65, 0x12, 0x18, + 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, + 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2c, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x68, 0x6f, 0x74, 0x73, 0x74, + 0x75, 0x66, 0x66, 0x77, 0x69, 0x72, 0x65, 0x2e, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x52, 0x06, + 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x28, 0x0a, 0x04, 0x76, 0x6f, 0x74, 0x65, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x68, 0x6f, 0x74, 0x73, 0x74, 0x75, 0x66, 0x66, 0x77, + 0x69, 0x72, 0x65, 0x2e, 0x56, 0x6f, 0x74, 0x65, 0x48, 0x00, 0x52, 0x04, 0x76, 0x6f, 0x74, 0x65, + 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x61, 0x6c, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x68, 0x6f, 0x74, 0x73, 0x74, 0x75, 0x66, 0x66, 0x77, 0x69, 0x72, + 0x65, 0x2e, 0x50, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x61, 0x6c, 0x48, 0x00, 0x52, 0x08, 0x70, 0x72, + 0x6f, 0x70, 0x6f, 0x73, 0x61, 0x6c, 0x42, 0x09, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x42, 0x11, 0x5a, 0x0f, 0x2e, 0x2f, 0x3b, 0x68, 0x6f, 0x74, 0x73, 0x74, 0x75, 0x66, 0x66, + 0x77, 0x69, 0x72, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_hotstuff_proto_rawDescOnce sync.Once + file_hotstuff_proto_rawDescData = file_hotstuff_proto_rawDesc +) + +func file_hotstuff_proto_rawDescGZIP() []byte { + file_hotstuff_proto_rawDescOnce.Do(func() { + file_hotstuff_proto_rawDescData = protoimpl.X.CompressGZIP(file_hotstuff_proto_rawDescData) + }) + return file_hotstuff_proto_rawDescData +} + +var file_hotstuff_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_hotstuff_proto_goTypes = []interface{}{ + (*Domain)(nil), // 0: hotstuffwire.Domain + (*Vote)(nil), // 1: hotstuffwire.Vote + (*QuorumCertificate)(nil), // 2: hotstuffwire.QuorumCertificate + (*Proposal)(nil), // 3: hotstuffwire.Proposal + (*Envelope)(nil), // 4: hotstuffwire.Envelope +} +var file_hotstuff_proto_depIdxs = []int32{ + 2, // 0: hotstuffwire.Proposal.justify:type_name -> hotstuffwire.QuorumCertificate + 0, // 1: hotstuffwire.Envelope.domain:type_name -> hotstuffwire.Domain + 1, // 2: hotstuffwire.Envelope.vote:type_name -> hotstuffwire.Vote + 3, // 3: hotstuffwire.Envelope.proposal:type_name -> hotstuffwire.Proposal + 4, // [4:4] is the sub-list for method output_type + 4, // [4:4] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name +} + +func init() { file_hotstuff_proto_init() } +func file_hotstuff_proto_init() { + if File_hotstuff_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_hotstuff_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Domain); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_hotstuff_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Vote); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_hotstuff_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*QuorumCertificate); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_hotstuff_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Proposal); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_hotstuff_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Envelope); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + file_hotstuff_proto_msgTypes[4].OneofWrappers = []interface{}{ + (*Envelope_Vote)(nil), + (*Envelope_Proposal)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_hotstuff_proto_rawDesc, + NumEnums: 0, + NumMessages: 5, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_hotstuff_proto_goTypes, + DependencyIndexes: file_hotstuff_proto_depIdxs, + MessageInfos: file_hotstuff_proto_msgTypes, + }.Build() + File_hotstuff_proto = out.File + file_hotstuff_proto_rawDesc = nil + file_hotstuff_proto_goTypes = nil + file_hotstuff_proto_depIdxs = nil +} diff --git a/api/proto/hotstuff/hotstuff.proto b/api/proto/hotstuff/hotstuff.proto new file mode 100644 index 0000000000..0fd1022277 --- /dev/null +++ b/api/proto/hotstuff/hotstuff.proto @@ -0,0 +1,39 @@ +syntax = "proto3"; +package hotstuffwire; +option go_package = "./;hotstuffwire"; + +message Domain { + uint32 chain_id = 1; + uint32 shard_id = 2; + uint64 epoch = 3; + bytes genesis = 4; +} + +message Vote { + bytes voter = 1; + bytes block = 2; + uint64 view = 3; + bytes signature = 4; +} + +message QuorumCertificate { + bytes block = 1; + uint64 view = 2; + bytes signers = 3; + bytes signature = 4; + bytes bitmap = 5; +} + +message Proposal { + bytes block = 1; + QuorumCertificate justify = 2; +} + +message Envelope { + uint32 version = 1; + Domain domain = 2; + oneof message { + Vote vote = 3; + Proposal proposal = 4; + } +} diff --git a/api/proto/hotstuff/message.go b/api/proto/hotstuff/message.go new file mode 100644 index 0000000000..eb9f06bed2 --- /dev/null +++ b/api/proto/hotstuff/message.go @@ -0,0 +1,3 @@ +package hotstuffwire + +//go:generate ./gen.sh diff --git a/consensus/hotstuff/harmony/wire.go b/consensus/hotstuff/harmony/wire.go new file mode 100644 index 0000000000..c9d1817547 --- /dev/null +++ b/consensus/hotstuff/harmony/wire.go @@ -0,0 +1,319 @@ +package harmony + +import ( + "errors" + + hmyproto "github.com/harmony-one/harmony/api/proto" + wirepb "github.com/harmony-one/harmony/api/proto/hotstuff" + "github.com/harmony-one/harmony/consensus/hotstuff" + hmybls "github.com/harmony-one/harmony/crypto/bls" + "google.golang.org/protobuf/proto" +) + +const ( + // WireVersion identifies the first dedicated Harmony HotStuff envelope. + WireVersion uint32 = 1 + // maxP2PFrameSize matches p2p.MaxMessageSize. ConstructMessage adds the + // five-byte outer frame before the dedicated HotStuff category. + maxP2PFrameSize = 1 << 21 + p2pOuterMessagePrefixSize = 5 + maxWireSigners = 4096 + wireMemberIDSize = len("bls:") + (hmybls.PublicKeySizeInBytes << 1) + maxWireBitmapSize = (maxWireSigners + 7) >> 3 + maxWireEnvelopeOverhead = maxWireSigners*wireMemberIDSize + maxWireBitmapSize + (8 << 10) + // MaxWireMessageSize bounds decode work before protobuf unmarshalling and + // keeps the completed outer frame within the host transport limit. + MaxWireMessageSize = maxP2PFrameSize - p2pOuterMessagePrefixSize + // MaxWireBlockSize reserves enough of that frame for the largest bounded QC. + MaxWireBlockSize = MaxWireMessageSize - maxWireEnvelopeOverhead +) + +var ( + ErrWireMessageTooLarge = errors.New("hotstuff wire message exceeds the size limit") + ErrWrongWireCategory = errors.New("hotstuff wire message has the wrong category") + ErrUnsupportedWireVersion = errors.New("hotstuff wire message version is unsupported") + ErrWireDomainMismatch = errors.New("hotstuff wire message belongs to a different domain") + ErrInvalidWireMessage = errors.New("hotstuff wire message is invalid") +) + +// WireMessage is an owned, decoded HotStuff transport envelope. +type WireMessage struct { + Version uint32 + Domain hotstuff.VoteDomain + Vote *hotstuff.SignedVote + Proposal *WireProposal +} + +// WireProposal contains an owned Harmony block encoding and the untrusted BLS +// QC evidence that must be verified by the domain authority before ingress. +type WireProposal struct { + Block []byte + Justify hotstuff.BLSQC +} + +// EncodeVoteMessage serializes one signed HotStuff vote in the dedicated +// versioned HotStuff category. It never uses the legacy FBFT ConsensusRequest. +func EncodeVoteMessage(domain hotstuff.VoteDomain, vote hotstuff.SignedVote) ([]byte, error) { + if err := validateWireBlockID(string(domain.Genesis)); err != nil { + return nil, err + } + if err := validateWireMemberID(string(vote.Vote.Voter)); err != nil { + return nil, err + } + if err := validateWireBlockID(string(vote.Vote.Block)); err != nil { + return nil, err + } + if len(vote.Signature) != hmybls.BLSSignatureSizeInBytes { + return nil, ErrInvalidWireMessage + } + envelope := &wirepb.Envelope{ + Version: WireVersion, + Domain: &wirepb.Domain{ + ChainId: domain.ChainID, + ShardId: domain.ShardID, + Epoch: domain.Epoch, + Genesis: []byte(domain.Genesis), + }, + Message: &wirepb.Envelope_Vote{ + Vote: &wirepb.Vote{ + Voter: []byte(vote.Vote.Voter), + Block: []byte(vote.Vote.Block), + View: uint64(vote.Vote.View), + Signature: append([]byte(nil), vote.Signature...), + }, + }, + } + payload, err := proto.Marshal(envelope) + if err != nil { + return nil, ErrInvalidWireMessage + } + message := hmyproto.ConstructHotStuffMessage(payload) + if len(message) > MaxWireMessageSize { + return nil, ErrWireMessageTooLarge + } + return message, nil +} + +// EncodeProposalMessage serializes one Harmony block and its parent QC +// evidence. The receiver must decode the block and call QCAuthority.Verify +// before mapping it into the HotStuff core. +func EncodeProposalMessage( + domain hotstuff.VoteDomain, + block []byte, + justify hotstuff.BLSQC, +) ([]byte, error) { + if err := validateWireBlockID(string(domain.Genesis)); err != nil { + return nil, err + } + if len(block) == 0 || len(block) > MaxWireBlockSize { + return nil, ErrInvalidWireMessage + } + wireQC, err := encodeWireQC(justify) + if err != nil { + return nil, err + } + envelope := &wirepb.Envelope{ + Version: WireVersion, + Domain: &wirepb.Domain{ + ChainId: domain.ChainID, + ShardId: domain.ShardID, + Epoch: domain.Epoch, + Genesis: []byte(domain.Genesis), + }, + Message: &wirepb.Envelope_Proposal{ + Proposal: &wirepb.Proposal{ + Block: append([]byte(nil), block...), + Justify: wireQC, + }, + }, + } + payload, err := proto.Marshal(envelope) + if err != nil { + return nil, ErrInvalidWireMessage + } + message := hmyproto.ConstructHotStuffMessage(payload) + if len(message) > MaxWireMessageSize { + return nil, ErrWireMessageTooLarge + } + return message, nil +} + +// DecodeWireMessage validates and owns one dedicated HotStuff transport +// envelope before it can reach certificate or state-machine processing. +func DecodeWireMessage(message []byte) (WireMessage, error) { + if len(message) > MaxWireMessageSize { + return WireMessage{}, ErrWireMessageTooLarge + } + category, err := hmyproto.GetMessageCategory(message) + if err != nil || category != hmyproto.HotStuff { + return WireMessage{}, ErrWrongWireCategory + } + if len(message) == hmyproto.MessageCategoryBytes { + return WireMessage{}, ErrInvalidWireMessage + } + envelope := &wirepb.Envelope{} + if err := proto.Unmarshal(message[hmyproto.MessageCategoryBytes:], envelope); err != nil { + return WireMessage{}, ErrInvalidWireMessage + } + if envelope.Version != WireVersion { + return WireMessage{}, ErrUnsupportedWireVersion + } + if envelope.Domain == nil { + return WireMessage{}, ErrInvalidWireMessage + } + if err := validateWireBlockBytes(envelope.Domain.Genesis); err != nil { + return WireMessage{}, err + } + result := WireMessage{ + Version: envelope.Version, + Domain: hotstuff.VoteDomain{ + ChainID: envelope.Domain.ChainId, + ShardID: envelope.Domain.ShardId, + Epoch: envelope.Domain.Epoch, + Genesis: hotstuff.BlockID(string(envelope.Domain.Genesis)), + }, + } + if wireVote := envelope.GetVote(); wireVote != nil { + vote, err := decodeWireVote(wireVote) + if err != nil { + return WireMessage{}, err + } + result.Vote = vote + return result, nil + } + if wireProposal := envelope.GetProposal(); wireProposal != nil { + proposal, err := decodeWireProposal(wireProposal) + if err != nil { + return WireMessage{}, err + } + result.Proposal = proposal + return result, nil + } + return WireMessage{}, ErrInvalidWireMessage +} + +// DecodeWireMessageForDomain rejects cross-chain, cross-shard, cross-epoch, +// and cross-genesis replay before returning a message to network ingress. +func DecodeWireMessageForDomain( + message []byte, + expected hotstuff.VoteDomain, +) (WireMessage, error) { + decoded, err := DecodeWireMessage(message) + if err != nil { + return WireMessage{}, err + } + if decoded.Domain != expected { + return WireMessage{}, ErrWireDomainMismatch + } + return decoded, nil +} + +func encodeWireQC(qc hotstuff.BLSQC) (*wirepb.QuorumCertificate, error) { + if err := validateWireBlockID(string(qc.QC.Block)); err != nil { + return nil, err + } + if len(qc.QC.Signers) == 0 || len(qc.QC.Signers) > maxWireSigners || + len(qc.Signature) != hmybls.BLSSignatureSizeInBytes || + len(qc.Bitmap) == 0 || len(qc.Bitmap) > maxWireBitmapSize { + return nil, ErrInvalidWireMessage + } + signers := make([]byte, 0, len(qc.QC.Signers)*wireMemberIDSize) + for _, signer := range qc.QC.Signers { + if err := validateWireMemberID(string(signer)); err != nil { + return nil, err + } + signers = append(signers, []byte(signer)...) + } + return &wirepb.QuorumCertificate{ + Block: []byte(qc.QC.Block), + View: uint64(qc.QC.View), + Signers: signers, + Signature: append([]byte(nil), qc.Signature...), + Bitmap: append([]byte(nil), qc.Bitmap...), + }, nil +} + +func decodeWireVote(wireVote *wirepb.Vote) (*hotstuff.SignedVote, error) { + if err := validateWireMemberBytes(wireVote.Voter); err != nil { + return nil, err + } + if err := validateWireBlockBytes(wireVote.Block); err != nil { + return nil, err + } + if len(wireVote.Signature) != hmybls.BLSSignatureSizeInBytes { + return nil, ErrInvalidWireMessage + } + return &hotstuff.SignedVote{ + Vote: hotstuff.Vote{ + Voter: hotstuff.MemberID(string(wireVote.Voter)), + Block: hotstuff.BlockID(string(wireVote.Block)), + View: hotstuff.View(wireVote.View), + }, + Signature: append([]byte(nil), wireVote.Signature...), + }, nil +} + +func decodeWireProposal(wireProposal *wirepb.Proposal) (*WireProposal, error) { + if len(wireProposal.Block) == 0 || len(wireProposal.Block) > MaxWireBlockSize || wireProposal.Justify == nil { + return nil, ErrInvalidWireMessage + } + wireQC := wireProposal.Justify + if err := validateWireBlockBytes(wireQC.Block); err != nil { + return nil, err + } + if len(wireQC.Signers) == 0 || len(wireQC.Signers)%wireMemberIDSize != 0 || + len(wireQC.Signers) > maxWireSigners*wireMemberIDSize || + len(wireQC.Signature) != hmybls.BLSSignatureSizeInBytes || + len(wireQC.Bitmap) == 0 || len(wireQC.Bitmap) > maxWireBitmapSize { + return nil, ErrInvalidWireMessage + } + signers := make([]hotstuff.MemberID, len(wireQC.Signers)/wireMemberIDSize) + for index := range signers { + start := index * wireMemberIDSize + signer := wireQC.Signers[start : start+wireMemberIDSize] + if err := validateWireMemberBytes(signer); err != nil { + return nil, err + } + signers[index] = hotstuff.MemberID(string(signer)) + } + return &WireProposal{ + Block: append([]byte(nil), wireProposal.Block...), + Justify: hotstuff.BLSQC{ + QC: hotstuff.QC{ + Block: hotstuff.BlockID(string(wireQC.Block)), + View: hotstuff.View(wireQC.View), + Signers: signers, + }, + Signature: append([]byte(nil), wireQC.Signature...), + Bitmap: append([]byte(nil), wireQC.Bitmap...), + }, + }, nil +} + +func validateWireBlockID(id string) error { + return validateWireHexID(id, "0x", 32) +} + +func validateWireMemberID(id string) error { + return validateWireHexID(id, "bls:", hmybls.PublicKeySizeInBytes) +} + +func validateWireBlockBytes(id []byte) error { + return validateWireBlockID(string(id)) +} + +func validateWireMemberBytes(id []byte) error { + return validateWireMemberID(string(id)) +} + +func validateWireHexID(id, prefix string, byteLength int) error { + if len(id) != len(prefix)+(byteLength<<1) || id[:len(prefix)] != prefix { + return ErrInvalidWireMessage + } + for _, character := range id[len(prefix):] { + if (character < '0' || character > '9') && (character < 'a' || character > 'f') { + return ErrInvalidWireMessage + } + } + return nil +} diff --git a/consensus/hotstuff/harmony/wire_test.go b/consensus/hotstuff/harmony/wire_test.go new file mode 100644 index 0000000000..0597ccd373 --- /dev/null +++ b/consensus/hotstuff/harmony/wire_test.go @@ -0,0 +1,269 @@ +package harmony + +import ( + "bytes" + "encoding/hex" + "testing" + + "github.com/harmony-one/harmony/api/proto" + wirepb "github.com/harmony-one/harmony/api/proto/hotstuff" + "github.com/harmony-one/harmony/consensus/hotstuff" + "github.com/harmony-one/harmony/p2p" + "github.com/stretchr/testify/require" + protobuf "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" +) + +func TestHotStuffWireVoteRoundTripUsesDedicatedCategory(t *testing.T) { + domain := hotstuff.VoteDomain{ + ChainID: 1666700000, + ShardID: 2, + Epoch: 42, + Genesis: testWireBlockID(0x01), + } + vote := hotstuff.SignedVote{ + Vote: hotstuff.Vote{ + Voter: testWireMemberID(0x02), + Block: testWireBlockID(0x03), + View: 17, + }, + Signature: bytes.Repeat([]byte{0x5a}, 96), + } + + encoded, err := EncodeVoteMessage(domain, vote) + require.NoError(t, err) + category, err := proto.GetMessageCategory(encoded) + require.NoError(t, err) + require.Equal(t, proto.HotStuff, category) + + decoded, err := DecodeWireMessage(encoded) + require.NoError(t, err) + require.Equal(t, WireVersion, decoded.Version) + require.Equal(t, domain, decoded.Domain) + require.NotNil(t, decoded.Vote) + require.Equal(t, vote, *decoded.Vote) + + encoded[len(encoded)-1] ^= 0xff + require.Equal(t, bytes.Repeat([]byte{0x5a}, 96), decoded.Vote.Signature) +} + +func TestHotStuffWireProposalRoundTripOwnsBlockAndQC(t *testing.T) { + domain := hotstuff.VoteDomain{ + ChainID: 1666700000, + ShardID: 2, + Epoch: 42, + Genesis: testWireBlockID(0x01), + } + block := []byte{0xf8, 0x01, 0x02, 0x03} + justify := hotstuff.BLSQC{ + QC: hotstuff.QC{ + Block: testWireBlockID(0x04), + View: 16, + Signers: []hotstuff.MemberID{ + testWireMemberID(0x05), + testWireMemberID(0x06), + }, + }, + Signature: bytes.Repeat([]byte{0x6b}, 96), + Bitmap: []byte{0x03}, + } + + encoded, err := EncodeProposalMessage(domain, block, justify) + require.NoError(t, err) + decoded, err := DecodeWireMessage(encoded) + require.NoError(t, err) + require.Nil(t, decoded.Vote) + require.NotNil(t, decoded.Proposal) + require.Equal(t, block, decoded.Proposal.Block) + require.Equal(t, justify, decoded.Proposal.Justify) + + for index := range encoded { + encoded[index] ^= 0xff + } + require.Equal(t, []byte{0xf8, 0x01, 0x02, 0x03}, decoded.Proposal.Block) + require.Equal(t, bytes.Repeat([]byte{0x6b}, 96), decoded.Proposal.Justify.Signature) + require.Equal(t, []byte{0x03}, decoded.Proposal.Justify.Bitmap) +} + +func TestHotStuffWireDecodeRejectsEveryDomainMismatch(t *testing.T) { + domain := hotstuff.VoteDomain{ + ChainID: 1666700000, + ShardID: 2, + Epoch: 42, + Genesis: testWireBlockID(0x01), + } + vote := hotstuff.SignedVote{ + Vote: hotstuff.Vote{ + Voter: testWireMemberID(0x02), + Block: testWireBlockID(0x03), + View: 17, + }, + Signature: bytes.Repeat([]byte{0x5a}, 96), + } + encoded, err := EncodeVoteMessage(domain, vote) + require.NoError(t, err) + + tests := map[string]hotstuff.VoteDomain{ + "chain": {ChainID: domain.ChainID + 1, ShardID: domain.ShardID, Epoch: domain.Epoch, Genesis: domain.Genesis}, + "shard": {ChainID: domain.ChainID, ShardID: domain.ShardID + 1, Epoch: domain.Epoch, Genesis: domain.Genesis}, + "epoch": {ChainID: domain.ChainID, ShardID: domain.ShardID, Epoch: domain.Epoch + 1, Genesis: domain.Genesis}, + "genesis": {ChainID: domain.ChainID, ShardID: domain.ShardID, Epoch: domain.Epoch, Genesis: testWireBlockID(0x07)}, + } + for name, expected := range tests { + t.Run(name, func(t *testing.T) { + _, err := DecodeWireMessageForDomain(encoded, expected) + require.ErrorIs(t, err, ErrWireDomainMismatch) + }) + } + + decoded, err := DecodeWireMessageForDomain(encoded, domain) + require.NoError(t, err) + require.Equal(t, vote, *decoded.Vote) +} + +func TestHotStuffWireEncodeRejectsNonCanonicalIdentities(t *testing.T) { + domain := hotstuff.VoteDomain{ + ChainID: 1666700000, + ShardID: 2, + Epoch: 42, + Genesis: "0xgenesis", + } + vote := hotstuff.SignedVote{ + Vote: hotstuff.Vote{ + Voter: "bls:validator-key", + Block: "0xblock", + View: 17, + }, + Signature: bytes.Repeat([]byte{0x5a}, 96), + } + + _, err := EncodeVoteMessage(domain, vote) + require.ErrorIs(t, err, ErrInvalidWireMessage) +} + +func TestHotStuffWireDecodeRejectsMalformedEnvelopeBeforeDispatch(t *testing.T) { + domain := hotstuff.VoteDomain{ + ChainID: 1666700000, + ShardID: 2, + Epoch: 42, + Genesis: testWireBlockID(0x01), + } + vote := hotstuff.SignedVote{ + Vote: hotstuff.Vote{ + Voter: testWireMemberID(0x02), + Block: testWireBlockID(0x03), + View: 17, + }, + Signature: bytes.Repeat([]byte{0x5a}, 96), + } + valid, err := EncodeVoteMessage(domain, vote) + require.NoError(t, err) + + tests := map[string]struct { + message []byte + target error + }{ + "wrong category": { + message: append([]byte{byte(proto.Node)}, valid[proto.MessageCategoryBytes:]...), + target: ErrWrongWireCategory, + }, + "unsupported version": { + message: mutateWireEnvelope(t, valid, func(envelope *wirepb.Envelope) { + envelope.Version++ + }), + target: ErrUnsupportedWireVersion, + }, + "missing domain": { + message: mutateWireEnvelope(t, valid, func(envelope *wirepb.Envelope) { + envelope.Domain = nil + }), + target: ErrInvalidWireMessage, + }, + "missing body": { + message: mutateWireEnvelope(t, valid, func(envelope *wirepb.Envelope) { + envelope.Message = nil + }), + target: ErrInvalidWireMessage, + }, + "short signature": { + message: mutateWireEnvelope(t, valid, func(envelope *wirepb.Envelope) { + envelope.GetVote().Signature = envelope.GetVote().Signature[:95] + }), + target: ErrInvalidWireMessage, + }, + "alternate uppercase voter": { + message: mutateWireEnvelope(t, valid, func(envelope *wirepb.Envelope) { + envelope.GetVote().Voter[len("bls:")] = 'A' + }), + target: ErrInvalidWireMessage, + }, + "oversized frame": { + message: append([]byte{byte(proto.HotStuff)}, make([]byte, MaxWireMessageSize)...), + target: ErrWireMessageTooLarge, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + _, err := DecodeWireMessage(test.message) + require.ErrorIs(t, err, test.target) + }) + } +} + +func TestHotStuffWireQCSignersUseBoundedPackedEncoding(t *testing.T) { + fields := (&wirepb.QuorumCertificate{}).ProtoReflect().Descriptor().Fields() + signers := fields.ByName("signers") + require.NotNil(t, signers) + require.Equal(t, protoreflect.Optional, signers.Cardinality()) + require.Equal(t, protoreflect.BytesKind, signers.Kind()) +} + +func TestHotStuffWireMaximumProposalFitsP2PFrame(t *testing.T) { + domain := hotstuff.VoteDomain{ + ChainID: 1, + ShardID: 2, + Epoch: 3, + Genesis: testWireBlockID(1), + } + signers := make([]hotstuff.MemberID, maxWireSigners) + for index := range signers { + signers[index] = testWireMemberID(byte(index)) + } + encoded, err := EncodeProposalMessage( + domain, + make([]byte, MaxWireBlockSize), + hotstuff.BLSQC{ + QC: hotstuff.QC{ + Block: testWireBlockID(2), + View: 4, + Signers: signers, + }, + Signature: bytes.Repeat([]byte{0x44}, 96), + Bitmap: bytes.Repeat([]byte{0xff}, maxWireBitmapSize), + }, + ) + require.NoError(t, err) + require.LessOrEqual(t, len(p2p.ConstructMessage(encoded)), p2p.MaxMessageSize) +} + +func mutateWireEnvelope( + t *testing.T, + message []byte, + mutate func(*wirepb.Envelope), +) []byte { + t.Helper() + envelope := &wirepb.Envelope{} + require.NoError(t, protobuf.Unmarshal(message[proto.MessageCategoryBytes:], envelope)) + mutate(envelope) + payload, err := protobuf.Marshal(envelope) + require.NoError(t, err) + return proto.ConstructHotStuffMessage(payload) +} + +func testWireBlockID(fill byte) hotstuff.BlockID { + return hotstuff.BlockID("0x" + hex.EncodeToString(bytes.Repeat([]byte{fill}, 32))) +} + +func testWireMemberID(fill byte) hotstuff.MemberID { + return hotstuff.MemberID("bls:" + hex.EncodeToString(bytes.Repeat([]byte{fill}, 48))) +} diff --git a/node/harmony/hotstuff_shadow.go b/node/harmony/hotstuff_shadow.go new file mode 100644 index 0000000000..c436430833 --- /dev/null +++ b/node/harmony/hotstuff_shadow.go @@ -0,0 +1,86 @@ +package node + +import ( + "context" + "errors" + "reflect" + + "github.com/harmony-one/harmony/consensus/hotstuff" + hshotstuff "github.com/harmony-one/harmony/consensus/hotstuff/harmony" +) + +var ( + ErrHotStuffShadowDisabled = errors.New("hotstuff shadow ingress is disabled") + ErrInvalidHotStuffShadowHandler = errors.New("hotstuff shadow handler is invalid") +) + +// HotStuffShadowHandler is the feature-gated network boundary for shadow-only +// HotStuff processing. Implementations may observe commits but must not write +// blocks or canonical finality state. +type HotStuffShadowHandler interface { + Domain() hotstuff.VoteDomain + Handle(context.Context, hshotstuff.WireMessage) error +} + +type hotStuffShadowRegistration struct { + handler HotStuffShadowHandler + domain hotstuff.VoteDomain +} + +// SetHotStuffShadowHandler installs the shadow ingress before StartPubSub. +// Passing nil explicitly disables the feature; typed-nil handlers are rejected. +func (node *Node) SetHotStuffShadowHandler(handler HotStuffShadowHandler) error { + if isTypedNilHotStuffShadowHandler(handler) { + return ErrInvalidHotStuffShadowHandler + } + var registration *hotStuffShadowRegistration + if handler != nil { + registration = &hotStuffShadowRegistration{ + handler: handler, + domain: handler.Domain(), + } + } + node.hotStuffShadowMu.Lock() + defer node.hotStuffShadowMu.Unlock() + node.hotStuffShadowHandler = registration + return nil +} + +func (node *Node) validateHotStuffShadowMessage( + message []byte, +) (HotStuffShadowHandler, hshotstuff.WireMessage, error) { + node.hotStuffShadowMu.RLock() + registration := node.hotStuffShadowHandler + node.hotStuffShadowMu.RUnlock() + if registration == nil { + return nil, hshotstuff.WireMessage{}, ErrHotStuffShadowDisabled + } + decoded, err := hshotstuff.DecodeWireMessageForDomain(message, registration.domain) + if err != nil { + return nil, hshotstuff.WireMessage{}, err + } + return registration.handler, decoded, nil +} + +func (node *Node) validateHotStuffShadowTopicMessage( + message []byte, + consensusBound bool, +) (HotStuffShadowHandler, hshotstuff.WireMessage, error) { + if !consensusBound { + return nil, hshotstuff.WireMessage{}, errConsensusMessageOnUnexpectedTopic + } + return node.validateHotStuffShadowMessage(message) +} + +func isTypedNilHotStuffShadowHandler(handler HotStuffShadowHandler) bool { + if handler == nil { + return false + } + value := reflect.ValueOf(handler) + switch value.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice: + return value.IsNil() + default: + return false + } +} diff --git a/node/harmony/hotstuff_shadow_test.go b/node/harmony/hotstuff_shadow_test.go new file mode 100644 index 0000000000..44bcecc426 --- /dev/null +++ b/node/harmony/hotstuff_shadow_test.go @@ -0,0 +1,128 @@ +package node + +import ( + "bytes" + "context" + "encoding/hex" + "testing" + + "github.com/harmony-one/harmony/consensus/hotstuff" + hshotstuff "github.com/harmony-one/harmony/consensus/hotstuff/harmony" + "github.com/stretchr/testify/require" +) + +type testHotStuffShadowHandler struct { + domain hotstuff.VoteDomain + handled []hshotstuff.WireMessage +} + +func (handler *testHotStuffShadowHandler) Domain() hotstuff.VoteDomain { + return handler.domain +} + +func (handler *testHotStuffShadowHandler) Handle( + _ context.Context, + message hshotstuff.WireMessage, +) error { + handler.handled = append(handler.handled, message) + return nil +} + +func TestHotStuffShadowIngressIsDisabledByDefault(t *testing.T) { + node := &Node{} + message := testHotStuffVoteMessage(t, testHotStuffDomain()) + + _, _, err := node.validateHotStuffShadowMessage(message) + require.ErrorIs(t, err, ErrHotStuffShadowDisabled) +} + +func TestHotStuffShadowIngressBindsDomainBeforeDispatch(t *testing.T) { + node := &Node{} + domain := testHotStuffDomain() + handler := &testHotStuffShadowHandler{domain: domain} + require.NoError(t, node.SetHotStuffShadowHandler(handler)) + + message := testHotStuffVoteMessage(t, domain) + target, decoded, err := node.validateHotStuffShadowMessage(message) + require.NoError(t, err) + require.Same(t, handler, target) + require.NoError(t, target.Handle(context.Background(), decoded)) + require.Len(t, handler.handled, 1) + + wrongDomain := domain + wrongDomain.Epoch++ + message = testHotStuffVoteMessage(t, wrongDomain) + _, _, err = node.validateHotStuffShadowMessage(message) + require.ErrorIs(t, err, hshotstuff.ErrWireDomainMismatch) + require.Len(t, handler.handled, 1) +} + +func TestHotStuffShadowIngressRejectsTypedNilHandler(t *testing.T) { + node := &Node{} + var handler *testHotStuffShadowHandler + require.ErrorIs(t, node.SetHotStuffShadowHandler(handler), ErrInvalidHotStuffShadowHandler) +} + +func TestHotStuffShadowIngressRejectsNonConsensusTopic(t *testing.T) { + node := &Node{} + domain := testHotStuffDomain() + require.NoError(t, node.SetHotStuffShadowHandler(&testHotStuffShadowHandler{domain: domain})) + + message := testHotStuffVoteMessage(t, domain) + _, _, err := node.validateHotStuffShadowTopicMessage(message, false) + require.ErrorIs(t, err, errConsensusMessageOnUnexpectedTopic) +} + +func TestHotStuffShadowIngressSnapshotsDomainAtRegistration(t *testing.T) { + node := &Node{} + domain := testHotStuffDomain() + handler := &testHotStuffShadowHandler{domain: domain} + require.NoError(t, node.SetHotStuffShadowHandler(handler)) + + handler.domain.Epoch++ + target, decoded, err := node.validateHotStuffShadowMessage(testHotStuffVoteMessage(t, domain)) + require.NoError(t, err) + require.Same(t, handler, target) + require.Equal(t, domain, decoded.Domain) +} + +func TestHotStuffShadowIngressCanBeDisabledExplicitly(t *testing.T) { + node := &Node{} + domain := testHotStuffDomain() + require.NoError(t, node.SetHotStuffShadowHandler(&testHotStuffShadowHandler{domain: domain})) + require.NoError(t, node.SetHotStuffShadowHandler(nil)) + + _, _, err := node.validateHotStuffShadowMessage(testHotStuffVoteMessage(t, domain)) + require.ErrorIs(t, err, ErrHotStuffShadowDisabled) +} + +func testHotStuffDomain() hotstuff.VoteDomain { + return hotstuff.VoteDomain{ + ChainID: 1666700000, + ShardID: 2, + Epoch: 42, + Genesis: testHotStuffBlockID(0x01), + } +} + +func testHotStuffVoteMessage(t *testing.T, domain hotstuff.VoteDomain) []byte { + t.Helper() + message, err := hshotstuff.EncodeVoteMessage(domain, hotstuff.SignedVote{ + Vote: hotstuff.Vote{ + Voter: testHotStuffMemberID(0x02), + Block: testHotStuffBlockID(0x03), + View: 17, + }, + Signature: bytes.Repeat([]byte{0x5a}, 96), + }) + require.NoError(t, err) + return message +} + +func testHotStuffBlockID(fill byte) hotstuff.BlockID { + return hotstuff.BlockID("0x" + hex.EncodeToString(bytes.Repeat([]byte{fill}, 32))) +} + +func testHotStuffMemberID(fill byte) hotstuff.MemberID { + return hotstuff.MemberID("bls:" + hex.EncodeToString(bytes.Repeat([]byte{fill}, 48))) +} diff --git a/node/harmony/node.go b/node/harmony/node.go index fb10bde8ba..2bb22ed53e 100644 --- a/node/harmony/node.go +++ b/node/harmony/node.go @@ -132,7 +132,10 @@ type Node struct { // context control for pub-sub handling psCtx context.Context psCancel func() - registry *registry.Registry + + hotStuffShadowMu sync.RWMutex + hotStuffShadowHandler *hotStuffShadowRegistration + registry *registry.Registry } // Blockchain returns the blockchain for the node's current shard. @@ -689,6 +692,7 @@ func (node *Node) StartPubSub() error { type validated struct { peerID libp2p_peer.ID consensusBound bool + handleH func(context.Context) error handleC p2pHandlerConsensus handleCArg *msg_pb.Message handleE p2pHandlerElse @@ -767,6 +771,22 @@ func (node *Node) StartPubSub() error { } return libp2p_pubsub.ValidationAccept + case proto.HotStuff: + target, decoded, err := node.validateHotStuffShadowTopicMessage(openBox, isConsensusBound) + if err != nil { + errChan <- withError{err, msg.GetFrom()} + return libp2p_pubsub.ValidationReject + } + nodeP2PMessageCounterVec.With(prometheus.Labels{"type": "hotstuff_total"}).Inc() + msg.ValidatorData = validated{ + peerID: peer, + consensusBound: true, + handleH: func(ctx context.Context) error { + return target.Handle(ctx, decoded) + }, + } + return libp2p_pubsub.ValidationAccept + case proto.Node: // node message is almost empty if len(openBox) <= p2pNodeMsgPrefixSize { @@ -835,7 +855,11 @@ func (node *Node) StartPubSub() error { if semConsensus.TryAcquire(1) { defer semConsensus.Release(1) - if isThisNodeAnExplorerNode { + if msg.handleH != nil { + if err := msg.handleH(ctx); err != nil { + errChan <- withError{err, msg.peerID} + } + } else if isThisNodeAnExplorerNode { if err := node.explorerMessageHandler( ctx, msg.handleCArg, ); err != nil { From a65cd2aa758339c4bd10ad09cbeeed1ae9548a8f Mon Sep 17 00:00:00 2001 From: frozen <355847+Frozen@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:22:54 -0400 Subject: [PATCH 13/13] consensus: allow HotStuff genesis QC on wire --- consensus/hotstuff/harmony/wire.go | 61 ++++++++++++++++---- consensus/hotstuff/harmony/wire_test.go | 77 +++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 11 deletions(-) diff --git a/consensus/hotstuff/harmony/wire.go b/consensus/hotstuff/harmony/wire.go index c9d1817547..566cdde80a 100644 --- a/consensus/hotstuff/harmony/wire.go +++ b/consensus/hotstuff/harmony/wire.go @@ -44,8 +44,9 @@ type WireMessage struct { Proposal *WireProposal } -// WireProposal contains an owned Harmony block encoding and the untrusted BLS -// QC evidence that must be verified by the domain authority before ingress. +// WireProposal contains an owned Harmony block encoding and untrusted QC +// evidence. Signed evidence must pass QCAuthority.Verify; an empty genesis +// trust root must match the capability minted while configuring that authority. type WireProposal struct { Block []byte Justify hotstuff.BLSQC @@ -95,8 +96,8 @@ func EncodeVoteMessage(domain hotstuff.VoteDomain, vote hotstuff.SignedVote) ([] } // EncodeProposalMessage serializes one Harmony block and its parent QC -// evidence. The receiver must decode the block and call QCAuthority.Verify -// before mapping it into the HotStuff core. +// evidence. Before core ingress, the receiver must decode the block and either +// verify signed evidence or match the configured genesis trust-root capability. func EncodeProposalMessage( domain hotstuff.VoteDomain, block []byte, @@ -108,7 +109,7 @@ func EncodeProposalMessage( if len(block) == 0 || len(block) > MaxWireBlockSize { return nil, ErrInvalidWireMessage } - wireQC, err := encodeWireQC(justify) + wireQC, err := encodeWireQC(domain, justify) if err != nil { return nil, err } @@ -182,7 +183,7 @@ func DecodeWireMessage(message []byte) (WireMessage, error) { return result, nil } if wireProposal := envelope.GetProposal(); wireProposal != nil { - proposal, err := decodeWireProposal(wireProposal) + proposal, err := decodeWireProposal(result.Domain, wireProposal) if err != nil { return WireMessage{}, err } @@ -208,10 +209,23 @@ func DecodeWireMessageForDomain( return decoded, nil } -func encodeWireQC(qc hotstuff.BLSQC) (*wirepb.QuorumCertificate, error) { +func encodeWireQC(domain hotstuff.VoteDomain, qc hotstuff.BLSQC) (*wirepb.QuorumCertificate, error) { if err := validateWireBlockID(string(qc.QC.Block)); err != nil { return nil, err } + if isGenesisTrustRootQC( + domain, + string(qc.QC.Block), + uint64(qc.QC.View), + len(qc.QC.Signers), + len(qc.Signature), + len(qc.Bitmap), + ) { + return &wirepb.QuorumCertificate{ + Block: []byte(qc.QC.Block), + View: uint64(qc.QC.View), + }, nil + } if len(qc.QC.Signers) == 0 || len(qc.QC.Signers) > maxWireSigners || len(qc.Signature) != hmybls.BLSSignatureSizeInBytes || len(qc.Bitmap) == 0 || len(qc.Bitmap) > maxWireBitmapSize { @@ -253,7 +267,7 @@ func decodeWireVote(wireVote *wirepb.Vote) (*hotstuff.SignedVote, error) { }, nil } -func decodeWireProposal(wireProposal *wirepb.Proposal) (*WireProposal, error) { +func decodeWireProposal(domain hotstuff.VoteDomain, wireProposal *wirepb.Proposal) (*WireProposal, error) { if len(wireProposal.Block) == 0 || len(wireProposal.Block) > MaxWireBlockSize || wireProposal.Justify == nil { return nil, ErrInvalidWireMessage } @@ -261,13 +275,23 @@ func decodeWireProposal(wireProposal *wirepb.Proposal) (*WireProposal, error) { if err := validateWireBlockBytes(wireQC.Block); err != nil { return nil, err } - if len(wireQC.Signers) == 0 || len(wireQC.Signers)%wireMemberIDSize != 0 || + if !isGenesisTrustRootQC( + domain, + string(wireQC.Block), + wireQC.View, + len(wireQC.Signers), + len(wireQC.Signature), + len(wireQC.Bitmap), + ) && (len(wireQC.Signers) == 0 || len(wireQC.Signers)%wireMemberIDSize != 0 || len(wireQC.Signers) > maxWireSigners*wireMemberIDSize || len(wireQC.Signature) != hmybls.BLSSignatureSizeInBytes || - len(wireQC.Bitmap) == 0 || len(wireQC.Bitmap) > maxWireBitmapSize { + len(wireQC.Bitmap) == 0 || len(wireQC.Bitmap) > maxWireBitmapSize) { return nil, ErrInvalidWireMessage } - signers := make([]hotstuff.MemberID, len(wireQC.Signers)/wireMemberIDSize) + var signers []hotstuff.MemberID + if len(wireQC.Signers) > 0 { + signers = make([]hotstuff.MemberID, len(wireQC.Signers)/wireMemberIDSize) + } for index := range signers { start := index * wireMemberIDSize signer := wireQC.Signers[start : start+wireMemberIDSize] @@ -290,6 +314,21 @@ func decodeWireProposal(wireProposal *wirepb.Proposal) (*WireProposal, error) { }, nil } +func isGenesisTrustRootQC( + domain hotstuff.VoteDomain, + block string, + view uint64, + signerEvidenceSize int, + signatureSize int, + bitmapSize int, +) bool { + return hotstuff.BlockID(block) == domain.Genesis && + view == 0 && + signerEvidenceSize == 0 && + signatureSize == 0 && + bitmapSize == 0 +} + func validateWireBlockID(id string) error { return validateWireHexID(id, "0x", 32) } diff --git a/consensus/hotstuff/harmony/wire_test.go b/consensus/hotstuff/harmony/wire_test.go index 0597ccd373..56848c01e1 100644 --- a/consensus/hotstuff/harmony/wire_test.go +++ b/consensus/hotstuff/harmony/wire_test.go @@ -85,6 +85,83 @@ func TestHotStuffWireProposalRoundTripOwnsBlockAndQC(t *testing.T) { require.Equal(t, []byte{0x03}, decoded.Proposal.Justify.Bitmap) } +func TestHotStuffWireProposalAcceptsOnlyCanonicalGenesisTrustRoot(t *testing.T) { + domain := hotstuff.VoteDomain{ + ChainID: 1666700000, + ShardID: 2, + Epoch: 42, + Genesis: testWireBlockID(0x01), + } + block := []byte{0xf8, 0x01, 0x02, 0x03} + genesis := hotstuff.BLSQC{ + QC: hotstuff.QC{Block: domain.Genesis}, + } + + encoded, err := EncodeProposalMessage(domain, block, genesis) + require.NoError(t, err) + decoded, err := DecodeWireMessageForDomain(encoded, domain) + require.NoError(t, err) + require.NotNil(t, decoded.Proposal) + require.Equal(t, genesis, decoded.Proposal.Justify) + foreignDomain := domain + foreignDomain.Epoch++ + _, err = DecodeWireMessageForDomain(encoded, foreignDomain) + require.ErrorIs(t, err, ErrWireDomainMismatch) + + invalidEncode := map[string]hotstuff.BLSQC{ + "wrong block": { + QC: hotstuff.QC{Block: testWireBlockID(0x02)}, + }, + "nonzero view": { + QC: hotstuff.QC{Block: domain.Genesis, View: 1}, + }, + "partial signers": { + QC: hotstuff.QC{Block: domain.Genesis, Signers: []hotstuff.MemberID{testWireMemberID(0x03)}}, + }, + "partial signature": { + QC: hotstuff.QC{Block: domain.Genesis}, + Signature: bytes.Repeat([]byte{0x44}, 96), + }, + "partial bitmap": { + QC: hotstuff.QC{Block: domain.Genesis}, + Bitmap: []byte{0x01}, + }, + } + for name, justify := range invalidEncode { + t.Run("encode "+name, func(t *testing.T) { + _, err := EncodeProposalMessage(domain, block, justify) + require.ErrorIs(t, err, ErrInvalidWireMessage) + }) + } + + invalidDecode := map[string]func(*wirepb.QuorumCertificate){ + "wrong block": func(qc *wirepb.QuorumCertificate) { + qc.Block = []byte(testWireBlockID(0x02)) + }, + "nonzero view": func(qc *wirepb.QuorumCertificate) { + qc.View = 1 + }, + "partial signers": func(qc *wirepb.QuorumCertificate) { + qc.Signers = []byte(testWireMemberID(0x03)) + }, + "partial signature": func(qc *wirepb.QuorumCertificate) { + qc.Signature = bytes.Repeat([]byte{0x44}, 96) + }, + "partial bitmap": func(qc *wirepb.QuorumCertificate) { + qc.Bitmap = []byte{0x01} + }, + } + for name, mutateQC := range invalidDecode { + t.Run("decode "+name, func(t *testing.T) { + malformed := mutateWireEnvelope(t, encoded, func(envelope *wirepb.Envelope) { + mutateQC(envelope.GetProposal().Justify) + }) + _, err := DecodeWireMessageForDomain(malformed, domain) + require.ErrorIs(t, err, ErrInvalidWireMessage) + }) + } +} + func TestHotStuffWireDecodeRejectsEveryDomainMismatch(t *testing.T) { domain := hotstuff.VoteDomain{ ChainID: 1666700000,