Skip to content

Add Reports type to outcome. Currently unused. #892

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
Draft
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
10 changes: 8 additions & 2 deletions execute/exectypes/outcome.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ type Outcome struct {

// Report is built from the oldest pending commit reports.
Report cciptypes.ExecutePluginReport `json:"report"`

// Reports are built from the oldest pending commit reports.
Reports []cciptypes.ExecutePluginReport `json:"reports"`
}

// IsEmpty returns true if the outcome has no pending commit reports or chain reports.
Expand Down Expand Up @@ -108,16 +111,18 @@ func NewOutcome(
state PluginState,
selectedCommits []CommitData,
report cciptypes.ExecutePluginReport,
reports []cciptypes.ExecutePluginReport,
) Outcome {
return NewSortedOutcome(state, selectedCommits, report)
return NewSortedOutcome(state, selectedCommits, report, reports)
}

// NewSortedOutcome ensures canonical ordering of the outcome.
// TODO: handle canonicalization in the encoder.
// TODO: this sorting doesn't make sense for all states.
Comment on lines 119 to +120
Copy link
Preview

Copilot AI May 21, 2025

Choose a reason for hiding this comment

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

[nitpick] Revisit the sorting logic in NewSortedOutcome to ensure that the ordering of outcomes remains predictable when multiple reports are provided. Clarify in documentation how the sorting should behave across different plugin states.

Copilot uses AI. Check for mistakes.

func NewSortedOutcome(
state PluginState,
pendingCommits []CommitData,
report cciptypes.ExecutePluginReport,
reports []cciptypes.ExecutePluginReport,
) Outcome {
pendingCommitsCP := append([]CommitData{}, pendingCommits...)
reportCP := append([]cciptypes.ExecutePluginReportSingleChain{}, report.ChainReports...)
Expand All @@ -135,5 +140,6 @@ func NewSortedOutcome(
State: state,
CommitReports: pendingCommitsCP,
Report: cciptypes.ExecutePluginReport{ChainReports: reportCP},
Reports: reports,
}
}
2 changes: 1 addition & 1 deletion execute/exectypes/outcome_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ func TestNewSortedOutcome(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := NewSortedOutcome(Unknown, tt.pendingCommits, tt.report)
got := NewSortedOutcome(Unknown, tt.pendingCommits, tt.report, nil)

if len(tt.wantCommits) > 0 {
require.Equal(t, tt.wantCommits, got.CommitReports)
Expand Down
3 changes: 3 additions & 0 deletions execute/tracked.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,5 +101,8 @@ func currentState(p *TrackedPlugin, outctx ocr3types.OutcomeContext) exectypes.P
p.lggr.Errorw("unable to get state", "error", err)
return exectypes.Unknown
}

// TODO: Support "Reports" field.

return out.State.Next()
}
224 changes: 224 additions & 0 deletions pkg/ocrtypecodec/v1/compatability_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
package v1_test

import (
"encoding/base64"
"github.com/smartcontractkit/chainlink-ccip/pkg/ocrtypecodec/v1/ocrtypecodecpb"
"google.golang.org/protobuf/proto"
"testing"
"time"

"github.com/stretchr/testify/require"

"github.com/smartcontractkit/chainlink-ccip/execute/exectypes"
v1 "github.com/smartcontractkit/chainlink-ccip/pkg/ocrtypecodec/v1"
cciptypes "github.com/smartcontractkit/chainlink-ccip/pkg/types/ccipocr3"
)

var inputObjectOld = exectypes.Outcome{
State: "yee haw",
CommitReports: []exectypes.CommitData{
{
SourceChain: 99,
Timestamp: time.UnixMilli(9000),
BlockNum: 201,
SequenceNumberRange: cciptypes.NewSeqNumRange(250, 300),
},
},
Report: cciptypes.ExecutePluginReport{
ChainReports: []cciptypes.ExecutePluginReportSingleChain{
{
SourceChainSelector: 123,
},
},
},
}

