From a01f4bfc02ff75236948e3006781fb53c8cd26e4 Mon Sep 17 00:00:00 2001 From: Arash Deshmeh Date: Thu, 14 Aug 2025 14:22:02 -0400 Subject: [PATCH 1/6] QoS Solana: disqualify endpoints with recent non-JSONRPC response --- .../qos/jsonrpc_validation_error.pb.go | 194 ++++++++++++++++++ observation/qos/solana.pb.go | 49 +++-- proto/path/qos/jsonrpc_validation_error.proto | 24 +++ proto/path/qos/solana.proto | 6 +- qos/solana/endpoint.go | 61 ++++-- qos/solana/response_generic.go | 54 +++-- 6 files changed, 335 insertions(+), 53 deletions(-) create mode 100644 observation/qos/jsonrpc_validation_error.pb.go create mode 100644 proto/path/qos/jsonrpc_validation_error.proto diff --git a/observation/qos/jsonrpc_validation_error.pb.go b/observation/qos/jsonrpc_validation_error.pb.go new file mode 100644 index 000000000..f0cfc7350 --- /dev/null +++ b/observation/qos/jsonrpc_validation_error.pb.go @@ -0,0 +1,194 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.6 +// protoc v5.29.3 +// source: path/qos/jsonrpc_validation_error.proto + +package qos + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +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) +) + +// Enum for different types of JSON-RPC validation errors +type JsonRpcValidationErrorType int32 + +const ( + // Default/unspecified validation error + JsonRpcValidationErrorType_JSON_RPC_VALIDATION_ERROR_TYPE_UNSPECIFIED JsonRpcValidationErrorType = 0 + // Response is not a valid JSON-RPC response + JsonRpcValidationErrorType_NON_JSONRPC_RESPONSE JsonRpcValidationErrorType = 1 +) + +// Enum value maps for JsonRpcValidationErrorType. +var ( + JsonRpcValidationErrorType_name = map[int32]string{ + 0: "JSON_RPC_VALIDATION_ERROR_TYPE_UNSPECIFIED", + 1: "NON_JSONRPC_RESPONSE", + } + JsonRpcValidationErrorType_value = map[string]int32{ + "JSON_RPC_VALIDATION_ERROR_TYPE_UNSPECIFIED": 0, + "NON_JSONRPC_RESPONSE": 1, + } +) + +func (x JsonRpcValidationErrorType) Enum() *JsonRpcValidationErrorType { + p := new(JsonRpcValidationErrorType) + *p = x + return p +} + +func (x JsonRpcValidationErrorType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (JsonRpcValidationErrorType) Descriptor() protoreflect.EnumDescriptor { + return file_path_qos_jsonrpc_validation_error_proto_enumTypes[0].Descriptor() +} + +func (JsonRpcValidationErrorType) Type() protoreflect.EnumType { + return &file_path_qos_jsonrpc_validation_error_proto_enumTypes[0] +} + +func (x JsonRpcValidationErrorType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use JsonRpcValidationErrorType.Descriptor instead. +func (JsonRpcValidationErrorType) EnumDescriptor() ([]byte, []int) { + return file_path_qos_jsonrpc_validation_error_proto_rawDescGZIP(), []int{0} +} + +// JsonRpcResponseValidationError captures validation errors for JSON-RPC responses +type JsonRpcResponseValidationError struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Type of validation error + ErrorType JsonRpcValidationErrorType `protobuf:"varint,1,opt,name=error_type,json=errorType,proto3,enum=path.qos.JsonRpcValidationErrorType" json:"error_type,omitempty"` + // Timestamp when the validation error occurred + Timestamp *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *JsonRpcResponseValidationError) Reset() { + *x = JsonRpcResponseValidationError{} + mi := &file_path_qos_jsonrpc_validation_error_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *JsonRpcResponseValidationError) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*JsonRpcResponseValidationError) ProtoMessage() {} + +func (x *JsonRpcResponseValidationError) ProtoReflect() protoreflect.Message { + mi := &file_path_qos_jsonrpc_validation_error_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use JsonRpcResponseValidationError.ProtoReflect.Descriptor instead. +func (*JsonRpcResponseValidationError) Descriptor() ([]byte, []int) { + return file_path_qos_jsonrpc_validation_error_proto_rawDescGZIP(), []int{0} +} + +func (x *JsonRpcResponseValidationError) GetErrorType() JsonRpcValidationErrorType { + if x != nil { + return x.ErrorType + } + return JsonRpcValidationErrorType_JSON_RPC_VALIDATION_ERROR_TYPE_UNSPECIFIED +} + +func (x *JsonRpcResponseValidationError) GetTimestamp() *timestamppb.Timestamp { + if x != nil { + return x.Timestamp + } + return nil +} + +var File_path_qos_jsonrpc_validation_error_proto protoreflect.FileDescriptor + +const file_path_qos_jsonrpc_validation_error_proto_rawDesc = "" + + "\n" + + "'path/qos/jsonrpc_validation_error.proto\x12\bpath.qos\x1a\x1fgoogle/protobuf/timestamp.proto\"\x9f\x01\n" + + "\x1eJsonRpcResponseValidationError\x12C\n" + + "\n" + + "error_type\x18\x01 \x01(\x0e2$.path.qos.JsonRpcValidationErrorTypeR\terrorType\x128\n" + + "\ttimestamp\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp*f\n" + + "\x1aJsonRpcValidationErrorType\x12.\n" + + "*JSON_RPC_VALIDATION_ERROR_TYPE_UNSPECIFIED\x10\x00\x12\x18\n" + + "\x14NON_JSONRPC_RESPONSE\x10\x01B0Z.github.com/buildwithgrove/path/observation/qosb\x06proto3" + +var ( + file_path_qos_jsonrpc_validation_error_proto_rawDescOnce sync.Once + file_path_qos_jsonrpc_validation_error_proto_rawDescData []byte +) + +func file_path_qos_jsonrpc_validation_error_proto_rawDescGZIP() []byte { + file_path_qos_jsonrpc_validation_error_proto_rawDescOnce.Do(func() { + file_path_qos_jsonrpc_validation_error_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_path_qos_jsonrpc_validation_error_proto_rawDesc), len(file_path_qos_jsonrpc_validation_error_proto_rawDesc))) + }) + return file_path_qos_jsonrpc_validation_error_proto_rawDescData +} + +var file_path_qos_jsonrpc_validation_error_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_path_qos_jsonrpc_validation_error_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_path_qos_jsonrpc_validation_error_proto_goTypes = []any{ + (JsonRpcValidationErrorType)(0), // 0: path.qos.JsonRpcValidationErrorType + (*JsonRpcResponseValidationError)(nil), // 1: path.qos.JsonRpcResponseValidationError + (*timestamppb.Timestamp)(nil), // 2: google.protobuf.Timestamp +} +var file_path_qos_jsonrpc_validation_error_proto_depIdxs = []int32{ + 0, // 0: path.qos.JsonRpcResponseValidationError.error_type:type_name -> path.qos.JsonRpcValidationErrorType + 2, // 1: path.qos.JsonRpcResponseValidationError.timestamp:type_name -> google.protobuf.Timestamp + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_path_qos_jsonrpc_validation_error_proto_init() } +func file_path_qos_jsonrpc_validation_error_proto_init() { + if File_path_qos_jsonrpc_validation_error_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_path_qos_jsonrpc_validation_error_proto_rawDesc), len(file_path_qos_jsonrpc_validation_error_proto_rawDesc)), + NumEnums: 1, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_path_qos_jsonrpc_validation_error_proto_goTypes, + DependencyIndexes: file_path_qos_jsonrpc_validation_error_proto_depIdxs, + EnumInfos: file_path_qos_jsonrpc_validation_error_proto_enumTypes, + MessageInfos: file_path_qos_jsonrpc_validation_error_proto_msgTypes, + }.Build() + File_path_qos_jsonrpc_validation_error_proto = out.File + file_path_qos_jsonrpc_validation_error_proto_goTypes = nil + file_path_qos_jsonrpc_validation_error_proto_depIdxs = nil +} diff --git a/observation/qos/solana.pb.go b/observation/qos/solana.pb.go index 478bdb87a..faa7a7d70 100644 --- a/observation/qos/solana.pb.go +++ b/observation/qos/solana.pb.go @@ -354,6 +354,8 @@ func (x *SolanaGetHealthResponse) GetResult() string { type SolanaUnrecognizedResponse struct { state protoimpl.MessageState `protogen:"open.v1"` JsonrpcResponse *JsonRpcResponse `protobuf:"bytes,1,opt,name=jsonrpc_response,json=jsonrpcResponse,proto3" json:"jsonrpc_response,omitempty"` + // Optional validation error information + ValidationError *JsonRpcResponseValidationError `protobuf:"bytes,2,opt,name=validation_error,json=validationError,proto3,oneof" json:"validation_error,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -395,11 +397,18 @@ func (x *SolanaUnrecognizedResponse) GetJsonrpcResponse() *JsonRpcResponse { return nil } +func (x *SolanaUnrecognizedResponse) GetValidationError() *JsonRpcResponseValidationError { + if x != nil { + return x.ValidationError + } + return nil +} + var File_path_qos_solana_proto protoreflect.FileDescriptor const file_path_qos_solana_proto_rawDesc = "" + "\n" + - "\x15path/qos/solana.proto\x12\bpath.qos\x1a\x16path/qos/jsonrpc.proto\x1a\x1dpath/qos/request_origin.proto\x1a\x1cpath/qos/request_error.proto\"\xd5\x03\n" + + "\x15path/qos/solana.proto\x12\bpath.qos\x1a\x16path/qos/jsonrpc.proto\x1a\x1dpath/qos/request_origin.proto\x1a\x1cpath/qos/request_error.proto\x1a'path/qos/jsonrpc_validation_error.proto\"\xd5\x03\n" + "\x19SolanaRequestObservations\x12\x19\n" + "\bchain_id\x18\x01 \x01(\tR\achainId\x12\x1d\n" + "\n" + @@ -421,9 +430,11 @@ const file_path_qos_solana_proto_rawDesc = "" + "\fblock_height\x18\x01 \x01(\x04R\vblockHeight\x12\x14\n" + "\x05epoch\x18\x02 \x01(\x04R\x05epoch\"1\n" + "\x17SolanaGetHealthResponse\x12\x16\n" + - "\x06result\x18\x01 \x01(\tR\x06result\"b\n" + + "\x06result\x18\x01 \x01(\tR\x06result\"\xd1\x01\n" + "\x1aSolanaUnrecognizedResponse\x12D\n" + - "\x10jsonrpc_response\x18\x01 \x01(\v2\x19.path.qos.JsonRpcResponseR\x0fjsonrpcResponseB0Z.github.com/buildwithgrove/path/observation/qosb\x06proto3" + "\x10jsonrpc_response\x18\x01 \x01(\v2\x19.path.qos.JsonRpcResponseR\x0fjsonrpcResponse\x12X\n" + + "\x10validation_error\x18\x02 \x01(\v2(.path.qos.JsonRpcResponseValidationErrorH\x00R\x0fvalidationError\x88\x01\x01B\x13\n" + + "\x11_validation_errorB0Z.github.com/buildwithgrove/path/observation/qosb\x06proto3" var ( file_path_qos_solana_proto_rawDescOnce sync.Once @@ -439,15 +450,16 @@ func file_path_qos_solana_proto_rawDescGZIP() []byte { var file_path_qos_solana_proto_msgTypes = make([]protoimpl.MessageInfo, 5) var file_path_qos_solana_proto_goTypes = []any{ - (*SolanaRequestObservations)(nil), // 0: path.qos.SolanaRequestObservations - (*SolanaEndpointObservation)(nil), // 1: path.qos.SolanaEndpointObservation - (*SolanaGetEpochInfoResponse)(nil), // 2: path.qos.SolanaGetEpochInfoResponse - (*SolanaGetHealthResponse)(nil), // 3: path.qos.SolanaGetHealthResponse - (*SolanaUnrecognizedResponse)(nil), // 4: path.qos.SolanaUnrecognizedResponse - (RequestOrigin)(0), // 5: path.qos.RequestOrigin - (*RequestError)(nil), // 6: path.qos.RequestError - (*JsonRpcRequest)(nil), // 7: path.qos.JsonRpcRequest - (*JsonRpcResponse)(nil), // 8: path.qos.JsonRpcResponse + (*SolanaRequestObservations)(nil), // 0: path.qos.SolanaRequestObservations + (*SolanaEndpointObservation)(nil), // 1: path.qos.SolanaEndpointObservation + (*SolanaGetEpochInfoResponse)(nil), // 2: path.qos.SolanaGetEpochInfoResponse + (*SolanaGetHealthResponse)(nil), // 3: path.qos.SolanaGetHealthResponse + (*SolanaUnrecognizedResponse)(nil), // 4: path.qos.SolanaUnrecognizedResponse + (RequestOrigin)(0), // 5: path.qos.RequestOrigin + (*RequestError)(nil), // 6: path.qos.RequestError + (*JsonRpcRequest)(nil), // 7: path.qos.JsonRpcRequest + (*JsonRpcResponse)(nil), // 8: path.qos.JsonRpcResponse + (*JsonRpcResponseValidationError)(nil), // 9: path.qos.JsonRpcResponseValidationError } var file_path_qos_solana_proto_depIdxs = []int32{ 5, // 0: path.qos.SolanaRequestObservations.request_origin:type_name -> path.qos.RequestOrigin @@ -458,11 +470,12 @@ var file_path_qos_solana_proto_depIdxs = []int32{ 3, // 5: path.qos.SolanaEndpointObservation.get_health_response:type_name -> path.qos.SolanaGetHealthResponse 4, // 6: path.qos.SolanaEndpointObservation.unrecognized_response:type_name -> path.qos.SolanaUnrecognizedResponse 8, // 7: path.qos.SolanaUnrecognizedResponse.jsonrpc_response:type_name -> path.qos.JsonRpcResponse - 8, // [8:8] is the sub-list for method output_type - 8, // [8:8] is the sub-list for method input_type - 8, // [8:8] is the sub-list for extension type_name - 8, // [8:8] is the sub-list for extension extendee - 0, // [0:8] is the sub-list for field type_name + 9, // 8: path.qos.SolanaUnrecognizedResponse.validation_error:type_name -> path.qos.JsonRpcResponseValidationError + 9, // [9:9] is the sub-list for method output_type + 9, // [9:9] is the sub-list for method input_type + 9, // [9:9] is the sub-list for extension type_name + 9, // [9:9] is the sub-list for extension extendee + 0, // [0:9] is the sub-list for field type_name } func init() { file_path_qos_solana_proto_init() } @@ -473,12 +486,14 @@ func file_path_qos_solana_proto_init() { file_path_qos_jsonrpc_proto_init() file_path_qos_request_origin_proto_init() file_path_qos_request_error_proto_init() + file_path_qos_jsonrpc_validation_error_proto_init() file_path_qos_solana_proto_msgTypes[0].OneofWrappers = []any{} file_path_qos_solana_proto_msgTypes[1].OneofWrappers = []any{ (*SolanaEndpointObservation_GetEpochInfoResponse)(nil), (*SolanaEndpointObservation_GetHealthResponse)(nil), (*SolanaEndpointObservation_UnrecognizedResponse)(nil), } + file_path_qos_solana_proto_msgTypes[4].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ diff --git a/proto/path/qos/jsonrpc_validation_error.proto b/proto/path/qos/jsonrpc_validation_error.proto new file mode 100644 index 000000000..3ae9cc5e3 --- /dev/null +++ b/proto/path/qos/jsonrpc_validation_error.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; +package path.qos; + +option go_package = "github.com/buildwithgrove/path/observation/qos"; + +import "google/protobuf/timestamp.proto"; + +// JsonRpcResponseValidationError captures validation errors for JSON-RPC responses +message JsonRpcResponseValidationError { + // Type of validation error + JsonRpcValidationErrorType error_type = 1; + + // Timestamp when the validation error occurred + google.protobuf.Timestamp timestamp = 2; +} + +// Enum for different types of JSON-RPC validation errors +enum JsonRpcValidationErrorType { + // Default/unspecified validation error + JSON_RPC_VALIDATION_ERROR_TYPE_UNSPECIFIED = 0; + + // Response is not a valid JSON-RPC response + NON_JSONRPC_RESPONSE = 1; +} \ No newline at end of file diff --git a/proto/path/qos/solana.proto b/proto/path/qos/solana.proto index bbcb4f08f..570922e98 100644 --- a/proto/path/qos/solana.proto +++ b/proto/path/qos/solana.proto @@ -6,6 +6,7 @@ option go_package = "github.com/buildwithgrove/path/observation/qos"; import "path/qos/jsonrpc.proto"; import "path/qos/request_origin.proto"; import "path/qos/request_error.proto"; +import "path/qos/jsonrpc_validation_error.proto"; // SolanaRequestObservations captures QoS data for a single Solana blockchain service request, // including all observations made during potential retries. @@ -79,4 +80,7 @@ message SolanaGetHealthResponse { // Examples: getTokenSupply, getTransaction message SolanaUnrecognizedResponse { JsonRpcResponse jsonrpc_response = 1; -} + + // Optional validation error information + optional JsonRpcResponseValidationError validation_error = 2; +} \ No newline at end of file diff --git a/qos/solana/endpoint.go b/qos/solana/endpoint.go index 4d76435e4..aaadf9fc5 100644 --- a/qos/solana/endpoint.go +++ b/qos/solana/endpoint.go @@ -2,6 +2,7 @@ package solana import ( "fmt" + "time" qosobservations "github.com/buildwithgrove/path/observation/qos" ) @@ -9,6 +10,11 @@ import ( // Expected value of the `result` field to a `getHealth` request. const resultGetHealthOK = "ok" +const ( + // TODO_TECHDEBT(@adshmh): Make this configurable. + validationErrorWindow = 30 * time.Minute +) + // The errors below list all the possible basic validation errors on an endpoint. var ( errNoGetHealthObs = fmt.Errorf("endpoint has not had an observation of its response to a %q request", methodGetHealth) @@ -16,30 +22,36 @@ var ( errNoGetEpochInfoObs = fmt.Errorf("endpoint has not had an observation of its response to a %q request", methodGetEpochInfo) errInvalidGetEpochInfoHeightZeroObs = fmt.Errorf("endpoint responded with blockHeight of 0 to a %q request, expected a blockHeight of > 0", methodGetEpochInfo) errInvalidGetEpochInfoEpochZeroObs = fmt.Errorf("endpoint responded with epoch of 0 to a %q request, expected an epoch of > 0", methodGetEpochInfo) + errRecentValidationError = fmt.Errorf("endpoint has recent JSON-RPC validation errors") ) -// endpoint captures the details required to validate a Solana endpoint. +// TODO_TECHDEBT(@adshmh): Include a Sanctions mechanism to handle endpoints with dishonest behavior, e.g. using public RPCs. +// +// endpoint captures details required to validate a Solana endpoint. type endpoint struct { - // SolanaGetHealthResponse stores the result of processing the endpoint's response to a `getHealth` request. - // A pointer is used to distinguish between the following scenarios: - // 1. There has NOT been an observation of the endpoint's response to a `getHealth` request, and - // 2. There has been an observation of the endpoint's response to a `getHealth` request. + // SolanaGetHealthResponse stores result of processing endpoint's `getHealth` response. + // Pointer distinguishes between no observation vs. observed response scenarios. *qosobservations.SolanaGetHealthResponse - // SolanaGetEpochInfoResponse stores the result of processing the endpoint's response to a `getEpochInfo` request. - // A pointer is used to distinguish between the following scenarios: - // 1. There has NOT been an observation of the endpoint's response to a `getEpochInfo` request - // 2. There has been an observation of the endpoint's response to a `getEpochInfo` request + // SolanaGetEpochInfoResponse stores result of processing endpoint's `getEpochInfo` response. + // Pointer distinguishes between no observation vs. observed response scenarios. *qosobservations.SolanaGetEpochInfoResponse + // latestValidationError tracks most recent JSON-RPC response validation error + latestValidationError *qosobservations.JsonRpcResponseValidationError + // TODO_FUTURE: support archival endpoints. } -// validateBasic checks if the endpoint has the required observations to be considered valid. -// Returns an error if the necessary responses are either lacking or invalid. +// validateBasic checks if endpoint has required observations to be valid. +// Returns error if necessary responses are lacking, invalid, or have recent validation errors. func (e endpoint) validateBasic() error { - switch { + // Check for recent validation errors first + if e.hasRecentValidationErrors() { + return errRecentValidationError + } + switch { case e.SolanaGetHealthResponse == nil: return errNoGetHealthObs @@ -60,8 +72,18 @@ func (e endpoint) validateBasic() error { } } -// applyObservation updates the endpoint data using the provided observation. -// Returns true if the observation was recognized. +// hasRecentValidationErrors checks if endpoint has validation error within the configured window. +func (e endpoint) hasRecentValidationErrors() bool { + if e.latestValidationError == nil { + return false + } + + cutoff := time.Now().Add(-validationErrorWindow) + return e.latestValidationError.Timestamp.AsTime().After(cutoff) +} + +// applyObservation updates endpoint data using provided observation. +// Returns true if observation was recognized. // IMPORTANT: This function mutates the endpoint. func (e *endpoint) applyObservation(obs *qosobservations.SolanaEndpointObservation) bool { if epochInfoResponse := obs.GetGetEpochInfoResponse(); epochInfoResponse != nil { @@ -74,5 +96,16 @@ func (e *endpoint) applyObservation(obs *qosobservations.SolanaEndpointObservati return true } + if unrecognizedResponse := obs.GetUnrecognizedResponse(); unrecognizedResponse != nil { + // Update latest validation error if observation contains more recent error + if validationError := unrecognizedResponse.ValidationError; validationError != nil { + if e.latestValidationError == nil || + validationError.Timestamp.AsTime().After(e.latestValidationError.Timestamp.AsTime()) { + e.latestValidationError = validationError + } + } + return true + } + return false } diff --git a/qos/solana/response_generic.go b/qos/solana/response_generic.go index 8da18e452..69a1d2408 100644 --- a/qos/solana/response_generic.go +++ b/qos/solana/response_generic.go @@ -2,8 +2,10 @@ package solana import ( "encoding/json" + "time" "github.com/pokt-network/poktroll/pkg/polylog" + "google.golang.org/protobuf/types/known/timestamppb" qosobservations "github.com/buildwithgrove/path/observation/qos" "github.com/buildwithgrove/path/qos/jsonrpc" @@ -45,43 +47,46 @@ func responseUnmarshallerGeneric( } } -// TODO_MVP(@adshmh): implement the generic jsonrpc response -// (with the scope limited to the Solana blockchain) -// responseGeneric captures the fields expected in response to any request on the Solana blockchain. -// It is intended to be used when no validation/observation is applicable to the corresponding request's JSON-RPC method. -// i.e. when there are no unmarshallers/structs matching the method specified by the request. +// responseGeneric captures fields for any Solana blockchain response. +// Used when no validation/observation applies to the request's JSON-RPC method. type responseGeneric struct { Logger polylog.Logger jsonrpc.Response + // validationError tracks JSON-RPC validation errors if response unmarshaling failed + validationError *qosobservations.JsonRpcResponseValidationError } -// GetObservation returns an observation that is NOT used in validating endpoints. -// This allows sharing data with other entities, e.g. a data pipeline. -// Implements the response interface. -// As of PR 372, this is a default catchall for any response to any requests other than `getHealth` and `getEpochInfo`. +// GetObservation returns observation NOT used for endpoint validation. +// Shares data with other entities (e.g., data pipeline). +// As of PR 372, default catchall for responses other than `getHealth` and `getEpochInfo`. func (r responseGeneric) GetObservation() qosobservations.SolanaEndpointObservation { - return qosobservations.SolanaEndpointObservation{ + unrecognizedResponse := &qosobservations.SolanaUnrecognizedResponse{ // TODO_TECHDEBT(@adshmh): set additional JSON-RPC response fields, specifically the `error` object, on the observation. // This needs a utility function to convert a `qos.jsonrpc.Response` to an `observation.qos.JsonRpcResponse. + JsonrpcResponse: &qosobservations.JsonRpcResponse{ + Id: r.ID.String(), + }, + } + + // Include validation error if present + if r.validationError != nil { + unrecognizedResponse.ValidationError = r.validationError + } + + return qosobservations.SolanaEndpointObservation{ ResponseObservation: &qosobservations.SolanaEndpointObservation_UnrecognizedResponse{ - UnrecognizedResponse: &qosobservations.SolanaUnrecognizedResponse{ - JsonrpcResponse: &qosobservations.JsonRpcResponse{ - Id: r.ID.String(), - }, - }, + UnrecognizedResponse: unrecognizedResponse, }, } } -// GetResponsePayload returns the payload for the response to a `/health` request. -// Implements the response interface. -// -// TODO_MVP(@adshmh): handle any unmarshaling errors and build a method-specific payload generator. +// GetJSONRPCResponse returns response payload. func (r responseGeneric) GetJSONRPCResponse() jsonrpc.Response { return r.Response } -// getGenericJSONRPCErrResponse returns a generic response wrapped around a JSON-RPC error response with the supplied ID, error, and the invalid payload in the "data" field. +// getGenericJSONRPCErrResponse returns generic response with JSON-RPC error and validation error observation. +// Includes supplied ID, error, and invalid payload in "data" field. func getGenericJSONRPCErrResponse( logger polylog.Logger, id jsonrpc.ID, @@ -93,7 +98,14 @@ func getGenericJSONRPCErrResponse( errDataFieldUnmarshalingErr: err.Error(), } + // Create validation error observation + validationError := &qosobservations.JsonRpcResponseValidationError{ + ErrorType: qosobservations.JsonRpcValidationErrorType_NON_JSONRPC_RESPONSE, + Timestamp: timestamppb.New(time.Now()), + } + return responseGeneric{ - Response: jsonrpc.GetErrorResponse(id, errCodeUnmarshaling, errMsgUnmarshaling, errData), + Response: jsonrpc.GetErrorResponse(id, errCodeUnmarshaling, errMsgUnmarshaling, errData), + validationError: validationError, } } From 282b28e4128a97813f352d6d4c3331d28a25c618 Mon Sep 17 00:00:00 2001 From: Daniel Olshansky Date: Thu, 14 Aug 2025 13:25:36 -0700 Subject: [PATCH 2/6] WIP review --- observation/qos/jsonrpc_validation_error.pb.go | 14 +++++++------- proto/path/qos/jsonrpc_validation_error.proto | 8 ++++---- proto/path/qos/solana.proto | 4 ++-- qos/solana/endpoint.go | 13 +++++++------ qos/solana/response_generic.go | 8 ++++---- 5 files changed, 24 insertions(+), 23 deletions(-) diff --git a/observation/qos/jsonrpc_validation_error.pb.go b/observation/qos/jsonrpc_validation_error.pb.go index f0cfc7350..f3d2708d4 100644 --- a/observation/qos/jsonrpc_validation_error.pb.go +++ b/observation/qos/jsonrpc_validation_error.pb.go @@ -29,18 +29,18 @@ const ( // Default/unspecified validation error JsonRpcValidationErrorType_JSON_RPC_VALIDATION_ERROR_TYPE_UNSPECIFIED JsonRpcValidationErrorType = 0 // Response is not a valid JSON-RPC response - JsonRpcValidationErrorType_NON_JSONRPC_RESPONSE JsonRpcValidationErrorType = 1 + JsonRpcValidationErrorType_JSON_RPC_VALIDATION_ERROR_TYPE_NON_JSONRPC_RESPONSE JsonRpcValidationErrorType = 1 ) // Enum value maps for JsonRpcValidationErrorType. var ( JsonRpcValidationErrorType_name = map[int32]string{ 0: "JSON_RPC_VALIDATION_ERROR_TYPE_UNSPECIFIED", - 1: "NON_JSONRPC_RESPONSE", + 1: "JSON_RPC_VALIDATION_ERROR_TYPE_NON_JSONRPC_RESPONSE", } JsonRpcValidationErrorType_value = map[string]int32{ - "JSON_RPC_VALIDATION_ERROR_TYPE_UNSPECIFIED": 0, - "NON_JSONRPC_RESPONSE": 1, + "JSON_RPC_VALIDATION_ERROR_TYPE_UNSPECIFIED": 0, + "JSON_RPC_VALIDATION_ERROR_TYPE_NON_JSONRPC_RESPONSE": 1, } ) @@ -134,10 +134,10 @@ const file_path_qos_jsonrpc_validation_error_proto_rawDesc = "" + "\x1eJsonRpcResponseValidationError\x12C\n" + "\n" + "error_type\x18\x01 \x01(\x0e2$.path.qos.JsonRpcValidationErrorTypeR\terrorType\x128\n" + - "\ttimestamp\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp*f\n" + + "\ttimestamp\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp*\x85\x01\n" + "\x1aJsonRpcValidationErrorType\x12.\n" + - "*JSON_RPC_VALIDATION_ERROR_TYPE_UNSPECIFIED\x10\x00\x12\x18\n" + - "\x14NON_JSONRPC_RESPONSE\x10\x01B0Z.github.com/buildwithgrove/path/observation/qosb\x06proto3" + "*JSON_RPC_VALIDATION_ERROR_TYPE_UNSPECIFIED\x10\x00\x127\n" + + "3JSON_RPC_VALIDATION_ERROR_TYPE_NON_JSONRPC_RESPONSE\x10\x01B0Z.github.com/buildwithgrove/path/observation/qosb\x06proto3" var ( file_path_qos_jsonrpc_validation_error_proto_rawDescOnce sync.Once diff --git a/proto/path/qos/jsonrpc_validation_error.proto b/proto/path/qos/jsonrpc_validation_error.proto index 3ae9cc5e3..bdea68715 100644 --- a/proto/path/qos/jsonrpc_validation_error.proto +++ b/proto/path/qos/jsonrpc_validation_error.proto @@ -9,7 +9,7 @@ import "google/protobuf/timestamp.proto"; message JsonRpcResponseValidationError { // Type of validation error JsonRpcValidationErrorType error_type = 1; - + // Timestamp when the validation error occurred google.protobuf.Timestamp timestamp = 2; } @@ -18,7 +18,7 @@ message JsonRpcResponseValidationError { enum JsonRpcValidationErrorType { // Default/unspecified validation error JSON_RPC_VALIDATION_ERROR_TYPE_UNSPECIFIED = 0; - + // Response is not a valid JSON-RPC response - NON_JSONRPC_RESPONSE = 1; -} \ No newline at end of file + JSON_RPC_VALIDATION_ERROR_TYPE_NON_JSONRPC_RESPONSE = 1; +} diff --git a/proto/path/qos/solana.proto b/proto/path/qos/solana.proto index 570922e98..b43a8b41d 100644 --- a/proto/path/qos/solana.proto +++ b/proto/path/qos/solana.proto @@ -80,7 +80,7 @@ message SolanaGetHealthResponse { // Examples: getTokenSupply, getTransaction message SolanaUnrecognizedResponse { JsonRpcResponse jsonrpc_response = 1; - + // Optional validation error information optional JsonRpcResponseValidationError validation_error = 2; -} \ No newline at end of file +} diff --git a/qos/solana/endpoint.go b/qos/solana/endpoint.go index aaadf9fc5..49d8fcc04 100644 --- a/qos/solana/endpoint.go +++ b/qos/solana/endpoint.go @@ -11,8 +11,10 @@ import ( const resultGetHealthOK = "ok" const ( - // TODO_TECHDEBT(@adshmh): Make this configurable. - validationErrorWindow = 30 * time.Minute + // TODO_TECHDEBT(@adshmh): Make configurable via service config. + // 30 minutes allows for temporary network issues while preventing + // persistently broken endpoints from being used. + validationErrorResponseWindow = 30 * time.Minute ) // The errors below list all the possible basic validation errors on an endpoint. @@ -25,9 +27,8 @@ var ( errRecentValidationError = fmt.Errorf("endpoint has recent JSON-RPC validation errors") ) -// TODO_TECHDEBT(@adshmh): Include a Sanctions mechanism to handle endpoints with dishonest behavior, e.g. using public RPCs. -// // endpoint captures details required to validate a Solana endpoint. +// TODO_TECHDEBT(@adshmh): Add sanctions mechanism for dishonest endpoints (e.g., using public RPCs). type endpoint struct { // SolanaGetHealthResponse stores result of processing endpoint's `getHealth` response. // Pointer distinguishes between no observation vs. observed response scenarios. @@ -78,8 +79,8 @@ func (e endpoint) hasRecentValidationErrors() bool { return false } - cutoff := time.Now().Add(-validationErrorWindow) - return e.latestValidationError.Timestamp.AsTime().After(cutoff) + lastValidationErrorTimestamp := time.Now().Add(-validationErrorResponseWindow) + return e.latestValidationError.Timestamp.AsTime().After(lastValidationErrorTimestamp) } // applyObservation updates endpoint data using provided observation. diff --git a/qos/solana/response_generic.go b/qos/solana/response_generic.go index 69a1d2408..b88bf1039 100644 --- a/qos/solana/response_generic.go +++ b/qos/solana/response_generic.go @@ -58,11 +58,11 @@ type responseGeneric struct { // GetObservation returns observation NOT used for endpoint validation. // Shares data with other entities (e.g., data pipeline). -// As of PR 372, default catchall for responses other than `getHealth` and `getEpochInfo`. +// Default catchall for responses other than `getHealth` and `getEpochInfo`. func (r responseGeneric) GetObservation() qosobservations.SolanaEndpointObservation { unrecognizedResponse := &qosobservations.SolanaUnrecognizedResponse{ - // TODO_TECHDEBT(@adshmh): set additional JSON-RPC response fields, specifically the `error` object, on the observation. - // This needs a utility function to convert a `qos.jsonrpc.Response` to an `observation.qos.JsonRpcResponse. + // TODO_TECHDEBT(@adshmh): Add utility to convert qos.jsonrpc.Response to observation.qos.JsonRpcResponse + // to include error object and other fields in observations. JsonrpcResponse: &qosobservations.JsonRpcResponse{ Id: r.ID.String(), }, @@ -100,7 +100,7 @@ func getGenericJSONRPCErrResponse( // Create validation error observation validationError := &qosobservations.JsonRpcResponseValidationError{ - ErrorType: qosobservations.JsonRpcValidationErrorType_NON_JSONRPC_RESPONSE, + ErrorType: qosobservations.JsonRpcValidationErrorType_JSON_RPC_VALIDATION_ERROR_TYPE_NON_JSONRPC_RESPONSE, Timestamp: timestamppb.New(time.Now()), } From a938d033e0758c14428c9e525692b93043d6e0ad Mon Sep 17 00:00:00 2001 From: Arash <23505281+adshmh@users.noreply.github.com> Date: Thu, 14 Aug 2025 20:34:08 -0400 Subject: [PATCH 3/6] Update qos/solana/endpoint.go Co-authored-by: Daniel Olshansky --- qos/solana/endpoint.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qos/solana/endpoint.go b/qos/solana/endpoint.go index 49d8fcc04..703307038 100644 --- a/qos/solana/endpoint.go +++ b/qos/solana/endpoint.go @@ -24,7 +24,7 @@ var ( errNoGetEpochInfoObs = fmt.Errorf("endpoint has not had an observation of its response to a %q request", methodGetEpochInfo) errInvalidGetEpochInfoHeightZeroObs = fmt.Errorf("endpoint responded with blockHeight of 0 to a %q request, expected a blockHeight of > 0", methodGetEpochInfo) errInvalidGetEpochInfoEpochZeroObs = fmt.Errorf("endpoint responded with epoch of 0 to a %q request, expected an epoch of > 0", methodGetEpochInfo) - errRecentValidationError = fmt.Errorf("endpoint has recent JSON-RPC validation errors") + errRecentJSONRPCValidationError = fmt.Errorf("endpoint has recent JSON-RPC validation errors") ) // endpoint captures details required to validate a Solana endpoint. From a104834c15bb20d1b48791b8f4d417e866907494 Mon Sep 17 00:00:00 2001 From: Arash Deshmeh Date: Fri, 15 Aug 2025 10:29:10 -0400 Subject: [PATCH 4/6] Address review comments --- observation/qos/jsonrpc_validation_error.pb.go | 4 ++++ proto/path/qos/jsonrpc_validation_error.proto | 3 +++ qos/solana/endpoint.go | 9 +++++++-- qos/solana/response_generic.go | 14 +++++++------- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/observation/qos/jsonrpc_validation_error.pb.go b/observation/qos/jsonrpc_validation_error.pb.go index f3d2708d4..9f43f43a9 100644 --- a/observation/qos/jsonrpc_validation_error.pb.go +++ b/observation/qos/jsonrpc_validation_error.pb.go @@ -1,3 +1,7 @@ +// TODO_TECHDEBT(@adshmh): Use the JsonRpcResponseValidationError in all JSONRPC QoS. +// Include this refactor in PR #253 (JUDGE framework) +// + // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.6 diff --git a/proto/path/qos/jsonrpc_validation_error.proto b/proto/path/qos/jsonrpc_validation_error.proto index bdea68715..8d9f5b43c 100644 --- a/proto/path/qos/jsonrpc_validation_error.proto +++ b/proto/path/qos/jsonrpc_validation_error.proto @@ -1,3 +1,6 @@ +// TODO_TECHDEBT(@adshmh): Use the JsonRpcResponseValidationError in all JSONRPC QoS. +// Include this refactor in PR #253 (JUDGE framework) +// syntax = "proto3"; package path.qos; diff --git a/qos/solana/endpoint.go b/qos/solana/endpoint.go index 703307038..4fc20a75e 100644 --- a/qos/solana/endpoint.go +++ b/qos/solana/endpoint.go @@ -11,6 +11,12 @@ import ( const resultGetHealthOK = "ok" const ( + // TODO_TECHDEBT(@adshmh): Add sanctions mechanism for dishonest endpoints (e.g., using public RPCs). + // The sanctions store will apply to all QoS packages via PR #253 (JUDGE framework). + // It will replace: + // - The constant below with configurable sanction duration for different errors. + // - endpoint struct's basicValidation method. + // // TODO_TECHDEBT(@adshmh): Make configurable via service config. // 30 minutes allows for temporary network issues while preventing // persistently broken endpoints from being used. @@ -24,11 +30,10 @@ var ( errNoGetEpochInfoObs = fmt.Errorf("endpoint has not had an observation of its response to a %q request", methodGetEpochInfo) errInvalidGetEpochInfoHeightZeroObs = fmt.Errorf("endpoint responded with blockHeight of 0 to a %q request, expected a blockHeight of > 0", methodGetEpochInfo) errInvalidGetEpochInfoEpochZeroObs = fmt.Errorf("endpoint responded with epoch of 0 to a %q request, expected an epoch of > 0", methodGetEpochInfo) - errRecentJSONRPCValidationError = fmt.Errorf("endpoint has recent JSON-RPC validation errors") + errRecentJSONRPCValidationError = fmt.Errorf("endpoint has recent JSON-RPC validation errors") ) // endpoint captures details required to validate a Solana endpoint. -// TODO_TECHDEBT(@adshmh): Add sanctions mechanism for dishonest endpoints (e.g., using public RPCs). type endpoint struct { // SolanaGetHealthResponse stores result of processing endpoint's `getHealth` response. // Pointer distinguishes between no observation vs. observed response scenarios. diff --git a/qos/solana/response_generic.go b/qos/solana/response_generic.go index b88bf1039..2a226d9e4 100644 --- a/qos/solana/response_generic.go +++ b/qos/solana/response_generic.go @@ -52,8 +52,8 @@ func responseUnmarshallerGeneric( type responseGeneric struct { Logger polylog.Logger jsonrpc.Response - // validationError tracks JSON-RPC validation errors if response unmarshaling failed - validationError *qosobservations.JsonRpcResponseValidationError + // jsonrpcResponseValidationError tracks JSON-RPC validation errors if response unmarshaling failed + jsonrpcResponseValidationError *qosobservations.JsonRpcResponseValidationError } // GetObservation returns observation NOT used for endpoint validation. @@ -69,8 +69,8 @@ func (r responseGeneric) GetObservation() qosobservations.SolanaEndpointObservat } // Include validation error if present - if r.validationError != nil { - unrecognizedResponse.ValidationError = r.validationError + if r.jsonrpcResponseValidationError != nil { + unrecognizedResponse.ValidationError = r.jsonrpcResponseValidationError } return qosobservations.SolanaEndpointObservation{ @@ -99,13 +99,13 @@ func getGenericJSONRPCErrResponse( } // Create validation error observation - validationError := &qosobservations.JsonRpcResponseValidationError{ + jsonrpcResponseValidationError := &qosobservations.JsonRpcResponseValidationError{ ErrorType: qosobservations.JsonRpcValidationErrorType_JSON_RPC_VALIDATION_ERROR_TYPE_NON_JSONRPC_RESPONSE, Timestamp: timestamppb.New(time.Now()), } return responseGeneric{ - Response: jsonrpc.GetErrorResponse(id, errCodeUnmarshaling, errMsgUnmarshaling, errData), - validationError: validationError, + Response: jsonrpc.GetErrorResponse(id, errCodeUnmarshaling, errMsgUnmarshaling, errData), + jsonrpcResponseValidationError: jsonrpcResponseValidationError, } } From ce56847faaac572a45fb9629864ff29b0bc6a134 Mon Sep 17 00:00:00 2001 From: Arash Deshmeh Date: Fri, 15 Aug 2025 11:22:52 -0400 Subject: [PATCH 5/6] Add JSONRPC response observations to qos --- observation/qos/jsonrpc.pb.go | 27 ++++++------ .../qos/jsonrpc_validation_error.pb.go | 3 ++ proto/path/qos/jsonrpc.proto | 9 ++-- proto/path/qos/jsonrpc_validation_error.proto | 3 ++ qos/cosmos/check_evm_chainid_test.go | 2 +- qos/cosmos/response_jsonrpc_unrecognized.go | 5 +-- qos/evm/response_generic.go | 5 +-- qos/jsonrpc/observation.go | 41 +++++++++++++++++++ qos/jsonrpc/response.go | 5 +++ qos/solana/endpoint.go | 16 ++++---- qos/solana/response_generic.go | 7 +--- 11 files changed, 85 insertions(+), 38 deletions(-) diff --git a/observation/qos/jsonrpc.pb.go b/observation/qos/jsonrpc.pb.go index 4569457bd..b8316fa98 100644 --- a/observation/qos/jsonrpc.pb.go +++ b/observation/qos/jsonrpc.pb.go @@ -83,10 +83,11 @@ type JsonRpcResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Must match the id value from the corresponding request Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - // JSON-serializable response data - Result string `protobuf:"bytes,2,opt,name=result,proto3" json:"result,omitempty"` + // A preview of the JSONRPC response's `result` field. + // It may have been truncated to reduce message size. + ResultPreview string `protobuf:"bytes,2,opt,name=result_preview,json=resultPreview,proto3" json:"result_preview,omitempty"` // Error details, if the request failed - Err *JsonRpcResponseError `protobuf:"bytes,3,opt,name=err,proto3,oneof" json:"err,omitempty"` + Error *JsonRpcResponseError `protobuf:"bytes,3,opt,name=error,proto3,oneof" json:"error,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -128,16 +129,16 @@ func (x *JsonRpcResponse) GetId() string { return "" } -func (x *JsonRpcResponse) GetResult() string { +func (x *JsonRpcResponse) GetResultPreview() string { if x != nil { - return x.Result + return x.ResultPreview } return "" } -func (x *JsonRpcResponse) GetErr() *JsonRpcResponseError { +func (x *JsonRpcResponse) GetError() *JsonRpcResponseError { if x != nil { - return x.Err + return x.Error } return nil } @@ -207,12 +208,12 @@ const file_path_qos_jsonrpc_proto_rawDesc = "" + "\x16path/qos/jsonrpc.proto\x12\bpath.qos\"8\n" + "\x0eJsonRpcRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x16\n" + - "\x06method\x18\x02 \x01(\tR\x06method\"x\n" + + "\x06method\x18\x02 \x01(\tR\x06method\"\x8d\x01\n" + "\x0fJsonRpcResponse\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\x12\x16\n" + - "\x06result\x18\x02 \x01(\tR\x06result\x125\n" + - "\x03err\x18\x03 \x01(\v2\x1e.path.qos.JsonRpcResponseErrorH\x00R\x03err\x88\x01\x01B\x06\n" + - "\x04_err\"D\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12%\n" + + "\x0eresult_preview\x18\x02 \x01(\tR\rresultPreview\x129\n" + + "\x05error\x18\x03 \x01(\v2\x1e.path.qos.JsonRpcResponseErrorH\x00R\x05error\x88\x01\x01B\b\n" + + "\x06_error\"D\n" + "\x14JsonRpcResponseError\x12\x12\n" + "\x04code\x18\x01 \x01(\x03R\x04code\x12\x18\n" + "\amessage\x18\x02 \x01(\tR\amessageB0Z.github.com/buildwithgrove/path/observation/qosb\x06proto3" @@ -236,7 +237,7 @@ var file_path_qos_jsonrpc_proto_goTypes = []any{ (*JsonRpcResponseError)(nil), // 2: path.qos.JsonRpcResponseError } var file_path_qos_jsonrpc_proto_depIdxs = []int32{ - 2, // 0: path.qos.JsonRpcResponse.err:type_name -> path.qos.JsonRpcResponseError + 2, // 0: path.qos.JsonRpcResponse.error:type_name -> path.qos.JsonRpcResponseError 1, // [1:1] is the sub-list for method output_type 1, // [1:1] is the sub-list for method input_type 1, // [1:1] is the sub-list for extension type_name diff --git a/observation/qos/jsonrpc_validation_error.pb.go b/observation/qos/jsonrpc_validation_error.pb.go index 9f43f43a9..118073793 100644 --- a/observation/qos/jsonrpc_validation_error.pb.go +++ b/observation/qos/jsonrpc_validation_error.pb.go @@ -1,6 +1,9 @@ // TODO_TECHDEBT(@adshmh): Use the JsonRpcResponseValidationError in all JSONRPC QoS. // Include this refactor in PR #253 (JUDGE framework) // +// TODO_TECHDEBT(@adshmh): The JsonRpcResponseValidationError should only be constructed by the qos/jsonrpc package. +// See the comment in qos/jsonrpc/observation.go +// // Code generated by protoc-gen-go. DO NOT EDIT. // versions: diff --git a/proto/path/qos/jsonrpc.proto b/proto/path/qos/jsonrpc.proto index 7645060d0..b5b01676b 100644 --- a/proto/path/qos/jsonrpc.proto +++ b/proto/path/qos/jsonrpc.proto @@ -22,11 +22,12 @@ message JsonRpcResponse { // Must match the id value from the corresponding request string id = 1; - // JSON-serializable response data - string result = 2; + // A preview of the JSONRPC response's `result` field. + // It may have been truncated to reduce message size. + string result_preview = 2; // Error details, if the request failed - optional JsonRpcResponseError err = 3; + optional JsonRpcResponseError error = 3; // Note: This message captures only essential JSON-RPC fields. // Add fields as needed. @@ -42,4 +43,4 @@ message JsonRpcResponseError { // Human-readable error description string message = 2; -} \ No newline at end of file +} diff --git a/proto/path/qos/jsonrpc_validation_error.proto b/proto/path/qos/jsonrpc_validation_error.proto index 8d9f5b43c..820f58025 100644 --- a/proto/path/qos/jsonrpc_validation_error.proto +++ b/proto/path/qos/jsonrpc_validation_error.proto @@ -1,6 +1,9 @@ // TODO_TECHDEBT(@adshmh): Use the JsonRpcResponseValidationError in all JSONRPC QoS. // Include this refactor in PR #253 (JUDGE framework) // +// TODO_TECHDEBT(@adshmh): The JsonRpcResponseValidationError should only be constructed by the qos/jsonrpc package. +// See the comment in qos/jsonrpc/observation.go +// syntax = "proto3"; package path.qos; diff --git a/qos/cosmos/check_evm_chainid_test.go b/qos/cosmos/check_evm_chainid_test.go index b2ff9832b..a54822fa3 100644 --- a/qos/cosmos/check_evm_chainid_test.go +++ b/qos/cosmos/check_evm_chainid_test.go @@ -107,4 +107,4 @@ func TestEndpointCheckEVMChainID_GetChainID(t *testing.T) { // Helper function to create string pointers func stringPtr(s string) *string { return &s -} \ No newline at end of file +} diff --git a/qos/cosmos/response_jsonrpc_unrecognized.go b/qos/cosmos/response_jsonrpc_unrecognized.go index e0dc6a327..127df0302 100644 --- a/qos/cosmos/response_jsonrpc_unrecognized.go +++ b/qos/cosmos/response_jsonrpc_unrecognized.go @@ -31,10 +31,7 @@ func (r *jsonrpcUnrecognizedResponse) GetObservation() qosobservations.CosmosEnd HttpStatusCode: int32(r.jsonrpcResponse.GetRecommendedHTTPStatusCode()), ValidationError: &r.validationErr, ParsedResponse: &qosobservations.CosmosEndpointResponseValidationResult_ResponseJsonrpc{ - ResponseJsonrpc: &qosobservations.JsonRpcResponse{ - Id: r.jsonrpcResponse.ID.String(), - // TODO_TECHDEBT(@adshmh): Store JSONRPC response's error code. - }, + ResponseJsonrpc: r.jsonrpcResponse.GetObservation(), }, }, } diff --git a/qos/evm/response_generic.go b/qos/evm/response_generic.go index 4db516a35..4270031e6 100644 --- a/qos/evm/response_generic.go +++ b/qos/evm/response_generic.go @@ -54,9 +54,8 @@ func (r responseGeneric) GetObservation() qosobservations.EVMEndpointObservation return qosobservations.EVMEndpointObservation{ ResponseObservation: &qosobservations.EVMEndpointObservation_UnrecognizedResponse{ UnrecognizedResponse: &qosobservations.EVMUnrecognizedResponse{ - JsonrpcResponse: &qosobservations.JsonRpcResponse{ - Id: r.jsonRPCResponse.ID.String(), - }, + // Include JSONRPC response's details in the observation. + JsonrpcResponse: r.jsonRPCResponse.GetObservation(), ResponseValidationError: r.validationError, HttpStatusCode: int32(r.getHTTPStatusCode()), }, diff --git a/qos/jsonrpc/observation.go b/qos/jsonrpc/observation.go index 8b35fb322..ab681809e 100644 --- a/qos/jsonrpc/observation.go +++ b/qos/jsonrpc/observation.go @@ -4,6 +4,13 @@ import ( "github.com/buildwithgrove/path/observation/qos" ) +// TODO_TECHDEBT(@adshmh): Add a method to response struct to build validation error observations. +// - Prerequisite: updating of Response unmarshaling to define and use exported errors. +// - Makes this file the single source of truth on observation.qos.JsonRpcResponseValidationError struct contents. +// +// Include only the first 100 characters of the JSONRPC response's result field in the observation. +const maxResponseResultPreviewLength = 100 + // GetObservation returns a qos.JsonRpcRequest struct that can be used by QoS services // to populate observation fields. func (r Request) GetObservation() *qos.JsonRpcRequest { @@ -12,3 +19,37 @@ func (r Request) GetObservation() *qos.JsonRpcRequest { Method: string(r.Method), } } + +// GetObservation builds and returns an observation.qos.JsonRpcResponse struct +// Used to populate observation fields. +// Truncates the result +func (r Response) GetObservation() *qos.JsonRpcResponse { + // Build a preview string of the JSONRPC response's result field. + var resultPreview string + if err := r.UnmarshalResult(&resultPreview); err == nil { + // Pick a maximum of 100 characters to include in the observation + resultPreview = resultPreview[:max(len(resultPreview), maxResponseResultPreviewLength)] + } + + // Build the JSONRPC response's observation. + responseObservation := &qos.JsonRpcResponse{ + Id: r.ID.String(), + ResultPreview: resultPreview, + } + + // Update the observation with the JSONRPC response's error field, if present. + if r.Error != nil { + responseObservation.Error = r.Error.GetObservation() + } + + return responseObservation +} + +// GetObservation builds and returns an observation.qos.JsonRpcResponseError struct. +// Used to populate observation fields. +func (re ResponseError) GetObservation() *qos.JsonRpcResponseError { + return &qos.JsonRpcResponseError{ + Code: int64(re.Code), + Message: re.Message, + } +} diff --git a/qos/jsonrpc/response.go b/qos/jsonrpc/response.go index dabef9999..3191b4ed9 100644 --- a/qos/jsonrpc/response.go +++ b/qos/jsonrpc/response.go @@ -77,6 +77,11 @@ func (r *Response) IsError() bool { return r.Error != nil } +// TODO_TECHDEBT(@adshmh): Validate the results JSONRPC result struct: +// - Return an error if invalid: e.g. if missing both result and error fields. +// - Define and use exported errors for each validation failure scenario. +// - Add a method to construct an observation of type observation.qos.JsonRpcResponseValidationError from the above error. +// // UnmarshalJSON implements custom unmarshaling to handle the result field presence detection func (r *Response) UnmarshalJSON(data []byte) error { // Use a temporary struct to unmarshal into, avoiding infinite recursion diff --git a/qos/solana/endpoint.go b/qos/solana/endpoint.go index 4fc20a75e..800e31afe 100644 --- a/qos/solana/endpoint.go +++ b/qos/solana/endpoint.go @@ -43,8 +43,8 @@ type endpoint struct { // Pointer distinguishes between no observation vs. observed response scenarios. *qosobservations.SolanaGetEpochInfoResponse - // latestValidationError tracks most recent JSON-RPC response validation error - latestValidationError *qosobservations.JsonRpcResponseValidationError + // latestJSONRPCValidationError tracks most recent JSON-RPC response validation error + latestJSONRPCValidationError *qosobservations.JsonRpcResponseValidationError // TODO_FUTURE: support archival endpoints. } @@ -54,7 +54,7 @@ type endpoint struct { func (e endpoint) validateBasic() error { // Check for recent validation errors first if e.hasRecentValidationErrors() { - return errRecentValidationError + return errRecentJSONRPCValidationError } switch { @@ -80,12 +80,12 @@ func (e endpoint) validateBasic() error { // hasRecentValidationErrors checks if endpoint has validation error within the configured window. func (e endpoint) hasRecentValidationErrors() bool { - if e.latestValidationError == nil { + if e.latestJSONRPCValidationError == nil { return false } lastValidationErrorTimestamp := time.Now().Add(-validationErrorResponseWindow) - return e.latestValidationError.Timestamp.AsTime().After(lastValidationErrorTimestamp) + return e.latestJSONRPCValidationError.Timestamp.AsTime().After(lastValidationErrorTimestamp) } // applyObservation updates endpoint data using provided observation. @@ -105,9 +105,9 @@ func (e *endpoint) applyObservation(obs *qosobservations.SolanaEndpointObservati if unrecognizedResponse := obs.GetUnrecognizedResponse(); unrecognizedResponse != nil { // Update latest validation error if observation contains more recent error if validationError := unrecognizedResponse.ValidationError; validationError != nil { - if e.latestValidationError == nil || - validationError.Timestamp.AsTime().After(e.latestValidationError.Timestamp.AsTime()) { - e.latestValidationError = validationError + if e.latestJSONRPCValidationError == nil || + validationError.Timestamp.AsTime().After(e.latestJSONRPCValidationError.Timestamp.AsTime()) { + e.latestJSONRPCValidationError = validationError } } return true diff --git a/qos/solana/response_generic.go b/qos/solana/response_generic.go index 2a226d9e4..76cedc05e 100644 --- a/qos/solana/response_generic.go +++ b/qos/solana/response_generic.go @@ -60,12 +60,9 @@ type responseGeneric struct { // Shares data with other entities (e.g., data pipeline). // Default catchall for responses other than `getHealth` and `getEpochInfo`. func (r responseGeneric) GetObservation() qosobservations.SolanaEndpointObservation { + // Build an observation from the stored JSONRPC response. unrecognizedResponse := &qosobservations.SolanaUnrecognizedResponse{ - // TODO_TECHDEBT(@adshmh): Add utility to convert qos.jsonrpc.Response to observation.qos.JsonRpcResponse - // to include error object and other fields in observations. - JsonrpcResponse: &qosobservations.JsonRpcResponse{ - Id: r.ID.String(), - }, + JsonrpcResponse: r.Response.GetObservation(), } // Include validation error if present From 4d6f23083200e796cb91d74865e2cae359f22eba Mon Sep 17 00:00:00 2001 From: Arash Deshmeh Date: Sun, 17 Aug 2025 13:59:39 -0400 Subject: [PATCH 6/6] Fix: use min length for JSONRPC result preview observation --- qos/jsonrpc/observation.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qos/jsonrpc/observation.go b/qos/jsonrpc/observation.go index ab681809e..5797b3e0a 100644 --- a/qos/jsonrpc/observation.go +++ b/qos/jsonrpc/observation.go @@ -28,7 +28,7 @@ func (r Response) GetObservation() *qos.JsonRpcResponse { var resultPreview string if err := r.UnmarshalResult(&resultPreview); err == nil { // Pick a maximum of 100 characters to include in the observation - resultPreview = resultPreview[:max(len(resultPreview), maxResponseResultPreviewLength)] + resultPreview = resultPreview[:min(len(resultPreview), maxResponseResultPreviewLength)] } // Build the JSONRPC response's observation.