From f448d094315c52872b741f603a55d354163f3e9e Mon Sep 17 00:00:00 2001 From: vreff <104409744+vreff@users.noreply.github.com> Date: Fri, 3 Apr 2026 07:55:40 -0400 Subject: [PATCH] fix: use deterministic proto marshalling in SetResponse and RegisterTrigger anypb.New() uses default proto.Marshal() which does not guarantee deterministic byte output for messages containing map fields, because Go iterates maps in random order. In distributed quorum-based systems, this causes nodes to produce different request hashes for logically identical data, preventing quorum from being reached. Replace anypb.New() with anypb.MarshalFrom() using proto.MarshalOptions{Deterministic: true} in both SetResponse and RegisterTrigger, ensuring all nodes produce byte-identical serializations. Add a regression test that verifies SetResponse produces stable output across 100 iterations for a proto message with map fields. --- pkg/capabilities/utils.go | 16 ++++++++++++++-- pkg/capabilities/utils_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) 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) {