var inputObjectReports = exectypes.Outcome{
State: "yee haw",
CommitReports: []exectypes.CommitData{
{
SourceChain: 99,
Timestamp: time.UnixMilli(9000),
BlockNum: 201,
SequenceNumberRange: cciptypes.NewSeqNumRange(250, 300),
},
},
Reports: []cciptypes.ExecutePluginReport{
{
ChainReports: []cciptypes.ExecutePluginReportSingleChain{
{
SourceChainSelector: 123,
},
},
},
},
}

func TestProtobufBackwardCompatability(t *testing.T) {
testcases := []struct {
name string
encodedOutcome string // b64
input exectypes.Outcome
}{
{
name: "original outcome",
encodedOutcome: "Cgd5ZWUgaGF3EjMIYxoCCAkgyQEqIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMgYI+gEQrAIaBAoCCHs=",
input: inputObjectOld,
},
{
name: "reports outcome",
encodedOutcome: "Cgd5ZWUgaGF3EjMIYxoCCAkgyQEqIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMgYI+gEQrAIaBAoCCHs=",
input: inputObjectReports,
},
}

for _, testcase := range testcases {
t.Run(testcase.name, func(t *testing.T) {
enc := v1.NewExecCodecProto()
encoded, err := enc.EncodeOutcome(testcase.input)
require.NoError(t, err)
b64Encoded := base64.StdEncoding.EncodeToString(encoded)
require.Equal(t, testcase.encodedOutcome, b64Encoded)
})
}
}

// TestExistingEncodingCompatibility verifies existing encoded data can be decoded correctly
func TestExistingEncodingCompatibility(t *testing.T) {
// Base64 encoded data from original test
encodedB64 := "Cgd5ZWUgaGF3EjMIYxoCCAkgyQEqIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMgYI+gEQrAIaBAoCCHs="

codec := v1.NewExecCodecProto()
encoded, err := base64.StdEncoding.DecodeString(encodedB64)
require.NoError(t, err)

outcome, err := codec.DecodeOutcome(encoded)
require.NoError(t, err)

// Verify the outcome has both fields populated
require.Equal(t, exectypes.PluginState("yee haw"), outcome.State)
require.NotEmpty(t, outcome.Report.ChainReports)
require.Equal(t, cciptypes.ChainSelector(123), outcome.Report.ChainReports[0].SourceChainSelector)
require.Len(t, outcome.Reports, 1)
require.Equal(t, outcome.Report, outcome.Reports[0])
}

// TestSingleReportBackwardCompatibility tests that single reports use the legacy field
func TestSingleReportBackwardCompatibility(t *testing.T) {
codec := v1.NewExecCodecProto()

// Create an outcome with a single report in Reports
outcome := exectypes.Outcome{
State: "test state",
Reports: []cciptypes.ExecutePluginReport{
{
ChainReports: []cciptypes.ExecutePluginReportSingleChain{
{SourceChainSelector: 42},
},
},
},
}

encoded, err := codec.EncodeOutcome(outcome)
require.NoError(t, err)

// Manually decode the protobuf to verify structure
pbOutcome := &ocrtypecodecpb.ExecOutcome{}
err = proto.Unmarshal(encoded, pbOutcome)
require.NoError(t, err)

// With a single report, it should use the legacy field only
require.NotNil(t, pbOutcome.ExecutePluginReport)
require.Empty(t, pbOutcome.ExecutePluginReports)
require.Equal(t, uint64(42), pbOutcome.ExecutePluginReport.ChainReports[0].SourceChainSelector)

decodedOutcome, err := codec.DecodeOutcome(encoded)
require.NoError(t, err)

// Both fields should now be populated in the decoded outcome
require.Equal(t, cciptypes.ChainSelector(42), decodedOutcome.Report.ChainReports[0].SourceChainSelector)
require.Len(t, decodedOutcome.Reports, 1)
require.Equal(t, cciptypes.ChainSelector(42), decodedOutcome.Reports[0].ChainReports[0].SourceChainSelector)
}

