Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions pkg/capabilities/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
34 changes: 34 additions & 0 deletions pkg/capabilities/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We use deterministic marshaling for CapabilityResponse as a whole, which should propagate to the nested any proto: https://github.com/smartcontractkit/chainlink-common/blob/main/pkg/capabilities/pb/capabilities_helpers.go#L35

I think this test is not representative and the problem doesn't exist. Am I wrong?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't get a repro using this particular bit of nondeterminism anyway. Otherwise there are threads for the stuff I was able to repro.

require.NoError(t, err)

if firstBytes == nil {
firstBytes = b
} else {
assert.Equal(t, firstBytes, b, "SetResponse produced non-deterministic bytes on iteration %d", i)
}
}
Comment on lines +173 to +203
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The determinism regression test only covers the SetResponse path. Since this PR also changes RegisterTrigger to use deterministic Any wrapping, it would be good to add a similar assertion for the migrated trigger-event path (e.g., emit a trigger message with map fields and verify the produced TriggerEvent.Payload bytes are identical across multiple events/calls) to prevent regressions specifically in RegisterTrigger.

Copilot uses AI. Check for mistakes.
})
}

func TestExecute(t *testing.T) {
Expand Down
Loading