diff --git a/Dockerfile b/Dockerfile index f2422c8d4f..4689baa439 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,6 +55,7 @@ COPY ./pkg/tecdsa/dkg/gen $APP_DIR/pkg/tecdsa/dkg/gen COPY ./pkg/tecdsa/signing/gen $APP_DIR/pkg/tecdsa/signing/gen COPY ./pkg/tecdsa/gen $APP_DIR/pkg/tecdsa/gen COPY ./pkg/protocol/announcer/gen $APP_DIR/pkg/protocol/announcer/gen +COPY ./pkg/protocol/inactivity/gen $APP_DIR/pkg/protocol/inactivity/gen # Environment is to download published and tagged NPM packages versions. ARG ENVIRONMENT diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index b5b7a76a6c..3fd3ce6172 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -5,12 +5,13 @@ import ( "crypto/elliptic" "encoding/binary" "fmt" - "github.com/keep-network/keep-common/pkg/cache" "math/big" "reflect" "sort" "time" + "github.com/keep-network/keep-common/pkg/cache" + "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" @@ -26,6 +27,7 @@ import ( "github.com/keep-network/keep-core/pkg/internal/byteutils" "github.com/keep-network/keep-core/pkg/operator" "github.com/keep-network/keep-core/pkg/protocol/group" + "github.com/keep-network/keep-core/pkg/protocol/inactivity" "github.com/keep-network/keep-core/pkg/subscription" "github.com/keep-network/keep-core/pkg/tbtc" "github.com/keep-network/keep-core/pkg/tecdsa/dkg" @@ -1001,6 +1003,187 @@ func (tc *TbtcChain) DKGParameters() (*tbtc.DKGParameters, error) { }, nil } +func (tc *TbtcChain) OnInactivityClaimed( + handler func(event *tbtc.InactivityClaimedEvent), +) subscription.EventSubscription { + onEvent := func( + walletID [32]byte, + nonce *big.Int, + notifier common.Address, + blockNumber uint64, + ) { + handler(&tbtc.InactivityClaimedEvent{ + WalletID: walletID, + Nonce: nonce, + Notifier: chain.Address(notifier.Hex()), + BlockNumber: blockNumber, + }) + } + + return tc.walletRegistry.InactivityClaimedEvent(nil, nil).OnEvent(onEvent) +} + +func (tc *TbtcChain) AssembleInactivityClaim( + walletID [32]byte, + inactiveMembersIndices []group.MemberIndex, + signatures map[group.MemberIndex][]byte, + heartbeatFailed bool, +) ( + *tbtc.InactivityClaim, + error, +) { + signingMemberIndices, signatureBytes, err := convertSignaturesToChainFormat( + signatures, + ) + if err != nil { + return nil, fmt.Errorf( + "could not convert signatures to chain format: [%v]", + err, + ) + } + + return &tbtc.InactivityClaim{ + WalletID: walletID, + InactiveMembersIndices: inactiveMembersIndices, + HeartbeatFailed: heartbeatFailed, + Signatures: signatureBytes, + SigningMembersIndices: signingMemberIndices, + }, nil +} + +// convertInactivityClaimToAbiType converts the TBTC-specific inactivity claim +// to the format applicable for the WalletRegistry ABI. +func convertInactivityClaimToAbiType( + claim *tbtc.InactivityClaim, +) ecdsaabi.EcdsaInactivityClaim { + inactiveMembersIndices := make([]*big.Int, len(claim.InactiveMembersIndices)) + for i, memberIndex := range claim.InactiveMembersIndices { + inactiveMembersIndices[i] = big.NewInt(int64(memberIndex)) + } + + signingMembersIndices := make([]*big.Int, len(claim.SigningMembersIndices)) + for i, memberIndex := range claim.SigningMembersIndices { + signingMembersIndices[i] = big.NewInt(int64(memberIndex)) + } + + return ecdsaabi.EcdsaInactivityClaim{ + WalletID: claim.WalletID, + InactiveMembersIndices: inactiveMembersIndices, + HeartbeatFailed: claim.HeartbeatFailed, + Signatures: claim.Signatures, + SigningMembersIndices: signingMembersIndices, + } +} + +func (tc *TbtcChain) SubmitInactivityClaim( + claim *tbtc.InactivityClaim, + nonce *big.Int, + groupMembers []uint32, +) error { + _, err := tc.walletRegistry.NotifyOperatorInactivity( + convertInactivityClaimToAbiType(claim), + nonce, + groupMembers, + ) + + return err +} + +func (tc *TbtcChain) CalculateInactivityClaimHash( + claim *inactivity.ClaimPreimage, +) (inactivity.ClaimHash, error) { + walletPublicKeyBytes := elliptic.Marshal( + claim.WalletPublicKey.Curve, + claim.WalletPublicKey.X, + claim.WalletPublicKey.Y, + ) + // Crop the 04 prefix as the calculateInactivityClaimHash function expects + // an unprefixed 64-byte public key, + unprefixedGroupPublicKeyBytes := walletPublicKeyBytes[1:] + + // The type representing inactive member index should be `big.Int` as the + // smart contract reading the calculated hash uses `uint256` for inactive + // member indexes. + inactiveMembersIndexes := make([]*big.Int, len(claim.InactiveMembersIndexes)) + for i, index := range claim.InactiveMembersIndexes { + inactiveMembersIndexes[i] = big.NewInt(int64(index)) + } + + return calculateInactivityClaimHash( + tc.chainID, + claim.Nonce, + unprefixedGroupPublicKeyBytes, + inactiveMembersIndexes, + claim.HeartbeatFailed, + ) +} + +func calculateInactivityClaimHash( + chainID *big.Int, + nonce *big.Int, + walletPublicKey []byte, + inactiveMembersIndexes []*big.Int, + heartbeatFailed bool, +) (inactivity.ClaimHash, error) { + publicKeySize := 64 + + if len(walletPublicKey) != publicKeySize { + return inactivity.ClaimHash{}, fmt.Errorf( + "wrong wallet public key length", + ) + } + + uint256Type, err := abi.NewType("uint256", "uint256", nil) + if err != nil { + return inactivity.ClaimHash{}, err + } + bytesType, err := abi.NewType("bytes", "bytes", nil) + if err != nil { + return inactivity.ClaimHash{}, err + } + uint256SliceType, err := abi.NewType("uint256[]", "uint256[]", nil) + if err != nil { + return inactivity.ClaimHash{}, err + } + boolType, err := abi.NewType("bool", "bool", nil) + if err != nil { + return inactivity.ClaimHash{}, err + } + + bytes, err := abi.Arguments{ + {Type: uint256Type}, + {Type: uint256Type}, + {Type: bytesType}, + {Type: uint256SliceType}, + {Type: boolType}, + }.Pack( + chainID, + nonce, + walletPublicKey, + inactiveMembersIndexes, + heartbeatFailed, + ) + if err != nil { + return inactivity.ClaimHash{}, err + } + + return inactivity.ClaimHash(crypto.Keccak256Hash(bytes)), nil +} + +func (tc *TbtcChain) GetInactivityClaimNonce( + walletID [32]byte, +) (*big.Int, error) { + nonce, err := tc.walletRegistry.InactivityClaimNonce(walletID) + if err != nil { + return nil, fmt.Errorf( + "failed to get inactivity claim nonce: [%w]", + err, + ) + } + + return nonce, nil +} + func (tc *TbtcChain) PastDepositRevealedEvents( filter *tbtc.DepositRevealedEventFilter, ) ([]*tbtc.DepositRevealedEvent, error) { diff --git a/pkg/chain/ethereum/tbtc_test.go b/pkg/chain/ethereum/tbtc_test.go index 950dd4e5dc..996e06a199 100644 --- a/pkg/chain/ethereum/tbtc_test.go +++ b/pkg/chain/ethereum/tbtc_test.go @@ -240,6 +240,45 @@ func TestCalculateDKGResultSignatureHash(t *testing.T) { ) } +func TestCalculateInactivityClaimHash(t *testing.T) { + chainID := big.NewInt(31337) + nonce := big.NewInt(3) + + walletPublicKey, err := hex.DecodeString( + "9a0544440cc47779235ccb76d669590c2cd20c7e431f97e17a1093faf03291c473e" + + "661a208a8a565ca1e384059bd2ff7ff6886df081ff1229250099d388c83df", + ) + if err != nil { + t.Fatal(err) + } + + inactiveMembersIndexes := []*big.Int{ + big.NewInt(1), big.NewInt(2), big.NewInt(30), + } + + heartbeatFailed := true + + hash, err := calculateInactivityClaimHash( + chainID, + nonce, + walletPublicKey, + inactiveMembersIndexes, + heartbeatFailed, + ) + if err != nil { + t.Fatal(err) + } + + expectedHash := "f3210008cba186e90386a1bd0c63b6f29a67666f632350be22ce63ab39fc506e" + + testutils.AssertStringsEqual( + t, + "hash", + expectedHash, + hex.EncodeToString(hash[:]), + ) +} + func TestParseDkgResultValidationOutcome(t *testing.T) { isValid, err := parseDkgResultValidationOutcome( &struct { diff --git a/pkg/protocol/inactivity/gen/pb/message.pb.go b/pkg/protocol/inactivity/gen/pb/message.pb.go new file mode 100644 index 0000000000..2990d8e0e5 --- /dev/null +++ b/pkg/protocol/inactivity/gen/pb/message.pb.go @@ -0,0 +1,184 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.28.0 +// protoc v3.19.4 +// source: pkg/protocol/inactivity/gen/pb/message.proto + +package pb + +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 ClaimSignatureMessage struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + SenderID uint32 `protobuf:"varint,1,opt,name=senderID,proto3" json:"senderID,omitempty"` + ClaimHash []byte `protobuf:"bytes,2,opt,name=claimHash,proto3" json:"claimHash,omitempty"` + Signature []byte `protobuf:"bytes,3,opt,name=signature,proto3" json:"signature,omitempty"` + PublicKey []byte `protobuf:"bytes,4,opt,name=publicKey,proto3" json:"publicKey,omitempty"` + SessionID string `protobuf:"bytes,5,opt,name=sessionID,proto3" json:"sessionID,omitempty"` +} + +func (x *ClaimSignatureMessage) Reset() { + *x = ClaimSignatureMessage{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_protocol_inactivity_gen_pb_message_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ClaimSignatureMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClaimSignatureMessage) ProtoMessage() {} + +func (x *ClaimSignatureMessage) ProtoReflect() protoreflect.Message { + mi := &file_pkg_protocol_inactivity_gen_pb_message_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 ClaimSignatureMessage.ProtoReflect.Descriptor instead. +func (*ClaimSignatureMessage) Descriptor() ([]byte, []int) { + return file_pkg_protocol_inactivity_gen_pb_message_proto_rawDescGZIP(), []int{0} +} + +func (x *ClaimSignatureMessage) GetSenderID() uint32 { + if x != nil { + return x.SenderID + } + return 0 +} + +func (x *ClaimSignatureMessage) GetClaimHash() []byte { + if x != nil { + return x.ClaimHash + } + return nil +} + +func (x *ClaimSignatureMessage) GetSignature() []byte { + if x != nil { + return x.Signature + } + return nil +} + +func (x *ClaimSignatureMessage) GetPublicKey() []byte { + if x != nil { + return x.PublicKey + } + return nil +} + +func (x *ClaimSignatureMessage) GetSessionID() string { + if x != nil { + return x.SessionID + } + return "" +} + +var File_pkg_protocol_inactivity_gen_pb_message_proto protoreflect.FileDescriptor + +var file_pkg_protocol_inactivity_gen_pb_message_proto_rawDesc = []byte{ + 0x0a, 0x2c, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x2f, 0x69, + 0x6e, 0x61, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x70, 0x62, + 0x2f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0a, + 0x69, 0x6e, 0x61, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x22, 0xab, 0x01, 0x0a, 0x15, 0x43, + 0x6c, 0x61, 0x69, 0x6d, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x4d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x49, 0x44, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x49, 0x44, + 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x48, 0x61, 0x73, 0x68, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x09, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x48, 0x61, 0x73, 0x68, 0x12, 0x1c, + 0x0a, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, 0x1c, 0x0a, 0x09, + 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, + 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, + 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x42, 0x06, 0x5a, 0x04, 0x2e, 0x2f, 0x70, 0x62, + 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_pkg_protocol_inactivity_gen_pb_message_proto_rawDescOnce sync.Once + file_pkg_protocol_inactivity_gen_pb_message_proto_rawDescData = file_pkg_protocol_inactivity_gen_pb_message_proto_rawDesc +) + +func file_pkg_protocol_inactivity_gen_pb_message_proto_rawDescGZIP() []byte { + file_pkg_protocol_inactivity_gen_pb_message_proto_rawDescOnce.Do(func() { + file_pkg_protocol_inactivity_gen_pb_message_proto_rawDescData = protoimpl.X.CompressGZIP(file_pkg_protocol_inactivity_gen_pb_message_proto_rawDescData) + }) + return file_pkg_protocol_inactivity_gen_pb_message_proto_rawDescData +} + +var file_pkg_protocol_inactivity_gen_pb_message_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_pkg_protocol_inactivity_gen_pb_message_proto_goTypes = []interface{}{ + (*ClaimSignatureMessage)(nil), // 0: inactivity.ClaimSignatureMessage +} +var file_pkg_protocol_inactivity_gen_pb_message_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_pkg_protocol_inactivity_gen_pb_message_proto_init() } +func file_pkg_protocol_inactivity_gen_pb_message_proto_init() { + if File_pkg_protocol_inactivity_gen_pb_message_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_pkg_protocol_inactivity_gen_pb_message_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ClaimSignatureMessage); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_pkg_protocol_inactivity_gen_pb_message_proto_rawDesc, + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pkg_protocol_inactivity_gen_pb_message_proto_goTypes, + DependencyIndexes: file_pkg_protocol_inactivity_gen_pb_message_proto_depIdxs, + MessageInfos: file_pkg_protocol_inactivity_gen_pb_message_proto_msgTypes, + }.Build() + File_pkg_protocol_inactivity_gen_pb_message_proto = out.File + file_pkg_protocol_inactivity_gen_pb_message_proto_rawDesc = nil + file_pkg_protocol_inactivity_gen_pb_message_proto_goTypes = nil + file_pkg_protocol_inactivity_gen_pb_message_proto_depIdxs = nil +} diff --git a/pkg/protocol/inactivity/gen/pb/message.proto b/pkg/protocol/inactivity/gen/pb/message.proto new file mode 100644 index 0000000000..e0e768a356 --- /dev/null +++ b/pkg/protocol/inactivity/gen/pb/message.proto @@ -0,0 +1,12 @@ +syntax = "proto3"; + +option go_package = "./pb"; +package inactivity; + +message ClaimSignatureMessage { + uint32 senderID = 1; + bytes claimHash = 2; + bytes signature = 3; + bytes publicKey = 4; + string sessionID = 5; +} \ No newline at end of file diff --git a/pkg/protocol/inactivity/inactivity.go b/pkg/protocol/inactivity/inactivity.go new file mode 100644 index 0000000000..97ab07b00c --- /dev/null +++ b/pkg/protocol/inactivity/inactivity.go @@ -0,0 +1,151 @@ +package inactivity + +import ( + "context" + "crypto/ecdsa" + "fmt" + "math/big" + "sort" + + "github.com/ipfs/go-log/v2" + + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" + "github.com/keep-network/keep-core/pkg/protocol/state" +) + +// ClaimPreimage represents an inactivity claim preimage. +type ClaimPreimage struct { + Nonce *big.Int + WalletPublicKey *ecdsa.PublicKey + InactiveMembersIndexes []group.MemberIndex + HeartbeatFailed bool +} + +func NewClaimPreimage( + nonce *big.Int, + walletPublicKey *ecdsa.PublicKey, + inactiveMembersIndexes []group.MemberIndex, + heartbeatFailed bool, +) *ClaimPreimage { + // Made the inactive member indexes unique as expected by the on-chain + // contract. + indexesCache := make(map[group.MemberIndex]bool) + uniqueIndexes := []group.MemberIndex{} + + for _, index := range inactiveMembersIndexes { + if _, exists := indexesCache[index]; !exists { + indexesCache[index] = true + uniqueIndexes = append(uniqueIndexes, index) + } + } + + // Sort the inactive member indexes as expected by the on-chain contract. + sort.Slice(uniqueIndexes, func(i, j int) bool { + return uniqueIndexes[i] < uniqueIndexes[j] + }) + + return &ClaimPreimage{ + Nonce: nonce, + WalletPublicKey: walletPublicKey, + InactiveMembersIndexes: uniqueIndexes, + HeartbeatFailed: heartbeatFailed, + } +} + +const ClaimHashByteSize = 32 + +// ClaimHash is a hash of the inactivity claim. The hashing algorithm used +// depends on the client code. +type ClaimHash [ClaimHashByteSize]byte + +// ClaimHashFromBytes converts bytes slice to ClaimHash. It requires provided +// bytes slice size to be exactly ClaimHashByteSize. +func ClaimHashFromBytes(bytes []byte) (ClaimHash, error) { + var hash ClaimHash + + if len(bytes) != ClaimHashByteSize { + return hash, fmt.Errorf( + "bytes length is not equal %v", ClaimHashByteSize, + ) + } + copy(hash[:], bytes) + + return hash, nil +} + +// SignedClaimHash represents information pertaining to the process of signing +// an inactivity claim: the public key used during signing, the resulting +// signature and the hash of the inactivity claim that was used during signing. +type SignedClaimHash struct { + PublicKey []byte + Signature []byte + ClaimHash ClaimHash +} + +type ClaimSigner interface { + SignClaim(claim *ClaimPreimage) (*SignedClaimHash, error) + VerifySignature(signedClaim *SignedClaimHash) (bool, error) +} + +type ClaimSubmitter interface { + SubmitClaim( + ctx context.Context, + memberIndex group.MemberIndex, + claim *ClaimPreimage, + signatures map[group.MemberIndex][]byte, + ) error +} + +func PublishClaim( + ctx context.Context, + logger log.StandardLogger, + sessionID string, + memberIndex group.MemberIndex, + channel net.BroadcastChannel, + groupSize int, + dishonestThreshold int, + membershipValidator *group.MembershipValidator, + claimSigner ClaimSigner, + claimSubmitter ClaimSubmitter, + claim *ClaimPreimage, +) error { + initialState := &claimSigningState{ + BaseAsyncState: state.NewBaseAsyncState(), + channel: channel, + claimSigner: claimSigner, + claimSubmitter: claimSubmitter, + member: newSigningMember( + logger, + memberIndex, + groupSize, + dishonestThreshold, + membershipValidator, + sessionID, + ), + claim: claim, + } + + stateMachine := state.NewAsyncMachine(logger, ctx, channel, initialState) + + lastState, err := stateMachine.Execute() + if err != nil { + return err + } + + _, ok := lastState.(*claimSubmissionState) + if !ok { + return fmt.Errorf("execution ended on state %T", lastState) + } + + return nil +} + +// RegisterUnmarshallers initializes the given broadcast channel to be able to +// perform inactivity claim interactions by registering all the required +// protocol message unmarshallers. +func RegisterUnmarshallers(channel net.BroadcastChannel) { + channel.SetUnmarshaler(func() net.TaggedUnmarshaler { + return &claimSignatureMessage{} + }) +} diff --git a/pkg/protocol/inactivity/marshalling.go b/pkg/protocol/inactivity/marshalling.go new file mode 100644 index 0000000000..f117f015a4 --- /dev/null +++ b/pkg/protocol/inactivity/marshalling.go @@ -0,0 +1,57 @@ +package inactivity + +import ( + "fmt" + + "google.golang.org/protobuf/proto" + + "github.com/keep-network/keep-core/pkg/protocol/group" + "github.com/keep-network/keep-core/pkg/protocol/inactivity/gen/pb" +) + +func validateMemberIndex(protoIndex uint32) error { + // Protobuf does not have uint8 type, so we are using uint32. When + // unmarshalling message, we need to make sure we do not overflow. + if protoIndex > group.MaxMemberIndex { + return fmt.Errorf("invalid member index value: [%v]", protoIndex) + } + return nil +} + +// Marshal converts this claimSignatureMessage to a byte array suitable +// for network communication. +func (csm *claimSignatureMessage) Marshal() ([]byte, error) { + return proto.Marshal(&pb.ClaimSignatureMessage{ + SenderID: uint32(csm.senderID), + ClaimHash: csm.claimHash[:], + Signature: csm.signature, + PublicKey: csm.publicKey, + SessionID: csm.sessionID, + }) +} + +// Unmarshal converts a byte array produced by Marshal to a +// claimSignatureMessage. +func (csm *claimSignatureMessage) Unmarshal(bytes []byte) error { + pbMsg := pb.ClaimSignatureMessage{} + if err := proto.Unmarshal(bytes, &pbMsg); err != nil { + return err + } + + if err := validateMemberIndex(pbMsg.SenderID); err != nil { + return err + } + csm.senderID = group.MemberIndex(pbMsg.SenderID) + + claimHash, err := ClaimHashFromBytes(pbMsg.ClaimHash) + if err != nil { + return err + } + csm.claimHash = claimHash + + csm.signature = pbMsg.Signature + csm.publicKey = pbMsg.PublicKey + csm.sessionID = pbMsg.SessionID + + return nil +} diff --git a/pkg/protocol/inactivity/marshalling_test.go b/pkg/protocol/inactivity/marshalling_test.go new file mode 100644 index 0000000000..5e0a2d771d --- /dev/null +++ b/pkg/protocol/inactivity/marshalling_test.go @@ -0,0 +1,65 @@ +package inactivity + +import ( + "reflect" + "testing" + + fuzz "github.com/google/gofuzz" + + "github.com/keep-network/keep-core/pkg/internal/pbutils" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func TestClaimSignatureMessage_MarshalingRoundtrip(t *testing.T) { + msg := &claimSignatureMessage{ + senderID: 123, + claimHash: [32]byte{0: 11, 10: 22, 31: 33}, + signature: []byte("signature"), + publicKey: []byte("pubkey"), + sessionID: "session-1", + } + unmarshaled := &claimSignatureMessage{} + + err := pbutils.RoundTrip(msg, unmarshaled) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(msg, unmarshaled) { + t.Fatalf("unexpected content of unmarshaled message") + } +} + +func TestFuzzClaimSignatureMessage_MarshalingRoundtrip(t *testing.T) { + for i := 0; i < 10; i++ { + var ( + senderID group.MemberIndex + claimHash ClaimHash + signature []byte + publicKey []byte + sessionID string + ) + + f := fuzz.New().NilChance(0.1).NumElements(0, 512) + + f.Fuzz(&senderID) + f.Fuzz(&claimHash) + f.Fuzz(&signature) + f.Fuzz(&publicKey) + f.Fuzz(&sessionID) + + message := &claimSignatureMessage{ + senderID: senderID, + claimHash: claimHash, + signature: signature, + publicKey: publicKey, + sessionID: sessionID, + } + + _ = pbutils.RoundTrip(message, &claimSignatureMessage{}) + } +} + +func TestFuzzClaimSignatureMessage_Unmarshaler(t *testing.T) { + pbutils.FuzzUnmarshaler(&claimSignatureMessage{}) +} diff --git a/pkg/protocol/inactivity/member.go b/pkg/protocol/inactivity/member.go new file mode 100644 index 0000000000..9343f48ece --- /dev/null +++ b/pkg/protocol/inactivity/member.go @@ -0,0 +1,182 @@ +package inactivity + +import ( + "context" + "fmt" + + "github.com/ipfs/go-log/v2" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +type signingMember struct { + logger log.StandardLogger + // Index of this group member. + memberIndex group.MemberIndex + // Group to which this member belongs. + group *group.Group + // Validator allowing to check public key and member index against + // group members. + membershipValidator *group.MembershipValidator + // Identifier of the particular operator inactivity notification session + // this member is part of. + sessionID string + // Hash of inactivity claim preferred by the current participant. + preferredInactivityClaimHash ClaimHash + // Signature over preferredInactivityClaimHash calculated by the member. + selfInactivityClaimSignature []byte +} + +// newSigningMember creates a new signingMember in the initial state. +func newSigningMember( + logger log.StandardLogger, + memberIndex group.MemberIndex, + groupSize int, + dishonestThreshold int, + membershipValidator *group.MembershipValidator, + sessionID string, +) *signingMember { + return &signingMember{ + logger: logger, + memberIndex: memberIndex, + group: group.NewGroup(dishonestThreshold, groupSize), + membershipValidator: membershipValidator, + sessionID: sessionID, + } +} + +// shouldAcceptMessage indicates whether the given member should accept +// a message from the given sender. +func (sm *signingMember) shouldAcceptMessage( + senderID group.MemberIndex, + senderPublicKey []byte, +) bool { + isMessageFromSelf := senderID == sm.memberIndex + isSenderValid := sm.membershipValidator.IsValidMembership( + senderID, + senderPublicKey, + ) + isSenderAccepted := sm.group.IsOperating(senderID) + + return !isMessageFromSelf && isSenderValid && isSenderAccepted +} + +// initializeSubmittingMember performs a transition of a member state to the +// next phase of the protocol. +func (sm *signingMember) initializeSubmittingMember() *submittingMember { + return &submittingMember{ + signingMember: sm, + } +} + +func (sm *signingMember) signClaim( + claim *ClaimPreimage, + claimSigner ClaimSigner, +) (*claimSignatureMessage, error) { + signedClaim, err := claimSigner.SignClaim(claim) + if err != nil { + return nil, fmt.Errorf("failed to sign inactivity claim [%v]", err) + } + + // Register self signature and claim hash. + sm.selfInactivityClaimSignature = signedClaim.Signature + sm.preferredInactivityClaimHash = signedClaim.ClaimHash + + return &claimSignatureMessage{ + senderID: sm.memberIndex, + claimHash: signedClaim.ClaimHash, + signature: signedClaim.Signature, + publicKey: signedClaim.PublicKey, + sessionID: sm.sessionID, + }, nil +} + +// verifyInactivityClaimSignatures verifies signatures received in messages from +// other group members. It collects signatures supporting only the same +// inactivity claim hash as the one preferred by the current member. Each member +// is allowed to broadcast only one signature over a preferred inactivity claim +// hash. The function assumes that the input messages list does not contain a +// message from self and that the public key presented in each message is the +// correct one. This key needs to be compared against the one used by network +// client earlier, before this function is called. +func (sm *signingMember) verifyInactivityClaimSignatures( + messages []*claimSignatureMessage, + resultSigner ClaimSigner, +) map[group.MemberIndex][]byte { + receivedValidClaimSignatures := make(map[group.MemberIndex][]byte) + + for _, message := range messages { + // Sender's preferred inactivity claim hash doesn't match current + // member's preferred inactivity claim hash. + if message.claimHash != sm.preferredInactivityClaimHash { + sm.logger.Infof( + "[member:%v] signature from sender [%d] supports "+ + "result different than preferred", + sm.memberIndex, + message.senderID, + ) + continue + } + + // Check if the signature is valid. + isValid, err := resultSigner.VerifySignature( + &SignedClaimHash{ + ClaimHash: message.claimHash, + Signature: message.signature, + PublicKey: message.publicKey, + }, + ) + if err != nil { + sm.logger.Infof( + "[member:%v] verification of signature "+ + "from sender [%d] failed: [%v]", + sm.memberIndex, + message.senderID, + err, + ) + continue + } + if !isValid { + sm.logger.Infof( + "[member:%v] sender [%d] provided invalid signature", + sm.memberIndex, + message.senderID, + ) + continue + } + + receivedValidClaimSignatures[message.senderID] = message.signature + } + + // Register member's self signature. + receivedValidClaimSignatures[sm.memberIndex] = sm.selfInactivityClaimSignature + + return receivedValidClaimSignatures +} + +// submittingMember represents a member submitting an inactivity claim to the +// blockchain along with signatures received from other group members supporting +// the claim. +type submittingMember struct { + *signingMember +} + +// submitClaim submits the inactivity claim along with the supporting signatures +// to the provided claim submitter. +func (sm *submittingMember) submitClaim( + ctx context.Context, + claim *ClaimPreimage, + signatures map[group.MemberIndex][]byte, + claimSubmitter ClaimSubmitter, +) error { + if err := claimSubmitter.SubmitClaim( + ctx, + sm.memberIndex, + claim, + signatures, + ); err != nil { + return fmt.Errorf("failed to submit inactivity [%v]", err) + } + + return nil +} diff --git a/pkg/protocol/inactivity/member_test.go b/pkg/protocol/inactivity/member_test.go new file mode 100644 index 0000000000..d65f7d61ea --- /dev/null +++ b/pkg/protocol/inactivity/member_test.go @@ -0,0 +1,571 @@ +package inactivity + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "encoding/hex" + "fmt" + "math/big" + "reflect" + "testing" + + "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/chain/local_v1" + "github.com/keep-network/keep-core/pkg/operator" + "github.com/keep-network/keep-core/pkg/protocol/group" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +func TestShouldAcceptMessage(t *testing.T) { + groupSize := 5 + honestThreshold := 3 + + localChain := local_v1.Connect(groupSize, honestThreshold) + + operatorsAddresses := make([]chain.Address, groupSize) + operatorsPublicKeys := make([][]byte, groupSize) + for i := range operatorsAddresses { + _, operatorPublicKey, err := operator.GenerateKeyPair( + local_v1.DefaultCurve, + ) + if err != nil { + t.Fatal(err) + } + + operatorAddress, err := localChain.Signing().PublicKeyToAddress( + operatorPublicKey, + ) + if err != nil { + t.Fatal(err) + } + + operatorsAddresses[i] = operatorAddress + operatorsPublicKeys[i] = operator.MarshalUncompressed(operatorPublicKey) + } + + tests := map[string]struct { + senderIndex group.MemberIndex + senderPublicKey []byte + inactiveMembersIDs []group.MemberIndex + expectedResult bool + }{ + "message from another valid and operating member": { + senderIndex: group.MemberIndex(2), + senderPublicKey: operatorsPublicKeys[1], + inactiveMembersIDs: []group.MemberIndex{}, + expectedResult: true, + }, + "message from another valid but non-operating member": { + senderIndex: group.MemberIndex(2), + senderPublicKey: operatorsPublicKeys[1], + inactiveMembersIDs: []group.MemberIndex{2}, + expectedResult: false, + }, + "message from self": { + senderIndex: group.MemberIndex(1), + senderPublicKey: operatorsPublicKeys[0], + inactiveMembersIDs: []group.MemberIndex{}, + expectedResult: false, + }, + "message from another invalid member": { + senderIndex: group.MemberIndex(2), + senderPublicKey: operatorsPublicKeys[3], + inactiveMembersIDs: []group.MemberIndex{}, + expectedResult: false, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + membershipValdator := group.NewMembershipValidator( + &testutils.MockLogger{}, + operatorsAddresses, + localChain.Signing(), + ) + + member := newSigningMember( + &testutils.MockLogger{}, + group.MemberIndex(1), + groupSize, + groupSize-honestThreshold, + membershipValdator, + "session_1", + ) + + for _, inactiveMemberID := range test.inactiveMembersIDs { + member.group.MarkMemberAsInactive(inactiveMemberID) + } + + result := member.shouldAcceptMessage(test.senderIndex, test.senderPublicKey) + + testutils.AssertBoolsEqual( + t, + "result from message validator", + test.expectedResult, + result, + ) + }) + } +} + +func TestSignClaim(t *testing.T) { + signingMember := initializeSigningMember(t) + + walletPublicKeyHex, err := hex.DecodeString( + "0471e30bca60f6548d7b42582a478ea37ada63b402af7b3ddd57f0c95bb6843175" + + "aa0d2053a91a050a6797d85c38f2909cb7027f2344a01986aa2f9f8ca7a0c289", + ) + if err != nil { + t.Fatal(err) + } + + walletPublicKey := unmarshalPublicKey(walletPublicKeyHex) + + claim := NewClaimPreimage( + big.NewInt(3), + walletPublicKey, + []group.MemberIndex{1, 3}, + true, + ) + + publicKey := []byte("publicKey") + signature := []byte("signature") + claimHash := ClaimHash{0: 11, 6: 22, 31: 33} + sessionID := signingMember.sessionID + + claimSigner := newMockClaimSigner(publicKey) + claimSigner.setSigningOutcome(claim, &signingOutcome{ + signature: signature, + claimHash: claimHash, + err: nil, + }) + + actualSignatureMessage, err := signingMember.signClaim( + claim, + claimSigner, + ) + if err != nil { + t.Fatal(err) + } + + expectedSignatureMessage := &claimSignatureMessage{ + senderID: signingMember.memberIndex, + claimHash: claimHash, + signature: signature, + publicKey: publicKey, + sessionID: sessionID, + } + + if !reflect.DeepEqual( + expectedSignatureMessage, + actualSignatureMessage, + ) { + t.Errorf( + "unexpected signature message \nexpected: %v\nactual: %v\n", + expectedSignatureMessage, + actualSignatureMessage, + ) + } + + if !bytes.Equal(signature, signingMember.selfInactivityClaimSignature) { + t.Errorf( + "unexpected self inactivity claim signature\nexpected: %v\nactual: %v\n", + signature, + signingMember.selfInactivityClaimSignature, + ) + } + + if claimHash != signingMember.preferredInactivityClaimHash { + t.Errorf( + "unexpected preferred inactivity claim hash\nexpected: %v\nactual: %v\n", + claimHash, + signingMember.preferredInactivityClaimHash, + ) + } +} + +func TestSignClaim_ErrorDuringSigning(t *testing.T) { + signingMember := initializeSigningMember(t) + + walletPublicKeyHex, err := hex.DecodeString( + "0471e30bca60f6548d7b42582a478ea37ada63b402af7b3ddd57f0c95bb6843175" + + "aa0d2053a91a050a6797d85c38f2909cb7027f2344a01986aa2f9f8ca7a0c289", + ) + if err != nil { + t.Fatal(err) + } + + walletPublicKey := unmarshalPublicKey(walletPublicKeyHex) + + claim := NewClaimPreimage( + big.NewInt(3), + walletPublicKey, + []group.MemberIndex{1, 3}, + true, + ) + + claimSigner := newMockClaimSigner([]byte("publicKey")) + claimSigner.setSigningOutcome(claim, &signingOutcome{ + signature: []byte("signature"), + claimHash: ClaimHash{0: 11, 6: 22, 31: 33}, + err: fmt.Errorf("dummy error"), + }) + + _, err = signingMember.signClaim( + claim, + claimSigner, + ) + + expectedErr := fmt.Errorf("failed to sign inactivity claim [dummy error]") + if !reflect.DeepEqual(expectedErr, err) { + t.Errorf( + "unexpected error\nexpected: %v\nactual: %v\n", + expectedErr, + err, + ) + } +} + +func TestVerifyInactivityClaimSignatures(t *testing.T) { + signingMember := initializeSigningMember(t) + signingMember.preferredInactivityClaimHash = ClaimHash{11: 11} + signingMember.selfInactivityClaimSignature = []byte("sign 1") + + type messageWithOutcome struct { + message *claimSignatureMessage + outcome *verificationOutcome + } + + tests := map[string]struct { + messagesWithOutcomes []messageWithOutcome + expectedValidSignatures map[group.MemberIndex][]byte + }{ + "messages from other members with valid signatures for the preferred claim": { + messagesWithOutcomes: []messageWithOutcome{ + { + &claimSignatureMessage{ + senderID: 2, + claimHash: ClaimHash{11: 11}, + signature: []byte("sign 2"), + publicKey: []byte("pubKey 2"), + sessionID: "session-1", + }, + &verificationOutcome{ + isValid: true, + err: nil, + }, + }, + { + &claimSignatureMessage{ + senderID: 3, + claimHash: ClaimHash{11: 11}, + signature: []byte("sign 3"), + publicKey: []byte("pubKey 3"), + sessionID: "session-1", + }, + &verificationOutcome{ + isValid: true, + err: nil, + }, + }, + }, + expectedValidSignatures: map[group.MemberIndex][]byte{ + signingMember.memberIndex: signingMember.selfInactivityClaimSignature, + 2: []byte("sign 2"), + 3: []byte("sign 3"), + }, + }, + "received a message from other member with signature for claim " + + "different than preferred": { + messagesWithOutcomes: []messageWithOutcome{ + { + &claimSignatureMessage{ + senderID: 2, + claimHash: ClaimHash{12: 12}, + signature: []byte("sign 2"), + publicKey: []byte("pubKey 2"), + sessionID: "session-1", + }, + &verificationOutcome{ + isValid: true, + err: nil, + }, + }, + }, + expectedValidSignatures: map[group.MemberIndex][]byte{ + signingMember.memberIndex: signingMember.selfInactivityClaimSignature, + }, + }, + "message from other member that causes an error during signature " + + "verification": { + messagesWithOutcomes: []messageWithOutcome{ + { + &claimSignatureMessage{ + senderID: 2, + claimHash: ClaimHash{11: 11}, + signature: []byte("sign 2"), + publicKey: []byte("pubKey 2"), + sessionID: "session-1", + }, + &verificationOutcome{ + isValid: false, + err: fmt.Errorf("dummy error"), + }, + }, + }, + expectedValidSignatures: map[group.MemberIndex][]byte{ + signingMember.memberIndex: signingMember.selfInactivityClaimSignature, + }, + }, + "message from other member with invalid signature": { + messagesWithOutcomes: []messageWithOutcome{ + { + &claimSignatureMessage{ + senderID: 2, + claimHash: ClaimHash{11: 11}, + signature: []byte("bad sign"), + publicKey: []byte("pubKey 2"), + sessionID: "session-1", + }, + &verificationOutcome{ + isValid: false, + err: nil, + }, + }, + }, + expectedValidSignatures: map[group.MemberIndex][]byte{ + signingMember.memberIndex: signingMember.selfInactivityClaimSignature, + }, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + claimSigner := newMockClaimSigner([]byte("publicKey")) + + var messages []*claimSignatureMessage + for _, messageWithOutcome := range test.messagesWithOutcomes { + messages = append(messages, messageWithOutcome.message) + claimSigner.setVerificationOutcome( + messageWithOutcome.message, + messageWithOutcome.outcome, + ) + } + + validSignatures := signingMember.verifyInactivityClaimSignatures( + messages, + claimSigner, + ) + if !reflect.DeepEqual(validSignatures, test.expectedValidSignatures) { + t.Errorf( + "unexpected valid signatures\nexpected: %v\nactual: %v\n", + test.expectedValidSignatures, + validSignatures, + ) + } + }) + } +} + +func TestSubmitClaim(t *testing.T) { + submittingMember := initializeSubmittingMember(t) + + claim := &ClaimPreimage{} + signatures := map[group.MemberIndex][]byte{ + 11: []byte("signature 11"), + 22: []byte("signature 22"), + 33: []byte("signature 33"), + } + + claimSubmitter := newMockClaimSubmitter() + claimSubmitter.setSubmittingOutcome(claim, nil) + + ctx, cancelCtx := context.WithCancel(context.Background()) + defer cancelCtx() + + err := submittingMember.submitClaim( + ctx, + claim, + signatures, + claimSubmitter, + ) + if err != nil { + t.Fatal(err) + } +} + +func initializeSigningMember(t *testing.T) *signingMember { + groupSize := 5 + honestThreshold := 3 + + localChain := local_v1.Connect(groupSize, honestThreshold) + + operatorsAddresses := make([]chain.Address, groupSize) + operatorsPublicKeys := make([][]byte, groupSize) + for i := range operatorsAddresses { + _, operatorPublicKey, err := operator.GenerateKeyPair( + local_v1.DefaultCurve, + ) + if err != nil { + t.Fatal(err) + } + + operatorAddress, err := localChain.Signing().PublicKeyToAddress( + operatorPublicKey, + ) + if err != nil { + t.Fatal(err) + } + + operatorsAddresses[i] = operatorAddress + operatorsPublicKeys[i] = operator.MarshalUncompressed(operatorPublicKey) + } + + membershipValidator := group.NewMembershipValidator( + &testutils.MockLogger{}, + operatorsAddresses, + localChain.Signing(), + ) + + return newSigningMember( + &testutils.MockLogger{}, + group.MemberIndex(1), + groupSize, + groupSize-honestThreshold, + membershipValidator, + "session_1", + ) +} + +func initializeSubmittingMember(t *testing.T) *submittingMember { + signingMember := initializeSigningMember(t) + return signingMember.initializeSubmittingMember() +} + +type signingOutcome struct { + signature []byte + claimHash ClaimHash + err error +} + +type verificationOutcome struct { + isValid bool + err error +} + +type mockClaimSigner struct { + publicKey []byte + signingOutcomes map[*ClaimPreimage]*signingOutcome + verificationOutcomes map[string]*verificationOutcome +} + +func newMockClaimSigner(publicKey []byte) *mockClaimSigner { + return &mockClaimSigner{ + publicKey: publicKey, + signingOutcomes: make(map[*ClaimPreimage]*signingOutcome), + verificationOutcomes: make(map[string]*verificationOutcome), + } +} + +func (mrs *mockClaimSigner) setSigningOutcome( + claim *ClaimPreimage, + outcome *signingOutcome, +) { + mrs.signingOutcomes[claim] = outcome +} + +func (mrs *mockClaimSigner) setVerificationOutcome( + message *claimSignatureMessage, + outcome *verificationOutcome, +) { + key := signatureVerificationKey( + message.publicKey, + message.signature, + message.claimHash, + ) + mrs.verificationOutcomes[key] = outcome +} + +func (mrs *mockClaimSigner) SignClaim(claim *ClaimPreimage) (*SignedClaimHash, error) { + if outcome, ok := mrs.signingOutcomes[claim]; ok { + return &SignedClaimHash{ + PublicKey: mrs.publicKey, + Signature: outcome.signature, + ClaimHash: outcome.claimHash, + }, outcome.err + } + + return nil, fmt.Errorf( + "could not find singing outcome for the inactivity claim", + ) +} + +func (mrs *mockClaimSigner) VerifySignature(signedClaimHash *SignedClaimHash) (bool, error) { + key := signatureVerificationKey( + signedClaimHash.PublicKey, + signedClaimHash.Signature, + signedClaimHash.ClaimHash, + ) + if outcome, ok := mrs.verificationOutcomes[key]; ok { + return outcome.isValid, outcome.err + } + + return false, fmt.Errorf( + "could not find signature verification outcome for the signed claim", + ) +} + +func signatureVerificationKey( + publicKey []byte, + signature []byte, + claimHash ClaimHash, +) string { + return fmt.Sprintf("%s-%s-%s", publicKey, signature, claimHash[:]) +} + +type mockClaimSubmitter struct { + submittingOutcomes map[*ClaimPreimage]error +} + +func newMockClaimSubmitter() *mockClaimSubmitter { + return &mockClaimSubmitter{ + submittingOutcomes: make(map[*ClaimPreimage]error), + } +} + +func (mrs *mockClaimSubmitter) setSubmittingOutcome( + claim *ClaimPreimage, + err error, +) { + mrs.submittingOutcomes[claim] = err +} + +func (mrs *mockClaimSubmitter) SubmitClaim( + ctx context.Context, + memberIndex group.MemberIndex, + claim *ClaimPreimage, + signatures map[group.MemberIndex][]byte, +) error { + if err, ok := mrs.submittingOutcomes[claim]; ok { + return err + } + return fmt.Errorf( + "could not find submitting outcome for the claim", + ) +} + +func unmarshalPublicKey(bytes []byte) *ecdsa.PublicKey { + x, y := elliptic.Unmarshal( + tecdsa.Curve, + bytes, + ) + + return &ecdsa.PublicKey{ + Curve: tecdsa.Curve, + X: x, + Y: y, + } +} diff --git a/pkg/protocol/inactivity/message.go b/pkg/protocol/inactivity/message.go new file mode 100644 index 0000000000..693722251c --- /dev/null +++ b/pkg/protocol/inactivity/message.go @@ -0,0 +1,42 @@ +package inactivity + +import ( + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +const messageTypePrefix = "protocol_inactivity/" + +// message holds common traits of all inactivity protocol messages. +type message interface { + // SenderID returns protocol-level identifier of the message sender. + SenderID() group.MemberIndex + // SessionID returns the session identifier of the message. + SessionID() string + // Type returns the exact type of the message. + Type() string +} + +type claimSignatureMessage struct { + senderID group.MemberIndex + + claimHash ClaimHash + signature []byte + publicKey []byte + sessionID string +} + +// SenderID returns protocol-level identifier of the message sender. +func (csm *claimSignatureMessage) SenderID() group.MemberIndex { + return csm.senderID +} + +// SessionID returns the session identifier of the message. +func (csm *claimSignatureMessage) SessionID() string { + return csm.sessionID +} + +// Type returns a string describing an claimSignatureMessage type for +// marshaling purposes. +func (csm *claimSignatureMessage) Type() string { + return messageTypePrefix + "claim_signature_message" +} diff --git a/pkg/protocol/inactivity/states.go b/pkg/protocol/inactivity/states.go new file mode 100644 index 0000000000..d5d6a1059a --- /dev/null +++ b/pkg/protocol/inactivity/states.go @@ -0,0 +1,207 @@ +package inactivity + +import ( + "bytes" + "context" + "strconv" + + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" + "github.com/keep-network/keep-core/pkg/protocol/state" +) + +// claimSigningState is the state during which group members sign their +// preferred inactivity claim (by hashing their inactivity, and then signing the +// result), and share this over the broadcast channel. +type claimSigningState struct { + *state.BaseAsyncState + + channel net.BroadcastChannel + claimSigner ClaimSigner + claimSubmitter ClaimSubmitter + + member *signingMember + + claim *ClaimPreimage +} + +func (css *claimSigningState) Initiate(ctx context.Context) error { + message, err := css.member.signClaim(css.claim, css.claimSigner) + if err != nil { + return err + } + + if err := css.channel.Send( + ctx, + message, + net.BackoffRetransmissionStrategy, + ); err != nil { + return err + } + + return nil +} + +func (css *claimSigningState) Receive(netMessage net.Message) error { + // The network layer determines the message sender's public key based on + // the network client's pinned identity. The sender can not use any other + // public key than the one it is identified with in the network. + // Furthermore, the sender must possess the associated private key - each + // network message is signed with it. + // + // The network layer rejects any message with an incorrect signature or + // altered public key. By this point, we've conducted enough checks to + // be very certain that the sender' public key presented in the network + // net.Message is the correct one. + // + // In this final step, we compare the pinned network key with one used to + // produce a signature over the inactivity claim hash. If the keys don't + // match, it means that an incorrect key was used to sign inactivity claim + // hash and the message should be rejected. + isValidKeyUsed := func(signatureMessage *claimSignatureMessage) bool { + return bytes.Equal(signatureMessage.publicKey, netMessage.SenderPublicKey()) + } + + // As there is only one message type exchanged during result publication, + // we can simplify the code and cast directly to the concrete type + // `*resultSignatureMessage` instead of casting to the generic `message`. + if signatureMessage, ok := netMessage.Payload().(*claimSignatureMessage); ok { + if css.member.shouldAcceptMessage( + signatureMessage.SenderID(), + netMessage.SenderPublicKey(), + ) && isValidKeyUsed( + signatureMessage, + ) && css.member.sessionID == signatureMessage.sessionID { + css.ReceiveToHistory(netMessage) + } + } + + return nil +} + +func (css *claimSigningState) CanTransition() bool { + // Require the number of received signatures to be at least the honest + // threshold. Unlike in the case of DKG, we cannot expect all the members to + // participate in signing as we know we are dealing with some problem + // arising from operator inactivity. + messagingDone := len(receivedMessages[*claimSignatureMessage](css.BaseAsyncState)) >= + css.member.group.HonestThreshold()-1 + + return messagingDone +} + +func (css *claimSigningState) Next() (state.AsyncState, error) { + return &signaturesVerificationState{ + BaseAsyncState: css.BaseAsyncState, + channel: css.channel, + claimSigner: css.claimSigner, + claimSubmitter: css.claimSubmitter, + member: css.member, + claim: css.claim, + validSignatures: make(map[group.MemberIndex][]byte), + }, nil +} + +func (css *claimSigningState) MemberIndex() group.MemberIndex { + return css.member.memberIndex +} + +type signaturesVerificationState struct { + *state.BaseAsyncState + + channel net.BroadcastChannel + claimSigner ClaimSigner + claimSubmitter ClaimSubmitter + + member *signingMember + + claim *ClaimPreimage + + validSignatures map[group.MemberIndex][]byte +} + +func (svs *signaturesVerificationState) Initiate(ctx context.Context) error { + svs.validSignatures = svs.member.verifyInactivityClaimSignatures( + receivedMessages[*claimSignatureMessage](svs.BaseAsyncState), + svs.claimSigner, + ) + return nil +} + +func (svs *signaturesVerificationState) Receive(msg net.Message) error { + return nil +} + +func (svs *signaturesVerificationState) CanTransition() bool { + return true +} + +func (svs *signaturesVerificationState) Next() (state.AsyncState, error) { + return &claimSubmissionState{ + BaseAsyncState: svs.BaseAsyncState, + channel: svs.channel, + claimSubmitter: svs.claimSubmitter, + member: svs.member.initializeSubmittingMember(), + claim: svs.claim, + signatures: svs.validSignatures, + }, nil +} + +func (svs *signaturesVerificationState) MemberIndex() group.MemberIndex { + return svs.member.memberIndex +} + +type claimSubmissionState struct { + *state.BaseAsyncState + + channel net.BroadcastChannel + claimSubmitter ClaimSubmitter + + member *submittingMember + + claim *ClaimPreimage + signatures map[group.MemberIndex][]byte +} + +func (css *claimSubmissionState) Initiate(ctx context.Context) error { + return css.member.submitClaim( + ctx, + css.claim, + css.signatures, + css.claimSubmitter, + ) +} + +func (css *claimSubmissionState) Receive(msg net.Message) error { + return nil +} + +func (css *claimSubmissionState) CanTransition() bool { + return true +} + +func (css *claimSubmissionState) Next() (state.AsyncState, error) { + // returning nil represents this is the final state + return nil, nil +} + +func (css *claimSubmissionState) MemberIndex() group.MemberIndex { + return css.member.memberIndex +} + +// receivedMessages returns all messages of type T that have been received +// and validated so far. Returned messages are deduplicated so there is a +// guarantee that only one message of the given type is returned for the +// given sender. +func receivedMessages[T message](base *state.BaseAsyncState) []T { + var messageTemplate T + + payloads := state.ExtractMessagesPayloads[T](base, messageTemplate.Type()) + + return state.DeduplicateMessagesPayloads( + payloads, + func(message T) string { + return strconv.Itoa(int(message.SenderID())) + }, + ) +} diff --git a/pkg/protocol/inactivity/states_test.go b/pkg/protocol/inactivity/states_test.go new file mode 100644 index 0000000000..61d2d5c205 --- /dev/null +++ b/pkg/protocol/inactivity/states_test.go @@ -0,0 +1,70 @@ +package inactivity + +import ( + "reflect" + "testing" + + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/state" +) + +func TestReceivedMessages(t *testing.T) { + state := state.NewBaseAsyncState() + + message1 := &claimSignatureMessage{senderID: 1} + message2 := &claimSignatureMessage{senderID: 2} + message3 := &claimSignatureMessage{senderID: 1} + message4 := &claimSignatureMessage{senderID: 3} + message5 := &claimSignatureMessage{senderID: 3} + + state.ReceiveToHistory(newMockNetMessage(message1)) + state.ReceiveToHistory(newMockNetMessage(message2)) + state.ReceiveToHistory(newMockNetMessage(message3)) + state.ReceiveToHistory(newMockNetMessage(message4)) + state.ReceiveToHistory(newMockNetMessage(message5)) + + expectedMessages := []*claimSignatureMessage{message1, message2, message4} + actualType1Messages := receivedMessages[*claimSignatureMessage](state) + if !reflect.DeepEqual(expectedMessages, actualType1Messages) { + t.Errorf( + "unexpected messages\n"+ + "expected: [%v]\n"+ + "actual: [%v]", + expectedMessages, + actualType1Messages, + ) + } +} + +type mockNetMessage struct { + payload interface{} +} + +func newMockNetMessage(payload interface{}) *mockNetMessage { + return &mockNetMessage{payload} +} + +func (mnm *mockNetMessage) TransportSenderID() net.TransportIdentifier { + panic("not implemented") +} + +func (mnm *mockNetMessage) SenderPublicKey() []byte { + panic("not implemented") +} + +func (mnm *mockNetMessage) Payload() interface{} { + return mnm.payload +} + +func (mnm *mockNetMessage) Type() string { + payload, ok := mnm.payload.(message) + if !ok { + panic("wrong payload type") + } + + return payload.Type() +} + +func (mnm *mockNetMessage) Seqno() uint64 { + panic("not implemented") +} diff --git a/pkg/tbtc/chain.go b/pkg/tbtc/chain.go index 7563b76b11..1b579e326b 100644 --- a/pkg/tbtc/chain.go +++ b/pkg/tbtc/chain.go @@ -9,6 +9,7 @@ import ( "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/operator" "github.com/keep-network/keep-core/pkg/protocol/group" + "github.com/keep-network/keep-core/pkg/protocol/inactivity" "github.com/keep-network/keep-core/pkg/sortition" "github.com/keep-network/keep-core/pkg/subscription" "github.com/keep-network/keep-core/pkg/tecdsa/dkg" @@ -120,6 +121,59 @@ type DistributedKeyGenerationChain interface { DKGParameters() (*DKGParameters, error) } +// InactivityClaimedEvent represents an inactivity claimed event. It is emitted +// after a submitted inactivity claim lands on the chain. +type InactivityClaimedEvent struct { + WalletID [32]byte + Nonce *big.Int + Notifier chain.Address + BlockNumber uint64 +} + +// InactivityClaim represents an inactivity claim submitted to the chain. +type InactivityClaim struct { + WalletID [32]byte + InactiveMembersIndices []group.MemberIndex + HeartbeatFailed bool + Signatures []byte + SigningMembersIndices []group.MemberIndex +} + +type InactivityClaimChain interface { + // OnInactivityClaimed registers a callback that is invoked when an on-chain + // notification of the inactivity claim submission is seen. + OnInactivityClaimed( + func(event *InactivityClaimedEvent), + ) subscription.EventSubscription + + // AssembleInactivityClaim assembles the inactivity chain claim according to + // the rules expected by the given chain. + AssembleInactivityClaim( + walletID [32]byte, + inactiveMembersIndices []group.MemberIndex, + signatures map[group.MemberIndex][]byte, + heartbeatFailed bool, + ) (*InactivityClaim, error) + + // SubmitInactivityClaim submits the inactivity claim to the chain. + SubmitInactivityClaim( + claim *InactivityClaim, + nonce *big.Int, + groupMembers []uint32, + ) error + + // CalculateInactivityClaimHash calculates hash for the given inactivity + // claim. + CalculateInactivityClaimHash(claim *inactivity.ClaimPreimage) ( + inactivity.ClaimHash, + error, + ) + + // GetInactivityClaimNonce returns inactivity claim nonce for the given + // wallet. + GetInactivityClaimNonce(walletID [32]byte) (*big.Int, error) +} + // DKGChainResultHash represents a hash of the DKGChainResult. The algorithm // used is specific to the chain. type DKGChainResultHash [32]byte @@ -479,6 +533,7 @@ type Chain interface { sortition.Chain GroupSelectionChain DistributedKeyGenerationChain + InactivityClaimChain BridgeChain WalletProposalValidatorChain } diff --git a/pkg/tbtc/chain_test.go b/pkg/tbtc/chain_test.go index a344b8b2b4..6e293f23dc 100644 --- a/pkg/tbtc/chain_test.go +++ b/pkg/tbtc/chain_test.go @@ -22,11 +22,15 @@ import ( "github.com/keep-network/keep-core/pkg/chain/local_v1" "github.com/keep-network/keep-core/pkg/operator" "github.com/keep-network/keep-core/pkg/protocol/group" + "github.com/keep-network/keep-core/pkg/protocol/inactivity" "github.com/keep-network/keep-core/pkg/subscription" "github.com/keep-network/keep-core/pkg/tecdsa/dkg" ) -const localChainOperatorID = chain.OperatorID(1) +const ( + localChainOperatorID = chain.OperatorID(1) + stakingProvider = chain.Address("0x1111111111111111111111111111111111111111") +) type movingFundsParameters = struct { txMaxTotalFee uint64 @@ -54,6 +58,9 @@ type localChain struct { dkgResultChallengeHandlersMutex sync.Mutex dkgResultChallengeHandlers map[int]func(submission *DKGResultChallengedEvent) + inactivityClaimedHandlersMutex sync.Mutex + inactivityClaimedHandlers map[int]func(submission *InactivityClaimedEvent) + dkgMutex sync.Mutex dkgState DKGState dkgResult *DKGChainResult @@ -62,6 +69,9 @@ type localChain struct { walletsMutex sync.Mutex wallets map[[20]byte]*WalletChainData + inactivityNonceMutex sync.Mutex + inactivityNonces map[[32]byte]uint64 + blocksByTimestampMutex sync.Mutex blocksByTimestamp map[uint64]uint64 @@ -101,6 +111,9 @@ type localChain struct { movingFundsParametersMutex sync.Mutex movingFundsParameters movingFundsParameters + eligibleStakesMutex sync.Mutex + eligibleStakes map[chain.Address]*big.Int + blockCounter chain.BlockCounter operatorPrivateKey *operator.PrivateKey } @@ -178,11 +191,26 @@ func (lc *localChain) setBlockHashByNumber( } func (lc *localChain) OperatorToStakingProvider() (chain.Address, bool, error) { - panic("unsupported") + return stakingProvider, true, nil } func (lc *localChain) EligibleStake(stakingProvider chain.Address) (*big.Int, error) { - panic("unsupported") + lc.eligibleStakesMutex.Lock() + defer lc.eligibleStakesMutex.Unlock() + + eligibleStake, ok := lc.eligibleStakes[stakingProvider] + if !ok { + return nil, fmt.Errorf("eligible stake not found") + } + + return eligibleStake, nil +} + +func (lc *localChain) setOperatorsEligibleStake(stake *big.Int) { + lc.eligibleStakesMutex.Lock() + defer lc.eligibleStakesMutex.Unlock() + + lc.eligibleStakes[stakingProvider] = stake } func (lc *localChain) IsPoolLocked() (bool, error) { @@ -566,6 +594,109 @@ func (lc *localChain) DKGParameters() (*DKGParameters, error) { }, nil } +func (lc *localChain) OnInactivityClaimed( + handler func(event *InactivityClaimedEvent), +) subscription.EventSubscription { + lc.inactivityClaimedHandlersMutex.Lock() + defer lc.inactivityClaimedHandlersMutex.Unlock() + + handlerID := generateHandlerID() + lc.inactivityClaimedHandlers[handlerID] = handler + + return subscription.NewEventSubscription(func() { + lc.inactivityClaimedHandlersMutex.Lock() + defer lc.inactivityClaimedHandlersMutex.Unlock() + + delete(lc.inactivityClaimedHandlers, handlerID) + }) +} + +func (lc *localChain) AssembleInactivityClaim( + walletID [32]byte, + inactiveMembersIndices []group.MemberIndex, + signatures map[group.MemberIndex][]byte, + heartbeatFailed bool, +) ( + *InactivityClaim, + error, +) { + signingMembersIndexes := make([]group.MemberIndex, 0) + signaturesConcatenation := make([]byte, 0) + for memberIndex, signature := range signatures { + signingMembersIndexes = append(signingMembersIndexes, memberIndex) + signaturesConcatenation = append(signaturesConcatenation, signature...) + } + + return &InactivityClaim{ + WalletID: walletID, + InactiveMembersIndices: inactiveMembersIndices, + HeartbeatFailed: heartbeatFailed, + Signatures: signaturesConcatenation, + SigningMembersIndices: signingMembersIndexes, + }, nil +} + +func (lc *localChain) SubmitInactivityClaim( + claim *InactivityClaim, + nonce *big.Int, + groupMembers []uint32, +) error { + lc.inactivityClaimedHandlersMutex.Lock() + defer lc.inactivityClaimedHandlersMutex.Unlock() + + lc.inactivityNonceMutex.Lock() + defer lc.inactivityNonceMutex.Unlock() + + if nonce.Uint64() != lc.inactivityNonces[claim.WalletID] { + return fmt.Errorf("wrong inactivity claim nonce") + } + + blockNumber, err := lc.blockCounter.CurrentBlock() + if err != nil { + return fmt.Errorf("failed to get the current block") + } + + for _, handler := range lc.inactivityClaimedHandlers { + handler(&InactivityClaimedEvent{ + WalletID: claim.WalletID, + Nonce: nonce, + Notifier: "", + BlockNumber: blockNumber, + }) + } + + lc.inactivityNonces[claim.WalletID]++ + + return nil +} + +func (lc *localChain) CalculateInactivityClaimHash( + claim *inactivity.ClaimPreimage, +) (inactivity.ClaimHash, error) { + if claim.WalletPublicKey == nil { + return inactivity.ClaimHash{}, fmt.Errorf( + "wallet public key is nil", + ) + } + + encoded := fmt.Sprint( + claim.Nonce, + claim.WalletPublicKey, + claim.InactiveMembersIndexes, + claim.HeartbeatFailed, + ) + + return sha3.Sum256([]byte(encoded)), nil +} + +func (lc *localChain) GetInactivityClaimNonce(walletID [32]byte) (*big.Int, error) { + lc.inactivityNonceMutex.Lock() + defer lc.inactivityNonceMutex.Unlock() + + nonce := lc.inactivityNonces[walletID] + return big.NewInt(int64(nonce)), nil +} + func (lc *localChain) PastDepositRevealedEvents( filter *DepositRevealedEventFilter, ) ([]*DepositRevealedEvent, error) { @@ -1271,7 +1402,11 @@ func ConnectWithKey( dkgResultChallengeHandlers: make( map[int]func(submission *DKGResultChallengedEvent), ), + inactivityClaimedHandlers: make( + map[int]func(submission *InactivityClaimedEvent), + ), wallets: make(map[[20]byte]*WalletChainData), + inactivityNonces: make(map[[32]byte]uint64), blocksByTimestamp: make(map[uint64]uint64), blocksHashesByNumber: make(map[uint64][32]byte), pastDepositRevealedEvents: make(map[[32]byte][]*DepositRevealedEvent), @@ -1283,6 +1418,7 @@ func ConnectWithKey( movedFundsSweepProposalValidations: make(map[[32]byte]bool), heartbeatProposalValidations: make(map[[16]byte]bool), depositRequests: make(map[[32]byte]*DepositChainRequest), + eligibleStakes: make(map[chain.Address]*big.Int), blockCounter: blockCounter, operatorPrivateKey: operatorPrivateKey, } diff --git a/pkg/tbtc/heartbeat.go b/pkg/tbtc/heartbeat.go index 5c4141966b..770d055879 100644 --- a/pkg/tbtc/heartbeat.go +++ b/pkg/tbtc/heartbeat.go @@ -2,27 +2,43 @@ package tbtc import ( "context" + "encoding/hex" "fmt" + "math/big" + "sync" + "github.com/ipfs/go-log/v2" "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/protocol/group" "github.com/keep-network/keep-core/pkg/tecdsa" - "math/big" ) const ( - // heartbeatProposalValidityBlocks determines the wallet heartbeat proposal - // validity time expressed in blocks. In other words, this is the worst-case - // time for a wallet heartbeat during which the wallet is busy and cannot - // take another actions. The value of 300 blocks is roughly 1 hour, assuming - // 12 seconds per block. - heartbeatProposalValidityBlocks = 300 - // heartbeatRequestTimeoutSafetyMarginBlocks determines the duration of the - // safety margin that must be preserved between the signing timeout - // and the timeout of the entire heartbeat action. This safety + // heartbeatTotalProposalValidityBlocks determines the total wallet + // heartbeat proposal validity time expressed in blocks. In other words, + // this is the worst-case time for a wallet heartbeat during which the + // wallet is busy and cannot take another actions. It includes the total + // duration needed to perform both signing the heartbeat message and + // optionally notifying about operator inactivity if the heartbeat failed. + // The value of 600 blocks is roughly 2 hours, assuming 12 seconds per block. + heartbeatTotalProposalValidityBlocks = 600 + // heartbeatInactivityClaimValidityBlocks determines the duration that needs + // to be preserved for the optional notification about operator inactivity + // that follows a failed heartbeat signing. + heartbeatInactivityClaimValidityBlocks = 300 + // heartbeatTimeoutSafetyMarginBlocks determines the duration of the safety + // margin that must be preserved between the timeout of operator inactivity + // notification and the timeout of the entire heartbeat action. This safety // margin prevents against the case where signing completes too late and - // another action has been already requested by the coordinator. - // The value of 25 blocks is roughly 5 minutes, assuming 12 seconds per block. - heartbeatRequestTimeoutSafetyMarginBlocks = 25 + // another action has been already requested by the coordinator. The value + // of 25 blocks is roughly 5 minutes, assuming 12 seconds per block. + heartbeatTimeoutSafetyMarginBlocks = 25 + // heartbeatSigningMinimumActiveOperators determines the minimum number of + // active operators during signing for a heartbeat to be considered valid. + heartbeatSigningMinimumActiveOperators = 70 + // heartbeatConsecutiveFailuresThreshold determines the number of consecutive + // heartbeat failures required to trigger inactivity operator notification. + heartbeatConsecutiveFailureThreshold = 3 ) type HeartbeatProposal struct { @@ -34,7 +50,7 @@ func (hp *HeartbeatProposal) ActionType() WalletActionType { } func (hp *HeartbeatProposal) ValidityBlocks() uint64 { - return heartbeatProposalValidityBlocks + return heartbeatTotalProposalValidityBlocks } // heartbeatSigningExecutor is an interface meant to decouple the specific @@ -44,7 +60,19 @@ type heartbeatSigningExecutor interface { ctx context.Context, message *big.Int, startBlock uint64, - ) (*tecdsa.Signature, uint64, error) + ) (*tecdsa.Signature, uint32, uint64, error) +} + +// heartbeatInactivityClaimExecutor is an interface meant to decouple the +// specific implementation of the inactivity claim executor from the heartbeat +// action. +type heartbeatInactivityClaimExecutor interface { + claimInactivity( + ctx context.Context, + inactiveMembersIndexes []group.MemberIndex, + heartbeatFailed bool, + sessionID *big.Int, + ) error } // heartbeatAction is a walletAction implementation handling heartbeat requests @@ -56,7 +84,11 @@ type heartbeatAction struct { executingWallet wallet signingExecutor heartbeatSigningExecutor - proposal *HeartbeatProposal + proposal *HeartbeatProposal + failureCounter *heartbeatFailureCounter + + inactivityClaimExecutor heartbeatInactivityClaimExecutor + startBlock uint64 expiryBlock uint64 @@ -69,29 +101,54 @@ func newHeartbeatAction( executingWallet wallet, signingExecutor heartbeatSigningExecutor, proposal *HeartbeatProposal, + failureCounter *heartbeatFailureCounter, + inactivityClaimExecutor heartbeatInactivityClaimExecutor, startBlock uint64, expiryBlock uint64, waitForBlockFn waitForBlockFn, ) *heartbeatAction { return &heartbeatAction{ - logger: logger, - chain: chain, - executingWallet: executingWallet, - signingExecutor: signingExecutor, - proposal: proposal, - startBlock: startBlock, - expiryBlock: expiryBlock, - waitForBlockFn: waitForBlockFn, + logger: logger, + chain: chain, + executingWallet: executingWallet, + signingExecutor: signingExecutor, + proposal: proposal, + failureCounter: failureCounter, + inactivityClaimExecutor: inactivityClaimExecutor, + startBlock: startBlock, + expiryBlock: expiryBlock, + waitForBlockFn: waitForBlockFn, } } func (ha *heartbeatAction) execute() error { - // TODO: When implementing the moving funds action we should make sure - // heartbeats are not executed by unstaking clients. + // Do not execute the heartbeat action if the operator is unstaking. + isUnstaking, err := ha.isOperatorUnstaking() + if err != nil { + return fmt.Errorf( + "failed to check if the operator is unstaking [%v]", + err, + ) + } + + if isUnstaking { + ha.logger.Warn( + "quitting the heartbeat action without signing because the " + + "operator is unstaking", + ) + return nil + } + + walletPublicKey := ha.wallet().publicKey + walletPublicKeyHash := bitcoin.PublicKeyHash(walletPublicKey) + walletPublicKeyBytes, err := marshalPublicKey(walletPublicKey) + if err != nil { + return fmt.Errorf("failed to unmarshal wallet public key: [%v]", err) + } - walletPublicKeyHash := bitcoin.PublicKeyHash(ha.wallet().publicKey) + walletKey := hex.EncodeToString(walletPublicKeyBytes) - err := ha.chain.ValidateHeartbeatProposal(walletPublicKeyHash, ha.proposal) + err = ha.chain.ValidateHeartbeatProposal(walletPublicKeyHash, ha.proposal) if err != nil { return fmt.Errorf("heartbeat proposal is invalid: [%v]", err) } @@ -100,27 +157,87 @@ func (ha *heartbeatAction) execute() error { messageToSign := new(big.Int).SetBytes(messageBytes[:]) // Just in case. This should never happen. - if ha.expiryBlock < heartbeatRequestTimeoutSafetyMarginBlocks { + if ha.expiryBlock < heartbeatInactivityClaimValidityBlocks { return fmt.Errorf("invalid proposal expiry block") } - heartbeatCtx, cancelHeartbeatCtx := withCancelOnBlock( + heartbeatSigningCtx, cancelHeartbeatSigningCtx := withCancelOnBlock( context.Background(), - ha.expiryBlock-heartbeatRequestTimeoutSafetyMarginBlocks, + ha.expiryBlock-heartbeatInactivityClaimValidityBlocks, ha.waitForBlockFn, ) - defer cancelHeartbeatCtx() + defer cancelHeartbeatSigningCtx() - signature, _, err := ha.signingExecutor.sign(heartbeatCtx, messageToSign, ha.startBlock) - if err != nil { - return fmt.Errorf("cannot sign heartbeat message: [%v]", err) + signature, activeOperatorsCount, _, err := ha.signingExecutor.sign( + heartbeatSigningCtx, + messageToSign, + ha.startBlock, + ) + + // If there was no error and the number of active operators during signing + // was enough, we can consider the heartbeat procedure as successful. + if err == nil && activeOperatorsCount >= heartbeatSigningMinimumActiveOperators { + ha.logger.Infof( + "successfully generated signature [%s] for heartbeat message [0x%x]", + signature, + ha.proposal.Message[:], + ) + + // Reset the counter for consecutive heartbeat failure. + ha.failureCounter.reset(walletKey) + + return nil + } + + // If there was an error or the number of active operators during signing + // was not enough, we must consider the heartbeat procedure as a failure. + ha.logger.Warnf( + "heartbeat failed; [%d/%d] operators participated; the process "+ + "returned [%v] as error", + activeOperatorsCount, + heartbeatSigningMinimumActiveOperators, + err, + ) + + // Increment the heartbeat failure counter. + ha.failureCounter.increment(walletKey) + + // If the number of consecutive heartbeat failures does not exceed the + // threshold do not notify about operator inactivity. + if ha.failureCounter.get(walletKey) < heartbeatConsecutiveFailureThreshold { + ha.logger.Warnf( + "leaving without notifying about operator inactivity; current "+ + "heartbeat failure count is [%d]", + ha.failureCounter.get(walletKey), + ) + return nil } - logger.Infof( - "generated signature [%s] for heartbeat message [0x%x]", - signature, - ha.proposal.Message[:], + heartbeatInactivityCtx, cancelHeartbeatInactivityCtx := withCancelOnBlock( + context.Background(), + ha.expiryBlock-heartbeatTimeoutSafetyMarginBlocks, + ha.waitForBlockFn, + ) + defer cancelHeartbeatInactivityCtx() + + // The value of consecutive heartbeat failures exceeds the threshold. + // Proceed with operator inactivity notification. + err = ha.inactivityClaimExecutor.claimInactivity( + heartbeatInactivityCtx, + // Leave the list of inactive operators empty even if some operators + // were inactive during signing heartbeat. The inactive operators could + // simply be in the process of unstaking and therefore should not be + // punished. + []group.MemberIndex{}, + true, + messageToSign, ) + if err != nil { + return fmt.Errorf( + "error while notifying about operator inactivity [%v]]", + err, + ) + } return nil } @@ -132,3 +249,66 @@ func (ha *heartbeatAction) wallet() wallet { func (ha *heartbeatAction) actionType() WalletActionType { return ActionHeartbeat } + +func (ha *heartbeatAction) isOperatorUnstaking() (bool, error) { + stakingProvider, isRegistered, err := ha.chain.OperatorToStakingProvider() + if err != nil { + return false, fmt.Errorf( + "failed to get staking provider for operator [%v]", + err, + ) + } + + if !isRegistered { + return false, fmt.Errorf("staking provider not registered for operator") + } + + // Eligible stake is defined as the currently authorized stake minus the + // pending authorization decrease. + eligibleStake, err := ha.chain.EligibleStake(stakingProvider) + if err != nil { + return false, fmt.Errorf( + "failed to check eligible stake for operator [%v]", + err, + ) + } + + // The operator is considered unstaking if their eligible stake is `0`. + return eligibleStake.Cmp(big.NewInt(0)) == 0, nil +} + +// heartbeatFailureCounter holds counters keeping track of consecutive +// heartbeat failures. Each wallet has a separate counter. The key used in +// the map is the uncompressed public key (with 04 prefix) of the wallet. +type heartbeatFailureCounter struct { + mutex sync.Mutex + counters map[string]uint +} + +func newHeartbeatFailureCounter() *heartbeatFailureCounter { + return &heartbeatFailureCounter{ + counters: make(map[string]uint), + } +} + +func (hfc *heartbeatFailureCounter) increment(walletPublicKey string) { + hfc.mutex.Lock() + defer hfc.mutex.Unlock() + + hfc.counters[walletPublicKey]++ + +} + +func (hfc *heartbeatFailureCounter) reset(walletPublicKey string) { + hfc.mutex.Lock() + defer hfc.mutex.Unlock() + + hfc.counters[walletPublicKey] = 0 +} + +func (hfc *heartbeatFailureCounter) get(walletPublicKey string) uint { + hfc.mutex.Lock() + defer hfc.mutex.Unlock() + + return hfc.counters[walletPublicKey] +} diff --git a/pkg/tbtc/heartbeat_test.go b/pkg/tbtc/heartbeat_test.go index 32941e9b59..6946904201 100644 --- a/pkg/tbtc/heartbeat_test.go +++ b/pkg/tbtc/heartbeat_test.go @@ -2,12 +2,15 @@ package tbtc import ( "context" + "crypto/ecdsa" "encoding/hex" "fmt" - "github.com/keep-network/keep-core/internal/testutils" - "github.com/keep-network/keep-core/pkg/tecdsa" "math/big" "testing" + + "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/protocol/group" + "github.com/keep-network/keep-core/pkg/tecdsa" ) func TestHeartbeatAction_HappyPath(t *testing.T) { @@ -19,8 +22,10 @@ func TestHeartbeatAction_HappyPath(t *testing.T) { t.Fatal(err) } + walletPublicKeyStr := hex.EncodeToString(walletPublicKeyHex) + startBlock := uint64(10) - expiryBlock := startBlock + heartbeatProposalValidityBlocks + expiryBlock := startBlock + heartbeatTotalProposalValidityBlocks proposal := &HeartbeatProposal{ Message: [16]byte{ @@ -29,6 +34,11 @@ func TestHeartbeatAction_HappyPath(t *testing.T) { }, } + // Set the heartbeat failure counter to `1` for the given wallet. The value + // of the counter should be reset to `0` after executing the action. + heartbeatFailureCounter := newHeartbeatFailureCounter() + heartbeatFailureCounter.increment(walletPublicKeyStr) + // sha256(sha256(messageToSign)) sha256d, err := hex.DecodeString("38d30dacec5083c902952ce99fc0287659ad0b1ca2086827a8e78b0bef2c8bc1") if err != nil { @@ -36,9 +46,15 @@ func TestHeartbeatAction_HappyPath(t *testing.T) { } hostChain := Connect() + hostChain.setOperatorsEligibleStake(big.NewInt(100000)) hostChain.setHeartbeatProposalValidationResult(proposal, true) + // Set the active operators count to the minimum required value. mockExecutor := &mockHeartbeatSigningExecutor{} + mockExecutor.activeOperatorsCount = heartbeatSigningMinimumActiveOperators + + inactivityClaimExecutor := &mockInactivityClaimExecutor{} + action := newHeartbeatAction( logger, hostChain, @@ -47,6 +63,8 @@ func TestHeartbeatAction_HappyPath(t *testing.T) { }, mockExecutor, proposal, + heartbeatFailureCounter, + inactivityClaimExecutor, startBlock, expiryBlock, func(ctx context.Context, blockHeight uint64) error { @@ -59,6 +77,12 @@ func TestHeartbeatAction_HappyPath(t *testing.T) { t.Fatal(err) } + testutils.AssertUintsEqual( + t, + "heartbeat failure count", + 0, + uint64(heartbeatFailureCounter.get(walletPublicKeyStr)), + ) testutils.AssertBigIntsEqual( t, "message to sign", @@ -71,9 +95,15 @@ func TestHeartbeatAction_HappyPath(t *testing.T) { startBlock, mockExecutor.requestedStartBlock, ) + testutils.AssertBigIntsEqual( + t, + "inactivity claim executor session ID", + nil, // executor not called. + inactivityClaimExecutor.sessionID, + ) } -func TestHeartbeatAction_SigningError(t *testing.T) { +func TestHeartbeatAction_OperatorUnstaking(t *testing.T) { walletPublicKeyHex, err := hex.DecodeString( "0471e30bca60f6548d7b42582a478ea37ada63b402af7b3ddd57f0c95bb6843175" + "aa0d2053a91a050a6797d85c38f2909cb7027f2344a01986aa2f9f8ca7a0c289", @@ -83,7 +113,7 @@ func TestHeartbeatAction_SigningError(t *testing.T) { } startBlock := uint64(10) - expiryBlock := startBlock + heartbeatProposalValidityBlocks + expiryBlock := startBlock + heartbeatTotalProposalValidityBlocks proposal := &HeartbeatProposal{ Message: [16]byte{ @@ -92,11 +122,80 @@ func TestHeartbeatAction_SigningError(t *testing.T) { }, } + heartbeatFailureCounter := newHeartbeatFailureCounter() + hostChain := Connect() + hostChain.setOperatorsEligibleStake(big.NewInt(0)) + hostChain.setHeartbeatProposalValidationResult(proposal, true) + + // Set the active operators count to the minimum required value. + mockExecutor := &mockHeartbeatSigningExecutor{} + mockExecutor.activeOperatorsCount = heartbeatSigningMinimumActiveOperators + + inactivityClaimExecutor := &mockInactivityClaimExecutor{} + + action := newHeartbeatAction( + logger, + hostChain, + wallet{ + publicKey: unmarshalPublicKey(walletPublicKeyHex), + }, + mockExecutor, + proposal, + heartbeatFailureCounter, + inactivityClaimExecutor, + startBlock, + expiryBlock, + func(ctx context.Context, blockHeight uint64) error { + return nil + }, + ) + + err = action.execute() + if err != nil { + t.Fatal(err) + } + + testutils.AssertBigIntsEqual( + t, + "message to sign", + nil, // sign not called + mockExecutor.requestedMessage, + ) +} + +func TestHeartbeatAction_Failure_SigningError(t *testing.T) { + walletPublicKeyHex, err := hex.DecodeString( + "0471e30bca60f6548d7b42582a478ea37ada63b402af7b3ddd57f0c95bb6843175" + + "aa0d2053a91a050a6797d85c38f2909cb7027f2344a01986aa2f9f8ca7a0c289", + ) + if err != nil { + t.Fatal(err) + } + + walletPublicKeyStr := hex.EncodeToString(walletPublicKeyHex) + + startBlock := uint64(10) + expiryBlock := startBlock + heartbeatTotalProposalValidityBlocks + + proposal := &HeartbeatProposal{ + Message: [16]byte{ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + }, + } + + heartbeatFailureCounter := newHeartbeatFailureCounter() + + hostChain := Connect() + hostChain.setOperatorsEligibleStake(big.NewInt(100000)) hostChain.setHeartbeatProposalValidationResult(proposal, true) mockExecutor := &mockHeartbeatSigningExecutor{} mockExecutor.shouldFail = true + mockExecutor.activeOperatorsCount = heartbeatSigningMinimumActiveOperators + + inactivityClaimExecutor := &mockInactivityClaimExecutor{} action := newHeartbeatAction( logger, @@ -106,6 +205,240 @@ func TestHeartbeatAction_SigningError(t *testing.T) { }, mockExecutor, proposal, + heartbeatFailureCounter, + inactivityClaimExecutor, + startBlock, + expiryBlock, + func(ctx context.Context, blockHeight uint64) error { + return nil + }, + ) + + // Do not expect the execution to result in an error. Signing error does not + // mean the procedure failure. + err = action.execute() + if err != nil { + t.Fatal(err) + } + + testutils.AssertUintsEqual( + t, + "heartbeat failure count", + 1, + uint64(heartbeatFailureCounter.get(walletPublicKeyStr)), + ) + testutils.AssertBigIntsEqual( + t, + "inactivity claim executor session ID", + nil, // executor not called. + inactivityClaimExecutor.sessionID, + ) +} + +func TestHeartbeatAction_Failure_TooFewActiveOperators(t *testing.T) { + walletPublicKeyHex, err := hex.DecodeString( + "0471e30bca60f6548d7b42582a478ea37ada63b402af7b3ddd57f0c95bb6843175" + + "aa0d2053a91a050a6797d85c38f2909cb7027f2344a01986aa2f9f8ca7a0c289", + ) + if err != nil { + t.Fatal(err) + } + + walletPublicKeyStr := hex.EncodeToString(walletPublicKeyHex) + + startBlock := uint64(10) + expiryBlock := startBlock + heartbeatTotalProposalValidityBlocks + + proposal := &HeartbeatProposal{ + Message: [16]byte{ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + }, + } + + heartbeatFailureCounter := newHeartbeatFailureCounter() + + hostChain := Connect() + hostChain.setOperatorsEligibleStake(big.NewInt(100000)) + hostChain.setHeartbeatProposalValidationResult(proposal, true) + + // Set the active operators count just below the required number. + mockExecutor := &mockHeartbeatSigningExecutor{} + mockExecutor.activeOperatorsCount = heartbeatSigningMinimumActiveOperators - 1 + + inactivityClaimExecutor := &mockInactivityClaimExecutor{} + + action := newHeartbeatAction( + logger, + hostChain, + wallet{ + publicKey: unmarshalPublicKey(walletPublicKeyHex), + }, + mockExecutor, + proposal, + heartbeatFailureCounter, + inactivityClaimExecutor, + startBlock, + expiryBlock, + func(ctx context.Context, blockHeight uint64) error { + return nil + }, + ) + + // Do not expect the execution to result in an error. Signing error does not + // mean the procedure failure. + err = action.execute() + if err != nil { + t.Fatal(err) + } + + testutils.AssertUintsEqual( + t, + "heartbeat failure count", + 1, + uint64(heartbeatFailureCounter.get(walletPublicKeyStr)), + ) + testutils.AssertBigIntsEqual( + t, + "inactivity claim executor session ID", + nil, // executor not called. + inactivityClaimExecutor.sessionID, + ) +} + +func TestHeartbeatAction_Failure_CounterExceeded(t *testing.T) { + walletPublicKeyHex, err := hex.DecodeString( + "0471e30bca60f6548d7b42582a478ea37ada63b402af7b3ddd57f0c95bb6843175" + + "aa0d2053a91a050a6797d85c38f2909cb7027f2344a01986aa2f9f8ca7a0c289", + ) + if err != nil { + t.Fatal(err) + } + + walletPublicKeyStr := hex.EncodeToString(walletPublicKeyHex) + + startBlock := uint64(10) + expiryBlock := startBlock + heartbeatTotalProposalValidityBlocks + + proposal := &HeartbeatProposal{ + Message: [16]byte{ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + }, + } + + // sha256(sha256(messageToSign)) + sha256d, err := hex.DecodeString("38d30dacec5083c902952ce99fc0287659ad0b1ca2086827a8e78b0bef2c8bc1") + if err != nil { + t.Fatal(err) + } + + // Set the heartbeat failure counter to `2` so that the next failure will + // trigger operator inactivity claim execution. + heartbeatFailureCounter := newHeartbeatFailureCounter() + heartbeatFailureCounter.increment(walletPublicKeyStr) + heartbeatFailureCounter.increment(walletPublicKeyStr) + + hostChain := Connect() + hostChain.setOperatorsEligibleStake(big.NewInt(100000)) + hostChain.setHeartbeatProposalValidationResult(proposal, true) + + mockExecutor := &mockHeartbeatSigningExecutor{} + mockExecutor.shouldFail = true + + inactivityClaimExecutor := &mockInactivityClaimExecutor{} + + action := newHeartbeatAction( + logger, + hostChain, + wallet{ + publicKey: unmarshalPublicKey(walletPublicKeyHex), + }, + mockExecutor, + proposal, + heartbeatFailureCounter, + inactivityClaimExecutor, + startBlock, + expiryBlock, + func(ctx context.Context, blockHeight uint64) error { + return nil + }, + ) + + // Do not expect the execution to result in an error. Signing error does not + // mean the procedure failure. + err = action.execute() + if err != nil { + t.Fatal(err) + } + + testutils.AssertUintsEqual( + t, + "heartbeat failure count", + 3, + uint64(heartbeatFailureCounter.get(walletPublicKeyStr)), + ) + testutils.AssertBigIntsEqual( + t, + "inactivity claim executor session ID", + new(big.Int).SetBytes(sha256d), + inactivityClaimExecutor.sessionID, + ) +} + +func TestHeartbeatAction_Failure_InactivityExecutionFailure(t *testing.T) { + walletPublicKeyHex, err := hex.DecodeString( + "0471e30bca60f6548d7b42582a478ea37ada63b402af7b3ddd57f0c95bb6843175" + + "aa0d2053a91a050a6797d85c38f2909cb7027f2344a01986aa2f9f8ca7a0c289", + ) + if err != nil { + t.Fatal(err) + } + + walletPublicKeyStr := hex.EncodeToString(walletPublicKeyHex) + + startBlock := uint64(10) + expiryBlock := startBlock + heartbeatTotalProposalValidityBlocks + + proposal := &HeartbeatProposal{ + Message: [16]byte{ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + }, + } + + // sha256(sha256(messageToSign)) + sha256d, err := hex.DecodeString("38d30dacec5083c902952ce99fc0287659ad0b1ca2086827a8e78b0bef2c8bc1") + if err != nil { + t.Fatal(err) + } + + // Set the heartbeat failure counter to `2` so that the next failure will + // trigger operator inactivity claim execution. + heartbeatFailureCounter := newHeartbeatFailureCounter() + heartbeatFailureCounter.increment(walletPublicKeyStr) + heartbeatFailureCounter.increment(walletPublicKeyStr) + + hostChain := Connect() + hostChain.setOperatorsEligibleStake(big.NewInt(100000)) + hostChain.setHeartbeatProposalValidationResult(proposal, true) + + mockExecutor := &mockHeartbeatSigningExecutor{} + mockExecutor.shouldFail = true + + inactivityClaimExecutor := &mockInactivityClaimExecutor{} + inactivityClaimExecutor.shouldFail = true + + action := newHeartbeatAction( + logger, + hostChain, + wallet{ + publicKey: unmarshalPublicKey(walletPublicKeyHex), + }, + mockExecutor, + proposal, + heartbeatFailureCounter, + inactivityClaimExecutor, startBlock, expiryBlock, func(ctx context.Context, blockHeight uint64) error { @@ -120,13 +453,147 @@ func TestHeartbeatAction_SigningError(t *testing.T) { testutils.AssertStringsEqual( t, "error message", - "cannot sign heartbeat message: [oofta]", + "error while notifying about operator inactivity [mock inactivity "+ + "claim executor error]]", err.Error(), ) + + testutils.AssertUintsEqual( + t, + "heartbeat failure count", + 3, + uint64(heartbeatFailureCounter.get(walletPublicKeyStr)), + ) + testutils.AssertBigIntsEqual( + t, + "inactivity claim executor session ID", + new(big.Int).SetBytes(sha256d), + inactivityClaimExecutor.sessionID, + ) +} + +func TestHeartbeatFailureCounter_Increment(t *testing.T) { + walletPublicKey := createMockSigner(t).wallet.publicKey + walletPublicKeyBytes, err := marshalPublicKey(walletPublicKey) + if err != nil { + t.Fatal(t) + } + + heartbeatFailureCounter := newHeartbeatFailureCounter() + + counterKey := hex.EncodeToString(walletPublicKeyBytes) + + // Check first increment. + heartbeatFailureCounter.increment(counterKey) + count := heartbeatFailureCounter.get(counterKey) + testutils.AssertUintsEqual( + t, + "counter value", + 1, + uint64(count), + ) + + // Check second increment. + heartbeatFailureCounter.increment(counterKey) + count = heartbeatFailureCounter.get(counterKey) + testutils.AssertUintsEqual( + t, + "counter value", + 2, + uint64(count), + ) +} + +func TestHeartbeatFailureCounter_Reset(t *testing.T) { + walletPublicKey := createMockSigner(t).wallet.publicKey + walletPublicKeyBytes, err := marshalPublicKey(walletPublicKey) + if err != nil { + t.Fatal(t) + } + + heartbeatFailureCounter := newHeartbeatFailureCounter() + + counterKey := hex.EncodeToString(walletPublicKeyBytes) + + // Check reset works as the first operation. + heartbeatFailureCounter.reset(counterKey) + count := heartbeatFailureCounter.get(counterKey) + testutils.AssertUintsEqual( + t, + "counter value", + 0, + uint64(count), + ) + + // Check reset works after an increment. + heartbeatFailureCounter.increment(counterKey) + heartbeatFailureCounter.reset(counterKey) + + count = heartbeatFailureCounter.get(counterKey) + testutils.AssertUintsEqual( + t, + "counter value", + 0, + uint64(count), + ) +} + +func TestHeartbeatFailureCounter_Get(t *testing.T) { + walletPublicKey := createMockSigner(t).wallet.publicKey + walletPublicKeyBytes, err := marshalPublicKey(walletPublicKey) + if err != nil { + t.Fatal(t) + } + + heartbeatFailureCounter := newHeartbeatFailureCounter() + + counterKey := hex.EncodeToString(walletPublicKeyBytes) + + // Check get works as the first operation. + count := heartbeatFailureCounter.get(counterKey) + testutils.AssertUintsEqual( + t, + "counter value", + 0, + uint64(count), + ) + + // Check get works after an increment. + heartbeatFailureCounter.increment(counterKey) + count = heartbeatFailureCounter.get(counterKey) + testutils.AssertUintsEqual( + t, + "counter value", + 1, + uint64(count), + ) + + // Construct an arbitrary public key representing a different wallet. + x, y := walletPublicKey.Curve.Double(walletPublicKey.X, walletPublicKey.Y) + anotherWalletPublicKey := &ecdsa.PublicKey{ + Curve: walletPublicKey.Curve, + X: x, + Y: y, + } + anotherWalletPublicKeyBytes, err := marshalPublicKey(anotherWalletPublicKey) + if err != nil { + t.Fatal(t) + } + anotherCounterKey := hex.EncodeToString(anotherWalletPublicKeyBytes) + + // Check get works on another wallet. + count = heartbeatFailureCounter.get(anotherCounterKey) + testutils.AssertUintsEqual( + t, + "counter value", + 0, + uint64(count), + ) } type mockHeartbeatSigningExecutor struct { - shouldFail bool + shouldFail bool + activeOperatorsCount uint32 requestedMessage *big.Int requestedStartBlock uint64 @@ -136,13 +603,34 @@ func (mhse *mockHeartbeatSigningExecutor) sign( ctx context.Context, message *big.Int, startBlock uint64, -) (*tecdsa.Signature, uint64, error) { +) (*tecdsa.Signature, uint32, uint64, error) { mhse.requestedMessage = message mhse.requestedStartBlock = startBlock if mhse.shouldFail { - return nil, 0, fmt.Errorf("oofta") + return nil, 0, 0, fmt.Errorf("oofta") + } + + return &tecdsa.Signature{}, mhse.activeOperatorsCount, startBlock + 1, nil +} + +type mockInactivityClaimExecutor struct { + shouldFail bool + + sessionID *big.Int +} + +func (mice *mockInactivityClaimExecutor) claimInactivity( + ctx context.Context, + inactiveMembersIndexes []group.MemberIndex, + heartbeatFailed bool, + sessionID *big.Int, +) error { + mice.sessionID = sessionID + + if mice.shouldFail { + return fmt.Errorf("mock inactivity claim executor error") } - return &tecdsa.Signature{}, startBlock + 1, nil + return nil } diff --git a/pkg/tbtc/inactivity.go b/pkg/tbtc/inactivity.go new file mode 100644 index 0000000000..39bcfc00f8 --- /dev/null +++ b/pkg/tbtc/inactivity.go @@ -0,0 +1,451 @@ +package tbtc + +import ( + "context" + "errors" + "fmt" + "math/big" + "sync" + + "github.com/ipfs/go-log/v2" + "go.uber.org/zap" + "golang.org/x/sync/semaphore" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/generator" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" + "github.com/keep-network/keep-core/pkg/protocol/inactivity" +) + +const ( + // inactivityClaimSubmissionDelayStepBlocks determines the delay step in blocks + // that is used to calculate the submission delay period that should be respected + // by the given member to avoid all members submitting the same inactivity claim + // at the same time. + inactivityClaimSubmissionDelayStepBlocks = 2 +) + +// errInactivityClaimExecutorBusy is an error returned when the inactivity claim +// executor cannot execute the inactivity claim due to another inactivity claim +// execution in progress. +var errInactivityClaimExecutorBusy = fmt.Errorf("inactivity claim executor is busy") + +type inactivityClaimExecutor struct { + lock *semaphore.Weighted + + chain Chain + signers []*signer + broadcastChannel net.BroadcastChannel + membershipValidator *group.MembershipValidator + groupParameters *GroupParameters + protocolLatch *generator.ProtocolLatch + + waitForBlockFn waitForBlockFn +} + +func newInactivityClaimExecutor( + chain Chain, + signers []*signer, + broadcastChannel net.BroadcastChannel, + membershipValidator *group.MembershipValidator, + groupParameters *GroupParameters, + protocolLatch *generator.ProtocolLatch, + waitForBlockFn waitForBlockFn, +) *inactivityClaimExecutor { + return &inactivityClaimExecutor{ + lock: semaphore.NewWeighted(1), + chain: chain, + signers: signers, + broadcastChannel: broadcastChannel, + membershipValidator: membershipValidator, + groupParameters: groupParameters, + protocolLatch: protocolLatch, + waitForBlockFn: waitForBlockFn, + } +} + +func (ice *inactivityClaimExecutor) claimInactivity( + ctx context.Context, + inactiveMembersIndexes []group.MemberIndex, + heartbeatFailed bool, + sessionID *big.Int, +) error { + if lockAcquired := ice.lock.TryAcquire(1); !lockAcquired { + return errInactivityClaimExecutorBusy + } + defer ice.lock.Release(1) + + wallet := ice.wallet() + + walletPublicKeyHash := bitcoin.PublicKeyHash(wallet.publicKey) + walletPublicKeyBytes, err := marshalPublicKey(wallet.publicKey) + if err != nil { + return fmt.Errorf("cannot marshal wallet public key: [%v]", err) + } + + execLogger := logger.With( + zap.String("wallet", fmt.Sprintf("0x%x", walletPublicKeyBytes)), + zap.String("sessionID", fmt.Sprintf("0x%x", sessionID)), + ) + + walletRegistryData, err := ice.chain.GetWallet(walletPublicKeyHash) + if err != nil { + return fmt.Errorf("could not get registry data on wallet: [%v]", err) + } + + nonce, err := ice.chain.GetInactivityClaimNonce( + walletRegistryData.EcdsaWalletID, + ) + if err != nil { + return fmt.Errorf("could not get nonce for wallet: [%v]", err) + } + + claim := inactivity.NewClaimPreimage( + nonce, + wallet.publicKey, + inactiveMembersIndexes, + heartbeatFailed, + ) + + groupMembers, err := ice.getWalletOperatorsIDs() + if err != nil { + return fmt.Errorf("could not get wallet members info: [%v]", err) + } + + wg := sync.WaitGroup{} + wg.Add(len(ice.signers)) + + for _, currentSigner := range ice.signers { + go func(signer *signer) { + ice.protocolLatch.Lock() + defer ice.protocolLatch.Unlock() + + defer wg.Done() + + execLogger.Info( + "[member:%v] starting inactivity claim publishing", + signer.signingGroupMemberIndex, + ) + + signerCtx, cancelSignerCtx := context.WithCancel(ctx) + defer cancelSignerCtx() + + subscription := ice.chain.OnInactivityClaimed( + func(event *InactivityClaimedEvent) { + defer cancelSignerCtx() + + execLogger.Infof( + "[member:%v] Inactivity claim submitted for wallet "+ + "with ID [0x%x] and nonce [%v] by notifier [%v] "+ + "at block [%v]", + signer.signingGroupMemberIndex, + event.WalletID, + event.Nonce, + event.Notifier, + event.BlockNumber, + ) + }) + defer subscription.Unsubscribe() + + err := ice.publishInactivityClaim( + signerCtx, + execLogger, + sessionID, + signer.signingGroupMemberIndex, + wallet.groupSize(), + wallet.groupDishonestThreshold( + ice.groupParameters.HonestThreshold, + ), + groupMembers, + ice.membershipValidator, + claim, + ) + if err != nil { + if errors.Is(err, context.Canceled) { + execLogger.Infof( + "[member:%v] inactivity claim is no longer awaiting "+ + "publishing; aborting inactivity claim publishing", + signer.signingGroupMemberIndex, + ) + return + } + + execLogger.Errorf( + "[member:%v] inactivity claim publishing failed [%v]", + signer.signingGroupMemberIndex, + err, + ) + return + } + }(currentSigner) + } + + // Wait until all controlled signers complete their routine. + wg.Wait() + + return nil +} + +func (ice *inactivityClaimExecutor) getWalletOperatorsIDs() ([]uint32, error) { + // Cache mapping operator addresses to their wallet member IDs. It helps to + // limit the number of calls to the ETH client if some operator addresses + // occur on the list multiple times. + operatorIDCache := make(map[chain.Address]uint32) + + walletMemberIDs := make([]uint32, 0) + + for _, operatorAddress := range ice.wallet().signingGroupOperators { + // Search for the operator address in the cache. Store the operator + // address in the cache if it's not there. + if operatorID, found := operatorIDCache[operatorAddress]; !found { + fetchedOperatorID, err := ice.chain.GetOperatorID(operatorAddress) + if err != nil { + return nil, fmt.Errorf("could not get operator ID: [%w]", err) + } + operatorIDCache[operatorAddress] = fetchedOperatorID + walletMemberIDs = append(walletMemberIDs, fetchedOperatorID) + } else { + walletMemberIDs = append(walletMemberIDs, operatorID) + } + } + + return walletMemberIDs, nil +} + +func (ice *inactivityClaimExecutor) publishInactivityClaim( + ctx context.Context, + inactivityLogger log.StandardLogger, + sessionID *big.Int, + memberIndex group.MemberIndex, + groupSize int, + dishonestThreshold int, + groupMembers []uint32, + membershipValidator *group.MembershipValidator, + inactivityClaim *inactivity.ClaimPreimage, +) error { + return inactivity.PublishClaim( + ctx, + inactivityLogger, + sessionID.Text(16), + memberIndex, + ice.broadcastChannel, + groupSize, + dishonestThreshold, + membershipValidator, + newInactivityClaimSigner(ice.chain), + newInactivityClaimSubmitter( + inactivityLogger, + ice.chain, + ice.groupParameters, + groupMembers, + ice.waitForBlockFn, + ), + inactivityClaim, + ) +} + +func (ice *inactivityClaimExecutor) wallet() wallet { + // All signers belong to one wallet. Take that wallet from the + // first signer. + return ice.signers[0].wallet +} + +// inactivityClaimSigner is responsible for signing the inactivity claim and +// verification of signatures generated by other group members. +type inactivityClaimSigner struct { + chain Chain +} + +func newInactivityClaimSigner( + chain Chain, +) *inactivityClaimSigner { + return &inactivityClaimSigner{ + chain: chain, + } +} + +func (ics *inactivityClaimSigner) SignClaim(claim *inactivity.ClaimPreimage) ( + *inactivity.SignedClaimHash, + error, +) { + if claim == nil { + return nil, fmt.Errorf("claim is nil") + } + + claimHash, err := ics.chain.CalculateInactivityClaimHash(claim) + if err != nil { + return nil, fmt.Errorf( + "inactivity claim hash calculation failed [%w]", + err, + ) + } + + signing := ics.chain.Signing() + + signature, err := signing.Sign(claimHash[:]) + if err != nil { + return nil, fmt.Errorf( + "inactivity claim hash signing failed [%w]", + err, + ) + } + + return &inactivity.SignedClaimHash{ + PublicKey: signing.PublicKey(), + Signature: signature, + ClaimHash: claimHash, + }, nil +} + +// VerifySignature verifies if the signature was generated from the provided +// inactivity claim using the provided public key. +func (ics *inactivityClaimSigner) VerifySignature( + signedClaim *inactivity.SignedClaimHash, +) ( + bool, + error, +) { + return ics.chain.Signing().VerifyWithPublicKey( + signedClaim.ClaimHash[:], + signedClaim.Signature, + signedClaim.PublicKey, + ) +} + +type inactivityClaimSubmitter struct { + inactivityLogger log.StandardLogger + + chain Chain + groupParameters *GroupParameters + groupMembers []uint32 + + waitForBlockFn waitForBlockFn +} + +func newInactivityClaimSubmitter( + inactivityLogger log.StandardLogger, + chain Chain, + groupParameters *GroupParameters, + groupMembers []uint32, + waitForBlockFn waitForBlockFn, +) *inactivityClaimSubmitter { + return &inactivityClaimSubmitter{ + inactivityLogger: inactivityLogger, + chain: chain, + groupParameters: groupParameters, + groupMembers: groupMembers, + waitForBlockFn: waitForBlockFn, + } +} + +func (ics *inactivityClaimSubmitter) SubmitClaim( + ctx context.Context, + memberIndex group.MemberIndex, + claim *inactivity.ClaimPreimage, + signatures map[group.MemberIndex][]byte, +) error { + if len(signatures) < ics.groupParameters.HonestThreshold { + return fmt.Errorf( + "could not submit inactivity claim with [%v] signatures for "+ + "group honest threshold [%v]", + len(signatures), + ics.groupParameters.HonestThreshold, + ) + } + + // The inactivity nonce at the beginning of the execution process. + inactivityNonce := claim.Nonce + + walletPublicKeyHash := bitcoin.PublicKeyHash(claim.WalletPublicKey) + + walletRegistryData, err := ics.chain.GetWallet(walletPublicKeyHash) + if err != nil { + return fmt.Errorf("could not get registry data on wallet: [%v]", err) + } + + ecdsaWalletID := walletRegistryData.EcdsaWalletID + + currentNonce, err := ics.chain.GetInactivityClaimNonce( + ecdsaWalletID, + ) + if err != nil { + return fmt.Errorf("could not get nonce for wallet: [%v]", err) + } + + if currentNonce.Cmp(inactivityNonce) > 0 { + // Someone who was ahead of us in the queue submitted the claim. Giving up. + ics.inactivityLogger.Infof( + "[member:%v] inactivity claim already submitted; "+ + "aborting inactivity claim on-chain submission", + memberIndex, + ) + return nil + } + + chainClaim, err := ics.chain.AssembleInactivityClaim( + ecdsaWalletID, + claim.InactiveMembersIndexes, + signatures, + claim.HeartbeatFailed, + ) + if err != nil { + return fmt.Errorf("could not assemble inactivity chain claim [%w]", err) + } + + blockCounter, err := ics.chain.BlockCounter() + if err != nil { + return err + } + + // We can't determine a common block at which the publication starts. + // However, all we want here is to ensure the members does not submit + // in the same time. This can be achieved by simply using the index-based + // delay starting from the current block. + currentBlock, err := blockCounter.CurrentBlock() + if err != nil { + return fmt.Errorf("cannot get current block: [%v]", err) + } + delayBlocks := uint64(memberIndex-1) * inactivityClaimSubmissionDelayStepBlocks + submissionBlock := currentBlock + delayBlocks + + ics.inactivityLogger.Infof( + "[member:%v] waiting for block [%v] to submit inactivity claim", + memberIndex, + submissionBlock, + ) + + err = ics.waitForBlockFn(ctx, submissionBlock) + if err != nil { + return fmt.Errorf( + "error while waiting for inactivity claim submission block: [%v]", + err, + ) + } + + if ctx.Err() != nil { + // The context was cancelled by the upstream. Regardless of the cause, + // that means the inactivity execution is no longer awaiting the result, + // and we can safely return. + ics.inactivityLogger.Infof( + "[member:%v] inactivity execution is no longer awaiting the "+ + "result; aborting inactivity claim on-chain submission", + memberIndex, + ) + return nil + } + + ics.inactivityLogger.Infof( + "[member:%v] submitting inactivity claim with [%v] supporting "+ + "member signatures", + memberIndex, + len(signatures), + ) + + return ics.chain.SubmitInactivityClaim( + chainClaim, + inactivityNonce, + ics.groupMembers, + ) +} diff --git a/pkg/tbtc/inactivity_test.go b/pkg/tbtc/inactivity_test.go new file mode 100644 index 0000000000..b1065e4607 --- /dev/null +++ b/pkg/tbtc/inactivity_test.go @@ -0,0 +1,842 @@ +package tbtc + +import ( + "context" + "fmt" + "math/big" + "reflect" + "testing" + "time" + + "golang.org/x/crypto/sha3" + + "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/chain/local_v1" + "github.com/keep-network/keep-core/pkg/generator" + "github.com/keep-network/keep-core/pkg/internal/tecdsatest" + "github.com/keep-network/keep-core/pkg/net/local" + "github.com/keep-network/keep-core/pkg/operator" + "github.com/keep-network/keep-core/pkg/protocol/group" + "github.com/keep-network/keep-core/pkg/protocol/inactivity" + "github.com/keep-network/keep-core/pkg/tecdsa" +) + +func TestInactivityClaimExecutor_ClaimInactivity(t *testing.T) { + executor, walletEcdsaID, chain := setupInactivityClaimExecutorScenario(t) + + initialNonce, err := chain.GetInactivityClaimNonce(walletEcdsaID) + if err != nil { + t.Fatal(err) + } + + ctx, cancelCtx := context.WithCancel(context.Background()) + defer cancelCtx() + + message := big.NewInt(100) + inactiveMembersIndexes := []group.MemberIndex{1, 4} + + err = executor.claimInactivity( + ctx, + inactiveMembersIndexes, + true, + message, + ) + if err != nil { + t.Fatal(err) + } + + currentNonce, err := chain.GetInactivityClaimNonce(walletEcdsaID) + if err != nil { + t.Fatal(err) + } + + expectedNonceDiff := uint64(1) + nonceDiff := currentNonce.Uint64() - initialNonce.Uint64() + + testutils.AssertUintsEqual( + t, + "inactivity nonce difference", + expectedNonceDiff, + nonceDiff, + ) +} + +func TestInactivityClaimExecutor_ClaimInactivity_Busy(t *testing.T) { + executor, _, _ := setupInactivityClaimExecutorScenario(t) + + ctx, cancelCtx := context.WithCancel(context.Background()) + defer cancelCtx() + + message := big.NewInt(100) + inactiveMembersIndexes := []group.MemberIndex{1, 4} + + errChan := make(chan error, 1) + go func() { + err := executor.claimInactivity( + ctx, + inactiveMembersIndexes, + true, + message, + ) + errChan <- err + }() + + time.Sleep(100 * time.Millisecond) + + err := executor.claimInactivity( + ctx, + inactiveMembersIndexes, + true, + message, + ) + testutils.AssertErrorsSame(t, errInactivityClaimExecutorBusy, err) + + err = <-errChan + if err != nil { + t.Errorf("unexpected error: [%v]", err) + } +} + +func setupInactivityClaimExecutorScenario(t *testing.T) ( + *inactivityClaimExecutor, + [32]byte, + *localChain, +) { + groupParameters := &GroupParameters{ + GroupSize: 5, + GroupQuorum: 4, + HonestThreshold: 3, + } + + operatorPrivateKey, operatorPublicKey, err := operator.GenerateKeyPair( + local_v1.DefaultCurve, + ) + if err != nil { + t.Fatal(err) + } + + localChain := ConnectWithKey(operatorPrivateKey) + + localProvider := local.ConnectWithKey(operatorPublicKey) + + operatorAddress, err := localChain.Signing().PublicKeyToAddress( + operatorPublicKey, + ) + if err != nil { + t.Fatal(err) + } + + var operators []chain.Address + for i := 0; i < groupParameters.GroupSize; i++ { + operators = append(operators, operatorAddress) + } + + testData, err := tecdsatest.LoadPrivateKeyShareTestFixtures( + groupParameters.GroupSize, + ) + if err != nil { + t.Fatalf("failed to load test data: [%v]", err) + } + + signers := make([]*signer, len(testData)) + for i := range testData { + privateKeyShare := tecdsa.NewPrivateKeyShare(testData[i]) + + signers[i] = &signer{ + wallet: wallet{ + publicKey: privateKeyShare.PublicKey(), + signingGroupOperators: operators, + }, + signingGroupMemberIndex: group.MemberIndex(i + 1), + privateKeyShare: privateKeyShare, + } + } + + keyStorePersistence := createMockKeyStorePersistence(t, signers...) + + walletPublicKeyHash := bitcoin.PublicKeyHash(signers[0].wallet.publicKey) + ecdsaWalletID := [32]byte{1, 2, 3} + + localChain.setWallet( + walletPublicKeyHash, + &WalletChainData{ + EcdsaWalletID: ecdsaWalletID, + }, + ) + + node, err := newNode( + groupParameters, + localChain, + newLocalBitcoinChain(), + localProvider, + keyStorePersistence, + &mockPersistenceHandle{}, + generator.StartScheduler(), + &mockCoordinationProposalGenerator{}, + Config{}, + ) + if err != nil { + t.Fatal(err) + } + + executor, ok, err := node.getInactivityClaimExecutor( + signers[0].wallet.publicKey, + ) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("node is supposed to control wallet signers") + } + + return executor, ecdsaWalletID, localChain +} + +func TestSignClaim_SigningSuccessful(t *testing.T) { + chain := Connect() + inactivityClaimSigner := newInactivityClaimSigner(chain) + + testData, err := tecdsatest.LoadPrivateKeyShareTestFixtures(1) + if err != nil { + t.Fatalf("failed to load test data: [%v]", err) + } + privateKeyShare := tecdsa.NewPrivateKeyShare(testData[0]) + + claim := inactivity.NewClaimPreimage( + big.NewInt(5), + privateKeyShare.PublicKey(), + []group.MemberIndex{11, 22, 33}, + true, + ) + + signedClaim, err := inactivityClaimSigner.SignClaim(claim) + if err != nil { + t.Fatal(err) + } + + expectedPublicKey := chain.Signing().PublicKey() + if !reflect.DeepEqual( + expectedPublicKey, + signedClaim.PublicKey, + ) { + t.Errorf( + "unexpected public key\n"+ + "expected: %v\n"+ + "actual: %v\n", + expectedPublicKey, + signedClaim.PublicKey, + ) + } + + expectedInactivityClaimHash := inactivity.ClaimHash( + sha3.Sum256( + []byte(fmt.Sprint( + claim.Nonce, + claim.WalletPublicKey, + claim.InactiveMembersIndexes, + claim.HeartbeatFailed, + )), + ), + ) + if expectedInactivityClaimHash != signedClaim.ClaimHash { + t.Errorf( + "unexpected claim hash\n"+ + "expected: %v\n"+ + "actual: %v\n", + expectedInactivityClaimHash, + signedClaim.ClaimHash, + ) + } + + // Since signature is different on every run (even if the same private key + // and claim hash are used), simply verify if it's correct + signatureVerification, err := chain.Signing().Verify( + signedClaim.ClaimHash[:], + signedClaim.Signature, + ) + if err != nil { + t.Fatal(err) + } + + if !signatureVerification { + t.Errorf( + "Signature [0x%x] was not generated properly for the claim hash "+ + "[0x%x]", + signedClaim.Signature, + signedClaim.ClaimHash, + ) + } +} + +func TestSignClaim_ErrorDuringInactivityClaimHashCalculation(t *testing.T) { + chain := Connect() + inactivityClaimSigner := newInactivityClaimSigner(chain) + + // Use nil as the claim to cause hash calculation error. + _, err := inactivityClaimSigner.SignClaim(nil) + + expectedError := fmt.Errorf("claim is nil") + if !reflect.DeepEqual(expectedError, err) { + t.Errorf( + "unexpected error\nexpected: %v\nactual: %v\n", + expectedError, + err, + ) + } +} + +func TestVerifySignature_VerifySuccessful(t *testing.T) { + chain := Connect() + inactivityClaimSigner := newInactivityClaimSigner(chain) + + testData, err := tecdsatest.LoadPrivateKeyShareTestFixtures(1) + if err != nil { + t.Fatalf("failed to load test data: [%v]", err) + } + privateKeyShare := tecdsa.NewPrivateKeyShare(testData[0]) + + claim := inactivity.NewClaimPreimage( + big.NewInt(5), + privateKeyShare.PublicKey(), + []group.MemberIndex{11, 22, 33}, + true, + ) + + signedClaim, err := inactivityClaimSigner.SignClaim(claim) + if err != nil { + t.Fatal(err) + } + + verificationSuccessful, err := inactivityClaimSigner.VerifySignature( + signedClaim, + ) + if err != nil { + t.Fatal(err) + } + + if !verificationSuccessful { + t.Fatal( + "Expected successful verification of signature, but it was " + + "unsuccessful", + ) + } +} + +func TestVerifySignature_VerifyFailure(t *testing.T) { + chain := Connect() + inactivityClaimSigner := newInactivityClaimSigner(chain) + + testData, err := tecdsatest.LoadPrivateKeyShareTestFixtures(1) + if err != nil { + t.Fatalf("failed to load test data: [%v]", err) + } + privateKeyShare := tecdsa.NewPrivateKeyShare(testData[0]) + + claim := inactivity.NewClaimPreimage( + big.NewInt(5), + privateKeyShare.PublicKey(), + []group.MemberIndex{11, 22, 33}, + true, + ) + + signedClaim, err := inactivityClaimSigner.SignClaim(claim) + if err != nil { + t.Fatal(err) + } + + anotherClaim := inactivity.NewClaimPreimage( + big.NewInt(6), + privateKeyShare.PublicKey(), + []group.MemberIndex{11, 22, 33}, + true, + ) + + anotherSignedClaim, err := inactivityClaimSigner.SignClaim(anotherClaim) + if err != nil { + t.Fatal(err) + } + + // Assign signature from another claim to cause a signature verification + // failure. + signedClaim.Signature = anotherSignedClaim.Signature + + verificationSuccessful, err := inactivityClaimSigner.VerifySignature( + signedClaim, + ) + if err != nil { + t.Fatal(err) + } + + if verificationSuccessful { + t.Fatal( + "Expected unsuccessful verification of signature, but it was " + + "successful", + ) + } +} + +func TestVerifySignature_VerifyError(t *testing.T) { + chain := Connect() + inactivityClaimSigner := newInactivityClaimSigner(chain) + + testData, err := tecdsatest.LoadPrivateKeyShareTestFixtures(1) + if err != nil { + t.Fatalf("failed to load test data: [%v]", err) + } + privateKeyShare := tecdsa.NewPrivateKeyShare(testData[0]) + + claim := inactivity.NewClaimPreimage( + big.NewInt(5), + privateKeyShare.PublicKey(), + []group.MemberIndex{11, 22, 33}, + true, + ) + + signedClaim, err := inactivityClaimSigner.SignClaim(claim) + if err != nil { + t.Fatal(err) + } + + // Drop the last byte of the signature to cause an error during signature + // verification. + signedClaim.Signature = signedClaim.Signature[:len(signedClaim.Signature)-1] + + _, err = inactivityClaimSigner.VerifySignature(signedClaim) + + expectedError := fmt.Errorf( + "failed to unmarshal signature: [asn1: syntax error: data truncated]", + ) + if !reflect.DeepEqual(expectedError, err) { + t.Errorf( + "unexpected error\n"+ + "expected: [%+v]\n"+ + "actual: [%+v]", + expectedError, + err, + ) + } +} + +func TestSubmitClaim_MemberSubmitsClaim(t *testing.T) { + testData, err := tecdsatest.LoadPrivateKeyShareTestFixtures(1) + if err != nil { + t.Fatalf("failed to load test data: [%v]", err) + } + privateKeyShare := tecdsa.NewPrivateKeyShare(testData[0]) + + publicKey := privateKeyShare.PublicKey() + walletPublicKeyHash := bitcoin.PublicKeyHash(publicKey) + ecdsaWalletID := [32]byte{1, 2, 3} + + chain := Connect() + + chain.setWallet( + walletPublicKeyHash, + &WalletChainData{ + EcdsaWalletID: ecdsaWalletID, + }, + ) + + groupParameters := &GroupParameters{ + GroupSize: 5, + GroupQuorum: 4, + HonestThreshold: 3, + } + + groupMembers := []uint32{1, 2, 2, 3, 5} + + inactivityClaimSubmitter := newInactivityClaimSubmitter( + &testutils.MockLogger{}, + chain, + groupParameters, + groupMembers, + testWaitForBlockFn(chain), + ) + + ctx, cancelCtx := context.WithCancel(context.Background()) + defer cancelCtx() + + memberIndex := group.MemberIndex(1) + + claim := inactivity.NewClaimPreimage( + big.NewInt(0), + publicKey, + []group.MemberIndex{11, 22, 33}, + true, + ) + + signatures := map[group.MemberIndex][]byte{ + 1: []byte("signature 1"), + 2: []byte("signature 2"), + 3: []byte("signature 3"), + 4: []byte("signature 4"), + } + + err = inactivityClaimSubmitter.SubmitClaim( + ctx, + memberIndex, + claim, + signatures, + ) + if err != nil { + t.Fatal(err) + } + + expectedNonce := big.NewInt(1) + + nonce, err := chain.GetInactivityClaimNonce(ecdsaWalletID) + if err != nil { + t.Fatal(err) + } + + testutils.AssertBigIntsEqual( + t, + "inactivity nonce", + expectedNonce, + nonce, + ) +} + +func TestSubmitClaim_AnotherMemberSubmitsClaim(t *testing.T) { + testData, err := tecdsatest.LoadPrivateKeyShareTestFixtures(1) + if err != nil { + t.Fatalf("failed to load test data: [%v]", err) + } + privateKeyShare := tecdsa.NewPrivateKeyShare(testData[0]) + + publicKey := privateKeyShare.PublicKey() + walletPublicKeyHash := bitcoin.PublicKeyHash(publicKey) + ecdsaWalletID := [32]byte{1, 2, 3} + + chain := Connect() + + chain.setWallet( + walletPublicKeyHash, + &WalletChainData{ + EcdsaWalletID: ecdsaWalletID, + }, + ) + + groupParameters := &GroupParameters{ + GroupSize: 5, + GroupQuorum: 4, + HonestThreshold: 3, + } + + groupMembers := []uint32{1, 2, 2, 3, 5} + + inactivityClaimSubmitter := newInactivityClaimSubmitter( + &testutils.MockLogger{}, + chain, + groupParameters, + groupMembers, + testWaitForBlockFn(chain), + ) + + ctx, cancelCtx := context.WithCancel(context.Background()) + defer cancelCtx() + + claim := inactivity.NewClaimPreimage( + big.NewInt(0), + publicKey, + []group.MemberIndex{11, 22, 33}, + true, + ) + + signatures := map[group.MemberIndex][]byte{ + 1: []byte("signature 1"), + 2: []byte("signature 2"), + 3: []byte("signature 3"), + 4: []byte("signature 4"), + } + + // Set up a global listener that will cancel the common context upon claim + // submission. That mimics the real-world scenario. + chain.OnInactivityClaimed( + func(event *InactivityClaimedEvent) { + cancelCtx() + }, + ) + + secondMemberSubmissionChannel := make(chan error) + // Attempt to submit claim for the second member on a separate goroutine. + go func() { + secondMemberIndex := group.MemberIndex(2) + secondMemberErr := inactivityClaimSubmitter.SubmitClaim( + ctx, + secondMemberIndex, + claim, + signatures, + ) + secondMemberSubmissionChannel <- secondMemberErr + }() + + // This sleep is needed to give enough time for the second member to + // register their claim submission event handler and act properly on the + // claim submitted by the first member. + time.Sleep(1 * time.Second) + + // While the second member is waiting for submission eligibility, submit the + // claim with the first member. + firstMemberIndex := group.MemberIndex(1) + firstMemberErr := inactivityClaimSubmitter.SubmitClaim( + ctx, + firstMemberIndex, + claim, + signatures, + ) + if err != nil { + t.Fatal(firstMemberErr) + } + + // Check that the second member returned without errors + secondMemberErr := <-secondMemberSubmissionChannel + if secondMemberErr != nil { + t.Fatal(secondMemberErr) + } + + expectedNonce := big.NewInt(1) + + nonce, err := chain.GetInactivityClaimNonce(ecdsaWalletID) + if err != nil { + t.Fatal(err) + } + + testutils.AssertBigIntsEqual( + t, + "inactivity nonce", + expectedNonce, + nonce, + ) +} + +func TestSubmitClaim_InvalidResult(t *testing.T) { + testData, err := tecdsatest.LoadPrivateKeyShareTestFixtures(1) + if err != nil { + t.Fatalf("failed to load test data: [%v]", err) + } + privateKeyShare := tecdsa.NewPrivateKeyShare(testData[0]) + + publicKey := privateKeyShare.PublicKey() + walletPublicKeyHash := bitcoin.PublicKeyHash(publicKey) + ecdsaWalletID := [32]byte{1, 2, 3} + + chain := Connect() + + chain.setWallet( + walletPublicKeyHash, + &WalletChainData{ + EcdsaWalletID: ecdsaWalletID, + }, + ) + + groupParameters := &GroupParameters{ + GroupSize: 5, + GroupQuorum: 4, + HonestThreshold: 3, + } + + groupMembers := []uint32{1, 2, 2, 3, 5} + + inactivityClaimSubmitter := newInactivityClaimSubmitter( + &testutils.MockLogger{}, + chain, + groupParameters, + groupMembers, + testWaitForBlockFn(chain), + ) + + ctx, cancelCtx := context.WithCancel(context.Background()) + defer cancelCtx() + + memberIndex := group.MemberIndex(1) + + claim := inactivity.NewClaimPreimage( + big.NewInt(12345), // Use wrong nonce. + publicKey, + []group.MemberIndex{11, 22, 33}, + true, + ) + + signatures := map[group.MemberIndex][]byte{ + 1: []byte("signature 1"), + 2: []byte("signature 2"), + 3: []byte("signature 3"), + 4: []byte("signature 4"), + } + + err = inactivityClaimSubmitter.SubmitClaim( + ctx, + memberIndex, + claim, + signatures, + ) + + expectedErr := fmt.Errorf("wrong inactivity claim nonce") + if !reflect.DeepEqual(expectedErr, err) { + t.Errorf( + "unexpected error \nexpected: [%v]\nactual: [%v]\n", + expectedErr, + err, + ) + } +} + +func TestSubmitClaim_ContextCancelled(t *testing.T) { + testData, err := tecdsatest.LoadPrivateKeyShareTestFixtures(1) + if err != nil { + t.Fatalf("failed to load test data: [%v]", err) + } + privateKeyShare := tecdsa.NewPrivateKeyShare(testData[0]) + + publicKey := privateKeyShare.PublicKey() + walletPublicKeyHash := bitcoin.PublicKeyHash(publicKey) + ecdsaWalletID := [32]byte{1, 2, 3} + + chain := Connect() + + chain.setWallet( + walletPublicKeyHash, + &WalletChainData{ + EcdsaWalletID: ecdsaWalletID, + }, + ) + + groupParameters := &GroupParameters{ + GroupSize: 5, + GroupQuorum: 4, + HonestThreshold: 3, + } + + groupMembers := []uint32{1, 2, 2, 3, 5} + + inactivityClaimSubmitter := newInactivityClaimSubmitter( + &testutils.MockLogger{}, + chain, + groupParameters, + groupMembers, + testWaitForBlockFn(chain), + ) + + ctx, cancelCtx := context.WithCancel(context.Background()) + + // Simulate the case when timeout occurs and the context gets cancelled. + cancelCtx() + + memberIndex := group.MemberIndex(1) + + claim := inactivity.NewClaimPreimage( + big.NewInt(0), + publicKey, + []group.MemberIndex{11, 22, 33}, + true, + ) + + signatures := map[group.MemberIndex][]byte{ + 1: []byte("signature 1"), + 2: []byte("signature 2"), + 3: []byte("signature 3"), + 4: []byte("signature 4"), + } + + err = inactivityClaimSubmitter.SubmitClaim( + ctx, + memberIndex, + claim, + signatures, + ) + if err != nil { + t.Errorf("unexpected error [%v]", err) + } + + // Check the inactivity nonce is still 0. + expectedNonce := big.NewInt(0) + + nonce, err := chain.GetInactivityClaimNonce(ecdsaWalletID) + if err != nil { + t.Fatal(err) + } + + testutils.AssertBigIntsEqual( + t, + "inactivity nonce", + expectedNonce, + nonce, + ) +} + +func TestSubmitClaim_TooFewSignatures(t *testing.T) { + testData, err := tecdsatest.LoadPrivateKeyShareTestFixtures(1) + if err != nil { + t.Fatalf("failed to load test data: [%v]", err) + } + privateKeyShare := tecdsa.NewPrivateKeyShare(testData[0]) + + publicKey := privateKeyShare.PublicKey() + walletPublicKeyHash := bitcoin.PublicKeyHash(publicKey) + ecdsaWalletID := [32]byte{1, 2, 3} + + chain := Connect() + + chain.setWallet( + walletPublicKeyHash, + &WalletChainData{ + EcdsaWalletID: ecdsaWalletID, + }, + ) + + groupParameters := &GroupParameters{ + GroupSize: 5, + GroupQuorum: 4, + HonestThreshold: 3, + } + + groupMembers := []uint32{1, 2, 2, 3, 5} + + inactivityClaimSubmitter := newInactivityClaimSubmitter( + &testutils.MockLogger{}, + chain, + groupParameters, + groupMembers, + testWaitForBlockFn(chain), + ) + + ctx, cancelCtx := context.WithCancel(context.Background()) + defer cancelCtx() + + memberIndex := group.MemberIndex(1) + + claim := inactivity.NewClaimPreimage( + big.NewInt(0), + publicKey, + []group.MemberIndex{11, 22, 33}, + true, + ) + + signatures := map[group.MemberIndex][]byte{ + 1: []byte("signature 1"), + 2: []byte("signature 2"), + } + + err = inactivityClaimSubmitter.SubmitClaim( + ctx, + memberIndex, + claim, + signatures, + ) + + expectedError := fmt.Errorf( + "could not submit inactivity claim with [2] signatures for group honest threshold [3]", + ) + if !reflect.DeepEqual(expectedError, err) { + t.Errorf( + "unexpected error\n"+ + "expected: [%+v]\n"+ + "actual: [%+v]", + expectedError, + err, + ) + } +} diff --git a/pkg/tbtc/node.go b/pkg/tbtc/node.go index c36a7174a7..af0c37293f 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -18,6 +18,7 @@ import ( "github.com/keep-network/keep-core/pkg/net" "github.com/keep-network/keep-core/pkg/protocol/announcer" "github.com/keep-network/keep-core/pkg/protocol/group" + "github.com/keep-network/keep-core/pkg/protocol/inactivity" "github.com/keep-network/keep-core/pkg/tecdsa/signing" ) @@ -65,6 +66,22 @@ type node struct { // dkgExecutor MUST NOT be used outside this struct. dkgExecutor *dkgExecutor + // heartbeatFailureCounter stores the counters of consecutive heartbeat + // failures for each wallet. + heartbeatFailureCounter *heartbeatFailureCounter + + inactivityClaimExecutorMutex sync.Mutex + // inactivityClaimExecutors is the cache holding inactivity claim executors + // for specific wallets. The cache key is the uncompressed public key + // (with 04 prefix) of the wallet. + // inactivityClaimExecutor encapsulates the logic of handling inactivity + // claim signing and submitting. + // + // inactivityClaimExecutors MUST NOT be used outside this struct. Please use + // wallet actions and walletDispatcher to execute an action on an existing + // wallet. + inactivityClaimExecutors map[string]*inactivityClaimExecutor + signingExecutorsMutex sync.Mutex // signingExecutors is the cache holding signing executors for specific wallets. // The cache key is the uncompressed public key (with 04 prefix) of the wallet. @@ -106,16 +123,18 @@ func newNode( scheduler.RegisterProtocol(latch) node := &node{ - groupParameters: groupParameters, - chain: chain, - btcChain: btcChain, - netProvider: netProvider, - walletRegistry: walletRegistry, - walletDispatcher: newWalletDispatcher(), - protocolLatch: latch, - signingExecutors: make(map[string]*signingExecutor), - coordinationExecutors: make(map[string]*coordinationExecutor), - proposalGenerator: proposalGenerator, + groupParameters: groupParameters, + chain: chain, + btcChain: btcChain, + netProvider: netProvider, + walletRegistry: walletRegistry, + walletDispatcher: newWalletDispatcher(), + protocolLatch: latch, + heartbeatFailureCounter: newHeartbeatFailureCounter(), + signingExecutors: make(map[string]*signingExecutor), + inactivityClaimExecutors: make(map[string]*inactivityClaimExecutor), + coordinationExecutors: make(map[string]*coordinationExecutor), + proposalGenerator: proposalGenerator, } // Only the operator address is known at this point and can be pre-fetched. @@ -405,6 +424,89 @@ func (n *node) getCoordinationExecutor( return executor, true, nil } +// getInactivityClaimExecutor gets the inactivity claim executor responsible for +// executing inactivity claim signing and submission related to a specific +// wallet whose part is controlled by this node. The second boolean return value +// indicates whether the node controls at least one signer for the given wallet. +func (n *node) getInactivityClaimExecutor( + walletPublicKey *ecdsa.PublicKey, +) (*inactivityClaimExecutor, bool, error) { + n.inactivityClaimExecutorMutex.Lock() + defer n.inactivityClaimExecutorMutex.Unlock() + + walletPublicKeyBytes, err := marshalPublicKey(walletPublicKey) + if err != nil { + return nil, false, fmt.Errorf("cannot marshal wallet public key: [%v]", err) + } + + executorKey := hex.EncodeToString(walletPublicKeyBytes) + + if executor, exists := n.inactivityClaimExecutors[executorKey]; exists { + return executor, true, nil + } + + executorLogger := logger.With( + zap.String("wallet", fmt.Sprintf("0x%x", walletPublicKeyBytes)), + ) + + signers := n.walletRegistry.getSigners(walletPublicKey) + if len(signers) == 0 { + // This is not an error because the node simply does not control + // the given wallet. + return nil, false, nil + } + + // All signers belong to one wallet. Take that wallet from the first signer. + wallet := signers[0].wallet + + channelName := fmt.Sprintf( + "%s-%s-inactivity", + ProtocolName, + hex.EncodeToString(walletPublicKeyBytes), + ) + + broadcastChannel, err := n.netProvider.BroadcastChannelFor(channelName) + if err != nil { + return nil, false, fmt.Errorf("failed to get broadcast channel: [%v]", err) + } + + inactivity.RegisterUnmarshallers(broadcastChannel) + + membershipValidator := group.NewMembershipValidator( + executorLogger, + wallet.signingGroupOperators, + n.chain.Signing(), + ) + + err = broadcastChannel.SetFilter(membershipValidator.IsInGroup) + if err != nil { + return nil, false, fmt.Errorf( + "could not set filter for channel [%v]: [%v]", + broadcastChannel.Name(), + err, + ) + } + + executorLogger.Infof( + "inactivity executor created; controlling [%v] signers", + len(signers), + ) + + executor := newInactivityClaimExecutor( + n.chain, + signers, + broadcastChannel, + membershipValidator, + n.groupParameters, + n.protocolLatch, + n.waitForBlockHeight, + ) + + n.inactivityClaimExecutors[executorKey] = executor + + return executor, true, nil +} + // handleHeartbeatProposal handles an incoming heartbeat proposal by // orchestrating and dispatching an appropriate wallet action. func (n *node) handleHeartbeatProposal( @@ -437,6 +539,24 @@ func (n *node) handleHeartbeatProposal( return } + inactivityClaimExecutor, ok, err := n.getInactivityClaimExecutor(wallet.publicKey) + if err != nil { + logger.Errorf("cannot get inactivity claim executor: [%v]", err) + return + } + // This check is actually redundant. We know the node controls some + // wallet signers as we just got the wallet from the registry using their + // public key hash. However, we are doing it just in case. The API + // contract of getInactivityClaimExecutor may change one day. + if !ok { + logger.Infof( + "node does not control signers of wallet [0x%x]; "+ + "ignoring the received heartbeat request", + walletPublicKeyBytes, + ) + return + } + logger.Infof( "starting orchestration of the heartbeat action for wallet [0x%x]; "+ "20-byte public key hash of that wallet is [0x%x]", @@ -458,6 +578,8 @@ func (n *node) handleHeartbeatProposal( wallet, signingExecutor, proposal, + n.heartbeatFailureCounter, + inactivityClaimExecutor, startBlock, expiryBlock, n.waitForBlockHeight, diff --git a/pkg/tbtc/signing.go b/pkg/tbtc/signing.go index 532a03942d..b4ab2b069e 100644 --- a/pkg/tbtc/signing.go +++ b/pkg/tbtc/signing.go @@ -145,7 +145,7 @@ func (se *signingExecutor) signBatch( signingStartBlock = endBlocks[i-1] + signingBatchInterludeBlocks } - signature, endBlock, err := se.sign(ctx, message, signingStartBlock) + signature, _, endBlock, err := se.sign(ctx, message, signingStartBlock) if err != nil { return nil, err } @@ -167,15 +167,16 @@ func (se *signingExecutor) signBatch( // triggered according to the given start block. If the message cannot be signed // within a limited time window, an error is returned. If the message was // signed successfully, this function returns the signature along with the -// block at which the signature was calculated. This end block is common for -// all wallet signers so can be used as a synchronization point. +// number of active members that participated in signing, the block at which the +// signature was calculated. The end block is common for all wallet signers so +// can be used as a synchronization point. func (se *signingExecutor) sign( ctx context.Context, message *big.Int, startBlock uint64, -) (*tecdsa.Signature, uint64, error) { +) (*tecdsa.Signature, uint32, uint64, error) { if lockAcquired := se.lock.TryAcquire(1); !lockAcquired { - return nil, 0, errSigningExecutorBusy + return nil, 0, 0, errSigningExecutorBusy } defer se.lock.Release(1) @@ -183,7 +184,7 @@ func (se *signingExecutor) sign( walletPublicKeyBytes, err := marshalPublicKey(wallet.publicKey) if err != nil { - return nil, 0, fmt.Errorf("cannot marshal wallet public key: [%v]", err) + return nil, 0, 0, fmt.Errorf("cannot marshal wallet public key: [%v]", err) } loopTimeoutBlock := startBlock + @@ -197,8 +198,9 @@ func (se *signingExecutor) sign( ) type signingOutcome struct { - signature *tecdsa.Signature - endBlock uint64 + signature *tecdsa.Signature + activeMembersCount uint32 + endBlock uint64 } wg := sync.WaitGroup{} @@ -365,8 +367,9 @@ func (se *signingExecutor) sign( ) signingOutcomeChan <- &signingOutcome{ - signature: loopResult.result.Signature, - endBlock: loopResult.latestEndBlock, + signature: loopResult.result.Signature, + activeMembersCount: loopResult.activeMembersCount, + endBlock: loopResult.latestEndBlock, } }(currentSigner) } @@ -383,9 +386,9 @@ func (se *signingExecutor) sign( // signer, that means all signers failed and have not produced a signature. select { case outcome := <-signingOutcomeChan: - return outcome.signature, outcome.endBlock, nil + return outcome.signature, outcome.activeMembersCount, outcome.endBlock, nil default: - return nil, 0, fmt.Errorf("all signers failed") + return nil, 0, 0, fmt.Errorf("all signers failed") } } diff --git a/pkg/tbtc/signing_loop.go b/pkg/tbtc/signing_loop.go index f67a585fe7..48f881df03 100644 --- a/pkg/tbtc/signing_loop.go +++ b/pkg/tbtc/signing_loop.go @@ -5,11 +5,12 @@ import ( "crypto/sha256" "encoding/binary" "fmt" - "github.com/keep-network/keep-core/pkg/protocol/announcer" "math/big" "math/rand" "sort" + "github.com/keep-network/keep-core/pkg/protocol/announcer" + "github.com/ipfs/go-log/v2" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/protocol/group" @@ -143,6 +144,9 @@ type signingAttemptFn func(*signingAttemptParams) (*signing.Result, uint64, erro type signingRetryLoopResult struct { // result is the outcome of the signing process. result *signing.Result + // activeMembersCount is the number of members that participated in the + // signing process. + activeMembersCount uint32 // latestEndBlock is the block at which the slowest signer of the successful // signing attempt completed signature computation. This block is also // the common end block accepted by all other members of the signing group. @@ -407,6 +411,7 @@ func (srl *signingRetryLoop) start( return &signingRetryLoopResult{ result: result, + activeMembersCount: uint32(len(readyMembersIndexes)), latestEndBlock: latestEndBlock, attemptTimeoutBlock: timeoutBlock, }, nil diff --git a/pkg/tbtc/signing_loop_test.go b/pkg/tbtc/signing_loop_test.go index fbdba89552..cf3b02e716 100644 --- a/pkg/tbtc/signing_loop_test.go +++ b/pkg/tbtc/signing_loop_test.go @@ -101,6 +101,7 @@ func TestSigningRetryLoop(t *testing.T) { expectedErr: nil, expectedResult: &signingRetryLoopResult{ result: testResult, + activeMembersCount: 10, latestEndBlock: 215, // the end block resolved by the done check phase attemptTimeoutBlock: 236, // start block of the first attempt + 30 }, @@ -151,6 +152,7 @@ func TestSigningRetryLoop(t *testing.T) { expectedErr: nil, expectedResult: &signingRetryLoopResult{ result: testResult, + activeMembersCount: 6, latestEndBlock: 215, // the end block resolved by the done check phase attemptTimeoutBlock: 236, // start block of the first attempt + 30 }, @@ -205,6 +207,7 @@ func TestSigningRetryLoop(t *testing.T) { expectedErr: nil, expectedResult: &signingRetryLoopResult{ result: testResult, + activeMembersCount: 10, latestEndBlock: 260, // the end block resolved by the done check phase attemptTimeoutBlock: 277, // start block of the second attempt + 30 }, @@ -261,6 +264,7 @@ func TestSigningRetryLoop(t *testing.T) { expectedErr: nil, expectedResult: &signingRetryLoopResult{ result: testResult, + activeMembersCount: 10, latestEndBlock: 260, // the end block resolved by the done check phase attemptTimeoutBlock: 277, // start block of the second attempt + 30 }, @@ -317,6 +321,7 @@ func TestSigningRetryLoop(t *testing.T) { expectedErr: nil, expectedResult: &signingRetryLoopResult{ result: testResult, + activeMembersCount: 10, latestEndBlock: 260, // the end block resolved by the done check phase attemptTimeoutBlock: 277, // start block of the second attempt + 30 }, @@ -365,6 +370,7 @@ func TestSigningRetryLoop(t *testing.T) { expectedErr: nil, expectedResult: &signingRetryLoopResult{ result: testResult, + activeMembersCount: 10, latestEndBlock: 260, // the end block resolved by the done check phase attemptTimeoutBlock: 277, // start block of the second attempt + 30 }, @@ -436,6 +442,7 @@ func TestSigningRetryLoop(t *testing.T) { expectedErr: nil, expectedResult: &signingRetryLoopResult{ result: testResult, + activeMembersCount: 10, latestEndBlock: 260, // the end block resolved by the done check phase attemptTimeoutBlock: 277, // start block of the second attempt + 30 }, @@ -541,6 +548,7 @@ func TestSigningRetryLoop(t *testing.T) { expectedErr: nil, expectedResult: &signingRetryLoopResult{ result: testResult, + activeMembersCount: 10, latestEndBlock: 260, // the end block resolved by the done check phase attemptTimeoutBlock: 277, // start block of the second attempt + 30 }, diff --git a/pkg/tbtc/signing_test.go b/pkg/tbtc/signing_test.go index 1ace228c72..9bcd42ef6c 100644 --- a/pkg/tbtc/signing_test.go +++ b/pkg/tbtc/signing_test.go @@ -27,7 +27,7 @@ func TestSigningExecutor_Sign(t *testing.T) { message := big.NewInt(100) startBlock := uint64(0) - signature, endBlock, err := executor.sign(ctx, message, startBlock) + signature, _, endBlock, err := executor.sign(ctx, message, startBlock) if err != nil { t.Fatal(err) } @@ -59,13 +59,13 @@ func TestSigningExecutor_Sign_Busy(t *testing.T) { errChan := make(chan error, 1) go func() { - _, _, err := executor.sign(ctx, message, startBlock) + _, _, _, err := executor.sign(ctx, message, startBlock) errChan <- err }() time.Sleep(100 * time.Millisecond) - _, _, err := executor.sign(ctx, message, startBlock) + _, _, _, err := executor.sign(ctx, message, startBlock) testutils.AssertErrorsSame(t, errSigningExecutorBusy, err) err = <-errChan