// TestMultipleReportEncoding verifies that multiple reports use the new field
func TestMultipleReportEncoding(t *testing.T) {
codec := v1.NewExecCodecProto()

// Create an outcome with multiple reports
outcome := exectypes.Outcome{
State: "test state",
Reports: []cciptypes.ExecutePluginReport{
{
ChainReports: []cciptypes.ExecutePluginReportSingleChain{
{SourceChainSelector: 42},
},
},
{
ChainReports: []cciptypes.ExecutePluginReportSingleChain{
{SourceChainSelector: 43},
},
},
},
}

encoded, err := codec.EncodeOutcome(outcome)
require.NoError(t, err)

// Manually decode the protobuf to verify structure
pbOutcome := &ocrtypecodecpb.ExecOutcome{}
err = proto.Unmarshal(encoded, pbOutcome)
require.NoError(t, err)

// With multiple reports, it should use the new field only
require.Nil(t, pbOutcome.ExecutePluginReport)
require.Len(t, pbOutcome.ExecutePluginReports, 2)
require.Equal(t, uint64(42), pbOutcome.ExecutePluginReports[0].ChainReports[0].SourceChainSelector)
require.Equal(t, uint64(43), pbOutcome.ExecutePluginReports[1].ChainReports[0].SourceChainSelector)

decodedOutcome, err := codec.DecodeOutcome(encoded)
require.NoError(t, err)
require.Len(t, decodedOutcome.Reports, 2)
require.Equal(t, cciptypes.ChainSelector(42), decodedOutcome.Reports[0].ChainReports[0].SourceChainSelector)
require.Equal(t, cciptypes.ChainSelector(43), decodedOutcome.Reports[1].ChainReports[0].SourceChainSelector)
}

// TestEdgeCases tests various edge cases
func TestEdgeCases(t *testing.T) {
codec := v1.NewExecCodecProto()

// Test with empty Reports array
t.Run("empty reports array", func(t *testing.T) {
outcome := exectypes.Outcome{
State: "test state",
Reports: []cciptypes.ExecutePluginReport{},
}

encoded, err := codec.EncodeOutcome(outcome)
require.NoError(t, err)

decoded, err := codec.DecodeOutcome(encoded)
require.NoError(t, err)

require.Equal(t, outcome.State, decoded.State)
require.Empty(t, decoded.Reports)
require.Nil(t, decoded.Report.ChainReports)
})

t.Run("report with zero chain reports", func(t *testing.T) {
outcome := exectypes.Outcome{
State: "test state",
Report: cciptypes.ExecutePluginReport{},
}

encoded, err := codec.EncodeOutcome(outcome)
require.NoError(t, err)

decoded, err := codec.DecodeOutcome(encoded)
require.NoError(t, err)

require.Equal(t, outcome.State, decoded.State)
require.Empty(t, decoded.Reports)
require.Nil(t, decoded.Report.ChainReports)
})
}
63 changes: 44 additions & 19 deletions pkg/ocrtypecodec/v1/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
"github.com/smartcontractkit/chainlink-ccip/execute/exectypes"
"github.com/smartcontractkit/chainlink-ccip/internal/plugincommon/discovery/discoverytypes"
"github.com/smartcontractkit/chainlink-ccip/pkg/ocrtypecodec/v1/ocrtypecodecpb"
cciptypes "github.com/smartcontractkit/chainlink-ccip/pkg/types/ccipocr3"
"github.com/smartcontractkit/chainlink-ccip/pkg/types/ccipocr3"
)

var DefaultExecCodec ExecCodec = NewExecCodecProto()
Expand Down Expand Up @@ -79,38 +79,63 @@ func (e *ExecCodecProto) DecodeObservation(data []byte) (exectypes.Observation,
}

