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/api_test.go b/consensus/hotstuff/api_test.go new file mode 100644 index 0000000000..00f1ce5e9b --- /dev/null +++ b/consensus/hotstuff/api_test.go @@ -0,0 +1,90 @@ +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" +) + +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"} { + 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) +} + +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 new file mode 100644 index 0000000000..2fa02389fc --- /dev/null +++ b/consensus/hotstuff/bls.go @@ -0,0 +1,319 @@ +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/v2" + +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, 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 +// registry after proof-of-possession validation. +type BLSMember struct { + Member + PublicKey hmybls.PublicKeyWrapper +} + +// 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 + 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) { + 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) + } + committee, err := NewCommittee(structuralMembers) + 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, + 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, error) { + s.mu.Lock() + defer s.mu.Unlock() + + qc, formed, err := s.votes.QC() + if err != nil { + return BLSQC{}, false, err + } + if !formed { + return BLSQC{}, false, nil + } + 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, err + } + } + aggregate := hmybls.AggregateSig(signatures) + return BLSQC{ + QC: qc, + Signature: append([]byte(nil), aggregate.Serialize()...), + Bitmap: mask.Mask(), + }, true, nil +} + +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(domain.Genesis)) > uint64(math.MaxUint32) || + 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.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))) + _, _ = 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..0d84a28b49 --- /dev/null +++ b/consensus/hotstuff/bls_test.go @@ -0,0 +1,153 @@ +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, Genesis: "genesis"} + 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, err := set.QC() + require.NoError(t, err) + 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, Genesis: "genesis"}, vote, secrets["alice"]) + require.NoError(t, err) + + otherEpoch := NewBLSVoteSet( + committee, "b7", 7, + 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) { + 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"} + 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, err := set.QC() + require.NoError(t, err) + 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, Genesis: "genesis"} + 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, 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, err := set.QC() + require.NoError(t, err) + 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/bls_timeout.go b/consensus/hotstuff/bls_timeout.go new file mode 100644 index 0000000000..da2316c5b9 --- /dev/null +++ b/consensus/hotstuff/bls_timeout.go @@ -0,0 +1,387 @@ +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, error) { + s.mu.Lock() + defer s.mu.Unlock() + + certificate, formed, err := s.timeouts.Certificate() + if err != nil { + return BLSTimeoutCertificate{}, false, err + } + if !formed { + return BLSTimeoutCertificate{}, false, nil + } + 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, err + } + } + 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, nil +} + +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..6d12a7840b --- /dev/null +++ b/consensus/hotstuff/bls_timeout_test.go @@ -0,0 +1,280 @@ +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, err := set.Certificate() + require.NoError(t, err) + require.False(t, formed) + } + } + + 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) + 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, err := set.Certificate() + require.NoError(t, err) + 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, err := set.Certificate() + require.NoError(t, err) + 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, err := set.Certificate() + require.NoError(t, err) + 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/certified.go b/consensus/hotstuff/certified.go new file mode 100644 index 0000000000..f645e8b107 --- /dev/null +++ b/consensus/hotstuff/certified.go @@ -0,0 +1,152 @@ +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") + ErrNilBLSCommittee = errors.New("hotstuff BLS committee is nil") +) + +// 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 + leaders LeaderSchedule + domain VoteDomain + mu sync.Mutex + genesis QC + configured bool +} + +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 { + 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 + } + 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 +} + +// 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..9615d436dd --- /dev/null +++ b/consensus/hotstuff/certified_test.go @@ -0,0 +1,227 @@ +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, 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 new file mode 100644 index 0000000000..62f1cc3b48 --- /dev/null +++ b/consensus/hotstuff/committee.go @@ -0,0 +1,102 @@ +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") + 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 canonical +// certificate signer order and the default round-robin leader schedule. +type Committee struct { + members []Member + byID map[MemberID]Member + total uint64 + certificateQuorum CertificateQuorum +} + +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 +} + +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.go b/consensus/hotstuff/core.go new file mode 100644 index 0000000000..d393b7b019 --- /dev/null +++ b/consensus/hotstuff/core.go @@ -0,0 +1,159 @@ +package hotstuff + +import ( + "errors" + "sync" +) + +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 { + mu sync.RWMutex + blocks map[BlockID]Block + genesis BlockID + committed BlockID + authority *QCAuthority +} + +func newCore(genesis Block) *Core { + core := &Core{blocks: make(map[BlockID]Block)} + if genesis.ID != "" && genesis.Parent == "" && genesis.View == 0 { + core.blocks[genesis.ID] = cloneBlock(genesis) + core.genesis = genesis.ID + 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 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 { + 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] = cloneBlock(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 +} + +func cloneBlock(block Block) Block { + block.Justify = cloneQC(block.Justify) + return block +} diff --git a/consensus/hotstuff/core_test.go b/consensus/hotstuff/core_test.go new file mode 100644 index 0000000000..4a1865c06a --- /dev/null +++ b/consensus/hotstuff/core_test.go @@ -0,0 +1,150 @@ +package hotstuff + +import ( + "sync" + "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, err := collector.QC() + require.NoError(t, err) + 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, err := collector.QC() + require.NoError(t, err) + 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, err := collector.QC() + require.NoError(t, err) + require.False(t, formed) +} + +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..90425b151d --- /dev/null +++ b/consensus/hotstuff/doc.go @@ -0,0 +1,9 @@ +// Package hotstuff contains an isolated chained HotStuff research spike. +// +// 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 and +// timeout certificates, structural block processing, and the direct +// three-chain commit rule. +package hotstuff 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/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..15dba40d2f --- /dev/null +++ b/consensus/hotstuff/harmony/doc.go @@ -0,0 +1,7 @@ +// Package harmony provides canonical Harmony adapters for isolated HotStuff +// integration work. Leader identities remain validator-level while the +// 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 new file mode 100644 index 0000000000..ca015d0a64 --- /dev/null +++ b/consensus/hotstuff/harmony/quorum.go @@ -0,0 +1,229 @@ +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 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 + 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<> 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 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 +} + +// 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. 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, + 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(domain, 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(result.Domain, 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(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 { + 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(domain hotstuff.VoteDomain, 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 !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) { + return nil, ErrInvalidWireMessage + } + 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] + 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 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) +} + +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..56848c01e1 --- /dev/null +++ b/consensus/hotstuff/harmony/wire_test.go @@ -0,0 +1,346 @@ +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 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, + 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/consensus/hotstuff/pacemaker.go b/consensus/hotstuff/pacemaker.go new file mode 100644 index 0000000000..d56b18d958 --- /dev/null +++ b/consensus/hotstuff/pacemaker.go @@ -0,0 +1,268 @@ +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{} + 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() + + _, 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{}{} + canonicalHighQC := s.committee.canonicalQC(timeout.HighQC) + if higherQC(canonicalHighQC, s.highQC) { + s.highQC = canonicalHighQC + } + return nil +} + +func (s *TimeoutSet) Certificate() (TimeoutCertificate, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + 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, nil +} + +// 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 + leaders LeaderSchedule + view View + highQC QC + authority *QCAuthority +} + +func newPacemaker(committee *Committee, initial View) *Pacemaker { + 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 { + p.mu.Lock() + defer p.mu.Unlock() + return p.view +} + +func (p *Pacemaker) Leader() MemberID { + p.mu.Lock() + defer p.mu.Unlock() + return p.leaders.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 { + 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 false, ErrDuplicateSigner + } + member, exists := c.byID[signer] + if !exists { + return false, ErrUnknownVoter + } + seen[signer] = struct{}{} + power += member.Power + } + if c.certificateQuorum != nil { + hasQuorum, err := c.certificateQuorum.HasQuorum(append([]MemberID(nil), signers...)) + if err != nil { + return false, err + } + return hasQuorum, nil + } + return power >= c.quorumPower(), 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..7e62279154 --- /dev/null +++ b/consensus/hotstuff/pacemaker_test.go @@ -0,0 +1,161 @@ +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, err := set.Certificate() + require.NoError(t, err) + 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} +} 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/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..0003f910f8 --- /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 +} 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..adde188b33 --- /dev/null +++ b/consensus/hotstuff/votes.go @@ -0,0 +1,69 @@ +package hotstuff + +import ( + "errors" + "sync" +) + +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 { + mu sync.Mutex + committee *Committee + block BlockID + view View + voters map[MemberID]struct{} +} + +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 { + s.mu.Lock() + defer s.mu.Unlock() + _, 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{}{} + return nil +} + +func (s *VoteSet) QC() (QC, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + 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) + } + } + 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 +} 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 {