diff --git a/pkg/capabilities/utils.go b/pkg/capabilities/utils.go index cf999a147..64f9516aa 100644 --- a/pkg/capabilities/utils.go +++ b/pkg/capabilities/utils.go @@ -42,7 +42,7 @@ func UnwrapResponse(response CapabilityResponse, value proto.Message) (bool, err // SetResponse sets the response payload based on whether it was migrated to use pbany.Any values. func SetResponse(response *CapabilityResponse, migrated bool, value proto.Message, ocrAttestation *OCRAttestation) error { if migrated { - wrapped, err := anypb.New(value) + wrapped, err := marshalAnyDeterministic(value) if err != nil { return err } @@ -113,6 +113,18 @@ func Execute[I, C, O proto.Message]( return response, nil } +// marshalAnyDeterministic wraps a proto.Message into an anypb.Any using +// deterministic marshalling. This is critical for ensuring that all nodes in a +// DON produce byte-identical serializations of proto messages containing map +// fields, which is required for quorum-based systems that compare request hashes. +func marshalAnyDeterministic(msg proto.Message) (*anypb.Any, error) { + dst := &anypb.Any{} + if err := anypb.MarshalFrom(dst, msg, proto.MarshalOptions{Deterministic: true}); err != nil { + return nil, err + } + return dst, nil +} + type TriggerAndId[T proto.Message] struct { Trigger T Id string @@ -155,7 +167,7 @@ func RegisterTrigger[I, O proto.Message]( }, } if migrated { - wrapped, err := anypb.New(resp.Trigger) + wrapped, err := marshalAnyDeterministic(resp.Trigger) tr.Err = err tr.Event.Payload = wrapped } else { diff --git a/pkg/capabilities/utils_test.go b/pkg/capabilities/utils_test.go index d6ec7e91d..d22f46ef6 100644 --- a/pkg/capabilities/utils_test.go +++ b/pkg/capabilities/utils_test.go @@ -9,6 +9,7 @@ import ( ocrtypes "github.com/smartcontractkit/libocr/offchainreporting2/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/anypb" "google.golang.org/protobuf/types/known/emptypb" @@ -168,6 +169,39 @@ func TestSetResponse(t *testing.T) { assert.Nil(t, resp.Payload) assert.Nil(t, resp.OCRAttestation) }) + + t.Run("deterministic marshalling with map fields", func(t *testing.T) { + // Proto messages with map fields can serialize in different orders + // because Go map iteration order is randomized. SetResponse must + // produce identical bytes across calls so that distributed nodes + // reach quorum on the same hash. + msg := &pb.CapabilityConfig{ + MethodConfigs: map[string]*pb.CapabilityMethodConfig{ + "alpha": {}, + "bravo": {}, + "charlie": {}, + "delta": {}, + "echo": {}, + }, + } + + var firstBytes []byte + for i := 0; i < 100; i++ { + resp := capabilities.CapabilityResponse{} + err := capabilities.SetResponse(&resp, true, msg, nil) + require.NoError(t, err) + require.NotNil(t, resp.Payload) + + b, err := proto.Marshal(resp.Payload) + require.NoError(t, err) + + if firstBytes == nil { + firstBytes = b + } else { + assert.Equal(t, firstBytes, b, "SetResponse produced non-deterministic bytes on iteration %d", i) + } + } + }) } func TestExecute(t *testing.T) {