func (e *ExecCodecProto) EncodeOutcome(outcome exectypes.Outcome) ([]byte, error) {
outcome = exectypes.NewSortedOutcome(outcome.State, outcome.CommitReports, outcome.Report)
outcome = exectypes.NewOutcome(outcome.State, outcome.CommitReports, outcome.Report, outcome.Reports)

pbObs := &ocrtypecodecpb.ExecOutcome{
PluginState: string(outcome.State),
CommitReports: e.tr.commitDataSliceToProto(outcome.CommitReports),
ExecutePluginReport: &ocrtypecodecpb.ExecutePluginReport{
ChainReports: e.tr.chainReportsToProto(outcome.Report.ChainReports),
},
pbOtcm := &ocrtypecodecpb.ExecOutcome{
PluginState: string(outcome.State),
CommitReports: e.tr.commitDataSliceToProto(outcome.CommitReports),
ExecutePluginReports: e.tr.execPluginReportsToProto(outcome.Reports),
}

// If there is only one report, use the legacy field. This way new clients can still
// form consensus with old ones.
// TODO: Remove backwards compatibility code after a few releases.
if len(pbOtcm.ExecutePluginReports) == 1 {
pbOtcm.ExecutePluginReport = pbOtcm.ExecutePluginReports[0]
pbOtcm.ExecutePluginReports = nil
}

// TODO: Remove after "Reports" is fully supported.
if len(outcome.Report.ChainReports) != 0 {
r := e.tr.execPluginReportsToProto([]ccipocr3.ExecutePluginReport{outcome.Report})
if len(r) > 0 {
pbOtcm.ExecutePluginReport = r[0]
}
}

return proto.MarshalOptions{Deterministic: true}.Marshal(pbObs)
return proto.MarshalOptions{Deterministic: true}.Marshal(pbOtcm)
}

func (e *ExecCodecProto) DecodeOutcome(data []byte) (exectypes.Outcome, error) {
if len(data) == 0 {
return exectypes.Outcome{}, nil
}

pbOutc := &ocrtypecodecpb.ExecOutcome{}
if err := proto.Unmarshal(data, pbOutc); err != nil {
pbOtcm := &ocrtypecodecpb.ExecOutcome{}
if err := proto.Unmarshal(data, pbOtcm); err != nil {
return exectypes.Outcome{}, fmt.Errorf("proto unmarshal ExecOutcome: %w", err)
}

outc := exectypes.Outcome{
State: exectypes.PluginState(pbOutc.PluginState),
CommitReports: e.tr.commitDataSliceFromProto(pbOutc.CommitReports),
Report: cciptypes.ExecutePluginReport{
ChainReports: e.tr.chainReportsFromProto(pbOutc.ExecutePluginReport.ChainReports),
},
otcm := exectypes.Outcome{
State: exectypes.PluginState(pbOtcm.PluginState),
CommitReports: e.tr.commitDataSliceFromProto(pbOtcm.CommitReports),
Reports: e.tr.execPluginReportsFromProto(pbOtcm.ExecutePluginReports),
}

// Decode the legacy Report field into the new Reports field. This way the plugin layer doesn't
// need to worry about type migration.
// TODO: Remove temporary migration code after a few releases.
if pbOtcm.ExecutePluginReport != nil {
otcm.Reports = e.tr.execPluginReportsFromProto([]*ocrtypecodecpb.ExecutePluginReport{pbOtcm.ExecutePluginReport})
}

// Decode the new report format into the legacy field as an intermediate step for implementing this feature.
// TODO: Remove temporary function after the 'Reports' field is fully implemented.
if len(otcm.Reports) > 0 {
otcm.Report = otcm.Reports[0]
}

return outc, nil
return otcm, nil
}

// ExecCodecJSON is an implementation of ExecCodec that uses JSON.
Expand All @@ -137,7 +162,7 @@ func (*ExecCodecJSON) DecodeObservation(data []byte) (exectypes.Observation, err

func (*ExecCodecJSON) EncodeOutcome(outcome exectypes.Outcome) ([]byte, error) {
// We sort again here in case construction is not via the constructor.
return json.Marshal(exectypes.NewSortedOutcome(outcome.State, outcome.CommitReports, outcome.Report))
return json.Marshal(exectypes.NewOutcome(outcome.State, outcome.CommitReports, outcome.Report, outcome.Reports))
}

func (*ExecCodecJSON) DecodeOutcome(data []byte) (exectypes.Outcome, error) {
Expand Down
Loading
Loading