diff --git a/consensus/go.mod b/consensus/go.mod index f9d76c19f..2cb17b91c 100644 --- a/consensus/go.mod +++ b/consensus/go.mod @@ -9,7 +9,7 @@ require ( github.com/shopspring/decimal v1.4.0 github.com/smartcontractkit/capabilities/libs v0.0.0-20260210010829-97eb42ca2924 github.com/smartcontractkit/chainlink-common v0.11.2-0.20260529092756-a94bc8ce96d6 - github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260526195338-adcf8013a1b7 + github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260714170805-29c5577b5f55 github.com/smartcontractkit/cre-sdk-go v0.9.0 github.com/smartcontractkit/libocr v0.0.0-20250912173940-f3ab0246e23d github.com/stretchr/testify v1.11.1 @@ -80,7 +80,7 @@ require ( github.com/prometheus/procfs v0.20.1 // indirect github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect github.com/scylladb/go-reflectx v1.0.1 // indirect - github.com/smartcontractkit/chain-selectors v1.0.100 // indirect + github.com/smartcontractkit/chain-selectors v1.0.104 // indirect github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260529092756-a94bc8ce96d6 // indirect github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b // indirect github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260205130626-db2a2aab956b // indirect diff --git a/consensus/go.sum b/consensus/go.sum index 38e46af17..1b70124cc 100644 --- a/consensus/go.sum +++ b/consensus/go.sum @@ -206,16 +206,16 @@ github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/smartcontractkit/capabilities/libs v0.0.0-20260210010829-97eb42ca2924 h1:6wqsOpDXA0ZMEswN7f8hX04Y3+gXva7p5emXThtJVlI= github.com/smartcontractkit/capabilities/libs v0.0.0-20260210010829-97eb42ca2924/go.mod h1:v0O0Au8RE00Z89QxBE6I2q9bR9r3+RO1gLD3oaO2WB0= -github.com/smartcontractkit/chain-selectors v1.0.100 h1:wpiSpmI/eFjY+wx/nPr5VuNF4hki0prIBMKEaQWn3g4= -github.com/smartcontractkit/chain-selectors v1.0.100/go.mod h1:qy7whtgG5g+7z0jt0nRyii9bLND9m15NZTzuQPkMZ5w= +github.com/smartcontractkit/chain-selectors v1.0.104 h1:/n9pPGM5W/+r1eHoWZv4VwX9LNS1af4+ICyhM8zKRNM= +github.com/smartcontractkit/chain-selectors v1.0.104/go.mod h1:qy7whtgG5g+7z0jt0nRyii9bLND9m15NZTzuQPkMZ5w= github.com/smartcontractkit/chainlink-common v0.11.2-0.20260529092756-a94bc8ce96d6 h1:hms02zQQ0BPcp9CBwh/xda5KwJWdU0IIA/yjtwyRoA4= github.com/smartcontractkit/chainlink-common v0.11.2-0.20260529092756-a94bc8ce96d6/go.mod h1:jueIfDkkRexwGgLbVB7vGCZlNtd383zuwi4uHHwcbqc= github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260529092756-a94bc8ce96d6 h1:ucHu2bPDT/58AzSgnPDyp4IjnjVbrVWYD3bG5jCbXMY= github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260529092756-a94bc8ce96d6/go.mod h1:HmUyH2oD9m+GRpKq7q3vuRnm1F2Uczf/Nd1v3ipMSK8= github.com/smartcontractkit/chainlink-protos/billing/go v0.0.0-20251024234028-0988426d98f4 h1:GCzrxDWn3b7jFfEA+WiYRi8CKoegsayiDoJBCjYkneE= github.com/smartcontractkit/chainlink-protos/billing/go v0.0.0-20251024234028-0988426d98f4/go.mod h1:HHGeDUpAsPa0pmOx7wrByCitjQ0mbUxf0R9v+g67uCA= -github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260526195338-adcf8013a1b7 h1:iljEJss3WOwcsMkWy72Yn2zvjw7Gyxc+RXL7r8YKM6g= -github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260526195338-adcf8013a1b7/go.mod h1:vTFHTCbLui4Vn8fTmAadfE3rdnvfrDwOmMujmW857D0= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260714170805-29c5577b5f55 h1:QD/iBCa4yY3z9q48PhsjpO9ml6+ZDW4vWJq0h8oelDk= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260714170805-29c5577b5f55/go.mod h1:/i8hjTPFdVWHiY+QjeSiVS2Z3GB3WAZznGgXHstC02E= github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM= github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b/go.mod h1:qSTSwX3cBP3FKQwQacdjArqv0g6QnukjV4XuzO6UyoY= github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260205130626-db2a2aab956b h1:36knUpKHHAZ86K4FGWXtx8i/EQftGdk2bqCoEu/Cha8= diff --git a/consensus/oracle/consensus_execution.go b/consensus/oracle/consensus_execution.go index 2ec09da39..bda28d3de 100644 --- a/consensus/oracle/consensus_execution.go +++ b/consensus/oracle/consensus_execution.go @@ -1,6 +1,7 @@ package oracle import ( + "cmp" "errors" "fmt" "math/big" @@ -59,6 +60,8 @@ func CalculateOutcomeForObservations( return handleCommonPrefixAggregation(lggr, observations, f, errorsMigrationFlag) case sdk.AggregationType_AGGREGATION_TYPE_COMMON_SUFFIX: return handleCommonSuffixAggregation(lggr, observations, f, errorsMigrationFlag) + case sdk.AggregationType_AGGREGATION_TYPE_FREQUENCY_LIST: + return handleValueCountsAggregation(lggr, observations, f) default: return nil, fmt.Errorf("unknown aggregation type: %s", aggregation) } @@ -338,6 +341,83 @@ func handleIdenticalAggregation(_ logger.Logger, values []*valuespb.Value, f int return uniqueCandidate, nil } +// handleValueCountsAggregation returns a list of all distinct observation values +// together with how many times each was seen. Each list element is a map with +// "value" and "count" fields. Results are sorted by count descending, then by +// serialized value ascending for determinism. +func handleValueCountsAggregation( + _ logger.Logger, + observations []*valuespb.Value, + f int, +) (*valuespb.Value, error) { + if len(observations) < f+1 { + return nil, ErrInsufficientObservations + } + + type valueOccurrence struct { + count int64 + value *valuespb.Value + } + + var ( + marshaler = &proto.MarshalOptions{Deterministic: true} + occurrences = make(map[string]valueOccurrence) + ) + + for _, currentValue := range observations { + if currentValue == nil || currentValue.Value == nil { + continue + } + + b, err := marshaler.Marshal(currentValue) + if err != nil { + return nil, fmt.Errorf("unable to marshal value: %w", err) + } + key := string(b) + + occurrence := occurrences[key] + occurrence.count++ + if occurrence.value == nil { + occurrence.value = currentValue + } + occurrences[key] = occurrence + } + + if len(occurrences) == 0 { + return nil, ErrInsufficientObservations + } + + type countedValue struct { + key string + count int64 + value *valuespb.Value + } + + counted := make([]countedValue, 0, len(occurrences)) + for key, occurrence := range occurrences { + counted = append(counted, countedValue{ + key: key, + count: occurrence.count, + value: occurrence.value, + }) + } + + slices.SortFunc(counted, func(a, b countedValue) int { + // count favors b, and key favors a. this is by design... + return cmp.Or(cmp.Compare(b.count, a.count), cmp.Compare(a.key, b.key)) + }) + + result := make([]*valuespb.Value, 0, len(counted)) + for _, entry := range counted { + result = append(result, valuespb.NewMapValue(map[string]*valuespb.Value{ + "value": entry.value, + "count": valuespb.NewInt64Value(entry.count), + })) + } + + return valuespb.NewListValue(result), nil +} + // handleCommonSuffixAggregation reverses the underlying lists in the slice of // observations and delegates logic to handleCommonPrefixAggregation and then // reverses the result a final time. diff --git a/consensus/oracle/consensus_execution_test.go b/consensus/oracle/consensus_execution_test.go index 30fee3c83..aeb29e1db 100644 --- a/consensus/oracle/consensus_execution_test.go +++ b/consensus/oracle/consensus_execution_test.go @@ -204,6 +204,50 @@ func Test_CalculateOutcomeForObservations(t *testing.T) { }, expectedOutcome: mustNewList("7", "8", "9"), }, + { + name: "value counts aggregation: distinct values with counts", + observations: []*valuespb.Value{ + values.Proto(values.NewInt64(42)), + values.Proto(values.NewInt64(42)), + values.Proto(values.NewInt64(42)), + values.Proto(values.NewInt64(50)), + values.Proto(values.NewString("malicious")), + }, + descriptor: &sdk.ConsensusDescriptor{ + Descriptor_: &sdk.ConsensusDescriptor_Aggregation{ + Aggregation: sdk.AggregationType_AGGREGATION_TYPE_FREQUENCY_LIST, + }, + }, + f: 2, + expectedOutcome: valuespb.NewListValue([]*valuespb.Value{ + valuespb.NewMapValue(map[string]*valuespb.Value{ + "value": values.Proto(values.NewInt64(42)), + "count": valuespb.NewInt64Value(3), + }), + valuespb.NewMapValue(map[string]*valuespb.Value{ + "value": values.Proto(values.NewString("malicious")), + "count": valuespb.NewInt64Value(1), + }), + valuespb.NewMapValue(map[string]*valuespb.Value{ + "value": values.Proto(values.NewInt64(50)), + "count": valuespb.NewInt64Value(1), + }), + }), + }, + { + name: "value counts aggregation: insufficient observations", + observations: []*valuespb.Value{ + values.Proto(values.NewInt64(10)), + values.Proto(values.NewInt64(20)), + }, + descriptor: &sdk.ConsensusDescriptor{ + Descriptor_: &sdk.ConsensusDescriptor_Aggregation{ + Aggregation: sdk.AggregationType_AGGREGATION_TYPE_FREQUENCY_LIST, + }, + }, + f: 2, + expectedError: ErrInsufficientObservations, + }, { name: "unknown aggregation type (UNSPECIFIED)", observations: []*valuespb.Value{