From 632d18404562c807fa309acf3a21eb2566ee8108 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Fri, 9 Oct 2026 12:50:39 +0100 Subject: [PATCH 001/103] llo/protocol: count tombstones toward size budgets on admission --- llo/protocol/channel_definitions.go | 29 +++++++++++++++-------- llo/protocol/channel_definitions_test.go | 30 ++++++++++++++++++++---- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/llo/protocol/channel_definitions.go b/llo/protocol/channel_definitions.go index 2df61eb..6599246 100644 --- a/llo/protocol/channel_definitions.go +++ b/llo/protocol/channel_definitions.go @@ -232,8 +232,9 @@ func analyzeChannelDefinitions(codecs map[llotypes.ReportFormat]ReportCodec, cha } // Whole-set budgets, accumulated over the channels the loop below visits - // (tombstones excluded: they carry neither streams nor opts that anything - // reads) and checked once at the end. + // and checked once at the end. Tombstones count: nothing reads their + // streams or opts, but they are persisted in the definitions record all the + // same. var totalStreamEntries, totalOptsBytes int // Every (streamID, aggregator) pair that will hold an aggregate. Distinct @@ -260,7 +261,23 @@ func analyzeChannelDefinitions(codecs map[llotypes.ReportFormat]ReportCodec, cha for _, channelID := range channelIDs { cd := channelDefs[channelID] + + // Opts are opaque bytes, so length is the only thing that can be + // checked here. Admission-only: a committed definition carrying an + // oversized blob is left alone rather than failing verification on every + // oracle, every round. + if len(cd.Opts) > MaxChannelOptsBytes { + admit(fmt.Errorf("ChannelDefinition with ID %d has opts that are too long, got: %d/%d", channelID, len(cd.Opts), MaxChannelOptsBytes), channelID) + } + totalOptsBytes += len(cd.Opts) + + // A tombstone is persisted as is, so its streams are bounded like any + // other. Admission-only, so that a committed tombstone stays reapable. if cd.Tombstone { + if len(cd.Streams) > MaxStreamsPerChannel { + admit(fmt.Errorf("ChannelDefinition with ID %d has too many streams, got: %d/%d", channelID, len(cd.Streams), MaxStreamsPerChannel), channelID) + } + totalStreamEntries += len(cd.Streams) continue } @@ -273,14 +290,6 @@ func analyzeChannelDefinitions(codecs map[llotypes.ReportFormat]ReportCodec, cha continue } totalStreamEntries += len(cd.Streams) - // Opts are opaque bytes, so length is the only thing that can be - // checked here. Admission-only: a committed definition carrying an - // oversized blob is left alone rather than failing verification on every - // oracle, every round. - if len(cd.Opts) > MaxChannelOptsBytes { - admit(fmt.Errorf("ChannelDefinition with ID %d has opts that are too long, got: %d/%d", channelID, len(cd.Opts), MaxChannelOptsBytes), channelID) - } - totalOptsBytes += len(cd.Opts) for _, strm := range cd.Streams { if strm.Aggregator == 0 { base(fmt.Errorf("ChannelDefinition with ID %d has stream %d with zero aggregator (this may indicate an uninitialized struct)", channelID, strm.StreamID), channelID) diff --git a/llo/protocol/channel_definitions_test.go b/llo/protocol/channel_definitions_test.go index 39af360..2216737 100644 --- a/llo/protocol/channel_definitions_test.go +++ b/llo/protocol/channel_definitions_test.go @@ -545,14 +545,36 @@ func Test_VerifyChannelDefinitions_SizeBudgets(t *testing.T) { } }) - t.Run("tombstones cost nothing", func(t *testing.T) { - // A tombstone carries neither streams nor opts that anything reads, so - // it must not consume either budget. + t.Run("tombstones count toward the budgets", func(t *testing.T) { + // A tombstone is persisted in the definitions record like any other + // definition, so its streams and opts consume the same budgets. defs := sharedStream(11, MaxTotalStreamEntries/10) tombstoned := defs[11] tombstoned.Tombstone = true defs[11] = tombstoned - require.NoError(t, verifyAdmittingAll(codecs, defs)) + require.EqualError(t, verifyAdmittingAll(codecs, defs), + fmt.Sprintf("too many stream entries across all channels, got: %d/%d", 11*(MaxTotalStreamEntries/10), MaxTotalStreamEntries)) + + over := channel(1, MaxChannelOptsBytes+1) + over.Tombstone = true + require.EqualError(t, verifyAdmittingAll(codecs, llotypes.ChannelDefinitions{1: over}), + fmt.Sprintf("ChannelDefinition with ID 1 has opts that are too long, got: %d/%d", MaxChannelOptsBytes+1, MaxChannelOptsBytes)) + + set := llotypes.ChannelDefinitions{} + for c := range MaxTotalOptsBytes/MaxChannelOptsBytes + 1 { + cd := channel(1, MaxChannelOptsBytes) + cd.Tombstone = c == 0 + set[llotypes.ChannelID(c+1)] = cd + } + require.EqualError(t, verifyAdmittingAll(codecs, set), + fmt.Sprintf("too many opts bytes across all channels, got: %d/%d", MaxTotalOptsBytes+MaxChannelOptsBytes, MaxTotalOptsBytes)) + + manyStreams := channel(MaxStreamsPerChannel+1, 0) + manyStreams.Tombstone = true + require.EqualError(t, verifyAdmittingAll(codecs, llotypes.ChannelDefinitions{1: manyStreams}), + fmt.Sprintf("ChannelDefinition with ID 1 has too many streams, got: %d/%d", MaxStreamsPerChannel+1, MaxStreamsPerChannel)) + // Admission-only: a committed tombstone over the limits still verifies. + require.NoError(t, VerifyChannelDefinitions(codecs, llotypes.ChannelDefinitions{1: manyStreams})) }) } From c2a60c6db04912ac57e5dc59ef65914ff87d71f2 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Fri, 9 Oct 2026 13:23:52 +0100 Subject: [PATCH 002/103] llo/protocol: validate history backfill values on admission --- llo/protocol/channel_definitions.go | 3 ++ llo/protocol/history_backfill.go | 39 ++++++++++++++++++ llo/protocol/history_backfill_test.go | 58 +++++++++++++++++++++++++++ 3 files changed, 100 insertions(+) diff --git a/llo/protocol/channel_definitions.go b/llo/protocol/channel_definitions.go index 6599246..4f53627 100644 --- a/llo/protocol/channel_definitions.go +++ b/llo/protocol/channel_definitions.go @@ -360,6 +360,9 @@ func analyzeChannelDefinitions(codecs map[llotypes.ReportFormat]ReportCodec, cha if err := ValidateHistoryBackfillTarget(cd, channelDefs); err != nil { admit(fmt.Errorf("invalid history backfill channel %d: %w", channelID, err), channelID) } + if err := ValidateHistoryBackfillValues(cd, channelDefs); err != nil { + admit(fmt.Errorf("invalid history backfill channel %d: %w", channelID, err), channelID) + } } } diff --git a/llo/protocol/history_backfill.go b/llo/protocol/history_backfill.go index 04a72ac..d92258d 100644 --- a/llo/protocol/history_backfill.go +++ b/llo/protocol/history_backfill.go @@ -3,7 +3,9 @@ package protocol import ( "errors" "fmt" + "maps" "math" + "slices" "sort" "strconv" @@ -315,6 +317,43 @@ func ValidateHistoryBackfillTarget(cd llotypes.ChannelDefinition, defs llotypes. return nil } +// ValidateHistoryBackfillValues parses every observation of a backfill channel +// the way Reports will, and holds each value to the bounds an observed value +// meets: the decimal coefficient bound, and bid <= benchmark <= ask for quotes. +// +// Nothing else reads the values before Reports does, so without this an +// unparseable row is only found at selection. There it stops the channel for +// good, because selection keeps returning the same row and the watermark never +// moves past it. +func ValidateHistoryBackfillValues(cd llotypes.ChannelDefinition, defs llotypes.ChannelDefinitions) error { + opts, err := ParseHistoryBackfillOpts(cd.Opts) + if err != nil { + return err + } + target, ok := defs[opts.TargetChannelID] + if !ok { + return nil // reported by ValidateHistoryBackfillAgainstDefinitions + } + // Sorted so that a definition with more than one bad row always reports the + // same one. + for _, rawTS := range slices.Sorted(maps.Keys(opts.Observations)) { + values, err := BuildBackfillStreamValues(target, opts.Observations[rawTS]) + if err != nil { + return fmt.Errorf("timestamp %d: %w", rawTS, err) + } + for i, v := range values { + streamID := target.Streams[i].StreamID + if err := checkObservedStreamValue(v); err != nil { + return fmt.Errorf("timestamp %d: stream %d: %w", rawTS, streamID, err) + } + if q, ok := v.(*Quote); ok && !q.IsValid() { + return fmt.Errorf("timestamp %d: stream %d: quote violates bid <= benchmark <= ask", rawTS, streamID) + } + } + } + return nil +} + // DropInvalidHistoryBackfillChannels returns a copy of defs without history_backfill channels that fail validation. // The input defs map is not modified. nowNanos should be wall-clock nanoseconds; use 0 to skip the future-timestamp check. func DropInvalidHistoryBackfillChannels(lggr logger.Logger, defs llotypes.ChannelDefinitions, nowNanos uint64) llotypes.ChannelDefinitions { diff --git a/llo/protocol/history_backfill_test.go b/llo/protocol/history_backfill_test.go index 2e2db56..b1b42e2 100644 --- a/llo/protocol/history_backfill_test.go +++ b/llo/protocol/history_backfill_test.go @@ -3,6 +3,7 @@ package protocol import ( "fmt" "math" + "strings" "testing" "github.com/stretchr/testify/require" @@ -121,3 +122,60 @@ func Test_ValidateHistoryBackfillTarget(t *testing.T) { require.ErrorContains(t, err, "is itself a history_backfill channel") }) } + +func Test_ValidateHistoryBackfillValues(t *testing.T) { + backfill := func(agg llotypes.Aggregator, value string) llotypes.ChannelDefinition { + return llotypes.ChannelDefinition{ + ReportFormat: llotypes.ReportFormatHistoryBackfill, + Streams: []llotypes.Stream{{StreamID: 1, Aggregator: agg}}, + Opts: llotypes.ChannelOpts(fmt.Sprintf(`{"targetChannelId":2,"observations":{"1700000000":{"1":%q}}}`, value)), + } + } + target := func(agg llotypes.Aggregator) llotypes.ChannelDefinition { + return llotypes.ChannelDefinition{ + ReportFormat: llotypes.ReportFormatEVMPremiumLegacy, + Streams: []llotypes.Stream{{StreamID: 1, Aggregator: agg}}, + } + } + // 59 nines is 196 bits, one digit past what MaxDecimalCoefficientBits admits. + oversized := strings.Repeat("9", 59) + + for _, tc := range []struct { + name string + agg llotypes.Aggregator + value string + wantErr string + }{ + {"a decimal is accepted", llotypes.AggregatorMedian, "1.5", ""}, + {"a valid quote is accepted", llotypes.AggregatorQuote, "Q{Bid: 1, Benchmark: 2, Ask: 3}", ""}, + {"an unparseable decimal is rejected", llotypes.AggregatorMedian, "not a number", "timestamp 1700000000: stream 1:"}, + {"an oversized decimal coefficient is rejected", llotypes.AggregatorMedian, oversized, ErrDecimalCoefficientOutOfRange.Error()}, + {"an oversized quote coefficient is rejected", llotypes.AggregatorQuote, fmt.Sprintf("Q{Bid: 1, Benchmark: 2, Ask: %s}", oversized), ErrDecimalCoefficientOutOfRange.Error()}, + {"an inverted quote is rejected", llotypes.AggregatorQuote, "Q{Bid: 3, Benchmark: 2, Ask: 1}", "quote violates bid <= benchmark <= ask"}, + } { + t.Run(tc.name, func(t *testing.T) { + defs := llotypes.ChannelDefinitions{1: backfill(tc.agg, tc.value), 2: target(tc.agg)} + err := ValidateHistoryBackfillValues(defs[1], defs) + if tc.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tc.wantErr) + }) + } + + t.Run("a missing target is left to ValidateHistoryBackfillAgainstDefinitions", func(t *testing.T) { + defs := llotypes.ChannelDefinitions{1: backfill(llotypes.AggregatorMedian, "not a number")} + require.NoError(t, ValidateHistoryBackfillValues(defs[1], defs)) + }) + + // The rule is admission-only: committed rows are left to the runtime path, + // but no new one is installable. + t.Run("committed definitions keep working", func(t *testing.T) { + defs := llotypes.ChannelDefinitions{1: backfill(llotypes.AggregatorQuote, "Q{Bid: 3, Benchmark: 2, Ask: 1}"), 2: target(llotypes.AggregatorQuote)} + codecs := map[llotypes.ReportFormat]ReportCodec{} + require.NoError(t, VerifyChannelDefinitions(codecs, defs)) + err := VerifyChannelDefinitionsForAdmission(codecs, defs, map[llotypes.ChannelID]struct{}{1: {}}) + require.ErrorContains(t, err, "quote violates bid <= benchmark <= ask") + }) +} From b30ec155fe6ec8441beba6e58448484b3cbbc83b Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Fri, 9 Oct 2026 14:59:55 +0100 Subject: [PATCH 003/103] llo/protocol: attribute backfill target finding to the target channel --- llo/protocol/channel_definitions.go | 8 +++++++- llo/protocol/history_backfill_test.go | 10 ++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/llo/protocol/channel_definitions.go b/llo/protocol/channel_definitions.go index 4f53627..53a72c4 100644 --- a/llo/protocol/channel_definitions.go +++ b/llo/protocol/channel_definitions.go @@ -357,8 +357,14 @@ func analyzeChannelDefinitions(codecs map[llotypes.ReportFormat]ReportCodec, cha if err := ValidateHistoryBackfillAgainstDefinitions(cd, channelDefs, 0); err != nil { base(fmt.Errorf("invalid history backfill channel %d: %w", channelID, err), channelID) } + // The target report format decides this as much as the backfill + // channel does, so changing either one is what installs the pair. if err := ValidateHistoryBackfillTarget(cd, channelDefs); err != nil { - admit(fmt.Errorf("invalid history backfill channel %d: %w", channelID, err), channelID) + implicated := []llotypes.ChannelID{channelID} + if opts, perr := ParseHistoryBackfillOpts(cd.Opts); perr == nil { + implicated = append(implicated, opts.TargetChannelID) + } + admit(fmt.Errorf("invalid history backfill channel %d: %w", channelID, err), implicated...) } if err := ValidateHistoryBackfillValues(cd, channelDefs); err != nil { admit(fmt.Errorf("invalid history backfill channel %d: %w", channelID, err), channelID) diff --git a/llo/protocol/history_backfill_test.go b/llo/protocol/history_backfill_test.go index b1b42e2..1953532 100644 --- a/llo/protocol/history_backfill_test.go +++ b/llo/protocol/history_backfill_test.go @@ -121,6 +121,16 @@ func Test_ValidateHistoryBackfillTarget(t *testing.T) { err := VerifyChannelDefinitionsForAdmission(codecs, defs, map[llotypes.ChannelID]struct{}{1: {}}) require.ErrorContains(t, err, "is itself a history_backfill channel") }) + + // Turning a committed backfill channel target into a backfill channel + // leaves the backfill channel own definition unchanged, so only the + // target is being admitted. + t.Run("admitting only the target is rejected", func(t *testing.T) { + defs := llotypes.ChannelDefinitions{1: backfill(2), 2: backfill(3), 3: reportable} + codecs := map[llotypes.ReportFormat]ReportCodec{} + err := VerifyChannelDefinitionsForAdmission(codecs, defs, map[llotypes.ChannelID]struct{}{2: {}}) + require.ErrorContains(t, err, "invalid history backfill channel 1: target channel 2 is itself a history_backfill channel") + }) } func Test_ValidateHistoryBackfillValues(t *testing.T) { From 69406e6e026269e5eab5e4b4fb8f2f177706cb7f Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Fri, 9 Oct 2026 18:52:15 +0100 Subject: [PATCH 004/103] llo/calculated: bound Round precision and decimal literal exponents --- llo/protocol/calculated/calculated.go | 27 +++++++-- llo/protocol/calculated/calculated_test.go | 66 ++++++++++++++++++++++ llo/protocol/calculated/history_ast.go | 36 ++++++++++++ llo/protocol/calculated/validation_test.go | 47 +++++++++++++++ llo/protocol/stream_value.go | 6 ++ 5 files changed, 177 insertions(+), 5 deletions(-) diff --git a/llo/protocol/calculated/calculated.go b/llo/protocol/calculated/calculated.go index 16e9da9..b717f70 100644 --- a/llo/protocol/calculated/calculated.go +++ b/llo/protocol/calculated/calculated.go @@ -491,8 +491,8 @@ func IsPositive(x any) (bool, error) { // Round returns the rounded value of x to the given precision func Round(x any, precision int) (decimal.Decimal, error) { - if precision > math.MaxInt32 { - return decimal.Decimal{}, fmt.Errorf("precision is too large") + if err := checkPrecision(precision); err != nil { + return decimal.Decimal{}, err } ad, err := toDecimal(x) if err != nil { @@ -503,8 +503,8 @@ func Round(x any, precision int) (decimal.Decimal, error) { // Truncate truncates off digits from the number, without rounding. func Truncate(x any, precision int) (decimal.Decimal, error) { - if precision > math.MaxInt32 { - return decimal.Decimal{}, fmt.Errorf("precision is too large") + if err := checkPrecision(precision); err != nil { + return decimal.Decimal{}, err } n, err := toDecimal(x) if err != nil { @@ -513,6 +513,14 @@ func Truncate(x any, precision int) (decimal.Decimal, error) { return n.Truncate(int32(precision)), nil } +// checkPrecision bounds a rounding precision to the exponent range of stream values. +func checkPrecision(precision int) error { + if precision > protocol.MaxDecimalExponent || precision < -protocol.MaxDecimalExponent { + return fmt.Errorf("precision %d out of range, expected absolute value <= %d", precision, protocol.MaxDecimalExponent) + } + return nil +} + // ParseDuration parses a duration string into a time.ParseDuration func ParseDuration(x string) (time.Duration, error) { return time.ParseDuration(x) @@ -526,7 +534,16 @@ func toDecimal(x any) (decimal.Decimal, error) { // backstop for a bypassed analysis rather than an expected path. return decimal.Decimal{}, fmt.Errorf("%w (length %d)", ErrSeriesAsScalar, v.Len()) case string: - return decimal.NewFromString(v) + // Bounded like an observed value: an operation on a literal such as + // "1e-2000000000" rescales its operands to the literal's exponent. + d, err := decimal.NewFromString(v) + if err != nil { + return decimal.Decimal{}, err + } + if err := protocol.CheckDecimalExponent(d); err != nil { + return decimal.Decimal{}, err + } + return d, nil case int: return decimal.NewFromInt(int64(v)), nil case int32: diff --git a/llo/protocol/calculated/calculated_test.go b/llo/protocol/calculated/calculated_test.go index b8e7df0..425ade1 100644 --- a/llo/protocol/calculated/calculated_test.go +++ b/llo/protocol/calculated/calculated_test.go @@ -32,6 +32,21 @@ func TestToDecimal(t *testing.T) { input: "invalid", expectError: true, }, + { + name: "string exponent at the bound", + input: "1e-1000", + expected: decimal.New(1, -1000), + }, + { + name: "string exponent below the bound", + input: "1e-1001", + expectError: true, + }, + { + name: "string exponent above the bound", + input: "1e2000000000", + expectError: true, + }, { name: "int", input: 123, @@ -1037,12 +1052,48 @@ func TestRound(t *testing.T) { precision: -1, expected: "120", }, + { + name: "precision at the upper bound", + input: "123.456", + precision: protocol.MaxDecimalExponent, + expected: "123.456", + }, + { + name: "precision at the lower bound", + input: "123.456", + precision: -protocol.MaxDecimalExponent, + expected: "0", + }, + { + name: "precision above the bound", + input: "123.456", + precision: protocol.MaxDecimalExponent + 1, + expectError: true, + }, + { + name: "precision below the bound", + input: "123.456", + precision: -protocol.MaxDecimalExponent - 1, + expectError: true, + }, { name: "precision too large", input: "123.456", precision: math.MaxInt32 + 1, expectError: true, }, + { + name: "precision near MaxInt32", + input: "123.456", + precision: math.MaxInt32, + expectError: true, + }, + { + name: "precision near MinInt32", + input: "123.456", + precision: math.MinInt32, + expectError: true, + }, { name: "invalid input", input: "invalid", @@ -1087,6 +1138,21 @@ func TestTruncate(t *testing.T) { precision: 2, expected: "3.14", }, + { + name: "precision above the bound", + precision: protocol.MaxDecimalExponent + 1, + expectError: true, + }, + { + name: "precision near MaxInt32", + precision: math.MaxInt32, + expectError: true, + }, + { + name: "precision near MinInt32", + precision: math.MinInt32, + expectError: true, + }, } // We use a string representation of math.Pi because if we pass the constant we only get 15 digits of precision. diff --git a/llo/protocol/calculated/history_ast.go b/llo/protocol/calculated/history_ast.go index 747581f..b2ce145 100644 --- a/llo/protocol/calculated/history_ast.go +++ b/llo/protocol/calculated/history_ast.go @@ -8,6 +8,7 @@ import ( "strconv" "github.com/expr-lang/expr/ast" + "github.com/shopspring/decimal" llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" @@ -38,6 +39,10 @@ const HistoryFunctionName = "History" // static analysis that validates its configuration. const twapFunctionName = "TWAP" +// roundFunctionName is the DSL name Round is registered under, shared with the +// static analysis that bounds its literal precision. +const roundFunctionName = "Round" + // Field selects which part of a stored stream value a window projects. One // stored window serves every field, so History(s1, 10), History(s1_bid, 10) and // History(s1_ask, 10) share a single series in state and differ only here. @@ -212,6 +217,15 @@ func (p *historyPatcher) Visit(node *ast.Node) { p.errorf("identifier %q uses the reserved history namespace (__h); use %s(s, ) instead", n.Value, HistoryFunctionName) } + case *ast.StringNode: + // Strings that are not decimals (durations, configuration keys) are + // left to the functions that take them. + if d, err := decimal.NewFromString(n.Value); err == nil { + if err := protocol.CheckDecimalExponent(d); err != nil { + p.errorf("decimal literal %q: %s", n.Value, err) + } + } + case *ast.CallNode: callee, ok := n.Callee.(*ast.IdentifierNode) if !ok { @@ -221,6 +235,14 @@ func (p *historyPatcher) Visit(node *ast.Node) { p.rewrite(node, n) return } + if callee.Value == roundFunctionName && len(n.Arguments) == 2 { + // A computed precision is left to the runtime bound. + if precision, ok := integerLiteral(n.Arguments[1]); ok { + if err := checkPrecision(precision); err != nil { + p.errorf("%s: %s", roundFunctionName, err) + } + } + } if rangeAcceptingFunctions[callee.Value] { for _, arg := range n.Arguments { if p.generated[arg] { @@ -377,6 +399,20 @@ func twapConfigLiteral(config *ast.MapNode, key string) (int64, bool) { return 0, false } +// integerLiteral returns the value of an integer literal, including a negated +// one, which the parser represents as a unary minus over the literal. +func integerLiteral(node ast.Node) (int, bool) { + switch n := node.(type) { + case *ast.IntegerNode: + return n.Value, true + case *ast.UnaryNode: + if v, ok := n.Node.(*ast.IntegerNode); ok && n.Operator == "-" { + return -v.Value, true + } + } + return 0, false +} + // err reports every problem found, including windows left in a position that // cannot consume them. // diff --git a/llo/protocol/calculated/validation_test.go b/llo/protocol/calculated/validation_test.go index 68b331c..c6eb72f 100644 --- a/llo/protocol/calculated/validation_test.go +++ b/llo/protocol/calculated/validation_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -262,6 +263,52 @@ func TestValidateExpression_SizeLimits(t *testing.T) { assert.Contains(t, err.Error(), "exceeds the maximum") } +// TestValidateExpression_LiteralBounds covers the static bounds on Round +// precision and decimal string literals. Both drive a rescale to 10^p, so an +// out of range literal is rejected at admission rather than costing every node +// that time and memory each round. +func TestValidateExpression_LiteralBounds(t *testing.T) { + t.Parallel() + + t.Run("accepts", func(t *testing.T) { + t.Parallel() + for _, expression := range []string{ + fmt.Sprintf("Round(s1, %d)", protocol.MaxDecimalExponent), + fmt.Sprintf("Round(s1, -%d)", protocol.MaxDecimalExponent), + fmt.Sprintf(`Sum(s1, "1e-%d")`, protocol.MaxDecimalExponent), + // Computed precision is left to the runtime bound. + "Round(s1, 2000000 * 1000)", + } { + assert.NoError(t, ValidateExpression(expression), "expression %q", expression) + } + }) + + t.Run("rejects", func(t *testing.T) { + t.Parallel() + for _, expression := range []string{ + fmt.Sprintf("Round(s1, %d)", protocol.MaxDecimalExponent+1), + fmt.Sprintf("Round(s1, -%d)", protocol.MaxDecimalExponent+1), + "Round(s1, 2000000000)", + fmt.Sprintf(`Sum(s1, "1e-%d")`, protocol.MaxDecimalExponent+1), + `Sum(s1, "1e2000000000")`, + } { + err := ValidateExpression(expression) + require.ErrorIs(t, err, ErrHistoryExpression, "expression %q", expression) + } + }) + + t.Run("runtime bound on computed precision", func(t *testing.T) { + t.Parallel() + env := NewEnv(0) + defer env.release() + env["s1"] = decimal.RequireFromString("1.5") + _, err := evalDecimal("Round(s1, 2000000 * 1000)", env) + require.ErrorContains(t, err, "precision 2000000000 out of range") + _, err = evalDecimal(`Sum(s1, "1e-" + "2000000000")`, env) + require.ErrorIs(t, err, protocol.ErrDecimalExponentOutOfRange) + }) +} + // TestValidateChannelExpressions_CountLimit covers the per-channel expression // count. The opts byte caps bound the blob, not the number of evaluations a // channel costs every round. diff --git a/llo/protocol/stream_value.go b/llo/protocol/stream_value.go index ef645d9..757543c 100644 --- a/llo/protocol/stream_value.go +++ b/llo/protocol/stream_value.go @@ -121,6 +121,12 @@ func checkDecimalCoefficient(d decimal.Decimal) error { return nil } +// CheckDecimalExponent bounds the exponent of a decimal parsed from an +// expression literal. See MaxDecimalExponent. +func CheckDecimalExponent(d decimal.Decimal) error { + return checkDecimalExponent(d) +} + // checkDecimalExponent bounds the exponent of a decimal decoded from an // untrusted source. See MaxDecimalExponent. func checkDecimalExponent(d decimal.Decimal) error { From 7b3e87808f8d92aa71407bc7eeb67f9077ee35a9 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Fri, 9 Oct 2026 19:25:03 +0100 Subject: [PATCH 005/103] llo/calculated: bound TWAP work across the definition set Add MaxTotalTWAPCalls, enforced on admission through an optional TWAPCounter codec interface. Compute TWAP from observed seconds instead of one bucket per window second, and charge TWAP windows in the evaluation weight. --- llo/protocol/calculated/calculated.go | 14 +- llo/protocol/calculated/calculated_test.go | 22 ++ .../calculated/functions_bench_test.go | 32 +++ llo/protocol/calculated/functions_twap.go | 176 ++++++------ .../functions_twap_reference_test.go | 260 ++++++++++++++++++ .../calculated/functions_twap_test.go | 8 +- llo/protocol/calculated/history_ast.go | 14 +- llo/protocol/calculated/history_ast_test.go | 20 +- llo/protocol/calculated/program.go | 43 ++- llo/protocol/calculated/validation_test.go | 37 +++ llo/protocol/channel_analysis_cache.go | 4 + llo/protocol/channel_definitions.go | 11 +- llo/protocol/channel_definitions_test.go | 31 +++ llo/protocol/limits.go | 18 +- llo/protocol/types.go | 11 + ...port_codec_evm_abi_encode_unpacked_expr.go | 9 + ...codec_evm_abi_encode_unpacked_expr_test.go | 11 + 17 files changed, 592 insertions(+), 129 deletions(-) create mode 100644 llo/protocol/calculated/functions_twap_reference_test.go diff --git a/llo/protocol/calculated/calculated.go b/llo/protocol/calculated/calculated.go index b717f70..4a538c2 100644 --- a/llo/protocol/calculated/calculated.go +++ b/llo/protocol/calculated/calculated.go @@ -827,17 +827,23 @@ func evaluateCalculatedStreams(works []channelWork) []channelResult { // evaluationWeight estimates how much evaluation a round holds, in values that // will be visited: one per expression, plus one per record in each window it -// reads. It is the cheapest predictor of cost that distinguishes the two cases -// that matter — a round of scalar arithmetic from a round of window functions — -// and it is derived from prepared work, so it costs nothing to compute. +// reads, plus the longest window each TWAP call may step through. It is the +// cheapest predictor of cost that distinguishes the two cases that matter — a +// round of scalar arithmetic from a round of window functions — and it is +// derived from prepared work and cached analysis, so it costs next to nothing. +// +// TWAP is charged its worst case because its cost follows the window it is +// configured with rather than the records it reads: two records a day apart +// are two values here but tens of thousands of interpolated seconds. func evaluationWeight(works []channelWork) int { weight := 0 for i := range works { - for _, window := range works[i].windows { + for j, window := range works[i].windows { weight++ for _, bound := range window { weight += bound.series.Len() } + weight += historyAnalysisCache.entry(works[i].opts.ABI[j].Expression).twapCalls * twapMaxWindowSeconds } } return weight diff --git a/llo/protocol/calculated/calculated_test.go b/llo/protocol/calculated/calculated_test.go index 425ade1..aac6f4e 100644 --- a/llo/protocol/calculated/calculated_test.go +++ b/llo/protocol/calculated/calculated_test.go @@ -1840,3 +1840,25 @@ func Test_environment_SetStreamValue_NestedNil(t *testing.T) { require.ErrorContains(t, err, "stream value is nil") require.NotContains(t, env, "s1") } + +// TestEvaluationWeight_ChargesTWAPWindow checks that a TWAP call over a shallow +// window still counts as heavy: two records a day apart are two values to read +// but tens of thousands of seconds to interpolate. +func TestEvaluationWeight_ChargesTWAPWindow(t *testing.T) { + t.Parallel() + + shallow := []boundSeries{{name: "w", series: benchSeries(2, 1)}} + work := func(expression string) []channelWork { + return []channelWork{{ + opts: calculatedStreamOpts{ABI: []protocol.CalculatedStreamABI{{Expression: expression, ExpressionStreamID: 900}}}, + windows: [][]boundSeries{shallow}, + }} + } + + assert.Equal(t, 3, evaluationWeight(work("Count(History(s1, 2))"))) + + twap := `TWAP(History(s1, 2), {window: Duration("24h"), minSamples: 1, maxHeadGap: 86400, maxInteriorGap: 86400, maxTailGap: 86400})` + weight := evaluationWeight(work(twap)) + assert.Equal(t, 3+twapMaxWindowSeconds, weight) + assert.GreaterOrEqual(t, weight, minParallelEvaluationWeight) +} diff --git a/llo/protocol/calculated/functions_bench_test.go b/llo/protocol/calculated/functions_bench_test.go index acfa85b..ad04d36 100644 --- a/llo/protocol/calculated/functions_bench_test.go +++ b/llo/protocol/calculated/functions_bench_test.go @@ -176,6 +176,38 @@ func BenchmarkTWAPRealisticGaps(b *testing.B) { } } +// BenchmarkTWAPMaxWindow measures one TWAP call at the longest window allowed +// over the deepest history allowed, which is what protocol.MaxTotalTWAPCalls +// charges every call. Records one second apart leave a head gap that costs one +// multiplication; records spread evenly across the window turn every missing +// second into an interior one, the worst case. +func BenchmarkTWAPMaxWindow(b *testing.B) { + const windowSeconds = twapMaxWindowSeconds + const depth = protocol.MaxHistoryRecordsPerPair + + for _, spacing := range []int{1, windowSeconds / depth} { + window := benchSeries(depth, spacing) + anchorNs := uint64(depth*spacing+1) * uint64(time.Second) + cfg := map[string]any{ + "window": time.Duration(windowSeconds) * time.Second, + "minSamples": 1, + "maxHeadGap": windowSeconds, + "maxInteriorGap": windowSeconds, + "maxTailGap": windowSeconds, + } + twap := twapFunc(anchorNs) + + b.Run(fmt.Sprintf("recordSpacing=%ds", spacing), func(b *testing.B) { + b.ReportAllocs() + for range b.N { + if _, err := twap(window, cfg); err != nil { + b.Fatal(err) + } + } + }) + } +} + // BenchmarkTWAPParallel shows what the transcendental lock costs when several // plugin instances evaluate TWAP at once. Compare ns/op against the serial // benchmark: no speedup means the lock is the limit. diff --git a/llo/protocol/calculated/functions_twap.go b/llo/protocol/calculated/functions_twap.go index c5686f0..29c14cc 100644 --- a/llo/protocol/calculated/functions_twap.go +++ b/llo/protocol/calculated/functions_twap.go @@ -74,8 +74,15 @@ type twapConfig struct { maxTailGap int } -// twapBucket is one second of the dense series the specification operates on. -// price is only meaningful when observed is true. +// twapObservation is one observed second of the window: its offset from the +// window start and the price recorded in it. +// +// The specification operates on a dense series of one-second buckets. Only the +// observed ones carry information, since every missing bucket is a function of +// the observations either side of it, so the window is held as its observations +// alone. That keeps the cost of a window proportional to its records rather than +// its length: a 24 hour window over a 1024-record history costs what its records +// and interior gaps cost, not 86400 buckets. // // The specification is written in log-price space throughout: build X[i] = ln(P[i]), // fill gaps in that space, then average exp of the filled series. This stores the @@ -93,11 +100,11 @@ type twapConfig struct { // transcendental lock. Measured: 197ms per evaluation for a 300-second window, // and 7.2s for 32 such channels in one round, against a round budget on the // order of a second. Doing it this way, a fully covered window needs no -// transcendental operations at all, and a window with gaps needs two logarithms -// per gap plus one exponential per missing bucket. -type twapBucket struct { - observed bool - price decimal.Decimal +// transcendental operations at all, and a window with gaps needs one power per +// interior gap. +type twapObservation struct { + offset int + price decimal.Decimal } // twapFunc returns the TWAP function bound to a round's consensus observation @@ -135,9 +142,10 @@ func twapUnbound(any, any) (decimal.Decimal, error) { func twap(series Series, cfg twapConfig, anchorSeconds int64) (decimal.Decimal, error) { windowStart := anchorSeconds - cfg.windowSeconds - buckets := make([]twapBucket, cfg.windowSeconds) + n := int(cfg.windowSeconds) values, timestamps := series.Values(), series.Timestamps() + observations := make([]twapObservation, 0, len(timestamps)) for i, ts := range timestamps { seconds := int64(ts / uint64(time.Second)) if seconds < windowStart || seconds >= anchorSeconds { @@ -150,12 +158,17 @@ func twap(series Series, cfg twapConfig, anchorSeconds int64) (decimal.Decimal, if !values[i].IsPositive() { return decimal.Decimal{}, fmt.Errorf("TWAP: record %d: price %s must be positive", i, values[i]) } - // Timestamps are strictly increasing, so a later record legitimately - // overwrites an earlier one in the same bucket: newest wins. - buckets[seconds-windowStart] = twapBucket{observed: true, price: values[i]} + // Timestamps are strictly increasing, so records sharing a second are + // adjacent and a later one replaces an earlier one: newest wins. + offset := int(seconds - windowStart) + if last := len(observations) - 1; last >= 0 && observations[last].offset == offset { + observations[last].price = values[i] + continue + } + observations = append(observations, twapObservation{offset: offset, price: values[i]}) } - m, gHead, gInt, gTail := twapGapStats(buckets) + m, gHead, gInt, gTail := twapGapStats(observations, n) var reasons []TWAPRejectionReason // A floor of 1 observation is required for head backfill to have an anchor. @@ -185,96 +198,67 @@ func twap(series Series, cfg twapConfig, anchorSeconds int64) (decimal.Decimal, } } - return twapFillThenAverage(buckets) + return twapFillThenAverage(observations, n) } -// twapGapStats measures M, Ghead, Gint and Gtail by classifying each missing run -// by its position (spec §2, ADR 0015). +// twapGapStats measures M, Ghead, Gint and Gtail over a window of n buckets +// from its observations, which must be in ascending offset order (spec §2, +// ADR 0015). // // Ghead and Gtail are kept separate from Gint deliberately: Gint is the // both-sides-anchored statistic, and a head or tail run has only one anchor. A -// run spanning the whole window is classified as none of them because it has no -// anchors at all; such a window is always rejected by the M check. -func twapGapStats(buckets []twapBucket) (m, gHead, gInt, gTail int) { - n := len(buckets) - for i := 0; i < n; { - runStart := i - observed := buckets[i].observed - for i < n && buckets[i].observed == observed { - i++ - } - runLen := i - runStart - - if observed { - m += runLen - continue - } - switch { - case runStart == 0 && i == n: - // Entire window missing: no anchors, so not head, tail or interior. - case runStart == 0: - gHead = runLen - case i == n: - gTail = runLen - default: - gInt = max(gInt, runLen) - } +// window with no observations has a single run with no anchors at all, which +// is classified as none of them; such a window is always rejected by the M +// check. +func twapGapStats(observations []twapObservation, n int) (m, gHead, gInt, gTail int) { + m = len(observations) + if m == 0 { + return 0, 0, 0, 0 + } + gHead = observations[0].offset + gTail = n - 1 - observations[m-1].offset + for i := 1; i < m; i++ { + gInt = max(gInt, observations[i].offset-observations[i-1].offset-1) } return m, gHead, gInt, gTail } -// twapFillThenAverage fills every bucket per spec §4 and returns the mean price -// over the full window. +// twapFillThenAverage fills every bucket of a window of n buckets per spec §4 +// and returns the mean price over the full window. // // Callers must only reach this once the acceptance rule has passed, which // guarantees at least one observation. -func twapFillThenAverage(buckets []twapBucket) (decimal.Decimal, error) { - n := len(buckets) - filled := make([]decimal.Decimal, n) - - for i := 0; i < n; { - if buckets[i].observed { - filled[i] = buckets[i].price // spec §4.1: X[i] passes through - i++ +func twapFillThenAverage(observations []twapObservation, n int) (decimal.Decimal, error) { + first, last := observations[0], observations[len(observations)-1] + + // Head gap: backfill the first observed price (ADR 0015). Tail gap: carry + // forward the last observed price (spec §4.3). Decimal multiplication and + // addition are exact, so a run of k copies adds price*k, the same sum as + // adding each copy. + sum := first.price.Mul(decimal.NewFromInt(int64(first.offset))) + sum = sum.Add(last.price.Mul(decimal.NewFromInt(int64(n - 1 - last.offset)))) + + for i, observation := range observations { + sum = sum.Add(observation.price) // spec §4.1: X[i] passes through + if i == 0 || observation.offset-observations[i-1].offset == 1 { continue } - runStart := i - for i < n && !buckets[i].observed { - i++ - } - switch { - case runStart == 0: - // Head gap: backfill the first observed price (ADR 0015). - // buckets[i] is observed, because a window with no observation at - // all was rejected above. - for k := 0; k < i; k++ { - filled[k] = buckets[i].price - } - case i == n: - // Tail gap: carry forward the last observed price (spec §4.3). - for k := runStart; k < n; k++ { - filled[k] = buckets[runStart-1].price - } - default: - // Interior gap: log-linear interpolation between the bracketing - // anchors at runStart-1 and i (spec §4.2). This is the only case - // that needs log space, so it is the only one that pays for it. - if err := twapInterpolate(buckets, filled, runStart, i); err != nil { - return decimal.Decimal{}, err - } + // Interior gap: log-linear interpolation between the bracketing + // anchors (spec §4.2). This is the only case that needs log space, so + // it is the only one that pays for it. + filled, err := twapInterpolate(observations[i-1], observation) + if err != nil { + return decimal.Decimal{}, err } + sum = sum.Add(filled) } // TWAP = mean over N (spec §4-5, denominator N not M). - sum := decimal.Zero - for _, price := range filled { - sum = sum.Add(price) - } return divRoundByInt(sum, n, precision) } -// twapInterpolate fills the missing run [runStart, rightIdx) between its -// bracketing anchors (spec §4.2). +// twapInterpolate fills the missing run between two adjacent observations +// (spec §4.2) and returns the sum of the filled prices. // // Linear interpolation in log space is geometric interpolation in price space: a // gap between 100 and 1600 fills as 200, 400, 800, not as evenly spaced prices. @@ -292,30 +276,28 @@ func twapFillThenAverage(buckets []twapBucket) (decimal.Decimal, error) { // // Determinism: the ratio is computed at a fixed precision and every step is // rounded, so the sequence is reproducible — the same requirement EMA has, for the -// same reason. -func twapInterpolate(buckets []twapBucket, filled []decimal.Decimal, runStart, rightIdx int) error { - leftIdx := runStart - 1 - left, right := buckets[leftIdx].price, buckets[rightIdx].price - - growth, err := divRound(right, left, doublePrecision) +// same reason. That is also why the steps cannot be summed in closed form. +func twapInterpolate(left, right twapObservation) (decimal.Decimal, error) { + growth, err := divRound(right.price, left.price, doublePrecision) if err != nil { - return fmt.Errorf("TWAP: bucket %d: %w", leftIdx, err) + return decimal.Decimal{}, fmt.Errorf("TWAP: bucket %d: %w", left.offset, err) } - exponent, err := divRoundByInt(decimal.NewFromInt(1), rightIdx-leftIdx, doublePrecision) + exponent, err := divRoundByInt(decimal.NewFromInt(1), right.offset-left.offset, doublePrecision) if err != nil { - return err + return decimal.Decimal{}, err } ratio, err := decimalPow(growth, exponent, doublePrecision) if err != nil { - return fmt.Errorf("TWAP: interpolating buckets %d..%d: %w", runStart, rightIdx-1, err) + return decimal.Decimal{}, fmt.Errorf("TWAP: interpolating buckets %d..%d: %w", left.offset+1, right.offset-1, err) } - price := left - for k := runStart; k < rightIdx; k++ { + sum := decimal.Zero + price := left.price + for range right.offset - left.offset - 1 { price = price.Mul(ratio).Round(doublePrecision) - filled[k] = price + sum = sum.Add(price) } - return nil + return sum, nil } // parseTWAPConfig decodes and validates the configuration map. @@ -414,7 +396,7 @@ func twapWindowSeconds(raw any) (int64, error) { // global. The division is exact here, but the rule holds everywhere. // Compared and bounded as a decimal, before any narrowing; see decimalToInt. // The upper bound also caps the per-evaluation work: the calculation - // allocates and fills one bucket per second of the window. + // steps through every missing interior second of the window. secondsDecimal := nanoseconds.DivRound(perSecond, 0) if secondsDecimal.LessThan(decimal.NewFromInt(1)) { return 0, fmt.Errorf("%w: window must be at least one second, got %s", ErrTWAPConfig, secondsDecimal) @@ -431,7 +413,7 @@ func twapWindowSeconds(raw any) (int64, error) { } // twapMaxWindowSeconds bounds the number of one-second buckets a single TWAP -// evaluation may allocate and fill. 24 hours is far beyond any settlement window +// evaluation may interpolate. 24 hours is far beyond any settlement window // while keeping the per-round work bounded. const twapMaxWindowSeconds = 24 * 60 * 60 diff --git a/llo/protocol/calculated/functions_twap_reference_test.go b/llo/protocol/calculated/functions_twap_reference_test.go new file mode 100644 index 0000000..fcdc64b --- /dev/null +++ b/llo/protocol/calculated/functions_twap_reference_test.go @@ -0,0 +1,260 @@ +package calculated + +import ( + "fmt" + "math/rand/v2" + "testing" + "time" + + "github.com/shopspring/decimal" + "github.com/stretchr/testify/require" +) + +// The dense implementation TWAP shipped with: one bucket per second of the +// window. Kept as the reference the sparse implementation is checked against, +// bit for bit, by TestTWAP_MatchesDenseReference. + +type twapBucket struct { + observed bool + price decimal.Decimal +} + +func twapDenseReference(series Series, cfg twapConfig, anchorSeconds int64) (decimal.Decimal, error) { + windowStart := anchorSeconds - cfg.windowSeconds + buckets := make([]twapBucket, cfg.windowSeconds) + + values, timestamps := series.Values(), series.Timestamps() + for i, ts := range timestamps { + seconds := int64(ts / uint64(time.Second)) + if seconds < windowStart || seconds >= anchorSeconds { + continue // outside the half-open window (ADR 0013) + } + // The price must be positive: the filling rules are defined in log space, + // so a non-positive price has no representation there. Checked here for + // every observed bucket rather than only where a logarithm is taken, so + // acceptance does not depend on where the gaps happen to fall. + if !values[i].IsPositive() { + return decimal.Decimal{}, fmt.Errorf("TWAP: record %d: price %s must be positive", i, values[i]) + } + // Timestamps are strictly increasing, so a later record legitimately + // overwrites an earlier one in the same bucket: newest wins. + buckets[seconds-windowStart] = twapBucket{observed: true, price: values[i]} + } + + m, gHead, gInt, gTail := twapDenseGapStats(buckets) + + var reasons []TWAPRejectionReason + // A floor of 1 observation is required for head backfill to have an anchor. + // With a validated minSamples >= 1 this is redundant, but it keeps a + // misconfiguration from reaching an out-of-range index below. + minSamples := max(cfg.minSamples, 1) + if m < minSamples { + reasons = append(reasons, ReasonInsufficientSamples) + } + if gHead > cfg.maxHeadGap { + reasons = append(reasons, ReasonHeadGapTooLong) + } + if gInt > cfg.maxInteriorGap { + reasons = append(reasons, ReasonInteriorGapTooLong) + } + if gTail > cfg.maxTailGap { + reasons = append(reasons, ReasonTailGapTooLong) + } + if len(reasons) > 0 { + return decimal.Decimal{}, &TWAPRejection{ + Reasons: reasons, + M: m, Ghead: gHead, Gint: gInt, Gtail: gTail, + MinSamples: cfg.minSamples, MaxHeadGap: cfg.maxHeadGap, + MaxInteriorGap: cfg.maxInteriorGap, MaxTailGap: cfg.maxTailGap, + WindowStartSeconds: windowStart, WindowEndSeconds: anchorSeconds, + Records: series.Len(), + } + } + + return twapDenseFillThenAverage(buckets) +} + +// twapDenseGapStats measures M, Ghead, Gint and Gtail by classifying each missing run +// by its position (spec §2, ADR 0015). +// +// Ghead and Gtail are kept separate from Gint deliberately: Gint is the +// both-sides-anchored statistic, and a head or tail run has only one anchor. A +// run spanning the whole window is classified as none of them because it has no +// anchors at all; such a window is always rejected by the M check. +func twapDenseGapStats(buckets []twapBucket) (m, gHead, gInt, gTail int) { + n := len(buckets) + for i := 0; i < n; { + runStart := i + observed := buckets[i].observed + for i < n && buckets[i].observed == observed { + i++ + } + runLen := i - runStart + + if observed { + m += runLen + continue + } + switch { + case runStart == 0 && i == n: + // Entire window missing: no anchors, so not head, tail or interior. + case runStart == 0: + gHead = runLen + case i == n: + gTail = runLen + default: + gInt = max(gInt, runLen) + } + } + return m, gHead, gInt, gTail +} + +// twapDenseFillThenAverage fills every bucket per spec §4 and returns the mean price +// over the full window. +// +// Callers must only reach this once the acceptance rule has passed, which +// guarantees at least one observation. +func twapDenseFillThenAverage(buckets []twapBucket) (decimal.Decimal, error) { + n := len(buckets) + filled := make([]decimal.Decimal, n) + + for i := 0; i < n; { + if buckets[i].observed { + filled[i] = buckets[i].price // spec §4.1: X[i] passes through + i++ + continue + } + runStart := i + for i < n && !buckets[i].observed { + i++ + } + switch { + case runStart == 0: + // Head gap: backfill the first observed price (ADR 0015). + // buckets[i] is observed, because a window with no observation at + // all was rejected above. + for k := 0; k < i; k++ { + filled[k] = buckets[i].price + } + case i == n: + // Tail gap: carry forward the last observed price (spec §4.3). + for k := runStart; k < n; k++ { + filled[k] = buckets[runStart-1].price + } + default: + // Interior gap: log-linear interpolation between the bracketing + // anchors at runStart-1 and i (spec §4.2). This is the only case + // that needs log space, so it is the only one that pays for it. + if err := twapDenseInterpolate(buckets, filled, runStart, i); err != nil { + return decimal.Decimal{}, err + } + } + } + + // TWAP = mean over N (spec §4-5, denominator N not M). + sum := decimal.Zero + for _, price := range filled { + sum = sum.Add(price) + } + return divRoundByInt(sum, n, precision) +} + +// twapDenseInterpolate fills the missing run [runStart, rightIdx) between its +// bracketing anchors (spec §4.2). +// +// Linear interpolation in log space is geometric interpolation in price space: a +// gap between 100 and 1600 fills as 200, 400, 800, not as evenly spaced prices. +// So rather than exponentiating each interpolated log-price, this takes the +// constant per-second ratio once and steps through the gap by multiplication: +// +// ratio = (right / left) ^ (1 / span) +// filled[k] = filled[k-1] * ratio +// +// One power per gap instead of two logarithms plus one exponential per missing +// bucket. With the spec's example thresholds a window can be missing 60 buckets, +// which cost ~73ms the other way and a fraction of that here. Exponentials are the +// expensive operation (~0.5ms each) and reducing their precision only helps by +// about a factor of two, so cutting their number is the only lever that matters. +// +// Determinism: the ratio is computed at a fixed precision and every step is +// rounded, so the sequence is reproducible — the same requirement EMA has, for the +// same reason. +func twapDenseInterpolate(buckets []twapBucket, filled []decimal.Decimal, runStart, rightIdx int) error { + leftIdx := runStart - 1 + left, right := buckets[leftIdx].price, buckets[rightIdx].price + + growth, err := divRound(right, left, doublePrecision) + if err != nil { + return fmt.Errorf("TWAP: bucket %d: %w", leftIdx, err) + } + exponent, err := divRoundByInt(decimal.NewFromInt(1), rightIdx-leftIdx, doublePrecision) + if err != nil { + return err + } + ratio, err := decimalPow(growth, exponent, doublePrecision) + if err != nil { + return fmt.Errorf("TWAP: interpolating buckets %d..%d: %w", runStart, rightIdx-1, err) + } + + price := left + for k := runStart; k < rightIdx; k++ { + price = price.Mul(ratio).Round(doublePrecision) + filled[k] = price + } + return nil +} + +// TestTWAP_MatchesDenseReference checks the sparse implementation against the +// dense one on random windows: the same value, digit for digit, or the same +// error. Records land several to a second, outside the window and across gaps +// of every kind, and thresholds vary so both acceptance and rejection are hit. +func TestTWAP_MatchesDenseReference(t *testing.T) { + t.Parallel() + + rng := rand.New(rand.NewPCG(1, 2)) + var accepted, rejected int + for i := range 2000 { + windowSeconds := 1 + rng.IntN(120) + anchorSeconds := int64(1_000 + windowSeconds) + + var values []decimal.Decimal + var timestamps []uint64 + ts := uint64(anchorSeconds-int64(windowSeconds)-2) * uint64(time.Second) + for ts < uint64(anchorSeconds+2)*uint64(time.Second) { + // Mostly sub-second steps within a second, sometimes a gap of + // several seconds. + if rng.IntN(4) == 0 { + ts += uint64(1+rng.IntN(15)) * uint64(time.Second) + } else { + ts += uint64(1 + rng.IntN(int(time.Second))) + } + values = append(values, decimal.New(1+rng.Int64N(1_000_000), -int32(rng.IntN(9)))) + timestamps = append(timestamps, ts) + } + series, err := NewSeries(values, timestamps) + require.NoError(t, err) + + cfg := twapConfig{ + windowSeconds: int64(windowSeconds), + minSamples: 1 + rng.IntN(windowSeconds), + maxHeadGap: rng.IntN(windowSeconds + 1), + maxInteriorGap: rng.IntN(windowSeconds + 1), + maxTailGap: rng.IntN(windowSeconds + 1), + } + + want, wantErr := twapDenseReference(series, cfg, anchorSeconds) + got, gotErr := twap(series, cfg, anchorSeconds) + if wantErr != nil { + require.Error(t, gotErr, "case %d", i) + require.Equal(t, wantErr.Error(), gotErr.Error(), "case %d", i) + rejected++ + continue + } + require.NoError(t, gotErr, "case %d", i) + require.Equal(t, want.String(), got.String(), "case %d", i) + accepted++ + } + // Both paths must actually be exercised for the comparison to mean anything. + require.Greater(t, accepted, 100) + require.Greater(t, rejected, 100) +} diff --git a/llo/protocol/calculated/functions_twap_test.go b/llo/protocol/calculated/functions_twap_test.go index 7b68817..f700211 100644 --- a/llo/protocol/calculated/functions_twap_test.go +++ b/llo/protocol/calculated/functions_twap_test.go @@ -219,11 +219,13 @@ func TestTWAP_GapStats(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - buckets := make([]twapBucket, len(tc.observed)) + var observations []twapObservation for i, observed := range tc.observed { - buckets[i] = twapBucket{observed: observed, price: decimal.NewFromInt(1)} + if observed { + observations = append(observations, twapObservation{offset: i, price: decimal.NewFromInt(1)}) + } } - m, head, interior, tail := twapGapStats(buckets) + m, head, interior, tail := twapGapStats(observations, len(tc.observed)) assert.Equal(t, tc.wantM, m, "M") assert.Equal(t, tc.wantHead, head, "Ghead") assert.Equal(t, tc.wantInt, interior, "Gint") diff --git a/llo/protocol/calculated/history_ast.go b/llo/protocol/calculated/history_ast.go index b2ce145..b5462e7 100644 --- a/llo/protocol/calculated/history_ast.go +++ b/llo/protocol/calculated/history_ast.go @@ -483,23 +483,23 @@ func (p *historyPatcher) sortedRefs() []HistoryRef { } // analyzeHistoryExpression parses an expression and recovers its History -// references without compiling it. It is a pure function of the expression -// string, which is what lets every node derive the same required depths from -// replicated channel definitions. +// references and its TWAP call count without compiling it. It is a pure +// function of the expression string, which is what lets every node derive the +// same required depths and TWAP budget from replicated channel definitions. // // An expression with no History calls returns no references and no error. -func analyzeHistoryExpression(expression string) ([]HistoryRef, error) { +func analyzeHistoryExpression(expression string) (refs []HistoryRef, twapCalls int, err error) { tree, err := parseExpression(expression) if err != nil { - return nil, fmt.Errorf("%w: failed to parse expression: %s", ErrHistoryExpression, err) + return nil, 0, fmt.Errorf("%w: failed to parse expression: %s", ErrHistoryExpression, err) } p := newHistoryPatcher() ast.Walk(&tree.Node, p) if err := p.err(); err != nil { - return nil, err + return nil, 0, err } - return p.sortedRefs(), nil + return p.sortedRefs(), p.twapCalls, nil } func describeNode(node ast.Node) string { diff --git a/llo/protocol/calculated/history_ast_test.go b/llo/protocol/calculated/history_ast_test.go index dfb987f..0995f26 100644 --- a/llo/protocol/calculated/history_ast_test.go +++ b/llo/protocol/calculated/history_ast_test.go @@ -110,7 +110,7 @@ func TestAnalyzeHistoryExpression_Accepts(t *testing.T) { } { t.Run(name, func(t *testing.T) { t.Parallel() - refs, err := analyzeHistoryExpression(tc.expression) + refs, _, err := analyzeHistoryExpression(tc.expression) require.NoError(t, err) assert.Equal(t, tc.want, refs) }) @@ -223,7 +223,7 @@ func TestAnalyzeHistoryExpression_Rejects(t *testing.T) { } { t.Run(name, func(t *testing.T) { t.Parallel() - refs, err := analyzeHistoryExpression(tc.expression) + refs, _, err := analyzeHistoryExpression(tc.expression) require.Error(t, err) require.ErrorIs(t, err, ErrHistoryExpression) assert.Contains(t, err.Error(), tc.wantErr) @@ -244,12 +244,12 @@ func TestAnalyzeHistoryExpression_FanOut(t *testing.T) { for i := range calls { within = append(within, fmt.Sprintf("Avg(History(s%d, %d))", i+1, perCall)) } - refs, err := analyzeHistoryExpression(strings.Join(within, " + ")) + refs, _, err := analyzeHistoryExpression(strings.Join(within, " + ")) require.NoError(t, err) assert.Len(t, refs, calls) over := append(within, fmt.Sprintf("Avg(History(s%d, 1))", calls+1)) - _, err = analyzeHistoryExpression(strings.Join(over, " + ")) + _, _, err = analyzeHistoryExpression(strings.Join(over, " + ")) require.ErrorIs(t, err, ErrHistoryExpression) assert.Contains(t, err.Error(), "total history depth") } @@ -260,7 +260,7 @@ func TestAnalyzeHistoryExpression_FanOut(t *testing.T) { func TestAnalyzeHistoryExpression_ReportsEveryProblem(t *testing.T) { t.Parallel() - _, err := analyzeHistoryExpression("Add(History(s1, 0), History(s2, x))") + _, _, err := analyzeHistoryExpression("Add(History(s1, 0), History(s2, x))") require.ErrorIs(t, err, ErrHistoryExpression) assert.Contains(t, err.Error(), "depth must be at least 1") assert.Contains(t, err.Error(), "depth must be an integer literal") @@ -273,20 +273,20 @@ func TestAnalyzeHistoryExpression_Deterministic(t *testing.T) { t.Parallel() expression := "Add(Avg(History(s7_bid, 10)), Add(Avg(History(s1, 300)), Avg(History(s7, 10))))" - first, err := analyzeHistoryExpression(expression) + first, _, err := analyzeHistoryExpression(expression) require.NoError(t, err) for range 20 { - again, err := analyzeHistoryExpression(expression) + again, _, err := analyzeHistoryExpression(expression) require.NoError(t, err) require.Equal(t, first, again) } errFirst := func() string { - _, err := analyzeHistoryExpression("Add(History(s1, 0), History(s2, x))") + _, _, err := analyzeHistoryExpression("Add(History(s1, 0), History(s2, x))") return err.Error() }() for range 20 { - _, err := analyzeHistoryExpression("Add(History(s1, 0), History(s2, x))") + _, _, err := analyzeHistoryExpression("Add(History(s1, 0), History(s2, x))") require.Equal(t, errFirst, err.Error(), "error text must be stable") } } @@ -451,7 +451,7 @@ func FuzzAnalyzeHistoryExpression(f *testing.F) { } f.Fuzz(func(t *testing.T, expression string) { - refs, err := analyzeHistoryExpression(expression) + refs, _, err := analyzeHistoryExpression(expression) if err != nil { require.Nil(t, refs, "a rejected expression must not yield references") return diff --git a/llo/protocol/calculated/program.go b/llo/protocol/calculated/program.go index b23f889..433d5f8 100644 --- a/llo/protocol/calculated/program.go +++ b/llo/protocol/calculated/program.go @@ -55,6 +55,7 @@ type analysisCache struct { type analysisEntry struct { expression string refs []HistoryRef + twapCalls int err error // program is the compiled expression, filled in on first successful @@ -95,6 +96,34 @@ func AnalyzeExpressionHistory(expression string) ([]HistoryRef, error) { return historyAnalysisCache.analyze(expression) } +// ChannelTWAPCalls counts the TWAP calls across every expression a channel's +// opts declare. It is what admission charges the channel against +// protocol.MaxTotalTWAPCalls, and like ValidateExpression it is a pure function +// of the definition. +// +// Only expressions that analyze cleanly are counted: one that does not is never +// evaluated, so it does no TWAP work. Rejecting such expressions is +// ValidateChannelExpressions' job. Every declared expression is counted, not +// just those within MaxExpressionsPerChannel, so that a committed definition +// predating that limit is still charged for what it evaluates. +func ChannelTWAPCalls(optsCache *protocol.OptsCache, cd llotypes.ChannelDefinition, cid llotypes.ChannelID) int { + o, err := getCalculatedStreamOpts(optsCache, cd, cid) + if err != nil { + return 0 + } + calls := 0 + for _, abi := range o.ABI { + if abi.Expression == "" { + continue + } + entry := historyAnalysisCache.entry(abi.Expression) + if entry.err == nil { + calls += entry.twapCalls + } + } + return calls +} + // ValidateExpression reports whether an expression is statically well formed. // // It is the check to run before a channel definition reaches consensus: it @@ -159,13 +188,19 @@ func ValidateChannelExpressions(optsCache *protocol.OptsCache, cd llotypes.Chann } func (c *analysisCache) analyze(expression string) ([]HistoryRef, error) { + entry := c.entry(expression) + return entry.refs, entry.err +} + +func (c *analysisCache) entry(expression string) *analysisEntry { if entry, ok := c.get(expression); ok { - return entry.refs, entry.err + return entry } - refs, err := analyzeHistoryExpression(expression) - c.put(&analysisEntry{expression: expression, refs: refs, err: err}) - return refs, err + refs, twapCalls, err := analyzeHistoryExpression(expression) + entry := &analysisEntry{expression: expression, refs: refs, twapCalls: twapCalls, err: err} + c.put(entry) + return entry } func (c *analysisCache) get(expression string) (*analysisEntry, bool) { diff --git a/llo/protocol/calculated/validation_test.go b/llo/protocol/calculated/validation_test.go index c6eb72f..f5d4549 100644 --- a/llo/protocol/calculated/validation_test.go +++ b/llo/protocol/calculated/validation_test.go @@ -339,3 +339,40 @@ func TestValidateChannelExpressions_CountLimit(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "exceeding the maximum") } + +func TestChannelTWAPCalls(t *testing.T) { + t.Parallel() + + const twapCall = `TWAP(History(s1, 3), {window: Duration("3s"), minSamples: 1, maxHeadGap: 3, maxInteriorGap: 3, maxTailGap: 3})` + channel := func(expressions ...string) llotypes.ChannelDefinition { + abi := "" + for i, expression := range expressions { + if i > 0 { + abi += "," + } + abi += fmt.Sprintf(`{"type":"int256","expression":%q,"expressionStreamID":%d}`, expression, 900+i) + } + return llotypes.ChannelDefinition{ + ReportFormat: llotypes.ReportFormatEVMABIEncodeUnpackedExpr, + Streams: []llotypes.Stream{{StreamID: 1, Aggregator: llotypes.AggregatorMedian}}, + Opts: []byte(fmt.Sprintf(`{"abi":[%s]}`, abi)), + } + } + + assert.Equal(t, 0, ChannelTWAPCalls(nil, channel("Add(s1, 1)"), 1)) + assert.Equal(t, 3, ChannelTWAPCalls(nil, channel( + twapCall, + "Add("+twapCall+", "+twapCall+")", + "Add(s1, 1)", + ), 1), "calls are summed across expressions") + + // An expression that fails analysis is never evaluated, so it is not + // charged, while the valid expressions beside it still are. + tooMany := strings.TrimSuffix(strings.Repeat(twapCall+" + ", protocol.MaxTWAPCallsPerExpression+1), " + ") + assert.Equal(t, 1, ChannelTWAPCalls(nil, channel(tooMany, twapCall), 1)) + + assert.Equal(t, 0, ChannelTWAPCalls(nil, llotypes.ChannelDefinition{ + ReportFormat: llotypes.ReportFormatEVMABIEncodeUnpackedExpr, + Opts: []byte(`not json`), + }, 1), "opts that do not decode declare no expressions") +} diff --git a/llo/protocol/channel_analysis_cache.go b/llo/protocol/channel_analysis_cache.go index 3e51a0a..b17a1a4 100644 --- a/llo/protocol/channel_analysis_cache.go +++ b/llo/protocol/channel_analysis_cache.go @@ -24,6 +24,7 @@ type channelFacts struct { feedIDErr error calculatedIDs []llotypes.StreamID calculatedErr error + twapCalls int } // ChannelAnalysisCache memoizes channelFacts so that an unchanged channel @@ -168,5 +169,8 @@ func deriveChannelFacts(codecs map[llotypes.ReportFormat]ReportCodec, channelID if feedIDer, ok := codec.(FeedIDer); ok { facts.feedID, facts.hasFeedID, facts.feedIDErr = feedIDer.FeedID(cd) } + if counter, ok := codec.(TWAPCounter); ok { + facts.twapCalls = counter.TWAPCalls(cd) + } return facts } diff --git a/llo/protocol/channel_definitions.go b/llo/protocol/channel_definitions.go index 53a72c4..22da305 100644 --- a/llo/protocol/channel_definitions.go +++ b/llo/protocol/channel_definitions.go @@ -246,6 +246,9 @@ func analyzeChannelDefinitions(codecs map[llotypes.ReportFormat]ReportCodec, cha // pairs above because they are recomputed each round rather than carried // forward, so they cost precursor bytes but no r/agg bytes. var totalCalculatedStreams int + // TWAP calls across every expression in the set, each charged its worst + // case; see MaxTotalTWAPCalls. + var totalTWAPCalls int uniqueStreamIDs := make(map[llotypes.StreamID]struct{}, len(channelDefs)) // Owners of every stream ID that will hold an aggregate: observed streams @@ -322,6 +325,7 @@ func analyzeChannelDefinitions(codecs map[llotypes.ReportFormat]ReportCodec, cha } } facts := channelFactsFor(cache, codecs, channelID, cd) + totalTWAPCalls += facts.twapCalls if HasCalculatedStreams(cd) { if facts.calculatedErr != nil { @@ -385,8 +389,8 @@ func analyzeChannelDefinitions(codecs map[llotypes.ReportFormat]ReportCodec, cha } // Whole-set budgets. Each is what the sizes of the channel-definitions - // record, the precursor and the carry-forward record actually depend on; - // see the limits they name. + // record, the precursor and the carry-forward record, or the per-round + // evaluation work, actually depend on; see the limits they name. if totalStreamEntries > MaxTotalStreamEntries { admitSet(fmt.Errorf("too many stream entries across all channels, got: %d/%d", totalStreamEntries, MaxTotalStreamEntries)) } @@ -399,6 +403,9 @@ func analyzeChannelDefinitions(codecs map[llotypes.ReportFormat]ReportCodec, cha if totalOptsBytes > MaxTotalOptsBytes { admitSet(fmt.Errorf("too many opts bytes across all channels, got: %d/%d", totalOptsBytes, MaxTotalOptsBytes)) } + if totalTWAPCalls > MaxTotalTWAPCalls { + admitSet(fmt.Errorf("too many TWAP calls across all channels, got: %d/%d", totalTWAPCalls, MaxTotalTWAPCalls)) + } res.uniqueStreamIDs = len(uniqueStreamIDs) return res diff --git a/llo/protocol/channel_definitions_test.go b/llo/protocol/channel_definitions_test.go index 2216737..abf0e09 100644 --- a/llo/protocol/channel_definitions_test.go +++ b/llo/protocol/channel_definitions_test.go @@ -200,6 +200,11 @@ func (stubReportCodec) Encode(Report, llotypes.ChannelDefinition, *OptsCache) ([ } func (stubReportCodec) Verify(llotypes.ChannelDefinition) error { return nil } +// twapCodec charges every definition MaxTWAPCallsPerExpression TWAP calls. +type twapCodec struct{ stubReportCodec } + +func (twapCodec) TWAPCalls(llotypes.ChannelDefinition) int { return MaxTWAPCallsPerExpression } + // verifyAdmittingAll verifies channelDefs with every channel treated as being // admitted, which is what the admission-only checks are exercised against. func verifyAdmittingAll(codecs map[llotypes.ReportFormat]ReportCodec, channelDefs llotypes.ChannelDefinitions) error { @@ -523,6 +528,32 @@ func Test_VerifyChannelDefinitions_SizeBudgets(t *testing.T) { fmt.Sprintf("too many opts bytes across all channels, got: %d/%d", MaxTotalOptsBytes+MaxChannelOptsBytes, MaxTotalOptsBytes)) }) + t.Run("TWAP calls across the set", func(t *testing.T) { + twapCodecs := map[llotypes.ReportFormat]ReportCodec{0: twapCodec{}} + set := func(channels int) llotypes.ChannelDefinitions { + defs := llotypes.ChannelDefinitions{} + for c := range channels { + defs[llotypes.ChannelID(c+1)] = channel(1, 0) + } + return defs + } + perChannel := MaxTWAPCallsPerExpression + atLimit := set(MaxTotalTWAPCalls / perChannel) + require.NoError(t, verifyAdmittingAll(twapCodecs, atLimit)) + + over := set(MaxTotalTWAPCalls/perChannel + 1) + require.EqualError(t, verifyAdmittingAll(twapCodecs, over), + fmt.Sprintf("too many TWAP calls across all channels, got: %d/%d", MaxTotalTWAPCalls+perChannel, MaxTotalTWAPCalls)) + // Admission-only, like the other budgets. + require.NoError(t, VerifyChannelDefinitions(twapCodecs, over)) + + // A tombstone is never evaluated, so it does no TWAP work. + tombstoned := over[1] + tombstoned.Tombstone = true + over[1] = tombstoned + require.NoError(t, verifyAdmittingAll(twapCodecs, over)) + }) + t.Run("budgets are admission-only", func(t *testing.T) { // A committed set over budget must not fail verification: every oracle // runs this every round, so rejecting it would halt the protocol over diff --git a/llo/protocol/limits.go b/llo/protocol/limits.go index b0ede89..596e51a 100644 --- a/llo/protocol/limits.go +++ b/llo/protocol/limits.go @@ -187,8 +187,8 @@ const ( // History depth is not a bound on TWAP work. A TWAP call needs only a // depth-1 history window, so MaxHistoryRecordsPerExpression permits // thousands of calls in one expression, and each is free to request the - // longest window allowed — one-second buckets allocated and filled every - // round, per channel, on the consensus path. + // longest window allowed — up to that many one-second buckets interpolated + // every round, per channel, on the consensus path. // // Capping the count rather than pricing each call by its window is // deliberate: the count is syntactic, so it holds for a configuration built @@ -202,6 +202,20 @@ const ( // oracle must reject the same expression, so it is never per-node // configurable. MaxTWAPCallsPerExpression = 4 + // MaxTotalTWAPCalls bounds the TWAP calls across the whole definition set. + // + // MaxTWAPCallsPerExpression bounds one expression, but expressions are + // bounded only by MaxTotalCalculatedStreams and MaxTotalOptsBytes, which + // between them admit thousands of maximum-length TWAP calls, all evaluated + // every round on every oracle. Like the per-expression cap, this charges + // every call its worst case. + // + // The worst case is a maximum-length window over a maximum-depth history + // spread evenly across it, so every missing second is interior and is + // stepped through costing cpu time across the evaluation workers, + // while a window with a short gap, the realistic shape, costs tens of + // microseconds per call. Admission-only, like the other whole-set budgets. + MaxTotalTWAPCalls = 64 // MaxHistoryRecordBytes is the maximum serialized size of one history // record, enforced on append (StreamHistory.Append) and used as the // per-record size when admitting pairs against MaxHistoryTotalBytes. diff --git a/llo/protocol/types.go b/llo/protocol/types.go index 3c37fb2..4089b22 100644 --- a/llo/protocol/types.go +++ b/llo/protocol/types.go @@ -56,6 +56,17 @@ type FeedIDer interface { FeedID(llotypes.ChannelDefinition) (feedID [32]byte, ok bool, err error) } +// TWAPCounter is optionally implemented by a ReportCodec whose channels +// evaluate expressions. It exists so that admission can bound the TWAP calls of +// the whole definition set (see MaxTotalTWAPCalls) without parsing expressions +// itself. +// +// TWAPCalls must be a pure function of the definition, like Verify, and is only +// consulted for definitions Verify accepted. +type TWAPCounter interface { + TWAPCalls(llotypes.ChannelDefinition) int +} + type ChannelDefinitionWithID struct { llotypes.ChannelDefinition ChannelID llotypes.ChannelID diff --git a/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr.go b/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr.go index 77c91c8..7b023ee 100644 --- a/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr.go +++ b/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr.go @@ -18,6 +18,7 @@ var ( _ protocol.ReportCodec = ReportCodecEVMABIEncodeUnpackedExpr{} _ protocol.FeedIDer = ReportCodecEVMABIEncodeUnpackedExpr{} _ protocol.AdmissionVerifier = ReportCodecEVMABIEncodeUnpackedExpr{} + _ protocol.TWAPCounter = ReportCodecEVMABIEncodeUnpackedExpr{} ) type ReportCodecEVMABIEncodeUnpackedExpr struct { @@ -130,6 +131,14 @@ func (r ReportCodecEVMABIEncodeUnpackedExpr) VerifyForAdmission(cd llotypes.Chan return nil } +// TWAPCalls implements protocol.TWAPCounter: it counts the TWAP calls across +// the channel's expressions, so admission can bound them over the whole set. +// +// nil opts cache, as in VerifyForAdmission. +func (r ReportCodecEVMABIEncodeUnpackedExpr) TWAPCalls(cd llotypes.ChannelDefinition) int { + return calculated.ChannelTWAPCalls(nil, cd, 0) +} + func (r ReportCodecEVMABIEncodeUnpackedExpr) buildHeader(rf BaseReportFields, resolution protocol.TimeResolution) ([]byte, error) { var merr error if rf.LinkFee == nil { diff --git a/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr_test.go b/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr_test.go index 321abec..ae62609 100644 --- a/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr_test.go +++ b/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr_test.go @@ -473,6 +473,17 @@ func TestReportCodecEVMABIEncodeUnpackedExpr_Verify(t *testing.T) { require.NoError(t, c.Verify(cd)) require.NoError(t, c.VerifyForAdmission(cd)) }) + t.Run("TWAP calls are counted across expressions", func(t *testing.T) { + cd := llotypes.ChannelDefinition{ + Streams: []llotypes.Stream{{StreamID: 1, Aggregator: llotypes.AggregatorMedian}}, + ReportFormat: llotypes.ReportFormatEVMABIEncodeUnpackedExpr, + Opts: []byte(`{"baseUSDFee":"1","feedID":"0x1111111111111111111111111111111111111111111111111111111111111111","ABI":[` + + `{"type":"int192","expression":"TWAP(History(s1, 3), {window: Duration(\"3s\"), minSamples: 1, maxHeadGap: 3, maxInteriorGap: 3, maxTailGap: 3})","expressionStreamID":998},` + + `{"type":"int192","expression":"Add(s1, 1)","expressionStreamID":999}]}`), + } + require.NoError(t, c.VerifyForAdmission(cd)) + assert.Equal(t, 1, c.TWAPCalls(cd)) + }) t.Run("statically invalid expression is rejected", func(t *testing.T) { // A window in a scalar position can never produce a value, so accepting // this definition would install a channel that never reports. From 02b9fcf36af071788850c5fb0c628753dc30c818 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Fri, 9 Oct 2026 20:33:47 +0100 Subject: [PATCH 006/103] llo/calculated: bound operand size and exact integer power size --- llo/protocol/calculated/calculated.go | 15 +++++- llo/protocol/calculated/decimalmath.go | 57 +++++++++++++++++++-- llo/protocol/calculated/decimalmath_test.go | 44 ++++++++++++++++ 3 files changed, 112 insertions(+), 4 deletions(-) diff --git a/llo/protocol/calculated/calculated.go b/llo/protocol/calculated/calculated.go index 4a538c2..67523f3 100644 --- a/llo/protocol/calculated/calculated.go +++ b/llo/protocol/calculated/calculated.go @@ -526,8 +526,21 @@ func ParseDuration(x string) (time.Duration, error) { return time.ParseDuration(x) } -// toDecimal converts x to a decimal.Decimal +// toDecimal converts x to a decimal.Decimal, refusing an operand too large to +// compute with. See checkOperand. func toDecimal(x any) (decimal.Decimal, error) { + d, err := convertToDecimal(x) + if err != nil { + return decimal.Decimal{}, err + } + if err := checkOperand(d); err != nil { + return decimal.Decimal{}, err + } + return d, nil +} + +// convertToDecimal converts x to a decimal.Decimal. +func convertToDecimal(x any) (decimal.Decimal, error) { switch v := x.(type) { case Series: // Static analysis rejects windows in scalar positions, so this is a diff --git a/llo/protocol/calculated/decimalmath.go b/llo/protocol/calculated/decimalmath.go index 1b0c959..56fd8de 100644 --- a/llo/protocol/calculated/decimalmath.go +++ b/llo/protocol/calculated/decimalmath.go @@ -5,6 +5,8 @@ import ( "sync" "github.com/shopspring/decimal" + + "github.com/smartcontractkit/chainlink-data-streams/llo/protocol" ) // transcendentalMu serializes every call into shopspring/decimal's @@ -109,9 +111,14 @@ const maxPowExponent = 100_000 // So the quantity exp() guards is checked here, ahead of the work: the magnitude // of exponent * ln(base), against maxExpArgument, which is the largest argument // whose result still fits MaxDecimalExponent. The logarithm needed to estimate it -// is taken at low precision, and its input is a stored value, so it is cheap next -// to the power it is protecting — and exact enough that its rounding error cannot -// move a value across a bound of 2400. +// is taken at low precision on an operand bounded by checkOperand, so it is cheap +// next to the power it is protecting — and exact enough that its rounding error +// cannot move a value across a bound of 2400. +// +// Magnitude does not bound digits. The integer part of the exponent is applied +// by exact repeated multiplication before anything is rounded, so a base close +// to 1 with a long coefficient has a tiny logarithm and a huge result. +// That size is checked separately, against maxPowResultBits. // // Both operands come from consensus, so an unbounded power is not one node's // problem: every node computes it in the same round, inside StateTransition, @@ -125,6 +132,12 @@ func checkPow(base, exponent decimal.Decimal) error { if abs.IsZero() { return nil } + // An upper bound on the coefficient of base^|trunc(exponent)|. + resultBits := decimal.NewFromInt(int64(abs.Coefficient().BitLen())).Mul(exponent.Truncate(0).Abs()) + if resultBits.GreaterThan(decimal.NewFromInt(maxPowResultBits)) { + return fmt.Errorf("power of %s by %s needs a coefficient of up to %s bits, which exceeds the maximum of %d", + base, exponent, resultBits, maxPowResultBits) + } lnBase, err := decimalLn(abs, powEstimatePrecision) if err != nil { return fmt.Errorf("power of %s by %s could not be bounded: %w", base, exponent, err) @@ -136,11 +149,49 @@ func checkPow(base, exponent decimal.Decimal) error { return nil } +// maxPowResultBits bounds the coefficient of the exact integer power computed +// before a power is rounded. It admits Pow(1.0001, 100000), about 1.4 million +// bits, which takes milliseconds. A 58 digit base near 1 raised to the same +// exponent needs 19 million bits and takes most of a second. +const maxPowResultBits = 1 << 21 + // powEstimatePrecision is the precision of the logarithm used to size a power's // result. It only has to place the result on the right side of maxExpArgument, so // it is deliberately far below the precision of the calculation itself. const powEstimatePrecision = 8 +// Operand bounds. Only the final result of an expression is held to the +// stored value bounds (MaxDecimalCoefficientBits, MaxDecimalExponent), so +// without these an intermediate grows without limit: each Mul of a value with +// itself doubles its coefficient, and 17 of them bound with let produce a +// 7.6 million digit operand whose logarithm took over four minutes. Every node +// evaluates the same expression in the same round, so that stalls the DON. +// +// Checking every operand bounds the cost of each operation by the size of its +// inputs, and each operation at most roughly doubles that size, so growth stops +// at the next operation. +// +// Both leave room for the product of four stored values at their bounds. +const ( + maxOperandCoefficientBits = 4 * protocol.MaxDecimalCoefficientBits + // The exponent is bounded separately because adding operands rescales the + // one with the larger exponent: a value whose exponent has grown to -1e9 + // has a one bit coefficient but turns Add(x, 1) into a billion digit one. + maxOperandExponent = 4 * protocol.MaxDecimalExponent +) + +// checkOperand refuses an operand too large to compute with. See the operand +// bounds above. +func checkOperand(d decimal.Decimal) error { + if bits := d.Coefficient().BitLen(); bits > maxOperandCoefficientBits { + return fmt.Errorf("operand has a coefficient of %d bits, which exceeds the maximum of %d", bits, maxOperandCoefficientBits) + } + if exp := d.Exponent(); exp > maxOperandExponent || exp < -maxOperandExponent { + return fmt.Errorf("operand has an exponent of %d, which exceeds the maximum magnitude of %d", exp, maxOperandExponent) + } + return nil +} + // decimalToInt converts an integral decimal to an int, refusing anything outside // [minimum, maximum]. // diff --git a/llo/protocol/calculated/decimalmath_test.go b/llo/protocol/calculated/decimalmath_test.go index bf6f6bb..d854f12 100644 --- a/llo/protocol/calculated/decimalmath_test.go +++ b/llo/protocol/calculated/decimalmath_test.go @@ -1,6 +1,8 @@ package calculated import ( + "fmt" + "strings" "sync" "testing" "time" @@ -159,6 +161,9 @@ func Test_checkPow(t *testing.T) { {"absurd exponent is refused by the cheap gate", "2", "100001", "exceeds the maximum magnitude of 100000"}, {"largest storable result", "10", "1000", ""}, {"base near one with a large exponent", "1.0001", "100000", ""}, + // The logarithm is tiny, so only the size bound refuses these. + {"long base near one", "1." + strings.Repeat("0", 56) + "1", "100000", "exceeds the maximum of 2097152"}, + {"long base near one, negative exponent", "1." + strings.Repeat("0", 56) + "1", "-100000", "exceeds the maximum of 2097152"}, {"square root", "4", "0.5", ""}, {"zero base has no logarithm", "0", "3", ""}, } { @@ -185,3 +190,42 @@ func Test_checkPow(t *testing.T) { }) } } + +func Test_checkOperand(t *testing.T) { + t.Parallel() + + // Squaring a stored value repeatedly doubles its coefficient each time. 17 + // squarings took over four minutes in the logarithm before operands were + // bounded. + squarings := `let a0 = "1` + strings.Repeat("7", 57) + `"; ` + for i := 1; i <= 17; i++ { + squarings += fmt.Sprintf("let a%d = Mul(a%d, a%d); ", i, i-1, i-1) + } + + for _, tc := range []struct { + name string + stmt string + wantErr string + }{ + {"coefficient grown by repeated squaring", squarings + "Ln(a17)", "exceeds the maximum of 768"}, + {"long literal coefficient", `Ln("1` + strings.Repeat("7", 300) + `")`, "exceeds the maximum of 768"}, + {"exponent grown by repeated squaring", `let a = Mul("1e-1000", "1e-1000"); let b = Mul(a, a); let c = Mul(b, b); Add(c, 1)`, "exceeds the maximum magnitude of 4000"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + start := time.Now() + _, err := evalDecimal(tc.stmt, NewEnv(1)) + require.Less(t, time.Since(start), time.Second) + require.ErrorContains(t, err, tc.wantErr) + }) + } + + t.Run("product of four stored values", func(t *testing.T) { + t.Parallel() + + v := `"1` + strings.Repeat("7", 57) + `e-1000"` + _, err := evalDecimal(fmt.Sprintf("Round(Mul(Mul(%[1]s, %[1]s), Mul(%[1]s, %[1]s)), 0)", v), NewEnv(1)) + require.NoError(t, err) + }) +} From 293b39772032c66f036fa95d5de1c228de94d91a Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Sat, 10 Oct 2026 12:03:52 +0100 Subject: [PATCH 007/103] llo/calculated: compute exponentials with argument reduction Replace ExpTaylor with expDecimal, which halves the argument below 1/256, sums the series there and squares back, in scaled integer arithmetic. Use it for exp and for the fractional part of a power, keeping the library's structure and precisions. --- llo/protocol/calculated/decimalmath.go | 159 ++++++++++++++++---- llo/protocol/calculated/decimalmath_test.go | 84 +++++++++++ 2 files changed, 211 insertions(+), 32 deletions(-) diff --git a/llo/protocol/calculated/decimalmath.go b/llo/protocol/calculated/decimalmath.go index 56fd8de..5134b80 100644 --- a/llo/protocol/calculated/decimalmath.go +++ b/llo/protocol/calculated/decimalmath.go @@ -2,6 +2,7 @@ package calculated import ( "fmt" + "math/big" "sync" "github.com/shopspring/decimal" @@ -18,7 +19,8 @@ import ( // racing with a read can yield a corrupted value rather than merely a stale one — // which in a consensus path means two nodes disagreeing, or a panic. // -// TWAP makes it far more likely by calling ln and exp once per bucket. +// Only Ln is called now: exponentials go through expDecimal, which shares no +// state. TWAP still calls ln once per bucket. // // The lock is taken per call rather than per evaluation to keep hold times short. // The cost is negligible against the arithmetic it guards. @@ -32,14 +34,6 @@ func decimalLn(x decimal.Decimal, prec int32) (result decimal.Decimal, err error return x.Ln(prec) } -// decimalExpTaylor is decimal.ExpTaylor under the transcendental lock. -func decimalExpTaylor(x decimal.Decimal, prec int32) (result decimal.Decimal, err error) { - transcendentalMu.Lock() - defer transcendentalMu.Unlock() - defer recoverTranscendental("exponential", x, &result, &err) - return x.ExpTaylor(prec) -} - // recoverTranscendental converts a panic from shopspring/decimal into an error. // // The library panics on some extreme inputs rather than returning an error (see @@ -53,9 +47,16 @@ func recoverTranscendental(operation string, input decimal.Decimal, result *deci } } -// decimalPow is decimal.PowWithPrecision under the transcendental lock. Powers -// with a non-integer exponent are evaluated via the same logarithm and -// exponential machinery. +// decimalPow is decimal.PowWithPrecision with its exponential replaced by +// expDecimal. +// +// The library evaluates base^(i+f), for the integer part i and fractional part +// f of the exponent, as base^i * exp(f * ln(base)), with the logarithm and the +// exponential taken at a precision raised to cover the operands. This keeps that +// structure and those precisions, so a result changes only where ExpTaylor was +// itself inaccurate. ExpTaylor sums the series on the full argument, needing +// about e*|x| terms of growing size: Sqrt of a stored value near 1e-943 took 4.4 +// seconds under the transcendental lock. See expDecimal. // // The exponent is bounded first. shopspring/decimal PANICS on an exponent large // enough to overflow the result's int32 scale ("exponent ... overflows an @@ -63,30 +64,66 @@ func recoverTranscendental(operation string, input decimal.Decimal, result *deci // Pow(s1, s2) with both values around 2.6e31 burned 22 seconds of CPU and then // panicked. Stream values come from consensus, so every node would hit that in // the same round — a panic in StateTransition takes the node down, and 22 seconds -// blows the round budget even without one. The transcendental lock makes it worse -// by serializing that work against every other plugin instance in the process. +// blows the round budget even without one. // // A value beyond MaxDecimalExponent could not be stored or transmitted anyway, so // refusing it early costs nothing real. -func decimalPow(base, exponent decimal.Decimal, prec int32) (result decimal.Decimal, err error) { +func decimalPow(base, exponent decimal.Decimal, prec int32) (decimal.Decimal, error) { if err := checkPow(base, exponent); err != nil { return decimal.Decimal{}, err } - transcendentalMu.Lock() - defer transcendentalMu.Unlock() + intPart := exponent.Truncate(0) + fracPart := exponent.Sub(intPart) + // A zero base never reaches the logarithm, so the library's own handling of + // it is kept as is. + if fracPart.IsZero() || base.IsZero() { + return decimalIntPow(base, exponent, prec) + } + if base.IsNegative() { + return decimal.Decimal{}, fmt.Errorf("cannot represent imaginary value of x ** y, where x < 0 and y is non-integer decimal") + } + + intPow, err := decimalIntPow(base, intPart, prec) + if err != nil { + return decimal.Decimal{}, err + } - // Backstop: the bound above covers the case seen in the wild, but the - // library reserves the right to panic on other extremes and an expression - // must never be able to bring the node down. Converting it to an error makes - // the channel unreportable, which is the correct fail-closed outcome. + // The precision the library raises the fractional part to. + fracPrec := prec + if digits := int32(base.NumDigits()); digits > fracPrec { + fracPrec = digits + } + if digits := int32(exponent.NumDigits()); digits > fracPrec { + fracPrec += digits + } + fracPrec += 10 + + lnBase, err := decimalLn(base, fracPrec) + if err != nil { + return decimal.Decimal{}, err + } + fracPow, err := expDecimal(lnBase.Mul(fracPart), fracPrec) + if err != nil { + return decimal.Decimal{}, err + } + return intPow.Mul(fracPow), nil +} + +// decimalIntPow is decimal.PowWithPrecision for an integer exponent or a zero +// base, neither of which reaches a transcendental function. +// +// Backstop: checkPow covers the case seen in the wild, but the library reserves +// the right to panic on other extremes and an expression must never be able to +// bring the node down. Converting it to an error makes the channel +// unreportable, which is the correct fail-closed outcome. +func decimalIntPow(base, exponent decimal.Decimal, prec int32) (result decimal.Decimal, err error) { defer func() { if r := recover(); r != nil { result = decimal.Decimal{} err = fmt.Errorf("power of %s by %s could not be computed: %v", base, exponent, r) } }() - return base.PowWithPrecision(exponent, prec) } @@ -273,17 +310,8 @@ func ln(x decimal.Decimal) (decimal.Decimal, error) { } // exp is a deterministic exponential at double precision, the inverse of ln. -// -// The argument is bounded because ExpTaylor's cost grows with the size of the -// result, not of the input: exp(1e6) has ~434,000 digits and does not complete in -// any useful time. Callers currently only pass logarithms of stored values, which -// MaxDecimalExponent already bounds to about ±2302, so the limit is not reachable -// through TWAP today — it is here so that stays true if another caller appears. func exp(x decimal.Decimal) (decimal.Decimal, error) { - if x.Abs().GreaterThan(decimal.NewFromInt(maxExpArgument)) { - return decimal.Decimal{}, fmt.Errorf("exponential argument %s exceeds the maximum magnitude of %d", x, maxExpArgument) - } - return decimalExpTaylor(x, doublePrecision) + return expDecimal(x, doublePrecision) } // maxExpArgument bounds the argument to exp. @@ -293,6 +321,73 @@ func exp(x decimal.Decimal) (decimal.Decimal, error) { // storable: 1000 * ln(10) is about 2302, rounded up for headroom. const maxExpArgument = 2400 +// expDecimal returns e^x rounded to places decimal places, the contract of +// decimal.ExpTaylor, rounding a negative argument's result twice as it does. +// +// ExpTaylor sums the series on x itself, which takes about e*|x| terms whose +// numerators are kept exact, so its cost grows steeply with |x|: exp(1085) took +// seconds. Here the argument is halved k times until it is below 1/256, where +// the series converges in a few digits per term, and the sum is squared k +// times to undo the halving. +// +// All arithmetic is on integers scaled by 10^w and truncated, so the result is +// a function of the inputs alone. Squaring doubles the relative error, so w +// covers the digits of the result, the places asked for, k*log10(2) digits lost +// to squaring, and a guard for the truncation in each term and step. That keeps +// the error several orders below the last place, so rounding only differs from +// the exact value's on a near tie. +// +// The argument is bounded because the result is not: exp(1e6) has ~434,000 +// digits. Callers pass logarithms of bounded operands, which checkPow and +// MaxDecimalExponent keep within about ±2400. +func expDecimal(x decimal.Decimal, places int32) (decimal.Decimal, error) { + if places < 0 { + return decimal.Decimal{}, fmt.Errorf("exponential precision %d must not be negative", places) + } + if x.Abs().GreaterThan(decimal.NewFromInt(maxExpArgument)) { + return decimal.Decimal{}, fmt.Errorf("exponential argument %s exceeds the maximum magnitude of %d", x, maxExpArgument) + } + if x.IsZero() { + return decimal.New(1, 0).Round(places), nil + } + + a := x.Abs() + k := a.Ceil().BigInt().BitLen() + 8 + // 0.4343 is just above 1/ln(10). + resultDigits := a.Mul(decimal.RequireFromString("0.4343")).Ceil().IntPart() + 1 + w := int32(places) + int32(resultDigits) + int32(k)/3 + 1 + expGuardDigits + scale := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(w)), nil) + + // r = a / 2^k, as an integer scaled by 10^w. + r := a.DivRound(decimal.NewFromBigInt(new(big.Int).Lsh(big.NewInt(1), uint(k)), 0), w).Shift(w).BigInt() + + sum := new(big.Int).Set(scale) + term := new(big.Int).Set(scale) + divisor := new(big.Int) + for i := int64(1); ; i++ { + term.Mul(term, r) + term.Quo(term, divisor.Mul(scale, big.NewInt(i))) + if term.Sign() == 0 { + break + } + sum.Add(sum, term) + } + for range k { + sum.Mul(sum, sum) + sum.Quo(sum, scale) + } + + result := decimal.NewFromBigInt(sum, -w) + if x.IsNegative() { + return decimal.New(1, 0).DivRound(result, places+1).Round(places), nil + } + return result.Round(places), nil +} + +// expGuardDigits covers the truncation error of expDecimal: one unit in the last +// working place per series term and per squaring, under a thousand of each. +const expGuardDigits = 10 + // sqrt is a deterministic square root, rounded to the package precision. func sqrt(x decimal.Decimal) (decimal.Decimal, error) { if x.IsNegative() { diff --git a/llo/protocol/calculated/decimalmath_test.go b/llo/protocol/calculated/decimalmath_test.go index d854f12..0226122 100644 --- a/llo/protocol/calculated/decimalmath_test.go +++ b/llo/protocol/calculated/decimalmath_test.go @@ -229,3 +229,87 @@ func Test_checkOperand(t *testing.T) { require.NoError(t, err) }) } + +func Test_expDecimal(t *testing.T) { + t.Parallel() + + t.Run("correctly rounded", func(t *testing.T) { + t.Parallel() + + // ExpTaylor is reliable given 30 spare places, and is cheap for these + // arguments. At the places asked for it is not: it misses the last place + // for a few percent of arguments, and is the reference only because the + // spare places absorb that. + for _, x := range []string{"0.0750760398084", "0.0275472644968", "7.28050264449", "12.4774569258", "1", "-1", "-0.5", "-12.4774569258"} { + for _, places := range []int32{18, 36, 50} { + d := decimal.RequireFromString(x) + got, err := expDecimal(d, places) + require.NoError(t, err) + // ExpTaylor shares its factorial cache with every other caller. + transcendentalMu.Lock() + want, err := d.Abs().ExpTaylor(places + 30) + transcendentalMu.Unlock() + require.NoError(t, err) + if d.IsNegative() { + // ExpTaylor's contract, kept: the reciprocal is rounded to + // one spare place before the final rounding. + want = decimal.New(1, 0).DivRound(want, places+1) + } + assertDecimal(t, want.Round(places).String(), got) + } + } + }) + + t.Run("large arguments are cheap", func(t *testing.T) { + t.Parallel() + + // ExpTaylor needs about e*|x| terms for these, and took seconds for + // exp(1085). + for _, x := range []string{"2400", "-2400", "1085.5", "-1085.5"} { + d := decimal.RequireFromString(x) + start := time.Now() + got, err := expDecimal(d, 36) + require.Less(t, time.Since(start), time.Second) + require.NoError(t, err) + + more, err := expDecimal(d, 66) + require.NoError(t, err) + if d.IsPositive() { + assertDecimal(t, more.Round(36).String(), got) + } + } + }) + + t.Run("zero", func(t *testing.T) { + t.Parallel() + + got, err := expDecimal(decimal.Zero, 18) + require.NoError(t, err) + assertDecimal(t, "1", got) + }) + + t.Run("argument beyond the bound", func(t *testing.T) { + t.Parallel() + + _, err := expDecimal(decimal.NewFromInt(2401), 18) + require.ErrorContains(t, err, "exceeds the maximum magnitude of 2400") + }) +} + +func TestPowFractionalExponentIsCheap(t *testing.T) { + t.Parallel() + + // A stored value at the coefficient and exponent bounds. Its square root + // took 4.4 seconds when the exponential went through ExpTaylor. + stored := "1" + strings.Repeat("7", 57) + "e-1000" + start := time.Now() + got, err := Sqrt(stored) + require.Less(t, time.Since(start), time.Second) + require.NoError(t, err) + assertDecimal(t, "0", got) + + start = time.Now() + _, err = Pow("1"+strings.Repeat("7", 57)+"e100", "0.75") + require.Less(t, time.Since(start), time.Second) + require.NoError(t, err) +} From 3b22f282f23e147020d826aa5789ef23def058fc Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Sat, 10 Oct 2026 12:58:23 +0100 Subject: [PATCH 008/103] llo/calculated: compute logarithms with Halley's method Replace decimal.Ln with lnDecimal, which reduces the argument to within a factor of sqrt(10) of 1 and runs Halley's method on expDecimal from an exact starting point. Remove the transcendental lock, as no production path reaches shopspring/decimal's shared factorial cache. --- llo/protocol/calculated/calculated.go | 8 +- llo/protocol/calculated/decimalmath.go | 158 ++++++++++++------ llo/protocol/calculated/decimalmath_test.go | 87 ++++++++-- llo/protocol/calculated/doc.go | 4 +- .../calculated/functions_bench_test.go | 12 +- llo/protocol/calculated/functions_twap.go | 4 +- 6 files changed, 193 insertions(+), 80 deletions(-) diff --git a/llo/protocol/calculated/calculated.go b/llo/protocol/calculated/calculated.go index 67523f3..5704335 100644 --- a/llo/protocol/calculated/calculated.go +++ b/llo/protocol/calculated/calculated.go @@ -424,7 +424,7 @@ func Ln(x any) (decimal.Decimal, error) { if n.IsZero() { return decimal.Decimal{}, fmt.Errorf("cannot represent natural logarithm of 0") } - return decimalLn(n, precision) + return lnDecimal(n, precision) } // Log returns the logarithms of y with base x. This is equivalent to log_x(y). @@ -439,7 +439,7 @@ func Log(x, y any) (decimal.Decimal, error) { if err != nil { return decimal.Decimal{}, err } - lnLog, err := decimalLn(log, doublePrecision) // double precision, since we're going to divide them + lnLog, err := lnDecimal(log, doublePrecision) // double precision, since we're going to divide them if err != nil { return decimal.Decimal{}, err } @@ -448,13 +448,13 @@ func Log(x, y any) (decimal.Decimal, error) { if err != nil { return decimal.Decimal{}, err } - lnBase, err := decimalLn(base, doublePrecision) // double precision, since we're going to divide them + lnBase, err := lnDecimal(base, doublePrecision) // double precision, since we're going to divide them if err != nil { return decimal.Decimal{}, err } // ln(1) is 0, so base 1 has no logarithm: DivRound would panic with - // "decimal division by 0". Bases <= 0 are already rejected by decimalLn. + // "decimal division by 0". Bases <= 0 are already rejected by lnDecimal. if lnLog.IsZero() { return decimal.Decimal{}, fmt.Errorf("logarithm base 1 is undefined") } diff --git a/llo/protocol/calculated/decimalmath.go b/llo/protocol/calculated/decimalmath.go index 5134b80..45da599 100644 --- a/llo/protocol/calculated/decimalmath.go +++ b/llo/protocol/calculated/decimalmath.go @@ -3,60 +3,23 @@ package calculated import ( "fmt" "math/big" - "sync" + "strconv" "github.com/shopspring/decimal" "github.com/smartcontractkit/chainlink-data-streams/llo/protocol" ) -// transcendentalMu serializes every call into shopspring/decimal's -// transcendental functions. -// -// Decimal.ExpTaylor memoizes factorials in an unsynchronized package-level slice -// (decimal.go: `var factorials = []Decimal{New(1, 0)}`) that it appends to, and -// Ln reads through the same path. Concurrent calls therefore race, and an append -// racing with a read can yield a corrupted value rather than merely a stale one — -// which in a consensus path means two nodes disagreeing, or a panic. -// -// Only Ln is called now: exponentials go through expDecimal, which shares no -// state. TWAP still calls ln once per bucket. -// -// The lock is taken per call rather than per evaluation to keep hold times short. -// The cost is negligible against the arithmetic it guards. -var transcendentalMu sync.Mutex - -// decimalLn is decimal.Ln under the transcendental lock. -func decimalLn(x decimal.Decimal, prec int32) (result decimal.Decimal, err error) { - transcendentalMu.Lock() - defer transcendentalMu.Unlock() - defer recoverTranscendental("logarithm", x, &result, &err) - return x.Ln(prec) -} - -// recoverTranscendental converts a panic from shopspring/decimal into an error. -// -// The library panics on some extreme inputs rather than returning an error (see -// decimalPow for a case found by fuzzing). An expression must never be able to -// bring the node down: turning it into an error makes the channel unreportable, -// which is the correct fail-closed outcome. -func recoverTranscendental(operation string, input decimal.Decimal, result *decimal.Decimal, err *error) { - if r := recover(); r != nil { - *result = decimal.Decimal{} - *err = fmt.Errorf("%s of %s could not be computed: %v", operation, input, r) - } -} - -// decimalPow is decimal.PowWithPrecision with its exponential replaced by -// expDecimal. +// decimalPow is decimal.PowWithPrecision with its logarithm and exponential +// replaced by lnDecimal and expDecimal. // // The library evaluates base^(i+f), for the integer part i and fractional part // f of the exponent, as base^i * exp(f * ln(base)), with the logarithm and the // exponential taken at a precision raised to cover the operands. This keeps that -// structure and those precisions, so a result changes only where ExpTaylor was -// itself inaccurate. ExpTaylor sums the series on the full argument, needing -// about e*|x| terms of growing size: Sqrt of a stored value near 1e-943 took 4.4 -// seconds under the transcendental lock. See expDecimal. +// structure and those precisions, so a result changes only where the library +// was itself inaccurate. Its own Ln and ExpTaylor take time that grows steeply +// with the argument and the precision: Sqrt of a stored value near 1e-943 took +// 4.4 seconds. See expDecimal and lnDecimal. // // The exponent is bounded first. shopspring/decimal PANICS on an exponent large // enough to overflow the result's int32 scale ("exponent ... overflows an @@ -99,7 +62,7 @@ func decimalPow(base, exponent decimal.Decimal, prec int32) (decimal.Decimal, er } fracPrec += 10 - lnBase, err := decimalLn(base, fracPrec) + lnBase, err := lnDecimal(base, fracPrec) if err != nil { return decimal.Decimal{}, err } @@ -111,7 +74,7 @@ func decimalPow(base, exponent decimal.Decimal, prec int32) (decimal.Decimal, er } // decimalIntPow is decimal.PowWithPrecision for an integer exponent or a zero -// base, neither of which reaches a transcendental function. +// base, neither of which reaches a logarithm or an exponential. // // Backstop: checkPow covers the case seen in the wild, but the library reserves // the right to panic on other extremes and an expression must never be able to @@ -158,8 +121,7 @@ const maxPowExponent = 100_000 // That size is checked separately, against maxPowResultBits. // // Both operands come from consensus, so an unbounded power is not one node's -// problem: every node computes it in the same round, inside StateTransition, -// holding the transcendental lock. +// problem: every node computes it in the same round, inside StateTransition. func checkPow(base, exponent decimal.Decimal) error { if exponent.Abs().GreaterThan(decimal.NewFromInt(maxPowExponent)) { return fmt.Errorf("exponent %s exceeds the maximum magnitude of %d", exponent, maxPowExponent) @@ -175,7 +137,7 @@ func checkPow(base, exponent decimal.Decimal) error { return fmt.Errorf("power of %s by %s needs a coefficient of up to %s bits, which exceeds the maximum of %d", base, exponent, resultBits, maxPowResultBits) } - lnBase, err := decimalLn(abs, powEstimatePrecision) + lnBase, err := lnDecimal(abs, powEstimatePrecision) if err != nil { return fmt.Errorf("power of %s by %s could not be bounded: %w", base, exponent, err) } @@ -261,7 +223,7 @@ func decimalToInt(name string, d decimal.Decimal, minimum, maximum int64) (int, // // 1. No float64. math.Log and math.Exp are not guaranteed bit-identical across // architectures or Go versions, so all logarithms and exponentials go through -// decimal.Ln and decimal.ExpTaylor at a fixed precision. This is why the TWAP +// lnDecimal and expDecimal at a fixed precision. This is why the TWAP // implementation here is a port of the mercury float-based one, not a reuse. // 2. No reliance on decimal.DivisionPrecision. That is a mutable package-level // global: anything in the process can change it and silently move every Div @@ -306,7 +268,7 @@ func ln(x decimal.Decimal) (decimal.Decimal, error) { if !x.IsPositive() { return decimal.Decimal{}, fmt.Errorf("cannot take the logarithm of %s: value must be positive", x) } - return decimalLn(x, doublePrecision) + return lnDecimal(x, doublePrecision) } // exp is a deterministic exponential at double precision, the inverse of ln. @@ -352,7 +314,8 @@ func expDecimal(x decimal.Decimal, places int32) (decimal.Decimal, error) { } a := x.Abs() - k := a.Ceil().BigInt().BitLen() + 8 + // The least k with a/2^k <= 1/256. + k := new(big.Int).Sub(a.Mul(decimal.NewFromInt(256)).Ceil().BigInt(), big.NewInt(1)).BitLen() // 0.4343 is just above 1/ln(10). resultDigits := a.Mul(decimal.RequireFromString("0.4343")).Ceil().IntPart() + 1 w := int32(places) + int32(resultDigits) + int32(k)/3 + 1 + expGuardDigits @@ -384,8 +347,97 @@ func expDecimal(x decimal.Decimal, places int32) (decimal.Decimal, error) { return result.Round(places), nil } +// lnDecimal returns the natural logarithm of x rounded to places decimal +// places, the contract of decimal.Ln. +// +// decimal.Ln refines a float64 estimate with Halley's method, calling ExpTaylor +// once per step at the full precision, so its cost grows steeply with +// precision: 0.84 seconds at 500 places. It also misses the last place for +// about 0.8% of arguments, and shares ExpTaylor's unsynchronized factorial +// cache, which forced a process-wide lock around it. +// +// Here x is split as m * 10^n with m in (0.316, 3.17), so ln(x) = ln(m) + +// n*ln(10), and both logarithms come from halleyLn. ln(10) carries extra digits +// to cover its multiplication by n. The error stays several orders below the +// last place, so rounding only differs from the exact value's on a near tie, +// and no state is shared. +func lnDecimal(x decimal.Decimal, places int32) (decimal.Decimal, error) { + if x.IsNegative() { + return decimal.Decimal{}, fmt.Errorf("cannot calculate natural logarithm for negative decimals") + } + if x.IsZero() { + return decimal.Decimal{}, fmt.Errorf("cannot represent natural logarithm of 0, result: -infinity") + } + if places < 0 { + return decimal.Decimal{}, fmt.Errorf("logarithm precision %d must not be negative", places) + } + + w := places + expGuardDigits + digits := int32(x.NumDigits()) + m := decimal.NewFromBigInt(x.Coefficient(), 1-digits) + n := int64(digits) - 1 + int64(x.Exponent()) + // Bring m from [1, 10) to within a factor of sqrt(10) of 1, where halleyLn + // is fastest. + if m.GreaterThan(decimal.RequireFromString("3.17")) { + m = m.Shift(-1) + n++ + } + + result, err := halleyLn(m, w) + if err != nil { + return decimal.Decimal{}, err + } + if n != 0 { + nDigits := int32(len(strconv.FormatInt(n, 10))) + ln10, err := halleyLn(decimal.NewFromInt(10), w+nDigits) + if err != nil { + return decimal.Decimal{}, err + } + result = result.Add(ln10.Mul(decimal.NewFromInt(n))) + } + return result.Round(places), nil +} + +// halleyLn returns ln(m) for a positive m, to within a few units in the w-th +// decimal place. +// +// Each step is y += 2(m - e^y) / (m + e^y), which is y += 2*tanh((ln(m) - y)/2): +// it moves toward ln(m) from any start, by less than the remaining distance, +// and cubes the error once close. It starts from 2(m - 1)/(m + 1), the first +// term of the series for ln(m), which is within 0.67 of ln(m) for m up to 10 +// and within 1e-12 when m is within 1e-4 of 1, as a TWAP growth ratio usually +// is. The start is exact rather than a float64 estimate, so every node takes +// the same steps. +// +// A step of s leaves an error of about s^3/12, so it stops after a step below +// 10^-(w/3+1), rather than spending a further exponential to confirm it. +func halleyLn(m decimal.Decimal, w int32) (decimal.Decimal, error) { + one := decimal.NewFromInt(1) + two := decimal.NewFromInt(2) + threshold := decimal.New(1, -(w+2)/3-1) + y := m.Sub(one).Mul(two).DivRound(m.Add(one), w) + for range maxHalleySteps { + e, err := expDecimal(y, w) + if err != nil { + return decimal.Decimal{}, err + } + step := m.Sub(e).Mul(two).DivRound(m.Add(e), w) + y = y.Add(step) + if step.Abs().LessThanOrEqual(threshold) { + return y, nil + } + } + return decimal.Decimal{}, fmt.Errorf("logarithm of %s did not converge in %d steps", m, maxHalleySteps) +} + +// maxHalleySteps backstops halleyLn. From the worst start, ln(10), its error +// falls 0.67, 0.025, 1e-6, 1e-19, 1e-57, 1e-173, 1e-520, 1e-1561, so a dozen +// steps covers any precision a bounded operand can ask for. +const maxHalleySteps = 12 + // expGuardDigits covers the truncation error of expDecimal: one unit in the last -// working place per series term and per squaring, under a thousand of each. +// working place per series term and per squaring, under a thousand of each. It +// covers the noise of halleyLn's steps in lnDecimal too. const expGuardDigits = 10 // sqrt is a deterministic square root, rounded to the package precision. diff --git a/llo/protocol/calculated/decimalmath_test.go b/llo/protocol/calculated/decimalmath_test.go index 0226122..ce4031f 100644 --- a/llo/protocol/calculated/decimalmath_test.go +++ b/llo/protocol/calculated/decimalmath_test.go @@ -99,17 +99,17 @@ func TestDivAndAvgPinnedToLegacyPrecision(t *testing.T) { assertDecimal(t, "2.3333333333333333", got) } -// TestTranscendentalsAreConcurrencySafe guards the lock around -// shopspring/decimal's transcendental functions. +// TestTranscendentalsAreConcurrencySafe guards against shared state in the +// logarithm and exponential. // -// ExpTaylor memoizes factorials in an unsynchronized package-level slice it -// appends to, and Ln reads through the same path, so concurrent calls race. An -// append racing with a read can produce a corrupted value rather than a merely -// stale one, which in a consensus path means nodes disagreeing. A node runs -// several plugin instances in one process, each evaluating on its own goroutine, -// so this is reachable in production. +// shopspring/decimal's ExpTaylor memoizes factorials in an unsynchronized +// package-level slice it appends to, and its Ln reads through the same path, so +// concurrent calls race. An append racing with a read can produce a corrupted +// value rather than a merely stale one, which in a consensus path means nodes +// disagreeing. A node runs several plugin instances in one process, each +// evaluating on its own goroutine, so this is reachable in production. // -// Run under -race, this fails if the guard is removed. +// Run under -race, this fails if any of these functions reaches either again. func TestTranscendentalsAreConcurrencySafe(t *testing.T) { t.Parallel() @@ -230,6 +230,70 @@ func Test_checkOperand(t *testing.T) { }) } +// libraryMu serializes the tests' reference calls into shopspring/decimal's Ln +// and ExpTaylor, which share an unsynchronized factorial cache. +var libraryMu sync.Mutex + +func Test_lnDecimal(t *testing.T) { + t.Parallel() + + t.Run("correctly rounded", func(t *testing.T) { + t.Parallel() + + // decimal.Ln is reliable given 30 spare places. At the places asked for + // it is not: it misses the last place for about 0.8% of arguments, and + // is the reference only because the spare places absorb that. + for _, x := range []string{ + "1", "10", "0.1", "2", "9.99999", "1.00000001", "0.99999999", + "34.382783417947313786", "103108677640616370", "0.000000000726289154090313027", + "1777777777777777777777777777777777777777777777777777777777e-1000", + "1777777777777777777777777777777777777777777777777777777777e1000", + } { + for _, places := range []int32{8, 18, 36, 50} { + d := decimal.RequireFromString(x) + got, err := lnDecimal(d, places) + require.NoError(t, err) + libraryMu.Lock() + want, err := d.Ln(places + 30) + libraryMu.Unlock() + require.NoError(t, err) + assertDecimal(t, want.Round(places).String(), got) + } + } + }) + + t.Run("high precision is cheap", func(t *testing.T) { + t.Parallel() + + // decimal.Ln took 0.84 seconds for this at 500 places. + start := time.Now() + got, err := lnDecimal(decimal.RequireFromString("103108677640616370"), 500) + require.Less(t, time.Since(start), 100*time.Millisecond) + require.NoError(t, err) + + more, err := lnDecimal(decimal.RequireFromString("103108677640616370"), 530) + require.NoError(t, err) + assertDecimal(t, more.Round(500).String(), got) + }) + + t.Run("inverse of exp", func(t *testing.T) { + t.Parallel() + + got, err := lnDecimal(decimal.RequireFromString("2.718281828459045235360287471352662497757"), 36) + require.NoError(t, err) + assertDecimal(t, "1", got.Round(30)) + }) + + t.Run("refuses non-positive arguments", func(t *testing.T) { + t.Parallel() + + _, err := lnDecimal(decimal.Zero, 18) + require.ErrorContains(t, err, "natural logarithm of 0") + _, err = lnDecimal(decimal.NewFromInt(-1), 18) + require.ErrorContains(t, err, "negative") + }) +} + func Test_expDecimal(t *testing.T) { t.Parallel() @@ -245,10 +309,9 @@ func Test_expDecimal(t *testing.T) { d := decimal.RequireFromString(x) got, err := expDecimal(d, places) require.NoError(t, err) - // ExpTaylor shares its factorial cache with every other caller. - transcendentalMu.Lock() + libraryMu.Lock() want, err := d.Abs().ExpTaylor(places + 30) - transcendentalMu.Unlock() + libraryMu.Unlock() require.NoError(t, err) if d.IsNegative() { // ExpTaylor's contract, kept: the reciprocal is rounded to diff --git a/llo/protocol/calculated/doc.go b/llo/protocol/calculated/doc.go index ac621a3..04a339f 100644 --- a/llo/protocol/calculated/doc.go +++ b/llo/protocol/calculated/doc.go @@ -131,8 +131,8 @@ // Expression results become consensus values, so identical inputs must give // bit-identical output on every node. See decimalmath.go: no float64 anywhere in // the calculation, no reliance on decimal.DivisionPrecision (a mutable global), -// fixed rounding at every step of an iterative calculation, and a lock around -// shopspring/decimal's transcendental functions, which are not concurrency-safe. +// fixed rounding at every step of an iterative calculation, and logarithms and +// exponentials of our own, since shopspring/decimal's are not concurrency-safe. // // Div and Avg are pinned to the precision they have always effectively used (16); // functions added with stream history use 18. Changing the former would move the diff --git a/llo/protocol/calculated/functions_bench_test.go b/llo/protocol/calculated/functions_bench_test.go index ad04d36..cb4375b 100644 --- a/llo/protocol/calculated/functions_bench_test.go +++ b/llo/protocol/calculated/functions_bench_test.go @@ -19,10 +19,8 @@ import ( // every expression of every channel is evaluated inside one state transition, so // per-expression cost multiplies by the channel count. // -// TWAP is the one to watch. It takes a logarithm per observed bucket and an -// exponential per bucket in the window, and all of those serialize on the -// process-wide transcendental lock (see decimalmath.go), so its cost does not -// parallelize across the plugin instances sharing a process. +// TWAP is the one to watch: its cost grows with the window, and it is +// evaluated for every channel that uses it. func benchSeries(depth int, intervalSeconds int) Series { values := make([]decimal.Decimal, 0, depth) @@ -208,9 +206,9 @@ func BenchmarkTWAPMaxWindow(b *testing.B) { } } -// BenchmarkTWAPParallel shows what the transcendental lock costs when several -// plugin instances evaluate TWAP at once. Compare ns/op against the serial -// benchmark: no speedup means the lock is the limit. +// BenchmarkTWAPParallel shows how TWAP scales when several plugin instances +// evaluate it at once. Compare ns/op against the serial benchmark: no speedup +// means something shared is the limit. func BenchmarkTWAPParallel(b *testing.B) { const windowSeconds = 300 window := benchSeries(windowSeconds, 1) diff --git a/llo/protocol/calculated/functions_twap.go b/llo/protocol/calculated/functions_twap.go index 29c14cc..4d762f9 100644 --- a/llo/protocol/calculated/functions_twap.go +++ b/llo/protocol/calculated/functions_twap.go @@ -96,8 +96,8 @@ type twapConfig struct { // // The reason it matters is cost. Filling in log space needs a logarithm per // observed bucket and an exponential per bucket in the window — about 600 -// operations for a five-minute window — and they all serialize on the -// transcendental lock. Measured: 197ms per evaluation for a 300-second window, +// operations for a five-minute window — and they were serialized on a +// process-wide lock at the time. Measured: 197ms per evaluation for a 300-second window, // and 7.2s for 32 such channels in one round, against a round budget on the // order of a second. Doing it this way, a fully covered window needs no // transcendental operations at all, and a window with gaps needs one power per From eada2a9f3e29960a67d8f70523558069ec354c18 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Sat, 10 Oct 2026 13:12:46 +0100 Subject: [PATCH 009/103] llo/protocol: compare mode votes in canonical form --- llo/protocol/aggregators.go | 83 +++++++++++++++++++++++++++++++- llo/protocol/aggregators_test.go | 78 ++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 2 deletions(-) diff --git a/llo/protocol/aggregators.go b/llo/protocol/aggregators.go index a6a7060..aa47752 100644 --- a/llo/protocol/aggregators.go +++ b/llo/protocol/aggregators.go @@ -2,6 +2,7 @@ package protocol import ( "fmt" + "math/big" "slices" "sort" @@ -95,18 +96,31 @@ func MedianAggregator(values []StreamValue, minContributions int) (StreamValue, // There must be at least minContributions contributions in agreement in order // to produce a value // nil contributions are ignored +// +// Values are compared in canonical form (see canonicalModeValue), so decimals +// that differ only in scale agree. A TimestampedStreamValue votes on its inner +// value alone, and the result carries the median timestamp of the +// contributions that agreed on it. func ModeAggregator(values []StreamValue, minContributions int) (StreamValue, error) { largestBucketType, largestBucket := mostCommonType(values) // find the most common value in the bucket - // use serialized representation for comparison/equality + // use serialized canonical representation for comparison/equality counts := make(map[string]int) + timestamps := make(map[string][]uint64) for _, value := range largestBucket { - b, err := value.MarshalBinary() + canonical, observedAt, err := canonicalModeValue(value) + if err != nil { + return nil, err + } + b, err := canonical.MarshalBinary() if err != nil { return nil, fmt.Errorf("failed to marshal value: %w", err) } counts[string(b)]++ + if largestBucketType == LLOStreamValue_TimestampedStreamValue { + timestamps[string(b)] = append(timestamps[string(b)], observedAt) + } } var modeSerialized []byte var modeCount int @@ -131,9 +145,74 @@ func ModeAggregator(values []StreamValue, minContributions int) (StreamValue, er if err != nil { return nil, fmt.Errorf("failed to unmarshal value: %w", err) } + if tsv, ok := val.(*TimestampedStreamValue); ok { + ts := timestamps[string(modeSerialized)] + slices.Sort(ts) + tsv.ObservedAtNanoseconds = ts[len(ts)/2] + } return val, nil } +// canonicalModeValue returns the form of v that ModeAggregator votes on. +// A TimestampedStreamValue has its timestamp cleared and returned separately, +// since honest oracles rarely observe at the same nanosecond. +func canonicalModeValue(v StreamValue) (StreamValue, uint64, error) { + tsv, ok := v.(*TimestampedStreamValue) + if !ok { + return canonicalStreamValue(v), 0, nil + } + if tsv == nil || tsv.StreamValue == nil { + return nil, 0, ErrNilStreamValue + } + return &TimestampedStreamValue{StreamValue: canonicalStreamValue(tsv.StreamValue)}, tsv.ObservedAtNanoseconds, nil +} + +// canonicalStreamValue rewrites the decimals in v to canonical form, so equal +// values serialize to equal bytes. Other types are returned unchanged. +func canonicalStreamValue(v StreamValue) StreamValue { + switch v := v.(type) { + case *Decimal: + if v == nil { + return v + } + return ToDecimal(canonicalDecimal(v.Decimal())) + case *Quote: + if v == nil { + return v + } + return &Quote{ + Bid: canonicalDecimal(v.Bid), + Benchmark: canonicalDecimal(v.Benchmark), + Ask: canonicalDecimal(v.Ask), + } + default: + return v + } +} + +// canonicalDecimal strips trailing zeros from the coefficient of d, so 1.0 +// and 1.00 both become 1. Zero becomes 0e0. Stripping stops at +// MaxDecimalExponent so the result still decodes. The form stays unique, as +// it is the smallest coefficient for that value with an exponent in bounds. +func canonicalDecimal(d decimal.Decimal) decimal.Decimal { + coef := d.Coefficient() + if coef.Sign() == 0 { + return decimal.New(0, 0) + } + exp := d.Exponent() + ten := big.NewInt(10) + quo, rem := new(big.Int), new(big.Int) + for exp < MaxDecimalExponent { + quo.QuoRem(coef, ten, rem) + if rem.Sign() != 0 { + break + } + coef, quo = quo, coef + exp++ + } + return decimal.NewFromBigInt(coef, exp) +} + func mostCommonType(values []StreamValue) (LLOStreamValue_Type, []StreamValue) { // Initialize variables to track the most common type buckets := make(map[LLOStreamValue_Type][]StreamValue) diff --git a/llo/protocol/aggregators_test.go b/llo/protocol/aggregators_test.go index 623d3fd..2b2f13c 100644 --- a/llo/protocol/aggregators_test.go +++ b/llo/protocol/aggregators_test.go @@ -174,6 +174,68 @@ func Test_ModeAggregator(t *testing.T) { minContributions: 3, output: ToDecimal(decimal.NewFromFloat(1.1)), }, + { + name: "decimals differing only in scale agree", + values: []StreamValue{ + ToDecimal(decimal.New(1, 0)), + ToDecimal(decimal.New(10, -1)), + ToDecimal(decimal.New(100, -2)), + ToDecimal(decimal.New(2, 0)), + }, + minContributions: 3, + output: ToDecimal(decimal.New(1, 0)), + }, + { + name: "integer decimals differing only in scale agree", + values: []StreamValue{ + ToDecimal(decimal.New(100, 0)), + ToDecimal(decimal.New(1, 2)), + ToDecimal(decimal.New(10, 1)), + }, + minContributions: 3, + output: ToDecimal(decimal.New(1, 2)), + }, + { + name: "zeros differing only in scale agree", + values: []StreamValue{ + ToDecimal(decimal.New(0, 0)), + ToDecimal(decimal.New(0, -5)), + ToDecimal(decimal.New(0, 3)), + }, + minContributions: 3, + output: ToDecimal(decimal.New(0, 0)), + }, + { + name: "quotes differing only in scale agree", + values: []StreamValue{ + &Quote{Bid: decimal.New(9, 0), Benchmark: decimal.New(10, 0), Ask: decimal.New(11, 0)}, + &Quote{Bid: decimal.New(90, -1), Benchmark: decimal.New(1000, -2), Ask: decimal.New(110, -1)}, + &Quote{Bid: decimal.New(900, -2), Benchmark: decimal.New(1, 1), Ask: decimal.New(11000, -3)}, + }, + minContributions: 3, + output: &Quote{Bid: decimal.New(9, 0), Benchmark: decimal.New(1, 1), Ask: decimal.New(11, 0)}, + }, + { + name: "timestamped values agree on inner value and take the median timestamp", + values: []StreamValue{ + &TimestampedStreamValue{ObservedAtNanoseconds: 300, StreamValue: ToDecimal(decimal.New(15, -1))}, + &TimestampedStreamValue{ObservedAtNanoseconds: 100, StreamValue: ToDecimal(decimal.New(150, -2))}, + &TimestampedStreamValue{ObservedAtNanoseconds: 200, StreamValue: ToDecimal(decimal.New(15, -1))}, + &TimestampedStreamValue{ObservedAtNanoseconds: 1, StreamValue: ToDecimal(decimal.New(2, 0))}, + }, + minContributions: 3, + output: &TimestampedStreamValue{ObservedAtNanoseconds: 200, StreamValue: ToDecimal(decimal.New(15, -1))}, + }, + { + name: "timestamped values that disagree on inner value fail the floor", + values: []StreamValue{ + &TimestampedStreamValue{ObservedAtNanoseconds: 100, StreamValue: ToDecimal(decimal.New(1, 0))}, + &TimestampedStreamValue{ObservedAtNanoseconds: 100, StreamValue: ToDecimal(decimal.New(2, 0))}, + &TimestampedStreamValue{ObservedAtNanoseconds: 100, StreamValue: ToDecimal(decimal.New(3, 0))}, + }, + minContributions: 2, + errStr: "not enough contributions in agreement to calculate mode: most common value had 1, need 2", + }, } for _, tc := range tcs { t.Run(tc.name, func(t *testing.T) { @@ -243,3 +305,19 @@ func Test_QuoteAggregator(t *testing.T) { assert.Equal(t, "6.6", q.Ask.String()) }) } + +func Test_canonicalDecimal(t *testing.T) { + t.Run("strips trailing zeros", func(t *testing.T) { + d := canonicalDecimal(decimal.New(-12300, -4)) + assert.Equal(t, "-123", d.Coefficient().String()) + assert.Equal(t, int32(-2), d.Exponent()) + }) + t.Run("stops at MaxDecimalExponent", func(t *testing.T) { + a := canonicalDecimal(decimal.New(100, MaxDecimalExponent-1)) + b := canonicalDecimal(decimal.New(1000, MaxDecimalExponent-2)) + assert.Equal(t, "10", a.Coefficient().String()) + assert.Equal(t, int32(MaxDecimalExponent), a.Exponent()) + assert.Equal(t, a, b) + require.NoError(t, checkDecimalExponent(a)) + }) +} From 914167bf02829733efe384612488ceffedc09a83 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Sat, 10 Oct 2026 16:22:07 +0100 Subject: [PATCH 010/103] llo/protocol: v31 median timestamp used contributions only --- llo/dev/v31/statetransition.go | 2 +- llo/protocol/aggregators.go | 35 +++++++++++++++++++---- llo/protocol/aggregators_test.go | 49 ++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 7 deletions(-) diff --git a/llo/dev/v31/statetransition.go b/llo/dev/v31/statetransition.go index 9145839..3bba103 100644 --- a/llo/dev/v31/statetransition.go +++ b/llo/dev/v31/statetransition.go @@ -529,7 +529,7 @@ func (p *Plugin) aggregate( prevTSV := prevCarry[sid][agg] - aggF := protocol.GetAggregatorFunc(agg) + aggF := protocol.GetAggregatorFuncV31(agg) if aggF == nil { // Unknown aggregator, e.g. one added by a newer version. Admission // rejects these, but a committed definition must not halt the diff --git a/llo/protocol/aggregators.go b/llo/protocol/aggregators.go index aa47752..033b89e 100644 --- a/llo/protocol/aggregators.go +++ b/llo/protocol/aggregators.go @@ -16,6 +16,7 @@ import ( // minContributions usable values of the selected type. type AggregatorFunc func(contributions []StreamValue, minContributions int) (StreamValue, error) +// GetAggregatorFunc returns the v30 aggregator for a. func GetAggregatorFunc(a llotypes.Aggregator) AggregatorFunc { switch a { case llotypes.AggregatorMedian: @@ -29,14 +30,33 @@ func GetAggregatorFunc(a llotypes.Aggregator) AggregatorFunc { } } +// GetAggregatorFuncV31 returns the v31 aggregator for a. It differs from +// GetAggregatorFunc only in the median, see MedianAggregatorV31. +func GetAggregatorFuncV31(a llotypes.Aggregator) AggregatorFunc { + if a == llotypes.AggregatorMedian { + return MedianAggregatorV31 + } + return GetAggregatorFunc(a) +} + +// MedianAggregator is the v30 median. func MedianAggregator(values []StreamValue, minContributions int) (StreamValue, error) { + return medianAggregator(values, minContributions, true) +} + +// MedianAggregatorV31 is the v31 median +func MedianAggregatorV31(values []StreamValue, minContributions int) (StreamValue, error) { + return medianAggregator(values, minContributions, false) +} + +func medianAggregator(values []StreamValue, minContributions int, countSkippedTimestamps bool) (StreamValue, error) { typ, typValues := mostCommonType(values) switch typ { case LLOStreamValue_TimestampedStreamValue: - svalues := make([]StreamValue, len(typValues)) - timestamps := make([]uint64, len(typValues)) - for i, value := range typValues { + svalues := make([]StreamValue, 0, len(typValues)) + timestamps := make([]uint64, 0, len(typValues)) + for _, value := range typValues { v, is := value.(*TimestampedStreamValue) if !is { return nil, fmt.Errorf("invariant violation, expected TimestampedStreamValue, got %T", value) @@ -47,13 +67,16 @@ func MedianAggregator(values []StreamValue, minContributions int) (StreamValue, // NOTE: This could get into an infinite loop if a malicious node sends // a TimestampedStreamValue with nested TimestampedStreamValues inside // it, so you definitely want to discard those. + if countSkippedTimestamps { + timestamps = append(timestamps, 0) + } continue } - svalues[i] = v.StreamValue - timestamps[i] = v.ObservedAtNanoseconds + svalues = append(svalues, v.StreamValue) + timestamps = append(timestamps, v.ObservedAtNanoseconds) } - medianValue, err := MedianAggregator(svalues, minContributions) + medianValue, err := medianAggregator(svalues, minContributions, countSkippedTimestamps) if err != nil { return nil, err } diff --git a/llo/protocol/aggregators_test.go b/llo/protocol/aggregators_test.go index 2b2f13c..e9023be 100644 --- a/llo/protocol/aggregators_test.go +++ b/llo/protocol/aggregators_test.go @@ -6,6 +6,8 @@ import ( "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" ) func Test_MedianAggregator(t *testing.T) { @@ -69,6 +71,38 @@ func Test_MedianAggregator(t *testing.T) { assert.Equal(t, uint64(102), sv.(*TimestampedStreamValue).ObservedAtNanoseconds) }) + t.Run("for *TimestampedStreamValue with skipped inner values", func(t *testing.T) { + quote := &Quote{Bid: decimal.NewFromInt(1), Benchmark: decimal.NewFromInt(2), Ask: decimal.NewFromInt(3)} + mixedValues := []StreamValue{ + &TimestampedStreamValue{ObservedAtNanoseconds: 100, StreamValue: ToDecimal(decimal.NewFromFloat(2.6))}, + &TimestampedStreamValue{ObservedAtNanoseconds: 102, StreamValue: ToDecimal(decimal.NewFromFloat(4.6))}, + &TimestampedStreamValue{ObservedAtNanoseconds: 104, StreamValue: ToDecimal(decimal.NewFromFloat(6.6))}, + &TimestampedStreamValue{ObservedAtNanoseconds: 999, StreamValue: quote}, + &TimestampedStreamValue{ObservedAtNanoseconds: 999, StreamValue: quote}, + &TimestampedStreamValue{ObservedAtNanoseconds: 999, StreamValue: quote}, + &TimestampedStreamValue{ObservedAtNanoseconds: 999, StreamValue: quote}, + } + + t.Run("v3.0 counts a zero timestamp for each skipped value", func(t *testing.T) { + sv, err := MedianAggregator(mixedValues, 3) + require.NoError(t, err) + assert.Equal(t, "4.6", sv.(*TimestampedStreamValue).StreamValue.(*Decimal).String()) + assert.Equal(t, uint64(0), sv.(*TimestampedStreamValue).ObservedAtNanoseconds) + }) + + t.Run("v3.1 takes the median timestamp over used values only", func(t *testing.T) { + sv, err := MedianAggregatorV31(mixedValues, 3) + require.NoError(t, err) + assert.Equal(t, "4.6", sv.(*TimestampedStreamValue).StreamValue.(*Decimal).String()) + assert.Equal(t, uint64(102), sv.(*TimestampedStreamValue).ObservedAtNanoseconds) + }) + + t.Run("v3.1 still enforces the floor on used values", func(t *testing.T) { + _, err := MedianAggregatorV31(mixedValues, 4) + require.EqualError(t, err, "not enough contributions to calculate median: got 3, need 4") + }) + }) + t.Run("fails below the contribution floor", func(t *testing.T) { _, err := MedianAggregator(values[:2], 4) require.EqualError(t, err, "not enough contributions to calculate median: got 2, need 4") @@ -321,3 +355,18 @@ func Test_canonicalDecimal(t *testing.T) { require.NoError(t, checkDecimalExponent(a)) }) } + +func Test_GetAggregatorFuncV31(t *testing.T) { + values := []StreamValue{ + &TimestampedStreamValue{ObservedAtNanoseconds: 100, StreamValue: ToDecimal(decimal.NewFromInt(1))}, + &TimestampedStreamValue{ObservedAtNanoseconds: 999, StreamValue: &Quote{}}, + &TimestampedStreamValue{ObservedAtNanoseconds: 999, StreamValue: &Quote{}}, + } + sv, err := GetAggregatorFuncV31(llotypes.AggregatorMedian)(values, 1) + require.NoError(t, err) + assert.Equal(t, uint64(100), sv.(*TimestampedStreamValue).ObservedAtNanoseconds) + + for _, a := range []llotypes.Aggregator{llotypes.AggregatorMode, llotypes.AggregatorQuote, llotypes.AggregatorCalculated} { + assert.Equal(t, GetAggregatorFunc(a) == nil, GetAggregatorFuncV31(a) == nil, "aggregator %v", a) + } +} From 054462565c7b4351f61f23c27fe06ea692957e52 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 07:51:33 +0100 Subject: [PATCH 011/103] llo/protocol: reject out of range numeric durations --- llo/protocol/types.go | 5 +++++ llo/protocol/types_test.go | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/llo/protocol/types.go b/llo/protocol/types.go index 4089b22..b38d98b 100644 --- a/llo/protocol/types.go +++ b/llo/protocol/types.go @@ -3,6 +3,7 @@ package protocol import ( "errors" "fmt" + "math" "time" "github.com/goccy/go-json" @@ -171,6 +172,10 @@ func (d *Duration) UnmarshalJSON(b []byte) error { } switch value := v.(type) { case float64: + // Reject magnitudes int64 cannot hold + if math.IsNaN(value) || value >= float64(math.MaxInt64) || value < float64(math.MinInt64) { + return fmt.Errorf("duration out of range: %v", value) + } *d = Duration(time.Duration(value)) return nil case string: diff --git a/llo/protocol/types_test.go b/llo/protocol/types_test.go index 449752c..543dc20 100644 --- a/llo/protocol/types_test.go +++ b/llo/protocol/types_test.go @@ -1,6 +1,7 @@ package protocol import ( + "math" "testing" "time" @@ -79,6 +80,8 @@ func TestDuration_UnmarshalJSON(t *testing.T) { {"zero", `0`, Duration(0)}, {"one billion (1s)", `1000000000`, Duration(time.Second)}, {"fractional", `1500000000.0`, Duration(1500 * time.Millisecond)}, + {"largest in range", `9.2e18`, Duration(9.2e18)}, + {"smallest in range", `-9223372036854775808`, Duration(math.MinInt64)}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -101,6 +104,9 @@ func TestDuration_UnmarshalJSON(t *testing.T) { {"null", `null`}, {"array", `[1,2]`}, {"object", `{"key":"val"}`}, + {"above int64 range", `9223372036854775808`}, + {"far above int64 range", `1e300`}, + {"far below int64 range", `-1e300`}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { From b0391225a7718c9425f6a44e9c3f3b610a919113 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 08:10:29 +0100 Subject: [PATCH 012/103] llo/protocol: verify peer channel definitions through a read-only cache view --- llo/dev/v31/flow_test.go | 47 +++++++++++++++ llo/dev/v31/plugin.go | 6 +- llo/protocol/channel_analysis_cache.go | 66 ++++++++++++++------- llo/protocol/channel_analysis_cache_test.go | 52 +++++++++++++++- 4 files changed, 145 insertions(+), 26 deletions(-) diff --git a/llo/dev/v31/flow_test.go b/llo/dev/v31/flow_test.go index a5ad0cc..5704cfa 100644 --- a/llo/dev/v31/flow_test.go +++ b/llo/dev/v31/flow_test.go @@ -6,6 +6,7 @@ import ( "fmt" "math" "sync" + "sync/atomic" "testing" "time" @@ -17,6 +18,7 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" protocol "github.com/smartcontractkit/chainlink-data-streams/llo/protocol" + "github.com/smartcontractkit/chainlink-data-streams/llo/reportcodec" "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3types" ocrtypes "github.com/smartcontractkit/libocr/offchainreporting2plus/types" @@ -990,3 +992,48 @@ func Test_IsReportable_MinReportIntervalDoesNotOverflow(t *testing.T) { require.Equal(t, uint64(math.MaxUint64), saturatingAdd(validAfter, math.MaxUint64)) require.Equal(t, uint64(9), saturatingAdd(4, 5)) } + +// verifyCountingCodec counts how often channel definitions are decoded, which +// the channel analysis cache is there to avoid. +type verifyCountingCodec struct { + reportcodec.JSONReportCodec + verifies atomic.Int64 +} + +func (c *verifyCountingCodec) Verify(cd llotypes.ChannelDefinition) error { + c.verifies.Add(1) + return c.JSONReportCodec.Verify(cd) +} + +// Test_ValidateObservation_DoesNotEvictChannelAnalysisCache pins that peer +// observations cannot displace what this node cached from its own inputs: more +// distinct updates of one channel than the cache retains leave the committed +// definition cached, and are not cached themselves. +func Test_ValidateObservation_DoesNotEvictChannelAnalysisCache(t *testing.T) { + ctx := tests.Context(t) + p := testPlugin(t) + codec := &verifyCountingCodec{} + p.ReportCodecs = map[llotypes.ReportFormat]protocol.ReportCodec{llotypes.ReportFormatJSON: codec} + p.ChannelAnalysisCache = protocol.NewChannelAnalysisCache() + kv := newMemKV() + + committed := llotypes.ChannelDefinitions{1: jsonChannel()} + require.NoError(t, writeChannelState(kv, 1, committed)) + // What Observation does with the committed set. + require.NoError(t, protocol.VerifyChannelDefinitionsWithCache(p.ReportCodecs, committed, p.ChannelAnalysisCache)) + require.Equal(t, int64(1), codec.verifies.Load()) + + const updates = 6 + for round := 0; round < 2; round++ { + for i := 0; i < updates; i++ { + cd := jsonChannel() + cd.Streams[0].StreamID = llotypes.StreamID(200 + i) //nolint:gosec // small test constant + obs := Observation{UnixTimestampNanoseconds: 1_000, UpdateChannelDefinitions: llotypes.ChannelDefinitions{1: cd}} + require.NoError(t, p.ValidateObservation(ctx, 2, ocrtypes.AttributedQuery{}, ao(i%4, mustEncodeObs(t, obs)), kv, testBlobs)) + } + } + require.Equal(t, int64(1+2*updates), codec.verifies.Load(), "peer updates must not be cached") + + require.NoError(t, protocol.VerifyChannelDefinitionsWithCache(p.ReportCodecs, committed, p.ChannelAnalysisCache)) + require.Equal(t, int64(1+2*updates), codec.verifies.Load(), "committed definition must still be cached") +} diff --git a/llo/dev/v31/plugin.go b/llo/dev/v31/plugin.go index eacd988..7241508 100644 --- a/llo/dev/v31/plugin.go +++ b/llo/dev/v31/plugin.go @@ -63,7 +63,9 @@ type Plugin struct { // desired ones in Observation, and the set each update-carrying observation // advocates in ValidateObservation. One cache is shared by all of them, so // the parallel ValidateObservation calls hit what Observation already - // decoded. May be nil, in which case nothing is memoized. + // decoded. Only Observation records entries: ValidateObservation reads + // through a read-only view, so peers cannot evict them. May be nil, in + // which case nothing is memoized. ChannelAnalysisCache *protocol.ChannelAnalysisCache // BlobPayloads memoizes blob payloads decoded within one round, so a handle @@ -467,7 +469,7 @@ func (p *Plugin) ValidateObservation(ctx context.Context, seqNr uint64, _ ocrtyp // updates from a staler one, which costs update-voting liveness only. An // observation that votes no update has nothing to verify here, so rounds // themselves are unaffected. - if err := protocol.VerifyChannelDefinitionsWithCache(p.ReportCodecs, defsForVerify, p.ChannelAnalysisCache); err != nil { + if err := protocol.VerifyChannelDefinitionsWithCache(p.ReportCodecs, defsForVerify, p.ChannelAnalysisCache.ReadOnly()); err != nil { return fmt.Errorf("UpdateChannelDefinitions is invalid: %w", err) } diff --git a/llo/protocol/channel_analysis_cache.go b/llo/protocol/channel_analysis_cache.go index b17a1a4..1e3a291 100644 --- a/llo/protocol/channel_analysis_cache.go +++ b/llo/protocol/channel_analysis_cache.go @@ -46,8 +46,19 @@ type channelFacts struct { // value within a round, so the cache holds a small number of entries per // channel rather than one. The overlapping sets agree on everything else. // +// Only sets derived from this node's own inputs should be recorded. Sets +// proposed by peers are verified through ReadOnly, so that a peer cannot evict +// what this node derived from the committed and the desired definitions. +// // A nil *ChannelAnalysisCache is usable and simply memoizes nothing. type ChannelAnalysisCache struct { + entries *channelAnalysisEntries + readOnly bool +} + +// channelAnalysisEntries is the state shared by a cache and its read-only +// views. +type channelAnalysisEntries struct { mu sync.Mutex facts map[llotypes.ChannelID][]cachedChannelFacts } @@ -62,14 +73,25 @@ type cachedChannelFacts struct { // channelFactsRetained bounds how many definitions of one channel are // remembered. Two is what a round asks for while a channel is being changed -// (the committed value and the proposed one); the third leaves room for a -// second proposal in flight. +// (the committed value and the desired one); the third leaves room for the +// desired value to change again before the previous one is committed. const channelFactsRetained = 3 func NewChannelAnalysisCache() *ChannelAnalysisCache { return &ChannelAnalysisCache{ - facts: make(map[llotypes.ChannelID][]cachedChannelFacts), + entries: &channelAnalysisEntries{ + facts: make(map[llotypes.ChannelID][]cachedChannelFacts), + }, + } +} + +// ReadOnly returns a view that serves the same entries but never records new +// ones. +func (c *ChannelAnalysisCache) ReadOnly() *ChannelAnalysisCache { + if c == nil { + return nil } + return &ChannelAnalysisCache{entries: c.entries, readOnly: true} } // Prune drops the entries of every channel channelDefs does not hold. Verifying @@ -77,14 +99,15 @@ func NewChannelAnalysisCache() *ChannelAnalysisCache { // callers that know the committed set: on the v31 hot path, the round that // loads it. func (c *ChannelAnalysisCache) Prune(channelDefs llotypes.ChannelDefinitions) { - if c == nil { + if c == nil || c.readOnly { return } - c.mu.Lock() - defer c.mu.Unlock() - for channelID := range c.facts { + e := c.entries + e.mu.Lock() + defer e.mu.Unlock() + for channelID := range e.facts { if _, ok := channelDefs[channelID]; !ok { - delete(c.facts, channelID) + delete(e.facts, channelID) } } } @@ -94,9 +117,10 @@ func (c *ChannelAnalysisCache) get(channelID llotypes.ChannelID, cd llotypes.Cha if c == nil { return channelFacts{}, false } - c.mu.Lock() - defer c.mu.Unlock() - for _, entry := range c.facts[channelID] { + e := c.entries + e.mu.Lock() + defer e.mu.Unlock() + for _, entry := range e.facts[channelID] { if entry.cd.Equals(cd) { return entry.facts, true } @@ -105,17 +129,18 @@ func (c *ChannelAnalysisCache) get(channelID llotypes.ChannelID, cd llotypes.Cha } // put records facts as derived from cd, evicting the channel's oldest entry -// once the bound is reached. cd is cloned: the caller's definition may share -// memory with a set that is mutated later, and the entry's identity has to stay -// what it was derived from. +// once the bound is reached. A read-only view records nothing. cd is cloned: +// the caller's definition may share memory with a set that is mutated later, +// and the entry's identity has to stay what it was derived from. func (c *ChannelAnalysisCache) put(channelID llotypes.ChannelID, cd llotypes.ChannelDefinition, facts channelFacts) { - if c == nil { + if c == nil || c.readOnly { return } - c.mu.Lock() - defer c.mu.Unlock() + e := c.entries + e.mu.Lock() + defer e.mu.Unlock() - entries := c.facts[channelID] + entries := e.facts[channelID] for _, entry := range entries { if entry.cd.Equals(cd) { // A concurrent analysis got there first. The facts are a pure @@ -123,14 +148,11 @@ func (c *ChannelAnalysisCache) put(channelID llotypes.ChannelID, cd llotypes.Cha return } } - if c.facts == nil { - c.facts = make(map[llotypes.ChannelID][]cachedChannelFacts) - } entries = append(entries, cachedChannelFacts{cd: cloneChannelDefinition(cd), facts: facts}) if len(entries) > channelFactsRetained { entries = entries[len(entries)-channelFactsRetained:] } - c.facts[channelID] = entries + e.facts[channelID] = entries } // channelFactsFor returns the facts for cd, decoding its opts only when they are diff --git a/llo/protocol/channel_analysis_cache_test.go b/llo/protocol/channel_analysis_cache_test.go index 014dc8c..a70b56f 100644 --- a/llo/protocol/channel_analysis_cache_test.go +++ b/llo/protocol/channel_analysis_cache_test.go @@ -152,6 +152,53 @@ func Test_ChannelAnalysisCache(t *testing.T) { require.Equal(t, int64(2), verifies) }) + t.Run("a read-only view hits existing entries and records none", func(t *testing.T) { + codec := &countingReportCodec{} + cache := NewChannelAnalysisCache() + committed := llotypes.ChannelDefinitions{1: cacheTestDef(`{"a":1}`)} + proposed := llotypes.ChannelDefinitions{1: cacheTestDef(`{"a":2}`)} + + require.NoError(t, VerifyChannelDefinitionsWithCache(countingCodecs(codec), committed, cache)) + require.NoError(t, VerifyChannelDefinitionsWithCache(countingCodecs(codec), committed, cache.ReadOnly())) + verifies, _, _ := codec.calls() + require.Equal(t, int64(1), verifies) + + require.NoError(t, VerifyChannelDefinitionsWithCache(countingCodecs(codec), proposed, cache.ReadOnly())) + require.NoError(t, VerifyChannelDefinitionsWithCache(countingCodecs(codec), proposed, cache.ReadOnly())) + verifies, _, _ = codec.calls() + require.Equal(t, int64(3), verifies) + + // Prune through a view is a no-op. + cache.ReadOnly().Prune(llotypes.ChannelDefinitions{}) + require.NoError(t, VerifyChannelDefinitionsWithCache(countingCodecs(codec), committed, cache)) + verifies, _, _ = codec.calls() + require.Equal(t, int64(3), verifies) + }) + + t.Run("peer proposals verified read-only do not evict this node's entries", func(t *testing.T) { + codec := &countingReportCodec{} + cache := NewChannelAnalysisCache() + committed := llotypes.ChannelDefinitions{1: cacheTestDef(`{"a":1}`)} + desired := llotypes.ChannelDefinitions{1: cacheTestDef(`{"a":2}`)} + + require.NoError(t, VerifyChannelDefinitionsWithCache(countingCodecs(codec), committed, cache)) + require.NoError(t, VerifyChannelDefinitionsWithCache(countingCodecs(codec), desired, cache)) + + // More distinct proposals for the channel than the cache retains, as + // faulty peers may send in a single round. + for i := 0; i < 2*channelFactsRetained; i++ { + peer := llotypes.ChannelDefinitions{1: cacheTestDef(fmt.Sprintf(`{"b":%d}`, i))} + require.NoError(t, VerifyChannelDefinitionsWithCache(countingCodecs(codec), peer, cache.ReadOnly())) + } + verifies, _, _ := codec.calls() + require.Equal(t, int64(2+2*channelFactsRetained), verifies) + + require.NoError(t, VerifyChannelDefinitionsWithCache(countingCodecs(codec), committed, cache)) + require.NoError(t, VerifyChannelDefinitionsWithCache(countingCodecs(codec), desired, cache.ReadOnly())) + verifies, _, _ = codec.calls() + require.Equal(t, int64(2+2*channelFactsRetained), verifies) + }) + t.Run("a nil cache memoizes nothing", func(t *testing.T) { codec := &countingReportCodec{} defs := llotypes.ChannelDefinitions{1: cacheTestDef(`{"a":1}`)} @@ -165,6 +212,7 @@ func Test_ChannelAnalysisCache(t *testing.T) { var nilCache *ChannelAnalysisCache nilCache.Prune(defs) require.NoError(t, VerifyChannelDefinitionsWithCache(countingCodecs(codec), defs, nilCache)) + require.Nil(t, nilCache.ReadOnly()) }) } @@ -241,7 +289,7 @@ func requireSameError(t *testing.T, want, got error) { // Test_ChannelAnalysisCache_Concurrent exercises the access pattern of a round: // Observation warming the cache while several ValidateObservation goroutines -// read it and each advocate a different update. +// read it through read-only views and each advocate a different update. func Test_ChannelAnalysisCache_Concurrent(t *testing.T) { codec := &countingReportCodec{} codecs := countingCodecs(codec) @@ -264,7 +312,7 @@ func Test_ChannelAnalysisCache_Concurrent(t *testing.T) { updated := CloneChannelDefinitions(committed) channelID := llotypes.ChannelID(i%50 + 1) updated[channelID] = cacheTestDef(fmt.Sprintf(`{"a":%d,"b":%d}`, channelID, j)) - requireSameError(t, VerifyChannelDefinitions(codecs, updated), VerifyChannelDefinitionsWithCache(codecs, updated, cache)) + requireSameError(t, VerifyChannelDefinitions(codecs, updated), VerifyChannelDefinitionsWithCache(codecs, updated, cache.ReadOnly())) } }(i) } From a72a5e3e4adef57263a450d9fca182f179ba4645 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 09:27:38 +0100 Subject: [PATCH 013/103] llo/calculated: bound calculated stream exponents before commit --- llo/protocol/aggregators.go | 5 +++ llo/protocol/calculated/calculated.go | 31 ++++++++++++-- llo/protocol/calculated/series_test.go | 59 ++++++++++++++++++++++++++ llo/protocol/stream_value.go | 2 +- 4 files changed, 93 insertions(+), 4 deletions(-) diff --git a/llo/protocol/aggregators.go b/llo/protocol/aggregators.go index 033b89e..25415b8 100644 --- a/llo/protocol/aggregators.go +++ b/llo/protocol/aggregators.go @@ -213,6 +213,11 @@ func canonicalStreamValue(v StreamValue) StreamValue { } } +// CanonicalDecimal returns the canonical form of d. See canonicalDecimal. +func CanonicalDecimal(d decimal.Decimal) decimal.Decimal { + return canonicalDecimal(d) +} + // canonicalDecimal strips trailing zeros from the coefficient of d, so 1.0 // and 1.00 both become 1. Zero becomes 0e0. Stripping stops at // MaxDecimalExponent so the result still decodes. The form stays unique, as diff --git a/llo/protocol/calculated/calculated.go b/llo/protocol/calculated/calculated.go index 5704335..9e26939 100644 --- a/llo/protocol/calculated/calculated.go +++ b/llo/protocol/calculated/calculated.go @@ -899,9 +899,10 @@ func applyCalculatedStreams(lggr logger.Logger, channelDefinitions llotypes.Chan work.cid, abi.ExpressionStreamID, abi.Expression) break } - // A calculated value never passes through observation decode, so - // this is the only place its coefficient is bounded. - if cerr := protocol.CheckDecimalCoefficient(value); cerr != nil { + + // bound the calculated value before it is stored. + var cerr error + if value, cerr = storableDecimal(value); cerr != nil { err = fmt.Errorf( "calculated stream value out of range, channelID: %d, expressionStreamID: %d, expression: %s: %w", work.cid, abi.ExpressionStreamID, abi.Expression, cerr) @@ -927,6 +928,25 @@ func applyCalculatedStreams(lggr logger.Logger, channelDefinitions llotypes.Chan } } +// storableDecimal returns d in a form that decodes, or an error if it has none. +// Only a value whose exponent is out of bounds is rewritten to its canonical +// form, so every value storable before keeps its encoding and the outcome it is +// part of stays the same across versions. +func storableDecimal(d decimal.Decimal) (decimal.Decimal, error) { + if err := protocol.CheckDecimalCoefficient(d); err != nil { + return decimal.Decimal{}, err + } + if protocol.CheckDecimalExponent(d) == nil { + return d, nil + } + // Canonicalizing only shortens the coefficient, so it stays in bounds. + d = protocol.CanonicalDecimal(d) + if err := protocol.CheckDecimalExponent(d); err != nil { + return decimal.Decimal{}, err + } + return d, nil +} + // AggregatorByStream indexes a channel's non-calculated streams by stream ID. // It is the mapping from what a History call names (a stream) to what history is // keyed by (a stream and an aggregator), and is exported so the plugin derives @@ -1217,6 +1237,11 @@ func ProcessCalculatedStreamsDryRun(expression string) error { return fmt.Errorf("failed to process expression: %w", result.err) } for i, value := range result.values { + // Held to the same bounds as applyCalculatedStreams, so a definition + // whose result cannot be stored is rejected. + if _, err := storableDecimal(value); err != nil { + return fmt.Errorf("calculated stream value out of range, expression: %s: %w", o.ABI[i].Expression, err) + } aggr[o.ABI[i].ExpressionStreamID] = map[llotypes.Aggregator]protocol.StreamValue{ llotypes.AggregatorCalculated: protocol.ToDecimal(value), } diff --git a/llo/protocol/calculated/series_test.go b/llo/protocol/calculated/series_test.go index 8cc9f47..a84dbcb 100644 --- a/llo/protocol/calculated/series_test.go +++ b/llo/protocol/calculated/series_test.go @@ -523,3 +523,62 @@ func TestProcessCalculatedStreams_CoefficientBound(t *testing.T) { assert.True(t, decimal.NewFromInt(5).Equal(value.Decimal()), "got %s", value.Decimal()) }) } + +func TestProcessCalculatedStreams_ExponentBound(t *testing.T) { + t.Parallel() + + process := func(t *testing.T, expression string) protocol.StreamValue { + t.Helper() + defs := llotypes.ChannelDefinitions{ + 1: historyChannel(medianStreams(1, 2), expression), + } + aggregates := protocol.StreamAggregates{ + 1: {llotypes.AggregatorMedian: protocol.ToDecimal(decimal.NewFromInt(2))}, + 2: {llotypes.AggregatorMedian: protocol.ToDecimal(decimal.NewFromInt(3))}, + } + ProcessCalculatedStreams(logger.Test(t), defs, aggregates, 1_000, protocol.NewOptsCache(), newStubHistoryReader()) + return aggregates[999][llotypes.AggregatorCalculated] + } + + t.Run("a value that would not decode writes no aggregate", func(t *testing.T) { + t.Parallel() + + // Each operand is in bounds, but a product adds their exponents. + for _, expression := range []string{ + `Add(s1, Mul("1e1000", "1e1000"))`, + `Add(s1, Mul("1e-1000", "1e-1000"))`, + } { + assert.Nil(t, process(t, expression), expression) + assert.Error(t, ProcessCalculatedStreamsDryRun(expression), expression) + } + }) + + t.Run("a value whose canonical form is in bounds is written", func(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + expression string + want decimal.Decimal + }{ + {`Mul(s1, Mul("10e-1000", "1e-1"))`, decimal.New(2, -1000)}, + {`Mul(s1, Mul("0e-1000", "1e-1000"))`, decimal.New(0, 0)}, + } { + got := process(t, tc.expression) + require.NotNil(t, got, tc.expression) + value, ok := got.(*protocol.Decimal) + require.True(t, ok) + assert.True(t, tc.want.Equal(value.Decimal()), "%s: got %s", tc.expression, value.Decimal()) + assert.NoError(t, protocol.CheckDecimalExponent(value.Decimal()), tc.expression) + assert.NoError(t, ProcessCalculatedStreamsDryRun(tc.expression), tc.expression) + } + }) + + t.Run("a value within the bounds keeps its encoding", func(t *testing.T) { + t.Parallel() + + value, ok := process(t, `Mul(s1, "1.500")`).(*protocol.Decimal) + require.True(t, ok) + assert.Equal(t, int32(-3), value.Decimal().Exponent()) + assert.Equal(t, "3000", value.Decimal().Coefficient().String()) + }) +} diff --git a/llo/protocol/stream_value.go b/llo/protocol/stream_value.go index 757543c..f064df5 100644 --- a/llo/protocol/stream_value.go +++ b/llo/protocol/stream_value.go @@ -122,7 +122,7 @@ func checkDecimalCoefficient(d decimal.Decimal) error { } // CheckDecimalExponent bounds the exponent of a decimal parsed from an -// expression literal. See MaxDecimalExponent. +// expression literal or about to be written into state. See MaxDecimalExponent. func CheckDecimalExponent(d decimal.Decimal) error { return checkDecimalExponent(d) } From 34789fdc8868b045246b20dd9cbf187668245582 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 09:46:15 +0100 Subject: [PATCH 014/103] llo/calculated: budget MaxTotalTWAPCalls against the gap count worst case Interpolate TWAP gaps with a direct logarithm and exponential --- .../calculated/functions_bench_test.go | 42 ++++++++++++--- llo/protocol/calculated/functions_twap.go | 54 +++++++++++++------ .../functions_twap_reference_test.go | 22 ++------ .../calculated/functions_twap_test.go | 38 +++++++++++++ llo/protocol/limits.go | 11 ++-- 5 files changed, 121 insertions(+), 46 deletions(-) diff --git a/llo/protocol/calculated/functions_bench_test.go b/llo/protocol/calculated/functions_bench_test.go index cb4375b..1909035 100644 --- a/llo/protocol/calculated/functions_bench_test.go +++ b/llo/protocol/calculated/functions_bench_test.go @@ -177,15 +177,24 @@ func BenchmarkTWAPRealisticGaps(b *testing.B) { // BenchmarkTWAPMaxWindow measures one TWAP call at the longest window allowed // over the deepest history allowed, which is what protocol.MaxTotalTWAPCalls // charges every call. Records one second apart leave a head gap that costs one -// multiplication; records spread evenly across the window turn every missing -// second into an interior one, the worst case. +// multiplication. Records spread evenly across the window turn every missing +// second into an interior one, the most steps. Records two seconds apart with +// prices alternating by a large factor give the most gaps, each with a ratio +// far from 1, the most expensive logarithm. func BenchmarkTWAPMaxWindow(b *testing.B) { const windowSeconds = twapMaxWindowSeconds const depth = protocol.MaxHistoryRecordsPerPair - for _, spacing := range []int{1, windowSeconds / depth} { - window := benchSeries(depth, spacing) - anchorNs := uint64(depth*spacing+1) * uint64(time.Second) + for _, tc := range []struct { + name string + window Series + }{ + {"recordSpacing=1s", benchSeries(depth, 1)}, + {fmt.Sprintf("recordSpacing=%ds", windowSeconds/depth), benchSeries(depth, windowSeconds/depth)}, + {"recordSpacing=2s/alternating", benchAlternatingSeries(depth, 2, decimal.NewFromInt(1000))}, + } { + timestamps := tc.window.Timestamps() + anchorNs := timestamps[len(timestamps)-1] + uint64(time.Second) cfg := map[string]any{ "window": time.Duration(windowSeconds) * time.Second, "minSamples": 1, @@ -195,10 +204,10 @@ func BenchmarkTWAPMaxWindow(b *testing.B) { } twap := twapFunc(anchorNs) - b.Run(fmt.Sprintf("recordSpacing=%ds", spacing), func(b *testing.B) { + b.Run(tc.name, func(b *testing.B) { b.ReportAllocs() for range b.N { - if _, err := twap(window, cfg); err != nil { + if _, err := twap(tc.window, cfg); err != nil { b.Fatal(err) } } @@ -206,6 +215,25 @@ func BenchmarkTWAPMaxWindow(b *testing.B) { } } +// benchAlternatingSeries is benchSeries with every other price multiplied by +// factor, so every gap interpolates across a large move. +func benchAlternatingSeries(depth int, intervalSeconds int, factor decimal.Decimal) Series { + series := benchSeries(depth, intervalSeconds) + values := series.Values() + alternating := make([]decimal.Decimal, len(values)) + for i, value := range values { + alternating[i] = value + if i%2 == 1 { + alternating[i] = value.Mul(factor) + } + } + s, err := NewSeries(alternating, series.Timestamps()) + if err != nil { + panic(err) + } + return s +} + // BenchmarkTWAPParallel shows how TWAP scales when several plugin instances // evaluate it at once. Compare ns/op against the serial benchmark: no speedup // means something shared is the limit. diff --git a/llo/protocol/calculated/functions_twap.go b/llo/protocol/calculated/functions_twap.go index 4d762f9..9925ef7 100644 --- a/llo/protocol/calculated/functions_twap.go +++ b/llo/protocol/calculated/functions_twap.go @@ -100,8 +100,8 @@ type twapConfig struct { // process-wide lock at the time. Measured: 197ms per evaluation for a 300-second window, // and 7.2s for 32 such channels in one round, against a round budget on the // order of a second. Doing it this way, a fully covered window needs no -// transcendental operations at all, and a window with gaps needs one power per -// interior gap. +// transcendental operations at all, and a window with gaps needs one logarithm +// and one exponential per interior gap. type twapObservation struct { offset int price decimal.Decimal @@ -268,25 +268,16 @@ func twapFillThenAverage(observations []twapObservation, n int) (decimal.Decimal // ratio = (right / left) ^ (1 / span) // filled[k] = filled[k-1] * ratio // -// One power per gap instead of two logarithms plus one exponential per missing -// bucket. With the spec's example thresholds a window can be missing 60 buckets, -// which cost ~73ms the other way and a fraction of that here. Exponentials are the -// expensive operation (~0.5ms each) and reducing their precision only helps by -// about a factor of two, so cutting their number is the only lever that matters. +// One logarithm and one exponential per gap instead of two logarithms plus one +// exponential per missing bucket. With the spec's example thresholds a window +// can be missing 60 buckets, which cost ~73ms the other way and a fraction of +// that here. // // Determinism: the ratio is computed at a fixed precision and every step is // rounded, so the sequence is reproducible — the same requirement EMA has, for the // same reason. That is also why the steps cannot be summed in closed form. func twapInterpolate(left, right twapObservation) (decimal.Decimal, error) { - growth, err := divRound(right.price, left.price, doublePrecision) - if err != nil { - return decimal.Decimal{}, fmt.Errorf("TWAP: bucket %d: %w", left.offset, err) - } - exponent, err := divRoundByInt(decimal.NewFromInt(1), right.offset-left.offset, doublePrecision) - if err != nil { - return decimal.Decimal{}, err - } - ratio, err := decimalPow(growth, exponent, doublePrecision) + ratio, err := twapStepRatio(left.price, right.price, right.offset-left.offset) if err != nil { return decimal.Decimal{}, fmt.Errorf("TWAP: interpolating buckets %d..%d: %w", left.offset+1, right.offset-1, err) } @@ -300,6 +291,37 @@ func twapInterpolate(left, right twapObservation) (decimal.Decimal, error) { return sum, nil } +// twapStepRatio returns (right / left) ^ (1 / span), the per-second ratio of a +// gap, as exp(ln(right / left) / span) rounded to doublePrecision. +// +// It takes the logarithm and the exponential directly rather than through +// decimalPow. A power raises its working precision by the digits of the +// exponent, and 1/span rounded to doublePrecision has that many, so decimalPow +// would work at more than twice the precision the ratio is rounded to. Dividing +// the logarithm by span also avoids rounding 1/span first. +// +// The exponential scales an error in its argument by the ratio, so the steps +// before it carry expGuardDigits more places than the result. Each step of the +// gap rounds to doublePrecision, so the ratio's error grows to at most span +// units of that place over the gap, far below the precision the TWAP is +// reported at. +func twapStepRatio(left, right decimal.Decimal, span int) (decimal.Decimal, error) { + const working = doublePrecision + expGuardDigits + growth, err := divRound(right, left, working) + if err != nil { + return decimal.Decimal{}, err + } + logGrowth, err := lnDecimal(growth, working) + if err != nil { + return decimal.Decimal{}, err + } + logRatio, err := divRoundByInt(logGrowth, span, working) + if err != nil { + return decimal.Decimal{}, err + } + return expDecimal(logRatio, doublePrecision) +} + // parseTWAPConfig decodes and validates the configuration map. // // Every key is required and no key is optional: a defaulted threshold would mean diff --git a/llo/protocol/calculated/functions_twap_reference_test.go b/llo/protocol/calculated/functions_twap_reference_test.go index fcdc64b..c5211c7 100644 --- a/llo/protocol/calculated/functions_twap_reference_test.go +++ b/llo/protocol/calculated/functions_twap_reference_test.go @@ -170,28 +170,14 @@ func twapDenseFillThenAverage(buckets []twapBucket) (decimal.Decimal, error) { // ratio = (right / left) ^ (1 / span) // filled[k] = filled[k-1] * ratio // -// One power per gap instead of two logarithms plus one exponential per missing -// bucket. With the spec's example thresholds a window can be missing 60 buckets, -// which cost ~73ms the other way and a fraction of that here. Exponentials are the -// expensive operation (~0.5ms each) and reducing their precision only helps by -// about a factor of two, so cutting their number is the only lever that matters. -// -// Determinism: the ratio is computed at a fixed precision and every step is -// rounded, so the sequence is reproducible — the same requirement EMA has, for the -// same reason. +// The ratio comes from twapStepRatio, shared with the sparse implementation: +// this reference checks how gaps are found and filled, not the arithmetic of +// the ratio itself. func twapDenseInterpolate(buckets []twapBucket, filled []decimal.Decimal, runStart, rightIdx int) error { leftIdx := runStart - 1 left, right := buckets[leftIdx].price, buckets[rightIdx].price - growth, err := divRound(right, left, doublePrecision) - if err != nil { - return fmt.Errorf("TWAP: bucket %d: %w", leftIdx, err) - } - exponent, err := divRoundByInt(decimal.NewFromInt(1), rightIdx-leftIdx, doublePrecision) - if err != nil { - return err - } - ratio, err := decimalPow(growth, exponent, doublePrecision) + ratio, err := twapStepRatio(left, right, rightIdx-leftIdx) if err != nil { return fmt.Errorf("TWAP: interpolating buckets %d..%d: %w", runStart, rightIdx-1, err) } diff --git a/llo/protocol/calculated/functions_twap_test.go b/llo/protocol/calculated/functions_twap_test.go index f700211..d3459fe 100644 --- a/llo/protocol/calculated/functions_twap_test.go +++ b/llo/protocol/calculated/functions_twap_test.go @@ -185,6 +185,44 @@ func TestTWAP_FillThenAverage(t *testing.T) { } } +// TestTWAP_StepRatio checks the per-second ratio of a gap against a power taken +// at twice the precision: it must be within one unit at doublePrecision. +func TestTWAP_StepRatio(t *testing.T) { + t.Parallel() + + ulp := decimal.New(1, -doublePrecision) + for _, tc := range []struct { + left, right string + span int + }{ + {"100", "1600", 4}, + {"1600", "100", 4}, + {"100", "100", 7}, + {"1234.56789012", "1234.56789013", 2}, + {"1234.56789012", "1851.85183518", 2}, + {"1234.56789012", "1234567.89012", 2}, + {"1234567.89012", "1234.56789012", 3}, + {"0.000012345", "98765.4321", 84}, + {"98765.4321", "98765.4322", 86400}, + } { + t.Run(fmt.Sprintf("%s->%s/span=%d", tc.left, tc.right, tc.span), func(t *testing.T) { + left, right := decimal.RequireFromString(tc.left), decimal.RequireFromString(tc.right) + got, err := twapStepRatio(left, right, tc.span) + require.NoError(t, err) + + const refPrecision = 2 * doublePrecision + growth, err := divRound(right, left, refPrecision) + require.NoError(t, err) + exponent, err := divRoundByInt(decimal.NewFromInt(1), tc.span, refPrecision) + require.NoError(t, err) + want, err := decimalPow(growth, exponent, refPrecision) + require.NoError(t, err) + + assert.True(t, got.Sub(want).Abs().LessThanOrEqual(ulp), "ratio %s, want %s", got, want) + }) + } +} + // TestTWAP_GapStats ports the mercury gap-classification cases. These are the // statistics the acceptance rule is written in terms of, so they are worth // pinning independently of the averaging. diff --git a/llo/protocol/limits.go b/llo/protocol/limits.go index 596e51a..7ab40e2 100644 --- a/llo/protocol/limits.go +++ b/llo/protocol/limits.go @@ -210,11 +210,12 @@ const ( // every round on every oracle. Like the per-expression cap, this charges // every call its worst case. // - // The worst case is a maximum-length window over a maximum-depth history - // spread evenly across it, so every missing second is interior and is - // stepped through costing cpu time across the evaluation workers, - // while a window with a short gap, the realistic shape, costs tens of - // microseconds per call. Admission-only, like the other whole-set budgets. + // A call costs one multiplication per missing interior second plus one + // logarithm and one exponential per interior gap, and the gap count is not + // bounded by the acceptance rule. The worst case is a maximum-depth history + // with a one-second gap between every pair of records. The realistic shape, + // costs tens of microseconds per call. Admission-only, like the other + // whole-set budgets. MaxTotalTWAPCalls = 64 // MaxHistoryRecordBytes is the maximum serialized size of one history // record, enforced on append (StreamHistory.Append) and used as the From e16f31556458c199857da067fb29f4105067b081 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 09:52:58 +0100 Subject: [PATCH 015/103] llo/protocol: anchor quote text regex and accept negative values --- llo/protocol/history_backfill.go | 2 +- llo/protocol/stream_value.go | 2 +- llo/protocol/stream_value_test.go | 30 ++++++++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/llo/protocol/history_backfill.go b/llo/protocol/history_backfill.go index d92258d..616a566 100644 --- a/llo/protocol/history_backfill.go +++ b/llo/protocol/history_backfill.go @@ -380,7 +380,7 @@ func DropInvalidHistoryBackfillChannels(lggr logger.Logger, defs llotypes.Channe func StreamValueFromBackfillString(agg llotypes.Aggregator, s string) (StreamValue, error) { switch agg { case llotypes.AggregatorQuote: - q := new(Quote) // {Bid: ([0-9.]+), Benchmark: ([0-9.]+), Ask: ([0-9.]+)} + q := new(Quote) if err := q.UnmarshalText([]byte(s)); err != nil { return nil, err } diff --git a/llo/protocol/stream_value.go b/llo/protocol/stream_value.go index f064df5..2232d31 100644 --- a/llo/protocol/stream_value.go +++ b/llo/protocol/stream_value.go @@ -329,7 +329,7 @@ func (v *Quote) MarshalText() ([]byte, error) { return []byte(fmt.Sprintf("Q{Bid: %s, Benchmark: %s, Ask: %s}", v.Bid.String(), v.Benchmark.String(), v.Ask.String())), nil } -var quoteRegex = regexp.MustCompile(`Q\{Bid: ([0-9.]+), Benchmark: ([0-9.]+), Ask: ([0-9.]+)\}`) +var quoteRegex = regexp.MustCompile(`^Q\{Bid: (-?[0-9.]+), Benchmark: (-?[0-9.]+), Ask: (-?[0-9.]+)\}$`) func (v *Quote) UnmarshalText(data []byte) error { if v == nil { diff --git a/llo/protocol/stream_value_test.go b/llo/protocol/stream_value_test.go index 9e71bd5..b374279 100644 --- a/llo/protocol/stream_value_test.go +++ b/llo/protocol/stream_value_test.go @@ -99,6 +99,36 @@ func Test_Decimal_ExponentBound(t *testing.T) { }) } +func Test_Quote_UnmarshalText(t *testing.T) { + t.Run("round trips negative values", func(t *testing.T) { + q := &Quote{ + Bid: decimal.RequireFromString("-2.5"), + Benchmark: decimal.RequireFromString("-1"), + Ask: decimal.RequireFromString("0.5"), + } + b, err := q.MarshalText() + require.NoError(t, err) + + q2 := new(Quote) + require.NoError(t, q2.UnmarshalText(b)) + assert.True(t, q.Bid.Equal(q2.Bid)) + assert.True(t, q.Benchmark.Equal(q2.Benchmark)) + assert.True(t, q.Ask.Equal(q2.Ask)) + }) + + t.Run("rejects input that is not exactly one quote", func(t *testing.T) { + for _, s := range []string{ + "garbage Q{Bid: 1, Benchmark: 2, Ask: 3}", + "Q{Bid: 1, Benchmark: 2, Ask: 3} garbage", + "Q{Bid: 1, Benchmark: 2, Ask: 3}Q{Bid: 9, Benchmark: 9, Ask: 9}", + "Q{Bid: --1, Benchmark: 2, Ask: 3}", + "Q{Bid: 1.2.3, Benchmark: 2, Ask: 3}", + } { + require.Error(t, new(Quote).UnmarshalText([]byte(s)), s) + } + }) +} + func Test_TimestampedStreamValue_Properties(t *testing.T) { properties := gopter.NewProperties(nil) From 8b1c777fcce2c1dfd48d563aed53075ca4825ccc Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 09:57:38 +0100 Subject: [PATCH 016/103] llo/protocol: document read-only contract of cached decoded opts --- llo/protocol/opts_cache.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/llo/protocol/opts_cache.go b/llo/protocol/opts_cache.go index a88069f..7a9f31b 100644 --- a/llo/protocol/opts_cache.go +++ b/llo/protocol/opts_cache.go @@ -19,7 +19,8 @@ type optsCacheKey struct { // OptsCache caches decoded channel definition options keyed by (ChannelID, target type). // Raw opts bytes are stored via Set during channel definition changes in Outcome(). // Decoded values are produced lazily on the first GetOpts call for a given (channelID, type) -// and reused until the channel is updated or removed. +// and reused until the channel is updated or removed. Callers share each decoded +// value, so they must not modify it. type OptsCache struct { mu sync.Mutex raw map[llotypes.ChannelID]llotypes.ChannelOpts @@ -151,6 +152,10 @@ func (c *OptsCache) ResetTo(channelDefinitions llotypes.ChannelDefinitions) { // decoded via json.Unmarshal and the result is cached. Subsequent calls return // the cached value directly. // +// The returned value shares its slices, maps and pointers with the cache and +// with every other caller. Treat it as read-only. Copy any field before +// modifying it. +// // Returns an error if the channel is not in the cache or decoding fails. // The caller must pass a valid opts cache. func GetOpts[T any](c *OptsCache, channelID llotypes.ChannelID) (T, error) { From 6fb213dcf3c5f0080067a9da9f065810cde798a4 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 10:01:58 +0100 Subject: [PATCH 017/103] llo/calculated: keep the existing analysis cache entry on a racing put --- llo/protocol/calculated/program.go | 15 ++++++++------- llo/protocol/calculated/program_test.go | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/llo/protocol/calculated/program.go b/llo/protocol/calculated/program.go index 433d5f8..25a2435 100644 --- a/llo/protocol/calculated/program.go +++ b/llo/protocol/calculated/program.go @@ -198,9 +198,7 @@ func (c *analysisCache) entry(expression string) *analysisEntry { } refs, twapCalls, err := analyzeHistoryExpression(expression) - entry := &analysisEntry{expression: expression, refs: refs, twapCalls: twapCalls, err: err} - c.put(entry) - return entry + return c.put(&analysisEntry{expression: expression, refs: refs, twapCalls: twapCalls, err: err}) } func (c *analysisCache) get(expression string) (*analysisEntry, bool) { @@ -215,25 +213,28 @@ func (c *analysisCache) get(expression string) (*analysisEntry, bool) { return element.Value.(*analysisEntry), true } -func (c *analysisCache) put(entry *analysisEntry) { +// put inserts entry unless one already exists for its expression, and returns +// the cached entry. Keep the existing one: analysis is pure, so both are +// equivalent, and the existing one may already carry a compiled program. +func (c *analysisCache) put(entry *analysisEntry) *analysisEntry { c.mu.Lock() defer c.mu.Unlock() if element, ok := c.entries[entry.expression]; ok { - element.Value = entry c.order.MoveToFront(element) - return + return element.Value.(*analysisEntry) } c.entries[entry.expression] = c.order.PushFront(entry) for c.order.Len() > c.max { oldest := c.order.Back() if oldest == nil { - return + break } c.order.Remove(oldest) delete(c.entries, oldest.Value.(*analysisEntry).expression) } + return entry } // program returns the cached compiled program for an expression, or nil if it diff --git a/llo/protocol/calculated/program_test.go b/llo/protocol/calculated/program_test.go index 8845d10..0478ec6 100644 --- a/llo/protocol/calculated/program_test.go +++ b/llo/protocol/calculated/program_test.go @@ -5,6 +5,7 @@ import ( "sync" "testing" + "github.com/expr-lang/expr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -91,6 +92,25 @@ func TestAnalysisCache_EvictsLeastRecentlyUsed(t *testing.T) { assert.True(t, ok) } +// TestAnalysisCache_PutKeepsCompiledProgram replays a cache-miss race: a late +// put for an already cached expression must not drop its compiled program. +func TestAnalysisCache_PutKeepsCompiledProgram(t *testing.T) { + t.Parallel() + + c := newAnalysisCache(8) + const expression = "Avg(History(s10001, 10))" + + first := c.entry(expression) + program, err := expr.Compile("1") + require.NoError(t, err) + c.storeProgram(expression, program) + + late := &analysisEntry{expression: expression, refs: first.refs, twapCalls: first.twapCalls} + assert.Same(t, first, c.put(late), "put must return the existing entry") + assert.Same(t, program, c.program(expression)) + assert.Equal(t, 1, c.len()) +} + // TestAnalysisCache_Concurrent checks the cache is safe under concurrent use; // the plugin and configuration tooling can both reach it. func TestAnalysisCache_Concurrent(t *testing.T) { From d7bc5c34a34726c4bedb98a666e43e2bb6493d38 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 10:10:53 +0100 Subject: [PATCH 018/103] llo/protocol: resolve backfill target resolution from one format mapping --- llo/protocol/history_backfill.go | 72 ++++++++++++++------------- llo/protocol/history_backfill_test.go | 43 +++++++++++++++- 2 files changed, 80 insertions(+), 35 deletions(-) diff --git a/llo/protocol/history_backfill.go b/llo/protocol/history_backfill.go index 616a566..46bb542 100644 --- a/llo/protocol/history_backfill.go +++ b/llo/protocol/history_backfill.go @@ -169,26 +169,30 @@ func (ReportCodecHistoryBackfill) Verify(cd llotypes.ChannelDefinition) error { // ReportTimestampResolutionNanos returns one tick of the target channel's observation timestamp resolution in nanoseconds. func ReportTimestampResolutionNanos(target llotypes.ChannelDefinition) (uint64, error) { - switch target.ReportFormat { - case llotypes.ReportFormatEVMPremiumLegacy: - return 1e9, nil - case llotypes.ReportFormatEVMABIEncodeUnpacked, llotypes.ReportFormatEVMABIEncodeUnpackedExpr: - res, err := TargetChannelTimeResolution(target) - if err != nil { - return 0, err - } - switch res { - case ResolutionMilliseconds: - return 1e6, nil - case ResolutionMicroseconds: - return 1e3, nil - case ResolutionNanoseconds: - return 1, nil - default: - return 1e9, nil - } + res, err := TargetChannelTimeResolution(target) + if err != nil { + return 0, err + } + nanos, ok := resolutionNanos(res) + if !ok { + return 0, fmt.Errorf("invalid time resolution %d", res) + } + return nanos, nil +} + +// resolutionNanos returns the length of one tick of res in nanoseconds. +func resolutionNanos(res TimeResolution) (uint64, bool) { + switch res { + case ResolutionSeconds: + return 1e9, true + case ResolutionMilliseconds: + return 1e6, true + case ResolutionMicroseconds: + return 1e3, true + case ResolutionNanoseconds: + return 1, true default: - return 1e9, nil + return 0, false } } @@ -202,25 +206,17 @@ func ReportTimestampResolutionNanos(target llotypes.ChannelDefinition) (uint64, // unrepresentable far-future timestamp would read as a valid past one. Keys come // from channel definition opts, which makes this reachable from configuration. func ObservationTimestampKeyToNanoseconds(rawKey uint64, res TimeResolution) (nanoseconds uint64, ok bool) { - var multiplier uint64 - switch res { - case ResolutionMilliseconds: - multiplier = 1e6 - case ResolutionMicroseconds: - multiplier = 1e3 - case ResolutionNanoseconds: - return rawKey, true - case ResolutionSeconds: - multiplier = 1e9 - default: - multiplier = 1e9 - } - if rawKey > math.MaxUint64/multiplier { + multiplier, ok := resolutionNanos(res) + if !ok || rawKey > math.MaxUint64/multiplier { return 0, false } return rawKey * multiplier, true } +// TargetChannelTimeResolution returns the resolution the target channel's codec +// encodes timestamps at, which is also the unit of its backfill observation keys. +// It is the only place that maps a report format to a resolution, so an unknown +// format is an error rather than a guess. func TargetChannelTimeResolution(target llotypes.ChannelDefinition) (TimeResolution, error) { switch target.ReportFormat { case llotypes.ReportFormatEVMABIEncodeUnpacked, llotypes.ReportFormatEVMABIEncodeUnpackedExpr: @@ -234,8 +230,16 @@ func TargetChannelTimeResolution(target llotypes.ChannelDefinition) (TimeResolut return 0, fmt.Errorf("target channel opts: %w", err) } return aux.TimeResolution, nil - default: + case llotypes.ReportFormatEVMPremiumLegacy, llotypes.ReportFormatJSON, + llotypes.ReportFormatRetirement, llotypes.ReportFormatCapabilityTrigger, + llotypes.ReportFormatEVMStreamlined: + return ResolutionSeconds, nil + case llotypes.ReportFormatHistoryBackfill: + // Rejected at admission by ValidateHistoryBackfillTarget. Resolving it + // keeps already committed definitions passing base validation. return ResolutionSeconds, nil + default: + return 0, fmt.Errorf("unsupported history_backfill target report format %s", target.ReportFormat) } } diff --git a/llo/protocol/history_backfill_test.go b/llo/protocol/history_backfill_test.go index 1953532..9db7500 100644 --- a/llo/protocol/history_backfill_test.go +++ b/llo/protocol/history_backfill_test.go @@ -28,7 +28,7 @@ func Test_ObservationTimestampKeyToNanoseconds_Overflow(t *testing.T) { {"milliseconds overflow", math.MaxUint64/uint64(1e6) + 1, ResolutionMilliseconds, 0, false}, {"microseconds overflow", math.MaxUint64/uint64(1e3) + 1, ResolutionMicroseconds, 0, false}, {"nanoseconds never overflow", math.MaxUint64, ResolutionNanoseconds, math.MaxUint64, true}, - {"unknown resolution is treated as seconds", math.MaxUint64, TimeResolution(200), 0, false}, + {"unknown resolution is rejected", 1, TimeResolution(200), 0, false}, } { t.Run(tc.name, func(t *testing.T) { got, ok := ObservationTimestampKeyToNanoseconds(tc.rawKey, tc.res) @@ -45,6 +45,47 @@ func Test_ObservationTimestampKeyToNanoseconds_Overflow(t *testing.T) { require.Less(t, rawKey*multiplier, uint64(1_700_000_000)*multiplier) } +func Test_TargetChannelTimeResolution_CoversEveryReportFormat(t *testing.T) { + // A new report format must be mapped on purpose: guessing its resolution + // would publish backfill reports with the wrong validAfter window. + for _, rf := range llotypes.ReportFormats { + t.Run(rf.String(), func(t *testing.T) { + target := llotypes.ChannelDefinition{ReportFormat: rf} + _, err := TargetChannelTimeResolution(target) + require.NoError(t, err) + _, err = ReportTimestampResolutionNanos(target) + require.NoError(t, err) + }) + } + + _, err := TargetChannelTimeResolution(llotypes.ChannelDefinition{ReportFormat: llotypes.ReportFormat(1000)}) + require.ErrorContains(t, err, "unsupported history_backfill target report format") + _, err = ReportTimestampResolutionNanos(llotypes.ChannelDefinition{ReportFormat: llotypes.ReportFormat(1000)}) + require.ErrorContains(t, err, "unsupported history_backfill target report format") +} + +func Test_ReportTimestampResolutionNanos(t *testing.T) { + for _, tc := range []struct { + name string + cd llotypes.ChannelDefinition + want uint64 + }{ + {"premium legacy", llotypes.ChannelDefinition{ReportFormat: llotypes.ReportFormatEVMPremiumLegacy}, 1e9}, + {"json", llotypes.ChannelDefinition{ReportFormat: llotypes.ReportFormatJSON}, 1e9}, + {"abi without opts", llotypes.ChannelDefinition{ReportFormat: llotypes.ReportFormatEVMABIEncodeUnpacked}, 1e9}, + {"abi seconds", llotypes.ChannelDefinition{ReportFormat: llotypes.ReportFormatEVMABIEncodeUnpacked, Opts: []byte(`{"TimeResolution":"s"}`)}, 1e9}, + {"abi milliseconds", llotypes.ChannelDefinition{ReportFormat: llotypes.ReportFormatEVMABIEncodeUnpacked, Opts: []byte(`{"TimeResolution":"ms"}`)}, 1e6}, + {"abi microseconds", llotypes.ChannelDefinition{ReportFormat: llotypes.ReportFormatEVMABIEncodeUnpacked, Opts: []byte(`{"TimeResolution":"us"}`)}, 1e3}, + {"abi expr nanoseconds", llotypes.ChannelDefinition{ReportFormat: llotypes.ReportFormatEVMABIEncodeUnpackedExpr, Opts: []byte(`{"TimeResolution":"ns"}`)}, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := ReportTimestampResolutionNanos(tc.cd) + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + func Test_GetHistoryBackfillOpts(t *testing.T) { raw := llotypes.ChannelOpts(`{"targetChannelId":7,"observations":{"1700000000":{"1":"1.5"}}}`) cd := llotypes.ChannelDefinition{ReportFormat: llotypes.ReportFormatHistoryBackfill, Opts: raw} From 97d37fbbf42e132f8ffee35c37759df23dac63c5 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 11:57:50 +0100 Subject: [PATCH 019/103] llo/dev/v31: apply the seconds overlap check to expr channels via the opts cache --- llo/dev/v31/history_flow_test.go | 4 +++- llo/dev/v31/plugin_test.go | 41 ++++++++++++++++++++++++++++++-- llo/dev/v31/reports.go | 2 +- llo/dev/v31/resolution.go | 28 ++++++++-------------- 4 files changed, 53 insertions(+), 22 deletions(-) diff --git a/llo/dev/v31/history_flow_test.go b/llo/dev/v31/history_flow_test.go index 3401153..42719ab 100644 --- a/llo/dev/v31/history_flow_test.go +++ b/llo/dev/v31/history_flow_test.go @@ -18,11 +18,13 @@ import ( ) // historyExprChannel is an expression channel reading a window of stream 100. +// It reports at nanosecond resolution so that rounds microseconds apart are not +// a seconds overlap. func historyExprChannel(expression string) llotypes.ChannelDefinition { return llotypes.ChannelDefinition{ ReportFormat: llotypes.ReportFormatEVMABIEncodeUnpackedExpr, Streams: []llotypes.Stream{{StreamID: 100, Aggregator: llotypes.AggregatorMedian}}, - Opts: fmt.Appendf(nil, `{"abi":[{"type":"int256","expression":%q,"expressionStreamID":999}]}`, expression), + Opts: fmt.Appendf(nil, `{"TimeResolution":"ns","abi":[{"type":"int256","expression":%q,"expressionStreamID":999}]}`, expression), } } diff --git a/llo/dev/v31/plugin_test.go b/llo/dev/v31/plugin_test.go index a71f6bb..35f0e58 100644 --- a/llo/dev/v31/plugin_test.go +++ b/llo/dev/v31/plugin_test.go @@ -526,7 +526,43 @@ func Test_SecondsResolutionOverlap(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { p := mkPrec(tc.format, tc.opts, tc.validAfter, tc.obsTs) - got := p.withSupport(1).reportableChannels(0, 0, protocol.NewOptsCache(), nil) + cache := protocol.NewOptsCache() + cache.ResetTo(p.ChannelDefinitions) + got := p.withSupport(1).reportableChannels(0, 0, cache, nil) + if tc.reportable { + require.Equal(t, []llotypes.ChannelID{1}, got) + } else { + require.Empty(t, got) + } + }) + } + + // Expr channels need a calculated aggregate to be reportable at all, so a + // not reportable result can only come from the overlap check. + const exprStreamID = 999 + exprOpts := func(resolution string) []byte { + return []byte(`{` + resolution + `"abi":[{"type":"int256","expression":"s1","expressionStreamID":999}]}`) + } + exprTests := []struct { + name string + opts []byte + obsTs uint64 + reportable bool + }{ + {"expr default opts same second -> not reportable (defaults to seconds)", exprOpts(``), sec1b, false}, + {"expr explicit seconds same second -> not reportable", exprOpts(`"TimeResolution":"s",`), sec1b, false}, + {"expr explicit seconds next second -> reportable", exprOpts(`"TimeResolution":"s",`), sec2, true}, + {"expr explicit ns same second -> reportable", exprOpts(`"TimeResolution":"ns",`), sec1b, true}, + } + for _, tc := range exprTests { + t.Run(tc.name, func(t *testing.T) { + p := mkPrec(llotypes.ReportFormatEVMABIEncodeUnpackedExpr, tc.opts, sec1a, tc.obsTs) + p.StreamAggregates = protocol.StreamAggregates{ + exprStreamID: {llotypes.AggregatorCalculated: protocol.ToDecimal(decimal.NewFromInt(3))}, + } + cache := protocol.NewOptsCache() + cache.ResetTo(p.ChannelDefinitions) + got := p.withSupport(1).reportableChannels(0, 0, cache, nil) if tc.reportable { require.Equal(t, []llotypes.ChannelID{1}, got) } else { @@ -570,7 +606,8 @@ func Test_DisableNilStreamValues_CalculatedStreams(t *testing.T) { base2 = llotypes.StreamID(200) expr1 = llotypes.StreamID(900) ) - validOpts := []byte(`{"abi":[{"type":"int256","expression":"Add(s100, s200)","expressionStreamID":900}]}`) + // Nanosecond resolution, so the 1000ns report window is not a seconds overlap. + validOpts := []byte(`{"TimeResolution":"ns","abi":[{"type":"int256","expression":"Add(s100, s200)","expressionStreamID":900}]}`) baseStreams := []llotypes.Stream{ {StreamID: base1, Aggregator: llotypes.AggregatorMedian}, diff --git a/llo/dev/v31/reports.go b/llo/dev/v31/reports.go index f06c241..5da7a12 100644 --- a/llo/dev/v31/reports.go +++ b/llo/dev/v31/reports.go @@ -398,7 +398,7 @@ func (o precursor) isReportable(channelID llotypes.ChannelID, minReportInterval } // For seconds-resolution report formats, also require a full second between // validAfter and the observation timestamp to prevent overlapping reports. - if isSecondsResolution(cd) && secondsOverlap(validAfter, o.ObservationTimestampNanoseconds) { + if isSecondsResolution(cd, channelID, optsCache) && secondsOverlap(validAfter, o.ObservationTimestampNanoseconds) { return false } return true diff --git a/llo/dev/v31/resolution.go b/llo/dev/v31/resolution.go index baa1bcc..b0d9bf5 100644 --- a/llo/dev/v31/resolution.go +++ b/llo/dev/v31/resolution.go @@ -1,8 +1,6 @@ package llo import ( - "github.com/goccy/go-json" - llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" protocol "github.com/smartcontractkit/chainlink-data-streams/llo/protocol" @@ -18,28 +16,22 @@ type timeResolutionOpts struct { // whose validAfter and observation timestamp fall in the same second, otherwise // the reports would overlap (or be not-yet-valid) once truncated to seconds. // -// Unlike the v30 equivalent (which reads a node-local, incrementally mutated -// OptsCache and is therefore non-deterministic on a cache miss), this parses the resolution directly from -// the channel definition's opts, which are part of the replicated state — so the -// result is deterministic across oracles. +// It reads opts through the round OptsCache. That cache is sealed to the same +// channel generation as the definitions, so it holds every channel opts and +// the result is deterministic across oracles. // // - ReportFormatEVMPremiumLegacy always uses seconds. -// - ReportFormatEVMABIEncodeUnpacked uses the TimeResolution opt, which -// defaults to seconds (the zero value) when unset; malformed opts are -// treated as non-seconds. +// - ReportFormatEVMABIEncodeUnpacked and ReportFormatEVMABIEncodeUnpackedExpr +// use the TimeResolution opt, which defaults to seconds (the zero value) +// when unset; malformed opts are treated as non-seconds. // - all other formats use full (nanosecond) resolution. -func isSecondsResolution(cd llotypes.ChannelDefinition) bool { +func isSecondsResolution(cd llotypes.ChannelDefinition, channelID llotypes.ChannelID, optsCache *protocol.OptsCache) bool { switch cd.ReportFormat { case llotypes.ReportFormatEVMPremiumLegacy: return true - case llotypes.ReportFormatEVMABIEncodeUnpacked: - var o timeResolutionOpts // TimeResolution zero value == ResolutionSeconds - if len(cd.Opts) > 0 { - if err := json.Unmarshal(cd.Opts, &o); err != nil { - return false - } - } - return o.TimeResolution == protocol.ResolutionSeconds + case llotypes.ReportFormatEVMABIEncodeUnpacked, llotypes.ReportFormatEVMABIEncodeUnpackedExpr: + o, err := protocol.GetOpts[timeResolutionOpts](optsCache, channelID) + return err == nil && o.TimeResolution == protocol.ResolutionSeconds default: return false } From 355725ac225e997fce140dee1e028c5b6b0eb2c9 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 12:08:46 +0100 Subject: [PATCH 020/103] llo/dev/v31: cap channel definition votes by encoded size --- llo/dev/v31/factory.go | 13 ++-- llo/dev/v31/limits_test.go | 156 +++++++++++++++++++++++++++++++++++++ llo/dev/v31/observation.go | 8 ++ llo/dev/v31/plugin.go | 29 +++++-- llo/protocol/limits.go | 4 + 5 files changed, 197 insertions(+), 13 deletions(-) diff --git a/llo/dev/v31/factory.go b/llo/dev/v31/factory.go index 395adfa..f474260 100644 --- a/llo/dev/v31/factory.go +++ b/llo/dev/v31/factory.go @@ -188,13 +188,12 @@ func (f *PluginFactory) NewReportingPlugin(ctx context.Context, cfg ocr3types.Re // rejects an oversized message or write set, which fails the round for every // oracle. The derivations, and which of them are currently enforced, are: // - // MaxObservationBytes bounded post-decompression by - // protocol.MaxDecompressedObservationLength, - // itself derived from - // MaxObservationStreamValuesLength stream - // values plus - // MaxObservationUpdateChannelDefinitionsLength - // definitions. + // MaxObservationBytes the observation is not compressed and + // carries no stream values, which travel + // in a blob. Its size is dominated by + // channel definition votes, held to + // maxObservationUpdateChannelDefinitionsBytes + // by channelUpdateVotes. // MaxReportsPlusPrecursorBytes the precursor embeds every definition plus // every stream aggregate. Admission bounds // the aggregate count by diff --git a/llo/dev/v31/limits_test.go b/llo/dev/v31/limits_test.go index b843e3c..d67d5b2 100644 --- a/llo/dev/v31/limits_test.go +++ b/llo/dev/v31/limits_test.go @@ -1,6 +1,8 @@ package llo import ( + "math" + "math/big" "strings" "testing" @@ -13,6 +15,7 @@ import ( protocol "github.com/smartcontractkit/chainlink-data-streams/llo/protocol" "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3_1types" + "google.golang.org/protobuf/proto" ) // The declared limits in factory.go are only honest if the plugin's own bounds @@ -252,3 +255,156 @@ func TestLimits_KnownUnboundedInputs(t *testing.T) { _, err = protocol.UnmarshalObservedProtoStreamValue(pb) require.ErrorIs(t, err, protocol.ErrDecimalCoefficientOutOfRange) } + +// worstCaseDefinitions builds channels definitions of streamsEach streams and +// optsEach opts bytes, with every ID in the 5-byte varint range so each entry +// encodes at its largest. The channels share one set of stream IDs, which +// keeps the set within the aggregated-pair budget however many channels it has. +func worstCaseDefinitions(channels, streamsEach, optsEach int) llotypes.ChannelDefinitions { + streams := make([]llotypes.Stream, 0, streamsEach) + for i := range streamsEach { + streams = append(streams, llotypes.Stream{ + StreamID: llotypes.StreamID(math.MaxUint32 - i), + Aggregator: llotypes.AggregatorMedian, + }) + } + defs := make(llotypes.ChannelDefinitions, channels) + for c := range channels { + defs[llotypes.ChannelID(math.MaxUint32-c)] = llotypes.ChannelDefinition{ + ReportFormat: llotypes.ReportFormatJSON, + Streams: streams, + Opts: []byte(strings.Repeat("x", optsEach)), + Source: math.MaxUint32, + DisableNilStreamValues: true, + } + } + return defs +} + +// TestLimits_ObservationWorstCaseFitsDeclared builds the largest observation +// this oracle can produce and measures it. The channel definition votes are +// the only part that admission alone does not keep under the libocr limit, so +// the votes are capped by bytes, and this test measures the cap together with +// every other field at its maximum. +func TestLimits_ObservationWorstCaseFitsDeclared(t *testing.T) { + const channels = protocol.MaxObservationUpdateChannelDefinitionsLength + defs := worstCaseDefinitions(channels, protocol.MaxStreamsPerChannel, protocol.MaxChannelOptsBytes) + admitting := make(map[llotypes.ChannelID]struct{}, channels) + for id := range defs { + admitting[id] = struct{}{} + } + require.NoError(t, protocol.VerifyChannelDefinitionsForAdmission(nil, defs, admitting)) + + // One admissible definition must always fit, or the first pending change + // could never be voted on. + for _, cd := range defs { + require.Less(t, proto.Size(protocol.ChannelDefinitionToProto(cd)), maxObservationUpdateChannelDefinitionsBytes) + break + } + updates := channelUpdateVotes(nil, defs) + require.NotEmpty(t, updates) + + // The largest retirement report a predecessor can attest: a validAfter for + // every channel, signed by every oracle. + validAfter := make(map[llotypes.ChannelID]uint64, protocol.MaxOutcomeChannelDefinitionsLength) + for i := range protocol.MaxOutcomeChannelDefinitionsLength { + validAfter[llotypes.ChannelID(math.MaxUint32-i)] = math.MaxUint64 + } + retirementReport, err := protocol.StandardRetirementReportCodec{}.Encode(protocol.RetirementReport{ + ProtocolVersion: math.MaxUint32, + ValidAfterNanoseconds: validAfter, + }) + require.NoError(t, err) + attested := &protocol.AttestedRetirementReport{RetirementReport: retirementReport, SeqNr: math.MaxUint64} + signers := make([][]byte, protocol.MaxObservationPredecessorSignersLength) + for i := range signers { + signers[i] = make([]byte, protocol.MaxPredecessorSignerBytes) + attested.Sigs = append(attested.Sigs, &protocol.AttributedOnchainSignature{ + Signature: make([]byte, protocol.MaxPredecessorSignerBytes), + Signer: math.MaxUint32, + }) + } + attestedBytes, err := proto.Marshal(attested) + require.NoError(t, err) + + removals := make(map[llotypes.ChannelID]struct{}, protocol.MaxObservationRemoveChannelIDsLength) + for i := range protocol.MaxObservationRemoveChannelIDsLength { + removals[llotypes.ChannelID(i)] = struct{}{} + } + formats := make(map[llotypes.ReportFormat]struct{}, protocol.MaxObservationSupportedReportFormatsLength) + for i := range protocol.MaxObservationSupportedReportFormatsLength { + formats[llotypes.ReportFormat(math.MaxUint32-i)] = struct{}{} + } + + obs := Observation{ + AttestedPredecessorRetirement: attestedBytes, + ShouldRetire: true, + UnixTimestampNanoseconds: math.MaxUint64, + RemoveChannelIDs: removals, + UpdateChannelDefinitions: updates, + SupportedReportFormats: formats, + PredecessorSigners: signers, + PredecessorF: math.MaxUint8, + } + // The encoder references one blob. A handle carries at most one + // availability signature per oracle, well under 4 KiB. + encoded, err := encodeObservation(obs, [][]byte{make([]byte, 4<<10)}) + require.NoError(t, err) + require.LessOrEqual(t, len(encoded), ocr3_1types.MaxMaxObservationBytes, + "worst-case observation (%d bytes) exceeds the declared limit", len(encoded)) + t.Logf("worst-case observation: %d bytes of the %d limit, voting %d of %d definitions", + len(encoded), ocr3_1types.MaxMaxObservationBytes, len(updates), channels) +} + +// TestLimits_PrecursorWorstCaseFitsDeclared builds the largest precursor the +// admission budgets and the aggregate caps permit and measures it against the +// declared libocr limit. +func TestLimits_PrecursorWorstCaseFitsDeclared(t *testing.T) { + const channels = protocol.MaxOutcomeChannelDefinitionsLength + defs := worstCaseDefinitions(channels, protocol.MaxTotalStreamEntries/channels, protocol.MaxTotalOptsBytes/channels) + + validAfter := make(map[llotypes.ChannelID]uint64, channels) + for id := range defs { + validAfter[id] = math.MaxUint64 + } + + // The largest decimal observation decode and the calculated stream write + // path accept. + coefficient := new(big.Int).Lsh(big.NewInt(1), protocol.MaxDecimalCoefficientBits) + coefficient.Sub(coefficient, big.NewInt(1)) + largest := decimal.NewFromBigInt(coefficient, -protocol.MaxDecimalExponent) + + // Observed pairs at the persisted-aggregate cap, each a quote of three + // largest decimals, plus calculated streams at their own cap. + aggregates := make(protocol.StreamAggregates, protocol.MaxPersistedAggregates+protocol.MaxTotalCalculatedStreams) + for i := range protocol.MaxPersistedAggregates { + aggregates[llotypes.StreamID(math.MaxUint32-i)] = map[llotypes.Aggregator]protocol.StreamValue{ + llotypes.AggregatorMedian: &protocol.Quote{Bid: largest, Benchmark: largest, Ask: largest}, + } + } + for i := range protocol.MaxTotalCalculatedStreams { + aggregates[llotypes.StreamID(math.MaxUint32-protocol.MaxPersistedAggregates-i)] = map[llotypes.Aggregator]protocol.StreamValue{ + llotypes.AggregatorCalculated: protocol.ToDecimal(largest), + } + } + + support := make(map[llotypes.ReportFormat]int, protocol.MaxOutcomeChannelDefinitionsLength) + for i := range protocol.MaxOutcomeChannelDefinitionsLength { + support[llotypes.ReportFormat(math.MaxUint32-i)] = math.MaxUint32 + } + + encoded, err := encodePrecursor(precursor{ + LifeCycleStage: llotypes.LifeCycleStage(strings.Repeat("x", 32)), + ObservationTimestampNanoseconds: math.MaxUint64, + ChannelDefinitions: defs, + ValidAfterNanoseconds: validAfter, + StreamAggregates: aggregates, + ChannelStateSeqNr: math.MaxUint64, + SupportByFormat: support, + }) + require.NoError(t, err) + require.LessOrEqual(t, len(encoded), ocr3_1types.MaxMaxReportsPlusPrecursorBytes, + "worst-case precursor (%d bytes) exceeds the declared limit", len(encoded)) + t.Logf("worst-case precursor: %d bytes of the %d limit", + len(encoded), ocr3_1types.MaxMaxReportsPlusPrecursorBytes) +} diff --git a/llo/dev/v31/observation.go b/llo/dev/v31/observation.go index 0bfe164..18d7323 100644 --- a/llo/dev/v31/observation.go +++ b/llo/dev/v31/observation.go @@ -53,6 +53,14 @@ const observationWireVersion byte = 1 // can force per round; see also maxObservationDecompressedBytes. const maxObservationBlobHandles = 4 +// maxObservationUpdateChannelDefinitionsBytes bounds the encoded size of the +// channel definitions one observation votes to add or change. Stream values +// travel in a blob, so these votes are the only part of the observation that +// can approach ocr3_1types.MaxMaxObservationBytes: five definitions at the +// admission caps encode to more than it. The remainder covers the other fields, +// whose worst case TestLimits_ObservationWorstCaseFitsDeclared measures. +const maxObservationUpdateChannelDefinitionsBytes = 384 << 10 + // maxObservationDecompressedBytes bounds the TOTAL decompressed blob bytes a // single observation may cause, across all of its handles. Without it, the // per-blob cap alone would let one peer force diff --git a/llo/dev/v31/plugin.go b/llo/dev/v31/plugin.go index 7241508..e79a8c3 100644 --- a/llo/dev/v31/plugin.go +++ b/llo/dev/v31/plugin.go @@ -19,6 +19,7 @@ import ( "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3types" ocrtypes "github.com/smartcontractkit/libocr/offchainreporting2plus/types" "github.com/smartcontractkit/libocr/quorumhelper" + "google.golang.org/protobuf/proto" ) // Config holds v31 plugin behavior toggles. @@ -378,20 +379,36 @@ func (p *Plugin) voteOnChannels(obs *Observation, state *kvState) { obs.RemoveChannelIDs[channelID] = struct{}{} } - obs.UpdateChannelDefinitions = make(llotypes.ChannelDefinitions) - expectedChannelIDs := maps.Keys(expectedChannelDefs) + obs.UpdateChannelDefinitions = channelUpdateVotes(state.channelDefinitions, expectedChannelDefs) +} + +// channelUpdateVotes returns the channels in expected that are new or changed +// against committed, in channel ID order, up to +// MaxObservationUpdateChannelDefinitionsLength channels and +// maxObservationUpdateChannelDefinitionsBytes encoded bytes. Admission bounds +// a single definition well below the byte budget, so the first change always +// fits, and the rest are voted on in later rounds. +func channelUpdateVotes(committed, expected llotypes.ChannelDefinitions) llotypes.ChannelDefinitions { + updates := make(llotypes.ChannelDefinitions) + expectedChannelIDs := maps.Keys(expected) sortChannelIDs(expectedChannelIDs) + size := 0 for _, channelID := range expectedChannelIDs { - prev, exists := state.channelDefinitions[channelID] - channelDefinition := expectedChannelDefs[channelID] + prev, exists := committed[channelID] + channelDefinition := expected[channelID] if exists && prev.Equals(channelDefinition) { continue } - obs.UpdateChannelDefinitions[channelID] = channelDefinition - if len(obs.UpdateChannelDefinitions) >= protocol.MaxObservationUpdateChannelDefinitionsLength { + size += proto.Size(protocol.ChannelDefinitionToProto(channelDefinition)) + if size > maxObservationUpdateChannelDefinitionsBytes { + break + } + updates[channelID] = channelDefinition + if len(updates) >= protocol.MaxObservationUpdateChannelDefinitionsLength { break } } + return updates } // ValidateObservation checks an observation is well-formed. Blob-referenced diff --git a/llo/protocol/limits.go b/llo/protocol/limits.go index 7ab40e2..6dd9f9a 100644 --- a/llo/protocol/limits.go +++ b/llo/protocol/limits.go @@ -294,6 +294,10 @@ const ( // MaxObservationUpdateChannelDefinitionsLength channel definitions of up // to MaxStreamsPerChannel streams each, which lands in the low single-digit // MiB range. 16 MiB leaves generous headroom. + // + // This bounds the v3.0 observation and the v3.1 stream-values blob. The + // v3.1 observation itself is not compressed and is bounded by the libocr + // observation limit instead (see the v3.1 PluginFactory). MaxDecompressedObservationLength = 16 << 20 // MaxPersistedAggregates bounds how many (streamID, aggregator) pairs may From 2b0133997db4fe78fece124363da9217493d07cc Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 12:50:04 +0100 Subject: [PATCH 021/103] llo/protocol: hash channel definitions through the unexported deterministic marshaller --- llo/protocol/channel_hash.go | 13 ++----------- llo/protocol/channel_hash_test.go | 18 ++++++++++++++++++ llo/protocol/stream_history.go | 6 +++--- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/llo/protocol/channel_hash.go b/llo/protocol/channel_hash.go index fd1077e..ea48408 100644 --- a/llo/protocol/channel_hash.go +++ b/llo/protocol/channel_hash.go @@ -1,15 +1,6 @@ package protocol -import ( - "crypto/sha256" - - "google.golang.org/protobuf/proto" -) - -// DeterministicMarshal marshals a proto message deterministically. Anything -// whose bytes must agree across oracles has to go through this rather than -// plain proto.Marshal, whose map and unknown-field ordering is unspecified. -var DeterministicMarshal = proto.MarshalOptions{Deterministic: true} +import "crypto/sha256" // ChannelHashV2 is the channel definition identity that consensus votes are // counted under from LLO protocol version 2 onwards, and the only channel hash @@ -23,7 +14,7 @@ func ChannelHashV2(cd ChannelDefinitionWithID) ChannelHash { ChannelID: cd.ChannelID, ChannelDefinition: ChannelDefinitionToProto(cd.ChannelDefinition), } - b, err := DeterministicMarshal.Marshal(pb) + b, err := deterministicMarshal.Marshal(pb) if err != nil { // Marshaling a well-formed definition cannot fail; hash empty on the // impossible error path rather than panicking. diff --git a/llo/protocol/channel_hash_test.go b/llo/protocol/channel_hash_test.go index 814a019..b4f9f9e 100644 --- a/llo/protocol/channel_hash_test.go +++ b/llo/protocol/channel_hash_test.go @@ -24,6 +24,24 @@ func Test_ChannelHashV2(t *testing.T) { // and any v3.0 instance running protocol version 2. It is a protocol // change, not a refactor. assert.Equal(t, "f0c21393ace2c693200c2a1a61ab915b767a0964263ccea2a86e124a664f9904", fmt.Sprintf("%x", ChannelHashV2(base))) + + // Set every field non-zero so the encoding of each one is pinned, not + // only the fields base happens to populate. + full := ChannelDefinitionWithID{ + ChannelID: 4294967295, + ChannelDefinition: llotypes.ChannelDefinition{ + ReportFormat: llotypes.ReportFormat(7), + Streams: []llotypes.Stream{ + {StreamID: 3, Aggregator: llotypes.AggregatorQuote}, + {StreamID: 1, Aggregator: llotypes.AggregatorMode}, + }, + Opts: []byte(`{"feedID":"0x01","multiplier":"1000000000000000000"}`), + Tombstone: true, + Source: 2, + DisableNilStreamValues: true, + }, + } + assert.Equal(t, "7453e6ac62c0a38ec6f08b5e86adbf3450491be64582ceb9edaf0bbe3213c2d5", fmt.Sprintf("%x", ChannelHashV2(full))) }) t.Run("commits to every field Equals compares", func(t *testing.T) { diff --git a/llo/protocol/stream_history.go b/llo/protocol/stream_history.go index 9554149..63c994e 100644 --- a/llo/protocol/stream_history.go +++ b/llo/protocol/stream_history.go @@ -28,9 +28,9 @@ var ( ) // deterministicMarshal is the marshaller for anything whose bytes are compared -// across oracles: replicated KeyValueState (history headers and chunks), and the -// serialized stream values ModeAggregator counts to pick a mode. A -// non-deterministic encoding there would halt the DON. +// across oracles: channel definition hashes, replicated KeyValueState (history +// headers and chunks), and the serialized stream values ModeAggregator counts +// to pick a mode. A non-deterministic encoding there would halt the DON. // // None of the messages marshalled through it has a map field today, and // Deterministic only orders map entries, so it is currently a no-op. It is From 62373941e0955516b4e28faf240abc50ccca284f Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 14:02:02 +0100 Subject: [PATCH 022/103] llo/protocol: decode calculated stream opts through the opts cache decoder --- llo/protocol/calculated_streams.go | 5 +- llo/protocol/calculated_streams_test.go | 15 ++++ llo/protocol/channel_definitions_test.go | 2 +- llo/protocol/opts_cache.go | 87 +++++++++++++++++-- llo/protocol/opts_cache_test.go | 28 ++++++ ...port_codec_evm_abi_encode_unpacked_expr.go | 6 ++ ...codec_evm_abi_encode_unpacked_expr_test.go | 18 ++++ 7 files changed, 151 insertions(+), 10 deletions(-) diff --git a/llo/protocol/calculated_streams.go b/llo/protocol/calculated_streams.go index 9d53f7f..d2b6491 100644 --- a/llo/protocol/calculated_streams.go +++ b/llo/protocol/calculated_streams.go @@ -1,7 +1,6 @@ package protocol import ( - "encoding/json" "errors" "fmt" @@ -44,8 +43,8 @@ func DecodeCalculatedStreamOpts(optsCache *OptsCache, cd llotypes.ChannelDefinit o, err = GetOpts[CalculatedStreamOpts](optsCache, cid) } if optsCache == nil || err != nil { - o = CalculatedStreamOpts{} - if uerr := json.Unmarshal(cd.Opts, &o); uerr != nil { + var uerr error + if o, uerr = decodeOpts[CalculatedStreamOpts](cd.Opts); uerr != nil { return o, fmt.Errorf("failed to decode calculated stream opts: %w", uerr) } } diff --git a/llo/protocol/calculated_streams_test.go b/llo/protocol/calculated_streams_test.go index a59a89e..db530e0 100644 --- a/llo/protocol/calculated_streams_test.go +++ b/llo/protocol/calculated_streams_test.go @@ -169,3 +169,18 @@ func TestHasCalculatedStreams(t *testing.T) { Streams: []llotypes.Stream{{StreamID: 1, Aggregator: llotypes.AggregatorCalculated}}, })) } + +func TestDecodeCalculatedStreamOpts_CacheAndFallbackAgree(t *testing.T) { + // encoding/json matches "expreſsion" (U+017F) to the expression field + // under Unicode case folding and goccy/go-json does not, so a decoder + // mismatch between the paths reads a different expression and stream ID. + cd := exprChannel(`{"abi":[{"type":"int256","expression":"s1","expreſsion":"s2",`+ + `"expressionStreamID":998,"expreſſionStreamID":999}]}`, observedStreams()) + cache := newSealedOptsCache(llotypes.ChannelDefinitions{1: cd}) + + fromCache, err := DecodeCalculatedStreamOpts(cache, cd, 1) + require.NoError(t, err) + fromDefinition, err := DecodeCalculatedStreamOpts(nil, cd, 1) + require.NoError(t, err) + require.Equal(t, fromCache, fromDefinition) +} diff --git a/llo/protocol/channel_definitions_test.go b/llo/protocol/channel_definitions_test.go index abf0e09..54ef2d0 100644 --- a/llo/protocol/channel_definitions_test.go +++ b/llo/protocol/channel_definitions_test.go @@ -271,7 +271,7 @@ func Test_VerifyChannelDefinitions_CalculatedStreamCollisions(t *testing.T) { }) t.Run("fails for undecodable, empty, and zero-ID expression opts", func(t *testing.T) { for opts, want := range map[string]string{ - `not json`: "invalid ChannelDefinition with ID 1: failed to decode calculated stream opts: invalid character 'o' in literal null (expecting 'u')", + `not json`: "invalid ChannelDefinition with ID 1: failed to decode calculated stream opts: json: invalid character o as null", `{}`: "invalid ChannelDefinition with ID 1: no expressions found in channel definition", `{"abi":[{"expressionStreamID":0}]}`: "invalid ChannelDefinition with ID 1: expression stream ID is 0, abi index: 0", } { diff --git a/llo/protocol/opts_cache.go b/llo/protocol/opts_cache.go index 7a9f31b..fa0d20f 100644 --- a/llo/protocol/opts_cache.go +++ b/llo/protocol/opts_cache.go @@ -3,8 +3,11 @@ package protocol import ( "bytes" "fmt" + "io" "reflect" + "strings" sync "sync" + "unicode/utf8" "github.com/goccy/go-json" @@ -149,7 +152,7 @@ func (c *OptsCache) ResetTo(channelDefinitions llotypes.ChannelDefinitions) { // GetOpts returns decoded channel opts of type T for the given channel. // On the first call for a given (channelID, T) after Set, the raw bytes are -// decoded via json.Unmarshal and the result is cached. Subsequent calls return +// decoded via decodeOpts and the result is cached. Subsequent calls return // the cached value directly. // // The returned value shares its slices, maps and pointers with the cache and @@ -177,13 +180,85 @@ func GetOpts[T any](c *OptsCache, channelID llotypes.ChannelID) (T, error) { return zero, fmt.Errorf("channel %d not in opts cache", channelID) } - var result T - if len(raw) > 0 { - if err := json.Unmarshal(raw, &result); err != nil { - return zero, fmt.Errorf("failed to decode opts for channel %d: %w", channelID, err) - } + result, err := decodeOpts[T](raw) + if err != nil { + return zero, fmt.Errorf("failed to decode opts for channel %d: %w", channelID, err) } c.decoded[key] = result return result, nil } + +// decodeOpts is the single decode of raw channel opts. The cache and every +// cache miss fallback go through it, so a definition reads the same whichever +// path decodes it. JSON libraries disagree on some inputs, such as keys that +// only match a field under Unicode case folding. +func decodeOpts[T any](raw []byte) (T, error) { + var result T + if len(raw) == 0 { + return result, nil + } + err := json.Unmarshal(raw, &result) + return result, err +} + +// VerifyOptsKeys rejects opts whose object keys, at any depth, are not ASCII or +// repeat under ASCII case folding. JSON decoders resolve such keys differently, +// so the same bytes could decode to different values in different places. +func VerifyOptsKeys(raw []byte) error { + type object struct { + seen map[string]struct{} + expectKey bool + } + var stack []*object + + dec := json.NewDecoder(bytes.NewReader(raw)) + for { + tok, err := dec.Token() + if err == io.EOF { + if len(stack) > 0 { + return fmt.Errorf("failed to scan opts: %w", io.ErrUnexpectedEOF) + } + return nil + } + if err != nil { + return fmt.Errorf("failed to scan opts: %w", err) + } + + var top *object + if len(stack) > 0 { + top = stack[len(stack)-1] + } + + if key, ok := tok.(string); ok && top != nil && top.expectKey { + for i := 0; i < len(key); i++ { + if key[i] >= utf8.RuneSelf { + return fmt.Errorf("opts key %q is not ASCII", key) + } + } + folded := strings.ToLower(key) + if _, dup := top.seen[folded]; dup { + return fmt.Errorf("opts key %q is repeated", key) + } + top.seen[folded] = struct{}{} + top.expectKey = false + continue + } + + switch tok { + case json.Delim('}'), json.Delim(']'): + stack = stack[:len(stack)-1] + continue + } + // Any other token starts a value, which completes the pending key. + if top != nil && top.seen != nil { + top.expectKey = true + } + switch tok { + case json.Delim('{'): + stack = append(stack, &object{seen: map[string]struct{}{}, expectKey: true}) + case json.Delim('['): + stack = append(stack, &object{}) + } + } +} diff --git a/llo/protocol/opts_cache_test.go b/llo/protocol/opts_cache_test.go index 6474580..5102a31 100644 --- a/llo/protocol/opts_cache_test.go +++ b/llo/protocol/opts_cache_test.go @@ -373,3 +373,31 @@ func TestOptsCache_SyncTo(t *testing.T) { assert.Empty(t, cache.decoded) }) } + +func TestVerifyOptsKeys(t *testing.T) { + for _, tc := range []struct { + name string + raw string + err string + }{ + {name: "empty", raw: ``}, + {name: "canonical", raw: `{"abi":[{"type":"int256","expression":"s1","expressionStreamID":1}],"feedID":"0x1"}`}, + {name: "repeated values are not keys", raw: `{"a":"a","b":["a","a"],"c":{"a":"a"}}`}, + {name: "same key in sibling objects", raw: `{"abi":[{"type":"a"},{"type":"b"}]}`}, + {name: "non-ASCII key", raw: `{"abi":[{"expreſsion":"s1"}]}`, err: "is not ASCII"}, + {name: "escaped non-ASCII key", raw: `{"abi":[{"expreſsion":"s1"}]}`, err: "is not ASCII"}, + {name: "repeated key", raw: `{"abi":[],"abi":[]}`, err: "is repeated"}, + {name: "key repeated under case folding", raw: `{"abi":[{"expression":"s1","EXPRESSION":"s2"}]}`, err: "is repeated"}, + {name: "repeated key after nested value", raw: `{"a":{"b":1},"A":2}`, err: "is repeated"}, + {name: "malformed", raw: `{"abi":`, err: "failed to scan opts"}, + } { + t.Run(tc.name, func(t *testing.T) { + err := VerifyOptsKeys([]byte(tc.raw)) + if tc.err == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tc.err) + }) + } +} diff --git a/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr.go b/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr.go index 7b023ee..d85073f 100644 --- a/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr.go +++ b/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr.go @@ -124,7 +124,13 @@ func (r ReportCodecEVMABIEncodeUnpackedExpr) Verify(cd llotypes.ChannelDefinitio // // nil opts cache: the definition is given directly, so the opts are decoded from // it rather than looked up. +// +// Opts keys must be canonical: the expressions and stream IDs admission checks +// must be the ones every decoder of these bytes reads. func (r ReportCodecEVMABIEncodeUnpackedExpr) VerifyForAdmission(cd llotypes.ChannelDefinition) error { + if err := protocol.VerifyOptsKeys(cd.Opts); err != nil { + return fmt.Errorf("invalid Opts: %w", err) + } if err := calculated.ValidateChannelExpressions(nil, cd, 0); err != nil { return fmt.Errorf("invalid calculated stream expressions: %w", err) } diff --git a/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr_test.go b/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr_test.go index ae62609..8ccb3a9 100644 --- a/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr_test.go +++ b/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr_test.go @@ -514,6 +514,24 @@ func TestReportCodecEVMABIEncodeUnpackedExpr_Verify(t *testing.T) { require.NoError(t, c.Verify(cd)) require.Error(t, c.VerifyForAdmission(cd)) }) + t.Run("non-canonical opts keys are rejected", func(t *testing.T) { + // "expreſſionStreamID" (U+017F) matches the stream ID field in some + // JSON decoders and not others, so admission could check an ID other + // than the one evaluation publishes. + cd := llotypes.ChannelDefinition{ + Streams: []llotypes.Stream{ + {StreamID: 1, Aggregator: llotypes.AggregatorMedian}, + {StreamID: 2, Aggregator: llotypes.AggregatorMedian}, + {StreamID: 3, Aggregator: llotypes.AggregatorMedian}, + }, + ReportFormat: llotypes.ReportFormatEVMABIEncodeUnpackedExpr, + Opts: []byte(`{"baseUSDFee":"1","feedID":"0x1111111111111111111111111111111111111111111111111111111111111111","ABI":[{"type":"int192","expression":"Add(s1, s2)","expressionStreamID":999,"expreſſionStreamID":998}]}`), + } + require.NoError(t, c.Verify(cd)) + err := c.VerifyForAdmission(cd) + require.Error(t, err) + assert.Contains(t, err.Error(), "is not ASCII") + }) t.Run("ambiguous stream aggregation is rejected", func(t *testing.T) { // Which aggregation a History call means would be undecidable. cd := llotypes.ChannelDefinition{ From 14d291448de2a62fb2397fb02dea387a025198ff Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 14:09:33 +0100 Subject: [PATCH 023/103] llo/protocol: return ErrNilStreamValue for a nil inner timestamped stream value --- llo/protocol/aggregators.go | 2 +- llo/protocol/aggregators_test.go | 15 +++++++++++++++ llo/protocol/stream_value.go | 6 +++--- llo/protocol/stream_value_test.go | 17 +++++++++++++++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/llo/protocol/aggregators.go b/llo/protocol/aggregators.go index 25415b8..d622cc7 100644 --- a/llo/protocol/aggregators.go +++ b/llo/protocol/aggregators.go @@ -61,7 +61,7 @@ func medianAggregator(values []StreamValue, minContributions int, countSkippedTi if !is { return nil, fmt.Errorf("invariant violation, expected TimestampedStreamValue, got %T", value) } - if v.StreamValue.Type() != LLOStreamValue_Decimal { + if v == nil || v.StreamValue == nil || v.StreamValue.Type() != LLOStreamValue_Decimal { // Only decimal is allowed for now, for simplicity. // // NOTE: This could get into an infinite loop if a malicious node sends diff --git a/llo/protocol/aggregators_test.go b/llo/protocol/aggregators_test.go index e9023be..3d4b742 100644 --- a/llo/protocol/aggregators_test.go +++ b/llo/protocol/aggregators_test.go @@ -103,6 +103,21 @@ func Test_MedianAggregator(t *testing.T) { }) }) + t.Run("for *TimestampedStreamValue skips nil values", func(t *testing.T) { + nilValues := []StreamValue{ + &TimestampedStreamValue{ObservedAtNanoseconds: 100, StreamValue: ToDecimal(decimal.NewFromFloat(2.6))}, + &TimestampedStreamValue{ObservedAtNanoseconds: 102, StreamValue: ToDecimal(decimal.NewFromFloat(4.6))}, + &TimestampedStreamValue{ObservedAtNanoseconds: 104, StreamValue: ToDecimal(decimal.NewFromFloat(6.6))}, + &TimestampedStreamValue{ObservedAtNanoseconds: 999}, + (*TimestampedStreamValue)(nil), + } + + sv, err := MedianAggregatorV31(nilValues, 3) + require.NoError(t, err) + assert.Equal(t, "4.6", sv.(*TimestampedStreamValue).StreamValue.(*Decimal).String()) + assert.Equal(t, uint64(102), sv.(*TimestampedStreamValue).ObservedAtNanoseconds) + }) + t.Run("fails below the contribution floor", func(t *testing.T) { _, err := MedianAggregator(values[:2], 4) require.EqualError(t, err, "not enough contributions to calculate median: got 2, need 4") diff --git a/llo/protocol/stream_value.go b/llo/protocol/stream_value.go index 2232d31..8212475 100644 --- a/llo/protocol/stream_value.go +++ b/llo/protocol/stream_value.go @@ -17,11 +17,11 @@ import ( type StreamValue interface { // Binary marshaler/unmarshaler used for protobufs - // Unmarshal should NOT panic on nil receiver, but instead return ErrNilStreamValue + // Marshal and Unmarshal should NOT panic on nil receiver, but instead return ErrNilStreamValue encoding.BinaryMarshaler encoding.BinaryUnmarshaler // TextMarshaler needed for JSON serialization and logging - // Unmarshal should NOT panic on nil receiver, but instead return ErrNilStreamValue + // Marshal and Unmarshal should NOT panic on nil receiver, but instead return ErrNilStreamValue encoding.TextMarshaler encoding.TextUnmarshaler // Type is needed for proto serialization so we know how to unserialize it @@ -463,7 +463,7 @@ func (v *TimestampedStreamValue) unmarshalBinary(data []byte, depth int) error { } func (v *TimestampedStreamValue) MarshalText() ([]byte, error) { - if v == nil { + if v == nil || v.StreamValue == nil { return nil, ErrNilStreamValue } serializedSv, err := v.StreamValue.MarshalText() diff --git a/llo/protocol/stream_value_test.go b/llo/protocol/stream_value_test.go index b374279..4b20529 100644 --- a/llo/protocol/stream_value_test.go +++ b/llo/protocol/stream_value_test.go @@ -27,6 +27,23 @@ func Test_TimestampedStreamValue_MarshalBinary(t *testing.T) { require.Equal(t, sv, sv2) } +func Test_TimestampedStreamValue_NilInnerValue(t *testing.T) { + sv := &TimestampedStreamValue{ObservedAtNanoseconds: 123} + + t.Run("MarshalBinary", func(t *testing.T) { + _, err := sv.MarshalBinary() + require.ErrorIs(t, err, ErrNilStreamValue) + }) + t.Run("MarshalText", func(t *testing.T) { + _, err := sv.MarshalText() + require.ErrorIs(t, err, ErrNilStreamValue) + }) + t.Run("NewTypedTextStreamValue", func(t *testing.T) { + _, err := NewTypedTextStreamValue(sv) + require.ErrorIs(t, err, ErrNilStreamValue) + }) +} + func Test_Decimal_ExponentBound(t *testing.T) { // A decimal with an unbounded exponent is only 6 bytes on the wire but // costs hundreds of MB to compare during aggregation, so it must be From 2f2313f1e1baaa77539efcd3c59fb1bd2fe80dd9 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 14:18:03 +0100 Subject: [PATCH 024/103] llo/protocol: make OptsCache.SyncTo a no-op on a sealed cache --- llo/protocol/opts_cache.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/llo/protocol/opts_cache.go b/llo/protocol/opts_cache.go index fa0d20f..9886956 100644 --- a/llo/protocol/opts_cache.go +++ b/llo/protocol/opts_cache.go @@ -116,6 +116,10 @@ func (c *OptsCache) SyncTo(channelDefinitions llotypes.ChannelDefinitions) { c.mu.Lock() defer c.mu.Unlock() + if c.sealed { + return + } + for channelID, cd := range channelDefinitions { if existing, ok := c.raw[channelID]; ok && bytes.Equal(existing, cd.Opts) { continue From 938339a5f4af90fa92339a4873dc989d4a76da90 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 14:27:07 +0100 Subject: [PATCH 025/103] llo/protocol: document the reachable history record worst case --- llo/protocol/limits.go | 26 +++++++++++++++----------- llo/protocol/stream_value.go | 6 ++++++ 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/llo/protocol/limits.go b/llo/protocol/limits.go index 6dd9f9a..9f3122a 100644 --- a/llo/protocol/limits.go +++ b/llo/protocol/limits.go @@ -54,13 +54,17 @@ const ( // exponent can still carry an arbitrarily long coefficient and be // arbitrarily large on the wire. // - // 192 bits is 58 decimal digits, derived from MaxHistoryRecordBytes - // A timestamped quote, the largest shape a stream value takes carrying - // three coefficients at this bound measures 125B as a history record, - // the most that fits the 128B per-record limit. - // One step up (224 bits) measures 137 B, which observation decode would accept - // and history would then refuse, leaving a gap in the series for a value the round agreed on. - // Asserted by TestDecimalCoefficientBoundFitsHistoryRecord. + // 192 bits is 58 decimal digits, derived from MaxHistoryRecordBytes. A + // timestamped quote, the largest shape decode admits at one nesting level, + // carrying three coefficients at this bound measures 125 B as a history + // record, the most that fits the 128 B per-record limit. One step up + // (224 bits) measures 137 B, which observation decode would accept and + // history would then refuse, leaving a gap in the series for a value the + // round agreed on. Asserted by TestDecimalCoefficientBoundFitsHistoryRecord. + // + // The timestamped quote is a conservative worst case. v31 observation + // validation only admits a timestamped value wrapping a Decimal, so the + // largest value that reaches history is a quote at this bound, 109 B. // // For scale, the fixtures in the size table below are 57 bits (an 18-digit // price) and 124 bits (38 digits), so this is ~3x and ~1.5x those. @@ -295,13 +299,13 @@ const ( // to MaxStreamsPerChannel streams each, which lands in the low single-digit // MiB range. 16 MiB leaves generous headroom. // - // This bounds the v3.0 observation and the v3.1 stream-values blob. The - // v3.1 observation itself is not compressed and is bounded by the libocr - // observation limit instead (see the v3.1 PluginFactory). + // This bounds the v30 observation and the v31 stream-values blob. The + // v31 observation itself is not compressed and is bounded by the libocr + // observation limit instead (see the v31 PluginFactory). MaxDecompressedObservationLength = 16 << 20 // MaxPersistedAggregates bounds how many (streamID, aggregator) pairs may - // carry a timestamped aggregate forward across rounds in the v3.1 r/agg + // carry a timestamped aggregate forward across rounds in the v31 r/agg // record. MaxPersistedAggregates = 2 * MaxObservationStreamValuesLength diff --git a/llo/protocol/stream_value.go b/llo/protocol/stream_value.go index 8212475..c934d62 100644 --- a/llo/protocol/stream_value.go +++ b/llo/protocol/stream_value.go @@ -60,6 +60,12 @@ var ( // coordinated across versions. // // Bounding observations bounds everything written from them going forward. +// +// The bounds apply per decimal, not per shape. Up to MaxStreamValueNesting +// timestamped levels decode here, and past one level a value at the +// coefficient bound outgrows MaxHistoryRecordBytes. Callers rely on the +// plugin's observation validation to restrict which shapes are admitted (see +// v31 Plugin.ValidateObservation). func UnmarshalObservedProtoStreamValue(enc *LLOStreamValue) (StreamValue, error) { sv, err := UnmarshalProtoStreamValue(enc) if err != nil { From e47fb30acd01e77c59021bf6e8d371f4bb7d5ea9 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 14:39:19 +0100 Subject: [PATCH 026/103] llo/protocol: document why SubtractChannelDefinitions keeps the lowest IDs --- llo/protocol/channel_definitions.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/llo/protocol/channel_definitions.go b/llo/protocol/channel_definitions.go index 22da305..3c2e0eb 100644 --- a/llo/protocol/channel_definitions.go +++ b/llo/protocol/channel_definitions.go @@ -428,6 +428,14 @@ func sortedStreamIDs(m map[llotypes.StreamID]llotypes.ChannelID) []llotypes.Stre return ids } +// SubtractChannelDefinitions returns the channels in minuend that are absent +// from subtrahend, keeping the limit lowest channel IDs. +// +// Keep the lowest IDs rather than rotating across rounds: a removal needs +// matching votes, and a fixed prefix maximizes the overlap between oracles +// whose desired sets differ slightly. Fairness is not a goal. Removed channels +// leave the committed set, so the next round reaches the following IDs, and +// only the channel definitions owner can refill the prefix. func SubtractChannelDefinitions(minuend llotypes.ChannelDefinitions, subtrahend llotypes.ChannelDefinitions, limit int) llotypes.ChannelDefinitions { differenceList := []ChannelDefinitionWithID{} for channelID, channelDefinition := range minuend { From 34962ad5bdbb31ca5c637dd8368a047c77c6f6cd Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 14:50:42 +0100 Subject: [PATCH 027/103] llo/protocol: bound retirement report channel count on decode --- llo/protocol/limits.go | 7 +++++++ llo/protocol/retirement_report_codec.go | 11 ++++++++-- llo/protocol/retirement_report_codec_test.go | 22 ++++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/llo/protocol/limits.go b/llo/protocol/limits.go index 9f3122a..400bf86 100644 --- a/llo/protocol/limits.go +++ b/llo/protocol/limits.go @@ -83,6 +83,13 @@ const ( // can be supported MaxOutcomeChannelDefinitionsLength = MaxReportCount + // MaxRetirementReportChannels bounds the ValidAfterNanoseconds entries a + // decoded retirement report may carry. v30 keeps a removed channel's + // watermark until an explicit removal vote, so an honest report can exceed + // the live channel count. Twice the definitions cap leaves room for that + // without rejecting a legitimate handover. + MaxRetirementReportChannels = 2 * MaxOutcomeChannelDefinitionsLength + // MaxTotalStreamEntries bounds the sum of len(cd.Streams) over the whole // definition set. // diff --git a/llo/protocol/retirement_report_codec.go b/llo/protocol/retirement_report_codec.go index 7abbea6..5959e28 100644 --- a/llo/protocol/retirement_report_codec.go +++ b/llo/protocol/retirement_report_codec.go @@ -1,6 +1,8 @@ package protocol import ( + "fmt" + "github.com/goccy/go-json" ) @@ -19,6 +21,11 @@ func (r StandardRetirementReportCodec) Encode(report RetirementReport) ([]byte, func (r StandardRetirementReportCodec) Decode(data []byte) (RetirementReport, error) { var report RetirementReport - err := json.Unmarshal(data, &report) - return report, err + if err := json.Unmarshal(data, &report); err != nil { + return RetirementReport{}, err + } + if n := len(report.ValidAfterNanoseconds); n > MaxRetirementReportChannels { + return RetirementReport{}, fmt.Errorf("retirement report has %d channels, max %d", n, MaxRetirementReportChannels) + } + return report, nil } diff --git a/llo/protocol/retirement_report_codec_test.go b/llo/protocol/retirement_report_codec_test.go index 67f8193..3398120 100644 --- a/llo/protocol/retirement_report_codec_test.go +++ b/llo/protocol/retirement_report_codec_test.go @@ -1,6 +1,7 @@ package protocol import ( + "fmt" "testing" "time" @@ -33,4 +34,25 @@ func Test_StandardRetirementReportCodec(t *testing.T) { require.Equal(t, rr, decoded) }) + + reportWithChannels := func(n int) []byte { + rr := RetirementReport{ProtocolVersion: 1, ValidAfterNanoseconds: make(map[llotypes.ChannelID]uint64, n)} + for i := range n { + rr.ValidAfterNanoseconds[llotypes.ChannelID(i)] = uint64(i) + } + encoded, err := codec.Encode(rr) + require.NoError(t, err) + return encoded + } + + t.Run("decodes a report at MaxRetirementReportChannels", func(t *testing.T) { + decoded, err := codec.Decode(reportWithChannels(MaxRetirementReportChannels)) + require.NoError(t, err) + assert.Len(t, decoded.ValidAfterNanoseconds, MaxRetirementReportChannels) + }) + + t.Run("rejects a report over MaxRetirementReportChannels", func(t *testing.T) { + _, err := codec.Decode(reportWithChannels(MaxRetirementReportChannels + 1)) + require.ErrorContains(t, err, fmt.Sprintf("retirement report has %d channels, max %d", MaxRetirementReportChannels+1, MaxRetirementReportChannels)) + }) } From 5169a55186b395542a622d44f04e65ffa17f416d Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 15:26:13 +0100 Subject: [PATCH 028/103] llo/protocol: reject unbound identifiers at calculated channel admission Collect the free identifiers of each expression during History analysis and cache them with it. ValidateChannelExpressions now rejects any name that is not a default function, observations_timestamp, or a stream the channel observes, with ErrUnknownIdentifier. --- llo/protocol/calculated/calculated.go | 10 ++-- llo/protocol/calculated/history_ast.go | 47 +++++++++++---- llo/protocol/calculated/history_ast_test.go | 20 +++---- llo/protocol/calculated/program.go | 46 +++++++++++++-- llo/protocol/calculated/validation_test.go | 65 +++++++++++++++++++++ 5 files changed, 158 insertions(+), 30 deletions(-) diff --git a/llo/protocol/calculated/calculated.go b/llo/protocol/calculated/calculated.go index 9e26939..cfd5108 100644 --- a/llo/protocol/calculated/calculated.go +++ b/llo/protocol/calculated/calculated.go @@ -5,7 +5,6 @@ import ( "fmt" "math" "math/big" - "regexp" "runtime" "slices" "strconv" @@ -183,10 +182,13 @@ func (e environment) release() { pool.Put(e) } +// observationsTimestampName binds the round's consensus observation timestamp. +const observationsTimestampName = "observations_timestamp" + // NewEnv returns a new environment with the default functions func NewEnv(observationTimestampNanoseconds uint64) environment { env := pool.Get().(environment) - env["observations_timestamp"] = observationTimestampNanoseconds + env[observationsTimestampName] = observationTimestampNanoseconds // TWAP's window is anchored on the round's consensus observation timestamp, // not on the data, so it is bound per round. release() restores the default // binding, which fails if called. @@ -1264,7 +1266,7 @@ func (v *visitor) Visit(node *ast.Node) { } if n, ok := (*node).(*ast.IdentifierNode); ok { - match := streamMatch.FindStringSubmatch(n.Value) + match := streamIdentifier.FindStringSubmatch(n.Value) if len(match) > 0 { id, err := strconv.ParseUint(match[1], 10, 32) if err != nil { @@ -1276,5 +1278,3 @@ func (v *visitor) Visit(node *ast.Node) { } } } - -var streamMatch = regexp.MustCompile(`s(\d+)(?:_(bid|ask|benchmark|timestamp))?`) diff --git a/llo/protocol/calculated/history_ast.go b/llo/protocol/calculated/history_ast.go index b5462e7..37bd3d0 100644 --- a/llo/protocol/calculated/history_ast.go +++ b/llo/protocol/calculated/history_ast.go @@ -115,10 +115,10 @@ func (r HistoryRef) String() string { } var ( - // historyStreamArg matches the first argument of a History call. It is - // anchored, unlike streamMatch, because here the whole argument must be a - // stream identifier and nothing else. - historyStreamArg = regexp.MustCompile(`^s(\d+)(?:_(bid|ask|benchmark|timestamp))?$`) + // streamIdentifier matches an identifier naming a stream value, such as s1 + // or s1_bid. It is anchored so that a longer identifier merely containing + // one, such as abs1, is not mistaken for it. + streamIdentifier = regexp.MustCompile(`^s(\d+)(?:_(bid|ask|benchmark|timestamp))?$`) // reservedHistoryName matches the identifier namespace this pass generates. // Expressions may not use it directly: allowing that would let a source @@ -189,6 +189,11 @@ type historyPatcher struct { // maximum-length window, so their count is what bounds the bucket work an // expression can ask for every round. twapCalls int + + // names holds every source identifier, and declared the names bound by a + // let, so the identifiers the environment must provide can be recovered. + names map[string]bool + declared map[string]bool } func newHistoryPatcher() *historyPatcher { @@ -196,6 +201,8 @@ func newHistoryPatcher() *historyPatcher { generated: map[ast.Node]bool{}, approved: map[ast.Node]bool{}, refByNode: map[ast.Node]HistoryRef{}, + names: map[string]bool{}, + declared: map[string]bool{}, } } @@ -213,10 +220,14 @@ func newHistoryPatcher() *historyPatcher { func (p *historyPatcher) Visit(node *ast.Node) { switch n := (*node).(type) { case *ast.IdentifierNode: + p.names[n.Value] = true if reservedHistoryName.MatchString(n.Value) { p.errorf("identifier %q uses the reserved history namespace (__h); use %s(s, ) instead", n.Value, HistoryFunctionName) } + case *ast.VariableDeclaratorNode: + p.declared[n.Name] = true + case *ast.StringNode: // Strings that are not decimals (durations, configuration keys) are // left to the functions that take them. @@ -270,7 +281,7 @@ func (p *historyPatcher) rewrite(node *ast.Node, call *ast.CallNode) { p.errorf("%s: first argument must be a stream identifier such as s10001, got %s", HistoryFunctionName, describeNode(call.Arguments[0])) return } - match := historyStreamArg.FindStringSubmatch(streamArg.Value) + match := streamIdentifier.FindStringSubmatch(streamArg.Value) if match == nil { p.errorf("%s: first argument %q is not a stream identifier", HistoryFunctionName, streamArg.Value) return @@ -454,6 +465,21 @@ func (p *historyPatcher) errorf(format string, args ...any) { p.errs = append(p.errs, fmt.Errorf(format, args...)) } +// freeNames returns the identifiers the expression reads from its environment, +// sorted. A name bound by a let anywhere in the expression is excluded: scoping +// is left to the compiler, which rejects a use outside the binding the same way +// on every node. +func (p *historyPatcher) freeNames() []string { + names := make([]string, 0, len(p.names)) + for name := range p.names { + if !p.declared[name] { + names = append(names, name) + } + } + sort.Strings(names) + return names +} + // sortedRefs returns the recovered references deduplicated and in a // deterministic order, so callers deriving persisted state from them agree // across nodes. @@ -483,23 +509,24 @@ func (p *historyPatcher) sortedRefs() []HistoryRef { } // analyzeHistoryExpression parses an expression and recovers its History -// references and its TWAP call count without compiling it. It is a pure +// references, its TWAP call count and the names it reads from its environment +// without compiling it. It is a pure // function of the expression string, which is what lets every node derive the // same required depths and TWAP budget from replicated channel definitions. // // An expression with no History calls returns no references and no error. -func analyzeHistoryExpression(expression string) (refs []HistoryRef, twapCalls int, err error) { +func analyzeHistoryExpression(expression string) (refs []HistoryRef, twapCalls int, names []string, err error) { tree, err := parseExpression(expression) if err != nil { - return nil, 0, fmt.Errorf("%w: failed to parse expression: %s", ErrHistoryExpression, err) + return nil, 0, nil, fmt.Errorf("%w: failed to parse expression: %s", ErrHistoryExpression, err) } p := newHistoryPatcher() ast.Walk(&tree.Node, p) if err := p.err(); err != nil { - return nil, 0, err + return nil, 0, nil, err } - return p.sortedRefs(), p.twapCalls, nil + return p.sortedRefs(), p.twapCalls, p.freeNames(), nil } func describeNode(node ast.Node) string { diff --git a/llo/protocol/calculated/history_ast_test.go b/llo/protocol/calculated/history_ast_test.go index 0995f26..b13585f 100644 --- a/llo/protocol/calculated/history_ast_test.go +++ b/llo/protocol/calculated/history_ast_test.go @@ -110,7 +110,7 @@ func TestAnalyzeHistoryExpression_Accepts(t *testing.T) { } { t.Run(name, func(t *testing.T) { t.Parallel() - refs, _, err := analyzeHistoryExpression(tc.expression) + refs, _, _, err := analyzeHistoryExpression(tc.expression) require.NoError(t, err) assert.Equal(t, tc.want, refs) }) @@ -223,7 +223,7 @@ func TestAnalyzeHistoryExpression_Rejects(t *testing.T) { } { t.Run(name, func(t *testing.T) { t.Parallel() - refs, _, err := analyzeHistoryExpression(tc.expression) + refs, _, _, err := analyzeHistoryExpression(tc.expression) require.Error(t, err) require.ErrorIs(t, err, ErrHistoryExpression) assert.Contains(t, err.Error(), tc.wantErr) @@ -244,12 +244,12 @@ func TestAnalyzeHistoryExpression_FanOut(t *testing.T) { for i := range calls { within = append(within, fmt.Sprintf("Avg(History(s%d, %d))", i+1, perCall)) } - refs, _, err := analyzeHistoryExpression(strings.Join(within, " + ")) + refs, _, _, err := analyzeHistoryExpression(strings.Join(within, " + ")) require.NoError(t, err) assert.Len(t, refs, calls) over := append(within, fmt.Sprintf("Avg(History(s%d, 1))", calls+1)) - _, _, err = analyzeHistoryExpression(strings.Join(over, " + ")) + _, _, _, err = analyzeHistoryExpression(strings.Join(over, " + ")) require.ErrorIs(t, err, ErrHistoryExpression) assert.Contains(t, err.Error(), "total history depth") } @@ -260,7 +260,7 @@ func TestAnalyzeHistoryExpression_FanOut(t *testing.T) { func TestAnalyzeHistoryExpression_ReportsEveryProblem(t *testing.T) { t.Parallel() - _, _, err := analyzeHistoryExpression("Add(History(s1, 0), History(s2, x))") + _, _, _, err := analyzeHistoryExpression("Add(History(s1, 0), History(s2, x))") require.ErrorIs(t, err, ErrHistoryExpression) assert.Contains(t, err.Error(), "depth must be at least 1") assert.Contains(t, err.Error(), "depth must be an integer literal") @@ -273,20 +273,20 @@ func TestAnalyzeHistoryExpression_Deterministic(t *testing.T) { t.Parallel() expression := "Add(Avg(History(s7_bid, 10)), Add(Avg(History(s1, 300)), Avg(History(s7, 10))))" - first, _, err := analyzeHistoryExpression(expression) + first, _, _, err := analyzeHistoryExpression(expression) require.NoError(t, err) for range 20 { - again, _, err := analyzeHistoryExpression(expression) + again, _, _, err := analyzeHistoryExpression(expression) require.NoError(t, err) require.Equal(t, first, again) } errFirst := func() string { - _, _, err := analyzeHistoryExpression("Add(History(s1, 0), History(s2, x))") + _, _, _, err := analyzeHistoryExpression("Add(History(s1, 0), History(s2, x))") return err.Error() }() for range 20 { - _, _, err := analyzeHistoryExpression("Add(History(s1, 0), History(s2, x))") + _, _, _, err := analyzeHistoryExpression("Add(History(s1, 0), History(s2, x))") require.Equal(t, errFirst, err.Error(), "error text must be stable") } } @@ -451,7 +451,7 @@ func FuzzAnalyzeHistoryExpression(f *testing.F) { } f.Fuzz(func(t *testing.T, expression string) { - refs, _, err := analyzeHistoryExpression(expression) + refs, _, _, err := analyzeHistoryExpression(expression) if err != nil { require.Nil(t, refs, "a rejected expression must not yield references") return diff --git a/llo/protocol/calculated/program.go b/llo/protocol/calculated/program.go index 25a2435..41a9a47 100644 --- a/llo/protocol/calculated/program.go +++ b/llo/protocol/calculated/program.go @@ -4,6 +4,7 @@ import ( "container/list" "errors" "fmt" + "strconv" "sync" "github.com/expr-lang/expr/conf" @@ -56,6 +57,7 @@ type analysisEntry struct { expression string refs []HistoryRef twapCalls int + names []string err error // program is the compiled expression, filled in on first successful @@ -164,11 +166,21 @@ func ValidateChannelExpressions(optsCache *protocol.OptsCache, cd llotypes.Chann errs = append(errs, fmt.Errorf("%w: expression is empty", ErrHistoryExpression)) continue } - refs, err := AnalyzeExpressionHistory(expression) - if err != nil { - errs = append(errs, fmt.Errorf("expression %q: %w", expression, err)) + entry := historyAnalysisCache.entry(expression) + if entry.err != nil { + errs = append(errs, fmt.Errorf("expression %q: %w", expression, entry.err)) continue } + // Every name the expression reads must be bound at evaluation. A name + // that is not compiles against no channel environment, so the channel + // would be installed and fail to compile every round. + for _, name := range entry.names { + if !channelBindsName(name, aggByStream) { + errs = append(errs, fmt.Errorf("expression %q: %w: identifier %q is neither a function nor a stream the channel observes", + expression, ErrUnknownIdentifier, name)) + } + } + refs := entry.refs // A History call may only read a stream the channel observes. The DSL // names a stream, and the channel definition is what says which // aggregation of it is meant, so a reference to a stream that is not @@ -187,6 +199,30 @@ func ValidateChannelExpressions(optsCache *protocol.OptsCache, cd llotypes.Chann return errors.Join(errs...) } +// ErrUnknownIdentifier is returned for an expression that reads a name a +// channel's evaluation environment does not bind. +var ErrUnknownIdentifier = errors.New("unknown identifier") + +// channelBindsName reports whether evaluation binds name for a channel whose +// observed streams are aggByStream. It cannot check a stream identifier's +// field: s1_bid is only bound when s1 observes a quote, and the value type is +// not part of the definition. +func channelBindsName(name string, aggByStream map[llotypes.StreamID]llotypes.Aggregator) bool { + if _, ok := defaultEnv[name]; ok || name == observationsTimestampName { + return true + } + match := streamIdentifier.FindStringSubmatch(name) + if match == nil { + return false + } + id, err := strconv.ParseUint(match[1], 10, 32) + if err != nil { + return false + } + _, ok := aggByStream[llotypes.StreamID(id)] + return ok +} + func (c *analysisCache) analyze(expression string) ([]HistoryRef, error) { entry := c.entry(expression) return entry.refs, entry.err @@ -197,8 +233,8 @@ func (c *analysisCache) entry(expression string) *analysisEntry { return entry } - refs, twapCalls, err := analyzeHistoryExpression(expression) - return c.put(&analysisEntry{expression: expression, refs: refs, twapCalls: twapCalls, err: err}) + refs, twapCalls, names, err := analyzeHistoryExpression(expression) + return c.put(&analysisEntry{expression: expression, refs: refs, twapCalls: twapCalls, names: names, err: err}) } func (c *analysisCache) get(expression string) (*analysisEntry, bool) { diff --git a/llo/protocol/calculated/validation_test.go b/llo/protocol/calculated/validation_test.go index f5d4549..2a23b97 100644 --- a/llo/protocol/calculated/validation_test.go +++ b/llo/protocol/calculated/validation_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/expr-lang/expr/ast" "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -172,6 +173,58 @@ func TestValidateChannelExpressions(t *testing.T) { assert.Contains(t, err.Error(), "abi index: 1") } +// TestValidateChannelExpressions_Identifiers checks every name an expression +// reads is one evaluation binds. One that is not fails to compile every round, +// so the channel would never report. +func TestValidateChannelExpressions_Identifiers(t *testing.T) { + t.Parallel() + + cd := func(expression string) llotypes.ChannelDefinition { + return llotypes.ChannelDefinition{ + ReportFormat: llotypes.ReportFormatEVMABIEncodeUnpackedExpr, + Streams: []llotypes.Stream{ + {StreamID: 1, Aggregator: llotypes.AggregatorMedian}, + {StreamID: 2, Aggregator: llotypes.AggregatorMedian}, + {StreamID: 900, Aggregator: llotypes.AggregatorCalculated}, + }, + Opts: []byte(fmt.Sprintf(`{"abi":[{"type":"int256","expression":%q,"expressionStreamID":900}]}`, expression)), + } + } + + for _, expression := range []string{ + "Add(s1, s2)", + "Add(s1_bid, s2_timestamp)", + "Sub(observations_timestamp, s1_timestamp)", + `TWAP(History(s1, 3), {window: Duration("3s"), minSamples: 1, maxHeadGap: 3, maxInteriorGap: 3, maxTailGap: 3})`, + "let a = Mul(s1, s2); Add(a, 1)", + } { + t.Run("accepts "+expression, func(t *testing.T) { + t.Parallel() + require.NoError(t, ValidateChannelExpressions(nil, cd(expression), 1)) + }) + } + + for _, tc := range []struct { + expression string + identifier string + }{ + {"Add(s9999, s1)", "s9999"}, + {"Add(s9999_bid, s1)", "s9999_bid"}, + {"Add(s900, s1)", "s900"}, // a calculated stream is not bound + {"Add(foo, s1)", "foo"}, + {"Foo(s1)", "Foo"}, + {"Add(xs1, s1)", "xs1"}, + {"Add($env, s1)", "$env"}, + } { + t.Run("rejects "+tc.expression, func(t *testing.T) { + t.Parallel() + err := ValidateChannelExpressions(nil, cd(tc.expression), 1) + require.ErrorIs(t, err, ErrUnknownIdentifier) + assert.Contains(t, err.Error(), fmt.Sprintf("identifier %q", tc.identifier)) + }) + } +} + // TestProcessCalculatedStreamsDryRun_Satisfiability checks the offline path // rejects the same configurations the static analysis does. func TestProcessCalculatedStreamsDryRun_Satisfiability(t *testing.T) { @@ -376,3 +429,15 @@ func TestChannelTWAPCalls(t *testing.T) { Opts: []byte(`not json`), }, 1), "opts that do not decode declare no expressions") } + +// TestVisitor_StreamIdentifiers checks the dry run only synthesizes inputs for +// identifiers that are whole stream names, not ones that merely contain one. +func TestVisitor_StreamIdentifiers(t *testing.T) { + t.Parallel() + + tree, err := parseExpression("Add(Add(xs1, s2_bid), s3x)") + require.NoError(t, err) + v := &visitor{} + ast.Walk(&tree.Node, v) + assert.Equal(t, map[llotypes.StreamID]string{2: "bid"}, v.Identifiers) +} From b218d0762387fe5e0fb634ff23f42bcf2aae78cc Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 15:53:26 +0100 Subject: [PATCH 029/103] llo/protocol: reject single-record windows for comparison functions --- llo/protocol/calculated/functions_series.go | 8 ++--- .../calculated/functions_series_test.go | 14 ++++++++- llo/protocol/calculated/history_ast.go | 30 +++++++++++++++++++ llo/protocol/calculated/history_ast_test.go | 27 +++++++++++++++++ llo/protocol/calculated/series.go | 15 ++++++++++ 5 files changed, 89 insertions(+), 5 deletions(-) diff --git a/llo/protocol/calculated/functions_series.go b/llo/protocol/calculated/functions_series.go index da222f7..bdd2cf5 100644 --- a/llo/protocol/calculated/functions_series.go +++ b/llo/protocol/calculated/functions_series.go @@ -67,7 +67,7 @@ func Median(x any) (decimal.Decimal, error) { // Population rather than sample variance: the window is the whole series being // described, not a sample drawn from a larger one. func Variance(x any) (decimal.Decimal, error) { - series, err := window("Variance", x) + series, err := comparisonWindow("Variance", x) if err != nil { return decimal.Decimal{}, err } @@ -103,7 +103,7 @@ func Stddev(x any) (decimal.Decimal, error) { // Delta returns the change across a history window: newest minus oldest. func Delta(x any) (decimal.Decimal, error) { - series, err := window("Delta", x) + series, err := comparisonWindow("Delta", x) if err != nil { return decimal.Decimal{}, err } @@ -122,7 +122,7 @@ func Delta(x any) (decimal.Decimal, error) { // (newest - oldest) / oldest. It is a fraction, not a percentage: 0.05 is a 5% // rise. func PctChange(x any) (decimal.Decimal, error) { - series, err := window("PctChange", x) + series, err := comparisonWindow("PctChange", x) if err != nil { return decimal.Decimal{}, err } @@ -142,7 +142,7 @@ func PctChange(x any) (decimal.Decimal, error) { // Spread returns the range of a history window: maximum minus minimum. func Spread(x any) (decimal.Decimal, error) { - series, err := window("Spread", x) + series, err := comparisonWindow("Spread", x) if err != nil { return decimal.Decimal{}, err } diff --git a/llo/protocol/calculated/functions_series_test.go b/llo/protocol/calculated/functions_series_test.go index e42e2d0..6d23197 100644 --- a/llo/protocol/calculated/functions_series_test.go +++ b/llo/protocol/calculated/functions_series_test.go @@ -56,7 +56,6 @@ func TestSeriesFunctions_Golden(t *testing.T) { "Variance": {Variance, []string{"2", "4", "4", "4", "5", "5", "7", "9"}, "4"}, "Variance flat": {Variance, []string{"3", "3", "3"}, "0"}, "Stddev": {Stddev, []string{"2", "4", "4", "4", "5", "5", "7", "9"}, "2"}, - "Stddev single": {Stddev, []string{"9"}, "0"}, } { t.Run(name, func(t *testing.T) { t.Parallel() @@ -88,6 +87,19 @@ func TestSeriesFunctions_Errors(t *testing.T) { }) } + comparison := map[string]func(any) (decimal.Decimal, error){ + "Variance": Variance, "Stddev": Stddev, "Delta": Delta, + "PctChange": PctChange, "Spread": Spread, + } + for name, fn := range comparison { + t.Run(name+" rejects a single record", func(t *testing.T) { + t.Parallel() + _, err := fn(seriesOf(t, "9")) + require.ErrorContains(t, err, "needs at least 2") + require.NotErrorIs(t, err, ErrInsufficientHistory) + }) + } + t.Run("PctChange from zero is undefined", func(t *testing.T) { t.Parallel() _, err := PctChange(seriesOf(t, "0", "10")) diff --git a/llo/protocol/calculated/history_ast.go b/llo/protocol/calculated/history_ast.go index 37bd3d0..c3c1c2d 100644 --- a/llo/protocol/calculated/history_ast.go +++ b/llo/protocol/calculated/history_ast.go @@ -153,6 +153,17 @@ var ( "EMA": true, "TWAP": true, } + + // minWindowDepth is the smallest depth at which a window function compares + // more than one record. Below it the function is constant by construction, + // so the expression is rejected rather than left to publish that constant. + minWindowDepth = map[string]uint32{ + "Variance": 2, + "Stddev": 2, + "Delta": 2, + "PctChange": 2, + "Spread": 2, + } ) // ErrHistoryExpression is returned for any expression that misuses History. It @@ -263,6 +274,7 @@ func (p *historyPatcher) Visit(node *ast.Node) { if callee.Value == twapFunctionName { p.checkTWAP(n) } + p.checkWindowDepth(callee.Value, n) } } } @@ -389,6 +401,24 @@ func (p *historyPatcher) checkTWAP(call *ast.CallNode) { } } +// checkWindowDepth rejects a window shallower than the function needs to +// compare records. Only a direct History argument is checked: that is the only +// way a window reaches a function. +func (p *historyPatcher) checkWindowDepth(name string, call *ast.CallNode) { + minDepth, ok := minWindowDepth[name] + if !ok || len(call.Arguments) == 0 { + return + } + ref, ok := p.refByNode[call.Arguments[0]] + if !ok { + return + } + if ref.Count < minDepth { + p.errorf("%s requires a history depth of at least %d but %s only keeps %d records", + name, minDepth, ref, ref.Count) + } +} + // twapConfigLiteral reads an integer-literal value out of a configuration map // literal, reporting whether it was present and literal. func twapConfigLiteral(config *ast.MapNode, key string) (int64, bool) { diff --git a/llo/protocol/calculated/history_ast_test.go b/llo/protocol/calculated/history_ast_test.go index b13585f..67c3009 100644 --- a/llo/protocol/calculated/history_ast_test.go +++ b/llo/protocol/calculated/history_ast_test.go @@ -97,6 +97,10 @@ func TestAnalyzeHistoryExpression_Accepts(t *testing.T) { expression: "Avg(History(s1, 1))", want: []HistoryRef{{StreamID: 1, Field: FieldValue, Count: 1}}, }, + "minimum depth of a comparison function": { + expression: "Delta(History(s1, 2))", + want: []HistoryRef{{StreamID: 1, Field: FieldValue, Count: 2}}, + }, // The case a text-level rewriter gets wrong: only the parser knows this // is a string, not a call. "history inside a string literal is not a call": { @@ -220,6 +224,26 @@ func TestAnalyzeHistoryExpression_Rejects(t *testing.T) { expression: "Avg(History(s1, 10)", wantErr: "failed to parse expression", }, + "Delta over a single record": { + expression: "Delta(History(s1, 1))", + wantErr: "Delta requires a history depth of at least 2", + }, + "PctChange over a single record": { + expression: "PctChange(History(s1_bid, 1))", + wantErr: "PctChange requires a history depth of at least 2", + }, + "Variance over a single record": { + expression: "Variance(History(s1, 1))", + wantErr: "Variance requires a history depth of at least 2", + }, + "Stddev over a single record": { + expression: "Stddev(History(s1, 1))", + wantErr: "Stddev requires a history depth of at least 2", + }, + "Spread over a single record": { + expression: "Spread(History(s1, 1))", + wantErr: "Spread requires a history depth of at least 2", + }, } { t.Run(name, func(t *testing.T) { t.Parallel() @@ -494,4 +518,7 @@ func TestRangeAcceptingFunctionsAreRegistered(t *testing.T) { for name := range rangeAcceptingFunctions { assert.Contains(t, defaultEnv, name, "%s accepts a window but is not registered", name) } + for name := range minWindowDepth { + assert.True(t, rangeAcceptingFunctions[name], "%s has a minimum window depth but does not accept a window", name) + } } diff --git a/llo/protocol/calculated/series.go b/llo/protocol/calculated/series.go index e5ac10d..9d5fc7f 100644 --- a/llo/protocol/calculated/series.go +++ b/llo/protocol/calculated/series.go @@ -141,6 +141,21 @@ func window(name string, x any) (Series, error) { return series, nil } +// comparisonWindow resolves the history window of a function that compares +// records, which needs at least two. Static analysis rejects a shallower depth +// (minWindowDepth), so this is a plain error rather than ErrInsufficientHistory: +// more rounds would not make the window evaluable. +func comparisonWindow(name string, x any) (Series, error) { + series, err := window(name, x) + if err != nil { + return Series{}, err + } + if series.Len() < 2 { + return Series{}, fmt.Errorf("%s: history window has %d record, needs at least 2", name, series.Len()) + } + return series, nil +} + // windowSize resolves a function's sample-count argument against a window. func windowSize(name string, series Series, x any) (int, error) { d, err := toDecimal(x) From cb1c3097ed93449555748d3759a8c9ee8a525e57 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 16:22:05 +0100 Subject: [PATCH 030/103] llo/protocol: reject stream fields the aggregator cannot produce at admission --- llo/protocol/calculated/program.go | 34 +++++++++++++++++----- llo/protocol/calculated/validation_test.go | 22 ++++++++++++-- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/llo/protocol/calculated/program.go b/llo/protocol/calculated/program.go index 41a9a47..e98adfb 100644 --- a/llo/protocol/calculated/program.go +++ b/llo/protocol/calculated/program.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "strconv" + "strings" "sync" "github.com/expr-lang/expr/conf" @@ -176,7 +177,7 @@ func ValidateChannelExpressions(optsCache *protocol.OptsCache, cd llotypes.Chann // would be installed and fail to compile every round. for _, name := range entry.names { if !channelBindsName(name, aggByStream) { - errs = append(errs, fmt.Errorf("expression %q: %w: identifier %q is neither a function nor a stream the channel observes", + errs = append(errs, fmt.Errorf("expression %q: %w: identifier %q is neither a function nor a field of a stream the channel observes", expression, ErrUnknownIdentifier, name)) } } @@ -190,9 +191,17 @@ func ValidateChannelExpressions(optsCache *protocol.OptsCache, cd llotypes.Chann // history and never reports. Reporting it here is the whole point of // validating the definition. for _, ref := range refs { - if _, ok := aggByStream[ref.StreamID]; !ok { + agg, ok := aggByStream[ref.StreamID] + if !ok { errs = append(errs, fmt.Errorf("expression %q: %w: %s references stream %d, which the channel does not observe", expression, ErrHistoryExpression, HistoryFunctionName, ref.StreamID)) + continue + } + // The aggregator fixes the type of the stored value, so a field it + // cannot carry fails projection every round. + if !aggregatorCarriesField(agg, strings.TrimPrefix(ref.Field.suffix(), "_")) { + errs = append(errs, fmt.Errorf("expression %q: %w: %s reads the %s field of stream %d, which its %s aggregator does not produce", + expression, ErrHistoryExpression, HistoryFunctionName, ref.Field, ref.StreamID, agg)) } } } @@ -204,9 +213,7 @@ func ValidateChannelExpressions(optsCache *protocol.OptsCache, cd llotypes.Chann var ErrUnknownIdentifier = errors.New("unknown identifier") // channelBindsName reports whether evaluation binds name for a channel whose -// observed streams are aggByStream. It cannot check a stream identifier's -// field: s1_bid is only bound when s1 observes a quote, and the value type is -// not part of the definition. +// observed streams are aggByStream. func channelBindsName(name string, aggByStream map[llotypes.StreamID]llotypes.Aggregator) bool { if _, ok := defaultEnv[name]; ok || name == observationsTimestampName { return true @@ -219,8 +226,21 @@ func channelBindsName(name string, aggByStream map[llotypes.StreamID]llotypes.Ag if err != nil { return false } - _, ok := aggByStream[llotypes.StreamID(id)] - return ok + agg, ok := aggByStream[llotypes.StreamID(id)] + return ok && aggregatorCarriesField(agg, match[2]) +} + +// aggregatorCarriesField reports whether the value agg produces can bind the +// stream identifier suffix field, given without its underscore. +func aggregatorCarriesField(agg llotypes.Aggregator, field string) bool { + switch agg { + case llotypes.AggregatorMedian: + return field == "" || field == "timestamp" + case llotypes.AggregatorQuote: + return field == "bid" || field == "ask" || field == "benchmark" + default: + return true + } } func (c *analysisCache) analyze(expression string) ([]HistoryRef, error) { diff --git a/llo/protocol/calculated/validation_test.go b/llo/protocol/calculated/validation_test.go index 2a23b97..72f2265 100644 --- a/llo/protocol/calculated/validation_test.go +++ b/llo/protocol/calculated/validation_test.go @@ -131,6 +131,17 @@ func TestValidateChannelExpressions(t *testing.T) { require.ErrorContains(t, err, "which the channel does not observe") assert.Contains(t, err.Error(), "stream 2") + // The aggregator fixes the type of the stored value, so a field it does + // not produce fails projection every round. + quote := llotypes.Stream{StreamID: 2, Aggregator: llotypes.AggregatorQuote} + require.NoError(t, ValidateChannelExpressions(nil, + channel([]llotypes.Stream{median(1), quote}, "Add(Avg(History(s1, 10)), Avg(History(s2_bid, 10)))"), 1)) + err = ValidateChannelExpressions(nil, + channel([]llotypes.Stream{median(1), quote}, "Avg(History(s1_bid, 10))", "Avg(History(s2, 10))"), 1) + require.ErrorIs(t, err, ErrHistoryExpression) + assert.Contains(t, err.Error(), "bid field of stream 1") + assert.Contains(t, err.Error(), "value field of stream 2") + // Calculated streams are not observed, so an expression cannot read the // history of one. err = ValidateChannelExpressions(nil, channel([]llotypes.Stream{ @@ -185,6 +196,8 @@ func TestValidateChannelExpressions_Identifiers(t *testing.T) { Streams: []llotypes.Stream{ {StreamID: 1, Aggregator: llotypes.AggregatorMedian}, {StreamID: 2, Aggregator: llotypes.AggregatorMedian}, + {StreamID: 3, Aggregator: llotypes.AggregatorQuote}, + {StreamID: 4, Aggregator: llotypes.AggregatorMode}, {StreamID: 900, Aggregator: llotypes.AggregatorCalculated}, }, Opts: []byte(fmt.Sprintf(`{"abi":[{"type":"int256","expression":%q,"expressionStreamID":900}]}`, expression)), @@ -193,7 +206,9 @@ func TestValidateChannelExpressions_Identifiers(t *testing.T) { for _, expression := range []string{ "Add(s1, s2)", - "Add(s1_bid, s2_timestamp)", + "Add(s3_bid, s2_timestamp)", + "Add(s3_ask, s3_benchmark)", + "Add(s4, s4_bid)", // mode carries whatever type its contributions agree on "Sub(observations_timestamp, s1_timestamp)", `TWAP(History(s1, 3), {window: Duration("3s"), minSamples: 1, maxHeadGap: 3, maxInteriorGap: 3, maxTailGap: 3})`, "let a = Mul(s1, s2); Add(a, 1)", @@ -210,7 +225,10 @@ func TestValidateChannelExpressions_Identifiers(t *testing.T) { }{ {"Add(s9999, s1)", "s9999"}, {"Add(s9999_bid, s1)", "s9999_bid"}, - {"Add(s900, s1)", "s900"}, // a calculated stream is not bound + {"Add(s900, s1)", "s900"}, // a calculated stream is not bound + {"Add(s1_bid, s1)", "s1_bid"}, // a median is never a quote + {"Add(s3, s1)", "s3"}, // a quote has no bare value + {"Add(s3_timestamp, s1)", "s3_timestamp"}, {"Add(foo, s1)", "foo"}, {"Foo(s1)", "Foo"}, {"Add(xs1, s1)", "xs1"}, From 9adf4608c9fc320ad155e0995f24c8199a06906a Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 16:48:49 +0100 Subject: [PATCH 031/103] llo/calculated: pin evaluated expressions in the analysis cache --- llo/dev/v31/factory.go | 2 + llo/dev/v31/plugin.go | 8 ++ llo/dev/v31/statetransition.go | 1 + llo/protocol/calculated/expression_pins.go | 93 +++++++++++++++ llo/protocol/calculated/program.go | 108 +++++++++++++++-- llo/protocol/calculated/program_test.go | 128 +++++++++++++++++++++ llo/v30/plugin.go | 9 ++ llo/v30/stream_calculated.go | 1 + 8 files changed, 339 insertions(+), 11 deletions(-) create mode 100644 llo/protocol/calculated/expression_pins.go diff --git a/llo/dev/v31/factory.go b/llo/dev/v31/factory.go index f474260..b4507b0 100644 --- a/llo/dev/v31/factory.go +++ b/llo/dev/v31/factory.go @@ -10,6 +10,7 @@ import ( llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" protocol "github.com/smartcontractkit/chainlink-data-streams/llo/protocol" + "github.com/smartcontractkit/chainlink-data-streams/llo/protocol/calculated" "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3_1types" "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3types" @@ -165,6 +166,7 @@ func (f *PluginFactory) NewReportingPlugin(ctx context.Context, cfg ocr3types.Re // immutable generation per c/seqnr, so a round can never mix the two. p.ChannelCache = protocol.NewChannelCache() p.ChannelAnalysisCache = protocol.NewChannelAnalysisCache() + p.ExpressionPins = calculated.NewExpressionPins() p.BlobPayloads = newBlobPayloadCache() // Setup the blobpump diff --git a/llo/dev/v31/plugin.go b/llo/dev/v31/plugin.go index e79a8c3..57d5c64 100644 --- a/llo/dev/v31/plugin.go +++ b/llo/dev/v31/plugin.go @@ -13,6 +13,7 @@ import ( llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" "github.com/smartcontractkit/chainlink-data-streams/llo/protocol" + "github.com/smartcontractkit/chainlink-data-streams/llo/protocol/calculated" "github.com/smartcontractkit/libocr/commontypes" "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3_1types" @@ -69,6 +70,12 @@ type Plugin struct { // which case nothing is memoized. ChannelAnalysisCache *protocol.ChannelAnalysisCache + // ExpressionPins keeps the expressions this instance evaluates in the + // shared analysis cache, so expressions analyzed for admission and peer + // validation cannot evict their compiled programs. May be nil, in which + // case nothing is pinned. + ExpressionPins *calculated.ExpressionPins + // BlobPayloads memoizes blob payloads decoded within one round, so a handle // referenced by an observation is fetched and decompressed once for // ValidateObservation and StateTransition together. May be nil, in which @@ -525,6 +532,7 @@ func (p *Plugin) ShouldTransmitAcceptedReport(context.Context, uint64, ocr3types } func (p *Plugin) Close() error { + p.ExpressionPins.Release() if p.attributedObservationTelemeter != nil { p.attributedObservationTelemeter.Close() } diff --git a/llo/dev/v31/statetransition.go b/llo/dev/v31/statetransition.go index 3bba103..e12cffa 100644 --- a/llo/dev/v31/statetransition.go +++ b/llo/dev/v31/statetransition.go @@ -227,6 +227,7 @@ func (p *Plugin) StateTransition(ctx context.Context, seqNr uint64, _ ocrtypes.A // channel reports is derived from its opts by protocol.EffectiveStreams, so // nothing about evaluation reaches replicated state and a persisted // definition stays exactly what was voted on. + p.ExpressionPins.Set(prev.opts, effective) calculated.ProcessCalculatedStreams(p.Logger, effective, out.StreamAggregates, out.ObservationTimestampNanoseconds, prev.opts, history) // Flush KV mutations. diff --git a/llo/protocol/calculated/expression_pins.go b/llo/protocol/calculated/expression_pins.go new file mode 100644 index 0000000..22d2e95 --- /dev/null +++ b/llo/protocol/calculated/expression_pins.go @@ -0,0 +1,93 @@ +package calculated + +import ( + "maps" + "slices" + "sync" + + llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" + + "github.com/smartcontractkit/chainlink-data-streams/llo/protocol" +) + +// ExpressionPins holds the expressions one plugin instance evaluates in the +// process-wide analysis cache, exempt from eviction, so that their compiled +// programs survive however many other expressions admission and peer +// validation analyze. +// +// Holds are counted per expression, so instances sharing the process do not +// unpin each other. Call Release when the instance closes. +// +// A nil *ExpressionPins is usable and pins nothing. +type ExpressionPins struct { + cache *analysisCache + + mu sync.Mutex + held map[string]struct{} +} + +func NewExpressionPins() *ExpressionPins { + return &ExpressionPins{cache: historyAnalysisCache} +} + +// Set pins the expressions channelDefinitions evaluate and releases those they +// no longer do. Call it with the definitions about to be evaluated. It is cheap +// when they declare the same expressions as the previous call. +func (p *ExpressionPins) Set(optsCache *protocol.OptsCache, channelDefinitions llotypes.ChannelDefinitions) { + if p == nil { + return + } + next := evaluatedExpressions(optsCache, channelDefinitions) + + p.mu.Lock() + defer p.mu.Unlock() + if maps.Equal(next, p.held) { + return + } + var added, removed []string + for expression := range next { + if _, ok := p.held[expression]; !ok { + added = append(added, expression) + } + } + for expression := range p.held { + if _, ok := next[expression]; !ok { + removed = append(removed, expression) + } + } + p.cache.pin(added) + p.cache.unpin(removed) + p.held = next +} + +// Release drops every hold. +func (p *ExpressionPins) Release() { + if p == nil { + return + } + p.mu.Lock() + defer p.mu.Unlock() + p.cache.unpin(slices.Collect(maps.Keys(p.held))) + p.held = nil +} + +// evaluatedExpressions returns the distinct expressions evaluation runs for +// channelDefinitions, selecting channels as prepareCalculatedStreams does. +func evaluatedExpressions(optsCache *protocol.OptsCache, channelDefinitions llotypes.ChannelDefinitions) map[string]struct{} { + expressions := make(map[string]struct{}) + for cid, cd := range channelDefinitions { + if cd.Tombstone || cd.ReportFormat != llotypes.ReportFormatEVMABIEncodeUnpackedExpr { + continue + } + o, err := getCalculatedStreamOpts(optsCache, cd, cid) + if err != nil { + continue + } + for _, abi := range o.ABI { + if abi.Expression != "" { + expressions[abi.Expression] = struct{}{} + } + } + } + return expressions +} diff --git a/llo/protocol/calculated/program.go b/llo/protocol/calculated/program.go index e98adfb..31c3d3b 100644 --- a/llo/protocol/calculated/program.go +++ b/llo/protocol/calculated/program.go @@ -47,11 +47,24 @@ func parseExpression(expression string) (*parser.Tree, error) { // // Both hits and failures are cached: a rejected expression stays rejected until // its text changes, and re-deriving the same error every round is wasted work. +// +// Expressions of committed definitions are pinned (see ExpressionPins) and kept +// outside the LRU. Admission and peer validation analyze expressions that may +// never be committed, so without pins enough of them would evict the live ones +// and force a recompile every round. type analysisCache struct { mu sync.Mutex entries map[string]*list.Element order *list.List // front is most recently used max int + pinned map[string]*pinnedEntry +} + +// pinnedEntry is an entry held out of eviction, counted by how many pin sets +// hold it. +type pinnedEntry struct { + entry *analysisEntry + holders int } type analysisEntry struct { @@ -78,6 +91,7 @@ func newAnalysisCache(max int) *analysisCache { entries: make(map[string]*list.Element, max), order: list.New(), max: max, + pinned: make(map[string]*pinnedEntry), } } @@ -252,15 +266,26 @@ func (c *analysisCache) entry(expression string) *analysisEntry { if entry, ok := c.get(expression); ok { return entry } + return c.put(newAnalysisEntry(expression)) +} +func newAnalysisEntry(expression string) *analysisEntry { refs, twapCalls, names, err := analyzeHistoryExpression(expression) - return c.put(&analysisEntry{expression: expression, refs: refs, twapCalls: twapCalls, names: names, err: err}) + return &analysisEntry{expression: expression, refs: refs, twapCalls: twapCalls, names: names, err: err} } func (c *analysisCache) get(expression string) (*analysisEntry, bool) { c.mu.Lock() defer c.mu.Unlock() + return c.lookupLocked(expression) +} +// lookupLocked returns the entry for expression, pinned or not, marking an +// unpinned one as most recently used. +func (c *analysisCache) lookupLocked(expression string) (*analysisEntry, bool) { + if pinned, ok := c.pinned[expression]; ok { + return pinned.entry, true + } element, ok := c.entries[expression] if !ok { return nil, false @@ -276,11 +301,15 @@ func (c *analysisCache) put(entry *analysisEntry) *analysisEntry { c.mu.Lock() defer c.mu.Unlock() - if element, ok := c.entries[entry.expression]; ok { - c.order.MoveToFront(element) - return element.Value.(*analysisEntry) + if existing, ok := c.lookupLocked(entry.expression); ok { + return existing } + c.insertLocked(entry) + return entry +} +// insertLocked adds entry to the front of the LRU and evicts past max. +func (c *analysisCache) insertLocked(entry *analysisEntry) { c.entries[entry.expression] = c.order.PushFront(entry) for c.order.Len() > c.max { oldest := c.order.Back() @@ -290,7 +319,6 @@ func (c *analysisCache) put(entry *analysisEntry) *analysisEntry { c.order.Remove(oldest) delete(c.entries, oldest.Value.(*analysisEntry).expression) } - return entry } // program returns the cached compiled program for an expression, or nil if it @@ -299,12 +327,11 @@ func (c *analysisCache) program(expression string) *vm.Program { c.mu.Lock() defer c.mu.Unlock() - element, ok := c.entries[expression] + entry, ok := c.lookupLocked(expression) if !ok { return nil } - c.order.MoveToFront(element) - return element.Value.(*analysisEntry).program + return entry.program } // storeProgram attaches a compiled program to an existing entry. It is a no-op @@ -314,16 +341,75 @@ func (c *analysisCache) storeProgram(expression string, program *vm.Program) { c.mu.Lock() defer c.mu.Unlock() - element, ok := c.entries[expression] + entry, ok := c.lookupLocked(expression) if !ok { return } - element.Value.(*analysisEntry).program = program - c.order.MoveToFront(element) + entry.program = program } +// pin takes one hold on each of expressions, which must be distinct. An +// expression already cached keeps its entry, and with it any compiled program. +// Analysis of the rest runs outside the lock and does not pass through the LRU, +// so pinning does not evict unpinned entries. +func (c *analysisCache) pin(expressions []string) { + analyzed := make([]*analysisEntry, len(expressions)) + for i, expression := range expressions { + if _, ok := c.get(expression); !ok { + analyzed[i] = newAnalysisEntry(expression) + } + } + + c.mu.Lock() + defer c.mu.Unlock() + for i, expression := range expressions { + if pinned, ok := c.pinned[expression]; ok { + pinned.holders++ + continue + } + entry := analyzed[i] + if element, ok := c.entries[expression]; ok { + entry = element.Value.(*analysisEntry) + c.order.Remove(element) + delete(c.entries, expression) + } + if entry == nil { + // Evicted between the lookup and the lock. + entry = newAnalysisEntry(expression) + } + c.pinned[expression] = &pinnedEntry{entry: entry, holders: 1} + } +} + +// unpin releases one hold on each of expressions. An expression no longer held +// returns to the front of the LRU rather than being dropped, since a definition +// change may bring it back. +func (c *analysisCache) unpin(expressions []string) { + c.mu.Lock() + defer c.mu.Unlock() + for _, expression := range expressions { + pinned, ok := c.pinned[expression] + if !ok { + continue + } + pinned.holders-- + if pinned.holders > 0 { + continue + } + delete(c.pinned, expression) + c.insertLocked(pinned.entry) + } +} + +// len returns the number of unpinned entries. func (c *analysisCache) len() int { c.mu.Lock() defer c.mu.Unlock() return c.order.Len() } + +func (c *analysisCache) pinnedLen() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.pinned) +} diff --git a/llo/protocol/calculated/program_test.go b/llo/protocol/calculated/program_test.go index 0478ec6..f41f9ed 100644 --- a/llo/protocol/calculated/program_test.go +++ b/llo/protocol/calculated/program_test.go @@ -8,6 +8,8 @@ import ( "github.com/expr-lang/expr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" ) func TestAnalysisCache_HitReturnsSameResult(t *testing.T) { @@ -147,3 +149,129 @@ func TestAnalyzeExpressionHistory(t *testing.T) { _, err = AnalyzeExpressionHistory("Add(History(s424243, 7), 1)") require.ErrorIs(t, err, ErrHistoryExpression) } + +// TestAnalysisCache_PinnedSurvivesChurn replays a peer flooding the cache with +// expressions it proposes: a pinned expression keeps its compiled program, and +// the unpinned entries stay bounded. +func TestAnalysisCache_PinnedSurvivesChurn(t *testing.T) { + t.Parallel() + + const max = 4 + c := newAnalysisCache(max) + const live = "Avg(History(s10001, 10))" + + c.pin([]string{live}) + program, err := expr.Compile("1") + require.NoError(t, err) + c.storeProgram(live, program) + + for i := range 100 { + c.entry(fmt.Sprintf("Avg(History(s%d, 10))", i+1)) + assert.LessOrEqual(t, c.len(), max) + } + + assert.Same(t, program, c.program(live), "pinned program must survive eviction") + assert.Equal(t, 1, c.pinnedLen()) + assert.Equal(t, max, c.len()) +} + +// TestAnalysisCache_PinKeepsCachedProgram checks that pinning an expression +// already in the LRU moves its entry rather than analyzing it again. +func TestAnalysisCache_PinKeepsCachedProgram(t *testing.T) { + t.Parallel() + + c := newAnalysisCache(8) + const expression = "Avg(History(s10001, 10))" + + first := c.entry(expression) + program, err := expr.Compile("1") + require.NoError(t, err) + c.storeProgram(expression, program) + + c.pin([]string{expression}) + assert.Same(t, first, c.entry(expression)) + assert.Same(t, program, c.program(expression)) + assert.Equal(t, 0, c.len(), "a pinned entry must leave the LRU") + assert.Equal(t, 1, c.pinnedLen()) +} + +// TestAnalysisCache_UnpinCountsHolders checks that an expression stays pinned +// while any holder remains, and returns to the LRU once none does. +func TestAnalysisCache_UnpinCountsHolders(t *testing.T) { + t.Parallel() + + c := newAnalysisCache(8) + const expression = "Avg(History(s10001, 10))" + + c.pin([]string{expression}) + c.pin([]string{expression}) + pinned := c.entry(expression) + + c.unpin([]string{expression}) + assert.Equal(t, 1, c.pinnedLen()) + assert.Equal(t, 0, c.len()) + + c.unpin([]string{expression}) + assert.Equal(t, 0, c.pinnedLen()) + assert.Equal(t, 1, c.len()) + assert.Same(t, pinned, c.entry(expression), "an unpinned entry must return to the LRU") + + // Unpinning what is not pinned is a no-op. + c.unpin([]string{expression}) + assert.Equal(t, 1, c.len()) +} + +func TestExpressionPins(t *testing.T) { + t.Parallel() + + channel := func(format llotypes.ReportFormat, expressions ...string) llotypes.ChannelDefinition { + abi := "" + for i, expression := range expressions { + if i > 0 { + abi += "," + } + abi += fmt.Sprintf(`{"type":"int256","expression":%q,"expressionStreamID":%d}`, expression, 900+i) + } + return llotypes.ChannelDefinition{ + ReportFormat: format, + Streams: []llotypes.Stream{{StreamID: 1, Aggregator: llotypes.AggregatorMedian}}, + Opts: []byte(fmt.Sprintf(`{"abi":[%s]}`, abi)), + } + } + const ( + a = "Avg(History(s1, 10))" + b = "Count(History(s1, 10))" + d = "Sum(History(s1, 10))" + ) + + c := newAnalysisCache(2) + first := &ExpressionPins{cache: c} + second := &ExpressionPins{cache: c} + + tombstoned := channel(llotypes.ReportFormatEVMABIEncodeUnpackedExpr, d) + tombstoned.Tombstone = true + first.Set(nil, llotypes.ChannelDefinitions{ + 1: channel(llotypes.ReportFormatEVMABIEncodeUnpackedExpr, a, b), + 2: channel(llotypes.ReportFormatEVMABIEncodeUnpackedExpr, a), + 3: tombstoned, + 4: channel(llotypes.ReportFormatJSON, d), + }) + assert.Equal(t, 2, c.pinnedLen(), "only distinct expressions of evaluated channels are pinned") + + second.Set(nil, llotypes.ChannelDefinitions{1: channel(llotypes.ReportFormatEVMABIEncodeUnpackedExpr, a)}) + assert.Equal(t, 2, c.pinnedLen()) + + // Dropping b from the first set unpins it, and a stays held by both. + first.Set(nil, llotypes.ChannelDefinitions{1: channel(llotypes.ReportFormatEVMABIEncodeUnpackedExpr, a)}) + assert.Equal(t, 1, c.pinnedLen()) + assert.Equal(t, 1, c.len()) + + first.Release() + assert.Equal(t, 1, c.pinnedLen(), "a must stay pinned while second holds it") + second.Release() + assert.Equal(t, 0, c.pinnedLen()) + + var nilPins *ExpressionPins + nilPins.Set(nil, llotypes.ChannelDefinitions{1: channel(llotypes.ReportFormatEVMABIEncodeUnpackedExpr, a)}) + nilPins.Release() +} diff --git a/llo/v30/plugin.go b/llo/v30/plugin.go index 4b2d3af..f31dcd3 100644 --- a/llo/v30/plugin.go +++ b/llo/v30/plugin.go @@ -12,6 +12,7 @@ import ( "github.com/smartcontractkit/chainlink-data-streams/llo/datasource" "github.com/smartcontractkit/chainlink-data-streams/llo/protocol" + "github.com/smartcontractkit/chainlink-data-streams/llo/protocol/calculated" "github.com/smartcontractkit/chainlink-common/pkg/logger" llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" @@ -183,6 +184,7 @@ func (f *PluginFactory) NewReportingPlugin(ctx context.Context, cfg ocr3types.Re cfg.MaxDurationObservation, offchainConfig.ProtocolVersion, offchainConfig.DefaultMinReportIntervalNanoseconds, + calculated.NewExpressionPins(), }, ocr3types.ReportingPluginInfo{ Name: "LLO", Limits: ocr3types.ReportingPluginLimits{ @@ -233,6 +235,12 @@ type Plugin struct { // From offchain config ProtocolVersion uint32 DefaultMinReportIntervalNanoseconds uint64 + + // ExpressionPins keeps the expressions this instance evaluates in the + // shared analysis cache, so expressions analyzed for admission and peer + // validation cannot evict their compiled programs. May be nil, in which + // case nothing is pinned. + ExpressionPins *calculated.ExpressionPins } // Query creates a Query that is sent from the leader to all follower nodes @@ -405,5 +413,6 @@ func (p *Plugin) ObservationQuorum(ctx context.Context, outctx ocr3types.Outcome } func (p *Plugin) Close() error { + p.ExpressionPins.Release() return nil } diff --git a/llo/v30/stream_calculated.go b/llo/v30/stream_calculated.go index c02cc75..0a2abee 100644 --- a/llo/v30/stream_calculated.go +++ b/llo/v30/stream_calculated.go @@ -20,6 +20,7 @@ func (p *Plugin) ProcessCalculatedStreams(outcome *Outcome) { // nil HistoryReader: v30 has no replicated key-value state, so there is // nowhere for stream history to live. Expressions using History fail closed // rather than evaluating against an empty window. + p.ExpressionPins.Set(p.OptsCache, outcome.ChannelDefinitions) calculated.ProcessCalculatedStreamsWithDefinitionAppend(p.Logger, outcome.ChannelDefinitions, outcome.StreamAggregates, outcome.ObservationTimestampNanoseconds, p.OptsCache, nil) } From c259a6f02dd59549177bdaa43b786e403e5701ec Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 16:54:23 +0100 Subject: [PATCH 032/103] llo/calculated: test pinned set larger than the analysis cache bound --- llo/protocol/calculated/program.go | 9 ++++--- llo/protocol/calculated/program_test.go | 35 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/llo/protocol/calculated/program.go b/llo/protocol/calculated/program.go index 31c3d3b..cba1be2 100644 --- a/llo/protocol/calculated/program.go +++ b/llo/protocol/calculated/program.go @@ -17,10 +17,11 @@ import ( "github.com/smartcontractkit/chainlink-data-streams/llo/protocol" ) -// maxAnalysisCacheEntries bounds the expression analysis cache. Channel -// definitions can churn, so the cache must not grow with the number of distinct -// expressions ever seen; a few hundred live expressions is already far more than -// any real configuration. +// maxAnalysisCacheEntries bounds the unpinned part of the expression analysis +// cache. Expressions of committed definitions are pinned and do not count +// against it, so it only bounds transient analysis by admission and peer +// validation, which must not grow with the number of distinct expressions ever +// seen. const maxAnalysisCacheEntries = 1024 // parseExpression is the single parse of the DSL: it applies the expression diff --git a/llo/protocol/calculated/program_test.go b/llo/protocol/calculated/program_test.go index f41f9ed..66421c4 100644 --- a/llo/protocol/calculated/program_test.go +++ b/llo/protocol/calculated/program_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/expr-lang/expr" + "github.com/expr-lang/expr/vm" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -175,6 +176,40 @@ func TestAnalysisCache_PinnedSurvivesChurn(t *testing.T) { assert.Equal(t, max, c.len()) } +// TestAnalysisCache_PinnedExceedsMax checks that a live expression set larger +// than the LRU bound keeps every compiled program through churn, since +// admission permits far more calculated streams than the bound. +func TestAnalysisCache_PinnedExceedsMax(t *testing.T) { + t.Parallel() + + const max = 4 + c := newAnalysisCache(max) + + live := make([]string, 4*max) + for i := range live { + live[i] = fmt.Sprintf("Avg(History(s%d, 10))", 10001+i) + } + c.pin(live) + + programs := make([]*vm.Program, len(live)) + for i, expression := range live { + program, err := expr.Compile("1") + require.NoError(t, err) + c.storeProgram(expression, program) + programs[i] = program + } + + for i := range 100 { + c.entry(fmt.Sprintf("Avg(History(s%d, 10))", i+1)) + } + + for i, expression := range live { + assert.Same(t, programs[i], c.program(expression), "pinned program must survive eviction: %s", expression) + } + assert.Equal(t, len(live), c.pinnedLen()) + assert.Equal(t, max, c.len()) +} + // TestAnalysisCache_PinKeepsCachedProgram checks that pinning an expression // already in the LRU moves its entry rather than analyzing it again. func TestAnalysisCache_PinKeepsCachedProgram(t *testing.T) { From 362fffc605ecaa4f2f86d890cba3d40ebfeb1c0b Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 17:22:29 +0100 Subject: [PATCH 033/103] llo/calculated: reject TWAP gap tolerances the window cannot reach --- llo/protocol/calculated/functions_twap.go | 15 ++++++++++++ .../calculated/functions_twap_test.go | 23 +++++++++++++++---- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/llo/protocol/calculated/functions_twap.go b/llo/protocol/calculated/functions_twap.go index 9925ef7..e62c001 100644 --- a/llo/protocol/calculated/functions_twap.go +++ b/llo/protocol/calculated/functions_twap.go @@ -381,6 +381,21 @@ func parseTWAPConfig(raw any) (twapConfig, error) { return twapConfig{}, fmt.Errorf("%w: minSamples %d exceeds the %d one-second buckets in the window", ErrTWAPConfig, minSamples, windowSeconds) } + // A gap spans at most windowSeconds-1 buckets, so a larger tolerance can + // never be reached and is a misconfiguration rather than a looser rule. + for _, tolerance := range []struct { + key string + value int + }{ + {keyMaxHeadGap, maxHeadGap}, + {keyMaxInteriorGap, maxInteriorGap}, + {keyMaxTailGap, maxTailGap}, + } { + if int64(tolerance.value) >= windowSeconds { + return twapConfig{}, fmt.Errorf("%w: %s %d must be less than the %d one-second buckets in the window", + ErrTWAPConfig, tolerance.key, tolerance.value, windowSeconds) + } + } return twapConfig{ windowSeconds: windowSeconds, diff --git a/llo/protocol/calculated/functions_twap_test.go b/llo/protocol/calculated/functions_twap_test.go index d3459fe..35d5a5a 100644 --- a/llo/protocol/calculated/functions_twap_test.go +++ b/llo/protocol/calculated/functions_twap_test.go @@ -117,7 +117,7 @@ func TestTWAP_FillThenAverage(t *testing.T) { { name: "insufficient samples: rejected even though every gap is within its threshold", prices: []int64{100, 0, 300, 0, 500}, // M=3, Gint=1 - minSamples: 4, maxInterior: 5, maxTail: 5, + minSamples: 4, maxInterior: 4, maxTail: 4, wantReasons: []TWAPRejectionReason{ReasonInsufficientSamples}, }, { @@ -150,7 +150,7 @@ func TestTWAP_FillThenAverage(t *testing.T) { { name: "multiple applicable reasons are all returned, not just the first", prices: []int64{100, 0, 0, 0, 0}, // M=1, Gtail=4 - minSamples: 3, maxInterior: 5, maxTail: 2, + minSamples: 3, maxInterior: 4, maxTail: 2, wantReasons: []TWAPRejectionReason{ReasonInsufficientSamples, ReasonTailGapTooLong}, }, { @@ -279,7 +279,7 @@ func TestTWAP_HalfOpenWindow(t *testing.T) { const windowSeconds = 3 anchorSeconds := uint64(twapWindowStartSeconds + windowSeconds) - cfg := twapConfigMap(windowSeconds, 1, windowSeconds, windowSeconds, windowSeconds) + cfg := twapConfigMap(windowSeconds, 1, windowSeconds-1, windowSeconds-1, windowSeconds-1) // An observation exactly at the anchor is excluded, leaving the window empty. series, err := NewSeries( @@ -419,6 +419,21 @@ func TestTWAP_ConfigValidation(t *testing.T) { require.ErrorContains(t, call(twapConfigMap(3, 4, 0, 0, 0)), "exceeds the") }) + t.Run("gap tolerances must be reachable within the window", func(t *testing.T) { + t.Parallel() + // A gap spans at most window-1 buckets. + for _, key := range []string{"maxHeadGap", "maxInteriorGap", "maxTailGap"} { + cfg := twapConfigMap(3, 1, 0, 0, 0) + cfg[key] = 3 + err := call(cfg) + require.ErrorIs(t, err, ErrTWAPConfig, key) + require.ErrorContains(t, err, key+" 3 must be less than", key) + + cfg[key] = 2 + require.NotErrorIs(t, call(cfg), ErrTWAPConfig, key) + } + }) + t.Run("a window too thin to satisfy minSamples is a rejection, not a config error", func(t *testing.T) { t.Parallel() // Whether the requested depth can ever supply minSamples is a static @@ -489,7 +504,7 @@ func TestTWAP_RejectionMessage(t *testing.T) { series := twapSeries(t, prices) anchorNs := uint64(twapWindowStartSeconds+len(prices)) * uint64(time.Second) - _, err := twapFunc(anchorNs)(series, twapConfigMap(len(prices), 3, 0, 5, 2)) + _, err := twapFunc(anchorNs)(series, twapConfigMap(len(prices), 3, 0, 4, 2)) require.Error(t, err) var rejection *TWAPRejection From f8e39cf9d16575bcfff7b73bcd9563208ca7bffe Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 17:32:57 +0100 Subject: [PATCH 034/103] llo/protocol: keep calculated streams declared before a zero-ID expression --- llo/protocol/calculated_streams.go | 3 ++- llo/protocol/channel_definitions.go | 3 +++ llo/protocol/channel_definitions_test.go | 10 ++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/llo/protocol/calculated_streams.go b/llo/protocol/calculated_streams.go index d2b6491..3010e03 100644 --- a/llo/protocol/calculated_streams.go +++ b/llo/protocol/calculated_streams.go @@ -116,7 +116,8 @@ func EffectiveStreams(optsCache *OptsCache, cd llotypes.ChannelDefinition, cid l // streams a channel is expected to produce. // // Returns an error if the opts cannot be resolved, declare no expressions, or -// declare a zero expression stream ID. +// declare a zero expression stream ID. On a zero ID it also returns the IDs +// declared before it, which evaluation still produces. func CalculatedStreamIDs(optsCache *OptsCache, cd llotypes.ChannelDefinition, cid llotypes.ChannelID) ([]llotypes.StreamID, error) { o, err := DecodeCalculatedStreamOpts(optsCache, cd, cid) if err != nil { diff --git a/llo/protocol/channel_definitions.go b/llo/protocol/channel_definitions.go index 3c2e0eb..8395f68 100644 --- a/llo/protocol/channel_definitions.go +++ b/llo/protocol/channel_definitions.go @@ -331,6 +331,9 @@ func analyzeChannelDefinitions(codecs map[llotypes.ReportFormat]ReportCodec, cha if facts.calculatedErr != nil { admit(fmt.Errorf("invalid ChannelDefinition with ID %d: %w", channelID, facts.calculatedErr), channelID) } + // Keep the IDs declared before a failing entry: evaluation still + // produces them, so they occupy their stream IDs and count against + // the budget like any other. totalCalculatedStreams += len(facts.calculatedIDs) for _, streamID := range facts.calculatedIDs { if owner, ok := calculatedBy[streamID]; ok { diff --git a/llo/protocol/channel_definitions_test.go b/llo/protocol/channel_definitions_test.go index 54ef2d0..ec864b7 100644 --- a/llo/protocol/channel_definitions_test.go +++ b/llo/protocol/channel_definitions_test.go @@ -279,6 +279,16 @@ func Test_VerifyChannelDefinitions_CalculatedStreamCollisions(t *testing.T) { require.EqualError(t, verifyAdmittingAll(codecs, channelDefs), want) } }) + t.Run("keeps the streams declared before a zero-ID expression", func(t *testing.T) { + // Channel 1 is committed and still evaluates stream 100, so channel 2 + // cannot be admitted declaring it. + channelDefs := llotypes.ChannelDefinitions{ + 1: exprChannel(10, `{"abi":[{"expressionStreamID":100},{"expressionStreamID":0}]}`, 100), + 2: exprChannel(11, `{"abi":[{"expressionStreamID":100}]}`, 100), + } + err := VerifyChannelDefinitionsForAdmission(codecs, channelDefs, map[llotypes.ChannelID]struct{}{2: {}}) + require.EqualError(t, err, "ChannelDefinition with ID 2 declares calculated stream 100 already declared by channel 1") + }) t.Run("ignores tombstoned channels", func(t *testing.T) { tombstoned := exprChannel(11, `{"abi":[{"expressionStreamID":100}]}`, 100) tombstoned.Tombstone = true From ee268c85e0536ce049d8b0ccb6ad0dbf869f6a0a Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 17:43:11 +0100 Subject: [PATCH 035/103] llo/reportcodec/evm: cap maxReportRange --- llo/protocol/types.go | 2 ++ llo/reportcodec/evm/report_codec_common.go | 22 +++++++++++++-- .../evm/report_codec_common_test.go | 28 +++++++++++++++++++ .../report_codec_evm_abi_encode_unpacked.go | 5 +--- ...port_codec_evm_abi_encode_unpacked_expr.go | 5 +--- .../evm/report_codec_evm_streamlined.go | 5 +--- .../evm/report_codec_evm_streamlined_test.go | 7 +++++ .../evm/report_codec_premium_legacy.go | 5 +--- 8 files changed, 60 insertions(+), 19 deletions(-) diff --git a/llo/protocol/types.go b/llo/protocol/types.go index b38d98b..97f9a3b 100644 --- a/llo/protocol/types.go +++ b/llo/protocol/types.go @@ -14,6 +14,8 @@ import ( const ( // DefaultMaxReportRange is the default maximum range of the report if unset in the opts. DefaultMaxReportRange = Duration(5 * time.Minute) + // MaxReportRangeCeiling is the largest maximum report range a channel may set. + MaxReportRangeCeiling = Duration(7 * 24 * time.Hour) ) type ReportCodec interface { diff --git a/llo/reportcodec/evm/report_codec_common.go b/llo/reportcodec/evm/report_codec_common.go index d295c2e..a7567e8 100644 --- a/llo/reportcodec/evm/report_codec_common.go +++ b/llo/reportcodec/evm/report_codec_common.go @@ -305,17 +305,33 @@ func EncodePackedBigInt(value *big.Int, typeStr string) ([]byte, error) { return result, nil } +// verifyMaxReportRange rejects a maxReportRange that is negative or above +// MaxReportRangeCeiling. +func verifyMaxReportRange(maxReportRange protocol.Duration) error { + if maxReportRange < 0 { + return fmt.Errorf("maxReportRange must be non-negative; got: %s", maxReportRange) + } + if maxReportRange > protocol.MaxReportRangeCeiling { + return fmt.Errorf("maxReportRange must not exceed %s; got: %s", protocol.MaxReportRangeCeiling, maxReportRange) + } + return nil +} + // ClampReportRange clamps the report range to the max report range if it exceeds the max range. // Returns the clamped valid after nanoseconds. // If the report range is within the max range, returns the valid after nanoseconds unchanged. // If the max report range is not specified, uses the default max report range. -// A non-positive maxReportRange falls back to the default; Verify rejects -// negative values in channel definitions, but a definition committed before -// that check existed could still carry one. +// A non-positive maxReportRange falls back to the default and one above +// MaxReportRangeCeiling is capped to it. Verify rejects both in channel +// definitions, but a definition committed before those checks existed could +// still carry one. func ClampReportRange(r logger.Logger, report protocol.Report, maxReportRange protocol.Duration) uint64 { if maxReportRange <= 0 { maxReportRange = protocol.DefaultMaxReportRange } + if maxReportRange > protocol.MaxReportRangeCeiling { + maxReportRange = protocol.MaxReportRangeCeiling + } maxRange := uint64(maxReportRange) // ValidAfterNanoseconds < ObservationTimestampNanoseconds is enforced diff --git a/llo/reportcodec/evm/report_codec_common_test.go b/llo/reportcodec/evm/report_codec_common_test.go index f29f014..c7e6bc2 100644 --- a/llo/reportcodec/evm/report_codec_common_test.go +++ b/llo/reportcodec/evm/report_codec_common_test.go @@ -461,6 +461,30 @@ func Test_ABIEncoder_EncodePadded_EncodePacked(t *testing.T) { }) } +func Test_verifyMaxReportRange(t *testing.T) { + tests := []struct { + name string + in protocol.Duration + wantErr string + }{ + {"zero", 0, ""}, + {"at ceiling", protocol.MaxReportRangeCeiling, ""}, + {"negative", protocol.Duration(-time.Second), "maxReportRange must be non-negative"}, + {"above ceiling", protocol.MaxReportRangeCeiling + 1, "maxReportRange must not exceed 168h0m0s"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := verifyMaxReportRange(tc.in) + if tc.wantErr == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + }) + } +} + func Test_ClampReportRange(t *testing.T) { const obsTs = uint64(1_700_000_000_000_000_000) @@ -480,6 +504,10 @@ func Test_ClampReportRange(t *testing.T) { report := protocol.Report{ValidAfterNanoseconds: obsTs - 1e9, ObservationTimestampNanoseconds: obsTs} assert.Equal(t, obsTs-1e9, ClampReportRange(logger.Nop(), report, protocol.Duration(-time.Hour))) }) + t.Run("max range above ceiling is capped to the ceiling", func(t *testing.T) { + report := protocol.Report{ValidAfterNanoseconds: obsTs - uint64(30*24*time.Hour), ObservationTimestampNanoseconds: obsTs} + assert.Equal(t, obsTs-uint64(protocol.MaxReportRangeCeiling), ClampReportRange(logger.Nop(), report, protocol.Duration(87600*time.Hour))) + }) t.Run("validAfter after observation timestamp yields zero-length range", func(t *testing.T) { report := protocol.Report{ValidAfterNanoseconds: obsTs + 1e9, ObservationTimestampNanoseconds: obsTs} assert.Equal(t, obsTs, ClampReportRange(logger.Nop(), report, protocol.Duration(time.Minute))) diff --git a/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked.go b/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked.go index 1b0e539..0579041 100644 --- a/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked.go +++ b/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked.go @@ -180,10 +180,7 @@ func (r ReportCodecEVMABIEncodeUnpacked) Verify(cd llotypes.ChannelDefinition) e if len(opts.ABI) != len(cd.Streams)-2 { return fmt.Errorf("ABI length mismatch; expected: %d, got: %d", len(cd.Streams)-2, len(opts.ABI)) } - if opts.MaxReportRange < 0 { - return fmt.Errorf("maxReportRange must be non-negative; got: %s", opts.MaxReportRange) - } - return nil + return verifyMaxReportRange(opts.MaxReportRange) } // BaseSchema represents the fixed base schema that remains unchanged for all diff --git a/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr.go b/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr.go index d85073f..919a10b 100644 --- a/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr.go +++ b/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr.go @@ -104,10 +104,7 @@ func (r ReportCodecEVMABIEncodeUnpackedExpr) Verify(cd llotypes.ChannelDefinitio if len(cd.Streams) < 3 { return fmt.Errorf("expected at least 3 streams; got: %d", len(cd.Streams)) } - if opts.MaxReportRange < 0 { - return fmt.Errorf("maxReportRange must be non-negative; got: %s", opts.MaxReportRange) - } - return nil + return verifyMaxReportRange(opts.MaxReportRange) } // VerifyForAdmission implements protocol.AdmissionVerifier: it rejects diff --git a/llo/reportcodec/evm/report_codec_evm_streamlined.go b/llo/reportcodec/evm/report_codec_evm_streamlined.go index 4140366..134e6dc 100644 --- a/llo/reportcodec/evm/report_codec_evm_streamlined.go +++ b/llo/reportcodec/evm/report_codec_evm_streamlined.go @@ -92,10 +92,7 @@ func (rc ReportCodecEVMStreamlined) Verify(cd llotypes.ChannelDefinition) error if len(opts.ABI) != len(cd.Streams) { return fmt.Errorf("ABI length mismatch; expected: %d, got: %d", len(cd.Streams), len(opts.ABI)) } - if opts.MaxReportRange < 0 { - return fmt.Errorf("maxReportRange must be non-negative; got: %s", opts.MaxReportRange) - } - return nil + return verifyMaxReportRange(opts.MaxReportRange) } func (rc ReportCodecEVMStreamlined) Pack(digest types.ConfigDigest, seqNr uint64, report ocr2types.Report, sigs []ocr2types.AttributedOnchainSignature) ([]byte, error) { diff --git a/llo/reportcodec/evm/report_codec_evm_streamlined_test.go b/llo/reportcodec/evm/report_codec_evm_streamlined_test.go index 13c613d..f7d0b19 100644 --- a/llo/reportcodec/evm/report_codec_evm_streamlined_test.go +++ b/llo/reportcodec/evm/report_codec_evm_streamlined_test.go @@ -113,6 +113,13 @@ func TestReportCodecEVMStreamlined(t *testing.T) { }) require.Error(t, err) require.Contains(t, err.Error(), "json: cannot unmarshal hex string of odd length") + err = codec.Verify(llotypes.ChannelDefinition{ + Streams: []llotypes.Stream{ + {StreamID: 123, Aggregator: llotypes.AggregatorMedian}, + }, + Opts: []byte(`{"abi":[{"type":"int160"}],"maxReportRange":"87600h"}`), + }) + require.EqualError(t, err, "maxReportRange must not exceed 168h0m0s; got: 87600h0m0s") }) }) }) diff --git a/llo/reportcodec/evm/report_codec_premium_legacy.go b/llo/reportcodec/evm/report_codec_premium_legacy.go index f0e9a9a..c47ab30 100644 --- a/llo/reportcodec/evm/report_codec_premium_legacy.go +++ b/llo/reportcodec/evm/report_codec_premium_legacy.go @@ -154,10 +154,7 @@ func (r ReportCodecPremiumLegacy) Verify(cd llotypes.ChannelDefinition) error { if len(cd.Streams) != 3 { return fmt.Errorf("ReportFormatEVMPremiumLegacy requires exactly 3 streams (NativePrice, LinkPrice, Quote); got: %v", cd.Streams) } - if opts.MaxReportRange < 0 { - return fmt.Errorf("maxReportRange must be non-negative; got: %s", opts.MaxReportRange) - } - return nil + return verifyMaxReportRange(opts.MaxReportRange) } func (r ReportCodecPremiumLegacy) Decode(b []byte) (*v3.Report, error) { From 5c5a115e9a7755f1e7f4d86f89cf517a6fc71e9f Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 17:52:25 +0100 Subject: [PATCH 036/103] llo/protocol: OnchainConfig version codec docs, error message and dead code removal --- llo/protocol/onchain_config_codec.go | 22 +++++++++++++--------- llo/protocol/onchain_config_codec_test.go | 3 ++- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/llo/protocol/onchain_config_codec.go b/llo/protocol/onchain_config_codec.go index 66ddd3f..4dbef12 100644 --- a/llo/protocol/onchain_config_codec.go +++ b/llo/protocol/onchain_config_codec.go @@ -1,8 +1,8 @@ package protocol import ( + "errors" "fmt" - "math" "math/big" "github.com/smartcontractkit/libocr/bigbigendian" @@ -15,6 +15,10 @@ var onchainConfigVersionBig = big.NewInt(onchainConfigVersion) const onchainConfigEncodedLength = 2 * 32 // 2x 32bit evm word: version and predecessorConfigDigest +// ErrUnsupportedOnchainConfigVersion is returned when decoding an onchain +// config whose version this node does not support. +var ErrUnsupportedOnchainConfigVersion = errors.New("unsupported OnchainConfig version") + type OnchainConfig struct { Version uint8 PredecessorConfigDigest *types.ConfigDigest @@ -32,8 +36,12 @@ var _ OnchainConfigCodec = EVMOnchainConfigCodec{} // // An encoded onchain config is expected to be in the format // -// where version is a uint8 and min and max are in the format -// returned by EncodeValueInt192. +// where each field is a 32 byte EVM word and a zero predecessorConfigDigest +// means no predecessor. +// +// Decode rejects any version other than onchainConfigVersion, since a node +// cannot safely interpret the semantics of a newer version. Upgrade nodes +// before publishing a config with a newer version. type EVMOnchainConfigCodec struct{} func (EVMOnchainConfigCodec) Decode(b []byte) (OnchainConfig, error) { @@ -46,15 +54,11 @@ func (EVMOnchainConfigCodec) Decode(b []byte) (OnchainConfig, error) { return OnchainConfig{}, err } if v.Cmp(onchainConfigVersionBig) != 0 { - return OnchainConfig{}, fmt.Errorf("unexpected version of OnchainConfig, expected %v, got %v", onchainConfigVersion, v) - } - version := v.Uint64() - if version > math.MaxUint8 { - return OnchainConfig{}, fmt.Errorf("version of OnchainConfig is too large, expected <= %v, got %v", math.MaxUint8, v) + return OnchainConfig{}, fmt.Errorf("%w: got %v, supported %v; upgrade the node before publishing a config with a newer version", ErrUnsupportedOnchainConfigVersion, v, onchainConfigVersion) } o := OnchainConfig{ - Version: uint8(version), + Version: onchainConfigVersion, } cd := types.ConfigDigest(b[32:64]) diff --git a/llo/protocol/onchain_config_codec_test.go b/llo/protocol/onchain_config_codec_test.go index 47dac10..c0f9d23 100644 --- a/llo/protocol/onchain_config_codec_test.go +++ b/llo/protocol/onchain_config_codec_test.go @@ -88,7 +88,8 @@ func Test_EVMOnchainConfigCodec(t *testing.T) { b[30] = 100 _, err := c.Decode(b) - require.EqualError(t, err, "unexpected version of OnchainConfig, expected 1, got 25600") + require.ErrorIs(t, err, ErrUnsupportedOnchainConfigVersion) + require.EqualError(t, err, "unsupported OnchainConfig version: got 25600, supported 1; upgrade the node before publishing a config with a newer version") }) t.Run("encoding nil PredecessorConfigDigest is ok", func(t *testing.T) { From 5cddfafae681a0f3d0396f76949b463f73a20e7e Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 18:08:50 +0100 Subject: [PATCH 037/103] llo/protocol: remove unused DropInvalidHistoryBackfillChannels --- llo/protocol/history_backfill.go | 23 ----------------------- llo/v30/history_backfill_test.go | 22 ---------------------- 2 files changed, 45 deletions(-) diff --git a/llo/protocol/history_backfill.go b/llo/protocol/history_backfill.go index 46bb542..426dbe6 100644 --- a/llo/protocol/history_backfill.go +++ b/llo/protocol/history_backfill.go @@ -11,7 +11,6 @@ import ( "github.com/goccy/go-json" - "github.com/smartcontractkit/chainlink-common/pkg/logger" llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" ) @@ -358,28 +357,6 @@ func ValidateHistoryBackfillValues(cd llotypes.ChannelDefinition, defs llotypes. return nil } -// DropInvalidHistoryBackfillChannels returns a copy of defs without history_backfill channels that fail validation. -// The input defs map is not modified. nowNanos should be wall-clock nanoseconds; use 0 to skip the future-timestamp check. -func DropInvalidHistoryBackfillChannels(lggr logger.Logger, defs llotypes.ChannelDefinitions, nowNanos uint64) llotypes.ChannelDefinitions { - if defs == nil { - return nil - } - out := make(llotypes.ChannelDefinitions, len(defs)) - for k, v := range defs { - out[k] = v - } - for cid, cd := range out { - if cd.ReportFormat != llotypes.ReportFormatHistoryBackfill { - continue - } - if err := ValidateHistoryBackfillAgainstDefinitions(cd, out, nowNanos); err != nil { - logger.Sugared(lggr).Warnw("dropping invalid history_backfill channel definition", "channelID", cid, "err", err) - delete(out, cid) - } - } - return out -} - // StreamValueFromBackfillString parses a backfill observation string for the given aggregator. func StreamValueFromBackfillString(agg llotypes.Aggregator, s string) (StreamValue, error) { switch agg { diff --git a/llo/v30/history_backfill_test.go b/llo/v30/history_backfill_test.go index c1329b8..50fa8dc 100644 --- a/llo/v30/history_backfill_test.go +++ b/llo/v30/history_backfill_test.go @@ -6,9 +6,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/smartcontractkit/chainlink-common/pkg/logger" llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" - "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" protocol "github.com/smartcontractkit/chainlink-data-streams/llo/protocol" ) @@ -88,26 +86,6 @@ func TestValidateHistoryBackfillAgainstDefinitions_invalidTargetOpts(t *testing. require.Contains(t, err.Error(), "target channel opts") } -func TestDropInvalidHistoryBackfillChannels_dropsInvalid(t *testing.T) { - tests.Context(t) - lggr := logger.Test(t) - streams := []llotypes.Stream{{StreamID: 1, Aggregator: llotypes.AggregatorMedian}} - defs := llotypes.ChannelDefinitions{ - 2: {ReportFormat: llotypes.ReportFormatJSON, Streams: streams}, - 10: { - ReportFormat: llotypes.ReportFormatHistoryBackfill, - Streams: streams, - Opts: []byte(`{"targetChannelId":99,"observations":{"1":{"1":"1"}}}`), - }, - } - ref := uint64(time.Now().Add(time.Hour).UnixNano()) - out := protocol.DropInvalidHistoryBackfillChannels(lggr, defs, ref) - _, ok := out[10] - require.False(t, ok) - _, still := defs[10] - require.True(t, still, "input definitions map must not be mutated") -} - func FuzzParseHistoryBackfillOpts(f *testing.F) { f.Add([]byte(`{"targetChannelId":1,"observations":{"1":{"1":"1"}}}`)) f.Fuzz(func(t *testing.T, data []byte) { From 87e10b1108864248f9fa353559ab6ad2f8add62d Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 18:20:13 +0100 Subject: [PATCH 038/103] llo/calculated: document minSamples as the cumulative TWAP gap bound --- llo/protocol/calculated/functions_twap.go | 4 +++- llo/protocol/calculated/functions_twap_test.go | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/llo/protocol/calculated/functions_twap.go b/llo/protocol/calculated/functions_twap.go index e62c001..83ef1df 100644 --- a/llo/protocol/calculated/functions_twap.go +++ b/llo/protocol/calculated/functions_twap.go @@ -31,7 +31,9 @@ var ( type TWAPRejectionReason string const ( - // ReasonInsufficientSamples: M < minSamples, the coverage floor. + // ReasonInsufficientSamples: M < minSamples, the coverage floor. It bounds the + // total filled buckets, N - M, across head, interior and tail gaps combined; + // maxInteriorGap bounds only the longest single interior run. ReasonInsufficientSamples TWAPRejectionReason = "min_samples" // ReasonHeadGapTooLong: Ghead > maxHeadGap, backfilled prefix too long. ReasonHeadGapTooLong TWAPRejectionReason = "head_gap_too_long" diff --git a/llo/protocol/calculated/functions_twap_test.go b/llo/protocol/calculated/functions_twap_test.go index 35d5a5a..7182f5d 100644 --- a/llo/protocol/calculated/functions_twap_test.go +++ b/llo/protocol/calculated/functions_twap_test.go @@ -96,6 +96,21 @@ func TestTWAP_FillThenAverage(t *testing.T) { // Interpolating in log space doubles each step: 100,200,400,800,1600. want: "620", }, + { + // maxInteriorGap bounds each run, not their total. The total is + // bounded by minSamples, so the window is accepted at M == minSamples. + name: "several interior gaps each at the threshold: accepted at the coverage floor", + prices: []int64{100, 0, 400, 0, 1600}, // M=3, two runs of Gint=1 + minSamples: 3, maxInterior: 1, + // Interpolating in log space doubles each step: 100,200,400,800,1600. + want: "620", + }, + { + name: "several interior gaps each at the threshold: rejected below the coverage floor", + prices: []int64{100, 0, 400, 0, 1600}, + minSamples: 4, maxInterior: 1, + wantReasons: []TWAPRejectionReason{ReasonInsufficientSamples}, + }, { name: "interior gap one over the threshold: rejected", prices: []int64{100, 0, 0, 0, 1600}, From b8796b4c5319499651266f79234778c12c4eea29 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 18:26:18 +0100 Subject: [PATCH 039/103] llo/protocol: return ErrNilStreamValue on nil receiver in binary unmarshal --- llo/protocol/stream_value.go | 6 ++++++ llo/protocol/stream_value_test.go | 14 ++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/llo/protocol/stream_value.go b/llo/protocol/stream_value.go index c934d62..56ea5fb 100644 --- a/llo/protocol/stream_value.go +++ b/llo/protocol/stream_value.go @@ -315,6 +315,9 @@ func (v *Quote) MarshalBinary() (b []byte, err error) { } func (v *Quote) UnmarshalBinary(data []byte) error { + if v == nil { + return ErrNilStreamValue + } q := new(LLOStreamValueQuote) if err := proto.Unmarshal(data, q); err != nil { return err @@ -455,6 +458,9 @@ func (v *TimestampedStreamValue) UnmarshalBinary(data []byte) error { // unmarshalBinary carries the nesting depth reached so far. See // unmarshalProtoStreamValue. func (v *TimestampedStreamValue) unmarshalBinary(data []byte, depth int) error { + if v == nil { + return ErrNilStreamValue + } t := new(LLOTimestampedStreamValue) if err := proto.Unmarshal(data, t); err != nil { return err diff --git a/llo/protocol/stream_value_test.go b/llo/protocol/stream_value_test.go index 4b20529..a307426 100644 --- a/llo/protocol/stream_value_test.go +++ b/llo/protocol/stream_value_test.go @@ -44,6 +44,20 @@ func Test_TimestampedStreamValue_NilInnerValue(t *testing.T) { }) } +func Test_StreamValue_UnmarshalNilReceiver(t *testing.T) { + for _, sv := range []StreamValue{ + (*Decimal)(nil), + (*Quote)(nil), + (*TimestampedStreamValue)(nil), + } { + t.Run(reflect.TypeOf(sv).Elem().Name(), func(t *testing.T) { + require.ErrorIs(t, sv.UnmarshalBinary(nil), ErrNilStreamValue) + require.ErrorIs(t, sv.UnmarshalText(nil), ErrNilStreamValue) + require.ErrorIs(t, Decode(sv, nil), ErrNilStreamValue) + }) + } +} + func Test_Decimal_ExponentBound(t *testing.T) { // A decimal with an unbounded exponent is only 6 bytes on the wire but // costs hundreds of MB to compare during aggregation, so it must be From 70064aac2574f13db104ab04e3972a8bb99ced35 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 18:40:58 +0100 Subject: [PATCH 040/103] llo/v31: document ReportCodecs as fixed after construction --- llo/dev/v31/plugin.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/llo/dev/v31/plugin.go b/llo/dev/v31/plugin.go index 57d5c64..816e573 100644 --- a/llo/dev/v31/plugin.go +++ b/llo/dev/v31/plugin.go @@ -49,8 +49,10 @@ type Plugin struct { F int OracleID commontypes.OracleID RetirementReportCodec protocol.RetirementReportCodec - ReportCodecs map[llotypes.ReportFormat]protocol.ReportCodec - DonID uint32 + // ReportCodecs must not change after construction: ChannelAnalysisCache + // keys the facts derived from them by definition alone. + ReportCodecs map[llotypes.ReportFormat]protocol.ReportCodec + DonID uint32 // ChannelCache memoizes the channel definitions record, together with the // opts decoded from it, across rounds so it is re-read only when its // sequence number changes. May be nil, in which case a fresh generation is From 7d6c76fd1bc59ad5d1af993ef435acb02f2621ca Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 18:48:33 +0100 Subject: [PATCH 041/103] llo/protocol: assert history pair cap fits byte budget at compile time --- llo/dev/v31/factory.go | 6 ++++-- llo/dev/v31/history_requirements.go | 4 ++-- llo/protocol/limits.go | 21 ++++++++++++--------- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/llo/dev/v31/factory.go b/llo/dev/v31/factory.go index b4507b0..9490130 100644 --- a/llo/dev/v31/factory.go +++ b/llo/dev/v31/factory.go @@ -211,8 +211,10 @@ func (f *PluginFactory) NewReportingPlugin(ctx context.Context, cfg ocr3types.Re // where it is written into the aggregates. // MaxKeyValueModifiedKeys* the per-round write set is c/defs plus r/agg // plus the history windows, and history is - // held to protocol.MaxHistoryTotalBytes so it - // cannot consume the whole budget on its own. + // bounded by protocol.MaxHistoryPairs, which + // protocol.MaxHistoryTotalBytes guarantees at + // compile time fits the budget, so it cannot + // consume the whole budget on its own. // MaxBlobPayloadBytes enforced on the write side by // encodeBlobPayload. info := ocr3_1types.ReportingPluginInfo1{ diff --git a/llo/dev/v31/history_requirements.go b/llo/dev/v31/history_requirements.go index 04b4d30..f433b24 100644 --- a/llo/dev/v31/history_requirements.go +++ b/llo/dev/v31/history_requirements.go @@ -107,8 +107,8 @@ func computeHistoryRequirements(defs llotypes.ChannelDefinitions, optsCache *pro // The pair cap is the only rule. Under the chunked layout a round rewrites one // chunk and one header per pair whatever the depth, so a pair's cost against the // per-round byte budget is a constant — MaxHistoryPairs of them fit inside -// MaxHistoryTotalBytes by construction, which TestHistoryBudgetFitsPairCap -// asserts. That was not true of the single-blob layout, where cost scaled with +// MaxHistoryTotalBytes by construction, which limits.go asserts at compile +// time. That was not true of the single-blob layout, where cost scaled with // depth and the budget denied pairs long before the cap did. func admitHistoryRequirements(required map[histKey]uint32, lggr logger.Logger) historyRequirements { keys := make([]histKey, 0, len(required)) diff --git a/llo/protocol/limits.go b/llo/protocol/limits.go index 400bf86..4a82390 100644 --- a/llo/protocol/limits.go +++ b/llo/protocol/limits.go @@ -179,11 +179,12 @@ const ( // silently getting a shortened window. A stream aggregated two ways // consumes two of these, not one. MaxHistoryPairs = 128 - // MaxHistoryTotalBytes bounds the estimated total history bytes rewritten - // per round (sum over pairs of requiredCount * MaxHistoryRecordBytes). The - // OCR3.1 per-round budget for modified keys plus values is 10 MiB and is - // shared with channel definitions and the other key prefixes, so history is - // held well below it. + // MaxHistoryTotalBytes bounds the history bytes rewritten per round. The + // worst case is MaxHistoryPairs * MaxHistoryPairRoundBytes, held under this + // bound at compile time, so admission enforces only the pair cap. The OCR3.1 + // per-round budget for modified keys plus values is 10 MiB and is shared + // with channel definitions and the other key prefixes, so history is held + // well below it. MaxHistoryTotalBytes = 4 << 20 // MaxHistoryRecordsPerExpression bounds the total history depth a single // expression may request, summed over all of its History calls. Each @@ -229,10 +230,9 @@ const ( // whole-set budgets. MaxTotalTWAPCalls = 64 // MaxHistoryRecordBytes is the maximum serialized size of one history - // record, enforced on append (StreamHistory.Append) and used as the - // per-record size when admitting pairs against MaxHistoryTotalBytes. - // Enforcing the same number that the budget assumes is what makes the - // budget a real bound rather than an estimate. + // record, enforced on append (StreamHistory.Append) and on chunk decode. It + // sizes MaxHistoryPairRoundBytes, so enforcing it is what makes the + // per-round budget a real bound rather than an estimate. // // It has to be enforced rather than assumed because MaxDecimalExponent // bounds a decimal's exponent but not its coefficient length: a 1000-digit @@ -296,6 +296,9 @@ const ( // independent of the window depth, where the single-blob layout charged // requiredCount * MaxHistoryRecordBytes every round. MaxHistoryPairRoundBytes = MaxHistoryChunkRecords*MaxHistoryRecordBytes + MaxHistoryHeaderBytes + // Admission enforces only MaxHistoryPairs, so every admitted pair at its + // worst case must fit MaxHistoryTotalBytes. Fail compilation otherwise. + _ = uint(MaxHistoryTotalBytes - MaxHistoryPairs*MaxHistoryPairRoundBytes) // MaxDecompressedObservationLength bounds the size of an observation after // zstd decompression. From 3b78b51ca8f5cdb8ecbccf8159f489a9c4c7dbfc Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 12 Oct 2026 18:53:31 +0100 Subject: [PATCH 042/103] llo/protocol: assert history chunk size divides max depth at compile time --- llo/protocol/limits.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/llo/protocol/limits.go b/llo/protocol/limits.go index 4a82390..3b9111d 100644 --- a/llo/protocol/limits.go +++ b/llo/protocol/limits.go @@ -260,7 +260,7 @@ const ( // Changing it changes the stored layout and requires resetting every window. // // It must divide MaxHistoryRecordsPerPair, so that a window at maximum depth - // is an exact number of chunks (asserted by TestHistoryChunkLimits). + // is an exact number of chunks (asserted below at compile time). MaxHistoryChunkRecords = 64 // MaxHistoryChunkSlots is the size of the ring: the number of distinct chunk // slots one pair may occupy. @@ -277,6 +277,9 @@ const ( // every slot. Reuse of a slot across a lap of the ring is caught by the // sequence stored inside each chunk. MaxHistoryChunkSlots = MaxHistoryRecordsPerPair/MaxHistoryChunkRecords + 2 + // The chunk size must divide the maximum depth, or the floor above + // undersizes the ring by one slot. Fail compilation otherwise. + _ = uint(0 - MaxHistoryRecordsPerPair%MaxHistoryChunkRecords) // MaxHistoryRetainedRecords is the most records a window may hold. Retention // works in whole chunks, so a window overshoots its required depth by up to // one chunk less one record. Readers ask for an exact depth and never see From 76109507814a143c331b6cab55906910ee99e286 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Tue, 13 Oct 2026 09:14:55 +0100 Subject: [PATCH 043/103] llo/v31: document blob handles as unbound to the citing oracle --- llo/dev/v31/observation.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/llo/dev/v31/observation.go b/llo/dev/v31/observation.go index 18d7323..d3e8b92 100644 --- a/llo/dev/v31/observation.go +++ b/llo/dev/v31/observation.go @@ -196,6 +196,12 @@ func decodeObservation(ctx context.Context, raw ocrtypes.Observation, bf ocr3_1t // Fetch and merge blob-carried stream values. The decompression budget is // shared across handles, so a peer cannot multiply the work it imposes by // naming several blobs. + // + // A handle is not checked against the citing oracle: any oracle may cite a + // blob another oracle submitted. This does not inflate contributions, since + // libocr guarantees at most one observation per oracle, and citing a peer's + // blob gains a faulty oracle nothing it could not get by submitting the + // same values itself. budget := maxObservationDecompressedBytes for _, h := range handles { entry, ok := memo.get(h.key) From e4b040921ae2168af3e03ec3c6b61199f528a816 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Tue, 13 Oct 2026 09:22:32 +0100 Subject: [PATCH 044/103] llo/v31: document f+1 retirement threshold and pin it with a boundary test --- llo/dev/v31/flow_test.go | 47 ++++++++++++++++++++++++++++++++++ llo/dev/v31/statetransition.go | 3 +++ 2 files changed, 50 insertions(+) diff --git a/llo/dev/v31/flow_test.go b/llo/dev/v31/flow_test.go index 5704cfa..5736fc6 100644 --- a/llo/dev/v31/flow_test.go +++ b/llo/dev/v31/flow_test.go @@ -580,6 +580,53 @@ func Test_StateTransition_Retirement(t *testing.T) { require.Equal(t, protocol.LifeCycleStageRetired, reports[0].ReportWithInfo.Info.LifeCycleStage) } +// Test_StateTransition_RetirementThreshold pins retirement at f+1 votes out of +// the 2f+1 observations a round may carry: f votes must not retire, and +// staging instances never retire on votes. +func Test_StateTransition_RetirementThreshold(t *testing.T) { + ctx := tests.Context(t) + + // retireRound returns 2f+1 observations, the first votes of which vote to + // retire. + retireRound := func(t *testing.T, votes int) []ocrtypes.AttributedObservation { + aos := make([]ocrtypes.AttributedObservation, 0, 3) + for i := 0; i < 3; i++ { + obs := Observation{UnixTimestampNanoseconds: 2000, ShouldRetire: i < votes} + aos = append(aos, ao(i, mustEncodeObs(t, obs))) + } + return aos + } + + productionPlugin := func(t *testing.T) (*Plugin, *memKV) { + p := testPlugin(t) + kv := newMemKV() + _, err := p.StateTransition(ctx, 1, ocrtypes.AttributedQuery{}, []ocrtypes.AttributedObservation{ao(0, nil), ao(1, nil), ao(2, nil)}, kv, testBlobs) + require.NoError(t, err) + return p, kv + } + + for _, tc := range []struct { + name string + setup func(t *testing.T) (*Plugin, *memKV) + votes int + want llotypes.LifeCycleStage + }{ + {"production with f votes", productionPlugin, 1, protocol.LifeCycleStageProduction}, + {"production with f+1 votes", productionPlugin, 2, protocol.LifeCycleStageRetired}, + {"staging with f+1 votes", func(t *testing.T) (*Plugin, *memKV) { + return stagedPlugin(t, &mockPredecessorRetirementReportCache{}) + }, 2, protocol.LifeCycleStageStaging}, + } { + t.Run(tc.name, func(t *testing.T) { + p, kv := tc.setup(t) + require.Equal(t, 1, p.F) + _, err := p.StateTransition(ctx, 2, ocrtypes.AttributedQuery{}, retireRound(t, tc.votes), kv, testBlobs) + require.NoError(t, err) + require.Equal(t, string(tc.want), string(kv.m[string(keyLifecycle)])) + }) + } +} + // Test_ValidateObservation_RemoveAddSwapAtBudget pins that the set verified is // the one the observation advocates: a swap that keeps the definition set // inside a whole-set budget must validate, even though the committed set plus diff --git a/llo/dev/v31/statetransition.go b/llo/dev/v31/statetransition.go index e12cffa..94b30cb 100644 --- a/llo/dev/v31/statetransition.go +++ b/llo/dev/v31/statetransition.go @@ -115,6 +115,9 @@ func (p *Plugin) StateTransition(ctx context.Context, seqNr uint64, _ ocrtypes.A } else { out.LifeCycleStage = prev.lifeCycleStage } + // ShouldRetire is a monotone onchain flag per config digest, so f+1 votes + // imply at least one honest oracle read it set. Requiring more would let f + // faulty oracles block retirement whenever only 2f+1 observations arrive. if out.LifeCycleStage == protocol.LifeCycleStageProduction && tally.shouldRetireVotes > p.F { p.Logger.Infow("Retiring production protocol instance ⚰️", "seqNr", seqNr) out.LifeCycleStage = protocol.LifeCycleStageRetired From 710241ebf42cfee31d8fb0b98454ef334429c560 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Tue, 13 Oct 2026 09:32:23 +0100 Subject: [PATCH 045/103] llo/v31: bound timestamped aggregates to consensus time plus skew --- llo/dev/v31/plugin_test.go | 76 ++++++++++++++++++++++++++++++++++ llo/dev/v31/statetransition.go | 35 ++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/llo/dev/v31/plugin_test.go b/llo/dev/v31/plugin_test.go index 35f0e58..a004614 100644 --- a/llo/dev/v31/plugin_test.go +++ b/llo/dev/v31/plugin_test.go @@ -781,6 +781,82 @@ func Test_TimestampedAggregate_CarryForwardOnAggregationFailure(t *testing.T) { }) } +func Test_TimestampedAggregate_RejectsFarFutureTimestamp(t *testing.T) { + p := testPlugin(t) // F=1 + defs := llotypes.ChannelDefinitions{1: {ReportFormat: llotypes.ReportFormatJSON, Streams: []llotypes.Stream{{StreamID: 100, Aggregator: llotypes.AggregatorMedian}}}} + key := histKey{streamID: 100, aggregator: llotypes.AggregatorMedian} + requirements := historyRequirements{depths: map[histKey]uint32{key: 5}} + + const now = uint64(1_000_000_000_000) + limit := now + maxTimestampedFutureSkew + + tsv := func(ts uint64, v int64) *protocol.TimestampedStreamValue { + return &protocol.TimestampedStreamValue{ObservedAtNanoseconds: ts, StreamValue: protocol.ToDecimal(decimal.NewFromInt(v))} + } + obs := func(ts uint64, v int64) map[llotypes.StreamID][]protocol.StreamValue { + return map[llotypes.StreamID][]protocol.StreamValue{100: {tsv(ts, v), tsv(ts, v), tsv(ts, v)}} + } + carryOf := func(c *protocol.TimestampedStreamValue) map[llotypes.StreamID]map[llotypes.Aggregator]*protocol.TimestampedStreamValue { + m := map[llotypes.StreamID]map[llotypes.Aggregator]*protocol.TimestampedStreamValue{} + if c != nil { + m[100] = map[llotypes.Aggregator]*protocol.TimestampedStreamValue{llotypes.AggregatorMedian: c} + } + return m + } + // round aggregates one round and returns the published value, the value + // carried into the next round and the history timestamps. + round := func(t *testing.T, carry *protocol.TimestampedStreamValue, observations map[llotypes.StreamID][]protocol.StreamValue) (published, carried *protocol.TimestampedStreamValue, history []uint64) { + t.Helper() + kv := newCountingKV() + store := newTestHistoryStore(t, kv) + require.NoError(t, requirements.apply(store)) + next := carryOf(nil) + out := protocol.StreamAggregates{} + require.NoError(t, p.aggregate(carryOf(carry), next, defs, observations, out, store, requirements, now)) + require.NoError(t, store.Flush(kv)) + + if v, ok := out[100][llotypes.AggregatorMedian]; ok { + published = v.(*protocol.TimestampedStreamValue) + } + return published, next[100][llotypes.AggregatorMedian], historyTimestamps(readHistoryRecords(t, kv, key.streamID, key.aggregator)) + } + + t.Run("far-future fresh aggregate is rejected and the carry kept", func(t *testing.T) { + published, carried, history := round(t, tsv(now-1, 5), obs(limit+1, 7)) + require.Equal(t, now-1, published.ObservedAtNanoseconds) + require.Equal(t, now-1, carried.ObservedAtNanoseconds) + require.Empty(t, history, "a rejected aggregate must not reach history") + }) + + t.Run("far-future fresh aggregate without a carry leaves the pair absent", func(t *testing.T) { + published, carried, history := round(t, nil, obs(limit+1, 7)) + require.Nil(t, published) + require.Nil(t, carried) + require.Empty(t, history) + }) + + t.Run("far-future carry is dropped and the fresh aggregate adopted", func(t *testing.T) { + published, carried, history := round(t, tsv(limit+1, 5), obs(now, 7)) + require.Equal(t, now, published.ObservedAtNanoseconds) + require.Equal(t, now, carried.ObservedAtNanoseconds) + require.Equal(t, []uint64{now}, history) + }) + + t.Run("far-future carry is dropped when aggregation fails", func(t *testing.T) { + starved := map[llotypes.StreamID][]protocol.StreamValue{100: {tsv(now, 7)}} + published, carried, _ := round(t, tsv(limit+1, 5), starved) + require.Nil(t, published) + require.Nil(t, carried) + }) + + t.Run("aggregate exactly at the skew limit is adopted", func(t *testing.T) { + published, carried, history := round(t, tsv(now, 5), obs(limit, 7)) + require.Equal(t, limit, published.ObservedAtNanoseconds) + require.Equal(t, limit, carried.ObservedAtNanoseconds) + require.Equal(t, []uint64{limit}, history) + }) +} + func Test_Telemetry(t *testing.T) { ctx := tests.Context(t) otCh := make(chan *protocol.LLOOutcomeTelemetry, 8) diff --git a/llo/dev/v31/statetransition.go b/llo/dev/v31/statetransition.go index 94b30cb..b0d3aa3 100644 --- a/llo/dev/v31/statetransition.go +++ b/llo/dev/v31/statetransition.go @@ -10,6 +10,7 @@ import ( "maps" "sort" "sync" + "time" "github.com/smartcontractkit/chainlink-common/pkg/logger" llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" @@ -478,6 +479,11 @@ func applyChannelVotes( // adopted. Regular (non-timestamped) aggregates are recomputed fresh each round // and never persisted. // +// A timestamped value more than maxTimestampedFutureSkew ahead of the round's +// consensus observation timestamp is never adopted, and a carried value that +// far ahead is dropped. Otherwise a single far-future timestamp would win the +// newer-wins comparison and pin the pair, and its history, indefinitely. +// // prevCarry holds the previous round's carry-forward values (read-only); // nextCarry is populated with the values to persist for the next round. A pair // that is not written into nextCarry is dropped from the store, which is how @@ -532,6 +538,12 @@ func (p *Plugin) aggregate( } prevTSV := prevCarry[sid][agg] + if prevTSV != nil && tooFarAhead(prevTSV, observationTimestampNanoseconds) { + p.Logger.Errorw("Dropping carried-forward timestamped aggregate: timestamp too far ahead of consensus time", + "streamID", sid, "aggregator", agg, "observedAtNanoseconds", prevTSV.ObservedAtNanoseconds, + "observationTimestampNanoseconds", observationTimestampNanoseconds) + prevTSV = nil + } aggF := protocol.GetAggregatorFuncV31(agg) if aggF == nil { @@ -567,6 +579,18 @@ func (p *Plugin) aggregate( switch v := result.(type) { case *protocol.TimestampedStreamValue: + if tooFarAhead(v, observationTimestampNanoseconds) { + // Treat as a failed aggregation: keep the carry and record + // nothing into history. + p.Logger.Errorw("Rejecting timestamped aggregate: timestamp too far ahead of consensus time", + "streamID", sid, "aggregator", agg, "observedAtNanoseconds", v.ObservedAtNanoseconds, + "observationTimestampNanoseconds", observationTimestampNanoseconds) + if prevTSV != nil { + m[agg] = prevTSV + keep(sid, agg, prevTSV) + } + continue + } if prevTSV == nil || v.ObservedAtNanoseconds > prevTSV.ObservedAtNanoseconds { // Strictly newer: adopt and persist. m[agg] = v @@ -591,6 +615,17 @@ func (p *Plugin) aggregate( return nil } +// maxTimestampedFutureSkew bounds how far a timestamped aggregate may sit +// ahead of the round's consensus observation timestamp. It absorbs ordinary +// skew between data source timestamps and oracle clocks. +const maxTimestampedFutureSkew = uint64(time.Minute) + +// tooFarAhead reports whether tsv is timestamped beyond the allowed skew past +// nowNanoseconds. +func tooFarAhead(tsv *protocol.TimestampedStreamValue, nowNanoseconds uint64) bool { + return tsv.ObservedAtNanoseconds > nowNanoseconds+maxTimestampedFutureSkew +} + // appendHistory records a pair's agreed value for this round. // // The timestamp is the value's own observation time for timestamped aggregates From 3fed1060eff7eee19bc9e3e3bafbafdce43045b0 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Tue, 13 Oct 2026 09:50:19 +0100 Subject: [PATCH 046/103] llo/v31: warn when agreed observation timestamp leads the local clock Also tighten timestamped future skew to 10s --- llo/dev/v31/plugin_test.go | 32 +++++++++++++++++++++++++++++++ llo/dev/v31/statetransition.go | 35 +++++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/llo/dev/v31/plugin_test.go b/llo/dev/v31/plugin_test.go index a004614..45186a9 100644 --- a/llo/dev/v31/plugin_test.go +++ b/llo/dev/v31/plugin_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/binary" "errors" + "math" "strconv" "sync" "testing" @@ -1495,6 +1496,37 @@ func Test_StateTransition_ObservationTimestampDoesNotRegress(t *testing.T) { require.Equal(t, uint64(2_000), agreedAt(4, ao(0, obsAt(2_000)), ao(1, obsAt(2_000)), ao(2, obsAt(2_000)))) } +// With at least 2f+1 observations and at most f of them byzantine, the median +// stays within the honest range however the byzantine values are placed. +func Test_MedianTimestamp_BoundedByHonestRange(t *testing.T) { + for f := 1; f <= 5; f++ { + for extraHonest := 0; extraHonest <= f; extraHonest++ { + honest := make([]uint64, f+1+extraHonest) + for i := range honest { + honest[i] = uint64(1_000 + i) + } + minHonest, maxHonest := honest[0], honest[len(honest)-1] + for _, byzantine := range []uint64{0, math.MaxUint64} { + ts := append([]uint64{}, honest...) + for range f { + ts = append(ts, byzantine) + } + median := medianTimestamp(ts) + assert.GreaterOrEqual(t, median, minHonest, "f=%d extraHonest=%d byzantine=%d", f, extraHonest, byzantine) + assert.LessOrEqual(t, median, maxHonest, "f=%d extraHonest=%d byzantine=%d", f, extraHonest, byzantine) + } + } + } +} + +func Test_AheadOfClock(t *testing.T) { + const now = uint64(1_000_000_000_000) + assert.False(t, aheadOfClock(now, now)) + assert.False(t, aheadOfClock(now+maxConsensusClockLead, now)) + assert.True(t, aheadOfClock(now+maxConsensusClockLead+1, now)) + assert.False(t, aheadOfClock(0, now)) +} + func Test_Observation_StampsTheRoundNotTheSnapshot(t *testing.T) { ctx := tests.Context(t) p := testPlugin(t) diff --git a/llo/dev/v31/statetransition.go b/llo/dev/v31/statetransition.go index b0d3aa3..e5a1d72 100644 --- a/llo/dev/v31/statetransition.go +++ b/llo/dev/v31/statetransition.go @@ -106,6 +106,7 @@ func (p *Plugin) StateTransition(ctx context.Context, seqNr uint64, _ ocrtypes.A StreamAggregates: protocol.StreamAggregates{}, SupportByFormat: supportVotesForEffectiveFormats(countSupportByFormat(codecSupport), effective), } + p.warnIfAheadOfLocalClock(out.ObservationTimestampNanoseconds, seqNr) // Lifecycle stage & promotion. promotedValidAfter := map[llotypes.ChannelID]uint64(nil) @@ -618,7 +619,7 @@ func (p *Plugin) aggregate( // maxTimestampedFutureSkew bounds how far a timestamped aggregate may sit // ahead of the round's consensus observation timestamp. It absorbs ordinary // skew between data source timestamps and oracle clocks. -const maxTimestampedFutureSkew = uint64(time.Minute) +const maxTimestampedFutureSkew = uint64(10 * time.Second) // tooFarAhead reports whether tsv is timestamped beyond the allowed skew past // nowNanoseconds. @@ -748,6 +749,14 @@ func prevReportable(prev *kvState, channelID llotypes.ChannelID) bool { // next round to exceed that watermark and appendHistory only records a value // strictly newer than the newest stored one. A regression leaves every channel // unreportable and silently drops history appends until the clock catches back up. +// +// The quorum is at least 2f+1 observations, so with at most f faulty oracles +// the median lies within the range of honest timestamps. An oracle with a +// broken clock counts as faulty: f byzantine oracles plus one fast clock can +// move the median ahead of real time, and the floor then holds every channel +// unreportable until real time catches up. No deterministic rule can exclude +// f+1 faults with this quorum, so the only defence is detection, see +// warnIfAheadOfLocalClock. func (p *Plugin) agreedObservationTimestamp(timestampsNanoseconds []uint64, prevObservationTimestampNs uint64, seqNr uint64) uint64 { median := medianTimestamp(timestampsNanoseconds) if median < prevObservationTimestampNs { @@ -758,6 +767,30 @@ func (p *Plugin) agreedObservationTimestamp(timestampsNanoseconds []uint64, prev return median } +// maxConsensusClockLead bounds how far the agreed observation timestamp may +// sit ahead of this node's clock before it is reported. It sits well above +// NTP skew between oracle clocks, and low enough that a forward jump costing +// more than a few rounds of reporting is caught. +const maxConsensusClockLead = uint64(5 * time.Second) + +// warnIfAheadOfLocalClock logs when the agreed observation timestamp is ahead +// of this node's clock by more than maxConsensusClockLead. It reads the local +// clock, so it must only produce side effects and never feed the outcome. +// Replayed rounds carry past timestamps and do not trigger it. +func (p *Plugin) warnIfAheadOfLocalClock(agreedNanoseconds uint64, seqNr uint64) { + nowNanoseconds := uint64(max(time.Now().UnixNano(), 0)) + if aheadOfClock(agreedNanoseconds, nowNanoseconds) { + p.Logger.Errorw("Agreed observation timestamp is ahead of the local clock; either this node's clock is behind or more than f oracles reported fast timestamps", + "seqNr", seqNr, "agreed", agreedNanoseconds, "local", nowNanoseconds, "lead", time.Duration(agreedNanoseconds-nowNanoseconds)) + } +} + +// aheadOfClock reports whether agreedNanoseconds leads nowNanoseconds by more +// than maxConsensusClockLead. +func aheadOfClock(agreedNanoseconds, nowNanoseconds uint64) bool { + return agreedNanoseconds > nowNanoseconds+maxConsensusClockLead +} + func medianTimestamp(timestampsNanoseconds []uint64) uint64 { sort.Slice(timestampsNanoseconds, func(i, j int) bool { return timestampsNanoseconds[i] < timestampsNanoseconds[j] }) return timestampsNanoseconds[len(timestampsNanoseconds)/2] From ed3ab15aa53c06b5a3b67685cd36762ffde919a2 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Tue, 13 Oct 2026 10:11:04 +0100 Subject: [PATCH 047/103] llo/v31: skip unemittable backfill rows and surface unemitted reports --- llo/dev/v31/backfill.go | 69 +++++++++++++++++++--------------- llo/dev/v31/plugin_test.go | 32 +++++++++++----- llo/dev/v31/report_metrics.go | 29 ++++++++++++++ llo/dev/v31/reports.go | 36 ++++++++++++------ llo/dev/v31/statetransition.go | 2 +- 5 files changed, 117 insertions(+), 51 deletions(-) create mode 100644 llo/dev/v31/report_metrics.go diff --git a/llo/dev/v31/backfill.go b/llo/dev/v31/backfill.go index 2b832e1..3fc6301 100644 --- a/llo/dev/v31/backfill.go +++ b/llo/dev/v31/backfill.go @@ -1,6 +1,8 @@ package llo import ( + "sort" + llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" protocol "github.com/smartcontractkit/chainlink-data-streams/llo/protocol" @@ -12,34 +14,42 @@ import ( // definition, wrong format, missing target, no watermark, or backfill complete). // // The watermark is validAfter[backfillCID] (the last emitted backfill observation -// time); the next candidate is the smallest observation strictly after the -// watermark and strictly before obsTsNanos. This mirrors the v30 +// time); the next candidate is the smallest emittable observation strictly after +// the watermark and strictly before obsTsNanos. This mirrors the v30 // SelectBackfillCandidate but operates on plain fields (shared by precursor and // kvState) and returns a bool instead of an UnreportableChannelError. -func selectBackfillCandidate(defs llotypes.ChannelDefinitions, validAfter map[llotypes.ChannelID]uint64, obsTsNanos uint64, backfillCID llotypes.ChannelID, optsCache *protocol.OptsCache) (tsNanos uint64, rawTS uint64, opts protocol.HistoryBackfillOpts, ok bool) { +// +// skipped counts the earlier rows in the window passed over as unemittable. +// Selecting the next row consumes them: the watermark advances past them. +func selectBackfillCandidate(defs llotypes.ChannelDefinitions, validAfter map[llotypes.ChannelID]uint64, obsTsNanos uint64, backfillCID llotypes.ChannelID, optsCache *protocol.OptsCache) (tsNanos uint64, rawTS uint64, opts protocol.HistoryBackfillOpts, skipped int, ok bool) { cd, exists := defs[backfillCID] if !exists || cd.ReportFormat != llotypes.ReportFormatHistoryBackfill { - return 0, 0, protocol.HistoryBackfillOpts{}, false + return 0, 0, protocol.HistoryBackfillOpts{}, 0, false } o, err := protocol.GetHistoryBackfillOpts(optsCache, cd, backfillCID) if err != nil { - return 0, 0, protocol.HistoryBackfillOpts{}, false + return 0, 0, protocol.HistoryBackfillOpts{}, 0, false } target, exists := defs[o.TargetChannelID] if !exists { - return 0, 0, protocol.HistoryBackfillOpts{}, false + return 0, 0, protocol.HistoryBackfillOpts{}, 0, false } res, err := protocol.TargetChannelTimeResolution(target) if err != nil { - return 0, 0, protocol.HistoryBackfillOpts{}, false + return 0, 0, protocol.HistoryBackfillOpts{}, 0, false + } + // Reports needs the target's report-timestamp resolution. It depends on the + // target alone, so when it fails no row can be emitted. + if _, err := protocol.ReportTimestampResolutionNanos(target); err != nil { + return 0, 0, protocol.HistoryBackfillOpts{}, 0, false } watermark, exists := validAfter[backfillCID] if !exists { - return 0, 0, protocol.HistoryBackfillOpts{}, false + return 0, 0, protocol.HistoryBackfillOpts{}, 0, false } - var bestRaw, bestNanos uint64 - found := false + type candidate struct{ raw, nanos uint64 } + var candidates []candidate for rawKey := range o.Observations { tsN, ok := protocol.ObservationTimestampKeyToNanoseconds(rawKey, res) if !ok { @@ -50,26 +60,25 @@ func selectBackfillCandidate(defs llotypes.ChannelDefinitions, validAfter map[ll if tsN >= obsTsNanos || tsN <= watermark { continue } - if !found || tsN < bestNanos || (tsN == bestNanos && rawKey < bestRaw) { - found = true - bestNanos = tsN - bestRaw = rawKey - } - } - if !found { - return 0, 0, protocol.HistoryBackfillOpts{}, false + candidates = append(candidates, candidate{raw: rawKey, nanos: tsN}) } - // The candidate must also be emittable, not merely selectable. Reports - // needs the target's report-timestamp resolution and the row's stream - // values; if either fails there, the report is skipped while the watermark - // has already advanced past the row in the state transition, losing it - // permanently. Both are pure functions of (target definition, row), so - // checking them here keeps selection and emission on one path. - if _, err := protocol.ReportTimestampResolutionNanos(target); err != nil { - return 0, 0, protocol.HistoryBackfillOpts{}, false - } - if _, err := protocol.BuildBackfillStreamValues(target, o.Observations[bestRaw]); err != nil { - return 0, 0, protocol.HistoryBackfillOpts{}, false + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].nanos != candidates[j].nanos { + return candidates[i].nanos < candidates[j].nanos + } + return candidates[i].raw < candidates[j].raw + }) + // The candidate must be emittable, not merely selectable, or Reports skips + // it after the watermark has already advanced past it. Building the row's + // stream values is a pure function of (target definition, row), so checking + // it here keeps selection and emission on one path. A row that cannot be + // built never will be: pass over it rather than stall the backfill on it. + for _, c := range candidates { + if _, err := protocol.BuildBackfillStreamValues(target, o.Observations[c.raw]); err != nil { + skipped++ + continue + } + return c.nanos, c.raw, o, skipped, true } - return bestNanos, bestRaw, o, true + return 0, 0, protocol.HistoryBackfillOpts{}, skipped, false } diff --git a/llo/dev/v31/plugin_test.go b/llo/dev/v31/plugin_test.go index 45186a9..77bafd5 100644 --- a/llo/dev/v31/plugin_test.go +++ b/llo/dev/v31/plugin_test.go @@ -990,17 +990,17 @@ func Test_HistoryBackfill(t *testing.T) { defs := llotypes.ChannelDefinitions{targetCID: targetCD, backfillCID: backfillCD} // Candidate selection advances with the watermark, then completes. - ts, raw, opts, ok := selectBackfillCandidate(defs, map[llotypes.ChannelID]uint64{backfillCID: 0}, tenSec, backfillCID, nil) + ts, raw, opts, _, ok := selectBackfillCandidate(defs, map[llotypes.ChannelID]uint64{backfillCID: 0}, tenSec, backfillCID, nil) require.True(t, ok) require.Equal(t, fiveSec, ts) require.Equal(t, uint64(5), raw) require.Equal(t, targetCID, opts.TargetChannelID) - ts2, _, _, ok2 := selectBackfillCandidate(defs, map[llotypes.ChannelID]uint64{backfillCID: fiveSec}, tenSec, backfillCID, nil) + ts2, _, _, _, ok2 := selectBackfillCandidate(defs, map[llotypes.ChannelID]uint64{backfillCID: fiveSec}, tenSec, backfillCID, nil) require.True(t, ok2) require.Equal(t, eightSec, ts2) - _, _, _, ok3 := selectBackfillCandidate(defs, map[llotypes.ChannelID]uint64{backfillCID: eightSec}, tenSec, backfillCID, nil) + _, _, _, _, ok3 := selectBackfillCandidate(defs, map[llotypes.ChannelID]uint64{backfillCID: eightSec}, tenSec, backfillCID, nil) require.False(t, ok3, "backfill should be complete once watermark passes the last observation") // Reports emits the backfill report, encoded with the target channel's format. @@ -1236,20 +1236,22 @@ func Test_SelectBackfillCandidate_UnemittableRow(t *testing.T) { targetCID = llotypes.ChannelID(10) backfillCID = llotypes.ChannelID(20) tenSec = uint64(10_000_000_000) + sevenSec = uint64(7_000_000_000) ) targetCD := llotypes.ChannelDefinition{ReportFormat: llotypes.ReportFormatJSON, Streams: []llotypes.Stream{{StreamID: 100, Aggregator: llotypes.AggregatorMedian}}} - // Row carries stream 999, not the target's stream 100, so - // BuildBackfillStreamValues would fail in Reports. The candidate must not - // be selectable: the watermark would otherwise advance past a row that - // never emitted, losing it permanently. + // Row 5 carries stream 999, not the target's stream 100, so + // BuildBackfillStreamValues would fail in Reports. Selecting it would advance + // the watermark past a row that never emitted, and refusing to select past it + // would stall the backfill on it for good: selection passes over it instead. missingStream := llotypes.ChannelDefinition{ ReportFormat: llotypes.ReportFormatHistoryBackfill, Opts: []byte(`{"targetChannelId":10,"observations":{"5":{"999":"1.5"}}}`), } defs := llotypes.ChannelDefinitions{targetCID: targetCD, backfillCID: missingStream} - _, _, _, ok := selectBackfillCandidate(defs, map[llotypes.ChannelID]uint64{backfillCID: 0}, tenSec, backfillCID, nil) + _, _, _, skipped, ok := selectBackfillCandidate(defs, map[llotypes.ChannelID]uint64{backfillCID: 0}, tenSec, backfillCID, nil) require.False(t, ok, "row missing a target stream must not be selectable") + require.Equal(t, 1, skipped) // Same row shape, but the value is not parseable as a stream value. badValue := llotypes.ChannelDefinition{ @@ -1257,8 +1259,20 @@ func Test_SelectBackfillCandidate_UnemittableRow(t *testing.T) { Opts: []byte(`{"targetChannelId":10,"observations":{"5":{"100":"not-a-number"}}}`), } defs[backfillCID] = badValue - _, _, _, ok = selectBackfillCandidate(defs, map[llotypes.ChannelID]uint64{backfillCID: 0}, tenSec, backfillCID, nil) + _, _, _, skipped, ok = selectBackfillCandidate(defs, map[llotypes.ChannelID]uint64{backfillCID: 0}, tenSec, backfillCID, nil) require.False(t, ok, "row with an unparseable value must not be selectable") + require.Equal(t, 1, skipped) + + // A later emittable row is selected past both unemittable ones. + defs[backfillCID] = llotypes.ChannelDefinition{ + ReportFormat: llotypes.ReportFormatHistoryBackfill, + Opts: []byte(`{"targetChannelId":10,"observations":{"5":{"999":"1.5"},"6":{"100":"not-a-number"},"7":{"100":"2.5"}}}`), + } + ts, raw, _, skipped, ok := selectBackfillCandidate(defs, map[llotypes.ChannelID]uint64{backfillCID: 0}, tenSec, backfillCID, nil) + require.True(t, ok, "an emittable row after unemittable ones must be selectable") + require.Equal(t, sevenSec, ts) + require.Equal(t, uint64(7), raw) + require.Equal(t, 2, skipped) } // withSupport returns a copy of the precursor with n oracles advertising a diff --git a/llo/dev/v31/report_metrics.go b/llo/dev/v31/report_metrics.go new file mode 100644 index 0000000..a266973 --- /dev/null +++ b/llo/dev/v31/report_metrics.go @@ -0,0 +1,29 @@ +package llo + +import ( + "strconv" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + + llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" +) + +// reportsNotEmittedMetric counts reportable channels that produced no report, +// and backfill rows passed over as unemittable. The state transition advances +// the watermark on reportability alone, so each count is an interval no report +// covers. Node-local, with no effect on consensus. +var reportsNotEmittedMetric = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: "llo", + Subsystem: "reports", + Name: "not_emitted_total", + Help: "Reportable channels that emitted no report, and backfill rows skipped as unemittable. Each is an interval no report covers.", +}, []string{"donID", "reportFormat", "reason"}) + +// reportNotEmitted logs and counts a reportable channel that emitted no report +// this round. Its watermark advances regardless, so the round's interval is lost. +func (p *Plugin) reportNotEmitted(seqNr uint64, channelID llotypes.ChannelID, format llotypes.ReportFormat, reason string, kv ...any) { + kv = append([]any{"reason", reason, "channelID", channelID, "reportFormat", format, "stage", "Report", "seqNr", seqNr}, kv...) + p.Logger.Errorw("Reportable channel emitted no report; its interval is not covered", kv...) + reportsNotEmittedMetric.WithLabelValues(strconv.FormatUint(uint64(p.DonID), 10), format.String(), reason).Inc() +} diff --git a/llo/dev/v31/reports.go b/llo/dev/v31/reports.go index 5da7a12..765fe04 100644 --- a/llo/dev/v31/reports.go +++ b/llo/dev/v31/reports.go @@ -5,6 +5,7 @@ import ( "fmt" "math" "sort" + "strconv" "github.com/smartcontractkit/chainlink-common/pkg/logger" llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" @@ -69,24 +70,28 @@ func (p *Plugin) Reports(ctx context.Context, seqNr uint64, rawPrecursor ocr3_1t cd := out.ChannelDefinitions[cid] if cd.ReportFormat == llotypes.ReportFormatHistoryBackfill { - tsNanos, rawTS, opts, ok := selectBackfillCandidate(out.ChannelDefinitions, out.ValidAfterNanoseconds, out.ObservationTimestampNanoseconds, cid, channelOpts) + tsNanos, rawTS, opts, skipped, ok := selectBackfillCandidate(out.ChannelDefinitions, out.ValidAfterNanoseconds, out.ObservationTimestampNanoseconds, cid, channelOpts) if !ok { - p.Logger.Warnw("backfill channel was reportable but selection failed", "channelID", cid, "stage", "Report", "seqNr", seqNr) + p.reportNotEmitted(seqNr, cid, cd.ReportFormat, "backfill_selection_failed") continue } + if skipped > 0 { + p.Logger.Errorw("Skipped unemittable backfill rows; the watermark advances past them", "channelID", cid, "skipped", skipped, "selectedTimestampNanoseconds", tsNanos, "stage", "Report", "seqNr", seqNr) + reportsNotEmittedMetric.WithLabelValues(strconv.FormatUint(uint64(p.DonID), 10), cd.ReportFormat.String(), "backfill_row_unemittable").Add(float64(skipped)) + } targetCD, exists := out.ChannelDefinitions[opts.TargetChannelID] if !exists { - p.Logger.Warnw("missing target channel for history_backfill", "channelID", cid, "targetChannelID", opts.TargetChannelID, "stage", "Report", "seqNr", seqNr) + p.reportNotEmitted(seqNr, cid, cd.ReportFormat, "backfill_target_missing", "targetChannelID", opts.TargetChannelID) continue } values, err := protocol.BuildBackfillStreamValues(targetCD, opts.Observations[rawTS]) if err != nil { - p.Logger.Warnw("Error building backfill stream values", "err", err, "channelID", cid, "stage", "Report", "seqNr", seqNr) + p.reportNotEmitted(seqNr, cid, cd.ReportFormat, "backfill_values_unbuildable", "err", err) continue } resNanos, err := protocol.ReportTimestampResolutionNanos(targetCD) if err != nil { - p.Logger.Warnw("Error resolving history_backfill report timestamp resolution", "err", err, "channelID", cid, "stage", "Report", "seqNr", seqNr) + p.reportNotEmitted(seqNr, cid, cd.ReportFormat, "backfill_resolution_invalid", "err", err) continue } // The backfill report spans one resolution tick ending at the selected observation time. @@ -110,12 +115,12 @@ func (p *Plugin) Reports(ctx context.Context, seqNr uint64, rawPrecursor ocr3_1t p.captureReportTelemetry(reportForEncode, targetCD, out.LifeCycleStage) codec, exists := p.ReportCodecs[targetCD.ReportFormat] if !exists { - p.Logger.Warnw("Error encoding backfill report; codec missing for target ReportFormat", "reportFormat", targetCD.ReportFormat, "channelID", cid, "targetChannelID", opts.TargetChannelID, "stage", "Report", "seqNr", seqNr) + p.reportNotEmitted(seqNr, cid, targetCD.ReportFormat, "codec_missing", "targetChannelID", opts.TargetChannelID) continue } encoded, err := codec.Encode(reportForEncode, targetCD, channelOpts) if err != nil { - p.Logger.Warnw("Error encoding backfill report", "reportFormat", targetCD.ReportFormat, "err", err, "channelID", cid, "stage", "Report", "seqNr", seqNr) + p.reportNotEmitted(seqNr, cid, targetCD.ReportFormat, "encode_failed", "targetChannelID", opts.TargetChannelID, "err", err) continue } rwis = append(rwis, ocr3types.ReportPlus[llotypes.ReportInfo]{ @@ -136,7 +141,7 @@ func (p *Plugin) Reports(ctx context.Context, seqNr uint64, rawPrecursor ocr3_1t // encodes as its payload. streams, err := protocol.EffectiveStreams(channelOpts, cd, cid) if err != nil { - p.Logger.Warnw("Error encoding report; cannot derive effective streams", "err", err, "channelID", cid, "stage", "Report", "seqNr", seqNr) + p.reportNotEmitted(seqNr, cid, cd.ReportFormat, "effective_streams_underivable", "err", err) continue } values := make([]protocol.StreamValue, 0, len(streams)) @@ -158,12 +163,12 @@ func (p *Plugin) Reports(ctx context.Context, seqNr uint64, rawPrecursor ocr3_1t codec, exists := p.ReportCodecs[cd.ReportFormat] if !exists { - p.Logger.Warnw("Error encoding report; codec missing for ReportFormat", "reportFormat", cd.ReportFormat, "channelID", cid, "stage", "Report", "seqNr", seqNr) + p.reportNotEmitted(seqNr, cid, cd.ReportFormat, "codec_missing") continue } encoded, encErr := codec.Encode(report, cd, channelOpts) if encErr != nil { - p.Logger.Warnw("Error encoding report", "reportFormat", cd.ReportFormat, "err", encErr, "channelID", cid, "stage", "Report", "seqNr", seqNr) + p.reportNotEmitted(seqNr, cid, cd.ReportFormat, "encode_failed", "err", encErr) continue } rwis = append(rwis, ocr3types.ReportPlus[llotypes.ReportInfo]{ @@ -339,8 +344,11 @@ func (o precursor) isReportable(channelID llotypes.ChannelID, minReportInterval // condition to the watermark, so the two stay consistent. return false } - _, _, opts, ok := selectBackfillCandidate(o.ChannelDefinitions, o.ValidAfterNanoseconds, o.ObservationTimestampNanoseconds, channelID, optsCache) + _, _, opts, skipped, ok := selectBackfillCandidate(o.ChannelDefinitions, o.ValidAfterNanoseconds, o.ObservationTimestampNanoseconds, channelID, optsCache) if !ok { + if skipped > 0 { + tally.note("no emittable backfill row", channelID, "skipped", skipped) + } return false } // Backfill reports are encoded with the target channel's codec, so the @@ -367,6 +375,12 @@ func (o precursor) isReportable(channelID llotypes.ChannelID, minReportInterval // in the state transition. Codec lookup and Encode are not: p.ReportCodecs // is node-local, and reading it here would make the state transition // node-dependent. Those failure modes remain outside this predicate. + // + // When one of them fails, Reports skips the channel and the watermark still + // advances over the round. This is deliberate. A deterministic failure fails + // again in every round, so holding the watermark would recover nothing and + // only widen the next report's interval. Reports logs each skip at error + // level and counts it in reportsNotEmittedMetric. streams, err := protocol.EffectiveStreams(optsCache, cd, channelID) if err != nil { tally.note("cannot derive effective streams", channelID, "err", err) diff --git a/llo/dev/v31/statetransition.go b/llo/dev/v31/statetransition.go index e5a1d72..510e056 100644 --- a/llo/dev/v31/statetransition.go +++ b/llo/dev/v31/statetransition.go @@ -166,7 +166,7 @@ func (p *Plugin) StateTransition(ctx context.Context, seqNr uint64, _ ocrtypes.A // Backfill: prevReportable and selection conditions must be met, or stays put. out.ValidAfterNanoseconds[channelID] = prevValidAfter if prevReportable(prev, channelID) { - if tsNanos, _, _, found := selectBackfillCandidate(effective, prev.validAfterNanoseconds, prev.observationTimestampNs, channelID, prev.opts); found { + if tsNanos, _, _, _, found := selectBackfillCandidate(effective, prev.validAfterNanoseconds, prev.observationTimestampNs, channelID, prev.opts); found { out.ValidAfterNanoseconds[channelID] = tsNanos } } From 222b90110d295351540f62984401aff61ae6fe61 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Tue, 13 Oct 2026 10:21:51 +0100 Subject: [PATCH 048/103] llo/v31: check report overlap at the channel time resolution --- llo/dev/v31/plugin_test.go | 16 +++++++++++++++- llo/dev/v31/reports.go | 7 ++++--- llo/dev/v31/resolution.go | 30 +++++++++++++++++------------- 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/llo/dev/v31/plugin_test.go b/llo/dev/v31/plugin_test.go index 77bafd5..2e30a40 100644 --- a/llo/dev/v31/plugin_test.go +++ b/llo/dev/v31/plugin_test.go @@ -493,7 +493,7 @@ func Test_decodeObservation_RejectsHugeHandleCount(t *testing.T) { require.Contains(t, err.Error(), "too many blobs") } -func Test_SecondsResolutionOverlap(t *testing.T) { +func Test_TimeResolutionOverlap(t *testing.T) { mkPrec := func(format llotypes.ReportFormat, opts []byte, validAfterNs, obsTsNs uint64) precursor { return precursor{ LifeCycleStage: protocol.LifeCycleStageProduction, @@ -507,6 +507,11 @@ func Test_SecondsResolutionOverlap(t *testing.T) { sec1a = 1_500_000_000 // second 1 sec1b = 1_900_000_000 // second 1 (later ns, same second) sec2 = 2_100_000_000 // second 2 + + ms1500b = 1_500_000_900 // same millisecond and microsecond as sec1a + ms1501 = 1_501_000_000 // next millisecond after sec1a + us1b = 1_500_000_500 // same microsecond as sec1a + us2 = 1_500_001_000 // next microsecond after sec1a ) tests := []struct { @@ -523,6 +528,11 @@ func Test_SecondsResolutionOverlap(t *testing.T) { {"unpacked default opts same second -> not reportable (defaults to seconds)", llotypes.ReportFormatEVMABIEncodeUnpacked, nil, sec1a, sec1b, false}, {"unpacked explicit ns same second -> reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, []byte(`{"TimeResolution":"ns"}`), sec1a, sec1b, true}, {"unpacked explicit seconds next second -> reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, []byte(`{"TimeResolution":"s"}`), sec1a, sec2, true}, + {"unpacked ms same millisecond -> not reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, []byte(`{"TimeResolution":"ms"}`), sec1a, ms1500b, false}, + {"unpacked ms next millisecond -> reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, []byte(`{"TimeResolution":"ms"}`), sec1a, ms1501, true}, + {"unpacked us same microsecond -> not reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, []byte(`{"TimeResolution":"us"}`), sec1a, us1b, false}, + {"unpacked us next microsecond -> reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, []byte(`{"TimeResolution":"us"}`), sec1a, us2, true}, + {"unpacked ns same microsecond -> reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, []byte(`{"TimeResolution":"ns"}`), sec1a, us1b, true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -554,6 +564,10 @@ func Test_SecondsResolutionOverlap(t *testing.T) { {"expr explicit seconds same second -> not reportable", exprOpts(`"TimeResolution":"s",`), sec1b, false}, {"expr explicit seconds next second -> reportable", exprOpts(`"TimeResolution":"s",`), sec2, true}, {"expr explicit ns same second -> reportable", exprOpts(`"TimeResolution":"ns",`), sec1b, true}, + {"expr ms same millisecond -> not reportable", exprOpts(`"TimeResolution":"ms",`), ms1500b, false}, + {"expr ms next millisecond -> reportable", exprOpts(`"TimeResolution":"ms",`), ms1501, true}, + {"expr us same microsecond -> not reportable", exprOpts(`"TimeResolution":"us",`), us1b, false}, + {"expr us next microsecond -> reportable", exprOpts(`"TimeResolution":"us",`), us2, true}, } for _, tc := range exprTests { t.Run(tc.name, func(t *testing.T) { diff --git a/llo/dev/v31/reports.go b/llo/dev/v31/reports.go index 765fe04..459a372 100644 --- a/llo/dev/v31/reports.go +++ b/llo/dev/v31/reports.go @@ -410,9 +410,10 @@ func (o precursor) isReportable(channelID llotypes.ChannelID, minReportInterval if o.ObservationTimestampNanoseconds < saturatingAdd(validAfter, minReportInterval) || o.ObservationTimestampNanoseconds <= validAfter { return false } - // For seconds-resolution report formats, also require a full second between - // validAfter and the observation timestamp to prevent overlapping reports. - if isSecondsResolution(cd, channelID, optsCache) && secondsOverlap(validAfter, o.ObservationTimestampNanoseconds) { + // Require validAfter and the observation timestamp to fall in distinct ticks + // of the report format resolution, so the encoded validity window is not + // inverted. + if res, ok := reportTimeResolution(cd, channelID, optsCache); ok && tickOverlap(validAfter, o.ObservationTimestampNanoseconds, res) { return false } return true diff --git a/llo/dev/v31/resolution.go b/llo/dev/v31/resolution.go index b0d9bf5..d0d410d 100644 --- a/llo/dev/v31/resolution.go +++ b/llo/dev/v31/resolution.go @@ -11,10 +11,10 @@ type timeResolutionOpts struct { TimeResolution protocol.TimeResolution `json:"TimeResolution"` } -// isSecondsResolution reports whether a channel's report format encodes report -// timestamps at second-level resolution. Such channels must not emit two reports -// whose validAfter and observation timestamp fall in the same second, otherwise -// the reports would overlap (or be not-yet-valid) once truncated to seconds. +// reportTimeResolution returns the resolution a channel's report format +// truncates report timestamps to. Such channels must not emit a report whose +// validAfter and observation timestamp fall in the same tick, otherwise the +// encoded report would be not yet valid (ValidFrom after Timestamp). // // It reads opts through the round OptsCache. That cache is sealed to the same // channel generation as the definitions, so it holds every channel opts and @@ -23,22 +23,26 @@ type timeResolutionOpts struct { // - ReportFormatEVMPremiumLegacy always uses seconds. // - ReportFormatEVMABIEncodeUnpacked and ReportFormatEVMABIEncodeUnpackedExpr // use the TimeResolution opt, which defaults to seconds (the zero value) -// when unset; malformed opts are treated as non-seconds. +// when unset. ok is false when the opts cannot be read. // - all other formats use full (nanosecond) resolution. -func isSecondsResolution(cd llotypes.ChannelDefinition, channelID llotypes.ChannelID, optsCache *protocol.OptsCache) bool { +func reportTimeResolution(cd llotypes.ChannelDefinition, channelID llotypes.ChannelID, optsCache *protocol.OptsCache) (res protocol.TimeResolution, ok bool) { switch cd.ReportFormat { case llotypes.ReportFormatEVMPremiumLegacy: - return true + return protocol.ResolutionSeconds, true case llotypes.ReportFormatEVMABIEncodeUnpacked, llotypes.ReportFormatEVMABIEncodeUnpackedExpr: o, err := protocol.GetOpts[timeResolutionOpts](optsCache, channelID) - return err == nil && o.TimeResolution == protocol.ResolutionSeconds + if err != nil { + return 0, false + } + return o.TimeResolution, true default: - return false + return protocol.ResolutionNanoseconds, true } } -// secondsOverlap reports whether validAfterNs and obsTsNs collapse to the same -// (or an inverted) second, which would make a seconds-resolution report invalid. -func secondsOverlap(validAfterNs, obsTsNs uint64) bool { - return validAfterNs/1e9 >= obsTsNs/1e9 +// tickOverlap reports whether validAfterNs and obsTsNs collapse to the same +// (or an inverted) tick at res. It truncates with protocol.ConvertTimestamp, +// the same conversion the codecs apply when encoding. +func tickOverlap(validAfterNs, obsTsNs uint64, res protocol.TimeResolution) bool { + return protocol.ConvertTimestamp(validAfterNs, res) >= protocol.ConvertTimestamp(obsTsNs, res) } From ce88ef16eb80890fbe8431c9097fbb1c96bbf9f3 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Tue, 13 Oct 2026 10:30:48 +0100 Subject: [PATCH 049/103] llo/v31: vote to remove only committed tombstones --- llo/dev/v31/flow_test.go | 57 +++++++++++++++----------------------- llo/dev/v31/plugin.go | 46 ++++++++++++------------------ llo/dev/v31/plugin_test.go | 50 +++++++++++++++++++++++---------- 3 files changed, 76 insertions(+), 77 deletions(-) diff --git a/llo/dev/v31/flow_test.go b/llo/dev/v31/flow_test.go index 5736fc6..5bf840e 100644 --- a/llo/dev/v31/flow_test.go +++ b/llo/dev/v31/flow_test.go @@ -919,11 +919,11 @@ func Test_MarshalStreamValues_IsDeterministic(t *testing.T) { } } -// Test_Observation_EmptyDesiredSetRemovesOnlyTombstones covers that -// the onchain cache reaps a tombstone by having the owner omit it, -// so an all-tombstoned committed set legitimately merges to empty, -// and abstaining there would strand those channels forever. -func Test_Observation_EmptyDesiredSetRemovesOnlyTombstones(t *testing.T) { +// Test_Observation_RemovesOnlyTombstones covers that a channel missing from +// the desired set is voted out only when it is a committed tombstone, which is +// how the onchain cache reaps it. A live channel missing from a partial or +// empty set is kept. +func Test_Observation_RemovesOnlyTombstones(t *testing.T) { ctx := tests.Context(t) live := jsonChannel() @@ -931,8 +931,8 @@ func Test_Observation_EmptyDesiredSetRemovesOnlyTombstones(t *testing.T) { tombstoned.Tombstone = true // observeRemovals commits defs, then asks what the node votes to remove - // once the definitions source has gone empty. - observeRemovals := func(t *testing.T, defs llotypes.ChannelDefinitions) map[llotypes.ChannelID]struct{} { + // once the definitions source returns desired. + observeRemovals := func(t *testing.T, defs, desired llotypes.ChannelDefinitions) map[llotypes.ChannelID]struct{} { t.Helper() p := testPlugin(t) p.ShouldRetireCache = &mockShouldRetireCache{} @@ -950,54 +950,43 @@ func Test_Observation_EmptyDesiredSetRemovesOnlyTombstones(t *testing.T) { require.NoError(t, err) require.Len(t, kvChannelDefs(t, kv), len(defs)) - cdc.defs = llotypes.ChannelDefinitions{} + cdc.defs = desired obsBytes, err := p.Observation(ctx, 3, ocrtypes.AttributedQuery{}, kv, nil) require.NoError(t, err) obs, err := decodeObservation(ctx, obsBytes, testBlobs, nil) require.NoError(t, err) return obs.RemoveChannelIDs } + empty := llotypes.ChannelDefinitions{} t.Run("live channels are never removed by an empty set", func(t *testing.T) { - require.Empty(t, observeRemovals(t, llotypes.ChannelDefinitions{1: live, 2: live})) + require.Empty(t, observeRemovals(t, llotypes.ChannelDefinitions{1: live, 2: live}, empty)) }) t.Run("tombstoned channels still are", func(t *testing.T) { require.Equal(t, map[llotypes.ChannelID]struct{}{2: {}}, - observeRemovals(t, llotypes.ChannelDefinitions{1: live, 2: tombstoned})) + observeRemovals(t, llotypes.ChannelDefinitions{1: live, 2: tombstoned}, empty)) }) t.Run("an all-tombstoned set can be reaped to empty", func(t *testing.T) { require.Equal(t, map[llotypes.ChannelID]struct{}{1: {}, 2: {}}, - observeRemovals(t, llotypes.ChannelDefinitions{1: tombstoned, 2: tombstoned})) + observeRemovals(t, llotypes.ChannelDefinitions{1: tombstoned, 2: tombstoned}, empty)) }) - t.Run("a non-empty desired set is still a genuine opinion", func(t *testing.T) { - p := testPlugin(t) - p.ShouldRetireCache = &mockShouldRetireCache{} - cdc := &mockChannelDefinitionCache{defs: llotypes.ChannelDefinitions{1: live, 2: live}} - p.ChannelDefinitionCache = cdc - kv := newMemKV() - - _, err := p.StateTransition(ctx, 1, ocrtypes.AttributedQuery{}, []ocrtypes.AttributedObservation{ao(0, nil), ao(1, nil), ao(2, nil)}, kv, testBlobs) - require.NoError(t, err) - agree := make([]ocrtypes.AttributedObservation, 0, 4) - for i := 0; i < 4; i++ { - agree = append(agree, ao(i, mustEncodeObs(t, Observation{UnixTimestampNanoseconds: 1000, UpdateChannelDefinitions: cdc.defs}))) - } - _, err = p.StateTransition(ctx, 2, ocrtypes.AttributedQuery{}, agree, kv, testBlobs) - require.NoError(t, err) + t.Run("live channels are never removed by a partial set", func(t *testing.T) { + require.Empty(t, observeRemovals(t, + llotypes.ChannelDefinitions{1: live, 2: live, 3: live}, + llotypes.ChannelDefinitions{1: live})) + }) - // Dropping one channel while still reporting the other is an opinion, - // so the dropped one is voted off even though it is live. - cdc.defs = llotypes.ChannelDefinitions{1: live} - obsBytes, err := p.Observation(ctx, 3, ocrtypes.AttributedQuery{}, kv, nil) - require.NoError(t, err) - obs, err := decodeObservation(ctx, obsBytes, testBlobs, nil) - require.NoError(t, err) - require.Equal(t, map[llotypes.ChannelID]struct{}{2: {}}, obs.RemoveChannelIDs) + t.Run("tombstoned channels are removed from a partial set", func(t *testing.T) { + require.Equal(t, + map[llotypes.ChannelID]struct{}{3: {}}, + observeRemovals(t, + llotypes.ChannelDefinitions{1: live, 2: live, 3: tombstoned}, + llotypes.ChannelDefinitions{1: live})) }) } diff --git a/llo/dev/v31/plugin.go b/llo/dev/v31/plugin.go index 816e573..c16dad8 100644 --- a/llo/dev/v31/plugin.go +++ b/llo/dev/v31/plugin.go @@ -337,13 +337,13 @@ func (p *Plugin) voteOnPredecessorConfig(obs *Observation, seqNr uint64) { // the owner omits it from a later file. So a channel leaves the committed // set only after the DON has already agreed it is a tombstone. // -// This function must not assume that, because the contract does not require it. -// Definitions may be implemented by anything, and the static cache shipped for -// benchmarks and the dummy relayer ignores the committed set entirely and -// returns its configured JSON verbatim. Everything below is therefore written -// against the weaker guarantee: the desired set is one node's opinion, votes -// decide, and an absent channel means "not mentioned", which is only treated as -// "remove" when the set as a whole is credible. See the empty-set case below. +// This function enforces the same rule rather than assume it, because the +// contract does not require it. Definitions may be implemented by anything, and +// the static cache shipped for benchmarks and the dummy relayer ignores the +// committed set entirely and returns its configured JSON verbatim. So the +// desired set is one node's opinion, votes decide, and an absent channel only +// means "remove" when it is already a committed tombstone. Removing a live +// channel with the static cache takes a tombstone first, as it does onchain. func (p *Plugin) voteOnChannels(obs *Observation, state *kvState) { obs.RemoveChannelIDs = map[llotypes.ChannelID]struct{}{} @@ -358,29 +358,17 @@ func (p *Plugin) voteOnChannels(obs *Observation, state *kvState) { return } - // An empty desired set against a non-empty committed one is not read as - // "remove every channel". Under the reconciliation above the onchain cache - // cannot produce that for live channels, but nothing in the interface says - // so, and an implementation that returns nothing before it has loaded - // anything, or on a source it could not read, would make every live channel - // a removal candidate. Every node would do it from the same input, so the - // channels would really go. - // - // Tombstoned channels stay removable. An empty desired set is exactly what - // the onchain cache produces on the last reap: the owner omits the - // tombstones it wants dropped, and when every committed channel is a - // tombstone the merge comes back empty. Abstaining there would stop the - // removal the DON has already agreed to and strand those channels in the - // definitions permanently. - removable := state.channelDefinitions - if len(expectedChannelDefs) == 0 && len(state.channelDefinitions) > 0 { - removable = llotypes.ChannelDefinitions{} - for channelID, cd := range state.channelDefinitions { - if cd.Tombstone { - removable[channelID] = cd - } + // Only tombstoned channels are removal candidates. A live channel missing + // from the desired set is not read as "remove": a cache that returns a + // partial set, before it has loaded or from a source it could not read, + // would otherwise get those channels removed, since every node would vote + // the same way from the same input. Removal stays a two step decision: the + // DON first agrees on the tombstone, then on dropping it. + removable := llotypes.ChannelDefinitions{} + for channelID, cd := range state.channelDefinitions { + if cd.Tombstone { + removable[channelID] = cd } - p.Logger.Warnw("ChannelDefinitionCache.Definitions is empty while channels are committed; voting to remove only the tombstoned ones", "committed", len(state.channelDefinitions), "removable", len(removable)) } removeChannelDefinitions := protocol.SubtractChannelDefinitions(removable, expectedChannelDefs, protocol.MaxObservationRemoveChannelIDsLength) diff --git a/llo/dev/v31/plugin_test.go b/llo/dev/v31/plugin_test.go index 2e30a40..519f93a 100644 --- a/llo/dev/v31/plugin_test.go +++ b/llo/dev/v31/plugin_test.go @@ -1704,8 +1704,8 @@ func (d *recordingDataSource) streams() []llotypes.StreamID { // Test_Observation_UnverifiableCommittedChannelIsNotFatal covers the // version-skew case: a channel committed under an older build fails this // build's codec.Verify. The node must not halt. It keeps observing and, -// crucially, still votes the offending channel out, which is the only way the -// DON recovers. +// crucially, still votes to tombstone the offending channel, which is the first +// step of the only way the DON recovers. func Test_Observation_UnverifiableCommittedChannelIsNotFatal(t *testing.T) { ctx := tests.Context(t) p := testPlugin(t) @@ -1736,12 +1736,14 @@ func Test_Observation_UnverifiableCommittedChannelIsNotFatal(t *testing.T) { require.Contains(t, kvChannelDefs(t, kv), llotypes.ChannelID(2)) // Now this node upgrades to a build whose codec rejects channel 2, and the - // definitions file drops it so the node has something to vote for. + // definitions file tombstones it so the node has something to vote for. p.ReportCodecs = map[llotypes.ReportFormat]protocol.ReportCodec{ llotypes.ReportFormatJSON: strictJSONCodec{rejectStream: 999}, } p.ChannelCache = protocol.NewChannelCache() - p.ChannelDefinitionCache = &mockChannelDefinitionCache{defs: llotypes.ChannelDefinitions{1: healthy}} + tombstoned := rejected + tombstoned.Tombstone = true + p.ChannelDefinitionCache = &mockChannelDefinitionCache{defs: llotypes.ChannelDefinitions{1: healthy, 2: tombstoned}} p.ShouldRetireCache = &mockShouldRetireCache{} ds := &recordingDataSource{} attachPump(t, p, ds, newFakeBroadcaster()) @@ -1751,10 +1753,11 @@ func Test_Observation_UnverifiableCommittedChannelIsNotFatal(t *testing.T) { obs, err := decodeObservation(ctx, obsBytes, testBlobs, nil) require.NoError(t, err) - // The removal vote is the recovery path, and it is only cast because the - // verification failure above was not fatal. - require.Contains(t, obs.RemoveChannelIDs, llotypes.ChannelID(2)) - require.NotContains(t, obs.UpdateChannelDefinitions, llotypes.ChannelID(2)) + // The tombstone vote is the recovery path, and it is only cast because the + // verification failure above was not fatal. The channel is live until the + // tombstone commits, so it is not voted out yet. + require.Equal(t, tombstoned, obs.UpdateChannelDefinitions[2]) + require.NotContains(t, obs.RemoveChannelIDs, llotypes.ChannelID(2)) // The rejected channel's streams are still observed: withholding them would // only starve the nodes still on the old build, which considers the channel @@ -1765,7 +1768,7 @@ func Test_Observation_UnverifiableCommittedChannelIsNotFatal(t *testing.T) { // Test_FullRound_RecoverFromUnverifiableChannel is the end-to-end recovery: // every node upgrades to a build that rejects a committed channel, and the DON -// keeps making rounds and votes the channel out. +// keeps making rounds, tombstones the channel and then votes it out. func Test_FullRound_RecoverFromUnverifiableChannel(t *testing.T) { ctx := tests.Context(t) p := testPlugin(t) @@ -1798,10 +1801,29 @@ func Test_FullRound_RecoverFromUnverifiableChannel(t *testing.T) { } p.ChannelCache = protocol.NewChannelCache() - // Every oracle votes the rejected channel out, and the round is accepted: - // the same observation must also pass ValidateObservation on the new build. - removeObs := Observation{ + // Every oracle votes to tombstone the rejected channel, then to remove it, + // and both rounds are accepted: each observation must also pass + // ValidateObservation on the new build. + tombstoned := rejected + tombstoned.Tombstone = true + tombstoneObs := Observation{ UnixTimestampNanoseconds: 2_000, + UpdateChannelDefinitions: llotypes.ChannelDefinitions{2: tombstoned}, + StreamValues: protocol.StreamValues{100: protocol.ToDecimal(decimal.NewFromInt(42))}, + } + tombstoneAOs := []ocrtypes.AttributedObservation{} + for i := 0; i < 4; i++ { + tombstoneAOs = append(tombstoneAOs, ao(i, mustEncodeObs(t, tombstoneObs))) + } + for _, aObs := range tombstoneAOs { + require.NoError(t, p.ValidateObservation(ctx, 3, ocrtypes.AttributedQuery{}, aObs, kv, testBlobs)) + } + _, err = p.StateTransition(ctx, 3, ocrtypes.AttributedQuery{}, tombstoneAOs, kv, testBlobs) + require.NoError(t, err) + require.True(t, kvChannelDefs(t, kv)[2].Tombstone) + + removeObs := Observation{ + UnixTimestampNanoseconds: 3_000, RemoveChannelIDs: map[llotypes.ChannelID]struct{}{2: {}}, StreamValues: protocol.StreamValues{100: protocol.ToDecimal(decimal.NewFromInt(42))}, } @@ -1810,9 +1832,9 @@ func Test_FullRound_RecoverFromUnverifiableChannel(t *testing.T) { removeAOs = append(removeAOs, ao(i, mustEncodeObs(t, removeObs))) } for _, aObs := range removeAOs { - require.NoError(t, p.ValidateObservation(ctx, 3, ocrtypes.AttributedQuery{}, aObs, kv, testBlobs)) + require.NoError(t, p.ValidateObservation(ctx, 4, ocrtypes.AttributedQuery{}, aObs, kv, testBlobs)) } - _, err = p.StateTransition(ctx, 3, ocrtypes.AttributedQuery{}, removeAOs, kv, testBlobs) + _, err = p.StateTransition(ctx, 4, ocrtypes.AttributedQuery{}, removeAOs, kv, testBlobs) require.NoError(t, err) require.NotContains(t, kvChannelDefs(t, kv), llotypes.ChannelID(2)) require.Contains(t, kvChannelDefs(t, kv), llotypes.ChannelID(1)) From f19288fa15f8d77694626bdf423ff04efdbc0700 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Tue, 13 Oct 2026 11:14:39 +0100 Subject: [PATCH 050/103] llo/v31: cap the default report cadence at 5s --- llo/dev/v31/factory.go | 4 ++++ llo/dev/v31/flow_test.go | 12 ++++++++++-- llo/dev/v31/reports.go | 20 +------------------- llo/protocol/offchain_config.go | 18 ++++++++++++++++++ 4 files changed, 33 insertions(+), 21 deletions(-) diff --git a/llo/dev/v31/factory.go b/llo/dev/v31/factory.go index 9490130..62c448c 100644 --- a/llo/dev/v31/factory.go +++ b/llo/dev/v31/factory.go @@ -124,6 +124,10 @@ func (f *PluginFactory) NewReportingPlugin(ctx context.Context, cfg ocr3types.Re if offchainConfig.ProtocolVersion > 0 && offchainConfig.DefaultMinReportIntervalNanoseconds == 0 { return nil, nil, fmt.Errorf("NewReportingPlugin: offchain config must set defaultMinReportIntervalNanoseconds to a non-zero value if protocol version is %d", offchainConfig.ProtocolVersion) } + // A cadence above the maximum stalls every channel for its whole length. + if offchainConfig.DefaultMinReportIntervalNanoseconds > protocol.MaxDefaultMinReportIntervalNanoseconds { + return nil, nil, fmt.Errorf("NewReportingPlugin: offchain config defaultMinReportIntervalNanoseconds (%d) exceeds the maximum (%d)", offchainConfig.DefaultMinReportIntervalNanoseconds, protocol.MaxDefaultMinReportIntervalNanoseconds) + } blobInFlightWaitFactor := f.BlobInFlightWaitFactor if blobInFlightWaitFactor == 0 { diff --git a/llo/dev/v31/flow_test.go b/llo/dev/v31/flow_test.go index 5bf840e..9a24e6f 100644 --- a/llo/dev/v31/flow_test.go +++ b/llo/dev/v31/flow_test.go @@ -279,6 +279,14 @@ func Test_Factory_NewReportingPlugin_reportCadence(t *testing.T) { t.Run("accepts a non-zero cadence", func(t *testing.T) { for _, protocolVersion := range []uint32{1, 2} { require.NoError(t, newPlugin(t, encode(t, protocolVersion, 1000))) + require.NoError(t, newPlugin(t, encode(t, protocolVersion, protocol.MaxDefaultMinReportIntervalNanoseconds))) + } + }) + + t.Run("refuses to start on a cadence above the maximum", func(t *testing.T) { + for _, protocolVersion := range []uint32{1, 2} { + err := newPlugin(t, encode(t, protocolVersion, protocol.MaxDefaultMinReportIntervalNanoseconds+1)) + require.EqualError(t, err, fmt.Sprintf("NewReportingPlugin: offchain config defaultMinReportIntervalNanoseconds (%d) exceeds the maximum (%d)", protocol.MaxDefaultMinReportIntervalNanoseconds+1, protocol.MaxDefaultMinReportIntervalNanoseconds)) } }) @@ -1025,8 +1033,8 @@ func Test_IsReportable_MinReportIntervalDoesNotOverflow(t *testing.T) { "interval %d must gate the report, not wrap", interval) } - require.Equal(t, uint64(math.MaxUint64), saturatingAdd(validAfter, math.MaxUint64)) - require.Equal(t, uint64(9), saturatingAdd(4, 5)) + require.Equal(t, uint64(math.MaxUint64), protocol.SaturatingAdd(validAfter, math.MaxUint64)) + require.Equal(t, uint64(9), protocol.SaturatingAdd(4, 5)) } // verifyCountingCodec counts how often channel definitions are decoded, which diff --git a/llo/dev/v31/reports.go b/llo/dev/v31/reports.go index 459a372..8d9c106 100644 --- a/llo/dev/v31/reports.go +++ b/llo/dev/v31/reports.go @@ -3,7 +3,6 @@ package llo import ( "context" "fmt" - "math" "sort" "strconv" @@ -289,23 +288,6 @@ func (o precursor) formatIsEncodable(format llotypes.ReportFormat, f int, channe // reportableChannels returns the sorted set of channels reportable in this // (current) round (see isReportable). -// saturatingAdd returns a+b, clamped to MaxUint64 instead of wrapping. -// -// validAfter is a nanosecond wall-clock timestamp, so it already sits around -// 1.7e18, and the offchain config puts no upper bound on -// DefaultMinReportIntervalNanoseconds (see protocol.OffchainConfig.Validate). -// A large enough -// interval would wrap the sum to a small number, the cadence comparison would -// then always pass, and the interval would silently stop gating anything. The -// config is replicated, so every oracle would do it identically: a silent loss -// of the cadence, not a fork. -func saturatingAdd(a, b uint64) uint64 { - if sum := a + b; sum >= a { - return sum - } - return math.MaxUint64 -} - func (o precursor) reportableChannels(minReportInterval uint64, f int, optsCache *protocol.OptsCache, tally *unreportableTally) []llotypes.ChannelID { reportable := make([]llotypes.ChannelID, 0, len(o.ChannelDefinitions)) for channelID := range o.ChannelDefinitions { @@ -407,7 +389,7 @@ func (o precursor) isReportable(channelID llotypes.ChannelID, minReportInterval if !ok { return false } - if o.ObservationTimestampNanoseconds < saturatingAdd(validAfter, minReportInterval) || o.ObservationTimestampNanoseconds <= validAfter { + if o.ObservationTimestampNanoseconds < protocol.SaturatingAdd(validAfter, minReportInterval) || o.ObservationTimestampNanoseconds <= validAfter { return false } // Require validAfter and the observation timestamp to fall in distinct ticks diff --git a/llo/protocol/offchain_config.go b/llo/protocol/offchain_config.go index 5f1282a..234f46b 100644 --- a/llo/protocol/offchain_config.go +++ b/llo/protocol/offchain_config.go @@ -4,10 +4,28 @@ import ( "errors" "fmt" "math" + "time" "google.golang.org/protobuf/proto" ) +// MaxDefaultMinReportIntervalNanoseconds bounds the report cadence. The +// interval gates every channel, so a larger value stalls all reporting for its +// whole length. LLO v31 enforces it (see llo/dev/v31.NewReportingPlugin). +const MaxDefaultMinReportIntervalNanoseconds = uint64(5 * time.Second) + +// SaturatingAdd returns a+b, clamped to MaxUint64 instead of wrapping. +// +// Use it for validAfter+DefaultMinReportIntervalNanoseconds. A wrapped sum is +// small, so the cadence comparison would always pass and the interval would +// silently stop gating anything. Clamping makes the gate fail closed. +func SaturatingAdd(a, b uint64) uint64 { + if sum := a + b; sum >= a { + return sum + } + return math.MaxUint64 +} + type OffchainConfig struct { ProtocolVersion uint32 // DefaultMinReportIntervalNanoseconds is the default minimum report interval in nanoseconds. From 23c32696091eeb73770ea4de0b671f84a16a6512 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Tue, 13 Oct 2026 11:34:22 +0100 Subject: [PATCH 051/103] llo/v31: enforce whole-set definition budgets when merging channel votes --- llo/dev/v31/limits_test.go | 50 +++++++++ llo/dev/v31/plugin.go | 3 +- llo/dev/v31/statetransition.go | 20 +++- llo/protocol/channel_definitions.go | 35 ++---- llo/protocol/channel_definitions_budget.go | 106 ++++++++++++++++++ .../channel_definitions_budget_test.go | 88 +++++++++++++++ 6 files changed, 271 insertions(+), 31 deletions(-) create mode 100644 llo/protocol/channel_definitions_budget.go create mode 100644 llo/protocol/channel_definitions_budget_test.go diff --git a/llo/dev/v31/limits_test.go b/llo/dev/v31/limits_test.go index d67d5b2..cd397bb 100644 --- a/llo/dev/v31/limits_test.go +++ b/llo/dev/v31/limits_test.go @@ -188,6 +188,56 @@ func TestLimits_DefinitionSetBudgetsAreAdmissionOnly(t *testing.T) { protocol.MaxTotalStreamEntries) } +// TestLimits_ChannelVotesRespectDefinitionSetBudgets covers updates that each +// pass admission on their own but whose union does not: two oracles with +// different desired sets each get an addition past the vote threshold in the +// same round. Admission cannot see the union, so the state transition must +// keep it within budget. +func TestLimits_ChannelVotesRespectDefinitionSetBudgets(t *testing.T) { + const f = 1 + channel := func(streamID llotypes.StreamID) llotypes.ChannelDefinition { + return llotypes.ChannelDefinition{ + ReportFormat: llotypes.ReportFormatJSON, + Streams: []llotypes.Stream{{StreamID: streamID, Aggregator: llotypes.AggregatorMedian}}, + Opts: []byte(strings.Repeat("x", protocol.MaxChannelOptsBytes)), + } + } + // One channel short of MaxTotalOptsBytes, so either addition fits alone. + committed := llotypes.ChannelDefinitions{} + for c := range protocol.MaxTotalOptsBytes/protocol.MaxChannelOptsBytes - 1 { + committed[llotypes.ChannelID(c+1)] = channel(1) + } + updates := map[[32]byte]protocol.ChannelDefinitionWithID{ + {1}: {ChannelDefinition: channel(2), ChannelID: 1000}, + {2}: {ChannelDefinition: channel(3), ChannelID: 1001}, + } + votes := map[[32]byte]int{{1}: f + 1, {2}: f + 1} + + for _, id := range []llotypes.ChannelID{1000, 1001} { + alone := cloneChannelDefinitions(committed) + alone[id] = channel(2) + require.NoError(t, protocol.NewChannelDefinitionsBudget(alone).Err()) + } + + pending := cloneChannelDefinitions(committed) + applyChannelVotes(pending, nil, updates, votes, f) + require.NoError(t, protocol.NewChannelDefinitionsBudget(pending).Err()) + // Applied in channel ID order, so the lower ID wins on every oracle. + require.Contains(t, pending, llotypes.ChannelID(1000)) + require.NotContains(t, pending, llotypes.ChannelID(1001)) + + // Once the committed set is over budget, a tombstone that trims it is + // still applied. + over := cloneChannelDefinitions(pending) + over[1001] = channel(3) + tombstone := llotypes.ChannelDefinition{ReportFormat: llotypes.ReportFormatJSON, Tombstone: true} + applyChannelVotes(over, nil, + map[[32]byte]protocol.ChannelDefinitionWithID{{3}: {ChannelDefinition: tombstone, ChannelID: 1001}}, + map[[32]byte]int{{3}: f + 1}, f) + require.True(t, over[1001].Tombstone) + require.NoError(t, protocol.NewChannelDefinitionsBudget(over).Err()) +} + // TestLimits_ChannelStateWorstCaseFitsPerKeyLimit builds the largest definition // set the admission budgets permit and measures the record it marshals to. The // budgets were sized from this number, so measuring it is what keeps them diff --git a/llo/dev/v31/plugin.go b/llo/dev/v31/plugin.go index c16dad8..2c7a9f4 100644 --- a/llo/dev/v31/plugin.go +++ b/llo/dev/v31/plugin.go @@ -457,7 +457,8 @@ func (p *Plugin) ValidateObservation(ctx context.Context, seqNr uint64, _ ocrtyp // // Whole-set budgets cannot be enforced per observation anyway, votes from // different oracles combine, and what gets committed is decided by the - // per-hash threshold, not by any single observation. + // per-hash threshold, not by any single observation. applyChannelVotes + // enforces them on the merged set instead. defsForVerify := observation.UpdateChannelDefinitions if len(observation.UpdateChannelDefinitions) > 0 { state, serr := loadColdKVState(kvReader, p.ChannelCache) diff --git a/llo/dev/v31/statetransition.go b/llo/dev/v31/statetransition.go index 510e056..deedbbb 100644 --- a/llo/dev/v31/statetransition.go +++ b/llo/dev/v31/statetransition.go @@ -421,7 +421,13 @@ func supportVotesForEffectiveFormats(votes map[llotypes.ReportFormat]int, effect } // applyChannelVotes applies remove/add votes with a >F threshold, in ascending -// channelID order, respecting MaxOutcomeChannelDefinitionsLength. +// channelID order, respecting MaxOutcomeChannelDefinitionsLength and the +// whole-set budgets of protocol.ChannelDefinitionsBudget. +// +// Each update passed admission on at least one honest oracle, but against that +// oracle's own desired set. Updates from oracles whose desired sets differ +// can each cross the threshold in the same round, and their union can exceed a +// budget none of them did, so the budgets are enforced here on the merged set. // // defs is the PENDING set: everything applied here takes effect next round. It // deliberately does not touch the round's channel generation, whose definitions @@ -457,16 +463,26 @@ func applyChannelVotes( } return bytes.Compare(ordered[i].hash[:], ordered[j].hash[:]) < 0 }) + budget := protocol.NewChannelDefinitionsBudget(defs) for _, hwid := range ordered { if updateVotesByHash[hwid.hash] <= f { continue } defWithID := hwid.def - _, exists := defs[defWithID.ChannelID] + prev, exists := defs[defWithID.ChannelID] if !exists && len(defs) >= protocol.MaxOutcomeChannelDefinitionsLength { // Skip additions beyond the cap; a replacement of an existing channel is still fine. continue } + var replaced *llotypes.ChannelDefinition + if exists { + replaced = &prev + } + if !budget.Replace(replaced, defWithID.ChannelDefinition) { + // Skip changes that grow the set past a whole-set budget; they are + // voted on again in later rounds. + continue + } defs[defWithID.ChannelID] = defWithID.ChannelDefinition } } diff --git a/llo/protocol/channel_definitions.go b/llo/protocol/channel_definitions.go index 8395f68..eca6b70 100644 --- a/llo/protocol/channel_definitions.go +++ b/llo/protocol/channel_definitions.go @@ -231,17 +231,11 @@ func analyzeChannelDefinitions(codecs map[llotypes.ReportFormat]ReportCodec, cha res.admissionFindings = append(res.admissionFindings, admissionFinding{wholeSet: true, err: err}) } - // Whole-set budgets, accumulated over the channels the loop below visits - // and checked once at the end. Tombstones count: nothing reads their - // streams or opts, but they are persisted in the definitions record all the - // same. - var totalStreamEntries, totalOptsBytes int - - // Every (streamID, aggregator) pair that will hold an aggregate. Distinct - // pairs, not stream entries: several channels naming the same pair cost one - // aggregate between them. Bounded by MaxPersistedAggregates, which is what - // the r/agg record can carry. - aggregatedPairs := make(map[streamAggregatorPair]struct{}, len(channelDefs)) + // Whole-set size budgets, measured the same way the state transition + // enforces them. Tombstones count: nothing reads their streams or opts, but + // they are persisted in the definitions record all the same. + budget := NewChannelDefinitionsBudget(channelDefs) + // Calculated streams declared across the set. Counted separately from the // pairs above because they are recomputed each round rather than carried // forward, so they cost precursor bytes but no r/agg bytes. @@ -272,7 +266,6 @@ func analyzeChannelDefinitions(codecs map[llotypes.ReportFormat]ReportCodec, cha if len(cd.Opts) > MaxChannelOptsBytes { admit(fmt.Errorf("ChannelDefinition with ID %d has opts that are too long, got: %d/%d", channelID, len(cd.Opts), MaxChannelOptsBytes), channelID) } - totalOptsBytes += len(cd.Opts) // A tombstone is persisted as is, so its streams are bounded like any // other. Admission-only, so that a committed tombstone stays reapable. @@ -280,7 +273,6 @@ func analyzeChannelDefinitions(codecs map[llotypes.ReportFormat]ReportCodec, cha if len(cd.Streams) > MaxStreamsPerChannel { admit(fmt.Errorf("ChannelDefinition with ID %d has too many streams, got: %d/%d", channelID, len(cd.Streams), MaxStreamsPerChannel), channelID) } - totalStreamEntries += len(cd.Streams) continue } @@ -292,7 +284,6 @@ func analyzeChannelDefinitions(codecs map[llotypes.ReportFormat]ReportCodec, cha base(fmt.Errorf("ChannelDefinition with ID %d has too many streams, got: %d/%d", channelID, len(cd.Streams), MaxStreamsPerChannel), channelID) continue } - totalStreamEntries += len(cd.Streams) for _, strm := range cd.Streams { if strm.Aggregator == 0 { base(fmt.Errorf("ChannelDefinition with ID %d has stream %d with zero aggregator (this may indicate an uninitialized struct)", channelID, strm.StreamID), channelID) @@ -307,12 +298,6 @@ func analyzeChannelDefinitions(codecs map[llotypes.ReportFormat]ReportCodec, cha admit(fmt.Errorf("ChannelDefinition with ID %d has stream %d with unknown aggregator %d", channelID, strm.StreamID, strm.Aggregator), channelID) } uniqueStreamIDs[strm.StreamID] = struct{}{} - // Pairs that aggregation actually produces a value for: calculated - // streams are recomputed each round rather than aggregated, and - // history backfill channels are not aggregated at all. - if strm.Aggregator != llotypes.AggregatorCalculated && cd.ReportFormat != llotypes.ReportFormatHistoryBackfill { - aggregatedPairs[streamAggregatorPair{streamID: strm.StreamID, aggregator: strm.Aggregator}] = struct{}{} - } // Calculated streams are derived from the opts that declare them and // are not stored on the definition, so anything listed here is // observed. Definitions written by older code may still carry their @@ -394,18 +379,12 @@ func analyzeChannelDefinitions(codecs map[llotypes.ReportFormat]ReportCodec, cha // Whole-set budgets. Each is what the sizes of the channel-definitions // record, the precursor and the carry-forward record, or the per-round // evaluation work, actually depend on; see the limits they name. - if totalStreamEntries > MaxTotalStreamEntries { - admitSet(fmt.Errorf("too many stream entries across all channels, got: %d/%d", totalStreamEntries, MaxTotalStreamEntries)) - } - if len(aggregatedPairs) > MaxPersistedAggregates { - admitSet(fmt.Errorf("too many aggregated (stream, aggregator) pairs across all channels, got: %d/%d", len(aggregatedPairs), MaxPersistedAggregates)) + if err := budget.Err(); err != nil { + admitSet(err) } if totalCalculatedStreams > MaxTotalCalculatedStreams { admitSet(fmt.Errorf("too many calculated streams across all channels, got: %d/%d", totalCalculatedStreams, MaxTotalCalculatedStreams)) } - if totalOptsBytes > MaxTotalOptsBytes { - admitSet(fmt.Errorf("too many opts bytes across all channels, got: %d/%d", totalOptsBytes, MaxTotalOptsBytes)) - } if totalTWAPCalls > MaxTotalTWAPCalls { admitSet(fmt.Errorf("too many TWAP calls across all channels, got: %d/%d", totalTWAPCalls, MaxTotalTWAPCalls)) } diff --git a/llo/protocol/channel_definitions_budget.go b/llo/protocol/channel_definitions_budget.go new file mode 100644 index 0000000..d77274e --- /dev/null +++ b/llo/protocol/channel_definitions_budget.go @@ -0,0 +1,106 @@ +package protocol + +import ( + "errors" + "fmt" + + llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" +) + +// ChannelDefinitionsBudget tracks the whole-set budgets that bound the size of +// the persisted records: MaxTotalStreamEntries and MaxTotalOptsBytes for the +// definitions record and the precursor, MaxPersistedAggregates for the +// carry-forward record. +// +// It needs nothing but the definitions, so every oracle reaches the same totals +// for the same set, which is what lets the state transition enforce it. +type ChannelDefinitionsBudget struct { + streamEntries int + optsBytes int + // Channels naming each (stream, aggregator) pair, so that removing one of + // them releases the pair only when no other channel still names it. + aggregatedPairs map[streamAggregatorPair]int +} + +// NewChannelDefinitionsBudget measures defs. +func NewChannelDefinitionsBudget(defs llotypes.ChannelDefinitions) *ChannelDefinitionsBudget { + b := &ChannelDefinitionsBudget{aggregatedPairs: make(map[streamAggregatorPair]int)} + for _, cd := range defs { + b.add(cd) + } + return b +} + +// Replace swaps prev for next in the measured set, with a nil prev for an +// addition. It refuses the change, leaving the totals as they were, when it +// grows a total past its budget. A total already over budget may stay there or +// shrink, so a set that is over budget can still be trimmed. +func (b *ChannelDefinitionsBudget) Replace(prev *llotypes.ChannelDefinition, next llotypes.ChannelDefinition) bool { + streamEntries, optsBytes, pairs := b.streamEntries, b.optsBytes, len(b.aggregatedPairs) + if prev != nil { + b.remove(*prev) + } + b.add(next) + grows := func(before, after, limit int) bool { return after > limit && after > before } + if grows(streamEntries, b.streamEntries, MaxTotalStreamEntries) || + grows(optsBytes, b.optsBytes, MaxTotalOptsBytes) || + grows(pairs, len(b.aggregatedPairs), MaxPersistedAggregates) { + b.remove(next) + if prev != nil { + b.add(*prev) + } + return false + } + return true +} + +// Err reports every budget the measured set exceeds. +func (b *ChannelDefinitionsBudget) Err() error { + var err error + if b.streamEntries > MaxTotalStreamEntries { + err = errors.Join(err, fmt.Errorf("too many stream entries across all channels, got: %d/%d", b.streamEntries, MaxTotalStreamEntries)) + } + if len(b.aggregatedPairs) > MaxPersistedAggregates { + err = errors.Join(err, fmt.Errorf("too many aggregated (stream, aggregator) pairs across all channels, got: %d/%d", len(b.aggregatedPairs), MaxPersistedAggregates)) + } + if b.optsBytes > MaxTotalOptsBytes { + err = errors.Join(err, fmt.Errorf("too many opts bytes across all channels, got: %d/%d", b.optsBytes, MaxTotalOptsBytes)) + } + return err +} + +func (b *ChannelDefinitionsBudget) add(cd llotypes.ChannelDefinition) { + b.streamEntries += len(cd.Streams) + b.optsBytes += len(cd.Opts) + for _, pair := range aggregatedPairsOf(cd) { + b.aggregatedPairs[pair]++ + } +} + +func (b *ChannelDefinitionsBudget) remove(cd llotypes.ChannelDefinition) { + b.streamEntries -= len(cd.Streams) + b.optsBytes -= len(cd.Opts) + for _, pair := range aggregatedPairsOf(cd) { + if b.aggregatedPairs[pair]--; b.aggregatedPairs[pair] <= 0 { + delete(b.aggregatedPairs, pair) + } + } +} + +// aggregatedPairsOf returns the (stream, aggregator) pairs aggregation produces +// a value for on behalf of cd, one entry per stream that names one. Tombstones +// and history backfill channels are not aggregated, and calculated streams are +// recomputed each round rather than carried forward. +func aggregatedPairsOf(cd llotypes.ChannelDefinition) []streamAggregatorPair { + if cd.Tombstone || cd.ReportFormat == llotypes.ReportFormatHistoryBackfill { + return nil + } + pairs := make([]streamAggregatorPair, 0, len(cd.Streams)) + for _, strm := range cd.Streams { + if strm.Aggregator == 0 || strm.Aggregator == llotypes.AggregatorCalculated { + continue + } + pairs = append(pairs, streamAggregatorPair{streamID: strm.StreamID, aggregator: strm.Aggregator}) + } + return pairs +} diff --git a/llo/protocol/channel_definitions_budget_test.go b/llo/protocol/channel_definitions_budget_test.go new file mode 100644 index 0000000..acb0c49 --- /dev/null +++ b/llo/protocol/channel_definitions_budget_test.go @@ -0,0 +1,88 @@ +package protocol + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" +) + +func Test_ChannelDefinitionsBudget(t *testing.T) { + channel := func(optsBytes int, streamIDs ...llotypes.StreamID) llotypes.ChannelDefinition { + cd := llotypes.ChannelDefinition{Opts: []byte(strings.Repeat("x", optsBytes))} + for _, id := range streamIDs { + cd.Streams = append(cd.Streams, llotypes.Stream{StreamID: id, Aggregator: llotypes.AggregatorMedian}) + } + return cd + } + // Every channel at MaxChannelOptsBytes, so the set sits exactly at + // MaxTotalOptsBytes with no other budget involved. + atOptsLimit := func() llotypes.ChannelDefinitions { + defs := llotypes.ChannelDefinitions{} + for c := range MaxTotalOptsBytes / MaxChannelOptsBytes { + defs[llotypes.ChannelID(c+1)] = channel(MaxChannelOptsBytes, 1) + } + return defs + } + + t.Run("refuses a change that grows the set past a budget", func(t *testing.T) { + defs := atOptsLimit() + b := NewChannelDefinitionsBudget(defs) + require.NoError(t, b.Err()) + + require.False(t, b.Replace(nil, channel(1, 1))) + prev := defs[1] + require.False(t, b.Replace(&prev, channel(MaxChannelOptsBytes+1, 1))) + // A refused change leaves the totals as they were. + require.NoError(t, b.Err()) + + // A change that stays within the budget is accepted. + require.True(t, b.Replace(nil, channel(0, 1))) + require.True(t, b.Replace(&prev, channel(MaxChannelOptsBytes, 2))) + require.NoError(t, b.Err()) + }) + + t.Run("lets a set already over budget stay or shrink", func(t *testing.T) { + defs := atOptsLimit() + defs[1000] = channel(MaxChannelOptsBytes, 1) + b := NewChannelDefinitionsBudget(defs) + require.ErrorContains(t, b.Err(), "too many opts bytes across all channels") + + prev := defs[1000] + require.False(t, b.Replace(&prev, channel(MaxChannelOptsBytes+1, 1))) + replaced := channel(MaxChannelOptsBytes, 2) + require.True(t, b.Replace(&prev, replaced)) + + prev = replaced + trimmed := channel(0, 2) + trimmed.Tombstone = true + require.True(t, b.Replace(&prev, trimmed)) + require.NoError(t, b.Err()) + }) + + t.Run("a pair is released only when no channel names it", func(t *testing.T) { + shared := channel(0, 1) + b := NewChannelDefinitionsBudget(llotypes.ChannelDefinitions{1: shared, 2: shared}) + require.Len(t, b.aggregatedPairs, 1) + + require.True(t, b.Replace(&shared, channel(0, 2))) + require.Len(t, b.aggregatedPairs, 2) + require.True(t, b.Replace(&shared, channel(0, 2))) + require.Len(t, b.aggregatedPairs, 1) + }) + + t.Run("tombstones and backfill channels hold no aggregate", func(t *testing.T) { + tombstone := channel(0, 1) + tombstone.Tombstone = true + backfill := channel(0, 2) + backfill.ReportFormat = llotypes.ReportFormatHistoryBackfill + calculated := llotypes.ChannelDefinition{Streams: []llotypes.Stream{{StreamID: 3, Aggregator: llotypes.AggregatorCalculated}}} + + b := NewChannelDefinitionsBudget(llotypes.ChannelDefinitions{1: tombstone, 2: backfill, 3: calculated}) + require.Empty(t, b.aggregatedPairs) + // Their stream entries are persisted all the same. + require.Equal(t, 3, b.streamEntries) + }) +} From 8a76626aa3f8f073be22d310f9448da460113de3 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Tue, 13 Oct 2026 11:48:26 +0100 Subject: [PATCH 052/103] llo/v31: refuse a zero aggregation fault tolerance on a byzantine DON and warn below F --- llo/dev/v31/factory.go | 9 +++++++++ llo/dev/v31/flow_test.go | 16 ++++++++++++++-- llo/protocol/offchain_config.go | 3 +++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/llo/dev/v31/factory.go b/llo/dev/v31/factory.go index 62c448c..3c9732b 100644 --- a/llo/dev/v31/factory.go +++ b/llo/dev/v31/factory.go @@ -115,6 +115,15 @@ func (f *PluginFactory) NewReportingPlugin(ctx context.Context, cfg ocr3types.Re return nil, nil, fmt.Errorf("aggregationFaultTolerance (%d) must not exceed consensus F (%d): a floor of %d contributions can never be met from %d observations", aggregationFaultTolerance, cfg.F, 2*aggregationFaultTolerance+1, 2*cfg.F+1) } + // Zero lets a single contributor set a stream aggregate, which a DON that + // tolerates Byzantine oracles must not allow. + if cfg.F > 0 && aggregationFaultTolerance == 0 { + return nil, nil, fmt.Errorf("aggregationFaultTolerance must be at least 1 when consensus F (%d) is non-zero: a floor of 1 contribution lets a single oracle set an aggregate", cfg.F) + } + if aggregationFaultTolerance < cfg.F { + l.Warnw("aggregationFaultTolerance is below consensus F: stream aggregates tolerate fewer Byzantine contributors than consensus tolerates oracles", + "aggregationFaultTolerance", aggregationFaultTolerance, "f", cfg.F, "minContributions", 2*aggregationFaultTolerance+1) + } // The report cadence is a replicated state transition parameter and gates // every channel (see isReportable). protocol.OffchainConfig.Validate used to diff --git a/llo/dev/v31/flow_test.go b/llo/dev/v31/flow_test.go index 9a24e6f..673f9c2 100644 --- a/llo/dev/v31/flow_test.go +++ b/llo/dev/v31/flow_test.go @@ -229,8 +229,20 @@ func Test_Factory_NewReportingPlugin_aggregationFaultTolerance(t *testing.T) { _, _, err := f.NewReportingPlugin(ctx, ocr3types.ReportingPluginConfig{N: 4, F: 1, ConfigDigest: ocrtypes.ConfigDigest{9}, OffchainConfig: mustEncodeOffchainConfig(t, 2)}, nil) require.EqualError(t, err, "aggregationFaultTolerance (2) must not exceed consensus F (1): a floor of 5 contributions can never be met from 3 observations") }) - t.Run("accepts zero", func(t *testing.T) { - p, _, err := f.NewReportingPlugin(ctx, ocr3types.ReportingPluginConfig{N: 4, F: 1, ConfigDigest: ocrtypes.ConfigDigest{9}, OffchainConfig: mustEncodeOffchainConfig(t, 0)}, nil) + t.Run("refuses zero when consensus F is non-zero", func(t *testing.T) { + _, _, err := f.NewReportingPlugin(ctx, ocr3types.ReportingPluginConfig{N: 4, F: 1, ConfigDigest: ocrtypes.ConfigDigest{9}, OffchainConfig: mustEncodeOffchainConfig(t, 0)}, nil) + require.EqualError(t, err, "aggregationFaultTolerance must be at least 1 when consensus F (1) is non-zero: a floor of 1 contribution lets a single oracle set an aggregate") + }) + t.Run("accepts below consensus F", func(t *testing.T) { + p, _, err := f.NewReportingPlugin(ctx, ocr3types.ReportingPluginConfig{N: 7, F: 2, ConfigDigest: ocrtypes.ConfigDigest{9}, OffchainConfig: mustEncodeOffchainConfig(t, 1)}, nil) + require.NoError(t, err) + pl, ok := p.(*Plugin) + require.True(t, ok) + require.Equal(t, 3, pl.minContributions()) + require.NoError(t, pl.Close()) + }) + t.Run("accepts zero when consensus F is zero", func(t *testing.T) { + p, _, err := f.NewReportingPlugin(ctx, ocr3types.ReportingPluginConfig{N: 1, F: 0, ConfigDigest: ocrtypes.ConfigDigest{9}, OffchainConfig: mustEncodeOffchainConfig(t, 0)}, nil) require.NoError(t, err) pl, ok := p.(*Plugin) require.True(t, ok) diff --git a/llo/protocol/offchain_config.go b/llo/protocol/offchain_config.go index 234f46b..067149b 100644 --- a/llo/protocol/offchain_config.go +++ b/llo/protocol/offchain_config.go @@ -47,6 +47,9 @@ type OffchainConfig struct { // There is no default. The right value depends on the DON's observed // per-stream omission rate, so a default would either silently weaken // safety or silently stall reporting. + // + // LLO v31 bounds it to [1, F], or to 0 when F is 0. Values below F + // tolerate fewer Byzantine contributors than consensus tolerates oracles. AggregationFaultTolerance *uint32 } From 66c204beaabb3fd3186a269f6ed3fd9fb18cb30e Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Tue, 13 Oct 2026 11:57:23 +0100 Subject: [PATCH 053/103] llo/v31: document that a same-round update wins over a tombstone removal --- llo/dev/v31/flow_test.go | 22 ++++++++++++++++++++++ llo/dev/v31/statetransition.go | 6 ++++++ 2 files changed, 28 insertions(+) diff --git a/llo/dev/v31/flow_test.go b/llo/dev/v31/flow_test.go index 673f9c2..d11078c 100644 --- a/llo/dev/v31/flow_test.go +++ b/llo/dev/v31/flow_test.go @@ -1010,6 +1010,28 @@ func Test_Observation_RemovesOnlyTombstones(t *testing.T) { }) } +// Test_ApplyChannelVotes_UpdateWinsOverSameRoundRemoval covers polling skew +// across a reap: some oracles read a file that omits a committed tombstone and +// vote to remove it, others read a newer one that reuses the channel ID, and +// both cross the threshold in the same round. The new definition is kept. +func Test_ApplyChannelVotes_UpdateWinsOverSameRoundRemoval(t *testing.T) { + const f = 1 + tombstoned := jsonChannel() + tombstoned.Tombstone = true + readded := jsonChannel() + readded.Streams = []llotypes.Stream{{StreamID: 200, Aggregator: llotypes.AggregatorMedian}} + + pending := llotypes.ChannelDefinitions{1: jsonChannel(), 2: tombstoned} + readdedWithID := protocol.ChannelDefinitionWithID{ChannelDefinition: readded, ChannelID: 2} + h := makeChannelHash(readdedWithID) + applyChannelVotes(pending, + map[llotypes.ChannelID]int{2: f + 1}, + map[[32]byte]protocol.ChannelDefinitionWithID{h: readdedWithID}, + map[[32]byte]int{h: f + 1}, f) + + require.Equal(t, llotypes.ChannelDefinitions{1: jsonChannel(), 2: readded}, pending) +} + // Test_IsReportable_MinReportIntervalDoesNotOverflow // validAfter is a nanosecond wall-clock timestamp and the offchain config // bounds DefaultMinReportIntervalNanoseconds only away from zero, so a large diff --git a/llo/dev/v31/statetransition.go b/llo/dev/v31/statetransition.go index deedbbb..1e3ba31 100644 --- a/llo/dev/v31/statetransition.go +++ b/llo/dev/v31/statetransition.go @@ -429,6 +429,12 @@ func supportVotesForEffectiveFormats(votes map[llotypes.ReportFormat]int, effect // can each cross the threshold in the same round, and their union can exceed a // budget none of them did, so the budgets are enforced here on the merged set. // +// Removals apply before updates, so an update that crosses the threshold in +// the same round as a removal of the same channel wins. This is intended: +// only committed tombstones are voted out (see voteOnChannels), and the update +// is a newer definition from the owner, so the result is the same as reaping +// the tombstone and adding the definition in consecutive rounds. +// // defs is the PENDING set: everything applied here takes effect next round. It // deliberately does not touch the round's channel generation, whose definitions // and decoded opts stay pinned to the effective set for the whole round. From f17ac8b889c92ed8101328ceb8c9d8e67539927f Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Tue, 13 Oct 2026 12:10:23 +0100 Subject: [PATCH 054/103] llo/v31: cap the attested predecessor retirement an observation may carry --- llo/dev/v31/decode_fuzz_test.go | 3 +++ llo/dev/v31/limits_test.go | 6 +++-- llo/dev/v31/observation.go | 4 ++++ llo/dev/v31/plugin_test.go | 23 +++++++++++++++++++ llo/protocol/limits.go | 7 ++++++ .../plugin_scoped_retirement_report_cache.go | 6 +++++ ...gin_scoped_retirement_report_cache_test.go | 20 +++++++++++++++- 7 files changed, 66 insertions(+), 3 deletions(-) diff --git a/llo/dev/v31/decode_fuzz_test.go b/llo/dev/v31/decode_fuzz_test.go index 7194689..bc4b0bd 100644 --- a/llo/dev/v31/decode_fuzz_test.go +++ b/llo/dev/v31/decode_fuzz_test.go @@ -76,6 +76,9 @@ func FuzzDecodeObservation(f *testing.F) { if len(decoded.SupportedReportFormats) > protocol.MaxObservationSupportedReportFormatsLength { t.Fatalf("decoded %d report formats, max %d", len(decoded.SupportedReportFormats), protocol.MaxObservationSupportedReportFormatsLength) } + if len(decoded.AttestedPredecessorRetirement) > protocol.MaxAttestedRetirementReportBytes { + t.Fatalf("decoded an attested predecessor retirement of %d bytes, max %d", len(decoded.AttestedPredecessorRetirement), protocol.MaxAttestedRetirementReportBytes) + } // Re-encoding the decoded set is what the wire has to be canonical in. wire := sortedFormatsToWire(decoded.SupportedReportFormats) for i := 1; i < len(wire); i++ { diff --git a/llo/dev/v31/limits_test.go b/llo/dev/v31/limits_test.go index cd397bb..d1fda28 100644 --- a/llo/dev/v31/limits_test.go +++ b/llo/dev/v31/limits_test.go @@ -356,8 +356,8 @@ func TestLimits_ObservationWorstCaseFitsDeclared(t *testing.T) { // The largest retirement report a predecessor can attest: a validAfter for // every channel, signed by every oracle. - validAfter := make(map[llotypes.ChannelID]uint64, protocol.MaxOutcomeChannelDefinitionsLength) - for i := range protocol.MaxOutcomeChannelDefinitionsLength { + validAfter := make(map[llotypes.ChannelID]uint64, protocol.MaxRetirementReportChannels) + for i := range protocol.MaxRetirementReportChannels { validAfter[llotypes.ChannelID(math.MaxUint32-i)] = math.MaxUint64 } retirementReport, err := protocol.StandardRetirementReportCodec{}.Encode(protocol.RetirementReport{ @@ -376,6 +376,8 @@ func TestLimits_ObservationWorstCaseFitsDeclared(t *testing.T) { } attestedBytes, err := proto.Marshal(attested) require.NoError(t, err) + require.LessOrEqual(t, len(attestedBytes), protocol.MaxAttestedRetirementReportBytes, + "worst-case attested retirement report (%d bytes) exceeds the declared limit", len(attestedBytes)) removals := make(map[llotypes.ChannelID]struct{}, protocol.MaxObservationRemoveChannelIDsLength) for i := range protocol.MaxObservationRemoveChannelIDsLength { diff --git a/llo/dev/v31/observation.go b/llo/dev/v31/observation.go index d3e8b92..0209381 100644 --- a/llo/dev/v31/observation.go +++ b/llo/dev/v31/observation.go @@ -290,6 +290,10 @@ func observationFromProto(main *protocol.LLOObservationProto) (Observation, erro return Observation{}, fmt.Errorf("observation advertises too many report formats: %d (max %d)", len(main.SupportedReportFormats), protocol.MaxObservationSupportedReportFormatsLength) } + if len(main.AttestedPredecessorRetirement) > protocol.MaxAttestedRetirementReportBytes { + return Observation{}, fmt.Errorf("observation carries an attested predecessor retirement of %d bytes (max %d)", len(main.AttestedPredecessorRetirement), protocol.MaxAttestedRetirementReportBytes) + } + if len(main.PredecessorSigners) > protocol.MaxObservationPredecessorSignersLength { return Observation{}, fmt.Errorf("observation carries too many predecessor signers: %d (max %d)", len(main.PredecessorSigners), protocol.MaxObservationPredecessorSignersLength) } diff --git a/llo/dev/v31/plugin_test.go b/llo/dev/v31/plugin_test.go index 519f93a..473a3ed 100644 --- a/llo/dev/v31/plugin_test.go +++ b/llo/dev/v31/plugin_test.go @@ -1449,6 +1449,29 @@ func Test_Observation_SupportedReportFormats_RoundTrip(t *testing.T) { require.ErrorContains(t, err, "advertises too many report formats") } +func Test_DecodeObservation_AttestedPredecessorRetirementBound(t *testing.T) { + ctx := tests.Context(t) + + atLimit, err := proto.Marshal(&protocol.LLOObservationProto{ + UnixTimestampNanoseconds: 1, + AttestedPredecessorRetirement: make([]byte, protocol.MaxAttestedRetirementReportBytes), + }) + require.NoError(t, err) + got, err := decodeObservation(ctx, frameObservation(nil, atLimit), nil, nil) + require.NoError(t, err) + require.Len(t, got.AttestedPredecessorRetirement, protocol.MaxAttestedRetirementReportBytes) + + // Oversized reports are rejected at decode rather than reaching the + // verification in the state transition. + overLimit, err := proto.Marshal(&protocol.LLOObservationProto{ + UnixTimestampNanoseconds: 1, + AttestedPredecessorRetirement: make([]byte, protocol.MaxAttestedRetirementReportBytes+1), + }) + require.NoError(t, err) + _, err = decodeObservation(ctx, frameObservation(nil, overLimit), nil, nil) + require.ErrorContains(t, err, "attested predecessor retirement of") +} + func Test_Precursor_SupportByFormat_RoundTrip(t *testing.T) { prec := precursor{ LifeCycleStage: protocol.LifeCycleStageProduction, diff --git a/llo/protocol/limits.go b/llo/protocol/limits.go index 3b9111d..6d3a328 100644 --- a/llo/protocol/limits.go +++ b/llo/protocol/limits.go @@ -40,6 +40,13 @@ const ( // largest key any supported chain uses is well under this; the headroom // stops a peer from padding its observation with oversized entries. MaxPredecessorSignerBytes = 128 + // MaxAttestedRetirementReportBytes bounds the attested predecessor + // retirement report an observation may carry. Every oracle hashes it once + // per signature while verifying, so the cap stops a peer from padding it up + // to the observation limit. The largest honest one, MaxRetirementReportChannels + // entries signed by MaxObservationPredecessorSignersLength oracles, encodes + // to about 137 KiB. + MaxAttestedRetirementReportBytes = 192 << 10 // Maximum allowed number of streams per channel MaxStreamsPerChannel = 10_000 // MaxDecimalExponent bounds the absolute value of the base-10 exponent of diff --git a/llo/retirement/plugin_scoped_retirement_report_cache.go b/llo/retirement/plugin_scoped_retirement_report_cache.go index f76e4dd..09e8453 100644 --- a/llo/retirement/plugin_scoped_retirement_report_cache.go +++ b/llo/retirement/plugin_scoped_retirement_report_cache.go @@ -60,6 +60,12 @@ func (pr *pluginScopedRetirementReportCache) VerifyAttestedRetirementReport(pred return protocol.RetirementReport{}, fmt.Errorf("Verify failed; failed to unmarshal protobuf: %w", err) } + // Each signature costs a verification, and there can be no more valid + // ones than signers. + if len(arr.Sigs) > len(signers) { + return protocol.RetirementReport{}, fmt.Errorf("Verify failed; attested report carries more signatures than signers (got: %d, max: %d)", len(arr.Sigs), len(signers)) + } + validSigs := 0 seenSigners := make(map[uint32]struct{}, len(signers)) for _, sig := range arr.Sigs { diff --git a/llo/retirement/plugin_scoped_retirement_report_cache_test.go b/llo/retirement/plugin_scoped_retirement_report_cache_test.go index 60492cc..407507f 100644 --- a/llo/retirement/plugin_scoped_retirement_report_cache_test.go +++ b/llo/retirement/plugin_scoped_retirement_report_cache_test.go @@ -119,7 +119,7 @@ func Test_PluginScopedRetirementReportCache(t *testing.T) { // config is invalid (no signers) _, err = psrrc.CheckAttestedRetirementReport(exampleDigest, serializedValidArr) - require.EqualError(t, err, "Verify failed; attested report signer index out of bounds (got: 0, max: -1)") + require.EqualError(t, err, "Verify failed; attested report carries more signatures than signers (got: 4, max: 0)") rrc.cfg = Config{Digest: exampleDigest, Signers: [][]byte{[]byte{0}, []byte{1}, []byte{2}, []byte{3}}, F: 1} @@ -190,6 +190,24 @@ func Test_PluginScopedRetirementReportCache(t *testing.T) { _, err = psrrc.CheckAttestedRetirementReport(exampleDigest, serializedDupArr) require.EqualError(t, err, "Verify failed; duplicate signature from signer index 0") }) + + t.Run("rejects a signer index out of bounds", func(t *testing.T) { + rrc.cfg = Config{Digest: exampleDigest, Signers: [][]byte{{0}, {1}, {2}, {3}}, F: 1} + rrc.exists = true + + oobArr := protocol.AttestedRetirementReport{ + RetirementReport: exampleUnattestedSerializedRetirementReport, + SeqNr: 42, + Sigs: []*protocol.AttributedOnchainSignature{ + {Signer: 4, Signature: []byte("bar4")}, + }, + } + serializedOOBArr, err := proto.Marshal(&oobArr) + require.NoError(t, err) + + _, err = psrrc.CheckAttestedRetirementReport(exampleDigest, serializedOOBArr) + require.EqualError(t, err, "Verify failed; attested report signer index out of bounds (got: 4, max: 3)") + }) }) t.Run("AttestedRetirementReport", func(t *testing.T) { rrc.arr = []byte("foo") From 69981bf43c348efc5a4914d045349696479a7abe Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Tue, 13 Oct 2026 13:04:47 +0100 Subject: [PATCH 055/103] llo/reportcodec: document that a nil fee price encodes a zero fee --- llo/reportcodec/evm/report_codec_premium_legacy.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/llo/reportcodec/evm/report_codec_premium_legacy.go b/llo/reportcodec/evm/report_codec_premium_legacy.go index c47ab30..2801568 100644 --- a/llo/reportcodec/evm/report_codec_premium_legacy.go +++ b/llo/reportcodec/evm/report_codec_premium_legacy.go @@ -222,6 +222,10 @@ func ExtractReportValues(report protocol.Report) (nativePrice, linkPrice decimal return nativePrice, linkPrice, quote, nil } +// extractPrice returns a fee token price. A nil price returns zero, which +// CalculateFee turns into a zero fee, so a fee price outage does not stop the +// channel from reporting. Channels that must always charge a fee set +// DisableNilStreamValues, which makes a nil aggregate unreportable. func extractPrice(price protocol.StreamValue) (decimal.Decimal, error) { switch p := price.(type) { case *protocol.Decimal: From 4ddfdcf9eeef2704537eb9236541f8a6d9ff0f36 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Tue, 13 Oct 2026 13:18:38 +0100 Subject: [PATCH 056/103] llo/v31: require 2f+1 decoded observations before taking the timestamp median --- llo/dev/v31/plugin_test.go | 32 ++++++++++++++++++++++++++++++++ llo/dev/v31/statetransition.go | 9 +++++---- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/llo/dev/v31/plugin_test.go b/llo/dev/v31/plugin_test.go index 473a3ed..e38b33b 100644 --- a/llo/dev/v31/plugin_test.go +++ b/llo/dev/v31/plugin_test.go @@ -1118,6 +1118,38 @@ func Test_StateTransition_PropagatesBlobFetchFailure(t *testing.T) { require.Contains(t, err.Error(), "fetch blob") } +// Test_StateTransition_RequiresQuorumAfterDecode checks that observations +// dropped on decode cannot shrink the sample below 2f+1, where f byzantine +// timestamps could control the median. +func Test_StateTransition_RequiresQuorumAfterDecode(t *testing.T) { + ctx := tests.Context(t) + p := testPlugin(t) + kv := newMemKV() + + // Bootstrap. + _, err := p.StateTransition(ctx, 1, ocrtypes.AttributedQuery{}, []ocrtypes.AttributedObservation{ao(0, nil), ao(1, nil), ao(2, nil)}, kv, testBlobs) + require.NoError(t, err) + + valid := func(i int) ocrtypes.AttributedObservation { + return ao(i, mustEncodeObs(t, Observation{UnixTimestampNanoseconds: 1000})) + } + invalid := func(i int) ocrtypes.AttributedObservation { + return ao(i, []byte{0x7f}) + } + + t.Run("2f+1 decoded observations proceed", func(t *testing.T) { + aos := []ocrtypes.AttributedObservation{valid(0), valid(1), valid(2), invalid(3)} + _, err := p.StateTransition(ctx, 2, ocrtypes.AttributedQuery{}, aos, kv, testBlobs) + require.NoError(t, err) + }) + + t.Run("fewer than 2f+1 decoded observations fail the round", func(t *testing.T) { + aos := []ocrtypes.AttributedObservation{valid(0), valid(1), invalid(2), invalid(3)} + _, err := p.StateTransition(ctx, 3, ocrtypes.AttributedQuery{}, aos, kv, testBlobs) + require.ErrorContains(t, err, "expected at least 2f+1 decoded observations, got 2 (f: 1)") + }) +} + // equalStreamValue compares two stream values by their binary encoding. func equalStreamValue(a, b protocol.StreamValue) bool { ba, err := a.MarshalBinary() diff --git a/llo/dev/v31/statetransition.go b/llo/dev/v31/statetransition.go index 1e3ba31..86907e4 100644 --- a/llo/dev/v31/statetransition.go +++ b/llo/dev/v31/statetransition.go @@ -79,8 +79,8 @@ func (p *Plugin) StateTransition(ctx context.Context, seqNr uint64, _ ocrtypes.A if err != nil { return nil, err } - if len(tally.timestampsNanoseconds) == 0 { - return nil, fmt.Errorf("no valid observations") + if len(tally.timestampsNanoseconds) < 2*p.F+1 { + return nil, fmt.Errorf("invariant violation: expected at least 2f+1 decoded observations, got %d (f: %d)", len(tally.timestampsNanoseconds), p.F) } // Verifying an attested predecessor retirement report needs the @@ -316,8 +316,9 @@ func (p *Plugin) decodeObservations(ctx context.Context, aos []ocrtypes.Attribut // an error (see the ReportingPlugin contract). return observationTally{}, fmt.Errorf("failed to fetch blob for observation from oracle %v: %w", ao.Observer, derr) } - // Deterministic decode failure (same bytes on every oracle): safe to - // drop just this observation. + // Unreachable while libocr runs ValidateObservation, which decodes + // the same bytes, before StateTransition. Drop rather than fail, and + // let the quorum check in StateTransition catch a shrunk sample. p.Logger.Warnw("ignoring invalid observation", "oracleID", ao.Observer, "error", derr) if collect { tally.attributed = append(tally.attributed, attributedObservation{observer: ao.Observer, decodeErr: derr}) From ee8caddbd0cbd0b6d57baa5d61b1b8093eb576e5 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Tue, 13 Oct 2026 13:30:44 +0100 Subject: [PATCH 057/103] llo/v31: treat a channel with undecodable time resolution opts as unreportable --- llo/dev/v31/plugin_test.go | 5 +++++ llo/dev/v31/reports.go | 13 +++++++++---- llo/dev/v31/resolution.go | 10 +++++----- llo/protocol/opts_cache.go | 13 +++++++++++++ 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/llo/dev/v31/plugin_test.go b/llo/dev/v31/plugin_test.go index e38b33b..cccf390 100644 --- a/llo/dev/v31/plugin_test.go +++ b/llo/dev/v31/plugin_test.go @@ -533,6 +533,11 @@ func Test_TimeResolutionOverlap(t *testing.T) { {"unpacked us same microsecond -> not reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, []byte(`{"TimeResolution":"us"}`), sec1a, us1b, false}, {"unpacked us next microsecond -> reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, []byte(`{"TimeResolution":"us"}`), sec1a, us2, true}, {"unpacked ns same microsecond -> reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, []byte(`{"TimeResolution":"ns"}`), sec1a, us1b, true}, + // The codec cannot encode a channel whose opts do not decode, so it must + // not be reportable, otherwise validAfter advances over a round that + // emitted nothing. + {"unpacked invalid resolution next second -> not reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, []byte(`{"TimeResolution":"bogus"}`), sec1a, sec2, false}, + {"unpacked malformed opts next second -> not reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, []byte(`{"not":`), sec1a, sec2, false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/llo/dev/v31/reports.go b/llo/dev/v31/reports.go index 8d9c106..3966f95 100644 --- a/llo/dev/v31/reports.go +++ b/llo/dev/v31/reports.go @@ -392,11 +392,16 @@ func (o precursor) isReportable(channelID llotypes.ChannelID, minReportInterval if o.ObservationTimestampNanoseconds < protocol.SaturatingAdd(validAfter, minReportInterval) || o.ObservationTimestampNanoseconds <= validAfter { return false } + // The codec reads the same opts, so a channel whose opts do not decode + // cannot be encoded. Treat it as unreportable so validAfter does not advance + // over a round that emitted nothing. + res, ok := reportTimeResolution(cd, channelID, optsCache) + if !ok { + tally.note("channel opts cannot be decoded", channelID) + return false + } // Require validAfter and the observation timestamp to fall in distinct ticks // of the report format resolution, so the encoded validity window is not // inverted. - if res, ok := reportTimeResolution(cd, channelID, optsCache); ok && tickOverlap(validAfter, o.ObservationTimestampNanoseconds, res) { - return false - } - return true + return !tickOverlap(validAfter, o.ObservationTimestampNanoseconds, res) } diff --git a/llo/dev/v31/resolution.go b/llo/dev/v31/resolution.go index d0d410d..d975752 100644 --- a/llo/dev/v31/resolution.go +++ b/llo/dev/v31/resolution.go @@ -16,21 +16,21 @@ type timeResolutionOpts struct { // validAfter and observation timestamp fall in the same tick, otherwise the // encoded report would be not yet valid (ValidFrom after Timestamp). // -// It reads opts through the round OptsCache. That cache is sealed to the same -// channel generation as the definitions, so it holds every channel opts and -// the result is deterministic across oracles. +// It reads opts through the round OptsCache and decodes the definition's raw +// opts on a cache miss, so the result is deterministic across oracles. // // - ReportFormatEVMPremiumLegacy always uses seconds. // - ReportFormatEVMABIEncodeUnpacked and ReportFormatEVMABIEncodeUnpackedExpr // use the TimeResolution opt, which defaults to seconds (the zero value) -// when unset. ok is false when the opts cannot be read. +// when unset. ok is false when the opts cannot be read, and the caller +// treats such a channel as unreportable. // - all other formats use full (nanosecond) resolution. func reportTimeResolution(cd llotypes.ChannelDefinition, channelID llotypes.ChannelID, optsCache *protocol.OptsCache) (res protocol.TimeResolution, ok bool) { switch cd.ReportFormat { case llotypes.ReportFormatEVMPremiumLegacy: return protocol.ResolutionSeconds, true case llotypes.ReportFormatEVMABIEncodeUnpacked, llotypes.ReportFormatEVMABIEncodeUnpackedExpr: - o, err := protocol.GetOpts[timeResolutionOpts](optsCache, channelID) + o, err := protocol.GetOptsOrDecode[timeResolutionOpts](optsCache, cd, channelID) if err != nil { return 0, false } diff --git a/llo/protocol/opts_cache.go b/llo/protocol/opts_cache.go index 9886956..4873382 100644 --- a/llo/protocol/opts_cache.go +++ b/llo/protocol/opts_cache.go @@ -193,6 +193,19 @@ func GetOpts[T any](c *OptsCache, channelID llotypes.ChannelID) (T, error) { return result, nil } +// GetOptsOrDecode returns the channel opts of type T from the cache, and +// decodes the definition's raw opts on a cache miss or a nil cache. The +// fallback keeps the result identical across oracles whether or not their +// cache holds the channel. The same read-only rule as GetOpts applies. +func GetOptsOrDecode[T any](c *OptsCache, cd llotypes.ChannelDefinition, channelID llotypes.ChannelID) (T, error) { + if c != nil { + if o, err := GetOpts[T](c, channelID); err == nil { + return o, nil + } + } + return decodeOpts[T](cd.Opts) +} + // decodeOpts is the single decode of raw channel opts. The cache and every // cache miss fallback go through it, so a definition reads the same whichever // path decodes it. JSON libraries disagree on some inputs, such as keys that From fb32f6c5b45a537f7e156423766eaf7dc37aa7ad Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Tue, 13 Oct 2026 14:00:31 +0100 Subject: [PATCH 058/103] llo/v31: apply the peer decoder bounds before broadcasting predecessor fields --- llo/dev/v31/flow_test.go | 28 +++++++++++++++++++++++ llo/dev/v31/observation.go | 46 +++++++++++++++++++++++++++----------- llo/dev/v31/plugin.go | 13 ++++++++--- 3 files changed, 71 insertions(+), 16 deletions(-) diff --git a/llo/dev/v31/flow_test.go b/llo/dev/v31/flow_test.go index d11078c..f29f2d8 100644 --- a/llo/dev/v31/flow_test.go +++ b/llo/dev/v31/flow_test.go @@ -141,6 +141,8 @@ type mockPredecessorRetirementReportCache struct { // signers overrides testPredecessorSigners; f is the predecessor's f. signers [][]byte f uint8 + // attested overrides the attested retirement report the cache returns. + attested []byte // verifyErr makes VerifyAttestedRetirementReport fail deterministically. verifyErr error } @@ -156,6 +158,9 @@ func (m *mockPredecessorRetirementReportCache) AttestedRetirementReport(ocrtypes if m.err != nil { return nil, m.err } + if m.attested != nil { + return m.attested, nil + } return []byte("attested"), nil } func (m *mockPredecessorRetirementReportCache) CheckAttestedRetirementReport(ocrtypes.ConfigDigest, []byte) (protocol.RetirementReport, error) { @@ -892,6 +897,29 @@ func Test_Observation_PredecessorConfigVote(t *testing.T) { require.Empty(t, obs.AttestedPredecessorRetirement) require.Empty(t, obs.PredecessorSigners, "the signer set is dead weight without a report") }) + + // A local entry that peers would reject must be left out, not broadcast, + // or every peer drops this node's whole observation. + for name, prrc := range map[string]*mockPredecessorRetirementReportCache{ + "empty signer key": {signers: [][]byte{{0x01}, {}, {0x03}, {0x04}}}, + "oversized signer key": {signers: [][]byte{{0x01}, make([]byte, protocol.MaxPredecessorSignerBytes+1), {0x03}, {0x04}}}, + "f out of range": {f: uint8(len(testPredecessorSigners))}, + } { + t.Run("abstains on a local config peers would reject: "+name, func(t *testing.T) { + p, kv := stagedObserver(t, prrc) + obs := observe(t, p, kv) + require.NotEmpty(t, obs.AttestedPredecessorRetirement) + require.Empty(t, obs.PredecessorSigners) + require.Zero(t, obs.PredecessorF) + }) + } + + t.Run("omits a local report peers would reject", func(t *testing.T) { + p, kv := stagedObserver(t, &mockPredecessorRetirementReportCache{attested: make([]byte, protocol.MaxAttestedRetirementReportBytes+1)}) + obs := observe(t, p, kv) + require.Empty(t, obs.AttestedPredecessorRetirement) + require.Empty(t, obs.PredecessorSigners) + }) } // Test_EncodeObservation_IsDeterministic covers the encoder builds repeated fields diff --git a/llo/dev/v31/observation.go b/llo/dev/v31/observation.go index 0209381..01cd1ec 100644 --- a/llo/dev/v31/observation.go +++ b/llo/dev/v31/observation.go @@ -290,29 +290,49 @@ func observationFromProto(main *protocol.LLOObservationProto) (Observation, erro return Observation{}, fmt.Errorf("observation advertises too many report formats: %d (max %d)", len(main.SupportedReportFormats), protocol.MaxObservationSupportedReportFormatsLength) } - if len(main.AttestedPredecessorRetirement) > protocol.MaxAttestedRetirementReportBytes { - return Observation{}, fmt.Errorf("observation carries an attested predecessor retirement of %d bytes (max %d)", len(main.AttestedPredecessorRetirement), protocol.MaxAttestedRetirementReportBytes) + if err := validateAttestedPredecessorRetirement(main.AttestedPredecessorRetirement); err != nil { + return Observation{}, fmt.Errorf("observation carries %w", err) } + if err := validatePredecessorConfig(main.PredecessorSigners, main.PredecessorF); err != nil { + return Observation{}, fmt.Errorf("observation carries %w", err) + } + obs.PredecessorSigners = main.PredecessorSigners + obs.PredecessorF = uint8(main.PredecessorF) + + obs.SupportedReportFormats = formatsFromWire(main.SupportedReportFormats) + return obs, nil +} - if len(main.PredecessorSigners) > protocol.MaxObservationPredecessorSignersLength { - return Observation{}, fmt.Errorf("observation carries too many predecessor signers: %d (max %d)", len(main.PredecessorSigners), protocol.MaxObservationPredecessorSignersLength) +// validateAttestedPredecessorRetirement bounds the attested predecessor +// retirement an observation may carry. Observation applies it before +// broadcasting, so a node never sends a report its peers would reject. +func validateAttestedPredecessorRetirement(report []byte) error { + if len(report) > protocol.MaxAttestedRetirementReportBytes { + return fmt.Errorf("an attested predecessor retirement of %d bytes (max %d)", len(report), protocol.MaxAttestedRetirementReportBytes) } - for i, signer := range main.PredecessorSigners { + return nil +} + +// validatePredecessorConfig bounds the predecessor config vote an observation +// may carry. An empty signer set passes, as it means no vote. Observation +// applies it before broadcasting, so a node never sends a vote its peers would +// reject. +func validatePredecessorConfig(signers [][]byte, f uint32) error { + if len(signers) > protocol.MaxObservationPredecessorSignersLength { + return fmt.Errorf("too many predecessor signers: %d (max %d)", len(signers), protocol.MaxObservationPredecessorSignersLength) + } + for i, signer := range signers { if len(signer) == 0 || len(signer) > protocol.MaxPredecessorSignerBytes { - return Observation{}, fmt.Errorf("observation carries predecessor signer %d of invalid length %d (max %d)", i, len(signer), protocol.MaxPredecessorSignerBytes) + return fmt.Errorf("predecessor signer %d of invalid length %d (max %d)", i, len(signer), protocol.MaxPredecessorSignerBytes) } } // f indexes nothing, but a set that cannot reach f+1 valid signatures could // never verify a report, so treat it as malformed rather than carrying it // into the vote. - if main.PredecessorF > 0 && int(main.PredecessorF) >= len(main.PredecessorSigners) { - return Observation{}, fmt.Errorf("observation carries predecessor f=%d for a signer set of %d", main.PredecessorF, len(main.PredecessorSigners)) + if f > 0 && int(f) >= len(signers) { + return fmt.Errorf("predecessor f=%d for a signer set of %d", f, len(signers)) } - obs.PredecessorSigners = main.PredecessorSigners - obs.PredecessorF = uint8(main.PredecessorF) - - obs.SupportedReportFormats = formatsFromWire(main.SupportedReportFormats) - return obs, nil + return nil } // sortedFormatsToWire returns the formats sorted ascending. nil in, nil out, so diff --git a/llo/dev/v31/plugin.go b/llo/dev/v31/plugin.go index 2c7a9f4..06e5608 100644 --- a/llo/dev/v31/plugin.go +++ b/llo/dev/v31/plugin.go @@ -168,12 +168,15 @@ func (p *Plugin) Observation(ctx context.Context, seqNr uint64, _ ocrtypes.Attri if p.PredecessorConfigDigest != nil && state.lifeCycleStage == protocol.LifeCycleStageStaging { obs.AttestedPredecessorRetirement, err = p.PredecessorRetirementReportCache.AttestedRetirementReport(*p.PredecessorConfigDigest) + if err == nil { + err = validateAttestedPredecessorRetirement(obs.AttestedPredecessorRetirement) + } if err != nil { // Best-effort: the state transition only needs one node to // supply a valid retirement report, so omit it rather than // failing the round. obs.AttestedPredecessorRetirement = nil - p.Logger.Errorw("Failed to fetch attested retirement report from cache, omitting it from this observation", "stage", "Observation", "seqNr", seqNr, "err", err) + p.Logger.Errorw("Failed to fetch a usable attested retirement report from cache, omitting it from this observation", "stage", "Observation", "seqNr", seqNr, "err", err) } if len(obs.AttestedPredecessorRetirement) != 0 { p.voteOnPredecessorConfig(&obs, seqNr) @@ -310,8 +313,12 @@ func (p *Plugin) voteOnPredecessorConfig(obs *Observation, seqNr uint64) { p.Logger.Warnw("Predecessor config not in the local cache yet, not voting on it this round", "stage", "Observation", "seqNr", seqNr, "predecessorConfigDigest", *p.PredecessorConfigDigest) return } - if len(signers) == 0 || len(signers) > protocol.MaxObservationPredecessorSignersLength { - p.Logger.Errorw("Local predecessor config has an unusable signer set, not voting on it", "stage", "Observation", "seqNr", seqNr, "signers", len(signers)) + if len(signers) == 0 { + p.Logger.Errorw("Local predecessor config has an empty signer set, not voting on it", "stage", "Observation", "seqNr", seqNr) + return + } + if err := validatePredecessorConfig(signers, uint32(f)); err != nil { + p.Logger.Errorw("Local predecessor config is unusable, not voting on it", "stage", "Observation", "seqNr", seqNr, "err", err) return } obs.PredecessorSigners = signers From 0bfa02abf7518f5c2c5f29f98495a80ba3379103 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Tue, 13 Oct 2026 14:09:19 +0100 Subject: [PATCH 059/103] llo/v31: document why decodePrecursor skips per-definition caps --- llo/dev/v31/precursor.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/llo/dev/v31/precursor.go b/llo/dev/v31/precursor.go index b3363aa..f49c9b0 100644 --- a/llo/dev/v31/precursor.go +++ b/llo/dev/v31/precursor.go @@ -118,6 +118,11 @@ func encodePrecursor(p precursor) (ocr3_1types.ReportsPlusPrecursor, error) { return b, nil } +// decodePrecursor decodes a precursor this oracle produced, or one fetched from +// a peer that libocr verified against the certified digest and size limit. Its +// channel definitions are the committed set, so the per-definition stream and +// opts caps are not applied here: they are admission-only, and rejecting a +// committed definition would fail Reports on every oracle. func decodePrecursor(b ocr3_1types.ReportsPlusPrecursor) (precursor, error) { pb := &protocol.LLOPrecursorProto{} if err := proto.Unmarshal(b, pb); err != nil { From b13b40282ef9e2c190b969705d81a31bdc327757 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Tue, 13 Oct 2026 14:23:08 +0100 Subject: [PATCH 060/103] llo/v31: document why codec support entries never expire --- llo/dev/v31/statetransition.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/llo/dev/v31/statetransition.go b/llo/dev/v31/statetransition.go index 86907e4..df6f5ab 100644 --- a/llo/dev/v31/statetransition.go +++ b/llo/dev/v31/statetransition.go @@ -372,6 +372,12 @@ func (p *Plugin) decodeObservations(ctx context.Context, aos []ocrtypes.Attribut // mergeCodecSupport overlays this round's advertisements on the persisted ones, // replacing the entry of every oracle that contributed an observation and // leaving the rest untouched. +// +// Entries are never expired. An oracle that stops contributing keeps counting +// for its last advertised formats, but it is then faulty and counts against f. +// The 2f+1 supporter threshold still leaves f+1 honest live encoders whenever +// at most f oracles are faulty, and this state is scoped to a single protocol +// instance. func mergeCodecSupport(persisted, thisRound map[commontypes.OracleID]map[llotypes.ReportFormat]struct{}) map[commontypes.OracleID]map[llotypes.ReportFormat]struct{} { merged := make(map[commontypes.OracleID]map[llotypes.ReportFormat]struct{}, len(persisted)+len(thisRound)) for oracleID, formats := range persisted { From ab5efa9a1003c0fb519498c53c9cb004c2debace Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Tue, 13 Oct 2026 14:31:36 +0100 Subject: [PATCH 061/103] llo/v31: skip channel updates with competing definitions past the threshold --- llo/dev/v31/flow_test.go | 52 ++++++++++++++++++++++++++++++++++ llo/dev/v31/statetransition.go | 20 ++++++++++--- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/llo/dev/v31/flow_test.go b/llo/dev/v31/flow_test.go index f29f2d8..12d7c57 100644 --- a/llo/dev/v31/flow_test.go +++ b/llo/dev/v31/flow_test.go @@ -1060,6 +1060,58 @@ func Test_ApplyChannelVotes_UpdateWinsOverSameRoundRemoval(t *testing.T) { require.Equal(t, llotypes.ChannelDefinitions{1: jsonChannel(), 2: readded}, pending) } +// Test_ApplyChannelVotes_ContestedUpdateIsSkipped covers polling skew across +// two publications in quick succession: oracles split between two new +// definitions of the same channel, and both cross the threshold in the same +// round. Definitions carry no version, so neither is applied. +func Test_ApplyChannelVotes_ContestedUpdateIsSkipped(t *testing.T) { + const f = 1 + withStream := func(id llotypes.StreamID) protocol.ChannelDefinitionWithID { + cd := jsonChannel() + cd.Streams = []llotypes.Stream{{StreamID: id, Aggregator: llotypes.AggregatorMedian}} + return protocol.ChannelDefinitionWithID{ChannelDefinition: cd, ChannelID: 2} + } + x, y := withStream(200), withStream(300) + hx, hy := makeChannelHash(x), makeChannelHash(y) + updates := map[[32]byte]protocol.ChannelDefinitionWithID{hx: x, hy: y} + + t.Run("both past the threshold leaves the channel unchanged", func(t *testing.T) { + pending := llotypes.ChannelDefinitions{1: jsonChannel(), 2: jsonChannel()} + applyChannelVotes(pending, nil, updates, map[[32]byte]int{hx: f + 1, hy: f + 1}, f) + require.Equal(t, llotypes.ChannelDefinitions{1: jsonChannel(), 2: jsonChannel()}, pending) + }) + + t.Run("both past the threshold adds nothing", func(t *testing.T) { + pending := llotypes.ChannelDefinitions{1: jsonChannel()} + applyChannelVotes(pending, nil, updates, map[[32]byte]int{hx: f + 1, hy: f + 1}, f) + require.Equal(t, llotypes.ChannelDefinitions{1: jsonChannel()}, pending) + }) + + t.Run("one past the threshold is applied", func(t *testing.T) { + pending := llotypes.ChannelDefinitions{1: jsonChannel(), 2: jsonChannel()} + applyChannelVotes(pending, nil, updates, map[[32]byte]int{hx: f, hy: f + 1}, f) + require.Equal(t, llotypes.ChannelDefinitions{1: jsonChannel(), 2: y.ChannelDefinition}, pending) + }) + + t.Run("other channels still apply", func(t *testing.T) { + other := protocol.ChannelDefinitionWithID{ChannelDefinition: x.ChannelDefinition, ChannelID: 3} + ho := makeChannelHash(other) + pending := llotypes.ChannelDefinitions{1: jsonChannel(), 2: jsonChannel()} + applyChannelVotes(pending, nil, + map[[32]byte]protocol.ChannelDefinitionWithID{hx: x, hy: y, ho: other}, + map[[32]byte]int{hx: f + 1, hy: f + 1, ho: f + 1}, f) + require.Equal(t, llotypes.ChannelDefinitions{1: jsonChannel(), 2: jsonChannel(), 3: x.ChannelDefinition}, pending) + }) + + t.Run("a same round removal still applies", func(t *testing.T) { + tombstoned := jsonChannel() + tombstoned.Tombstone = true + pending := llotypes.ChannelDefinitions{1: jsonChannel(), 2: tombstoned} + applyChannelVotes(pending, map[llotypes.ChannelID]int{2: f + 1}, updates, map[[32]byte]int{hx: f + 1, hy: f + 1}, f) + require.Equal(t, llotypes.ChannelDefinitions{1: jsonChannel()}, pending) + }) +} + // Test_IsReportable_MinReportIntervalDoesNotOverflow // validAfter is a nanosecond wall-clock timestamp and the offchain config // bounds DefaultMinReportIntervalNanoseconds only away from zero, so a large diff --git a/llo/dev/v31/statetransition.go b/llo/dev/v31/statetransition.go index df6f5ab..ca2db7d 100644 --- a/llo/dev/v31/statetransition.go +++ b/llo/dev/v31/statetransition.go @@ -442,6 +442,13 @@ func supportVotesForEffectiveFormats(votes map[llotypes.ReportFormat]int, effect // is a newer definition from the owner, so the result is the same as reaping // the tombstone and adding the definition in consecutive rounds. // +// A channel with more than one definition past the threshold is left +// unchanged. Definitions carry no version, so the votes cannot tell which is +// newer. This happens when the owner publishes twice within one polling +// window, and any pick would flip each round until the caches converge, since +// oracles skip voting for the committed definition and the others vote the +// competing one back in. +// // defs is the PENDING set: everything applied here takes effect next round. It // deliberately does not touch the round's channel generation, whose definitions // and decoded opts stay pinned to the effective set for the whole round. @@ -467,18 +474,23 @@ func applyChannelVotes( for h, d := range updateDefsByHash { ordered = append(ordered, hashWithID{h, d}) } - // Sort by (channelID, hash): the hash tiebreak keeps the order total, so - // two competing definitions for the same channelID are applied in the same - // sequence on every oracle (last one wins, consistently). + // Sort by (channelID, hash) so the cap and budgets see changes in the same + // order on every oracle. sort.Slice(ordered, func(i, j int) bool { if ordered[i].def.ChannelID != ordered[j].def.ChannelID { return ordered[i].def.ChannelID < ordered[j].def.ChannelID } return bytes.Compare(ordered[i].hash[:], ordered[j].hash[:]) < 0 }) + passing := make(map[llotypes.ChannelID]int) + for _, hwid := range ordered { + if updateVotesByHash[hwid.hash] > f { + passing[hwid.def.ChannelID]++ + } + } budget := protocol.NewChannelDefinitionsBudget(defs) for _, hwid := range ordered { - if updateVotesByHash[hwid.hash] <= f { + if updateVotesByHash[hwid.hash] <= f || passing[hwid.def.ChannelID] > 1 { continue } defWithID := hwid.def From 94755c4004e542cbea16196fc06bec9d19bb1355 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Tue, 13 Oct 2026 14:41:22 +0100 Subject: [PATCH 062/103] llo/v31: withhold votes only for inadmissible channels --- llo/dev/v31/plugin.go | 63 +++++++++++++++++++---- llo/dev/v31/plugin_test.go | 65 ++++++++++++++++++++++++ llo/protocol/channel_definitions.go | 56 ++++++++++++++++++-- llo/protocol/channel_definitions_test.go | 62 ++++++++++++++++++++++ 4 files changed, 231 insertions(+), 15 deletions(-) diff --git a/llo/dev/v31/plugin.go b/llo/dev/v31/plugin.go index 06e5608..bb4659a 100644 --- a/llo/dev/v31/plugin.go +++ b/llo/dev/v31/plugin.go @@ -355,22 +355,15 @@ func (p *Plugin) voteOnChannels(obs *Observation, state *kvState) { obs.RemoveChannelIDs = map[llotypes.ChannelID]struct{}{} expectedChannelDefs := p.ChannelDefinitionCache.Definitions(state.channelDefinitions) - // Only the channels this node would vote to add or change are held to the - // admission-only checks; the ones already committed are not, or a - // grandfathered channel would freeze channel voting entirely. - admitting := protocol.ChangedChannelIDs(state.channelDefinitions, expectedChannelDefs) - if err := protocol.VerifyChannelDefinitionsForAdmissionWithCache(p.ReportCodecs, expectedChannelDefs, admitting, p.ChannelAnalysisCache); err != nil { - // Don't halt on an invalid channel-definitions file; just don't vote. - p.Logger.Errorw("ChannelDefinitionCache.Definitions is invalid", "err", err) - return - } // Only tombstoned channels are removal candidates. A live channel missing // from the desired set is not read as "remove": a cache that returns a // partial set, before it has loaded or from a source it could not read, // would otherwise get those channels removed, since every node would vote // the same way from the same input. Removal stays a two step decision: the - // DON first agrees on the tombstone, then on dropping it. + // DON first agrees on the tombstone, then on dropping it. Reaping only + // shrinks the committed set, so it is voted on even when the desired set + // fails verification. removable := llotypes.ChannelDefinitions{} for channelID, cd := range state.channelDefinitions { if cd.Tombstone { @@ -383,7 +376,55 @@ func (p *Plugin) voteOnChannels(obs *Observation, state *kvState) { obs.RemoveChannelIDs[channelID] = struct{}{} } - obs.UpdateChannelDefinitions = channelUpdateVotes(state.channelDefinitions, expectedChannelDefs) + admissible, skipped, err := admissibleChannelDefinitions(p.ReportCodecs, state.channelDefinitions, expectedChannelDefs, p.ChannelAnalysisCache) + if len(skipped) > 0 { + p.Logger.Errorw("ChannelDefinitionCache.Definitions has inadmissible channels, not voting for them", "channelIDs", sortedChannelIDSet(skipped)) + } + if err != nil { + // Don't halt on an invalid channel-definitions file; just don't vote + // for any update. + p.Logger.Errorw("ChannelDefinitionCache.Definitions is invalid", "err", err) + return + } + obs.UpdateChannelDefinitions = channelUpdateVotes(state.channelDefinitions, admissible) +} + +// admissibleChannelDefinitions returns expected with every channel that fails +// admission reverted to its committed definition, or dropped if it has none, +// together with the channels it reverted. Only the channels being added or +// changed are held to the admission-only checks; the ones already committed +// are not, or a grandfathered channel would freeze channel voting entirely. +// +// Reverting a channel can expose a finding against another one, such as a +// history backfill channel whose target was reverted, so the result is +// verified once more and rejected as a whole if it still fails. An error means +// no update is admissible. +func admissibleChannelDefinitions(codecs map[llotypes.ReportFormat]protocol.ReportCodec, committed, expected llotypes.ChannelDefinitions, cache *protocol.ChannelAnalysisCache) (llotypes.ChannelDefinitions, map[llotypes.ChannelID]struct{}, error) { + admitting := protocol.ChangedChannelIDs(committed, expected) + skipped, err := protocol.InadmissibleChannelIDsWithCache(codecs, expected, admitting, cache) + if err != nil || len(skipped) == 0 { + return expected, skipped, err + } + + // expected may share its backing map with the committed set. + admissible := cloneChannelDefinitions(expected) + for channelID := range skipped { + if prev, ok := committed[channelID]; ok { + admissible[channelID] = prev + } else { + delete(admissible, channelID) + } + } + + admitting = protocol.ChangedChannelIDs(committed, admissible) + rest, err := protocol.InadmissibleChannelIDsWithCache(codecs, admissible, admitting, cache) + if err == nil && len(rest) > 0 { + err = fmt.Errorf("channels %v fail admission once channels %v are withheld", sortedChannelIDSet(rest), sortedChannelIDSet(skipped)) + } + if err != nil { + return nil, skipped, err + } + return admissible, skipped, nil } // channelUpdateVotes returns the channels in expected that are new or changed diff --git a/llo/dev/v31/plugin_test.go b/llo/dev/v31/plugin_test.go index cccf390..36fb389 100644 --- a/llo/dev/v31/plugin_test.go +++ b/llo/dev/v31/plugin_test.go @@ -1900,6 +1900,71 @@ func Test_FullRound_RecoverFromUnverifiableChannel(t *testing.T) { require.Contains(t, kvChannelDefs(t, kv), llotypes.ChannelID(1)) } +// Test_VoteOnChannels_WithholdsOnlyInadmissibleChannels covers a desired set +// holding channels that fail verification: the node withholds its vote for +// those and still votes on every other update and removal. +func Test_VoteOnChannels_WithholdsOnlyInadmissibleChannels(t *testing.T) { + channel := func(streamID llotypes.StreamID) llotypes.ChannelDefinition { + return llotypes.ChannelDefinition{ + ReportFormat: llotypes.ReportFormatJSON, + Streams: []llotypes.Stream{{StreamID: streamID, Aggregator: llotypes.AggregatorMedian}}, + } + } + unknownAggregator := channel(200) + unknownAggregator.Streams[0].Aggregator = 99 + tombstone := llotypes.ChannelDefinition{ReportFormat: llotypes.ReportFormatJSON, Tombstone: true} + + vote := func(t *testing.T, p *Plugin, committed, desired llotypes.ChannelDefinitions) Observation { + t.Helper() + p.ChannelDefinitionCache = &mockChannelDefinitionCache{defs: desired} + var obs Observation + p.voteOnChannels(&obs, &kvState{channelDefinitions: committed}) + return obs + } + + t.Run("an inadmissible addition does not block the others", func(t *testing.T) { + committed := llotypes.ChannelDefinitions{1: channel(100), 9: tombstone} + desired := llotypes.ChannelDefinitions{1: channel(100), 2: unknownAggregator, 3: channel(300), 4: {ReportFormat: llotypes.ReportFormatJSON}} + obs := vote(t, testPlugin(t), committed, desired) + require.Equal(t, llotypes.ChannelDefinitions{3: channel(300)}, obs.UpdateChannelDefinitions) + require.Equal(t, map[llotypes.ChannelID]struct{}{9: {}}, obs.RemoveChannelIDs) + }) + t.Run("an inadmissible change keeps the committed definition", func(t *testing.T) { + committed := llotypes.ChannelDefinitions{1: channel(100)} + changed := unknownAggregator + desired := llotypes.ChannelDefinitions{1: changed, 3: channel(300)} + obs := vote(t, testPlugin(t), committed, desired) + require.Equal(t, llotypes.ChannelDefinitions{3: channel(300)}, obs.UpdateChannelDefinitions) + }) + t.Run("a committed channel this build rejects does not block the others", func(t *testing.T) { + p := testPlugin(t) + p.ReportCodecs = map[llotypes.ReportFormat]protocol.ReportCodec{ + llotypes.ReportFormatJSON: strictJSONCodec{rejectStream: 999}, + } + committed := llotypes.ChannelDefinitions{1: channel(999)} + desired := llotypes.ChannelDefinitions{1: channel(999), 3: channel(300)} + obs := vote(t, p, committed, desired) + require.Equal(t, llotypes.ChannelDefinitions{3: channel(300)}, obs.UpdateChannelDefinitions) + }) + t.Run("a whole-set finding withholds every update but not removals", func(t *testing.T) { + committed := llotypes.ChannelDefinitions{9: tombstone} + desired := llotypes.ChannelDefinitions{} + for c := range protocol.MaxOutcomeChannelDefinitionsLength + 1 { + desired[llotypes.ChannelID(c+100)] = channel(100) + } + obs := vote(t, testPlugin(t), committed, desired) + require.Empty(t, obs.UpdateChannelDefinitions) + require.Equal(t, map[llotypes.ChannelID]struct{}{9: {}}, obs.RemoveChannelIDs) + }) + t.Run("does not mutate the desired set", func(t *testing.T) { + committed := llotypes.ChannelDefinitions{1: channel(100)} + desired := llotypes.ChannelDefinitions{1: unknownAggregator, 2: unknownAggregator} + obs := vote(t, testPlugin(t), committed, desired) + require.Empty(t, obs.UpdateChannelDefinitions) + require.Equal(t, llotypes.ChannelDefinitions{1: unknownAggregator, 2: unknownAggregator}, desired) + }) +} + // Test_Observation_RetirementCacheErrorsAreNotFatal guards the liveness fix: // both retirement caches read from node-local, asynchronously populated state, // so a transient failure must not fail the round. The vote is best-effort — a diff --git a/llo/protocol/channel_definitions.go b/llo/protocol/channel_definitions.go index eca6b70..5513d32 100644 --- a/llo/protocol/channel_definitions.go +++ b/llo/protocol/channel_definitions.go @@ -105,12 +105,11 @@ func (f admissionFinding) appliesTo(admitting map[llotypes.ChannelID]struct{}) b // For a DON where all participants share the same version this is unreachable, // as ValidateObservation runs the same baseline checks over the merged set, // but a version skew can make it reachable. -// Report the finding and carry on, which is the same treatment the -// admission-only findings get in voteOnChannels. +// Report the finding and carry on. // // Attributing the findings instead lets the caller drop just those channels and -// keep going, which is the same treatment the admission-only findings already -// get. The returned error carries the whole-set baseline findings, which +// keep going, which is the same treatment InadmissibleChannelIDsWithCache gives +// the channels being admitted. The returned error carries the whole-set baseline findings, which // implicate no particular channel and so cannot be skipped selectively. func UnverifiableChannelIDs(codecs map[llotypes.ReportFormat]ReportCodec, channelDefs llotypes.ChannelDefinitions) (map[llotypes.ChannelID]struct{}, error) { return UnverifiableChannelIDsWithCache(codecs, channelDefs, nil) @@ -127,6 +126,55 @@ func UnverifiableChannelIDsWithCache(codecs map[llotypes.ReportFormat]ReportCode return ids, res.setErr() } +// InadmissibleChannelIDsWithCache returns the channels of admitting that any +// baseline or admission-only finding implicates, so that a node can withhold +// its vote for just those instead of for the whole set. Findings against +// channels outside admitting are left out: those are already committed, and +// UnverifiableChannelIDs reports them. +// +// The returned error carries the findings that implicate no particular channel, +// which cannot be withheld selectively. A whole-set budget only applies when +// something is being admitted, the same as in VerifyChannelDefinitionsForAdmission. +// The unique stream ID budget is reported only once no channel is implicated, +// since withholding an implicated channel may bring the set back within it. +// +// The per-definition checks are memoized in cache. A nil cache memoizes +// nothing. +func InadmissibleChannelIDsWithCache(codecs map[llotypes.ReportFormat]ReportCodec, channelDefs llotypes.ChannelDefinitions, admitting map[llotypes.ChannelID]struct{}, cache *ChannelAnalysisCache) (map[llotypes.ChannelID]struct{}, error) { + res := analyzeChannelDefinitions(codecs, channelDefs, cache) + if res.wholeSetErr != nil { + return nil, res.wholeSetErr + } + + ids := make(map[llotypes.ChannelID]struct{}) + implicate := func(channelID llotypes.ChannelID) { + if _, ok := admitting[channelID]; ok { + ids[channelID] = struct{}{} + } + } + for channelID := range res.channelErrs { + implicate(channelID) + } + + var setErr error + for _, finding := range res.admissionFindings { + if finding.wholeSet { + if finding.appliesTo(admitting) { + setErr = errors.Join(setErr, finding.err) + } + continue + } + for _, channelID := range finding.channels { + implicate(channelID) + } + } + + if setErr == nil && len(ids) == 0 && len(admitting) > 0 && res.uniqueStreamIDs > MaxObservationStreamValuesLength { + setErr = fmt.Errorf("too many unique stream IDs, got: %d/%d", res.uniqueStreamIDs, MaxObservationStreamValuesLength) + } + return ids, setErr +} + // verifyResult is the outcome of analyzing a definition set: baseline findings // attributed to the channel that produced them, baseline findings that belong // to the set as a whole, and the admission-only findings, which are filtered by diff --git a/llo/protocol/channel_definitions_test.go b/llo/protocol/channel_definitions_test.go index ec864b7..f662d68 100644 --- a/llo/protocol/channel_definitions_test.go +++ b/llo/protocol/channel_definitions_test.go @@ -406,6 +406,68 @@ func Test_VerifyChannelDefinitions_AdmissionScope(t *testing.T) { }) } +func Test_InadmissibleChannelIDs(t *testing.T) { + codecs := map[llotypes.ReportFormat]ReportCodec{0: stubReportCodec{}} + feedIDCodecs := map[llotypes.ReportFormat]ReportCodec{0: mockFeedIDCodec{feedID: [32]byte{0xab}, ok: true}} + channel := func(streamID llotypes.StreamID) llotypes.ChannelDefinition { + return llotypes.ChannelDefinition{Streams: []llotypes.Stream{{StreamID: streamID, Aggregator: llotypes.AggregatorMedian}}} + } + ids := func(channelIDs ...llotypes.ChannelID) map[llotypes.ChannelID]struct{} { + set := make(map[llotypes.ChannelID]struct{}, len(channelIDs)) + for _, channelID := range channelIDs { + set[channelID] = struct{}{} + } + return set + } + + t.Run("attributes findings to the admitted channels that produced them", func(t *testing.T) { + unknownAggregator := channel(2) + unknownAggregator.Streams[0].Aggregator = 99 + defs := llotypes.ChannelDefinitions{1: channel(1), 2: unknownAggregator, 3: {}} + got, err := InadmissibleChannelIDsWithCache(codecs, defs, ids(1, 2, 3), nil) + require.NoError(t, err) + require.Equal(t, ids(2, 3), got) + }) + t.Run("leaves out findings against committed channels", func(t *testing.T) { + // Channel 1 fails a baseline check but is not being admitted. + defs := llotypes.ChannelDefinitions{1: {}, 2: channel(2)} + got, err := InadmissibleChannelIDsWithCache(codecs, defs, ids(2), nil) + require.NoError(t, err) + require.Empty(t, got) + }) + t.Run("a cross-definition finding implicates only the admitted side", func(t *testing.T) { + defs := llotypes.ChannelDefinitions{1: channel(1), 2: channel(1)} + for _, admitted := range []llotypes.ChannelID{1, 2} { + got, err := InadmissibleChannelIDsWithCache(feedIDCodecs, defs, ids(admitted), nil) + require.NoError(t, err) + require.Equal(t, ids(admitted), got) + } + }) + t.Run("a whole-set budget is an error only when something is admitted", func(t *testing.T) { + defs := llotypes.ChannelDefinitions{} + for c := range MaxTotalOptsBytes/MaxChannelOptsBytes + 1 { + cd := channel(1) + cd.Opts = []byte(strings.Repeat("x", MaxChannelOptsBytes)) + defs[llotypes.ChannelID(c+1)] = cd + } + got, err := InadmissibleChannelIDsWithCache(codecs, defs, ids(1), nil) + require.Error(t, err) + require.Empty(t, got) + + got, err = InadmissibleChannelIDsWithCache(codecs, defs, ids(), nil) + require.NoError(t, err) + require.Empty(t, got) + }) + t.Run("too many channels is an error", func(t *testing.T) { + defs := llotypes.ChannelDefinitions{} + for c := range MaxOutcomeChannelDefinitionsLength + 1 { + defs[llotypes.ChannelID(c+1)] = channel(1) + } + _, err := InadmissibleChannelIDsWithCache(codecs, defs, ids(1), nil) + require.EqualError(t, err, fmt.Sprintf("too many channels, got: %d/%d", MaxOutcomeChannelDefinitionsLength+1, MaxOutcomeChannelDefinitionsLength)) + }) +} + func Test_VerifyChannelDefinitions_SizeBudgets(t *testing.T) { codecs := map[llotypes.ReportFormat]ReportCodec{0: stubReportCodec{}} From 38486aa25ed5e7017ee01b9ed68d91241b406651 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Tue, 13 Oct 2026 14:51:15 +0100 Subject: [PATCH 063/103] llo/v31: reject a non-positive MaxDurationObservation --- llo/dev/v31/factory.go | 6 +++++ llo/dev/v31/flow_test.go | 51 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/llo/dev/v31/factory.go b/llo/dev/v31/factory.go index 3c9732b..7a45e0d 100644 --- a/llo/dev/v31/factory.go +++ b/llo/dev/v31/factory.go @@ -138,6 +138,12 @@ func (f *PluginFactory) NewReportingPlugin(ctx context.Context, cfg ocr3types.Re return nil, nil, fmt.Errorf("NewReportingPlugin: offchain config defaultMinReportIntervalNanoseconds (%d) exceeds the maximum (%d)", offchainConfig.DefaultMinReportIntervalNanoseconds, protocol.MaxDefaultMinReportIntervalNanoseconds) } + // OCR3.1 requires this to be positive. Reject a host that bypasses + // it rather than run a pump whose every cycle times out immediately. + if cfg.MaxDurationObservation <= 0 { + return nil, nil, fmt.Errorf("NewReportingPlugin: MaxDurationObservation (%v) must be positive", cfg.MaxDurationObservation) + } + blobInFlightWaitFactor := f.BlobInFlightWaitFactor if blobInFlightWaitFactor == 0 { blobInFlightWaitFactor = defaultBlobInFlightWaitFactor diff --git a/llo/dev/v31/flow_test.go b/llo/dev/v31/flow_test.go index 12d7c57..66e51d0 100644 --- a/llo/dev/v31/flow_test.go +++ b/llo/dev/v31/flow_test.go @@ -239,7 +239,7 @@ func Test_Factory_NewReportingPlugin_aggregationFaultTolerance(t *testing.T) { require.EqualError(t, err, "aggregationFaultTolerance must be at least 1 when consensus F (1) is non-zero: a floor of 1 contribution lets a single oracle set an aggregate") }) t.Run("accepts below consensus F", func(t *testing.T) { - p, _, err := f.NewReportingPlugin(ctx, ocr3types.ReportingPluginConfig{N: 7, F: 2, ConfigDigest: ocrtypes.ConfigDigest{9}, OffchainConfig: mustEncodeOffchainConfig(t, 1)}, nil) + p, _, err := f.NewReportingPlugin(ctx, ocr3types.ReportingPluginConfig{N: 7, F: 2, ConfigDigest: ocrtypes.ConfigDigest{9}, OffchainConfig: mustEncodeOffchainConfig(t, 1), MaxDurationObservation: time.Second}, nil) require.NoError(t, err) pl, ok := p.(*Plugin) require.True(t, ok) @@ -247,7 +247,7 @@ func Test_Factory_NewReportingPlugin_aggregationFaultTolerance(t *testing.T) { require.NoError(t, pl.Close()) }) t.Run("accepts zero when consensus F is zero", func(t *testing.T) { - p, _, err := f.NewReportingPlugin(ctx, ocr3types.ReportingPluginConfig{N: 1, F: 0, ConfigDigest: ocrtypes.ConfigDigest{9}, OffchainConfig: mustEncodeOffchainConfig(t, 0)}, nil) + p, _, err := f.NewReportingPlugin(ctx, ocr3types.ReportingPluginConfig{N: 1, F: 0, ConfigDigest: ocrtypes.ConfigDigest{9}, OffchainConfig: mustEncodeOffchainConfig(t, 0), MaxDurationObservation: time.Second}, nil) require.NoError(t, err) pl, ok := p.(*Plugin) require.True(t, ok) @@ -277,7 +277,7 @@ func Test_Factory_NewReportingPlugin_reportCadence(t *testing.T) { newPlugin := func(t *testing.T, offchainConfig []byte) error { t.Helper() - p, _, err := f.NewReportingPlugin(ctx, ocr3types.ReportingPluginConfig{N: 4, F: 1, ConfigDigest: ocrtypes.ConfigDigest{9}, OffchainConfig: offchainConfig}, nil) + p, _, err := f.NewReportingPlugin(ctx, ocr3types.ReportingPluginConfig{N: 4, F: 1, ConfigDigest: ocrtypes.ConfigDigest{9}, OffchainConfig: offchainConfig, MaxDurationObservation: time.Second}, nil) if err == nil { require.NoError(t, p.Close()) } @@ -314,10 +314,51 @@ func Test_Factory_NewReportingPlugin_reportCadence(t *testing.T) { }) } -func Test_Factory_MaxRoundPeriod(t *testing.T) { +func Test_Factory_NewReportingPlugin_maxDurationObservation(t *testing.T) { ctx := tests.Context(t) + f := NewPluginFactory(PluginFactoryParams{ + OnchainConfigCodec: mockOnchainConfigCodec{}, + Logger: logger.Test(t), + }) base := ocr3types.ReportingPluginConfig{N: 4, F: 1, ConfigDigest: ocrtypes.ConfigDigest{9}, OffchainConfig: mustEncodeOffchainConfig(t, 1)} + // The pump derives its observation timeout from this value, so zero would + // expire every cycle's context before it starts. + t.Run("refuses to start when not positive", func(t *testing.T) { + for _, d := range []time.Duration{0, -time.Second} { + cfg := base + cfg.MaxDurationObservation = d + _, _, err := f.NewReportingPlugin(ctx, cfg, nil) + require.EqualError(t, err, fmt.Sprintf("NewReportingPlugin: MaxDurationObservation (%v) must be positive", d)) + } + }) + + t.Run("refuses to start when not positive even with a blob observation override", func(t *testing.T) { + f := NewPluginFactory(PluginFactoryParams{ + OnchainConfigCodec: mockOnchainConfigCodec{}, + Logger: logger.Test(t), + MaxDurationBlobObservation: time.Second, + }) + _, _, err := f.NewReportingPlugin(ctx, base, nil) + require.EqualError(t, err, "NewReportingPlugin: MaxDurationObservation (0s) must be positive") + }) + + t.Run("derives the pump timeouts when positive", func(t *testing.T) { + cfg := base + cfg.MaxDurationObservation = time.Second + p, _, err := f.NewReportingPlugin(ctx, cfg, nil) + require.NoError(t, err) + pump := p.(*Plugin).pump + require.Equal(t, DefaultBlobObservationDurationMultiplier*time.Second, pump.observationTimeout) + require.Equal(t, time.Second/time.Duration(defaultBlobInFlightWaitFactor), pump.inFlightWait) + require.NoError(t, p.Close()) + }) +} + +func Test_Factory_MaxRoundPeriod(t *testing.T) { + ctx := tests.Context(t) + base := ocr3types.ReportingPluginConfig{N: 4, F: 1, ConfigDigest: ocrtypes.ConfigDigest{9}, OffchainConfig: mustEncodeOffchainConfig(t, 1), MaxDurationObservation: time.Second} + newPump := func(t *testing.T, params PluginFactoryParams, cfg ocr3types.ReportingPluginConfig) *blobPump { t.Helper() params.OnchainConfigCodec = mockOnchainConfigCodec{} @@ -344,7 +385,7 @@ func Test_Factory_NewReportingPlugin(t *testing.T) { OnchainConfigCodec: mockOnchainConfigCodec{}, Logger: logger.Test(t), }) - p, info, err := f.NewReportingPlugin(ctx, ocr3types.ReportingPluginConfig{N: 4, F: 1, ConfigDigest: ocrtypes.ConfigDigest{9}, OffchainConfig: mustEncodeOffchainConfig(t, 1)}, nil) + p, info, err := f.NewReportingPlugin(ctx, ocr3types.ReportingPluginConfig{N: 4, F: 1, ConfigDigest: ocrtypes.ConfigDigest{9}, OffchainConfig: mustEncodeOffchainConfig(t, 1), MaxDurationObservation: time.Second}, nil) require.NoError(t, err) info1, ok := info.(interface{ Validate() error }) From 347c84346464f0324ce66ab710105c2a7730dce8 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Tue, 13 Oct 2026 14:57:45 +0100 Subject: [PATCH 064/103] llo/v31: document why decodePrecursor bounds only support entries --- llo/dev/v31/precursor.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/llo/dev/v31/precursor.go b/llo/dev/v31/precursor.go index f49c9b0..df16a5b 100644 --- a/llo/dev/v31/precursor.go +++ b/llo/dev/v31/precursor.go @@ -149,6 +149,11 @@ func decodePrecursor(b ocr3_1types.ReportsPlusPrecursor) (precursor, error) { // cap is the bound here. The observation-side cap does not apply: the tally // is pruned to effective formats before encoding, and the effective set may // legitimately use more distinct formats than one observation may advertise. + // + // This mirrors the encode-side cap. The other collections are left + // unbounded on purpose: the precursor is certified state, libocr bounds its + // size, and the promotion round legitimately carries more validAfter + // entries than channels. if len(pb.SupportByFormat) > protocol.MaxOutcomeChannelDefinitionsLength { return precursor{}, fmt.Errorf("precursor carries too many report format support entries: %d (max %d)", len(pb.SupportByFormat), protocol.MaxOutcomeChannelDefinitionsLength) } From 56f954e15ffe9a7fc8cdd6b856d48fb6c40de5f8 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Tue, 13 Oct 2026 15:16:14 +0100 Subject: [PATCH 065/103] llo/v31: bound channel votes in observationFromProto --- llo/dev/v31/observation.go | 8 ++++++++ llo/dev/v31/plugin.go | 9 --------- llo/dev/v31/plugin_test.go | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 9 deletions(-) diff --git a/llo/dev/v31/observation.go b/llo/dev/v31/observation.go index 01cd1ec..fe8db5a 100644 --- a/llo/dev/v31/observation.go +++ b/llo/dev/v31/observation.go @@ -258,6 +258,14 @@ func decodeObservation(ctx context.Context, raw ocrtypes.Observation, bf ocr3_1t } func observationFromProto(main *protocol.LLOObservationProto) (Observation, error) { + // Bound the channel votes before copying them, and before decodeObservation + // fetches any blob, so an oversized observation costs no fetch. + if len(main.RemoveChannelIDs) > protocol.MaxObservationRemoveChannelIDsLength { + return Observation{}, fmt.Errorf("observation removes too many channels: %d (max %d)", len(main.RemoveChannelIDs), protocol.MaxObservationRemoveChannelIDsLength) + } + if len(main.UpdateChannelDefinitions) > protocol.MaxObservationUpdateChannelDefinitionsLength { + return Observation{}, fmt.Errorf("observation updates too many channels: %d (max %d)", len(main.UpdateChannelDefinitions), protocol.MaxObservationUpdateChannelDefinitionsLength) + } obs := Observation{ AttestedPredecessorRetirement: main.AttestedPredecessorRetirement, ShouldRetire: main.ShouldRetire, diff --git a/llo/dev/v31/plugin.go b/llo/dev/v31/plugin.go index bb4659a..181e676 100644 --- a/llo/dev/v31/plugin.go +++ b/llo/dev/v31/plugin.go @@ -484,15 +484,6 @@ func (p *Plugin) ValidateObservation(ctx context.Context, seqNr uint64, _ ocrtyp if len(observation.PredecessorSigners) != 0 && len(observation.AttestedPredecessorRetirement) == 0 { return errors.New("PredecessorSigners is not empty even though this observation carries no AttestedPredecessorRetirement") } - if len(observation.UpdateChannelDefinitions) > protocol.MaxObservationUpdateChannelDefinitionsLength { - return fmt.Errorf("UpdateChannelDefinitions is too long: %v vs %v", len(observation.UpdateChannelDefinitions), protocol.MaxObservationUpdateChannelDefinitionsLength) - } - if len(observation.RemoveChannelIDs) > protocol.MaxObservationRemoveChannelIDsLength { - return fmt.Errorf("RemoveChannelIDs is too long: %v vs %v", len(observation.RemoveChannelIDs), protocol.MaxObservationRemoveChannelIDsLength) - } - if len(observation.SupportedReportFormats) > protocol.MaxObservationSupportedReportFormatsLength { - return fmt.Errorf("SupportedReportFormats is too long: %v vs %v", len(observation.SupportedReportFormats), protocol.MaxObservationSupportedReportFormatsLength) - } // Only the baseline checks run here. A definition is installed on more than // f votes for its exact hash, so at least one honest oracle must have voted diff --git a/llo/dev/v31/plugin_test.go b/llo/dev/v31/plugin_test.go index 36fb389..97b0905 100644 --- a/llo/dev/v31/plugin_test.go +++ b/llo/dev/v31/plugin_test.go @@ -1486,6 +1486,38 @@ func Test_Observation_SupportedReportFormats_RoundTrip(t *testing.T) { require.ErrorContains(t, err, "advertises too many report formats") } +// Test_DecodeObservation_ChannelVoteBounds checks that over-length channel +// votes are rejected before any blob is fetched: the fetcher fails, so reaching +// it would surface a fetch error instead. +func Test_DecodeObservation_ChannelVoteBounds(t *testing.T) { + ctx := tests.Context(t) + + removals := make([]uint32, protocol.MaxObservationRemoveChannelIDsLength+1) + for i := range removals { + removals[i] = uint32(i) + } + updates := make(map[uint32]*protocol.LLOChannelDefinitionProto, protocol.MaxObservationUpdateChannelDefinitionsLength+1) + for i := range protocol.MaxObservationUpdateChannelDefinitionsLength + 1 { + updates[uint32(i)] = &protocol.LLOChannelDefinitionProto{} + } + + for _, tc := range []struct { + name string + main *protocol.LLOObservationProto + err string + }{ + {"RemoveChannelIDs", &protocol.LLOObservationProto{UnixTimestampNanoseconds: 1, RemoveChannelIDs: removals}, "removes too many channels"}, + {"UpdateChannelDefinitions", &protocol.LLOObservationProto{UnixTimestampNanoseconds: 1, UpdateChannelDefinitions: updates}, "updates too many channels"}, + } { + t.Run(tc.name, func(t *testing.T) { + raw, err := proto.Marshal(tc.main) + require.NoError(t, err) + _, err = decodeObservation(ctx, frameObservation([][]byte{validBlobHandleBytes()}, raw), &errBroadcaster{}, nil) + require.ErrorContains(t, err, tc.err) + }) + } +} + func Test_DecodeObservation_AttestedPredecessorRetirementBound(t *testing.T) { ctx := tests.Context(t) From 144f35a6e13a6766c86dab3c2d965e01200eceb3 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Tue, 13 Oct 2026 15:32:00 +0100 Subject: [PATCH 066/103] llo/v31: refuse promotion on divergent predecessor retirements --- llo/dev/v31/handover_test.go | 148 ++++++++++++++++++++++++++++++++- llo/dev/v31/statetransition.go | 23 ++++- 2 files changed, 168 insertions(+), 3 deletions(-) diff --git a/llo/dev/v31/handover_test.go b/llo/dev/v31/handover_test.go index c7837dd..e122b93 100644 --- a/llo/dev/v31/handover_test.go +++ b/llo/dev/v31/handover_test.go @@ -264,10 +264,16 @@ func (v handoverVerifier) Verify(_ ocrtypes.OnchainPublicKey, _ ocrtypes.ConfigD // attest wraps a raw retirement report in the attested protobuf form the // transmitter stores, signed by two of four signers (f+1 with F=1). func attest(t *testing.T, raw []byte) []byte { + t.Helper() + return attestAt(t, raw, handoverV30SeqNrRR) +} + +// attestAt is attest at the given predecessor seqNr. +func attestAt(t *testing.T, raw []byte, seqNr uint64) []byte { t.Helper() b, err := proto.Marshal(&protocol.AttestedRetirementReport{ RetirementReport: raw, - SeqNr: handoverV30SeqNrRR, + SeqNr: seqNr, Sigs: []*protocol.AttributedOnchainSignature{ {Signer: 0, Signature: []byte("sig0")}, {Signer: 1, Signature: []byte("sig1")}, @@ -698,6 +704,146 @@ func Test_Handover_RejectsIncompatibleProtocolVersion(t *testing.T) { require.NotContains(t, newInst.validAfter(), handoverChannelID) } +// Test_Handover_RetirementReportFrozenAfterRetirement pins the invariant that +// makes the successor's choice among attested retirement reports irrelevant: +// once retired, a predecessor of either version emits the same +// ValidAfterNanoseconds every round, however its observations move on. +func Test_Handover_RetirementReportFrozenAfterRetirement(t *testing.T) { + const pendingChannelID, votedChannelID = llotypes.ChannelID(2), llotypes.ChannelID(3) + + // Every round after retirement keeps voting to add a channel and remove + // one, with an advancing timestamp. + retiredUpdate := llotypes.ChannelDefinitions{votedChannelID: handoverChannel()} + retiredRemove := map[llotypes.ChannelID]struct{}{handoverChannelID: {}} + + requireFrozen := func(t *testing.T, retired []byte, next func() []byte) { + t.Helper() + first, err := protocol.StandardRetirementReportCodec{}.Decode(retired) + require.NoError(t, err) + require.Contains(t, first.ValidAfterNanoseconds, handoverChannelID) + require.Contains(t, first.ValidAfterNanoseconds, pendingChannelID, "a channel added before retiring must be handed over") + require.NotZero(t, first.ValidAfterNanoseconds[handoverBackfillChannelID], "the backfill must have made progress before retiring") + + for range 3 { + raw := next() + require.NotEmpty(t, raw, "a retired instance must emit a retirement report every round") + got, err := protocol.StandardRetirementReportCodec{}.Decode(raw) + require.NoError(t, err) + require.Equal(t, first, got, "retirement report must not change after retiring") + } + } + + t.Run("v30", func(t *testing.T) { + old := newV30Instance(t, ocrtypes.ConfigDigest{0x30}, nil, nil) + ts := handoverTickNanos * 10 + old.round(v30.Observation{UnixTimestampNanoseconds: ts}) + ts += handoverTickNanos + old.round(v30.Observation{UnixTimestampNanoseconds: ts, UpdateChannelDefinitions: handoverBackfillDefs(), StreamValues: handoverStreamValues()}) + for range 2 { + ts += handoverTickNanos + old.round(v30.Observation{UnixTimestampNanoseconds: ts, StreamValues: handoverStreamValues()}) + } + ts += handoverTickNanos + old.round(v30.Observation{ + UnixTimestampNanoseconds: ts, + UpdateChannelDefinitions: llotypes.ChannelDefinitions{pendingChannelID: handoverChannel()}, + StreamValues: handoverStreamValues(), + }) + ts += handoverTickNanos + old.round(v30.Observation{UnixTimestampNanoseconds: ts, ShouldRetire: true, StreamValues: handoverStreamValues()}) + require.Equal(t, protocol.LifeCycleStageRetired, old.outcome().LifeCycleStage) + + requireFrozen(t, old.retirementRR, func() []byte { + ts += handoverTickNanos + old.retirementRR = nil + old.round(v30.Observation{ + UnixTimestampNanoseconds: ts, + ShouldRetire: true, + UpdateChannelDefinitions: retiredUpdate, + RemoveChannelIDs: retiredRemove, + StreamValues: handoverStreamValues(), + }) + return old.retirementRR + }) + }) + + t.Run("v31", func(t *testing.T) { + old := newV31Instance(t, ocrtypes.ConfigDigest{0x31}, nil, nil) + old.round(Observation{}) // bootstrap -> production + ts := handoverTickNanos * 10 + old.round(Observation{UnixTimestampNanoseconds: ts, UpdateChannelDefinitions: handoverBackfillDefs(), StreamValues: handoverStreamValues()}) + for range 2 { + ts += handoverTickNanos + old.round(Observation{UnixTimestampNanoseconds: ts, StreamValues: handoverStreamValues()}) + } + // Agreed in the round before retiring, so it takes effect on the + // retiring round itself. + ts += handoverTickNanos + old.round(Observation{ + UnixTimestampNanoseconds: ts, + UpdateChannelDefinitions: llotypes.ChannelDefinitions{pendingChannelID: handoverChannel()}, + StreamValues: handoverStreamValues(), + }) + ts += handoverTickNanos + old.round(Observation{UnixTimestampNanoseconds: ts, ShouldRetire: true, StreamValues: handoverStreamValues()}) + require.Equal(t, protocol.LifeCycleStageRetired, old.lifeCycleStage()) + + requireFrozen(t, old.retirementRR, func() []byte { + ts += handoverTickNanos + old.retirementRR = nil + old.round(Observation{ + UnixTimestampNanoseconds: ts, + ShouldRetire: true, + UpdateChannelDefinitions: retiredUpdate, + RemoveChannelIDs: retiredRemove, + StreamValues: handoverStreamValues(), + }) + return old.retirementRR + }) + }) +} + +// Test_Handover_DivergentRetirementsRefusePromotion covers the guard for a +// broken freeze: when two validly signed retirement reports in one round carry +// different watermarks, the aos order would pick one, so the successor stays +// in staging instead. Reports that differ only in the attested envelope agree +// and still promote. +func Test_Handover_DivergentRetirementsRefusePromotion(t *testing.T) { + v30Digest := ocrtypes.ConfigDigest{0x30} + encode := func(validAfter uint64) []byte { + raw, err := protocol.StandardRetirementReportCodec{}.Encode(protocol.RetirementReport{ + ProtocolVersion: handoverProtocolV, + ValidAfterNanoseconds: map[llotypes.ChannelID]uint64{handoverChannelID: validAfter}, + }) + require.NoError(t, err) + return raw + } + withRetirement := func(attested []byte) Observation { + return Observation{UnixTimestampNanoseconds: handoverTickNanos, AttestedPredecessorRetirement: attested, PredecessorSigners: handoverPredecessorSigners, PredecessorF: 1} + } + + t.Run("different watermarks", func(t *testing.T) { + stale, fresh := attestAt(t, encode(500), 40), attestAt(t, encode(600), 41) + newInst := newV31Instance(t, ocrtypes.ConfigDigest{0x31}, &v30Digest, newPredecessorCache(t, fresh, true)) + newInst.round(Observation{}) + + require.NoError(t, newInst.tryRound(withRetirement(stale), withRetirement(fresh), withRetirement(fresh), withRetirement(fresh))) + require.Equal(t, protocol.LifeCycleStageStaging, newInst.lifeCycleStage(), + "instance must stay in staging when valid retirement reports disagree") + require.NotContains(t, newInst.validAfter(), handoverChannelID) + }) + + t.Run("same watermarks in different envelopes", func(t *testing.T) { + first, later := attestAt(t, encode(500), 40), attestAt(t, encode(500), 41) + newInst := newV31Instance(t, ocrtypes.ConfigDigest{0x31}, &v30Digest, newPredecessorCache(t, first, true)) + newInst.round(Observation{}) + + require.NoError(t, newInst.tryRound(withRetirement(first), withRetirement(later), withRetirement(first), withRetirement(later))) + require.Equal(t, protocol.LifeCycleStageProduction, newInst.lifeCycleStage()) + require.Equal(t, uint64(500), newInst.validAfter()[handoverChannelID]) + }) +} + // --- backfill guard --- const handoverBackfillChannelID = llotypes.ChannelID(10) diff --git a/llo/dev/v31/statetransition.go b/llo/dev/v31/statetransition.go index ca2db7d..2f78500 100644 --- a/llo/dev/v31/statetransition.go +++ b/llo/dev/v31/statetransition.go @@ -897,7 +897,19 @@ func (p *Plugin) resolvePredecessorRetirement( return nil } + // A retired predecessor freezes its watermarks, so every report it signs + // after retiring carries the same map and the aos order cannot matter. If + // valid reports disagree, that invariant is broken and the order would pick + // the watermarks: refuse to promote rather than let the leader choose. + var chosen *protocol.RetirementReport + verified := make(map[string]struct{}, len(tally.attestedRetirements)) for _, attested := range tally.attestedRetirements { + // Honest oracles usually attach the same bytes: verify them once. + if _, seen := verified[string(attested)]; seen { + continue + } + verified[string(attested)] = struct{}{} + retirementReport, verr := p.PredecessorRetirementReportCache.VerifyAttestedRetirementReport(*p.PredecessorConfigDigest, agreed.signers, agreed.f, attested) if verr != nil { p.Logger.Warnw("Ignoring invalid attested predecessor retirement", "seqNr", seqNr, "error", verr, "predecessorConfigDigest", *p.PredecessorConfigDigest) @@ -907,9 +919,16 @@ func (p *Plugin) resolvePredecessorRetirement( p.Logger.Warnw("Ignoring incompatible attested predecessor retirement", "seqNr", seqNr, "error", verr, "predecessorConfigDigest", *p.PredecessorConfigDigest) continue } - return &retirementReport + if chosen == nil { + chosen = &retirementReport + continue + } + if chosen.ProtocolVersion != retirementReport.ProtocolVersion || !maps.Equal(chosen.ValidAfterNanoseconds, retirementReport.ValidAfterNanoseconds) { + p.Logger.Errorw("Refusing promotion: valid attested predecessor retirements disagree", "seqNr", seqNr, "predecessorConfigDigest", *p.PredecessorConfigDigest) + return nil + } } - return nil + return chosen } // electPredecessorConfig returns the candidate with more than f votes, or nil. From ec65f5338d04703c03df9ad016a21015713d4fbb Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Tue, 13 Oct 2026 16:08:05 +0100 Subject: [PATCH 067/103] llo/protocol: support protocol version 2 in retirement report handover --- llo/protocol/lifecycle.go | 5 +++-- llo/protocol/lifecycle_test.go | 7 +++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/llo/protocol/lifecycle.go b/llo/protocol/lifecycle.go index be9cb9c..b601d13 100644 --- a/llo/protocol/lifecycle.go +++ b/llo/protocol/lifecycle.go @@ -69,8 +69,9 @@ type PredecessorRetirementReportCache interface { } // MaxSupportedProtocolVersion is the highest LLO protocol version this build -// understands. See [OffchainConfig.Validate]. -const MaxSupportedProtocolVersion uint32 = 1 +// understands. It must match the highest version [OffchainConfig.Validate] +// accepts, or a validly configured instance can never promote. +const MaxSupportedProtocolVersion uint32 = 2 // CheckCompatible reports whether this retirement report, produced by a // predecessor protocol instance, may be consumed by a successor running the diff --git a/llo/protocol/lifecycle_test.go b/llo/protocol/lifecycle_test.go index ab4fd26..65bc46d 100644 --- a/llo/protocol/lifecycle_test.go +++ b/llo/protocol/lifecycle_test.go @@ -31,4 +31,11 @@ func Test_RetirementReport_CheckCompatible(t *testing.T) { err := rr(0).CheckCompatible(MaxSupportedProtocolVersion + 1) require.ErrorContains(t, err, "this instance has unsupported protocol version") }) + + t.Run("matches the versions OffchainConfig accepts", func(t *testing.T) { + for v := uint32(0); v <= MaxSupportedProtocolVersion; v++ { + require.NoError(t, OffchainConfig{ProtocolVersion: v}.Validate(), "v%d", v) + } + require.ErrorContains(t, OffchainConfig{ProtocolVersion: MaxSupportedProtocolVersion + 1}.Validate(), "unknown protocol version") + }) } From 55fc1797dd0f516905c70299094662e027e00858 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Wed, 14 Oct 2026 11:45:55 +0100 Subject: [PATCH 068/103] llo/v31: describe libocr handling of a blob fetch error in StateTransition --- llo/dev/v31/observation.go | 6 ++++-- llo/dev/v31/statetransition.go | 9 ++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/llo/dev/v31/observation.go b/llo/dev/v31/observation.go index fe8db5a..632cc6e 100644 --- a/llo/dev/v31/observation.go +++ b/llo/dev/v31/observation.go @@ -131,8 +131,10 @@ func frameObservation(handles [][]byte, mainBytes []byte) []byte { // - A blob-fetch failure is node-local and possibly transient: one oracle may // fail to fetch while others succeed. Dropping the observation on only some // oracles would make StateTransition non-deterministic and could halt the -// protocol. Callers inside StateTransition must propagate this (aborting and -// uniformly retrying the round) rather than skipping the observation. +// protocol. Callers inside StateTransition must propagate this rather than +// skip the observation. The error costs only this oracle's Prepare for the +// round: the others commit without it when they reach quorum, and this +// oracle adopts the committed state through state sync. type blobFetchError struct{ err error } func (e *blobFetchError) Error() string { return e.err.Error() } diff --git a/llo/dev/v31/statetransition.go b/llo/dev/v31/statetransition.go index 2f78500..3c1b08a 100644 --- a/llo/dev/v31/statetransition.go +++ b/llo/dev/v31/statetransition.go @@ -311,9 +311,12 @@ func (p *Plugin) decodeObservations(ctx context.Context, aos []ocrtypes.Attribut if errors.As(derr, &bfErr) { // Node-local, possibly transient failure. Dropping this // observation on only some oracles would make StateTransition - // non-deterministic; instead abort the round so every oracle - // retries uniformly. Determinism is not required when returning - // an error (see the ReportingPlugin contract). + // non-deterministic, so fail instead. Determinism is not + // required when returning an error (see the ReportingPlugin + // contract). libocr does not retry: this oracle sends no + // Prepare for the round and adopts the committed state through + // state sync. If more than f oracles fail, the round does not + // commit and a new epoch starts it over. return observationTally{}, fmt.Errorf("failed to fetch blob for observation from oracle %v: %w", ao.Observer, derr) } // Unreachable while libocr runs ValidateObservation, which decodes From 487d81384ed3b5e234b57cd0b0b787001a47946b Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Wed, 14 Oct 2026 15:11:59 +0100 Subject: [PATCH 069/103] llo/reportcodec: reject specimen reports in ReportCodecEVMStreamlined --- .../evm/report_codec_evm_streamlined.go | 4 ++++ .../evm/report_codec_evm_streamlined_test.go | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/llo/reportcodec/evm/report_codec_evm_streamlined.go b/llo/reportcodec/evm/report_codec_evm_streamlined.go index 134e6dc..48fff99 100644 --- a/llo/reportcodec/evm/report_codec_evm_streamlined.go +++ b/llo/reportcodec/evm/report_codec_evm_streamlined.go @@ -3,6 +3,7 @@ package evm import ( "bytes" "encoding/binary" + "errors" "fmt" "math" @@ -34,6 +35,9 @@ type ReportCodecEVMStreamlined struct { } func (rc ReportCodecEVMStreamlined) Encode(r protocol.Report, cd llotypes.ChannelDefinition, optsCache *protocol.OptsCache) (payload []byte, err error) { + if r.Specimen { + return nil, errors.New("ReportCodecEVMStreamlined does not support encoding specimen reports") + } opts, getErr := protocol.GetOpts[ReportFormatEVMStreamlinedOpts](optsCache, r.ChannelID) if getErr != nil { return nil, fmt.Errorf("opts not in cache for channel %d: %w", r.ChannelID, getErr) diff --git a/llo/reportcodec/evm/report_codec_evm_streamlined_test.go b/llo/reportcodec/evm/report_codec_evm_streamlined_test.go index f7d0b19..a271204 100644 --- a/llo/reportcodec/evm/report_codec_evm_streamlined_test.go +++ b/llo/reportcodec/evm/report_codec_evm_streamlined_test.go @@ -73,6 +73,21 @@ func TestReportCodecEVMStreamlined(t *testing.T) { assert.Equal(t, "00000000499602d2", hex.EncodeToString(payload[32:40])) // timestamp assert.Equal(t, "000000000000000000000000000000000000000042f693bf", hex.EncodeToString(payload[40:])) // value }) + t.Run("does not encode specimen reports", func(t *testing.T) { + cd := llotypes.ChannelDefinition{ + Opts: []byte(`{"abi":[{"type":"int128"}]}`), + } + cache := protocol.NewOptsCache() + cache.Set(1, cd.Opts) + _, err := codec.Encode(protocol.Report{ + ChannelID: 1, + Values: []protocol.StreamValue{ + protocol.ToDecimal(decimal.NewFromFloat(1123455935.123)), + }, + Specimen: true, + }, cd, cache) + require.EqualError(t, err, "ReportCodecEVMStreamlined does not support encoding specimen reports") + }) }) t.Run("Verify", func(t *testing.T) { t.Run("with valid opts", func(t *testing.T) { From 4410298b03937537d632987e7108ffd237a7bebb Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Wed, 14 Oct 2026 15:14:10 +0100 Subject: [PATCH 070/103] llo/v30: select backfill candidate from the outcome synced opts cache in Reports --- llo/v30/plugin_reports.go | 2 +- llo/v30/plugin_reports_test.go | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/llo/v30/plugin_reports.go b/llo/v30/plugin_reports.go index 3a4c9bd..d22af6f 100644 --- a/llo/v30/plugin_reports.go +++ b/llo/v30/plugin_reports.go @@ -79,7 +79,7 @@ func (p *Plugin) reports(ctx context.Context, seqNr uint64, rawOutcome ocr3types cd := outcome.ChannelDefinitions[cid] if cd.ReportFormat == llotypes.ReportFormatHistoryBackfill { - tsNanos, rawTS, opts, uerr := SelectBackfillCandidate(&outcome, cid, p.OptsCache) + tsNanos, rawTS, opts, uerr := SelectBackfillCandidate(&outcome, cid, optsCache) if uerr != nil { p.Logger.Warnw("backfill channel was reportable but selection failed", "channelID", cid, "err", uerr, "stage", "Report", "seqNr", seqNr) continue diff --git a/llo/v30/plugin_reports_test.go b/llo/v30/plugin_reports_test.go index d9d4003..afba0df 100644 --- a/llo/v30/plugin_reports_test.go +++ b/llo/v30/plugin_reports_test.go @@ -205,6 +205,15 @@ func testReports(t *testing.T, outcomeCodec OutcomeCodec) { assert.Contains(t, reportJSON, `"ChannelID":2`) assert.NotContains(t, reportJSON, `"ChannelID":10`) assert.Contains(t, reportJSON, `"ObservationTimestampNanoseconds":100000000000`) + + // Backfill opts come from the outcome, not from the node local cache + // filled by Outcome(). + p.OptsCache.Set(backfillID, []byte(`{"targetChannelId":2,"observations":{"200":{"1":"7"}}}`)) + defer p.OptsCache.Remove(backfillID) + rwis, err = p.Reports(ctx, 2, encoded) + require.NoError(t, err) + require.Len(t, rwis, 1) + assert.Contains(t, string(rwis[0].ReportWithInfo.Report), `"ObservationTimestampNanoseconds":100000000000`) }) t.Run("(bug demo) results in a report gap due to validAfter being updated even if previous report was dropped", func(t *testing.T) { From d22631c85337eace8a0d5ecbdcfb1acc577383a9 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Wed, 14 Oct 2026 15:15:51 +0100 Subject: [PATCH 071/103] llo/v31: require MaxSnapshotRounds of at least 2 in ResolveBlobRounds --- llo/dev/v31/blobpump.go | 7 +++++++ llo/dev/v31/blobpump_test.go | 7 ++++++- llo/dev/v31/factory.go | 7 ++++--- llo/pluginconfig/v31config.go | 4 ++-- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/llo/dev/v31/blobpump.go b/llo/dev/v31/blobpump.go index 65c3ec9..82df678 100644 --- a/llo/dev/v31/blobpump.go +++ b/llo/dev/v31/blobpump.go @@ -63,6 +63,10 @@ const ( // roughly one blob per round, so the unexpired-blob budget declared to // libocr grows with the lifetime; this keeps that budget sane. MaxBlobLifetimeRounds = 64 + // MinMaxSnapshotRounds bounds MaxSnapshotRounds from below. A cycle gathers + // the snapshot for the round that kicked it, and the snapshot is first taken + // by the next round, so a window of one round discards every snapshot. + MinMaxSnapshotRounds = 2 // BlobBroadcastAttempts is how many times one cycle tries to broadcast the // payload it gathered. BlobBroadcastAttempts = 3 @@ -109,6 +113,9 @@ func ResolveBlobRounds(maxSnapshotRounds, blobLifetimeRounds uint64) (uint64, ui if blobLifetimeRounds == 0 { blobLifetimeRounds = DefaultBlobLifetimeRounds } + if maxSnapshotRounds < MinMaxSnapshotRounds { + return 0, 0, fmt.Errorf("MaxSnapshotRounds (%d) is below MinMaxSnapshotRounds (%d)", maxSnapshotRounds, MinMaxSnapshotRounds) + } if blobLifetimeRounds > MaxBlobLifetimeRounds { return 0, 0, fmt.Errorf("BlobLifetimeRounds (%d) exceeds MaxBlobLifetimeRounds (%d)", blobLifetimeRounds, MaxBlobLifetimeRounds) } diff --git a/llo/dev/v31/blobpump_test.go b/llo/dev/v31/blobpump_test.go index b84a697..7164eab 100644 --- a/llo/dev/v31/blobpump_test.go +++ b/llo/dev/v31/blobpump_test.go @@ -696,6 +696,11 @@ func Test_ResolveBlobRounds(t *testing.T) { assert.Equal(t, uint64(DefaultBlobLifetimeRounds), life) }) + t.Run("rejects a snapshot window below the minimum", func(t *testing.T) { + _, _, err := ResolveBlobRounds(1, 0) + require.ErrorContains(t, err, "below MinMaxSnapshotRounds") + }) + t.Run("rejects a lifetime past the maximum", func(t *testing.T) { _, _, err := ResolveBlobRounds(0, MaxBlobLifetimeRounds+1) require.ErrorContains(t, err, "exceeds MaxBlobLifetimeRounds") @@ -707,7 +712,7 @@ func Test_ResolveBlobRounds(t *testing.T) { }) t.Run("rejects a lifetime shorter than the margin", func(t *testing.T) { - _, _, err := ResolveBlobRounds(1, 1) + _, _, err := ResolveBlobRounds(MinMaxSnapshotRounds, 1) require.ErrorContains(t, err, "rounds of fetch margin") }) diff --git a/llo/dev/v31/factory.go b/llo/dev/v31/factory.go index 7a45e0d..0bf27e3 100644 --- a/llo/dev/v31/factory.go +++ b/llo/dev/v31/factory.go @@ -47,9 +47,10 @@ type PluginFactoryParams struct { DonID uint32 // MaxSnapshotRounds overrides DefaultMaxSnapshotRounds if non-zero. Bounds // how stale this node's own stream values may be when it references them, - // and nothing else; it does not affect what peers can fetch. Must be >0, - // since a snapshot gathered for one sequence number is consumed by a later - // one, and must leave BlobFetchMarginRounds below BlobLifetimeRounds. + // and nothing else; it does not affect what peers can fetch. Must be at + // least MinMaxSnapshotRounds, since a snapshot gathered for one sequence + // number is consumed by a later one, and must leave BlobFetchMarginRounds + // below BlobLifetimeRounds. MaxSnapshotRounds uint64 // BlobLifetimeRounds overrides DefaultBlobLifetimeRounds if non-zero. Bounds // how long peers can still fetch a broadcast blob, and nothing else; it does diff --git a/llo/pluginconfig/v31config.go b/llo/pluginconfig/v31config.go index 3e1e73b..75fafd7 100644 --- a/llo/pluginconfig/v31config.go +++ b/llo/pluginconfig/v31config.go @@ -58,8 +58,8 @@ type V31Config struct { VerboseLogging bool `json:"verboseLogging" toml:"verboseLogging"` // MaxSnapshotRounds bounds how stale this node's own stream values may be - // when it references them. Must leave BlobFetchMarginRounds below - // BlobLifetimeRounds. + // when it references them. Must be at least 2 and leave + // BlobFetchMarginRounds below BlobLifetimeRounds. MaxSnapshotRounds uint64 `json:"maxSnapshotRounds" toml:"maxSnapshotRounds"` // BlobLifetimeRounds bounds how long peers can still fetch a broadcast From 5969b1c54aae3b26d623ea91c3ec53a6bbf869cc Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Wed, 14 Oct 2026 15:17:56 +0100 Subject: [PATCH 072/103] llo/v31: stop backfill selection when the target channel is tombstoned --- llo/dev/v31/backfill.go | 7 +++++-- llo/dev/v31/plugin_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/llo/dev/v31/backfill.go b/llo/dev/v31/backfill.go index 3fc6301..9eecc77 100644 --- a/llo/dev/v31/backfill.go +++ b/llo/dev/v31/backfill.go @@ -11,7 +11,8 @@ import ( // selectBackfillCandidate returns the next history-backfill observation to emit // for backfillCID: its timestamp (nanoseconds), the raw opts key, and the parsed // opts. ok is false when the channel is not a valid, in-progress backfill (no -// definition, wrong format, missing target, no watermark, or backfill complete). +// definition, wrong format, missing or tombstoned target, no watermark, or +// backfill complete). // // The watermark is validAfter[backfillCID] (the last emitted backfill observation // time); the next candidate is the smallest emittable observation strictly after @@ -30,8 +31,10 @@ func selectBackfillCandidate(defs llotypes.ChannelDefinitions, validAfter map[ll if err != nil { return 0, 0, protocol.HistoryBackfillOpts{}, 0, false } + // A tombstoned target no longer reports, so its history is not backfilled + // either. The watermark holds, and backfill resumes if the target returns. target, exists := defs[o.TargetChannelID] - if !exists { + if !exists || target.Tombstone { return 0, 0, protocol.HistoryBackfillOpts{}, 0, false } res, err := protocol.TargetChannelTimeResolution(target) diff --git a/llo/dev/v31/plugin_test.go b/llo/dev/v31/plugin_test.go index 97b0905..0f706b2 100644 --- a/llo/dev/v31/plugin_test.go +++ b/llo/dev/v31/plugin_test.go @@ -1326,6 +1326,31 @@ func Test_SelectBackfillCandidate_UnemittableRow(t *testing.T) { require.Equal(t, 2, skipped) } +func Test_SelectBackfillCandidate_TombstonedTarget(t *testing.T) { + const ( + targetCID = llotypes.ChannelID(10) + backfillCID = llotypes.ChannelID(20) + tenSec = uint64(10_000_000_000) + ) + targetCD := llotypes.ChannelDefinition{ReportFormat: llotypes.ReportFormatJSON, Streams: []llotypes.Stream{{StreamID: 100, Aggregator: llotypes.AggregatorMedian}}} + defs := llotypes.ChannelDefinitions{ + targetCID: targetCD, + backfillCID: { + ReportFormat: llotypes.ReportFormatHistoryBackfill, + Opts: []byte(`{"targetChannelId":10,"observations":{"5":{"100":"1.5"}}}`), + }, + } + validAfter := map[llotypes.ChannelID]uint64{backfillCID: 0} + + _, _, _, _, ok := selectBackfillCandidate(defs, validAfter, tenSec, backfillCID, nil) + require.True(t, ok) + + targetCD.Tombstone = true + defs[targetCID] = targetCD + _, _, _, _, ok = selectBackfillCandidate(defs, validAfter, tenSec, backfillCID, nil) + require.False(t, ok, "a tombstoned target must not be backfilled") +} + // withSupport returns a copy of the precursor with n oracles advertising a // report codec for every format its channels need, so tests that are not about // the support gate are unaffected by it. Backfill channels are resolved to From 930615ec4215bee01a3f965fe85ee6a5b6b1cfef Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Wed, 14 Oct 2026 15:20:32 +0100 Subject: [PATCH 073/103] llo/v31: bound zstd blob decompression by the per call budget while decoding --- llo/dev/v31/blobcompress.go | 23 +++++++++++++++------ llo/dev/v31/blobcompress_test.go | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/llo/dev/v31/blobcompress.go b/llo/dev/v31/blobcompress.go index 10f3724..d626cd5 100644 --- a/llo/dev/v31/blobcompress.go +++ b/llo/dev/v31/blobcompress.go @@ -57,9 +57,12 @@ func getBlobCompressor() (blobCompressor, error) { return } // WithDecoderMaxMemory bounds the decompressed size to a safe limit. + // WithDecodeAllCapLimit bounds each DecodeAll call to the capacity of + // its destination, which decodeBlobPayload sizes to the call's budget. decoder, err := zstd.NewReader(nil, zstd.WithDecoderConcurrency(0), zstd.WithDecoderMaxMemory(maxDecompressedBlobPayloadBytes), + zstd.WithDecodeAllCapLimit(true), ) if err != nil { blobZstdErr = fmt.Errorf("new zstd decoder: %w", err) @@ -101,9 +104,10 @@ func encodeBlobPayload(raw []byte) ([]byte, error) { // decodeBlobPayload reverses encodeBlobPayload. The payload is untrusted, so // the decompressed size is bounded by maxOut: cheaply up front via the zstd -// frame header when it declares a content size, and again after the fact in -// case it does not. maxOut is a caller-supplied budget (see decodeObservation) -// and is clamped to maxDecompressedBlobPayloadBytes. +// frame header when it declares a content size, and during decompression via +// the capacity of the destination buffer in case it does not. maxOut is a +// caller-supplied budget (see decodeObservation) and is clamped to +// maxDecompressedBlobPayloadBytes. func decodeBlobPayload(payload []byte, maxOut int) ([]byte, error) { if len(payload) == 0 { return nil, fmt.Errorf("empty blob payload") @@ -136,12 +140,19 @@ func decodeBlobPayload(payload []byte, maxOut int) ([]byte, error) { if hdr.HasFCS && hdr.FrameContentSize > uint64(maxOut) { return nil, fmt.Errorf("declared decompressed blob payload too large: %d > %d bytes", hdr.FrameContentSize, maxOut) } - out, err := c.decoder.DecodeAll(body, nil) + // The decoder stops once the output outgrows the destination capacity, + // so the per-call budget holds while decompressing, not only after. + // encodeBlobPayload always declares the content size, so an honest + // payload allocates exactly its size here. + capacity := maxOut + if hdr.HasFCS { + capacity = int(hdr.FrameContentSize) //nolint:gosec // bounded by maxOut above + } + out, err := c.decoder.DecodeAll(body, make([]byte, 0, capacity)) if err != nil { return nil, fmt.Errorf("decompress blob payload: %w", err) } - // The shared decoder only enforces the process-wide ceiling, so this - // check is what enforces the (possibly tighter) per-call budget. + // The cap limit is checked per block, so a block can overshoot it. if len(out) > maxOut { return nil, fmt.Errorf("decompressed blob payload too large: %d > %d bytes", len(out), maxOut) } diff --git a/llo/dev/v31/blobcompress_test.go b/llo/dev/v31/blobcompress_test.go index 9e0e8ef..9f9d48f 100644 --- a/llo/dev/v31/blobcompress_test.go +++ b/llo/dev/v31/blobcompress_test.go @@ -1,6 +1,7 @@ package llo import ( + "bytes" "strings" "testing" @@ -108,6 +109,40 @@ func Test_decodeBlobPayload_Errors(t *testing.T) { }) } +func Test_decodeBlobPayload_UndeclaredSize(t *testing.T) { + // A streaming encoder does not know the content size up front, so the frame + // omits it and the header check cannot reject the payload. + var buf bytes.Buffer + buf.WriteByte(blobCodecZstd) + enc, err := zstd.NewWriter(&buf) + require.NoError(t, err) + _, err = enc.Write(make([]byte, 1<<20)) + require.NoError(t, err) + require.NoError(t, enc.Close()) + + var hdr zstd.Header + require.NoError(t, hdr.Decode(buf.Bytes()[1:])) + require.False(t, hdr.HasFCS) + + // Stopped by the decoder, not by the length check after it. + _, err = decodeBlobPayload(buf.Bytes(), 4096) + require.ErrorIs(t, err, zstd.ErrDecoderSizeExceeded) + + got, err := decodeBlobPayload(buf.Bytes(), 1<<20) + require.NoError(t, err) + assert.Len(t, got, 1<<20) +} + +func Test_encodeBlobPayload_DeclaresContentSize(t *testing.T) { + framed, err := encodeBlobPayload(make([]byte, 4096)) + require.NoError(t, err) + require.Equal(t, blobCodecZstd, framed[0]) + var hdr zstd.Header + require.NoError(t, hdr.Decode(framed[1:])) + require.True(t, hdr.HasFCS) + assert.Equal(t, uint64(4096), hdr.FrameContentSize) +} + func Test_encodeBlobPayload_RejectsOversized(t *testing.T) { _, err := encodeBlobPayload(make([]byte, maxDecompressedBlobPayloadBytes+1)) require.ErrorContains(t, err, "blob payload too large") From c9cf36cb30494eef0979a74f91b077ac40c7373d Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Wed, 14 Oct 2026 15:23:04 +0100 Subject: [PATCH 074/103] llo/v31: broadcast only the requested streams from the blob pump --- llo/dev/v31/blobpump.go | 11 +++++++++++ llo/dev/v31/blobpump_test.go | 15 +++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/llo/dev/v31/blobpump.go b/llo/dev/v31/blobpump.go index 82df678..be175c6 100644 --- a/llo/dev/v31/blobpump.go +++ b/llo/dev/v31/blobpump.go @@ -489,6 +489,17 @@ func (p *blobPump) observe(in pumpInput) (*blobSnapshot, error) { if err := p.ds.Observe(ctx, sv, opts); err != nil { return nil, fmt.Errorf("DataSource.Observe error: %w", err) } + // Broadcast only the requested streams. Peers reject an observation over + // MaxObservationStreamValuesLength, so unrequested keys from the + // DataSource could void this node's whole contribution. + if len(sv) > len(in.streams) { + requested := make(protocol.StreamValues, len(in.streams)) + for _, sid := range in.streams { + requested[sid] = sv[sid] + } + p.lggr.Warnw("DataSource returned unrequested streams; dropping them", "seqNr", in.seqNr, "requested", len(in.streams), "returned", len(sv)) + sv = requested + } payload, err := marshalStreamValues(sv) if err != nil { diff --git a/llo/dev/v31/blobpump_test.go b/llo/dev/v31/blobpump_test.go index 7164eab..6fe0f66 100644 --- a/llo/dev/v31/blobpump_test.go +++ b/llo/dev/v31/blobpump_test.go @@ -92,6 +92,21 @@ func Test_blobPump_TakeKicksNextCycle(t *testing.T) { require.Eventually(t, func() bool { return p.Cycles() >= 2 }, tests.WaitTimeout(t), 10*time.Millisecond) } +func Test_blobPump_DropsUnrequestedStreams(t *testing.T) { + ds := mockDS() + ds.vals[999] = protocol.ToDecimal(decimal.NewFromInt(9)) + p := testPump(t, ds, newFakeBroadcaster(), time.Minute) + + p.SetInput(pumpInputFor(2)) + p.Take(t.Context(), 2) + require.Eventually(t, func() bool { return p.Cycles() >= 1 }, tests.WaitTimeout(t), 10*time.Millisecond) + + p.SetInput(pumpInputFor(3)) + snap, reason := p.Take(t.Context(), 3) + require.NotNil(t, snap, "reason: %s", reason) + require.Equal(t, 1, snap.streamCount) +} + // Test_blobPump_TakeIsSingleUse guards against two rounds referencing the same // blob handle. func Test_blobPump_TakeIsSingleUse(t *testing.T) { From 5f4368f0414962e22654f083bac95c122beec2cb Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Wed, 14 Oct 2026 15:25:41 +0100 Subject: [PATCH 075/103] llo/v31: describe report telemetry values with the streams they were built from --- llo/dev/v31/reports.go | 4 ++-- llo/dev/v31/telemetry.go | 15 ++++++++------- llo/dev/v31/telemetry_test.go | 28 ++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/llo/dev/v31/reports.go b/llo/dev/v31/reports.go index 3966f95..1c19f86 100644 --- a/llo/dev/v31/reports.go +++ b/llo/dev/v31/reports.go @@ -111,7 +111,7 @@ func (p *Plugin) Reports(ctx context.Context, seqNr uint64, rawPrecursor ocr3_1t reportForEncode := report reportForEncode.ChannelID = opts.TargetChannelID - p.captureReportTelemetry(reportForEncode, targetCD, out.LifeCycleStage) + p.captureReportTelemetry(reportForEncode, targetCD, targetCD.Streams, out.LifeCycleStage) codec, exists := p.ReportCodecs[targetCD.ReportFormat] if !exists { p.reportNotEmitted(seqNr, cid, targetCD.ReportFormat, "codec_missing", "targetChannelID", opts.TargetChannelID) @@ -158,7 +158,7 @@ func (p *Plugin) Reports(ctx context.Context, seqNr uint64, rawPrecursor ocr3_1t Specimen: out.LifeCycleStage != protocol.LifeCycleStageProduction, } - p.captureReportTelemetry(report, cd, out.LifeCycleStage) + p.captureReportTelemetry(report, cd, streams, out.LifeCycleStage) codec, exists := p.ReportCodecs[cd.ReportFormat] if !exists { diff --git a/llo/dev/v31/telemetry.go b/llo/dev/v31/telemetry.go index aa6cf51..a6263c0 100644 --- a/llo/dev/v31/telemetry.go +++ b/llo/dev/v31/telemetry.go @@ -77,11 +77,12 @@ func makeOutcomeTelemetry(out precursor, configDigest ocrtypes.ConfigDigest, seq // captureReportTelemetry emits the telemetry of reports produced in a stage // that emits telemetry, when this oracle is one telemetry emitter for the seqNr. -func (p *Plugin) captureReportTelemetry(r protocol.Report, cd llotypes.ChannelDefinition, stage llotypes.LifeCycleStage) { +// streams are the streams r.Values were built from, in the same order. +func (p *Plugin) captureReportTelemetry(r protocol.Report, cd llotypes.ChannelDefinition, streams []llotypes.Stream, stage llotypes.LifeCycleStage) { if p.ReportTelemetryCh == nil || !p.emitsTelemetry(stage) || !p.isTelemetryEmitter(r.SeqNr) { return } - rt, err := makeReportTelemetry(r, cd, p.DonID) + rt, err := makeReportTelemetry(r, cd, streams, p.DonID) if err != nil { p.Logger.Warnw("Error making report telemetry", "err", err) return @@ -93,10 +94,10 @@ func (p *Plugin) captureReportTelemetry(r protocol.Report, cd llotypes.ChannelDe } } -func makeReportTelemetry(r protocol.Report, cd llotypes.ChannelDefinition, donID uint32) (*protocol.LLOReportTelemetry, error) { - streams := make([]*protocol.LLOStreamDefinition, len(cd.Streams)) - for i, s := range cd.Streams { - streams[i] = &protocol.LLOStreamDefinition{ +func makeReportTelemetry(r protocol.Report, cd llotypes.ChannelDefinition, streams []llotypes.Stream, donID uint32) (*protocol.LLOReportTelemetry, error) { + defs := make([]*protocol.LLOStreamDefinition, len(streams)) + for i, s := range streams { + defs[i] = &protocol.LLOStreamDefinition{ StreamID: s.StreamID, Aggregator: uint32(s.Aggregator), } @@ -124,7 +125,7 @@ func makeReportTelemetry(r protocol.Report, cd llotypes.ChannelDefinition, donID ObservationTimestampNanoseconds: r.ObservationTimestampNanoseconds, ReportFormat: uint32(cd.ReportFormat), Specimen: r.Specimen, - StreamDefinitions: streams, + StreamDefinitions: defs, StreamValues: svs, ChannelOpts: cd.Opts, SeqNr: r.SeqNr, diff --git a/llo/dev/v31/telemetry_test.go b/llo/dev/v31/telemetry_test.go index 3ed37ce..c29abac 100644 --- a/llo/dev/v31/telemetry_test.go +++ b/llo/dev/v31/telemetry_test.go @@ -64,6 +64,34 @@ func Test_IsTelemetryEmitter(t *testing.T) { } } +func Test_makeReportTelemetry_EffectiveStreams(t *testing.T) { + cid := llotypes.ChannelID(5) + cd := llotypes.ChannelDefinition{ + ReportFormat: llotypes.ReportFormatEVMABIEncodeUnpackedExpr, + Opts: []byte(`{"abi":[{"type":"int256","expression":"Add(s1, s2)","expressionStreamID":999}]}`), + Streams: []llotypes.Stream{ + {StreamID: 1, Aggregator: llotypes.AggregatorMedian}, + {StreamID: 2, Aggregator: llotypes.AggregatorMedian}, + }, + } + streams, err := protocol.EffectiveStreams(protocol.NewOptsCache(), cd, cid) + require.NoError(t, err) + require.Len(t, streams, 3) + + values := []protocol.StreamValue{ + protocol.ToDecimal(decimal.NewFromInt(3)), + protocol.ToDecimal(decimal.NewFromInt(4)), + protocol.ToDecimal(decimal.NewFromInt(7)), + } + rt, err := makeReportTelemetry(protocol.Report{ChannelID: cid, Values: values}, cd, streams, 0) + require.NoError(t, err) + + // Definitions line up with values, including the calculated stream. + require.Len(t, rt.StreamDefinitions, len(rt.StreamValues)) + assert.Equal(t, uint32(999), rt.StreamDefinitions[2].StreamID) + assert.Equal(t, uint32(llotypes.AggregatorCalculated), rt.StreamDefinitions[2].Aggregator) +} + func Test_AttributedObservationTelemetry_StateTransition(t *testing.T) { ctx := tests.Context(t) p := testPlugin(t) From 2d6f98e1224fc720faedac7d49e2c02fb3acffd8 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Wed, 14 Oct 2026 15:28:06 +0100 Subject: [PATCH 076/103] llo/retirement: remove unused NullRetirementReportCache --- .../null_retirement_report_cache.go | 25 ------------------- 1 file changed, 25 deletions(-) delete mode 100644 llo/retirement/null_retirement_report_cache.go diff --git a/llo/retirement/null_retirement_report_cache.go b/llo/retirement/null_retirement_report_cache.go deleted file mode 100644 index d562d89..0000000 --- a/llo/retirement/null_retirement_report_cache.go +++ /dev/null @@ -1,25 +0,0 @@ -package retirement - -import ( - "context" - - "github.com/smartcontractkit/libocr/offchainreporting2plus/types" - ocr2types "github.com/smartcontractkit/libocr/offchainreporting2plus/types" - - protocol "github.com/smartcontractkit/chainlink-data-streams/llo/protocol" -) - -type NullRetirementReportCache struct{} - -func (n *NullRetirementReportCache) StoreAttestedRetirementReport(ctx context.Context, cd ocr2types.ConfigDigest, retirementReport []byte, sigs []types.AttributedOnchainSignature) error { - return nil -} -func (n *NullRetirementReportCache) StoreConfig(ctx context.Context, cd ocr2types.ConfigDigest, signers [][]byte, f uint8) error { - return nil -} -func (n *NullRetirementReportCache) AttestedRetirementReport(predecessorConfigDigest ocr2types.ConfigDigest) ([]byte, error) { - return nil, nil -} -func (n *NullRetirementReportCache) CheckAttestedRetirementReport(predecessorConfigDigest ocr2types.ConfigDigest, attestedRetirementReport []byte) (protocol.RetirementReport, error) { - return protocol.RetirementReport{}, nil -} From deecffff97d56b0632e2c6e7b29ef13644ea8f28 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Wed, 14 Oct 2026 15:30:43 +0100 Subject: [PATCH 077/103] llo/v31: log denied history pairs once per round and demote duplicate requirement logs --- llo/dev/v31/history_flow_test.go | 3 +-- llo/dev/v31/history_requirements.go | 18 ++++++++---------- llo/dev/v31/history_requirements_test.go | 10 +++++----- llo/dev/v31/statetransition.go | 11 ++++++++--- 4 files changed, 22 insertions(+), 20 deletions(-) diff --git a/llo/dev/v31/history_flow_test.go b/llo/dev/v31/history_flow_test.go index 42719ab..535c7f5 100644 --- a/llo/dev/v31/history_flow_test.go +++ b/llo/dev/v31/history_flow_test.go @@ -8,7 +8,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/smartcontractkit/chainlink-common/pkg/logger" llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" @@ -249,7 +248,7 @@ func Test_History_UnavailableOnDeniedPair(t *testing.T) { for i := range protocol.MaxHistoryPairs + 1 { required[histKey{streamID: llotypes.StreamID(i + 1), aggregator: llotypes.AggregatorMedian}] = 1 } - got := admitHistoryRequirements(required, logger.Test(t)) + got := admitHistoryRequirements(required) require.NotEmpty(t, got.denied) denied := got.sortedDenied()[0] diff --git a/llo/dev/v31/history_requirements.go b/llo/dev/v31/history_requirements.go index f433b24..18a713d 100644 --- a/llo/dev/v31/history_requirements.go +++ b/llo/dev/v31/history_requirements.go @@ -34,7 +34,8 @@ type historyRequirements struct { // Channels whose expressions cannot be analyzed, or that aggregate one stream // two ways, contribute nothing: they cannot be evaluated either (see // calculated.ProcessCalculatedStreams), so reserving history for them would keep -// dead windows alive. +// dead windows alive. They are logged at debug level only, since evaluation +// reports the same channel at error level every round. func computeHistoryRequirements(defs llotypes.ChannelDefinitions, optsCache *protocol.OptsCache, lggr logger.Logger) historyRequirements { required := map[histKey]uint32{} @@ -54,14 +55,14 @@ func computeHistoryRequirements(defs llotypes.ChannelDefinitions, optsCache *pro aggByStream, err := calculated.AggregatorByStream(cd) if err != nil { - lggr.Errorw("Skipping history requirements for channel with ambiguous stream aggregators", + lggr.Debugw("Skipping history requirements for channel with ambiguous stream aggregators", "channelID", cid, "err", err) continue } expressions, err := calculated.Expressions(optsCache, cd, cid) if err != nil { - lggr.Errorw("Skipping history requirements for channel with unresolvable opts", + lggr.Debugw("Skipping history requirements for channel with unresolvable opts", "channelID", cid, "err", err) continue } @@ -69,14 +70,14 @@ func computeHistoryRequirements(defs llotypes.ChannelDefinitions, optsCache *pro for _, expression := range expressions { refs, err := calculated.AnalyzeExpressionHistory(expression) if err != nil { - lggr.Errorw("Skipping history requirements for invalid expression", + lggr.Debugw("Skipping history requirements for invalid expression", "channelID", cid, "expression", expression, "err", err) continue } for _, ref := range refs { aggregator, ok := aggByStream[ref.StreamID] if !ok { - lggr.Errorw("Expression reads history for a stream the channel does not observe", + lggr.Debugw("Expression reads history for a stream the channel does not observe", "channelID", cid, "streamID", ref.StreamID, "expression", expression) continue } @@ -90,7 +91,7 @@ func computeHistoryRequirements(defs llotypes.ChannelDefinitions, optsCache *pro } } - return admitHistoryRequirements(required, lggr) + return admitHistoryRequirements(required) } // admitHistoryRequirements applies the pair and byte caps. @@ -110,7 +111,7 @@ func computeHistoryRequirements(defs llotypes.ChannelDefinitions, optsCache *pro // MaxHistoryTotalBytes by construction, which limits.go asserts at compile // time. That was not true of the single-blob layout, where cost scaled with // depth and the budget denied pairs long before the cap did. -func admitHistoryRequirements(required map[histKey]uint32, lggr logger.Logger) historyRequirements { +func admitHistoryRequirements(required map[histKey]uint32) historyRequirements { keys := make([]histKey, 0, len(required)) for key := range required { keys = append(keys, key) @@ -120,9 +121,6 @@ func admitHistoryRequirements(required map[histKey]uint32, lggr logger.Logger) h out := historyRequirements{depths: make(map[histKey]uint32, len(keys))} for _, key := range keys { if len(out.depths) >= protocol.MaxHistoryPairs { - lggr.Errorw("Denying stream history: too many pairs require history", - "streamID", key.streamID, "aggregator", key.aggregator, - "depth", required[key], "maxPairs", protocol.MaxHistoryPairs) out.denied = append(out.denied, key) continue } diff --git a/llo/dev/v31/history_requirements_test.go b/llo/dev/v31/history_requirements_test.go index 65992ef..579ccc6 100644 --- a/llo/dev/v31/history_requirements_test.go +++ b/llo/dev/v31/history_requirements_test.go @@ -185,7 +185,7 @@ func TestAdmitHistoryRequirements(t *testing.T) { got := admitHistoryRequirements(map[histKey]uint32{ {streamID: 1, aggregator: llotypes.AggregatorMedian}: 10, {streamID: 2, aggregator: llotypes.AggregatorMedian}: 20, - }, logger.Test(t)) + }) assert.Len(t, got.depths, 2) assert.Empty(t, got.denied) }) @@ -196,7 +196,7 @@ func TestAdmitHistoryRequirements(t *testing.T) { for i := range protocol.MaxHistoryPairs + 5 { required[histKey{streamID: llotypes.StreamID(i + 1), aggregator: llotypes.AggregatorMedian}] = 1 } - got := admitHistoryRequirements(required, logger.Test(t)) + got := admitHistoryRequirements(required) assert.Len(t, got.depths, protocol.MaxHistoryPairs) require.Len(t, got.denied, 5) // Admission is by (streamID, aggregator) order, so the denied set is the @@ -215,7 +215,7 @@ func TestAdmitHistoryRequirements(t *testing.T) { for i := range protocol.MaxHistoryPairs { required[histKey{streamID: llotypes.StreamID(i + 1), aggregator: llotypes.AggregatorMedian}] = protocol.MaxHistoryRecordsPerPair } - got := admitHistoryRequirements(required, logger.Test(t)) + got := admitHistoryRequirements(required) assert.Len(t, got.depths, protocol.MaxHistoryPairs) assert.Empty(t, got.denied) }) @@ -226,9 +226,9 @@ func TestAdmitHistoryRequirements(t *testing.T) { for i := range protocol.MaxHistoryPairs + 10 { required[histKey{streamID: llotypes.StreamID(i + 1), aggregator: llotypes.AggregatorMedian}] = 8 } - first := admitHistoryRequirements(required, logger.Test(t)) + first := admitHistoryRequirements(required) for range 20 { - again := admitHistoryRequirements(required, logger.Test(t)) + again := admitHistoryRequirements(required) require.Equal(t, first.depths, again.depths) require.Equal(t, first.sortedDenied(), again.sortedDenied()) } diff --git a/llo/dev/v31/statetransition.go b/llo/dev/v31/statetransition.go index 3c1b08a..ed5e077 100644 --- a/llo/dev/v31/statetransition.go +++ b/llo/dev/v31/statetransition.go @@ -206,10 +206,15 @@ func (p *Plugin) StateTransition(ctx context.Context, seqNr uint64, _ ocrtypes.A if err := requirements.apply(history); err != nil { return nil, err } - for _, key := range requirements.sortedDenied() { - // Channels reading this pair cannot evaluate and so will not report. + if denied := requirements.sortedDenied(); len(denied) > 0 { + // Channels reading these pairs cannot evaluate and so will not report. + // One line per round, however many pairs are denied. + pairs := make([]string, len(denied)) + for i, key := range denied { + pairs[i] = fmt.Sprintf("%d/%d", key.streamID, key.aggregator) + } p.Logger.Errorw("Stream history denied; channels reading it will not report", - "streamID", key.streamID, "aggregator", key.aggregator, "seqNr", seqNr) + "denied", len(denied), "pairs", pairs, "maxPairs", protocol.MaxHistoryPairs, "seqNr", seqNr) } // Aggregation (regular fresh; timestamped with cross-round carry-forward via From 792e69424b7650168705464e8fd24f98e210a9f4 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Wed, 14 Oct 2026 15:53:17 +0100 Subject: [PATCH 078/103] llo/v31: admit pairs already holding history before new pairs at the pair cap --- llo/dev/v31/doc.go | 5 +-- llo/dev/v31/history_bench_test.go | 4 +-- llo/dev/v31/history_flow_test.go | 2 +- llo/dev/v31/history_requirements.go | 36 +++++++++++++++------ llo/dev/v31/history_requirements_test.go | 40 ++++++++++++++++++++---- llo/dev/v31/statetransition.go | 2 +- llo/protocol/limits.go | 11 ++++--- 7 files changed, 74 insertions(+), 26 deletions(-) diff --git a/llo/dev/v31/doc.go b/llo/dev/v31/doc.go index b7469a1..0caba09 100644 --- a/llo/dev/v31/doc.go +++ b/llo/dev/v31/doc.go @@ -201,8 +201,9 @@ // any live channel's expressions ask for) from the channel definitions and // their opts. Both are replicated and expression analysis is a pure function // of the expression string, so every oracle computes the same depths — they -// become persisted state. Pairs beyond MaxHistoryPairs are denied history -// entirely, in (streamID, aggregator) order; channels reading them do not +// become persisted state. Pairs already holding a window are admitted +// first, then new pairs, each in (streamID, aggregator) order. Pairs beyond +// MaxHistoryPairs are denied history entirely; channels reading them do not // report, rather than silently evaluating over a shorter window. The pair cap // is the only admission rule: per-round cost no longer depends on depth, so // MaxHistoryPairs of them fit the byte budget by construction. diff --git a/llo/dev/v31/history_bench_test.go b/llo/dev/v31/history_bench_test.go index e6d0a6b..70f04f5 100644 --- a/llo/dev/v31/history_bench_test.go +++ b/llo/dev/v31/history_bench_test.go @@ -200,12 +200,12 @@ func BenchmarkComputeHistoryRequirements(b *testing.B) { // depth * MaxHistoryRecordBytes per round and the byte budget ran out // first. Chunking made that cost independent of depth, so the pair // cap is now the only thing that can deny a window. - admitted := len(computeHistoryRequirements(defs, cache, lggr).depths) + admitted := len(computeHistoryRequirements(defs, cache, nil, lggr).depths) b.ReportMetric(float64(admitted), "admittedPairs") b.ResetTimer() for range b.N { - computeHistoryRequirements(defs, cache, lggr) + computeHistoryRequirements(defs, cache, nil, lggr) } }) } diff --git a/llo/dev/v31/history_flow_test.go b/llo/dev/v31/history_flow_test.go index 535c7f5..04eb139 100644 --- a/llo/dev/v31/history_flow_test.go +++ b/llo/dev/v31/history_flow_test.go @@ -248,7 +248,7 @@ func Test_History_UnavailableOnDeniedPair(t *testing.T) { for i := range protocol.MaxHistoryPairs + 1 { required[histKey{streamID: llotypes.StreamID(i + 1), aggregator: llotypes.AggregatorMedian}] = 1 } - got := admitHistoryRequirements(required) + got := admitHistoryRequirements(required, nil) require.NotEmpty(t, got.denied) denied := got.sortedDenied()[0] diff --git a/llo/dev/v31/history_requirements.go b/llo/dev/v31/history_requirements.go index 18a713d..b1e233a 100644 --- a/llo/dev/v31/history_requirements.go +++ b/llo/dev/v31/history_requirements.go @@ -36,7 +36,9 @@ type historyRequirements struct { // calculated.ProcessCalculatedStreams), so reserving history for them would keep // dead windows alive. They are logged at debug level only, since evaluation // reports the same channel at error level every round. -func computeHistoryRequirements(defs llotypes.ChannelDefinitions, optsCache *protocol.OptsCache, lggr logger.Logger) historyRequirements { +// +// incumbents are the pairs already holding a window (see admitHistoryRequirements). +func computeHistoryRequirements(defs llotypes.ChannelDefinitions, optsCache *protocol.OptsCache, incumbents []histKey, lggr logger.Logger) historyRequirements { required := map[histKey]uint32{} // Channels are visited in sorted order so that logging and the @@ -91,15 +93,19 @@ func computeHistoryRequirements(defs llotypes.ChannelDefinitions, optsCache *pro } } - return admitHistoryRequirements(required) + return admitHistoryRequirements(required, incumbents) } // admitHistoryRequirements applies the pair and byte caps. // -// Pairs are considered in (streamID, aggregator) order and admitted until a cap -// is reached; the rest are denied. Ordering by key rather than by, say, depth or -// channel count is what makes the outcome identical on every oracle — the -// admission decision is part of what determines persisted state. +// Required incumbents, the pairs already holding a window, are admitted first, +// then the remaining pairs, each group in (streamID, aggregator) order, until a +// cap is reached; the rest are denied. Admitting incumbents first stops a new +// pair with a lower key from evicting a warm window once the cap is reached. +// Incumbents come from the replicated history index and the order is by key +// rather than by, say, depth or channel count, which is what makes the outcome +// identical on every oracle, the admission decision is part of what +// determines persisted state. // // Denial is deliberately visible and total for the pair: a channel referencing a // denied pair produces no value and does not report, rather than silently @@ -111,12 +117,24 @@ func computeHistoryRequirements(defs llotypes.ChannelDefinitions, optsCache *pro // MaxHistoryTotalBytes by construction, which limits.go asserts at compile // time. That was not true of the single-blob layout, where cost scaled with // depth and the budget denied pairs long before the cap did. -func admitHistoryRequirements(required map[histKey]uint32) historyRequirements { +func admitHistoryRequirements(required map[histKey]uint32, incumbents []histKey) historyRequirements { + held := make(map[histKey]struct{}, len(incumbents)) keys := make([]histKey, 0, len(required)) - for key := range required { - keys = append(keys, key) + for _, key := range incumbents { + if _, ok := required[key]; ok { + held[key] = struct{}{} + keys = append(keys, key) + } } sortHistKeys(keys) + newcomers := make([]histKey, 0, len(required)-len(keys)) + for key := range required { + if _, ok := held[key]; !ok { + newcomers = append(newcomers, key) + } + } + sortHistKeys(newcomers) + keys = append(keys, newcomers...) out := historyRequirements{depths: make(map[histKey]uint32, len(keys))} for _, key := range keys { diff --git a/llo/dev/v31/history_requirements_test.go b/llo/dev/v31/history_requirements_test.go index 579ccc6..8591ec5 100644 --- a/llo/dev/v31/history_requirements_test.go +++ b/llo/dev/v31/history_requirements_test.go @@ -36,7 +36,7 @@ func requirementsFor(t *testing.T, defs llotypes.ChannelDefinitions) historyRequ t.Helper() cache := protocol.NewOptsCache() cache.ResetTo(defs) - return computeHistoryRequirements(defs, cache, logger.Test(t)) + return computeHistoryRequirements(defs, cache, nil, logger.Test(t)) } func TestComputeHistoryRequirements(t *testing.T) { @@ -185,7 +185,7 @@ func TestAdmitHistoryRequirements(t *testing.T) { got := admitHistoryRequirements(map[histKey]uint32{ {streamID: 1, aggregator: llotypes.AggregatorMedian}: 10, {streamID: 2, aggregator: llotypes.AggregatorMedian}: 20, - }) + }, nil) assert.Len(t, got.depths, 2) assert.Empty(t, got.denied) }) @@ -196,7 +196,7 @@ func TestAdmitHistoryRequirements(t *testing.T) { for i := range protocol.MaxHistoryPairs + 5 { required[histKey{streamID: llotypes.StreamID(i + 1), aggregator: llotypes.AggregatorMedian}] = 1 } - got := admitHistoryRequirements(required) + got := admitHistoryRequirements(required, nil) assert.Len(t, got.depths, protocol.MaxHistoryPairs) require.Len(t, got.denied, 5) // Admission is by (streamID, aggregator) order, so the denied set is the @@ -206,6 +206,34 @@ func TestAdmitHistoryRequirements(t *testing.T) { } }) + t.Run("incumbents keep their windows past the pair cap", func(t *testing.T) { + t.Parallel() + // The cap is full of pairs keyed from 1000. Pairs with lower keys + // arrive but do not evict them. + required := map[histKey]uint32{} + incumbents := make([]histKey, 0, protocol.MaxHistoryPairs) + for i := range protocol.MaxHistoryPairs { + key := histKey{streamID: llotypes.StreamID(1000 + i), aggregator: llotypes.AggregatorMedian} + required[key] = 1 + incumbents = append(incumbents, key) + } + for i := range 5 { + required[histKey{streamID: llotypes.StreamID(i + 1), aggregator: llotypes.AggregatorMedian}] = 1 + } + // An incumbent no longer required is not admitted. + gone := histKey{streamID: 999, aggregator: llotypes.AggregatorMedian} + got := admitHistoryRequirements(required, append(incumbents, gone)) + assert.Len(t, got.depths, protocol.MaxHistoryPairs) + for _, key := range incumbents { + assert.Contains(t, got.depths, key) + } + assert.NotContains(t, got.depths, gone) + require.Len(t, got.denied, 5) + for i, key := range got.sortedDenied() { + assert.Equal(t, llotypes.StreamID(i+1), key.streamID) + } + }) + t.Run("depth does not affect admission", func(t *testing.T) { t.Parallel() // Under the chunked layout a pair costs the same per round however deep @@ -215,7 +243,7 @@ func TestAdmitHistoryRequirements(t *testing.T) { for i := range protocol.MaxHistoryPairs { required[histKey{streamID: llotypes.StreamID(i + 1), aggregator: llotypes.AggregatorMedian}] = protocol.MaxHistoryRecordsPerPair } - got := admitHistoryRequirements(required) + got := admitHistoryRequirements(required, nil) assert.Len(t, got.depths, protocol.MaxHistoryPairs) assert.Empty(t, got.denied) }) @@ -226,9 +254,9 @@ func TestAdmitHistoryRequirements(t *testing.T) { for i := range protocol.MaxHistoryPairs + 10 { required[histKey{streamID: llotypes.StreamID(i + 1), aggregator: llotypes.AggregatorMedian}] = 8 } - first := admitHistoryRequirements(required) + first := admitHistoryRequirements(required, nil) for range 20 { - again := admitHistoryRequirements(required) + again := admitHistoryRequirements(required, nil) require.Equal(t, first.depths, again.depths) require.Equal(t, first.sortedDenied(), again.sortedDenied()) } diff --git a/llo/dev/v31/statetransition.go b/llo/dev/v31/statetransition.go index ed5e077..b77f92f 100644 --- a/llo/dev/v31/statetransition.go +++ b/llo/dev/v31/statetransition.go @@ -202,7 +202,7 @@ func (p *Plugin) StateTransition(ctx context.Context, seqNr uint64, _ ocrtypes.A if err != nil { return nil, err } - requirements := computeHistoryRequirements(effective, prev.opts, p.Logger) + requirements := computeHistoryRequirements(effective, prev.opts, history.indexKeys(), p.Logger) if err := requirements.apply(history); err != nil { return nil, err } diff --git a/llo/protocol/limits.go b/llo/protocol/limits.go index 6d3a328..8a85c53 100644 --- a/llo/protocol/limits.go +++ b/llo/protocol/limits.go @@ -180,11 +180,12 @@ const ( // request. Note this is per pair, not per stream: a stream aggregated two // ways holds two windows of up to this depth each. MaxHistoryRecordsPerPair = 1024 - // MaxHistoryPairs bounds how many pairs may have history at once. Pairs are - // ordered by (streamID, aggregator) and those beyond the cap are denied - // history; channels referencing them become unreportable rather than - // silently getting a shortened window. A stream aggregated two ways - // consumes two of these, not one. + // MaxHistoryPairs bounds how many pairs may have history at once. Pairs + // already holding a window come first, then new pairs, each ordered by + // (streamID, aggregator), and those beyond the cap are denied history, + // channels referencing them become unreportable rather than silently + // getting a shortened window. A stream aggregated two ways consumes + // two of these, not one. MaxHistoryPairs = 128 // MaxHistoryTotalBytes bounds the history bytes rewritten per round. The // worst case is MaxHistoryPairs * MaxHistoryPairRoundBytes, held under this From 6b621c71d4fbf44ee9b952e2e9fa09e4485f4a84 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Wed, 14 Oct 2026 15:55:36 +0100 Subject: [PATCH 079/103] llo/datasource: document that Observe must not touch streamValues after returning --- llo/datasource/datasource.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/llo/datasource/datasource.go b/llo/datasource/datasource.go index 0435b09..49980ae 100644 --- a/llo/datasource/datasource.go +++ b/llo/datasource/datasource.go @@ -60,6 +60,12 @@ func (o *dsOpts) LifeCycleStage() llotypes.LifeCycleStage { return o.lifeCycleSt // DataSource observes stream values. For each known streamID, Observe should // set the observed value in the passed protocol.StreamValues. Unknown/failed streams // should be left unset. Shared across all LLO plugin versions. +// +// Observe owns streamValues only until it returns, including when it returns +// because ctx is done. The caller reads and serializes the map right after, so +// an implementation that fills it from other goroutines must stop them, or stop +// them writing, before returning. Observe should not add streams that are not +// requested. type DataSource interface { Observe(ctx context.Context, streamValues protocol.StreamValues, opts DSOpts) error } From d955b4c53a661326e0326178864b10f6bd22dedf Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Wed, 14 Oct 2026 15:57:35 +0100 Subject: [PATCH 080/103] llo/v31: document blob memo behaviour when rounds overlap --- llo/dev/v31/blobmemo.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/llo/dev/v31/blobmemo.go b/llo/dev/v31/blobmemo.go index b8e0cbf..92b5462 100644 --- a/llo/dev/v31/blobmemo.go +++ b/llo/dev/v31/blobmemo.go @@ -40,6 +40,13 @@ const maxMemoizedPayloads = ocrtypes.MaxOracles * maxObservationBlobHandles // The memo enforces its own budget (maxMemoizedPayloadBytes) and simply declines // to store beyond it. Declining is safe because memoization is an optimization, // and a miss decodes and costs exactly what a hit would have. +// +// Only one sequence number is held at a time. If calls for two sequence numbers +// overlap, for example the leader validating observations for the next round +// while StateTransition still runs, each round call discards the other's +// entries and the superseded handle misses on every get and drops every put. +// That degrades to running without the memo and bound in the observation +// decompression budget in decodeObservation, not the memo. type blobPayloadCache struct { mu sync.Mutex seqNr uint64 From e8af155d28eab7150f5f17a32b24513e0d0a3000 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Wed, 14 Oct 2026 16:00:28 +0100 Subject: [PATCH 081/103] llo/v31: measure the hot state record at the adversarial worst case --- llo/dev/v31/limits_test.go | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/llo/dev/v31/limits_test.go b/llo/dev/v31/limits_test.go index d1fda28..5d410e9 100644 --- a/llo/dev/v31/limits_test.go +++ b/llo/dev/v31/limits_test.go @@ -82,20 +82,24 @@ func TestLimits_HistoryFitsWriteBudgets(t *testing.T) { // measurement the cap is only a count, and the byte limit libocr enforces is // what actually fails the round. func TestLimits_HotStateWorstCaseFitsPerKeyLimit(t *testing.T) { - // An 18-digit price, the largest value a real feed produces. Note this is - // the honest worst case, not the adversarial one: a decimal's coefficient - // length is not yet bounded, so a byzantine value can still exceed this. - // Bounding the coefficient is a separate, consensus-affecting change. - price := decimal.RequireFromString("123456789012345.678") + // The adversarial worst case: the longest coefficient observation decode + // admits (MaxDecimalCoefficientBits), negative, at the most negative + // exponent, with maximal IDs and timestamps so every varint is at its + // widest. An aggregate is one of the observed values, so it is held to the + // same bounds. + coefficient := new(big.Int).Lsh(big.NewInt(1), protocol.MaxDecimalCoefficientBits) + coefficient.Sub(coefficient, big.NewInt(1)).Neg(coefficient) + price := decimal.NewFromBigInt(coefficient, -protocol.MaxDecimalExponent) + const maxTimestamp = uint64(math.MaxUint64) carry := make(map[llotypes.StreamID]map[llotypes.Aggregator]*protocol.TimestampedStreamValue, protocol.MaxObservationStreamValuesLength) aggregators := []llotypes.Aggregator{llotypes.AggregatorMedian, llotypes.AggregatorMode} for i := range protocol.MaxObservationStreamValuesLength { - sid := llotypes.StreamID(i) + sid := llotypes.StreamID(math.MaxUint32 - i) carry[sid] = make(map[llotypes.Aggregator]*protocol.TimestampedStreamValue, len(aggregators)) for _, agg := range aggregators { carry[sid][agg] = &protocol.TimestampedStreamValue{ - ObservedAtNanoseconds: 1_700_000_000_000_000_000, + ObservedAtNanoseconds: maxTimestamp, StreamValue: protocol.ToDecimal(price), } } @@ -106,17 +110,19 @@ func TestLimits_HotStateWorstCaseFitsPerKeyLimit(t *testing.T) { validAfter := make(map[llotypes.ChannelID]uint64, protocol.MaxOutcomeChannelDefinitionsLength) reportable := make(map[llotypes.ChannelID]bool, protocol.MaxOutcomeChannelDefinitionsLength) for i := range protocol.MaxOutcomeChannelDefinitionsLength { - validAfter[llotypes.ChannelID(i)] = 1_700_000_000_000_000_000 - reportable[llotypes.ChannelID(i)] = true + cid := llotypes.ChannelID(math.MaxUint32 - i) + validAfter[cid] = maxTimestamp + reportable[cid] = true } kv := newMemKV() - require.NoError(t, writeHotState(kv, 1_700_000_000_000_000_000, validAfter, reportable, carry, logger.Test(t))) + require.NoError(t, writeHotState(kv, maxTimestamp, validAfter, reportable, carry, logger.Test(t))) record, err := kv.Read(keyHotState) require.NoError(t, err) require.LessOrEqual(t, len(record), ocr3_1types.MaxMaxKeyValueValueBytes, "worst-case r/agg record (%d bytes) exceeds the per-key limit", len(record)) + t.Logf("worst-case r/agg record: %d bytes of %d", len(record), ocr3_1types.MaxMaxKeyValueValueBytes) } // TestLimits_HotStateTruncatesAboveCap covers the cap itself: above it, pairs From dcf1350c2a6de27abe172b0f44afc0f6018eefb2 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Wed, 14 Oct 2026 16:04:02 +0100 Subject: [PATCH 082/103] llo/v30: document the node local retirement report verdict in Outcome --- llo/v30/plugin_outcome.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/llo/v30/plugin_outcome.go b/llo/v30/plugin_outcome.go index d133099..1706d6d 100644 --- a/llo/v30/plugin_outcome.go +++ b/llo/v30/plugin_outcome.go @@ -397,6 +397,14 @@ func (p *Plugin) decodeObservations(aos []types.AttributedObservation, outctx oc // verified locally (e.g. the predecessor's ConfigSet row has not // been loaded into the RetirementReportCache yet), since every // honest oracle attaches the same bytes. + // + // The verdict is node local: the signer set comes from this node's + // cache, which the config poller fills asynchronously. A node whose + // cache lags does not promote in the same round as the others and + // computes a different outcome. An outcome needs 2f+1 matching + // signatures to commit, so if more than f nodes lag the handover + // stalls until the caches converge. It cannot fork. v31 agrees on + // the signer set before verifying (see electPredecessorConfig). pcd := *p.PredecessorConfigDigest retirementReport, err3 := p.PredecessorRetirementReportCache.CheckAttestedRetirementReport(pcd, observation.AttestedPredecessorRetirement) if err3 != nil { From e108d9a1bdcadd8fe9cfe344c4a5afc5a6d7c0c0 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Wed, 14 Oct 2026 16:05:21 +0100 Subject: [PATCH 083/103] llo/v31: document why KV state decode trusts its counts and fails closed --- llo/dev/v31/kv.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/llo/dev/v31/kv.go b/llo/dev/v31/kv.go index de4d3e2..0210e34 100644 --- a/llo/dev/v31/kv.go +++ b/llo/dev/v31/kv.go @@ -259,6 +259,13 @@ func writeCodecSupport(w ocr3_1types.KeyValueStateReadWriter, support map[common } // readChannelState reads and decodes the c/defs record. +// +// Unlike decodeHistoryIndex it applies no count cap. The record is this +// plugin's own committed state, written only by StateTransition from verified +// definitions, whose count is capped at MaxOutcomeChannelDefinitionsLength. +// libocr bounds the record to MaxMaxKeyValueValueBytes, which bounds what the +// decode can allocate. A peer cannot inject records, and every oracle reads +// the same bytes. func readChannelState(r ocr3_1types.KeyValueStateReader) (llotypes.ChannelDefinitions, error) { b, err := r.Read(keyChannelState) if err != nil { @@ -281,7 +288,10 @@ func readChannelState(r ocr3_1types.KeyValueStateReader) (llotypes.ChannelDefini return defs, nil } -// readHotState reads and decodes the r/agg record into s. +// readHotState reads and decodes the r/agg record into s. Counts are not +// capped, for the same reasons as in readChannelState: writeHotState truncates +// aggregates to MaxPersistedAggregates, channels are capped by verification, +// and the record is bounded in bytes. func readHotState(r ocr3_1types.KeyValueStateReader, s *kvState) error { b, err := r.Read(keyHotState) if err != nil { @@ -549,6 +559,11 @@ func writeHistoryIndex(w ocr3_1types.KeyValueStateReadWriter, keys []histKey) er // round from the required set, which is capped there, with orphans dropped in // the same commit — so a longer index is corruption and is rejected instead of // sizing an allocation from it. +// +// The rejection fails every StateTransition, as an undecodable c/defs or r/agg +// record does, rather than resetting history. Only a writer bug can produce it, +// and every oracle reads the same bytes, so the DON halts together and loudly +// instead of silently discarding every window. func decodeHistoryIndex(b []byte) ([]histKey, error) { n := len(b) / 8 if n > protocol.MaxHistoryPairs { From d5e2a7895a522303db1ee1616d26e6ace70548e9 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Wed, 14 Oct 2026 16:08:18 +0100 Subject: [PATCH 084/103] llo/v31: correct the history read cost described in channelHistorySatisfied --- llo/dev/v31/history_metrics.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/llo/dev/v31/history_metrics.go b/llo/dev/v31/history_metrics.go index 17661f3..7ca612e 100644 --- a/llo/dev/v31/history_metrics.go +++ b/llo/dev/v31/history_metrics.go @@ -217,8 +217,14 @@ func channelHistorySatisfied(expressions []string, aggByStream map[llotypes.Stre if !ok { continue } - // Reads through the memoized store, so this costs no extra state - // access. + // Usually a hit in the memoized store: evaluation already loaded + // the pairs of every channel that reached history binding. A + // channel skipped before binding, such as one missing an input + // value, can load a header here for the first time. That read is + // round local and feeds only this metric. Corruption it finds is + // logged by reset but not counted in historyCorruptMetric, which + // is captured before this, and the reset is not flushed, so the + // next round that loads the pair before flushing counts it. h, err := history.Load(ref.StreamID, aggregator) if err != nil || uint32(h.Len()) < ref.Count { return false From 8bb8aa21dbf1991df78bef1b9b55c7d7753e703c Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Wed, 14 Oct 2026 16:09:37 +0100 Subject: [PATCH 085/103] llo/protocol: document that backfill keys follow the target current time resolution --- llo/protocol/history_backfill.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/llo/protocol/history_backfill.go b/llo/protocol/history_backfill.go index 426dbe6..9b7c066 100644 --- a/llo/protocol/history_backfill.go +++ b/llo/protocol/history_backfill.go @@ -216,6 +216,13 @@ func ObservationTimestampKeyToNanoseconds(rawKey uint64, res TimeResolution) (na // encodes timestamps at, which is also the unit of its backfill observation keys. // It is the only place that maps a report format to a resolution, so an unknown // format is an error rather than a guess. +// +// The resolution is read from the target's current definition every round, not +// fixed when the backfill was admitted. Changing a target's TimeResolution while +// a backfill for it is in progress therefore changes what the remaining keys +// mean. Base validation rechecks every key against the new resolution, and +// selection skips keys that overflow, so the result is deterministic, but the +// keys must be rewritten in the new unit together with the target change. func TargetChannelTimeResolution(target llotypes.ChannelDefinition) (TimeResolution, error) { switch target.ReportFormat { case llotypes.ReportFormatEVMABIEncodeUnpacked, llotypes.ReportFormatEVMABIEncodeUnpackedExpr: From ae9c333d3d434c67b98326fc6d2b03eb6185cf15 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Wed, 14 Oct 2026 16:11:48 +0100 Subject: [PATCH 086/103] llo/protocol: document the missing value encoding in report telemetry --- llo/dev/v31/telemetry.go | 5 +++-- llo/protocol/llo_plugin_telemetry.pb.go | 20 ++++++++++++-------- llo/protocol/llo_plugin_telemetry.proto | 4 ++++ 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/llo/dev/v31/telemetry.go b/llo/dev/v31/telemetry.go index a6263c0..f215d64 100644 --- a/llo/dev/v31/telemetry.go +++ b/llo/dev/v31/telemetry.go @@ -105,8 +105,9 @@ func makeReportTelemetry(r protocol.Report, cd llotypes.ChannelDefinition, strea svs := make([]*protocol.LLOStreamValue, len(r.Values)) for i, v := range r.Values { if v == nil { - // Missing stream value (allowed when DisableNilStreamValues is false); - // emit an empty entry rather than panicking. + // Missing stream value (allowed when DisableNilStreamValues is + // false). Emit an entry with an empty value, which no present value + // marshals to, so consumers can tell the two apart. svs[i] = &protocol.LLOStreamValue{} continue } diff --git a/llo/protocol/llo_plugin_telemetry.pb.go b/llo/protocol/llo_plugin_telemetry.pb.go index b50afa3..a6d7ce0 100644 --- a/llo/protocol/llo_plugin_telemetry.pb.go +++ b/llo/protocol/llo_plugin_telemetry.pb.go @@ -188,14 +188,18 @@ type LLOReportTelemetry struct { ObservationTimestampNanoseconds uint64 `protobuf:"varint,3,opt,name=observation_timestamp_nanoseconds,json=observationTimestampNanoseconds,proto3" json:"observation_timestamp_nanoseconds,omitempty"` ReportFormat uint32 `protobuf:"varint,4,opt,name=report_format,json=reportFormat,proto3" json:"report_format,omitempty"` Specimen bool `protobuf:"varint,5,opt,name=specimen,proto3" json:"specimen,omitempty"` - StreamDefinitions []*LLOStreamDefinition `protobuf:"bytes,6,rep,name=stream_definitions,json=streamDefinitions,proto3" json:"stream_definitions,omitempty"` - StreamValues []*LLOStreamValue `protobuf:"bytes,7,rep,name=stream_values,json=streamValues,proto3" json:"stream_values,omitempty"` - ChannelOpts []byte `protobuf:"bytes,8,opt,name=channel_opts,json=channelOpts,proto3" json:"channel_opts,omitempty"` - SeqNr uint64 `protobuf:"varint,9,opt,name=seq_nr,json=seqNr,proto3" json:"seq_nr,omitempty"` - ConfigDigest []byte `protobuf:"bytes,10,opt,name=config_digest,json=configDigest,proto3" json:"config_digest,omitempty"` - DonId uint32 `protobuf:"varint,11,opt,name=don_id,json=donId,proto3" json:"don_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // stream_definitions describes stream_values entry by entry. + StreamDefinitions []*LLOStreamDefinition `protobuf:"bytes,6,rep,name=stream_definitions,json=streamDefinitions,proto3" json:"stream_definitions,omitempty"` + // A missing value, allowed when the channel does not set + // disableNilStreamValues, is an entry with an empty value. A present value + // never has an empty value, zero included, so the two cannot be confused. + StreamValues []*LLOStreamValue `protobuf:"bytes,7,rep,name=stream_values,json=streamValues,proto3" json:"stream_values,omitempty"` + ChannelOpts []byte `protobuf:"bytes,8,opt,name=channel_opts,json=channelOpts,proto3" json:"channel_opts,omitempty"` + SeqNr uint64 `protobuf:"varint,9,opt,name=seq_nr,json=seqNr,proto3" json:"seq_nr,omitempty"` + ConfigDigest []byte `protobuf:"bytes,10,opt,name=config_digest,json=configDigest,proto3" json:"config_digest,omitempty"` + DonId uint32 `protobuf:"varint,11,opt,name=don_id,json=donId,proto3" json:"don_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *LLOReportTelemetry) Reset() { diff --git a/llo/protocol/llo_plugin_telemetry.proto b/llo/protocol/llo_plugin_telemetry.proto index 116d84d..c131134 100644 --- a/llo/protocol/llo_plugin_telemetry.proto +++ b/llo/protocol/llo_plugin_telemetry.proto @@ -36,7 +36,11 @@ message LLOReportTelemetry { uint64 observation_timestamp_nanoseconds = 3; uint32 report_format = 4; bool specimen = 5; + // stream_definitions describes stream_values entry by entry. repeated LLOStreamDefinition stream_definitions = 6; + // A missing value, allowed when the channel does not set + // disableNilStreamValues, is an entry with an empty value. A present value + // never has an empty value, zero included, so the two cannot be confused. repeated LLOStreamValue stream_values = 7; bytes channel_opts = 8; From 51af47d2144868707f906d80c34ac2839aa3179a Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Wed, 14 Oct 2026 16:13:30 +0100 Subject: [PATCH 087/103] llo/v31: drop stream values peers would reject before broadcasting the blob --- llo/dev/v31/blobpump.go | 22 ++++++++++++++++++++++ llo/dev/v31/blobpump_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/llo/dev/v31/blobpump.go b/llo/dev/v31/blobpump.go index be175c6..bd77944 100644 --- a/llo/dev/v31/blobpump.go +++ b/llo/dev/v31/blobpump.go @@ -500,6 +500,7 @@ func (p *blobPump) observe(in pumpInput) (*blobSnapshot, error) { p.lggr.Warnw("DataSource returned unrequested streams; dropping them", "seqNr", in.seqNr, "requested", len(in.streams), "returned", len(sv)) sv = requested } + dropUnobservable(sv, p.lggr, in.seqNr) payload, err := marshalStreamValues(sv) if err != nil { @@ -573,6 +574,27 @@ func (p *blobPump) latestSeqNr() uint64 { return p.input.seqNr } +// dropUnobservable unsets every value peers would reject on decode, such as one +// over MaxDecimalCoefficientBits. Peers reject the whole observation on the +// first such value, so one bad value from the DataSource would otherwise void +// every other value this node contributes. The check is the receiver's own +// decode, so both sides apply the same rules. +func dropUnobservable(sv protocol.StreamValues, lggr logger.Logger, seqNr uint64) { + for id, v := range sv { + if v == nil { + continue + } + pb, err := protocol.StreamValueToProto(v) + if err == nil { + _, err = streamValueFromProtoAllowNil(pb) + } + if err != nil { + lggr.Warnw("Dropping stream value peers would reject", "streamID", id, "err", err, "seqNr", seqNr) + sv[id] = nil + } + } +} + // marshalStreamValues serializes stream values into the stream-values-only // proto that is carried by a blob, framed by encodeBlobPayload (which // compresses it when that shrinks the payload). Returns nil when nothing was diff --git a/llo/dev/v31/blobpump_test.go b/llo/dev/v31/blobpump_test.go index 6fe0f66..7c0ed55 100644 --- a/llo/dev/v31/blobpump_test.go +++ b/llo/dev/v31/blobpump_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "math" + "math/big" "sync" "sync/atomic" "testing" @@ -12,6 +13,7 @@ import ( "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" "github.com/smartcontractkit/chainlink-common/pkg/logger" llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" @@ -107,6 +109,30 @@ func Test_blobPump_DropsUnrequestedStreams(t *testing.T) { require.Equal(t, 1, snap.streamCount) } +func Test_dropUnobservable(t *testing.T) { + over := new(big.Int).Lsh(big.NewInt(1), protocol.MaxDecimalCoefficientBits+1) + sv := protocol.StreamValues{ + 1: protocol.ToDecimal(decimal.NewFromInt(7)), + 2: protocol.ToDecimal(decimal.NewFromBigInt(over, 0)), + 3: nil, + } + dropUnobservable(sv, logger.Test(t), 1) + assert.Equal(t, protocol.ToDecimal(decimal.NewFromInt(7)), sv[1]) + assert.Nil(t, sv[2]) + assert.Nil(t, sv[3]) + + // What is left decodes on the receiving side. + payload, err := marshalStreamValues(sv) + require.NoError(t, err) + raw, err := decodeBlobPayload(payload, maxDecompressedBlobPayloadBytes) + require.NoError(t, err) + chunk := &protocol.LLOObservationProto{} + require.NoError(t, proto.Unmarshal(raw, chunk)) + require.Len(t, chunk.StreamValues, 1) + _, err = streamValueFromProtoAllowNil(chunk.StreamValues[1]) + require.NoError(t, err) +} + // Test_blobPump_TakeIsSingleUse guards against two rounds referencing the same // blob handle. func Test_blobPump_TakeIsSingleUse(t *testing.T) { From 5b565ebdad278878dd3b0ad704005a1403bc82fa Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Fri, 16 Oct 2026 08:27:01 +0100 Subject: [PATCH 088/103] llo/v31: treat any nil stream value as unreportable and deprecate DisableNilStreamValues --- llo/dev/v31/doc.go | 21 +++++---- llo/dev/v31/kv.go | 2 +- llo/dev/v31/plugin_test.go | 83 +++++++++++++++++++++++++++++++--- llo/dev/v31/reports.go | 27 ++++------- llo/dev/v31/statetransition.go | 2 +- llo/dev/v31/telemetry.go | 5 +- 6 files changed, 101 insertions(+), 39 deletions(-) diff --git a/llo/dev/v31/doc.go b/llo/dev/v31/doc.go index 0caba09..c8b43ee 100644 --- a/llo/dev/v31/doc.go +++ b/llo/dev/v31/doc.go @@ -126,11 +126,15 @@ // in both the current-round (isReportable) and previous-round (prevReportable) // reportability checks. // -// DisableNilStreamValues (a channel with any nil stream aggregate is -// unreportable; for expression channels the expected calculated streams are -// taken from the channel's opts, which is where they are declared), cross-round -// timestamped-aggregate carry-forward (in the r/agg record, newer-wins -// monotonicity), and best-effort outcome/report telemetry are also implemented. +// A channel with any nil value among its effective streams is unreportable, +// since no codec encodes a missing value. For expression channels the expected +// calculated streams are taken from the channel's opts, which is where they are +// declared. DisableNilStreamValues is deprecated: v31 ignores it and behaves as +// if it were always set. +// +// Cross-round timestamped-aggregate carry-forward (in the r/agg record, +// newer-wins monotonicity) and best-effort outcome/report telemetry are also +// implemented. // Reportability is persisted per channel each round (also in r/agg) so the next // round can advance validAfter faithfully without re-deriving it from // aggregates that are not otherwise persisted. @@ -138,10 +142,9 @@ // Calculated streams (EVMABIEncodeUnpackedExpr channels) are supported via the // expression engine in llo/protocol/calculated, run at the end of // StateTransition. A channel whose expressions did not produce every calculated -// stream its opts declare is not reportable, regardless of -// DisableNilStreamValues: the codec would have nothing to encode, so the report -// is skipped, and counting the channel as reported would advance validAfter over -// a round that emitted nothing. +// stream its opts declare is not reportable: the codec would have nothing to +// encode, so the report is skipped, and counting the channel as reported would +// advance validAfter over a round that emitted nothing. // // Evaluation writes stream aggregates and nothing else. It does not touch the // channel definitions, so a persisted definition is exactly what was voted on diff --git a/llo/dev/v31/kv.go b/llo/dev/v31/kv.go index 0210e34..69851f2 100644 --- a/llo/dev/v31/kv.go +++ b/llo/dev/v31/kv.go @@ -122,7 +122,7 @@ type kvState struct { validAfterNanoseconds map[llotypes.ChannelID]uint64 // reportedLastRound[cid] is the persisted reportability decision from the // previous round's StateTransition. It bakes in every reportability check - // (min-interval, seconds-resolution, DisableNilStreamValues), so the current + // (min-interval, seconds-resolution, nil stream values), so the current // round can advance validAfter faithfully without re-deriving it from // aggregates that are not persisted. reportedLastRound map[llotypes.ChannelID]bool diff --git a/llo/dev/v31/plugin_test.go b/llo/dev/v31/plugin_test.go index 0f706b2..53572fb 100644 --- a/llo/dev/v31/plugin_test.go +++ b/llo/dev/v31/plugin_test.go @@ -400,18 +400,28 @@ func Test_StateTransition_ContributionFloor(t *testing.T) { require.NoError(t, err) require.Contains(t, out.StreamAggregates, llotypes.StreamID(100)) - // Two do not: no aggregate, so no report. The channel itself stays - // reportable, since nil observed stream values are permitted unless the - // definition sets DisableNilStreamValues; the report is then dropped at - // encode time, exactly as for any other missing observed value. + // Two do not: no aggregate, so the channel is unreportable rather than + // dropped at encode time. prec, err = p.StateTransition(ctx, 4, ocrtypes.AttributedQuery{}, valObs(3_000, 2), kv, testBlobs) require.NoError(t, err) out, err = decodePrecursor(prec) require.NoError(t, err) require.NotContains(t, out.StreamAggregates, llotypes.StreamID(100)) + validAfter := out.ValidAfterNanoseconds[1] reports, err := p.Reports(ctx, 4, prec) require.NoError(t, err) require.Empty(t, reports) + + // The watermark holds over the round that emitted nothing, so the next + // report covers it. + prec, err = p.StateTransition(ctx, 5, ocrtypes.AttributedQuery{}, valObs(4_000, 3), kv, testBlobs) + require.NoError(t, err) + out, err = decodePrecursor(prec) + require.NoError(t, err) + require.Equal(t, validAfter, out.ValidAfterNanoseconds[1]) + reports, err = p.Reports(ctx, 5, prec) + require.NoError(t, err) + require.Len(t, reports, 1) } func Test_Precursor_RoundTrip_And_Determinism(t *testing.T) { @@ -620,6 +630,57 @@ func Test_DisableNilStreamValues(t *testing.T) { require.Equal(t, []llotypes.ChannelID{1}, full.withSupport(1).reportableChannels(0, 0, protocol.NewOptsCache(), nil)) } +// v31 ignores DisableNilStreamValues: a nil value makes any channel +// unreportable, whatever its format and whichever position the value has. +func Test_IsReportable_NilStreamValue(t *testing.T) { + streams := []llotypes.Stream{ + {StreamID: 100, Aggregator: llotypes.AggregatorMedian}, + {StreamID: 200, Aggregator: llotypes.AggregatorMedian}, + {StreamID: 300, Aggregator: llotypes.AggregatorQuote}, + } + allExcept := func(missing llotypes.StreamID) protocol.StreamAggregates { + aggs := protocol.StreamAggregates{} + for _, strm := range streams { + if strm.StreamID != missing { + aggs[strm.StreamID] = map[llotypes.Aggregator]protocol.StreamValue{strm.Aggregator: protocol.ToDecimal(decimal.NewFromInt(1))} + } + } + return aggs + } + for _, format := range []llotypes.ReportFormat{ + llotypes.ReportFormatJSON, + llotypes.ReportFormatEVMPremiumLegacy, + llotypes.ReportFormatEVMABIEncodeUnpacked, + llotypes.ReportFormatEVMStreamlined, + llotypes.ReportFormatCapabilityTrigger, + } { + for _, tc := range []struct { + missing llotypes.StreamID + reportable bool + }{ + {0, true}, + {100, false}, + {300, false}, + } { + t.Run(format.String()+"/missing="+strconv.Itoa(int(tc.missing)), func(t *testing.T) { + o := precursor{ + LifeCycleStage: protocol.LifeCycleStageProduction, + ObservationTimestampNanoseconds: 2 * uint64(time.Second), + ChannelDefinitions: llotypes.ChannelDefinitions{1: {ReportFormat: format, Streams: streams}}, + ValidAfterNanoseconds: map[llotypes.ChannelID]uint64{1: uint64(time.Second)}, + StreamAggregates: allExcept(tc.missing), + } + got := o.withSupport(1).reportableChannels(0, 0, protocol.NewOptsCache(), nil) + if tc.reportable { + require.Equal(t, []llotypes.ChannelID{1}, got) + } else { + require.Empty(t, got) + } + }) + } + } +} + func Test_DisableNilStreamValues_CalculatedStreams(t *testing.T) { const ( base1 = llotypes.StreamID(100) @@ -698,6 +759,13 @@ func Test_DisableNilStreamValues_CalculatedStreams(t *testing.T) { require.Empty(t, o.withSupport(1).reportableChannels(0, 0, populatedCache(o), nil)) }) + t.Run("DisableNilStreamValues=false, nil observed value -> not reportable", func(t *testing.T) { + aggs := evaluatedAggregates() + delete(aggs, base2) + o := mkPrec(false, validOpts, withCalculated, aggs) + require.Empty(t, o.withSupport(1).reportableChannels(0, 0, populatedCache(o), nil)) + }) + t.Run("DisableNilStreamValues=false, fully evaluated -> reportable", func(t *testing.T) { o := mkPrec(false, validOpts, withCalculated, evaluatedAggregates()) require.Equal(t, []llotypes.ChannelID{1}, o.withSupport(1).reportableChannels(0, 0, populatedCache(o), nil)) @@ -1714,11 +1782,12 @@ func Test_StateTransition_CodecSupportAccumulatesAcrossRounds(t *testing.T) { 1: {ReportFormat: llotypes.ReportFormatJSON, Streams: []llotypes.Stream{{StreamID: 1, Aggregator: llotypes.AggregatorMedian}}}, })) + values := protocol.StreamValues{1: protocol.ToDecimal(decimal.NewFromInt(1))} obsJSON := func(ts uint64) []byte { - return mustEncodeObs(t, Observation{UnixTimestampNanoseconds: ts, SupportedReportFormats: formatSet(llotypes.ReportFormatJSON)}) + return mustEncodeObs(t, Observation{UnixTimestampNanoseconds: ts, StreamValues: values, SupportedReportFormats: formatSet(llotypes.ReportFormatJSON)}) } obsNone := func(ts uint64) []byte { - return mustEncodeObs(t, Observation{UnixTimestampNanoseconds: ts, SupportedReportFormats: formatSet()}) + return mustEncodeObs(t, Observation{UnixTimestampNanoseconds: ts, StreamValues: values, SupportedReportFormats: formatSet()}) } // All N oracles advertise JSON. @@ -2058,7 +2127,7 @@ func Test_UnreportableTally_CollapsesPerChannelWarnings(t *testing.T) { ObservationTimestampNanoseconds: 1_000, ChannelDefinitions: llotypes.ChannelDefinitions{}, ValidAfterNanoseconds: map[llotypes.ChannelID]uint64{}, - StreamAggregates: protocol.StreamAggregates{}, + StreamAggregates: protocol.StreamAggregates{1: {llotypes.AggregatorMedian: protocol.ToDecimal(decimal.NewFromInt(1))}}, } for i := 1; i <= channels; i++ { cid := llotypes.ChannelID(i) //nolint:gosec // G115 bounded by the loop diff --git a/llo/dev/v31/reports.go b/llo/dev/v31/reports.go index 1c19f86..c1b32f1 100644 --- a/llo/dev/v31/reports.go +++ b/llo/dev/v31/reports.go @@ -338,15 +338,6 @@ func (o precursor) isReportable(channelID llotypes.ChannelID, minReportInterval // above already established the target exists. return o.formatIsEncodable(o.ChannelDefinitions[opts.TargetChannelID].ReportFormat, f, channelID, tally) } - // When DisableNilStreamValues is set, every stream must have a (non-nil) - // aggregate value for the channel to be reportable. - if cd.DisableNilStreamValues { - for _, strm := range cd.Streams { - if o.StreamAggregates[strm.StreamID][strm.Aggregator] == nil { - return false - } - } - } // Reportability and emission must agree on what the report carries. Reports // derives the report's values from protocol.EffectiveStreams, so the same // derivation has to succeed here: if it fails there, the report is skipped @@ -368,17 +359,17 @@ func (o precursor) isReportable(channelID llotypes.ChannelID, minReportInterval tally.note("cannot derive effective streams", channelID, "err", err) return false } - // Calculated streams are derived state, and unlike observed streams a - // missing one cannot be reported around: the codec has nothing to encode. - // A channel whose expressions failed (bad input, eval error, or history - // still warming up) has no aggregate for them. Observed streams stay - // nil-permitted here; that is what DisableNilStreamValues above governs. + // Every value the report carries must be present. Codecs do not encode a + // missing value, and a missing calculated stream means its expression failed + // (bad input, eval error, or history still warming up). Holding the + // watermark over such a round lets the next report cover it once the values + // are back. + // + // This applies regardless of DisableNilStreamValues, which v31 ignores and + // treats as always set. for _, strm := range streams { - if strm.Aggregator != llotypes.AggregatorCalculated { - continue - } if o.StreamAggregates[strm.StreamID][strm.Aggregator] == nil { - tally.note("nil calculated stream value", channelID, "streamID", strm.StreamID) + tally.note("nil stream value", channelID, "streamID", strm.StreamID, "aggregator", strm.Aggregator) return false } } diff --git a/llo/dev/v31/statetransition.go b/llo/dev/v31/statetransition.go index b77f92f..3d4325f 100644 --- a/llo/dev/v31/statetransition.go +++ b/llo/dev/v31/statetransition.go @@ -785,7 +785,7 @@ func channelDefinitionsChanged(prev, next llotypes.ChannelDefinitions) bool { // prevReportable reports whether the channel was reportable in the previous // round. This reads the reportability decision persisted by the previous // round's StateTransition, which already accounts for min-interval, -// seconds-resolution overlap, and DisableNilStreamValues. It is exactly +// seconds-resolution overlap, and nil stream values. It is exactly // the value the v30 code derives from previousOutcome.IsReportable. func prevReportable(prev *kvState, channelID llotypes.ChannelID) bool { return prev.reportedLastRound[channelID] diff --git a/llo/dev/v31/telemetry.go b/llo/dev/v31/telemetry.go index f215d64..956c99f 100644 --- a/llo/dev/v31/telemetry.go +++ b/llo/dev/v31/telemetry.go @@ -105,9 +105,8 @@ func makeReportTelemetry(r protocol.Report, cd llotypes.ChannelDefinition, strea svs := make([]*protocol.LLOStreamValue, len(r.Values)) for i, v := range r.Values { if v == nil { - // Missing stream value (allowed when DisableNilStreamValues is - // false). Emit an entry with an empty value, which no present value - // marshals to, so consumers can tell the two apart. + // Missing stream value. Emit an entry with an empty value, which no + // present value marshals to, so consumers can tell the two apart. svs[i] = &protocol.LLOStreamValue{} continue } From bea9456ad9ae9092fb0a255ca0afb1dbea57f981 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Fri, 16 Oct 2026 16:52:36 +0100 Subject: [PATCH 089/103] llo/v31: enforce calculated stream, TWAP, unique stream ID and stream ID ownership budgets when merging channel votes --- llo/dev/v31/flow_test.go | 12 +- llo/dev/v31/limits_test.go | 67 ++++++- llo/dev/v31/statetransition.go | 17 +- llo/protocol/channel_definitions.go | 34 ++-- llo/protocol/channel_definitions_budget.go | 174 ++++++++++++++---- .../channel_definitions_budget_test.go | 104 +++++++++-- llo/protocol/limits.go | 5 +- 7 files changed, 328 insertions(+), 85 deletions(-) diff --git a/llo/dev/v31/flow_test.go b/llo/dev/v31/flow_test.go index 66e51d0..6784afb 100644 --- a/llo/dev/v31/flow_test.go +++ b/llo/dev/v31/flow_test.go @@ -1093,7 +1093,7 @@ func Test_ApplyChannelVotes_UpdateWinsOverSameRoundRemoval(t *testing.T) { pending := llotypes.ChannelDefinitions{1: jsonChannel(), 2: tombstoned} readdedWithID := protocol.ChannelDefinitionWithID{ChannelDefinition: readded, ChannelID: 2} h := makeChannelHash(readdedWithID) - applyChannelVotes(pending, + applyChannelVotes(nil, nil, pending, map[llotypes.ChannelID]int{2: f + 1}, map[[32]byte]protocol.ChannelDefinitionWithID{h: readdedWithID}, map[[32]byte]int{h: f + 1}, f) @@ -1118,19 +1118,19 @@ func Test_ApplyChannelVotes_ContestedUpdateIsSkipped(t *testing.T) { t.Run("both past the threshold leaves the channel unchanged", func(t *testing.T) { pending := llotypes.ChannelDefinitions{1: jsonChannel(), 2: jsonChannel()} - applyChannelVotes(pending, nil, updates, map[[32]byte]int{hx: f + 1, hy: f + 1}, f) + applyChannelVotes(nil, nil, pending, nil, updates, map[[32]byte]int{hx: f + 1, hy: f + 1}, f) require.Equal(t, llotypes.ChannelDefinitions{1: jsonChannel(), 2: jsonChannel()}, pending) }) t.Run("both past the threshold adds nothing", func(t *testing.T) { pending := llotypes.ChannelDefinitions{1: jsonChannel()} - applyChannelVotes(pending, nil, updates, map[[32]byte]int{hx: f + 1, hy: f + 1}, f) + applyChannelVotes(nil, nil, pending, nil, updates, map[[32]byte]int{hx: f + 1, hy: f + 1}, f) require.Equal(t, llotypes.ChannelDefinitions{1: jsonChannel()}, pending) }) t.Run("one past the threshold is applied", func(t *testing.T) { pending := llotypes.ChannelDefinitions{1: jsonChannel(), 2: jsonChannel()} - applyChannelVotes(pending, nil, updates, map[[32]byte]int{hx: f, hy: f + 1}, f) + applyChannelVotes(nil, nil, pending, nil, updates, map[[32]byte]int{hx: f, hy: f + 1}, f) require.Equal(t, llotypes.ChannelDefinitions{1: jsonChannel(), 2: y.ChannelDefinition}, pending) }) @@ -1138,7 +1138,7 @@ func Test_ApplyChannelVotes_ContestedUpdateIsSkipped(t *testing.T) { other := protocol.ChannelDefinitionWithID{ChannelDefinition: x.ChannelDefinition, ChannelID: 3} ho := makeChannelHash(other) pending := llotypes.ChannelDefinitions{1: jsonChannel(), 2: jsonChannel()} - applyChannelVotes(pending, nil, + applyChannelVotes(nil, nil, pending, nil, map[[32]byte]protocol.ChannelDefinitionWithID{hx: x, hy: y, ho: other}, map[[32]byte]int{hx: f + 1, hy: f + 1, ho: f + 1}, f) require.Equal(t, llotypes.ChannelDefinitions{1: jsonChannel(), 2: jsonChannel(), 3: x.ChannelDefinition}, pending) @@ -1148,7 +1148,7 @@ func Test_ApplyChannelVotes_ContestedUpdateIsSkipped(t *testing.T) { tombstoned := jsonChannel() tombstoned.Tombstone = true pending := llotypes.ChannelDefinitions{1: jsonChannel(), 2: tombstoned} - applyChannelVotes(pending, map[llotypes.ChannelID]int{2: f + 1}, updates, map[[32]byte]int{hx: f + 1, hy: f + 1}, f) + applyChannelVotes(nil, nil, pending, map[llotypes.ChannelID]int{2: f + 1}, updates, map[[32]byte]int{hx: f + 1, hy: f + 1}, f) require.Equal(t, llotypes.ChannelDefinitions{1: jsonChannel()}, pending) }) } diff --git a/llo/dev/v31/limits_test.go b/llo/dev/v31/limits_test.go index 5d410e9..5ec5ad6 100644 --- a/llo/dev/v31/limits_test.go +++ b/llo/dev/v31/limits_test.go @@ -1,6 +1,7 @@ package llo import ( + "fmt" "math" "math/big" "strings" @@ -222,12 +223,12 @@ func TestLimits_ChannelVotesRespectDefinitionSetBudgets(t *testing.T) { for _, id := range []llotypes.ChannelID{1000, 1001} { alone := cloneChannelDefinitions(committed) alone[id] = channel(2) - require.NoError(t, protocol.NewChannelDefinitionsBudget(alone).Err()) + require.NoError(t, protocol.NewChannelDefinitionsBudget(nil, alone, nil).Err()) } pending := cloneChannelDefinitions(committed) - applyChannelVotes(pending, nil, updates, votes, f) - require.NoError(t, protocol.NewChannelDefinitionsBudget(pending).Err()) + applyChannelVotes(nil, nil, pending, nil, updates, votes, f) + require.NoError(t, protocol.NewChannelDefinitionsBudget(nil, pending, nil).Err()) // Applied in channel ID order, so the lower ID wins on every oracle. require.Contains(t, pending, llotypes.ChannelID(1000)) require.NotContains(t, pending, llotypes.ChannelID(1001)) @@ -237,11 +238,67 @@ func TestLimits_ChannelVotesRespectDefinitionSetBudgets(t *testing.T) { over := cloneChannelDefinitions(pending) over[1001] = channel(3) tombstone := llotypes.ChannelDefinition{ReportFormat: llotypes.ReportFormatJSON, Tombstone: true} - applyChannelVotes(over, nil, + applyChannelVotes(nil, nil, over, nil, map[[32]byte]protocol.ChannelDefinitionWithID{{3}: {ChannelDefinition: tombstone, ChannelID: 1001}}, map[[32]byte]int{{3}: f + 1}, f) require.True(t, over[1001].Tombstone) - require.NoError(t, protocol.NewChannelDefinitionsBudget(over).Err()) + require.NoError(t, protocol.NewChannelDefinitionsBudget(nil, over, nil).Err()) +} + +// TestLimits_ChannelVotesRespectExpressionBudgets covers the budgets measured +// from opts: two updates that each fit can still exceed the calculated stream +// budget together, or declare the same calculated stream. +func TestLimits_ChannelVotesRespectExpressionBudgets(t *testing.T) { + const f = 1 + expression := func(observed llotypes.StreamID, calculatedIDs ...llotypes.StreamID) llotypes.ChannelDefinition { + abi := make([]string, 0, len(calculatedIDs)) + for _, id := range calculatedIDs { + abi = append(abi, fmt.Sprintf(`{"expressionStreamID":%d}`, id)) + } + return llotypes.ChannelDefinition{ + ReportFormat: llotypes.ReportFormatEVMABIEncodeUnpackedExpr, + Streams: []llotypes.Stream{{StreamID: observed, Aggregator: llotypes.AggregatorMedian}}, + Opts: llotypes.ChannelOpts(fmt.Sprintf(`{"abi":[%s]}`, strings.Join(abi, ","))), + } + } + votes := map[[32]byte]int{{1}: f + 1, {2}: f + 1} + + t.Run("calculated streams", func(t *testing.T) { + // One calculated stream short of MaxTotalCalculatedStreams. + ids := make([]llotypes.StreamID, protocol.MaxTotalCalculatedStreams-1) + for i := range ids { + ids[i] = llotypes.StreamID(1_000_000 + i) + } + committed := llotypes.ChannelDefinitions{1: expression(1, ids...)} + updates := map[[32]byte]protocol.ChannelDefinitionWithID{ + {1}: {ChannelDefinition: expression(2, 2_000_000), ChannelID: 1000}, + {2}: {ChannelDefinition: expression(3, 2_000_001), ChannelID: 1001}, + } + for _, u := range updates { + alone := cloneChannelDefinitions(committed) + alone[u.ChannelID] = u.ChannelDefinition + require.NoError(t, protocol.NewChannelDefinitionsBudget(nil, alone, nil).Err()) + } + + pending := cloneChannelDefinitions(committed) + applyChannelVotes(nil, nil, pending, nil, updates, votes, f) + require.NoError(t, protocol.NewChannelDefinitionsBudget(nil, pending, nil).Err()) + require.Contains(t, pending, llotypes.ChannelID(1000)) + require.NotContains(t, pending, llotypes.ChannelID(1001)) + }) + + t.Run("calculated stream declared twice", func(t *testing.T) { + committed := llotypes.ChannelDefinitions{1: expression(1, 100)} + updates := map[[32]byte]protocol.ChannelDefinitionWithID{ + {1}: {ChannelDefinition: expression(2, 200), ChannelID: 1000}, + {2}: {ChannelDefinition: expression(3, 200), ChannelID: 1001}, + } + + pending := cloneChannelDefinitions(committed) + applyChannelVotes(nil, nil, pending, nil, updates, votes, f) + require.Contains(t, pending, llotypes.ChannelID(1000)) + require.NotContains(t, pending, llotypes.ChannelID(1001)) + }) } // TestLimits_ChannelStateWorstCaseFitsPerKeyLimit builds the largest definition diff --git a/llo/dev/v31/statetransition.go b/llo/dev/v31/statetransition.go index 3d4325f..ded9c2c 100644 --- a/llo/dev/v31/statetransition.go +++ b/llo/dev/v31/statetransition.go @@ -128,7 +128,7 @@ func (p *Plugin) StateTransition(ctx context.Context, seqNr uint64, _ ocrtypes.A // Channel definition changes (skipped once retired). These apply to pending // only: they take effect next round. if out.LifeCycleStage != protocol.LifeCycleStageRetired { - applyChannelVotes(pending, tally.removeChannelVotesByID, tally.updateChannelDefinitionsByHash, tally.updateChannelVotesByHash, p.F) + applyChannelVotes(p.ReportCodecs, p.ChannelAnalysisCache.ReadOnly(), pending, tally.removeChannelVotesByID, tally.updateChannelDefinitionsByHash, tally.updateChannelVotesByHash, p.F) } // validAfter. @@ -460,7 +460,15 @@ func supportVotesForEffectiveFormats(votes map[llotypes.ReportFormat]int, effect // defs is the PENDING set: everything applied here takes effect next round. It // deliberately does not touch the round's channel generation, whose definitions // and decoded opts stay pinned to the effective set for the whole round. +// +// The budgets that depend on opts are measured through cache, which holds the +// committed definitions once Observation has verified them. It should be +// read-only, since the updates were proposed by peers. The budget is only +// measured when an update passes the threshold, so a round without changes +// decodes nothing. func applyChannelVotes( + codecs map[llotypes.ReportFormat]protocol.ReportCodec, + cache *protocol.ChannelAnalysisCache, defs llotypes.ChannelDefinitions, removeVotesByID map[llotypes.ChannelID]int, updateDefsByHash map[[32]byte]protocol.ChannelDefinitionWithID, @@ -496,11 +504,14 @@ func applyChannelVotes( passing[hwid.def.ChannelID]++ } } - budget := protocol.NewChannelDefinitionsBudget(defs) + var budget *protocol.ChannelDefinitionsBudget for _, hwid := range ordered { if updateVotesByHash[hwid.hash] <= f || passing[hwid.def.ChannelID] > 1 { continue } + if budget == nil { + budget = protocol.NewChannelDefinitionsBudget(codecs, defs, cache) + } defWithID := hwid.def prev, exists := defs[defWithID.ChannelID] if !exists && len(defs) >= protocol.MaxOutcomeChannelDefinitionsLength { @@ -511,7 +522,7 @@ func applyChannelVotes( if exists { replaced = &prev } - if !budget.Replace(replaced, defWithID.ChannelDefinition) { + if !budget.Replace(defWithID.ChannelID, replaced, defWithID.ChannelDefinition) { // Skip changes that grow the set past a whole-set budget; they are // voted on again in later rounds. continue diff --git a/llo/protocol/channel_definitions.go b/llo/protocol/channel_definitions.go index 5513d32..5765c80 100644 --- a/llo/protocol/channel_definitions.go +++ b/llo/protocol/channel_definitions.go @@ -279,18 +279,22 @@ func analyzeChannelDefinitions(codecs map[llotypes.ReportFormat]ReportCodec, cha res.admissionFindings = append(res.admissionFindings, admissionFinding{wholeSet: true, err: err}) } - // Whole-set size budgets, measured the same way the state transition + // The facts of each channel, derived once per analysis: the budget and the + // checks below both read them, and cache may not record. + derived := make(map[llotypes.ChannelID]channelFacts, len(channelDefs)) + factsOf := func(channelID llotypes.ChannelID, cd llotypes.ChannelDefinition) channelFacts { + facts, ok := derived[channelID] + if !ok { + facts = channelFactsFor(cache, codecs, channelID, cd) + derived[channelID] = facts + } + return facts + } + + // Whole-set budgets, measured the same way the state transition // enforces them. Tombstones count: nothing reads their streams or opts, but // they are persisted in the definitions record all the same. - budget := NewChannelDefinitionsBudget(channelDefs) - - // Calculated streams declared across the set. Counted separately from the - // pairs above because they are recomputed each round rather than carried - // forward, so they cost precursor bytes but no r/agg bytes. - var totalCalculatedStreams int - // TWAP calls across every expression in the set, each charged its worst - // case; see MaxTotalTWAPCalls. - var totalTWAPCalls int + budget := newChannelDefinitionsBudget(channelDefs, factsOf) uniqueStreamIDs := make(map[llotypes.StreamID]struct{}, len(channelDefs)) // Owners of every stream ID that will hold an aggregate: observed streams @@ -357,8 +361,7 @@ func analyzeChannelDefinitions(codecs map[llotypes.ReportFormat]ReportCodec, cha } } } - facts := channelFactsFor(cache, codecs, channelID, cd) - totalTWAPCalls += facts.twapCalls + facts := factsOf(channelID, cd) if HasCalculatedStreams(cd) { if facts.calculatedErr != nil { @@ -367,7 +370,6 @@ func analyzeChannelDefinitions(codecs map[llotypes.ReportFormat]ReportCodec, cha // Keep the IDs declared before a failing entry: evaluation still // produces them, so they occupy their stream IDs and count against // the budget like any other. - totalCalculatedStreams += len(facts.calculatedIDs) for _, streamID := range facts.calculatedIDs { if owner, ok := calculatedBy[streamID]; ok { admit(fmt.Errorf("ChannelDefinition with ID %d declares calculated stream %d already declared by channel %d", channelID, streamID, owner), channelID, owner) @@ -430,12 +432,6 @@ func analyzeChannelDefinitions(codecs map[llotypes.ReportFormat]ReportCodec, cha if err := budget.Err(); err != nil { admitSet(err) } - if totalCalculatedStreams > MaxTotalCalculatedStreams { - admitSet(fmt.Errorf("too many calculated streams across all channels, got: %d/%d", totalCalculatedStreams, MaxTotalCalculatedStreams)) - } - if totalTWAPCalls > MaxTotalTWAPCalls { - admitSet(fmt.Errorf("too many TWAP calls across all channels, got: %d/%d", totalTWAPCalls, MaxTotalTWAPCalls)) - } res.uniqueStreamIDs = len(uniqueStreamIDs) return res diff --git a/llo/protocol/channel_definitions_budget.go b/llo/protocol/channel_definitions_budget.go index d77274e..fe1e9d7 100644 --- a/llo/protocol/channel_definitions_budget.go +++ b/llo/protocol/channel_definitions_budget.go @@ -8,53 +8,112 @@ import ( ) // ChannelDefinitionsBudget tracks the whole-set budgets that bound the size of -// the persisted records: MaxTotalStreamEntries and MaxTotalOptsBytes for the -// definitions record and the precursor, MaxPersistedAggregates for the -// carry-forward record. +// the persisted records and the per-round work: MaxTotalStreamEntries and +// MaxTotalOptsBytes for the definitions record and the precursor, +// MaxPersistedAggregates for the carry-forward record, MaxTotalCalculatedStreams +// and MaxTotalTWAPCalls for expression evaluation, and +// MaxObservationStreamValuesLength for the observed stream IDs. It also counts +// the claims on stream IDs that already have an owner, which no set may gain. // -// It needs nothing but the definitions, so every oracle reaches the same totals +// It needs nothing but the definitions and the report codecs, whose verdicts +// are pure functions of the definition, so every oracle reaches the same totals // for the same set, which is what lets the state transition enforce it. type ChannelDefinitionsBudget struct { - streamEntries int - optsBytes int + factsOf func(llotypes.ChannelID, llotypes.ChannelDefinition) channelFacts + + streamEntries int + optsBytes int + calculatedStreams int + twapCalls int // Channels naming each (stream, aggregator) pair, so that removing one of // them releases the pair only when no other channel still names it. aggregatedPairs map[streamAggregatorPair]int + // Channels listing each stream ID, counted the same way. + streamIDs map[llotypes.StreamID]int + // Owners of each stream ID that holds an aggregate: the channels observing + // it and the expressions declaring it. + observed map[llotypes.StreamID]int + calculated map[llotypes.StreamID]int + // Claims on a stream ID beyond its first owner, where the observing + // channels together are one owner and each declaring expression is another. + clashes int +} + +// NewChannelDefinitionsBudget measures defs. The costs derived from opts are +// read through cache; a nil cache memoizes nothing. +func NewChannelDefinitionsBudget(codecs map[llotypes.ReportFormat]ReportCodec, defs llotypes.ChannelDefinitions, cache *ChannelAnalysisCache) *ChannelDefinitionsBudget { + return newChannelDefinitionsBudget(defs, func(channelID llotypes.ChannelID, cd llotypes.ChannelDefinition) channelFacts { + return channelFactsFor(cache, codecs, channelID, cd) + }) } -// NewChannelDefinitionsBudget measures defs. -func NewChannelDefinitionsBudget(defs llotypes.ChannelDefinitions) *ChannelDefinitionsBudget { - b := &ChannelDefinitionsBudget{aggregatedPairs: make(map[streamAggregatorPair]int)} - for _, cd := range defs { - b.add(cd) +func newChannelDefinitionsBudget(defs llotypes.ChannelDefinitions, factsOf func(llotypes.ChannelID, llotypes.ChannelDefinition) channelFacts) *ChannelDefinitionsBudget { + b := &ChannelDefinitionsBudget{ + factsOf: factsOf, + aggregatedPairs: make(map[streamAggregatorPair]int), + streamIDs: make(map[llotypes.StreamID]int), + observed: make(map[llotypes.StreamID]int), + calculated: make(map[llotypes.StreamID]int), + } + for channelID, cd := range defs { + b.apply(channelID, cd, 1) } return b } -// Replace swaps prev for next in the measured set, with a nil prev for an -// addition. It refuses the change, leaving the totals as they were, when it -// grows a total past its budget. A total already over budget may stay there or -// shrink, so a set that is over budget can still be trimmed. -func (b *ChannelDefinitionsBudget) Replace(prev *llotypes.ChannelDefinition, next llotypes.ChannelDefinition) bool { - streamEntries, optsBytes, pairs := b.streamEntries, b.optsBytes, len(b.aggregatedPairs) +// Replace swaps prev for next as channelID's definition in the measured set, +// with a nil prev for an addition. It refuses the change, leaving the totals as +// they were, when it grows a total past its budget. A total already over budget +// may stay there or shrink, so a set that is over budget can still be trimmed. +func (b *ChannelDefinitionsBudget) Replace(channelID llotypes.ChannelID, prev *llotypes.ChannelDefinition, next llotypes.ChannelDefinition) bool { + before := b.totals() if prev != nil { - b.remove(*prev) + b.apply(channelID, *prev, -1) } - b.add(next) - grows := func(before, after, limit int) bool { return after > limit && after > before } - if grows(streamEntries, b.streamEntries, MaxTotalStreamEntries) || - grows(optsBytes, b.optsBytes, MaxTotalOptsBytes) || - grows(pairs, len(b.aggregatedPairs), MaxPersistedAggregates) { - b.remove(next) + b.apply(channelID, next, 1) + if b.totals().grows(before) { + b.apply(channelID, next, -1) if prev != nil { - b.add(*prev) + b.apply(channelID, *prev, 1) } return false } return true } -// Err reports every budget the measured set exceeds. +// budgetTotals is a snapshot of every total the budget bounds. +type budgetTotals struct { + streamEntries, optsBytes, aggregatedPairs, calculatedStreams, twapCalls, streamIDs, clashes int +} + +func (b *ChannelDefinitionsBudget) totals() budgetTotals { + return budgetTotals{ + streamEntries: b.streamEntries, + optsBytes: b.optsBytes, + aggregatedPairs: len(b.aggregatedPairs), + calculatedStreams: b.calculatedStreams, + twapCalls: b.twapCalls, + streamIDs: len(b.streamIDs), + clashes: b.clashes, + } +} + +// grows reports whether any total went from before past its budget, or further +// past it. +func (t budgetTotals) grows(before budgetTotals) bool { + grows := func(before, after, limit int) bool { return after > limit && after > before } + return grows(before.streamEntries, t.streamEntries, MaxTotalStreamEntries) || + grows(before.optsBytes, t.optsBytes, MaxTotalOptsBytes) || + grows(before.aggregatedPairs, t.aggregatedPairs, MaxPersistedAggregates) || + grows(before.calculatedStreams, t.calculatedStreams, MaxTotalCalculatedStreams) || + grows(before.twapCalls, t.twapCalls, MaxTotalTWAPCalls) || + grows(before.streamIDs, t.streamIDs, MaxObservationStreamValuesLength) || + grows(before.clashes, t.clashes, 0) +} + +// Err reports every budget the measured set exceeds. The unique stream ID +// budget and the clashing stream IDs are left to analyzeChannelDefinitions, +// which attributes them to the channels involved. func (b *ChannelDefinitionsBudget) Err() error { var err error if b.streamEntries > MaxTotalStreamEntries { @@ -66,24 +125,65 @@ func (b *ChannelDefinitionsBudget) Err() error { if b.optsBytes > MaxTotalOptsBytes { err = errors.Join(err, fmt.Errorf("too many opts bytes across all channels, got: %d/%d", b.optsBytes, MaxTotalOptsBytes)) } + if b.calculatedStreams > MaxTotalCalculatedStreams { + err = errors.Join(err, fmt.Errorf("too many calculated streams across all channels, got: %d/%d", b.calculatedStreams, MaxTotalCalculatedStreams)) + } + if b.twapCalls > MaxTotalTWAPCalls { + err = errors.Join(err, fmt.Errorf("too many TWAP calls across all channels, got: %d/%d", b.twapCalls, MaxTotalTWAPCalls)) + } return err } -func (b *ChannelDefinitionsBudget) add(cd llotypes.ChannelDefinition) { - b.streamEntries += len(cd.Streams) - b.optsBytes += len(cd.Opts) +// apply adds cd to the measured set with a delta of 1, and removes it with -1. +// Tombstones count towards the persisted sizes only: nothing reads their +// streams or evaluates their opts. +func (b *ChannelDefinitionsBudget) apply(channelID llotypes.ChannelID, cd llotypes.ChannelDefinition, delta int) { + b.streamEntries += delta * len(cd.Streams) + b.optsBytes += delta * len(cd.Opts) for _, pair := range aggregatedPairsOf(cd) { - b.aggregatedPairs[pair]++ + count(b.aggregatedPairs, pair, delta) + } + if cd.Tombstone { + return + } + for _, strm := range cd.Streams { + count(b.streamIDs, strm.StreamID, delta) + // Definitions written by older code may list their calculated streams + // inline. Those are declared by the opts, not observed. + if strm.Aggregator != llotypes.AggregatorCalculated { + b.own(b.observed, strm.StreamID, delta) + } + } + facts := b.factsOf(channelID, cd) + b.twapCalls += delta * facts.twapCalls + b.calculatedStreams += delta * len(facts.calculatedIDs) + for _, streamID := range facts.calculatedIDs { + b.own(b.calculated, streamID, delta) } } -func (b *ChannelDefinitionsBudget) remove(cd llotypes.ChannelDefinition) { - b.streamEntries -= len(cd.Streams) - b.optsBytes -= len(cd.Opts) - for _, pair := range aggregatedPairsOf(cd) { - if b.aggregatedPairs[pair]--; b.aggregatedPairs[pair] <= 0 { - delete(b.aggregatedPairs, pair) - } +// own counts one owner of streamID in owners, keeping clashes current. +func (b *ChannelDefinitionsBudget) own(owners map[llotypes.StreamID]int, streamID llotypes.StreamID, delta int) { + b.clashes -= b.excessClaims(streamID) + count(owners, streamID, delta) + b.clashes += b.excessClaims(streamID) +} + +func (b *ChannelDefinitionsBudget) excessClaims(streamID llotypes.StreamID) int { + declared := b.calculated[streamID] + if declared == 0 { + return 0 + } + if b.observed[streamID] > 0 { + return declared + } + return declared - 1 +} + +// count adds delta to m[k], deleting the key once nothing holds it. +func count[K comparable](m map[K]int, k K, delta int) { + if m[k] += delta; m[k] <= 0 { + delete(m, k) } } diff --git a/llo/protocol/channel_definitions_budget_test.go b/llo/protocol/channel_definitions_budget_test.go index acb0c49..aa61313 100644 --- a/llo/protocol/channel_definitions_budget_test.go +++ b/llo/protocol/channel_definitions_budget_test.go @@ -1,6 +1,7 @@ package protocol import ( + "fmt" "strings" "testing" @@ -29,47 +30,47 @@ func Test_ChannelDefinitionsBudget(t *testing.T) { t.Run("refuses a change that grows the set past a budget", func(t *testing.T) { defs := atOptsLimit() - b := NewChannelDefinitionsBudget(defs) + b := NewChannelDefinitionsBudget(nil, defs, nil) require.NoError(t, b.Err()) - require.False(t, b.Replace(nil, channel(1, 1))) + require.False(t, b.Replace(1000, nil, channel(1, 1))) prev := defs[1] - require.False(t, b.Replace(&prev, channel(MaxChannelOptsBytes+1, 1))) + require.False(t, b.Replace(1, &prev, channel(MaxChannelOptsBytes+1, 1))) // A refused change leaves the totals as they were. require.NoError(t, b.Err()) // A change that stays within the budget is accepted. - require.True(t, b.Replace(nil, channel(0, 1))) - require.True(t, b.Replace(&prev, channel(MaxChannelOptsBytes, 2))) + require.True(t, b.Replace(1000, nil, channel(0, 1))) + require.True(t, b.Replace(1, &prev, channel(MaxChannelOptsBytes, 2))) require.NoError(t, b.Err()) }) t.Run("lets a set already over budget stay or shrink", func(t *testing.T) { defs := atOptsLimit() defs[1000] = channel(MaxChannelOptsBytes, 1) - b := NewChannelDefinitionsBudget(defs) + b := NewChannelDefinitionsBudget(nil, defs, nil) require.ErrorContains(t, b.Err(), "too many opts bytes across all channels") prev := defs[1000] - require.False(t, b.Replace(&prev, channel(MaxChannelOptsBytes+1, 1))) + require.False(t, b.Replace(1000, &prev, channel(MaxChannelOptsBytes+1, 1))) replaced := channel(MaxChannelOptsBytes, 2) - require.True(t, b.Replace(&prev, replaced)) + require.True(t, b.Replace(1000, &prev, replaced)) prev = replaced trimmed := channel(0, 2) trimmed.Tombstone = true - require.True(t, b.Replace(&prev, trimmed)) + require.True(t, b.Replace(1000, &prev, trimmed)) require.NoError(t, b.Err()) }) t.Run("a pair is released only when no channel names it", func(t *testing.T) { shared := channel(0, 1) - b := NewChannelDefinitionsBudget(llotypes.ChannelDefinitions{1: shared, 2: shared}) + b := NewChannelDefinitionsBudget(nil, llotypes.ChannelDefinitions{1: shared, 2: shared}, nil) require.Len(t, b.aggregatedPairs, 1) - require.True(t, b.Replace(&shared, channel(0, 2))) + require.True(t, b.Replace(1, &shared, channel(0, 2))) require.Len(t, b.aggregatedPairs, 2) - require.True(t, b.Replace(&shared, channel(0, 2))) + require.True(t, b.Replace(2, &shared, channel(0, 2))) require.Len(t, b.aggregatedPairs, 1) }) @@ -80,9 +81,86 @@ func Test_ChannelDefinitionsBudget(t *testing.T) { backfill.ReportFormat = llotypes.ReportFormatHistoryBackfill calculated := llotypes.ChannelDefinition{Streams: []llotypes.Stream{{StreamID: 3, Aggregator: llotypes.AggregatorCalculated}}} - b := NewChannelDefinitionsBudget(llotypes.ChannelDefinitions{1: tombstone, 2: backfill, 3: calculated}) + b := NewChannelDefinitionsBudget(nil, llotypes.ChannelDefinitions{1: tombstone, 2: backfill, 3: calculated}, nil) require.Empty(t, b.aggregatedPairs) // Their stream entries are persisted all the same. require.Equal(t, 3, b.streamEntries) }) + + // expression declares one calculated stream per ID in its opts. + expression := func(observed llotypes.StreamID, calculatedIDs ...llotypes.StreamID) llotypes.ChannelDefinition { + abi := make([]string, 0, len(calculatedIDs)) + for _, id := range calculatedIDs { + abi = append(abi, fmt.Sprintf(`{"expressionStreamID":%d}`, id)) + } + return llotypes.ChannelDefinition{ + ReportFormat: llotypes.ReportFormatEVMABIEncodeUnpackedExpr, + Streams: []llotypes.Stream{{StreamID: observed, Aggregator: llotypes.AggregatorMedian}}, + Opts: llotypes.ChannelOpts(fmt.Sprintf(`{"abi":[%s]}`, strings.Join(abi, ","))), + } + } + + t.Run("refuses calculated streams past the budget", func(t *testing.T) { + ids := make([]llotypes.StreamID, MaxTotalCalculatedStreams) + for i := range ids { + ids[i] = llotypes.StreamID(1_000_000 + i) + } + b := NewChannelDefinitionsBudget(nil, llotypes.ChannelDefinitions{1: expression(1, ids...)}, nil) + require.NoError(t, b.Err()) + + require.False(t, b.Replace(2, nil, expression(2, 2_000_000))) + require.True(t, b.Replace(2, nil, expression(2))) + require.NoError(t, b.Err()) + }) + + t.Run("refuses TWAP calls past the budget", func(t *testing.T) { + codecs := map[llotypes.ReportFormat]ReportCodec{0: twapCodec{}} + defs := llotypes.ChannelDefinitions{} + for c := range MaxTotalTWAPCalls / MaxTWAPCallsPerExpression { + defs[llotypes.ChannelID(c+1)] = channel(0, 1) + } + b := NewChannelDefinitionsBudget(codecs, defs, nil) + require.NoError(t, b.Err()) + + require.False(t, b.Replace(1000, nil, channel(0, 1))) + tombstone := channel(0, 1) + tombstone.Tombstone = true + require.True(t, b.Replace(1000, nil, tombstone)) + require.NoError(t, b.Err()) + }) + + t.Run("refuses unique stream IDs past the budget", func(t *testing.T) { + defs := llotypes.ChannelDefinitions{} + for c := range MaxObservationStreamValuesLength { + defs[llotypes.ChannelID(c+1)] = channel(0, llotypes.StreamID(c+1)) + } + b := NewChannelDefinitionsBudget(nil, defs, nil) + + require.False(t, b.Replace(100_000, nil, channel(0, 100_000))) + // A stream already observed adds no ID. + require.True(t, b.Replace(100_000, nil, channel(0, 1))) + }) + + t.Run("refuses a calculated stream ID that another owner claims", func(t *testing.T) { + b := NewChannelDefinitionsBudget(nil, llotypes.ChannelDefinitions{1: expression(1, 100)}, nil) + + require.False(t, b.Replace(2, nil, expression(2, 100))) + require.False(t, b.Replace(2, nil, channel(0, 100))) + require.True(t, b.Replace(2, nil, expression(2, 101))) + + // Moving the declaration within one change is not a clash. + prev := expression(1, 100) + require.True(t, b.Replace(1, &prev, expression(1, 102))) + require.True(t, b.Replace(3, nil, channel(0, 100))) + }) + + t.Run("a set that already clashes may keep its clash", func(t *testing.T) { + b := NewChannelDefinitionsBudget(nil, llotypes.ChannelDefinitions{1: expression(1, 100), 2: channel(0, 100)}, nil) + require.Equal(t, 1, b.clashes) + + prev := channel(0, 100) + require.True(t, b.Replace(2, &prev, channel(1, 100))) + require.False(t, b.Replace(3, nil, expression(3, 100))) + require.Equal(t, 1, b.clashes) + }) } diff --git a/llo/protocol/limits.go b/llo/protocol/limits.go index 8a85c53..bdfc92c 100644 --- a/llo/protocol/limits.go +++ b/llo/protocol/limits.go @@ -234,8 +234,9 @@ const ( // logarithm and one exponential per interior gap, and the gap count is not // bounded by the acceptance rule. The worst case is a maximum-depth history // with a one-second gap between every pair of records. The realistic shape, - // costs tens of microseconds per call. Admission-only, like the other - // whole-set budgets. + // costs tens of microseconds per call. Like the other whole-set budgets, + // admission and the channel vote merge enforce it, and a committed set over + // it still verifies. MaxTotalTWAPCalls = 64 // MaxHistoryRecordBytes is the maximum serialized size of one history // record, enforced on append (StreamHistory.Append) and on chunk decode. It From 6d556eaa2bcdb2816ee8e53266728b520f087ddc Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Fri, 16 Oct 2026 18:18:45 +0100 Subject: [PATCH 090/103] llo/v31: document that a history reset drops the round's appended record --- llo/dev/v31/history.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/llo/dev/v31/history.go b/llo/dev/v31/history.go index dc14739..7be7858 100644 --- a/llo/dev/v31/history.go +++ b/llo/dev/v31/history.go @@ -162,6 +162,10 @@ func (s *historyStore) window(k histKey) (*protocol.RingWindow, error) { // the header is the only thing that says which chunks exist, so when it cannot // be trusted there is nothing else to consult. The admitted depth is reapplied // so the pair starts warming again immediately. +// +// A reset found by a read after this round's Append also drops that appended +// record. It is kept that way on purpose: the window is discarded either way, +// and the lost record costs one more round of warmup. func (s *historyStore) reset(k histKey, cause error) *protocol.RingWindow { s.lggr.Errorw("Discarding corrupt stream history; re-warming from empty", "err", cause, "streamID", k.streamID, "aggregator", k.aggregator) From 4f8e800b1a19d8ad9d753cf38c91b86cf7488b5c Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Fri, 16 Oct 2026 19:01:29 +0100 Subject: [PATCH 091/103] llo/v31: read only memoized history windows in insufficient history telemetry --- llo/dev/v31/history.go | 7 +++++++ llo/dev/v31/history_metrics.go | 22 ++++++++++++---------- llo/dev/v31/history_metrics_test.go | 20 ++++++++++++++++++++ 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/llo/dev/v31/history.go b/llo/dev/v31/history.go index 7be7858..9773c97 100644 --- a/llo/dev/v31/history.go +++ b/llo/dev/v31/history.go @@ -223,6 +223,13 @@ func (s *historyStore) Load(sid llotypes.StreamID, agg llotypes.Aggregator) (*pr return s.window(histKey{streamID: sid, aggregator: agg}) } +// cached returns a pair's working copy only if this round already loaded it, +// reading nothing. +func (s *historyStore) cached(sid llotypes.StreamID, agg llotypes.Aggregator) (*protocol.RingWindow, bool) { + w, ok := s.windows[histKey{streamID: sid, aggregator: agg}] + return w, ok +} + // Series implements calculated.HistoryReader, projecting the newest count // records onto one field. // diff --git a/llo/dev/v31/history_metrics.go b/llo/dev/v31/history_metrics.go index 7ca612e..d427ba8 100644 --- a/llo/dev/v31/history_metrics.go +++ b/llo/dev/v31/history_metrics.go @@ -217,16 +217,18 @@ func channelHistorySatisfied(expressions []string, aggByStream map[llotypes.Stre if !ok { continue } - // Usually a hit in the memoized store: evaluation already loaded - // the pairs of every channel that reached history binding. A - // channel skipped before binding, such as one missing an input - // value, can load a header here for the first time. That read is - // round local and feeds only this metric. Corruption it finds is - // logged by reset but not counted in historyCorruptMetric, which - // is captured before this, and the reset is not flushed, so the - // next round that loads the pair before flushing counts it. - h, err := history.Load(ref.StreamID, aggregator) - if err != nil || uint32(h.Len()) < ref.Count { + // Read only what evaluation loaded, so this runs after the flush + // without touching state. Evaluation walks the same references in + // the same order and stops at the same first unsatisfied one, so + // every channel that reached history binding finds its pairs here. + // A pair it never reached belongs to a channel skipped for another + // reason, such as a missing input value, which is not a history + // condition. + h, ok := history.cached(ref.StreamID, aggregator) + if !ok { + return true + } + if uint32(h.Len()) < ref.Count { return false } } diff --git a/llo/dev/v31/history_metrics_test.go b/llo/dev/v31/history_metrics_test.go index 2d943e4..b4fea12 100644 --- a/llo/dev/v31/history_metrics_test.go +++ b/llo/dev/v31/history_metrics_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" "github.com/smartcontractkit/chainlink-common/pkg/logger" + llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" ) // TestCaptureHistoryTelemetry_DeletesReclaimedPairs pins the per-pair gauge @@ -53,3 +54,22 @@ func TestCaptureHistoryTelemetry_DeletesReclaimedPairs(t *testing.T) { assert.False(t, historyBytesMetric.DeleteLabelValues(donID, streamID, aggregator), "bytes gauge must not outlive the pair") } + +// TestChannelHistorySatisfied_ReadsOnlyLoadedPairs pins that the insufficient +// history count, captured after the flush, reads no state. A pair evaluation +// loaded is judged by its depth. A pair it never reached belongs to a channel +// skipped for another reason and is neither read nor counted. +func TestChannelHistorySatisfied_ReadsOnlyLoadedPairs(t *testing.T) { + kv := newCountingKV() + s := newTestHistoryStore(t, kv) + aggByStream := map[llotypes.StreamID]llotypes.Aggregator{1: testAggMedian, 2: testAggMedian} + + assert.True(t, channelHistorySatisfied([]string{"Avg(History(s2, 4))"}, aggByStream, s)) + assert.Zero(t, historyKeyReads(kv, 2, testAggMedian), "an unloaded pair must not be read") + + _, err := s.Load(1, testAggMedian) + require.NoError(t, err) + reads := historyKeyReads(kv, 1, testAggMedian) + assert.False(t, channelHistorySatisfied([]string{"Avg(History(s1, 4))"}, aggByStream, s)) + assert.Equal(t, reads, historyKeyReads(kv, 1, testAggMedian), "a loaded pair must not be read again") +} From 50bdfbb37ba839a432dc2682dba1cb195d95ded9 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Fri, 16 Oct 2026 19:29:08 +0100 Subject: [PATCH 092/103] llo/v31: log carry-forward aggregates discarded when reading hot state --- llo/dev/v31/decode_fuzz_test.go | 3 ++- llo/dev/v31/golden_test.go | 2 +- llo/dev/v31/history_flow_test.go | 3 ++- llo/dev/v31/kv.go | 17 ++++++++++---- llo/dev/v31/kv_test.go | 39 +++++++++++++++++++++++++++++--- llo/dev/v31/limits_test.go | 2 +- llo/dev/v31/plugin_test.go | 4 ++-- llo/dev/v31/statetransition.go | 2 +- 8 files changed, 57 insertions(+), 15 deletions(-) diff --git a/llo/dev/v31/decode_fuzz_test.go b/llo/dev/v31/decode_fuzz_test.go index bc4b0bd..a930743 100644 --- a/llo/dev/v31/decode_fuzz_test.go +++ b/llo/dev/v31/decode_fuzz_test.go @@ -6,6 +6,7 @@ import ( "github.com/shopspring/decimal" + "github.com/smartcontractkit/chainlink-common/pkg/logger" llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" protocol "github.com/smartcontractkit/chainlink-data-streams/llo/protocol" @@ -178,7 +179,7 @@ func FuzzLoadKVState(f *testing.F) { if _, err := loadColdKVState(kv, protocol.NewChannelCache()); err != nil { return } - s, err := loadKVState(kv, nil) + s, err := loadKVState(kv, nil, logger.Nop()) if err != nil { return } diff --git a/llo/dev/v31/golden_test.go b/llo/dev/v31/golden_test.go index 3431c75..5fc310e 100644 --- a/llo/dev/v31/golden_test.go +++ b/llo/dev/v31/golden_test.go @@ -170,7 +170,7 @@ func Test_Golden_KVRecords(t *testing.T) { // The records must load back into the same projection the writers were // handed. - s, err := loadKVState(kv, nil) + s, err := loadKVState(kv, nil, logger.Test(t)) require.NoError(t, err) require.Equal(t, p.LifeCycleStage, s.lifeCycleStage) require.Equal(t, p.ChannelStateSeqNr, s.channelStateSeqNr) diff --git a/llo/dev/v31/history_flow_test.go b/llo/dev/v31/history_flow_test.go index 04eb139..133d21a 100644 --- a/llo/dev/v31/history_flow_test.go +++ b/llo/dev/v31/history_flow_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/smartcontractkit/chainlink-common/pkg/logger" llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" @@ -282,7 +283,7 @@ func storedHotState(t *testing.T, kv *memKV) *kvState { reportedLastRound: map[llotypes.ChannelID]bool{}, carryForward: map[llotypes.StreamID]map[llotypes.Aggregator]*protocol.TimestampedStreamValue{}, } - require.NoError(t, readHotState(kv, s)) + require.NoError(t, readHotState(kv, s, logger.Test(t))) return s } diff --git a/llo/dev/v31/kv.go b/llo/dev/v31/kv.go index 69851f2..a233cc5 100644 --- a/llo/dev/v31/kv.go +++ b/llo/dev/v31/kv.go @@ -138,12 +138,12 @@ type kvState struct { // // cache may be nil, in which case the channel definitions are always re-read // and decoded. -func loadKVState(r ocr3_1types.KeyValueStateReader, cache *protocol.ChannelCache) (*kvState, error) { +func loadKVState(r ocr3_1types.KeyValueStateReader, cache *protocol.ChannelCache, lggr logger.Logger) (*kvState, error) { s, err := loadColdKVState(r, cache) if err != nil { return nil, err } - if err := readHotState(r, s); err != nil { + if err := readHotState(r, s, lggr); err != nil { return nil, err } return s, nil @@ -292,7 +292,7 @@ func readChannelState(r ocr3_1types.KeyValueStateReader) (llotypes.ChannelDefini // capped, for the same reasons as in readChannelState: writeHotState truncates // aggregates to MaxPersistedAggregates, channels are capped by verification, // and the record is bounded in bytes. -func readHotState(r ocr3_1types.KeyValueStateReader, s *kvState) error { +func readHotState(r ocr3_1types.KeyValueStateReader, s *kvState, lggr logger.Logger) error { b, err := r.Read(keyHotState) if err != nil { return fmt.Errorf("read hot state: %w", err) @@ -316,12 +316,19 @@ func readHotState(r ocr3_1types.KeyValueStateReader, s *kvState) error { if err != nil { return fmt.Errorf("unmarshal carry-forward aggregate for stream %d: %w", sa.StreamID, err) } + agg := llotypes.Aggregator(sa.Aggregator) tsv, ok := sv.(*protocol.TimestampedStreamValue) if !ok { - // Only timestamped values are persisted; ignore anything else. + // writeHotState only persists timestamped values. Skip anything else + // rather than fail: the record is replicated, so an error would halt + // every node on the same state. + lggr.Errorw("Discarding non-timestamped carry-forward aggregate from hot state", + "streamID", sa.StreamID, + "aggregator", agg, + "type", fmt.Sprintf("%T", sv), + ) continue } - agg := llotypes.Aggregator(sa.Aggregator) if s.carryForward[sa.StreamID] == nil { s.carryForward[sa.StreamID] = map[llotypes.Aggregator]*protocol.TimestampedStreamValue{} } diff --git a/llo/dev/v31/kv_test.go b/llo/dev/v31/kv_test.go index 57fb305..0558040 100644 --- a/llo/dev/v31/kv_test.go +++ b/llo/dev/v31/kv_test.go @@ -6,6 +6,8 @@ import ( "github.com/shopspring/decimal" "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" + "google.golang.org/protobuf/proto" "github.com/smartcontractkit/chainlink-common/pkg/logger" llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" @@ -62,7 +64,7 @@ func Test_ChannelCache_StaleSeqNrForcesReload(t *testing.T) { require.NoError(t, writeHotState(kv, 0, nil, nil, nil, logger.Test(t))) cache := protocol.NewChannelCache() - s, err := loadKVState(kv, cache) + s, err := loadKVState(kv, cache, logger.Test(t)) require.NoError(t, err) require.Equal(t, uint64(5), s.channelStateSeqNr) require.Len(t, s.channelDefinitions, 1) @@ -71,7 +73,7 @@ func Test_ChannelCache_StaleSeqNrForcesReload(t *testing.T) { // the cache: the comparison is equality, not "cached is newer". require.NoError(t, writeChannelState(kv, 4, llotypes.ChannelDefinitions{})) before := kv.readCount(keyChannelState) - s, err = loadKVState(kv, cache) + s, err = loadKVState(kv, cache, logger.Test(t)) require.NoError(t, err) require.Greater(t, kv.readCount(keyChannelState), before) require.Empty(t, s.channelDefinitions) @@ -160,7 +162,7 @@ func Test_KVRecords_DeterministicAndRoundTrip(t *testing.T) { require.NoError(t, writeHotState(kv, 1_234, validAfter, reportable, carry, logger.Test(t))) require.Equal(t, uint64(9), binary.BigEndian.Uint64(kv.m[string(keyChannelSeqNr)])) - s, err := loadKVState(kv, nil) + s, err := loadKVState(kv, nil, logger.Test(t)) require.NoError(t, err) require.Equal(t, defs, s.channelDefinitions) require.Equal(t, uint64(1_234), s.observationTimestampNs) @@ -172,6 +174,37 @@ func Test_KVRecords_DeterministicAndRoundTrip(t *testing.T) { require.Equal(t, uint64(2), s.carryForward[200][llotypes.AggregatorMode].ObservedAtNanoseconds) } +func Test_HotState_DiscardsNonTimestampedCarryForward(t *testing.T) { + // writeHotState only persists timestamped values, so hand-build a record + // that holds a plain decimal next to a timestamped one. + plain, err := protocol.ToDecimal(decimal.NewFromInt(7)).MarshalBinary() + require.NoError(t, err) + tsv := &protocol.TimestampedStreamValue{ObservedAtNanoseconds: 42, StreamValue: protocol.ToDecimal(decimal.NewFromInt(8))} + timestamped, err := tsv.MarshalBinary() + require.NoError(t, err) + b, err := proto.Marshal(&protocol.LLOHotStateProto{ + StreamAggregates: []*protocol.LLOStreamAggregate{ + {StreamID: 100, Aggregator: uint32(llotypes.AggregatorMedian), StreamValue: &protocol.LLOStreamValue{Type: protocol.LLOStreamValue_Decimal, Value: plain}}, + {StreamID: 200, Aggregator: uint32(llotypes.AggregatorMedian), StreamValue: &protocol.LLOStreamValue{Type: tsv.Type(), Value: timestamped}}, + }, + }) + require.NoError(t, err) + kv := newMemKV() + require.NoError(t, kv.Write(keyHotState, b)) + + lggr, observed := logger.TestObserved(t, zapcore.ErrorLevel) + s := &kvState{ + validAfterNanoseconds: map[llotypes.ChannelID]uint64{}, + reportedLastRound: map[llotypes.ChannelID]bool{}, + carryForward: map[llotypes.StreamID]map[llotypes.Aggregator]*protocol.TimestampedStreamValue{}, + } + require.NoError(t, readHotState(kv, s, lggr)) + + require.Len(t, s.carryForward, 1) + require.Equal(t, uint64(42), s.carryForward[200][llotypes.AggregatorMedian].ObservedAtNanoseconds) + require.Equal(t, 1, observed.FilterMessage("Discarding non-timestamped carry-forward aggregate from hot state").Len()) +} + // Test_DeferredDefinitions_TakeEffectNextRound covers the core rule: an agreed // definition change is persisted immediately but is not in effect until the // following round, so the round that agreed it still aggregates, reports and diff --git a/llo/dev/v31/limits_test.go b/llo/dev/v31/limits_test.go index 5ec5ad6..b89e18c 100644 --- a/llo/dev/v31/limits_test.go +++ b/llo/dev/v31/limits_test.go @@ -151,7 +151,7 @@ func TestLimits_HotStateTruncatesAboveCap(t *testing.T) { reportedLastRound: map[llotypes.ChannelID]bool{}, carryForward: map[llotypes.StreamID]map[llotypes.Aggregator]*protocol.TimestampedStreamValue{}, } - require.NoError(t, readHotState(kv, state)) + require.NoError(t, readHotState(kv, state, logger.Test(t))) require.Len(t, state.carryForward, protocol.MaxPersistedAggregates) // The kept pairs are the lowest ones: truncation is after the sort, so the diff --git a/llo/dev/v31/plugin_test.go b/llo/dev/v31/plugin_test.go index 53572fb..9195586 100644 --- a/llo/dev/v31/plugin_test.go +++ b/llo/dev/v31/plugin_test.go @@ -79,7 +79,7 @@ func kvHotState(t *testing.T, kv *memKV) *kvState { reportedLastRound: map[llotypes.ChannelID]bool{}, carryForward: map[llotypes.StreamID]map[llotypes.Aggregator]*protocol.TimestampedStreamValue{}, } - require.NoError(t, readHotState(kv, s)) + require.NoError(t, readHotState(kv, s, logger.Test(t))) return s } @@ -2283,6 +2283,6 @@ func readHotStateForTest(t *testing.T, kv *memKV) *kvState { reportedLastRound: map[llotypes.ChannelID]bool{}, carryForward: map[llotypes.StreamID]map[llotypes.Aggregator]*protocol.TimestampedStreamValue{}, } - require.NoError(t, readHotState(kv, s)) + require.NoError(t, readHotState(kv, s, logger.Test(t))) return s } diff --git a/llo/dev/v31/statetransition.go b/llo/dev/v31/statetransition.go index ded9c2c..59f311c 100644 --- a/llo/dev/v31/statetransition.go +++ b/llo/dev/v31/statetransition.go @@ -70,7 +70,7 @@ func (p *Plugin) StateTransition(ctx context.Context, seqNr uint64, _ ocrtypes.A return encodePrecursor(precursor{LifeCycleStage: stage}) } - prev, err := loadKVState(kvRW, p.ChannelCache) + prev, err := loadKVState(kvRW, p.ChannelCache, p.Logger) if err != nil { return nil, fmt.Errorf("failed to load KV state: %w", err) } From 6bf2af913afb678706dc0de63e865ed7a2e85ab0 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Fri, 16 Oct 2026 21:35:54 +0100 Subject: [PATCH 093/103] llo/calculated: document the v30 trailing stream append check and its collision case --- llo/protocol/calculated/calculated.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/llo/protocol/calculated/calculated.go b/llo/protocol/calculated/calculated.go index cfd5108..3f9b453 100644 --- a/llo/protocol/calculated/calculated.go +++ b/llo/protocol/calculated/calculated.go @@ -880,6 +880,18 @@ func applyCalculatedStreams(lggr logger.Logger, channelDefinitions llotypes.Chan if appendToDefinitions { // channel definitions are inherited from the previous outcome, // so we only update the channel definition streams if we haven't done it before + // + // Comparing only the trailing stream is enough, an opts change + // arrives with a channel update vote, which replaces the whole + // definition, so the appended streams never outlive the ABI they + // came from. The one false positive is a definition whose last + // observed stream shares its ID with the last calculated stream. + // The append is then skipped and the channel reports observed + // values in the expression payload, while evaluation logs the + // clash every round. Admission rejects that clash, so only a + // definition committed before the check can reach it. Kept as is: + // changing the check changes v30 outcomes, and v31 derives its + // streams through protocol.EffectiveStreams instead. cd := work.cd if cd.Streams[len(cd.Streams)-1].StreamID != work.opts.ABI[len(work.opts.ABI)-1].ExpressionStreamID { for _, abi := range work.opts.ABI { From 80eda382c1414e2783bafa48e19870c3e491f1bc Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Mon, 19 Oct 2026 12:27:52 +0100 Subject: [PATCH 094/103] llo/retirement: add NullRetirementReportCache for testing --- .../null_retirement_report_cache.go | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 llo/retirement/null_retirement_report_cache.go diff --git a/llo/retirement/null_retirement_report_cache.go b/llo/retirement/null_retirement_report_cache.go new file mode 100644 index 0000000..d562d89 --- /dev/null +++ b/llo/retirement/null_retirement_report_cache.go @@ -0,0 +1,25 @@ +package retirement + +import ( + "context" + + "github.com/smartcontractkit/libocr/offchainreporting2plus/types" + ocr2types "github.com/smartcontractkit/libocr/offchainreporting2plus/types" + + protocol "github.com/smartcontractkit/chainlink-data-streams/llo/protocol" +) + +type NullRetirementReportCache struct{} + +func (n *NullRetirementReportCache) StoreAttestedRetirementReport(ctx context.Context, cd ocr2types.ConfigDigest, retirementReport []byte, sigs []types.AttributedOnchainSignature) error { + return nil +} +func (n *NullRetirementReportCache) StoreConfig(ctx context.Context, cd ocr2types.ConfigDigest, signers [][]byte, f uint8) error { + return nil +} +func (n *NullRetirementReportCache) AttestedRetirementReport(predecessorConfigDigest ocr2types.ConfigDigest) ([]byte, error) { + return nil, nil +} +func (n *NullRetirementReportCache) CheckAttestedRetirementReport(predecessorConfigDigest ocr2types.ConfigDigest, attestedRetirementReport []byte) (protocol.RetirementReport, error) { + return protocol.RetirementReport{}, nil +} From a31ebada45f8d5b186a926fc06b9225ffdef11b8 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Tue, 20 Oct 2026 09:06:05 +0100 Subject: [PATCH 095/103] llo: gate reportability on encodable value ranges and carry the decision in the precursor - Add protocol.SolidityInt, ABIElement/ABISpec, ABIPayload and CheckEncodableValues so the reportability check and the EVM codecs share one range rule and one payload layout. - Make a channel with a value outside its encoded integer range unreportable, so the watermark holds until the value is back in range. Skip backfill rows that can never be encoded. - Decide reportability once in StateTransition and carry it in the precursor as reportableChannelIDs, replacing supportByFormat. Reports emits exactly those channels. Pass codec support as a parameter instead of a precursor field. - Carry only the aggregates the reportable channels read in the precursor. - Reject out of range int192 prices and uint192 fees at encode, since abi.Pack wraps them silently. - Add VerifyForAdmission checks for non integer ABI types, expirationWindow above one year, Streamlined layouts over the uint16 length prefix and a zero PremiumLegacy multiplier. - Move ubig to llo/ubig so protocol can use it. --- llo/dev/v31/backfill.go | 9 +- llo/dev/v31/decode_fuzz_test.go | 3 - llo/dev/v31/doc.go | 9 + llo/dev/v31/flow_test.go | 6 +- llo/dev/v31/golden_test.go | 5 +- llo/dev/v31/limits_test.go | 10 +- llo/dev/v31/plugin_test.go | 401 ++++++++++++------ llo/dev/v31/precursor.go | 94 ++-- llo/dev/v31/reports.go | 73 ++-- llo/dev/v31/statetransition.go | 22 +- llo/dev/v31/telemetry_test.go | 2 +- .../v31/testdata/golden/precursor_full.bin | Bin 193 -> 185 bytes llo/protocol/abi.go | 165 +++++++ llo/protocol/abi_test.go | 97 +++++ llo/protocol/encodable.go | 109 +++++ llo/protocol/encodable_test.go | 107 +++++ llo/protocol/plugin_codecs.pb.go | 129 ++---- llo/protocol/plugin_codecs.proto | 19 +- llo/reportcodec/evm/encodable_test.go | 212 +++++++++ llo/reportcodec/evm/report_codec_common.go | 116 ++--- .../evm/report_codec_common_test.go | 2 +- .../report_codec_evm_abi_encode_unpacked.go | 44 +- ...port_codec_evm_abi_encode_unpacked_expr.go | 28 +- ...codec_evm_abi_encode_unpacked_expr_test.go | 2 +- ...port_codec_evm_abi_encode_unpacked_test.go | 2 +- .../evm/report_codec_evm_streamlined.go | 51 ++- .../evm/report_codec_premium_legacy.go | 27 +- llo/reportcodec/evm/v3/report_codec.go | 22 + llo/{reportcodec/evm/utils => ubig}/big.go | 2 +- 29 files changed, 1345 insertions(+), 423 deletions(-) create mode 100644 llo/protocol/abi.go create mode 100644 llo/protocol/abi_test.go create mode 100644 llo/protocol/encodable.go create mode 100644 llo/protocol/encodable_test.go create mode 100644 llo/reportcodec/evm/encodable_test.go rename llo/{reportcodec/evm/utils => ubig}/big.go (99%) diff --git a/llo/dev/v31/backfill.go b/llo/dev/v31/backfill.go index 9eecc77..973d53a 100644 --- a/llo/dev/v31/backfill.go +++ b/llo/dev/v31/backfill.go @@ -77,7 +77,14 @@ func selectBackfillCandidate(defs llotypes.ChannelDefinitions, validAfter map[ll // it here keeps selection and emission on one path. A row that cannot be // built never will be: pass over it rather than stall the backfill on it. for _, c := range candidates { - if _, err := protocol.BuildBackfillStreamValues(target, o.Observations[c.raw]); err != nil { + values, err := protocol.BuildBackfillStreamValues(target, o.Observations[c.raw]) + if err != nil { + skipped++ + continue + } + // Backfill rows are fixed in the opts, so a value outside its encoded + // range never comes back into it. + if protocol.CheckEncodableValues(optsCache, target, o.TargetChannelID, values) != nil { skipped++ continue } diff --git a/llo/dev/v31/decode_fuzz_test.go b/llo/dev/v31/decode_fuzz_test.go index a930743..2232bce 100644 --- a/llo/dev/v31/decode_fuzz_test.go +++ b/llo/dev/v31/decode_fuzz_test.go @@ -119,9 +119,6 @@ func FuzzDecodePrecursor(f *testing.F) { if err != nil { return } - if len(p.SupportByFormat) > protocol.MaxObservationSupportedReportFormatsLength { - t.Fatalf("decoded %d report format support entries, max %d", len(p.SupportByFormat), protocol.MaxObservationSupportedReportFormatsLength) - } // Re-encoding a decoded precursor must be a fixed point: encode is // deterministic, so a value that survives decode has exactly one // encoding, and every oracle must agree on it. diff --git a/llo/dev/v31/doc.go b/llo/dev/v31/doc.go index c8b43ee..3e764ab 100644 --- a/llo/dev/v31/doc.go +++ b/llo/dev/v31/doc.go @@ -132,6 +132,15 @@ // declared. DisableNilStreamValues is deprecated: v31 ignores it and behaves as // if it were always set. // +// A channel with a value outside the integer range its report format encodes it +// in is unreportable too (protocol.CheckEncodableValues). Such a value depends +// on the data, so the watermark holds and the next report covers the round. +// +// StateTransition decides reportability once per round and carries the decision +// in the precursor (ReportableChannelIDs), together with only the aggregates +// those channels report. Reports emits exactly those channels, so emission and +// the validAfter advance read the same decision. +// // Cross-round timestamped-aggregate carry-forward (in the r/agg record, // newer-wins monotonicity) and best-effort outcome/report telemetry are also // implemented. diff --git a/llo/dev/v31/flow_test.go b/llo/dev/v31/flow_test.go index 6784afb..f6f58f9 100644 --- a/llo/dev/v31/flow_test.go +++ b/llo/dev/v31/flow_test.go @@ -1170,8 +1170,8 @@ func Test_IsReportable_MinReportIntervalDoesNotOverflow(t *testing.T) { StreamAggregates: protocol.StreamAggregates{ 100: {llotypes.AggregatorMedian: protocol.ToDecimal(decimal.NewFromInt(1))}, }, - SupportByFormat: map[llotypes.ReportFormat]int{llotypes.ReportFormatJSON: 4}, } + support := map[llotypes.ReportFormat]int{llotypes.ReportFormatJSON: 4} gen, err := protocol.NewChannelCache().Load(1, func() (llotypes.ChannelDefinitions, error) { return out.ChannelDefinitions, nil }) @@ -1179,12 +1179,12 @@ func Test_IsReportable_MinReportIntervalDoesNotOverflow(t *testing.T) { optsCache := gen.Opts() // One nanosecond past validAfter, so only the interval can hold it back. - require.True(t, out.isReportable(1, 1, 1, optsCache, nil), + require.True(t, out.isReportable(1, 1, 1, support, optsCache, nil), "a one nanosecond interval must not gate a report one nanosecond late") // An interval that overflows the sum must gate, not wrap into passing. for _, interval := range []uint64{math.MaxUint64, math.MaxUint64 - validAfter + 1} { - require.False(t, out.isReportable(1, interval, 1, optsCache, nil), + require.False(t, out.isReportable(1, interval, 1, support, optsCache, nil), "interval %d must gate the report, not wrap", interval) } diff --git a/llo/dev/v31/golden_test.go b/llo/dev/v31/golden_test.go index 5fc310e..9084cd3 100644 --- a/llo/dev/v31/golden_test.go +++ b/llo/dev/v31/golden_test.go @@ -73,10 +73,7 @@ func goldenPrecursor() precursor { llotypes.AggregatorMode: protocol.ToDecimal(decimal.NewFromInt(124)), }, }, - SupportByFormat: map[llotypes.ReportFormat]int{ - llotypes.ReportFormatEVMPremiumLegacy: 3, - llotypes.ReportFormatJSON: 4, - }, + ReportableChannelIDs: []llotypes.ChannelID{1, 3}, } } diff --git a/llo/dev/v31/limits_test.go b/llo/dev/v31/limits_test.go index b89e18c..e2209c6 100644 --- a/llo/dev/v31/limits_test.go +++ b/llo/dev/v31/limits_test.go @@ -2,8 +2,10 @@ package llo import ( "fmt" + "maps" "math" "math/big" + "slices" "strings" "testing" @@ -503,10 +505,8 @@ func TestLimits_PrecursorWorstCaseFitsDeclared(t *testing.T) { } } - support := make(map[llotypes.ReportFormat]int, protocol.MaxOutcomeChannelDefinitionsLength) - for i := range protocol.MaxOutcomeChannelDefinitionsLength { - support[llotypes.ReportFormat(math.MaxUint32-i)] = math.MaxUint32 - } + // Every channel reportable, under the largest IDs. + reportable := slices.Sorted(maps.Keys(defs)) encoded, err := encodePrecursor(precursor{ LifeCycleStage: llotypes.LifeCycleStage(strings.Repeat("x", 32)), @@ -515,7 +515,7 @@ func TestLimits_PrecursorWorstCaseFitsDeclared(t *testing.T) { ValidAfterNanoseconds: validAfter, StreamAggregates: aggregates, ChannelStateSeqNr: math.MaxUint64, - SupportByFormat: support, + ReportableChannelIDs: reportable, }) require.NoError(t, err) require.LessOrEqual(t, len(encoded), ocr3_1types.MaxMaxReportsPlusPrecursorBytes, diff --git a/llo/dev/v31/plugin_test.go b/llo/dev/v31/plugin_test.go index 9195586..e3301e6 100644 --- a/llo/dev/v31/plugin_test.go +++ b/llo/dev/v31/plugin_test.go @@ -393,31 +393,31 @@ func Test_StateTransition_ContributionFloor(t *testing.T) { return aos } - // Three contributions meet the floor, so the stream aggregates. - prec, err := p.StateTransition(ctx, 3, ocrtypes.AttributedQuery{}, valObs(2_000, 3), kv, testBlobs) + // The channel takes its first watermark this round. + _, err = p.StateTransition(ctx, 3, ocrtypes.AttributedQuery{}, valObs(2_000, 3), kv, testBlobs) require.NoError(t, err) - out, err := decodePrecursor(prec) - require.NoError(t, err) - require.Contains(t, out.StreamAggregates, llotypes.StreamID(100)) - // Two do not: no aggregate, so the channel is unreportable rather than - // dropped at encode time. - prec, err = p.StateTransition(ctx, 4, ocrtypes.AttributedQuery{}, valObs(3_000, 2), kv, testBlobs) + // Two contributions do not meet the floor: no aggregate, so the channel is + // unreportable rather than dropped at encode time. + prec, err := p.StateTransition(ctx, 4, ocrtypes.AttributedQuery{}, valObs(3_000, 2), kv, testBlobs) require.NoError(t, err) - out, err = decodePrecursor(prec) + out, err := decodePrecursor(prec) require.NoError(t, err) + require.Empty(t, out.ReportableChannelIDs) require.NotContains(t, out.StreamAggregates, llotypes.StreamID(100)) validAfter := out.ValidAfterNanoseconds[1] reports, err := p.Reports(ctx, 4, prec) require.NoError(t, err) require.Empty(t, reports) - // The watermark holds over the round that emitted nothing, so the next - // report covers it. + // Three contributions meet the floor, so the stream aggregates. The + // watermark held over the round that emitted nothing, so this report + // covers it. prec, err = p.StateTransition(ctx, 5, ocrtypes.AttributedQuery{}, valObs(4_000, 3), kv, testBlobs) require.NoError(t, err) out, err = decodePrecursor(prec) require.NoError(t, err) + require.Contains(t, out.StreamAggregates, llotypes.StreamID(100)) require.Equal(t, validAfter, out.ValidAfterNanoseconds[1]) reports, err = p.Reports(ctx, 5, prec) require.NoError(t, err) @@ -504,14 +504,35 @@ func Test_decodeObservation_RejectsHugeHandleCount(t *testing.T) { } func Test_TimeResolutionOverlap(t *testing.T) { + // Channels carry encodable values, so a not reportable result can only + // come from the overlap check or from opts that do not decode. mkPrec := func(format llotypes.ReportFormat, opts []byte, validAfterNs, obsTsNs uint64) precursor { + one := protocol.ToDecimal(decimal.NewFromInt(1)) + third := llotypes.Stream{StreamID: 3, Aggregator: llotypes.AggregatorMedian} + var thirdValue protocol.StreamValue = one + if format == llotypes.ReportFormatEVMPremiumLegacy { + third.Aggregator = llotypes.AggregatorQuote + thirdValue = &protocol.Quote{Bid: decimal.NewFromInt(1), Benchmark: decimal.NewFromInt(1), Ask: decimal.NewFromInt(1)} + } return precursor{ LifeCycleStage: protocol.LifeCycleStageProduction, ObservationTimestampNanoseconds: obsTsNs, - ChannelDefinitions: llotypes.ChannelDefinitions{1: {ReportFormat: format, Opts: opts}}, - ValidAfterNanoseconds: map[llotypes.ChannelID]uint64{1: validAfterNs}, + ChannelDefinitions: llotypes.ChannelDefinitions{1: {ReportFormat: format, Opts: opts, Streams: []llotypes.Stream{ + {StreamID: 1, Aggregator: llotypes.AggregatorMedian}, + {StreamID: 2, Aggregator: llotypes.AggregatorMedian}, + third, + }}}, + ValidAfterNanoseconds: map[llotypes.ChannelID]uint64{1: validAfterNs}, + StreamAggregates: protocol.StreamAggregates{ + 1: {llotypes.AggregatorMedian: one}, + 2: {llotypes.AggregatorMedian: one}, + 3: {third.Aggregator: thirdValue}, + }, } } + unpackedOpts := func(resolution string) []byte { + return []byte(`{` + resolution + `"abi":[{"type":"int192"}]}`) + } const ( sec1a = 1_500_000_000 // second 1 @@ -535,18 +556,18 @@ func Test_TimeResolutionOverlap(t *testing.T) { {"legacy same second -> not reportable", llotypes.ReportFormatEVMPremiumLegacy, nil, sec1a, sec1b, false}, {"legacy next second -> reportable", llotypes.ReportFormatEVMPremiumLegacy, nil, sec1a, sec2, true}, {"json same second -> reportable (nanosecond)", llotypes.ReportFormatJSON, nil, sec1a, sec1b, true}, - {"unpacked default opts same second -> not reportable (defaults to seconds)", llotypes.ReportFormatEVMABIEncodeUnpacked, nil, sec1a, sec1b, false}, - {"unpacked explicit ns same second -> reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, []byte(`{"TimeResolution":"ns"}`), sec1a, sec1b, true}, - {"unpacked explicit seconds next second -> reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, []byte(`{"TimeResolution":"s"}`), sec1a, sec2, true}, - {"unpacked ms same millisecond -> not reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, []byte(`{"TimeResolution":"ms"}`), sec1a, ms1500b, false}, - {"unpacked ms next millisecond -> reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, []byte(`{"TimeResolution":"ms"}`), sec1a, ms1501, true}, - {"unpacked us same microsecond -> not reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, []byte(`{"TimeResolution":"us"}`), sec1a, us1b, false}, - {"unpacked us next microsecond -> reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, []byte(`{"TimeResolution":"us"}`), sec1a, us2, true}, - {"unpacked ns same microsecond -> reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, []byte(`{"TimeResolution":"ns"}`), sec1a, us1b, true}, + {"unpacked default opts same second -> not reportable (defaults to seconds)", llotypes.ReportFormatEVMABIEncodeUnpacked, unpackedOpts(``), sec1a, sec1b, false}, + {"unpacked explicit ns same second -> reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, unpackedOpts(`"TimeResolution":"ns",`), sec1a, sec1b, true}, + {"unpacked explicit seconds next second -> reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, unpackedOpts(`"TimeResolution":"s",`), sec1a, sec2, true}, + {"unpacked ms same millisecond -> not reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, unpackedOpts(`"TimeResolution":"ms",`), sec1a, ms1500b, false}, + {"unpacked ms next millisecond -> reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, unpackedOpts(`"TimeResolution":"ms",`), sec1a, ms1501, true}, + {"unpacked us same microsecond -> not reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, unpackedOpts(`"TimeResolution":"us",`), sec1a, us1b, false}, + {"unpacked us next microsecond -> reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, unpackedOpts(`"TimeResolution":"us",`), sec1a, us2, true}, + {"unpacked ns same microsecond -> reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, unpackedOpts(`"TimeResolution":"ns",`), sec1a, us1b, true}, // The codec cannot encode a channel whose opts do not decode, so it must // not be reportable, otherwise validAfter advances over a round that // emitted nothing. - {"unpacked invalid resolution next second -> not reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, []byte(`{"TimeResolution":"bogus"}`), sec1a, sec2, false}, + {"unpacked invalid resolution next second -> not reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, unpackedOpts(`"TimeResolution":"bogus",`), sec1a, sec2, false}, {"unpacked malformed opts next second -> not reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, []byte(`{"not":`), sec1a, sec2, false}, } for _, tc := range tests { @@ -554,7 +575,7 @@ func Test_TimeResolutionOverlap(t *testing.T) { p := mkPrec(tc.format, tc.opts, tc.validAfter, tc.obsTs) cache := protocol.NewOptsCache() cache.ResetTo(p.ChannelDefinitions) - got := p.withSupport(1).reportableChannels(0, 0, cache, nil) + got := p.reportableChannels(0, 0, p.supportFor(1), cache, nil) if tc.reportable { require.Equal(t, []llotypes.ChannelID{1}, got) } else { @@ -563,8 +584,7 @@ func Test_TimeResolutionOverlap(t *testing.T) { }) } - // Expr channels need a calculated aggregate to be reportable at all, so a - // not reportable result can only come from the overlap check. + // Expr channels also need a calculated aggregate to be reportable at all. const exprStreamID = 999 exprOpts := func(resolution string) []byte { return []byte(`{` + resolution + `"abi":[{"type":"int256","expression":"s1","expressionStreamID":999}]}`) @@ -587,12 +607,12 @@ func Test_TimeResolutionOverlap(t *testing.T) { for _, tc := range exprTests { t.Run(tc.name, func(t *testing.T) { p := mkPrec(llotypes.ReportFormatEVMABIEncodeUnpackedExpr, tc.opts, sec1a, tc.obsTs) - p.StreamAggregates = protocol.StreamAggregates{ - exprStreamID: {llotypes.AggregatorCalculated: protocol.ToDecimal(decimal.NewFromInt(3))}, + p.StreamAggregates[exprStreamID] = map[llotypes.Aggregator]protocol.StreamValue{ + llotypes.AggregatorCalculated: protocol.ToDecimal(decimal.NewFromInt(3)), } cache := protocol.NewOptsCache() cache.ResetTo(p.ChannelDefinitions) - got := p.withSupport(1).reportableChannels(0, 0, cache, nil) + got := p.reportableChannels(0, 0, p.supportFor(1), cache, nil) if tc.reportable { require.Equal(t, []llotypes.ChannelID{1}, got) } else { @@ -620,14 +640,14 @@ func Test_DisableNilStreamValues(t *testing.T) { // Missing stream 200 -> not reportable. missing := base(protocol.StreamAggregates{100: {llotypes.AggregatorMedian: protocol.ToDecimal(decimal.NewFromInt(1))}}) - require.Empty(t, missing.withSupport(1).reportableChannels(0, 0, protocol.NewOptsCache(), nil)) + require.Empty(t, missing.reportableChannels(0, 0, missing.supportFor(1), protocol.NewOptsCache(), nil)) // Both streams present -> reportable. full := base(protocol.StreamAggregates{ 100: {llotypes.AggregatorMedian: protocol.ToDecimal(decimal.NewFromInt(1))}, 200: {llotypes.AggregatorMedian: protocol.ToDecimal(decimal.NewFromInt(2))}, }) - require.Equal(t, []llotypes.ChannelID{1}, full.withSupport(1).reportableChannels(0, 0, protocol.NewOptsCache(), nil)) + require.Equal(t, []llotypes.ChannelID{1}, full.reportableChannels(0, 0, full.supportFor(1), protocol.NewOptsCache(), nil)) } // v31 ignores DisableNilStreamValues: a nil value makes any channel @@ -638,15 +658,26 @@ func Test_IsReportable_NilStreamValue(t *testing.T) { {StreamID: 200, Aggregator: llotypes.AggregatorMedian}, {StreamID: 300, Aggregator: llotypes.AggregatorQuote}, } - allExcept := func(missing llotypes.StreamID) protocol.StreamAggregates { + // Present values are encodable, so a not reportable result can only come + // from the missing one. + allExcept := func(format llotypes.ReportFormat, missing llotypes.StreamID) protocol.StreamAggregates { aggs := protocol.StreamAggregates{} for _, strm := range streams { - if strm.StreamID != missing { - aggs[strm.StreamID] = map[llotypes.Aggregator]protocol.StreamValue{strm.Aggregator: protocol.ToDecimal(decimal.NewFromInt(1))} + if strm.StreamID == missing { + continue } + var v protocol.StreamValue = protocol.ToDecimal(decimal.NewFromInt(1)) + if format == llotypes.ReportFormatEVMPremiumLegacy && strm.Aggregator == llotypes.AggregatorQuote { + v = &protocol.Quote{Bid: decimal.NewFromInt(1), Benchmark: decimal.NewFromInt(1), Ask: decimal.NewFromInt(1)} + } + aggs[strm.StreamID] = map[llotypes.Aggregator]protocol.StreamValue{strm.Aggregator: v} } return aggs } + optsFor := map[llotypes.ReportFormat][]byte{ + llotypes.ReportFormatEVMABIEncodeUnpacked: []byte(`{"abi":[{"type":"int192"}]}`), + llotypes.ReportFormatEVMStreamlined: []byte(`{"abi":[{"type":"int192"},{"type":"int192"},{"type":"int192"}]}`), + } for _, format := range []llotypes.ReportFormat{ llotypes.ReportFormatJSON, llotypes.ReportFormatEVMPremiumLegacy, @@ -666,11 +697,11 @@ func Test_IsReportable_NilStreamValue(t *testing.T) { o := precursor{ LifeCycleStage: protocol.LifeCycleStageProduction, ObservationTimestampNanoseconds: 2 * uint64(time.Second), - ChannelDefinitions: llotypes.ChannelDefinitions{1: {ReportFormat: format, Streams: streams}}, + ChannelDefinitions: llotypes.ChannelDefinitions{1: {ReportFormat: format, Streams: streams, Opts: optsFor[format]}}, ValidAfterNanoseconds: map[llotypes.ChannelID]uint64{1: uint64(time.Second)}, - StreamAggregates: allExcept(tc.missing), + StreamAggregates: allExcept(format, tc.missing), } - got := o.withSupport(1).reportableChannels(0, 0, protocol.NewOptsCache(), nil) + got := o.reportableChannels(0, 0, o.supportFor(1), protocol.NewOptsCache(), nil) if tc.reportable { require.Equal(t, []llotypes.ChannelID{1}, got) } else { @@ -736,17 +767,17 @@ func Test_DisableNilStreamValues_CalculatedStreams(t *testing.T) { // ProcessCalculatedStreams bailed before writing the calculated // aggregate; the definition alone looks complete. o := mkPrec(true, validOpts, baseStreams, baseAggregates()) - require.Empty(t, o.withSupport(1).reportableChannels(0, 0, populatedCache(o), nil)) + require.Empty(t, o.reportableChannels(0, 0, o.supportFor(1), populatedCache(o), nil)) }) t.Run("inline calculated stream but nil aggregate -> not reportable", func(t *testing.T) { o := mkPrec(true, validOpts, withCalculated, baseAggregates()) - require.Empty(t, o.withSupport(1).reportableChannels(0, 0, populatedCache(o), nil)) + require.Empty(t, o.reportableChannels(0, 0, o.supportFor(1), populatedCache(o), nil)) }) t.Run("fully evaluated -> reportable", func(t *testing.T) { o := mkPrec(true, validOpts, withCalculated, evaluatedAggregates()) - require.Equal(t, []llotypes.ChannelID{1}, o.withSupport(1).reportableChannels(0, 0, populatedCache(o), nil)) + require.Equal(t, []llotypes.ChannelID{1}, o.reportableChannels(0, 0, o.supportFor(1), populatedCache(o), nil)) }) t.Run("DisableNilStreamValues=false, evaluation failed -> not reportable", func(t *testing.T) { @@ -756,39 +787,39 @@ func Test_DisableNilStreamValues_CalculatedStreams(t *testing.T) { // report. Treating the channel as reportable would advance validAfter // over a round that emitted nothing. o := mkPrec(false, validOpts, baseStreams, baseAggregates()) - require.Empty(t, o.withSupport(1).reportableChannels(0, 0, populatedCache(o), nil)) + require.Empty(t, o.reportableChannels(0, 0, o.supportFor(1), populatedCache(o), nil)) }) t.Run("DisableNilStreamValues=false, nil observed value -> not reportable", func(t *testing.T) { aggs := evaluatedAggregates() delete(aggs, base2) o := mkPrec(false, validOpts, withCalculated, aggs) - require.Empty(t, o.withSupport(1).reportableChannels(0, 0, populatedCache(o), nil)) + require.Empty(t, o.reportableChannels(0, 0, o.supportFor(1), populatedCache(o), nil)) }) t.Run("DisableNilStreamValues=false, fully evaluated -> reportable", func(t *testing.T) { o := mkPrec(false, validOpts, withCalculated, evaluatedAggregates()) - require.Equal(t, []llotypes.ChannelID{1}, o.withSupport(1).reportableChannels(0, 0, populatedCache(o), nil)) + require.Equal(t, []llotypes.ChannelID{1}, o.reportableChannels(0, 0, o.supportFor(1), populatedCache(o), nil)) }) t.Run("malformed opts -> not reportable", func(t *testing.T) { o := mkPrec(true, []byte(`{"abi":`), withCalculated, evaluatedAggregates()) - require.Empty(t, o.withSupport(1).reportableChannels(0, 0, populatedCache(o), nil)) + require.Empty(t, o.reportableChannels(0, 0, o.supportFor(1), populatedCache(o), nil)) }) t.Run("opts declare no expressions -> not reportable", func(t *testing.T) { o := mkPrec(true, []byte(`{"abi":[]}`), withCalculated, evaluatedAggregates()) - require.Empty(t, o.withSupport(1).reportableChannels(0, 0, populatedCache(o), nil)) + require.Empty(t, o.reportableChannels(0, 0, o.supportFor(1), populatedCache(o), nil)) }) t.Run("cache miss falls back to channel opts -> reportable", func(t *testing.T) { o := mkPrec(true, validOpts, withCalculated, evaluatedAggregates()) - require.Equal(t, []llotypes.ChannelID{1}, o.withSupport(1).reportableChannels(0, 0, protocol.NewOptsCache(), nil)) + require.Equal(t, []llotypes.ChannelID{1}, o.reportableChannels(0, 0, o.supportFor(1), protocol.NewOptsCache(), nil)) }) t.Run("cache miss falls back to channel opts -> not reportable when unevaluated", func(t *testing.T) { o := mkPrec(true, validOpts, baseStreams, baseAggregates()) - require.Empty(t, o.withSupport(1).reportableChannels(0, 0, protocol.NewOptsCache(), nil)) + require.Empty(t, o.reportableChannels(0, 0, o.supportFor(1), protocol.NewOptsCache(), nil)) }) } @@ -1098,7 +1129,7 @@ func Test_HistoryBackfill(t *testing.T) { ValidAfterNanoseconds: map[llotypes.ChannelID]uint64{targetCID: tenSec /* target not reportable */, backfillCID: 0}, StreamAggregates: protocol.StreamAggregates{}, } - b, err := encodePrecursor(prec.withSupport(3)) + b, err := encodePrecursor(prec.decided(p)) require.NoError(t, err) reports, err := p.Reports(ctx, 2, b) require.NoError(t, err) @@ -1291,7 +1322,7 @@ func Test_CalculatedStreams_ReportValues(t *testing.T) { cache.ResetTo(definitions) calculated.ProcessCalculatedStreams(p.Logger, prec.ChannelDefinitions, prec.StreamAggregates, prec.ObservationTimestampNanoseconds, cache, nil) - precBytes, err := encodePrecursor(prec.withSupport(3)) + precBytes, err := encodePrecursor(prec.decided(p)) require.NoError(t, err) reports, err := p.Reports(ctx, 2, precBytes) require.NoError(t, err) @@ -1347,7 +1378,7 @@ func Test_IsReportable_EffectiveStreamsFailure(t *testing.T) { 100: {llotypes.AggregatorMedian: protocol.ToDecimal(decimal.NewFromInt(1))}, }, } - require.Empty(t, prec.withSupport(1).reportableChannels(0, 0, protocol.NewOptsCache(), nil)) + require.Empty(t, prec.reportableChannels(0, 0, prec.supportFor(1), protocol.NewOptsCache(), nil)) } func Test_SelectBackfillCandidate_UnemittableRow(t *testing.T) { @@ -1419,15 +1450,22 @@ func Test_SelectBackfillCandidate_TombstonedTarget(t *testing.T) { require.False(t, ok, "a tombstoned target must not be backfilled") } -// withSupport returns a copy of the precursor with n oracles advertising a -// report codec for every format its channels need, so tests that are not about -// the support gate are unaffected by it. Backfill channels are resolved to -// their target's format, which is the one their report is encoded with. -func (o precursor) withSupport(n int) precursor { - o.SupportByFormat = make(map[llotypes.ReportFormat]int, len(o.ChannelDefinitions)) +// supportFor returns n oracles advertising a report codec for every format the +// precursor's channels use, so tests that are not about the support gate are +// unaffected by it. +func (o precursor) supportFor(n int) map[llotypes.ReportFormat]int { + support := make(map[llotypes.ReportFormat]int, len(o.ChannelDefinitions)) for _, cd := range o.ChannelDefinitions { - o.SupportByFormat[cd.ReportFormat] = n + support[cd.ReportFormat] = n } + return support +} + +// decided sets ReportableChannelIDs the way StateTransition does, with every +// oracle advertising every format, for tests that hand a precursor to Reports +// directly. +func (o precursor) decided(p *Plugin) precursor { + o.ReportableChannelIDs = o.reportableChannels(p.DefaultMinReportIntervalNanoseconds, p.F, o.supportFor(p.N), nil, nil) return o } @@ -1437,36 +1475,35 @@ func Test_ReportFormatSupportGate(t *testing.T) { ReportFormat: llotypes.ReportFormatJSON, Streams: []llotypes.Stream{{StreamID: 100, Aggregator: llotypes.AggregatorMedian}}, } - base := func(support int) precursor { - return precursor{ - LifeCycleStage: protocol.LifeCycleStageProduction, - ObservationTimestampNanoseconds: 2_000_000_000, - ChannelDefinitions: llotypes.ChannelDefinitions{cid: cd}, - ValidAfterNanoseconds: map[llotypes.ChannelID]uint64{cid: 1_000_000_000}, - StreamAggregates: protocol.StreamAggregates{ - 100: {llotypes.AggregatorMedian: protocol.ToDecimal(decimal.NewFromInt(1))}, - }, - SupportByFormat: map[llotypes.ReportFormat]int{llotypes.ReportFormatJSON: support}, - } + prec := precursor{ + LifeCycleStage: protocol.LifeCycleStageProduction, + ObservationTimestampNanoseconds: 2_000_000_000, + ChannelDefinitions: llotypes.ChannelDefinitions{cid: cd}, + ValidAfterNanoseconds: map[llotypes.ChannelID]uint64{cid: 1_000_000_000}, + StreamAggregates: protocol.StreamAggregates{ + 100: {llotypes.AggregatorMedian: protocol.ToDecimal(decimal.NewFromInt(1))}, + }, + } + reportable := func(support map[llotypes.ReportFormat]int) []llotypes.ChannelID { + return prec.reportableChannels(0, 1, support, protocol.NewOptsCache(), nil) + } + jsonSupport := func(n int) map[llotypes.ReportFormat]int { + return map[llotypes.ReportFormat]int{llotypes.ReportFormatJSON: n} } // f=1 requires 2f+1 = 3 advertised supporters: 2f would only guarantee f+1 // real encoders if none of the advertisements were lies. - require.Equal(t, []llotypes.ChannelID{cid}, base(3).reportableChannels(0, 1, protocol.NewOptsCache(), nil)) - require.Empty(t, base(2).reportableChannels(0, 1, protocol.NewOptsCache(), nil)) - require.Empty(t, base(0).reportableChannels(0, 1, protocol.NewOptsCache(), nil)) + require.Equal(t, []llotypes.ChannelID{cid}, reportable(jsonSupport(3))) + require.Empty(t, reportable(jsonSupport(2))) + require.Empty(t, reportable(jsonSupport(0))) // A format no oracle advertises is never reportable, however healthy the // channel otherwise is. - noEntry := base(3) - noEntry.SupportByFormat = map[llotypes.ReportFormat]int{} - require.Empty(t, noEntry.reportableChannels(0, 1, protocol.NewOptsCache(), nil)) + require.Empty(t, reportable(map[llotypes.ReportFormat]int{})) // Support is keyed by format, not channel: an unrelated format's coverage // does not carry the channel. - wrongFormat := base(0) - wrongFormat.SupportByFormat = map[llotypes.ReportFormat]int{llotypes.ReportFormatEVMPremiumLegacy: 4} - require.Empty(t, wrongFormat.reportableChannels(0, 1, protocol.NewOptsCache(), nil)) + require.Empty(t, reportable(map[llotypes.ReportFormat]int{llotypes.ReportFormatEVMPremiumLegacy: 4})) } func Test_ReportFormatSupportGate_Backfill(t *testing.T) { @@ -1482,25 +1519,22 @@ func Test_ReportFormatSupportGate_Backfill(t *testing.T) { Opts: []byte(`{"targetChannelId":10,"observations":{"5":{"100":"1.5"}}}`), }, } - base := func(support map[llotypes.ReportFormat]int) precursor { - return precursor{ - LifeCycleStage: protocol.LifeCycleStageProduction, - ObservationTimestampNanoseconds: tenSec, - ChannelDefinitions: defs, - ValidAfterNanoseconds: map[llotypes.ChannelID]uint64{targetCID: tenSec /* target not reportable */, backfillCID: 0}, - StreamAggregates: protocol.StreamAggregates{}, - SupportByFormat: support, - } + prec := precursor{ + LifeCycleStage: protocol.LifeCycleStageProduction, + ObservationTimestampNanoseconds: tenSec, + ChannelDefinitions: defs, + ValidAfterNanoseconds: map[llotypes.ChannelID]uint64{targetCID: tenSec /* target not reportable */, backfillCID: 0}, + StreamAggregates: protocol.StreamAggregates{}, } // The backfill report is encoded with the TARGET's codec, so the target's // format is what must be covered. Coverage of history_backfill itself is // irrelevant: no codec encodes it. - targetCovered := base(map[llotypes.ReportFormat]int{llotypes.ReportFormatJSON: 3}) - require.Equal(t, []llotypes.ChannelID{backfillCID}, targetCovered.reportableChannels(0, 1, protocol.NewOptsCache(), nil)) + targetCovered := map[llotypes.ReportFormat]int{llotypes.ReportFormatJSON: 3} + require.Equal(t, []llotypes.ChannelID{backfillCID}, prec.reportableChannels(0, 1, targetCovered, protocol.NewOptsCache(), nil)) - backfillCoveredOnly := base(map[llotypes.ReportFormat]int{llotypes.ReportFormatHistoryBackfill: 4}) - require.Empty(t, backfillCoveredOnly.reportableChannels(0, 1, protocol.NewOptsCache(), nil)) + backfillCoveredOnly := map[llotypes.ReportFormat]int{llotypes.ReportFormatHistoryBackfill: 4} + require.Empty(t, prec.reportableChannels(0, 1, backfillCoveredOnly, protocol.NewOptsCache(), nil)) } func Test_ReportFormatSupportGate_StopsValidAfterAdvance(t *testing.T) { @@ -1522,13 +1556,11 @@ func Test_ReportFormatSupportGate_StopsValidAfterAdvance(t *testing.T) { }, } - covered := prec - covered.SupportByFormat = map[llotypes.ReportFormat]int{llotypes.ReportFormatJSON: 3} - require.True(t, covered.isReportable(cid, 0, 1, protocol.NewOptsCache(), nil)) + covered := map[llotypes.ReportFormat]int{llotypes.ReportFormatJSON: 3} + require.True(t, prec.isReportable(cid, 0, 1, covered, protocol.NewOptsCache(), nil)) - uncovered := prec - uncovered.SupportByFormat = map[llotypes.ReportFormat]int{llotypes.ReportFormatJSON: 2} - require.False(t, uncovered.isReportable(cid, 0, 1, protocol.NewOptsCache(), nil)) + uncovered := map[llotypes.ReportFormat]int{llotypes.ReportFormatJSON: 2} + require.False(t, prec.isReportable(cid, 0, 1, uncovered, protocol.NewOptsCache(), nil)) } func Test_Observation_SupportedReportFormats_RoundTrip(t *testing.T) { @@ -1634,17 +1666,19 @@ func Test_DecodeObservation_AttestedPredecessorRetirementBound(t *testing.T) { require.ErrorContains(t, err, "attested predecessor retirement of") } -func Test_Precursor_SupportByFormat_RoundTrip(t *testing.T) { +func Test_Precursor_ReportableChannelIDs_RoundTrip(t *testing.T) { + defs := llotypes.ChannelDefinitions{ + 1: {ReportFormat: llotypes.ReportFormatJSON}, + 2: {ReportFormat: llotypes.ReportFormatJSON}, + 3: {ReportFormat: llotypes.ReportFormatJSON}, + } prec := precursor{ - LifeCycleStage: protocol.LifeCycleStageProduction, - SupportByFormat: map[llotypes.ReportFormat]int{ - llotypes.ReportFormatJSON: 4, - llotypes.ReportFormatEVMPremiumLegacy: 3, - }, + LifeCycleStage: protocol.LifeCycleStageProduction, + ChannelDefinitions: defs, + ReportableChannelIDs: []llotypes.ChannelID{3, 1}, } - // Encoding must be deterministic despite map iteration order: libocr - // digests the precursor bytes, so two oracles disagreeing on byte order - // would fail attestation. + // Encoding must be deterministic: libocr digests the precursor bytes, so + // two oracles disagreeing on byte order would fail attestation. b1, err := encodePrecursor(prec) require.NoError(t, err) b2, err := encodePrecursor(prec) @@ -1653,7 +1687,40 @@ func Test_Precursor_SupportByFormat_RoundTrip(t *testing.T) { got, err := decodePrecursor(b1) require.NoError(t, err) - require.Equal(t, prec.SupportByFormat, got.SupportByFormat) + require.Equal(t, []llotypes.ChannelID{1, 3}, got.ReportableChannelIDs) + + // A reportable channel without a definition is rejected on both sides. + _, err = encodePrecursor(precursor{ChannelDefinitions: defs, ReportableChannelIDs: []llotypes.ChannelID{4}}) + require.ErrorContains(t, err, "reportable channel 4 has no definition") + + encodeRaw := func(ids ...uint32) []byte { + pb := &protocol.LLOPrecursorProto{ReportableChannelIDs: ids} + for id := range defs { + pb.ChannelDefinitions = append(pb.ChannelDefinitions, &protocol.LLOChannelIDAndDefinitionProto{ + ChannelID: id, + ChannelDefinition: protocol.ChannelDefinitionToProto(defs[id]), + }) + } + b, err := proto.Marshal(pb) + require.NoError(t, err) + return b + } + _, err = decodePrecursor(encodeRaw(4)) + require.ErrorContains(t, err, "reportable channel 4 has no definition") + // Unsorted or repeated IDs would let Reports emit a channel twice. + _, err = decodePrecursor(encodeRaw(2, 1)) + require.ErrorContains(t, err, "not strictly ascending") + _, err = decodePrecursor(encodeRaw(1, 1)) + require.ErrorContains(t, err, "not strictly ascending") +} + +// supportAfter returns the codec support per report format that the round after +// a StateTransition reads, from the persisted c/codecs record. +func supportAfter(t *testing.T, p *Plugin, kv ocr3_1types.KeyValueStateReader) map[llotypes.ReportFormat]int { + t.Helper() + state, err := loadColdKVState(kv, p.ChannelCache) + require.NoError(t, err) + return supportVotesForEffectiveFormats(countSupportByFormat(state.codecSupport), state.channelDefinitions) } func Test_StateTransition_TalliesReportFormatSupport(t *testing.T) { @@ -1671,12 +1738,9 @@ func Test_StateTransition_TalliesReportFormatSupport(t *testing.T) { // of advertisements, and an oracle that advertises nothing is not counted. obsJSON := mustEncodeObs(t, Observation{UnixTimestampNanoseconds: 1, SupportedReportFormats: formatSet(llotypes.ReportFormatJSON)}) obsNone := mustEncodeObs(t, Observation{UnixTimestampNanoseconds: 1, SupportedReportFormats: formatSet()}) - precBytes, err := p.StateTransition(ctx, 2, ocrtypes.AttributedQuery{}, []ocrtypes.AttributedObservation{ao(0, obsJSON), ao(1, obsJSON), ao(2, obsNone)}, kv, testBlobs) - require.NoError(t, err) - - prec, err := decodePrecursor(precBytes) + _, err = p.StateTransition(ctx, 2, ocrtypes.AttributedQuery{}, []ocrtypes.AttributedObservation{ao(0, obsJSON), ao(1, obsJSON), ao(2, obsNone)}, kv, testBlobs) require.NoError(t, err) - require.Equal(t, 2, prec.SupportByFormat[llotypes.ReportFormatJSON]) + require.Equal(t, 2, supportAfter(t, p, kv)[llotypes.ReportFormatJSON]) } func Test_StateTransition_ObservationTimestampDoesNotRegress(t *testing.T) { @@ -1791,27 +1855,24 @@ func Test_StateTransition_CodecSupportAccumulatesAcrossRounds(t *testing.T) { } // All N oracles advertise JSON. - precBytes, err := p.StateTransition(ctx, 2, ocrtypes.AttributedQuery{}, []ocrtypes.AttributedObservation{ao(0, obsJSON(10)), ao(1, obsJSON(10)), ao(2, obsJSON(10)), ao(3, obsJSON(10))}, kv, testBlobs) + _, err = p.StateTransition(ctx, 2, ocrtypes.AttributedQuery{}, []ocrtypes.AttributedObservation{ao(0, obsJSON(10)), ao(1, obsJSON(10)), ao(2, obsJSON(10)), ao(3, obsJSON(10))}, kv, testBlobs) require.NoError(t, err) - prec, err := decodePrecursor(precBytes) - require.NoError(t, err) - require.Equal(t, p.N, prec.SupportByFormat[llotypes.ReportFormatJSON]) + require.Equal(t, p.N, supportAfter(t, p, kv)[llotypes.ReportFormatJSON]) // A minimal quorum in which one oracle now omits its advertisement. Oracle // 3 contributes nothing this round, but its last advertisement still counts, // so the format keeps 2f+1 supporters and the channel stays reportable. - precBytes, err = p.StateTransition(ctx, 3, ocrtypes.AttributedQuery{}, []ocrtypes.AttributedObservation{ao(0, obsJSON(20)), ao(1, obsJSON(20)), ao(2, obsNone(20))}, kv, testBlobs) + precBytes, err := p.StateTransition(ctx, 3, ocrtypes.AttributedQuery{}, []ocrtypes.AttributedObservation{ao(0, obsJSON(20)), ao(1, obsJSON(20)), ao(2, obsNone(20))}, kv, testBlobs) require.NoError(t, err) - prec, err = decodePrecursor(precBytes) + require.Equal(t, 2*p.F+1, supportAfter(t, p, kv)[llotypes.ReportFormatJSON]) + prec, err := decodePrecursor(precBytes) require.NoError(t, err) - require.Equal(t, 2*p.F+1, prec.SupportByFormat[llotypes.ReportFormatJSON]) - require.Equal(t, []llotypes.ChannelID{1}, prec.reportableChannels(0, p.F, nil, nil)) + require.Equal(t, []llotypes.ChannelID{1}, prec.ReportableChannelIDs) } // One oracle padding its observation with unused report formats must not be -// able to grow the precursor tally: the encoded map is keyed by oracle-chosen -// values, and an oversized one fails to decode on every oracle, which would -// stop reporting DON-wide while StateTransition keeps succeeding. +// able to grow the tally the state transition reads: it is keyed by +// oracle-chosen values, so only the formats of effective channels count. func Test_StateTransition_PrunesUnusedReportFormatSupport(t *testing.T) { ctx := tests.Context(t) p := testPlugin(t) @@ -1833,12 +1894,9 @@ func Test_StateTransition_PrunesUnusedReportFormatSupport(t *testing.T) { } return mustEncodeObs(t, Observation{UnixTimestampNanoseconds: 1, SupportedReportFormats: formats}) } - precBytes, err := p.StateTransition(ctx, 2, ocrtypes.AttributedQuery{}, []ocrtypes.AttributedObservation{ao(0, padded(0)), ao(1, padded(1)), ao(2, padded(2))}, kv, testBlobs) + _, err = p.StateTransition(ctx, 2, ocrtypes.AttributedQuery{}, []ocrtypes.AttributedObservation{ao(0, padded(0)), ao(1, padded(1)), ao(2, padded(2))}, kv, testBlobs) require.NoError(t, err) - - prec, err := decodePrecursor(precBytes) - require.NoError(t, err) - require.Equal(t, map[llotypes.ReportFormat]int{llotypes.ReportFormatJSON: 3}, prec.SupportByFormat) + require.Equal(t, map[llotypes.ReportFormat]int{llotypes.ReportFormatJSON: 3}, supportAfter(t, p, kv)) } // strictJSONCodec is reportcodec.JSONReportCodec with an extra Verify rule this @@ -2140,7 +2198,7 @@ func Test_UnreportableTally_CollapsesPerChannelWarnings(t *testing.T) { // No oracle advertises the format, so every channel fails the same check. tally := &unreportableTally{} - require.Empty(t, out.withSupport(0).reportableChannels(0, 1, protocol.NewOptsCache(), tally)) + require.Empty(t, out.reportableChannels(0, 1, out.supportFor(0), protocol.NewOptsCache(), tally)) require.Len(t, tally.reasons, 1, "one reason, not one entry per channel") reason := tally.reasons["too few oracles advertise a report codec for this format"] @@ -2150,7 +2208,7 @@ func Test_UnreportableTally_CollapsesPerChannelWarnings(t *testing.T) { require.Equal(t, []any{"reportFormat", llotypes.ReportFormatJSON, "supporters", 0, "required", 3}, reason.detail) // A nil tally is the no-op the predicate-only callers pass. - require.Empty(t, out.withSupport(0).reportableChannels(0, 1, protocol.NewOptsCache(), nil)) + require.Empty(t, out.reportableChannels(0, 1, out.supportFor(0), protocol.NewOptsCache(), nil)) } // Test_StateTransition_BackfillValidAfterRequiresPreviousReport covers the @@ -2286,3 +2344,90 @@ func readHotStateForTest(t *testing.T, kv *memKV) *kvState { require.NoError(t, readHotState(kv, s, logger.Test(t))) return s } + +// A value outside the integer range its format encodes it in would fail Encode. +// It depends on the data, so the channel is unreportable while it lasts and the +// watermark holds, then reports again once the value is back in range. +func Test_IsReportable_ValueOutOfEncodedRange(t *testing.T) { + cd := llotypes.ChannelDefinition{ + ReportFormat: llotypes.ReportFormatEVMStreamlined, + Opts: []byte(`{"abi":[{"type":"uint8"}]}`), + Streams: []llotypes.Stream{{StreamID: 100, Aggregator: llotypes.AggregatorMedian}}, + } + withValue := func(v string) precursor { + return precursor{ + LifeCycleStage: protocol.LifeCycleStageProduction, + ObservationTimestampNanoseconds: 2 * uint64(time.Second), + ChannelDefinitions: llotypes.ChannelDefinitions{1: cd}, + ValidAfterNanoseconds: map[llotypes.ChannelID]uint64{1: uint64(time.Second)}, + StreamAggregates: protocol.StreamAggregates{ + 100: {llotypes.AggregatorMedian: protocol.ToDecimal(decimal.RequireFromString(v))}, + }, + } + } + reportable := func(v string, tally *unreportableTally) []llotypes.ChannelID { + prec := withValue(v) + return prec.reportableChannels(0, 0, prec.supportFor(1), nil, tally) + } + tally := &unreportableTally{} + require.Empty(t, reportable("-1", tally)) + require.Contains(t, tally.reasons, "stream value not encodable") + require.Empty(t, reportable("256", nil)) + require.Equal(t, []llotypes.ChannelID{1}, reportable("255", nil)) +} + +// Backfill rows are fixed in the opts, so a row whose value is outside the +// target's encoded range can never be emitted. Selection passes over it. +func Test_SelectBackfillCandidate_SkipsUnencodableRow(t *testing.T) { + const ( + targetCID = llotypes.ChannelID(10) + backfillCID = llotypes.ChannelID(20) + tenSec = uint64(10_000_000_000) + sixSec = uint64(6_000_000_000) + ) + defs := llotypes.ChannelDefinitions{ + targetCID: { + ReportFormat: llotypes.ReportFormatEVMStreamlined, + Opts: []byte(`{"abi":[{"type":"uint8"}]}`), + Streams: []llotypes.Stream{{StreamID: 100, Aggregator: llotypes.AggregatorMedian}}, + }, + backfillCID: { + ReportFormat: llotypes.ReportFormatHistoryBackfill, + Opts: []byte(`{"targetChannelId":10,"observations":{"5":{"100":"256"},"6":{"100":"255"}}}`), + }, + } + ts, _, _, skipped, ok := selectBackfillCandidate(defs, map[llotypes.ChannelID]uint64{backfillCID: 0}, tenSec, backfillCID, nil) + require.True(t, ok) + require.Equal(t, sixSec, ts) + require.Equal(t, 1, skipped) +} + +// The precursor carries only the aggregates the reportable channels report, +// including derived calculated streams, which is all Reports reads. +func Test_Precursor_ReportedAggregates(t *testing.T) { + one := protocol.ToDecimal(decimal.NewFromInt(1)) + prec := precursor{ + ChannelDefinitions: llotypes.ChannelDefinitions{ + 1: {ReportFormat: llotypes.ReportFormatJSON, Streams: []llotypes.Stream{{StreamID: 100, Aggregator: llotypes.AggregatorMedian}}}, + 2: {ReportFormat: llotypes.ReportFormatJSON, Streams: []llotypes.Stream{{StreamID: 200, Aggregator: llotypes.AggregatorMedian}}}, + 3: { + ReportFormat: llotypes.ReportFormatEVMABIEncodeUnpackedExpr, + Opts: []byte(`{"abi":[{"type":"int256","expression":"s1","expressionStreamID":999}]}`), + Streams: []llotypes.Stream{{StreamID: 300, Aggregator: llotypes.AggregatorMedian}}, + }, + 4: {ReportFormat: llotypes.ReportFormatHistoryBackfill, Opts: []byte(`{"targetChannelId":1}`)}, + }, + StreamAggregates: protocol.StreamAggregates{ + 100: {llotypes.AggregatorMedian: one, llotypes.AggregatorMode: one}, + 200: {llotypes.AggregatorMedian: one}, + 300: {llotypes.AggregatorMedian: one}, + 999: {llotypes.AggregatorCalculated: one}, + }, + ReportableChannelIDs: []llotypes.ChannelID{1, 3, 4}, + } + require.Equal(t, protocol.StreamAggregates{ + 100: {llotypes.AggregatorMedian: one}, + 300: {llotypes.AggregatorMedian: one}, + 999: {llotypes.AggregatorCalculated: one}, + }, prec.reportedAggregates(nil)) +} diff --git a/llo/dev/v31/precursor.go b/llo/dev/v31/precursor.go index df16a5b..ab3340a 100644 --- a/llo/dev/v31/precursor.go +++ b/llo/dev/v31/precursor.go @@ -3,6 +3,7 @@ package llo import ( "errors" "fmt" + "slices" "sort" llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" @@ -26,15 +27,11 @@ type precursor struct { // ChannelDefinitions came from. It lets Reports tell whether the decoded-opts // cache already matches these definitions without walking every channel. ChannelStateSeqNr uint64 - // SupportByFormat is the number of oracles whose last advertisement named - // each report format, counted over the cumulative c/codecs record rather - // than over this round's observations alone (see writeCodecSupport). - // - // Snapshotted rather than recomputed so that reportability and the - // validAfter advance for a round read the identical number: Reports runs - // against this precursor, and StateTransition persists reportedLastRound - // from the same object (see isReportable). - SupportByFormat map[llotypes.ReportFormat]int + // ReportableChannelIDs is the sorted set of channels StateTransition found + // reportable this round (see isReportable). Reports emits exactly these, and + // the next round advances validAfter from the same decision, so the two + // cannot disagree. + ReportableChannelIDs []llotypes.ChannelID } func encodePrecursor(p precursor) (ocr3_1types.ReportsPlusPrecursor, error) { @@ -92,23 +89,15 @@ func encodePrecursor(p precursor) (ocr3_1types.ReportsPlusPrecursor, error) { }) } - if len(p.SupportByFormat) > protocol.MaxOutcomeChannelDefinitionsLength { - return nil, fmt.Errorf("too many report format support entries: %d (max %d)", len(p.SupportByFormat), protocol.MaxOutcomeChannelDefinitionsLength) - } - if len(p.SupportByFormat) > 0 { - pb.SupportByFormat = make([]*protocol.LLOReportFormatSupportProto, 0, len(p.SupportByFormat)) - for format, count := range p.SupportByFormat { - if count < 0 { - return nil, fmt.Errorf("negative support count for report format %v: %d", format, count) + if len(p.ReportableChannelIDs) > 0 { + pb.ReportableChannelIDs = make([]uint32, 0, len(p.ReportableChannelIDs)) + for _, id := range p.ReportableChannelIDs { + if _, ok := p.ChannelDefinitions[id]; !ok { + return nil, fmt.Errorf("reportable channel %d has no definition", id) } - pb.SupportByFormat = append(pb.SupportByFormat, &protocol.LLOReportFormatSupportProto{ - ReportFormat: uint32(format), - OracleCount: uint32(count), - }) + pb.ReportableChannelIDs = append(pb.ReportableChannelIDs, id) } - sort.Slice(pb.SupportByFormat, func(i, j int) bool { - return pb.SupportByFormat[i].ReportFormat < pb.SupportByFormat[j].ReportFormat - }) + slices.Sort(pb.ReportableChannelIDs) } b, err := deterministicMarshal.Marshal(pb) @@ -145,22 +134,21 @@ func decodePrecursor(b ocr3_1types.ReportsPlusPrecursor) (precursor, error) { for _, va := range pb.ValidAfterNanoseconds { p.ValidAfterNanoseconds[va.ChannelID] = va.ValidAfterNanoseconds } - // One entry per report format in the effective channel set, so the channel - // cap is the bound here. The observation-side cap does not apply: the tally - // is pruned to effective formats before encoding, and the effective set may - // legitimately use more distinct formats than one observation may advertise. - // - // This mirrors the encode-side cap. The other collections are left - // unbounded on purpose: the precursor is certified state, libocr bounds its - // size, and the promotion round legitimately carries more validAfter - // entries than channels. - if len(pb.SupportByFormat) > protocol.MaxOutcomeChannelDefinitionsLength { - return precursor{}, fmt.Errorf("precursor carries too many report format support entries: %d (max %d)", len(pb.SupportByFormat), protocol.MaxOutcomeChannelDefinitionsLength) - } - if len(pb.SupportByFormat) > 0 { - p.SupportByFormat = make(map[llotypes.ReportFormat]int, len(pb.SupportByFormat)) - for _, sup := range pb.SupportByFormat { - p.SupportByFormat[llotypes.ReportFormat(sup.ReportFormat)] = int(sup.OracleCount) + // Every reportable channel must have a definition and appear once, in + // ascending order, so Reports emits each at most once. The collections are + // otherwise left unbounded on purpose: the precursor is certified state, + // libocr bounds its size, and the promotion round legitimately carries more + // validAfter entries than channels. + if len(pb.ReportableChannelIDs) > 0 { + p.ReportableChannelIDs = make([]llotypes.ChannelID, 0, len(pb.ReportableChannelIDs)) + for i, id := range pb.ReportableChannelIDs { + if i > 0 && id <= pb.ReportableChannelIDs[i-1] { + return precursor{}, fmt.Errorf("reportable channel IDs not strictly ascending at index %d", i) + } + if _, ok := p.ChannelDefinitions[id]; !ok { + return precursor{}, fmt.Errorf("reportable channel %d has no definition", id) + } + p.ReportableChannelIDs = append(p.ReportableChannelIDs, id) } } for _, sa := range pb.StreamAggregates { @@ -175,3 +163,29 @@ func decodePrecursor(b ocr3_1types.ReportsPlusPrecursor) (precursor, error) { } return p, nil } + +// reportedAggregates returns the aggregates the reportable channels report, +// which is all Reports reads from StreamAggregates. Backfill reports carry +// their values in the backfill opts. +func (o precursor) reportedAggregates(optsCache *protocol.OptsCache) protocol.StreamAggregates { + reported := protocol.StreamAggregates{} + for _, cid := range o.ReportableChannelIDs { + cd := o.ChannelDefinitions[cid] + if cd.ReportFormat == llotypes.ReportFormatHistoryBackfill { + continue + } + // isReportable derived the same streams, so this does not fail for a + // reportable channel. + streams, err := protocol.EffectiveStreams(optsCache, cd, cid) + if err != nil { + continue + } + for _, strm := range streams { + if reported[strm.StreamID] == nil { + reported[strm.StreamID] = map[llotypes.Aggregator]protocol.StreamValue{} + } + reported[strm.StreamID][strm.Aggregator] = o.StreamAggregates[strm.StreamID][strm.Aggregator] + } + } + return reported +} diff --git a/llo/dev/v31/reports.go b/llo/dev/v31/reports.go index c1b32f1..35de085 100644 --- a/llo/dev/v31/reports.go +++ b/llo/dev/v31/reports.go @@ -63,9 +63,9 @@ func (p *Plugin) Reports(ctx context.Context, seqNr uint64, rawPrecursor ocr3_1t }) } - unreportable := &unreportableTally{} - defer unreportable.log(p.Logger, "Report", seqNr) - for _, cid := range out.reportableChannels(p.DefaultMinReportIntervalNanoseconds, p.F, channelOpts, unreportable) { + // StateTransition decided reportability and logged why channels are not + // reportable. Emit exactly the channels it chose. + for _, cid := range out.ReportableChannelIDs { cd := out.ChannelDefinitions[cid] if cd.ReportFormat == llotypes.ReportFormatHistoryBackfill { @@ -143,10 +143,7 @@ func (p *Plugin) Reports(ctx context.Context, seqNr uint64, rawPrecursor ocr3_1t p.reportNotEmitted(seqNr, cid, cd.ReportFormat, "effective_streams_underivable", "err", err) continue } - values := make([]protocol.StreamValue, 0, len(streams)) - for _, strm := range streams { - values = append(values, out.StreamAggregates[strm.StreamID][strm.Aggregator]) - } + values := out.reportValues(streams) report := protocol.Report{ ConfigDigest: p.ConfigDigest, @@ -274,11 +271,14 @@ func (t *unreportableTally) log(lggr logger.Logger, stage string, seqNr uint64) // formatIsEncodable reports whether enough oracles advertised a report codec // for format that a report encoded with it would actually be certified. // -// The tally is read from the precursor, not from p.ReportCodecs, so this stays -// a pure function of replicated state. An oracle that cannot encode still -// computes reportable=true when enough peers can. -func (o precursor) formatIsEncodable(format llotypes.ReportFormat, f int, channelID llotypes.ChannelID, tally *unreportableTally) bool { - if supporters := o.SupportByFormat[format]; supporters < 2*f+1 { +// support is the number of oracles whose last advertisement named each report +// format, counted over the cumulative c/codecs record rather than over this +// round's observations alone (see writeCodecSupport). It is read from replicated +// state, not from p.ReportCodecs, so this stays a pure function of it. An +// oracle that cannot encode still computes reportable=true when enough peers +// can. +func formatIsEncodable(support map[llotypes.ReportFormat]int, format llotypes.ReportFormat, f int, channelID llotypes.ChannelID, tally *unreportableTally) bool { + if supporters := support[format]; supporters < 2*f+1 { tally.note("too few oracles advertise a report codec for this format", channelID, "reportFormat", format, "supporters", supporters, "required", 2*f+1) return false @@ -286,12 +286,23 @@ func (o precursor) formatIsEncodable(format llotypes.ReportFormat, f int, channe return true } +// reportValues returns the aggregates a report carries for streams, in order. +// Reportability and emission both read values through it. +func (o precursor) reportValues(streams []llotypes.Stream) []protocol.StreamValue { + values := make([]protocol.StreamValue, 0, len(streams)) + for _, strm := range streams { + values = append(values, o.StreamAggregates[strm.StreamID][strm.Aggregator]) + } + return values +} + // reportableChannels returns the sorted set of channels reportable in this -// (current) round (see isReportable). -func (o precursor) reportableChannels(minReportInterval uint64, f int, optsCache *protocol.OptsCache, tally *unreportableTally) []llotypes.ChannelID { +// (current) round (see isReportable). StateTransition calls it once and carries +// the result in the precursor. +func (o precursor) reportableChannels(minReportInterval uint64, f int, support map[llotypes.ReportFormat]int, optsCache *protocol.OptsCache, tally *unreportableTally) []llotypes.ChannelID { reportable := make([]llotypes.ChannelID, 0, len(o.ChannelDefinitions)) for channelID := range o.ChannelDefinitions { - if o.isReportable(channelID, minReportInterval, f, optsCache, tally) { + if o.isReportable(channelID, minReportInterval, f, support, optsCache, tally) { reportable = append(reportable, channelID) } } @@ -299,7 +310,7 @@ func (o precursor) reportableChannels(minReportInterval uint64, f int, optsCache return reportable } -func (o precursor) isReportable(channelID llotypes.ChannelID, minReportInterval uint64, f int, optsCache *protocol.OptsCache, tally *unreportableTally) bool { +func (o precursor) isReportable(channelID llotypes.ChannelID, minReportInterval uint64, f int, support map[llotypes.ReportFormat]int, optsCache *protocol.OptsCache, tally *unreportableTally) bool { if o.LifeCycleStage == protocol.LifeCycleStageRetired { return false } @@ -336,7 +347,7 @@ func (o precursor) isReportable(channelID llotypes.ChannelID, minReportInterval // Backfill reports are encoded with the target channel's codec, so the // target's format is the one that must be encodable DON-wide. Selection // above already established the target exists. - return o.formatIsEncodable(o.ChannelDefinitions[opts.TargetChannelID].ReportFormat, f, channelID, tally) + return formatIsEncodable(support, o.ChannelDefinitions[opts.TargetChannelID].ReportFormat, f, channelID, tally) } // Reportability and emission must agree on what the report carries. Reports // derives the report's values from protocol.EffectiveStreams, so the same @@ -347,13 +358,15 @@ func (o precursor) isReportable(channelID llotypes.ChannelID, minReportInterval // EffectiveStreams is a pure function of (definition, opts), so it is safe // in the state transition. Codec lookup and Encode are not: p.ReportCodecs // is node-local, and reading it here would make the state transition - // node-dependent. Those failure modes remain outside this predicate. + // node-dependent. The value range rules the codecs apply are checked here + // through protocol.CheckEncodableValues, which the codecs share. Other + // Encode failures remain outside this predicate. // // When one of them fails, Reports skips the channel and the watermark still - // advances over the round. This is deliberate. A deterministic failure fails - // again in every round, so holding the watermark would recover nothing and - // only widen the next report's interval. Reports logs each skip at error - // level and counts it in reportsNotEmittedMetric. + // advances over the round. This is deliberate. Those failures come from + // configuration and fail again in every round, so holding the watermark + // would recover nothing and only widen the next report's interval. Reports + // logs each skip at error level and counts it in reportsNotEmittedMetric. streams, err := protocol.EffectiveStreams(optsCache, cd, channelID) if err != nil { tally.note("cannot derive effective streams", channelID, "err", err) @@ -367,13 +380,21 @@ func (o precursor) isReportable(channelID llotypes.ChannelID, minReportInterval // // This applies regardless of DisableNilStreamValues, which v31 ignores and // treats as always set. - for _, strm := range streams { - if o.StreamAggregates[strm.StreamID][strm.Aggregator] == nil { - tally.note("nil stream value", channelID, "streamID", strm.StreamID, "aggregator", strm.Aggregator) + values := o.reportValues(streams) + for i, v := range values { + if v == nil { + tally.note("nil stream value", channelID, "streamID", streams[i].StreamID, "aggregator", streams[i].Aggregator) return false } } - if !o.formatIsEncodable(cd.ReportFormat, f, channelID, tally) { + // A value outside the integer range its format encodes it in fails Encode. + // It depends on the data, so it can come back into range. Holding the + // watermark over such a round lets the next report cover it. + if err := protocol.CheckEncodableValues(optsCache, cd, channelID, values); err != nil { + tally.note("stream value not encodable", channelID, "err", err) + return false + } + if !formatIsEncodable(support, cd.ReportFormat, f, channelID, tally) { return false } validAfter, ok := o.ValidAfterNanoseconds[channelID] diff --git a/llo/dev/v31/statetransition.go b/llo/dev/v31/statetransition.go index 59f311c..125a203 100644 --- a/llo/dev/v31/statetransition.go +++ b/llo/dev/v31/statetransition.go @@ -104,7 +104,6 @@ func (p *Plugin) StateTransition(ctx context.Context, seqNr uint64, _ ocrtypes.A ChannelStateSeqNr: prev.channelStateSeqNr, ValidAfterNanoseconds: map[llotypes.ChannelID]uint64{}, StreamAggregates: protocol.StreamAggregates{}, - SupportByFormat: supportVotesForEffectiveFormats(countSupportByFormat(codecSupport), effective), } p.warnIfAheadOfLocalClock(out.ObservationTimestampNanoseconds, seqNr) @@ -240,6 +239,13 @@ func (p *Plugin) StateTransition(ctx context.Context, seqNr uint64, _ ocrtypes.A p.ExpressionPins.Set(prev.opts, effective) calculated.ProcessCalculatedStreams(p.Logger, effective, out.StreamAggregates, out.ObservationTimestampNanoseconds, prev.opts, history) + // Reportability is decided once, here. Reports emits exactly these channels + // and the next round advances validAfter from the same decision. + unreportable := &unreportableTally{} + support := supportVotesForEffectiveFormats(countSupportByFormat(codecSupport), effective) + out.ReportableChannelIDs = out.reportableChannels(p.DefaultMinReportIntervalNanoseconds, p.F, support, prev.opts, unreportable) + unreportable.log(p.Logger, "StateTransition", seqNr) + // Flush KV mutations. if err := p.flushKV(kvRW, seqNr, prev, out, pending, codecSupport, carryForward, history); err != nil { return nil, err @@ -254,6 +260,9 @@ func (p *Plugin) StateTransition(ctx context.Context, seqNr uint64, _ ocrtypes.A p.captureInsufficientHistory(effective, prev.opts, history) p.captureOutcomeTelemetry(out, seqNr) p.captureAttributedObservationTelemetry(out, seqNr, tally.attributed) + + // After telemetry, which reports every aggregate. + out.StreamAggregates = out.reportedAggregates(prev.opts) return encodePrecursor(out) } @@ -759,12 +768,11 @@ func (p *Plugin) flushKV( } } - // Reportability: persist this round's decision for each channel so the next - // round can advance validAfter faithfully (see prevReportable). - reportable := make(map[llotypes.ChannelID]bool, len(out.ChannelDefinitions)) - for id := range out.ChannelDefinitions { - // nill tally, Reports() will handle the logging - reportable[id] = out.isReportable(id, p.DefaultMinReportIntervalNanoseconds, p.F, prev.opts, nil) + // Reportability: persist this round's decision so the next round can + // advance validAfter faithfully (see prevReportable). + reportable := make(map[llotypes.ChannelID]bool, len(out.ReportableChannelIDs)) + for _, id := range out.ReportableChannelIDs { + reportable[id] = true } // Stream history: write modified windows, delete pairs no live channel diff --git a/llo/dev/v31/telemetry_test.go b/llo/dev/v31/telemetry_test.go index c29abac..6a9ad1b 100644 --- a/llo/dev/v31/telemetry_test.go +++ b/llo/dev/v31/telemetry_test.go @@ -662,7 +662,7 @@ func Test_Reports_StagingMarker(t *testing.T) { }, ValidAfterNanoseconds: map[llotypes.ChannelID]uint64{1: 1000}, StreamAggregates: protocol.StreamAggregates{100: {llotypes.AggregatorMedian: protocol.ToDecimal(decimal.NewFromInt(1))}}, - SupportByFormat: map[llotypes.ReportFormat]int{llotypes.ReportFormatJSON: 4}, + ReportableChannelIDs: []llotypes.ChannelID{1}, }) require.NoError(t, err) diff --git a/llo/dev/v31/testdata/golden/precursor_full.bin b/llo/dev/v31/testdata/golden/precursor_full.bin index e93d657b21657c104d16a873e62f007a691ef5fa..9e6e9f8fb39fa947b38bf369cbca7dab9a762fa7 100644 GIT binary patch delta 10 RcmX@exRY_hHfAP9W&jj%0?Ysa delta 18 ZcmdnVc#v_zHf|OUMge9k77iu>762`}14;k@ diff --git a/llo/protocol/abi.go b/llo/protocol/abi.go new file mode 100644 index 0000000..b5d3952 --- /dev/null +++ b/llo/protocol/abi.go @@ -0,0 +1,165 @@ +package protocol + +import ( + "errors" + "fmt" + "math/big" + + "github.com/goccy/go-json" + "github.com/shopspring/decimal" + + "github.com/smartcontractkit/chainlink-data-streams/llo/ubig" +) + +// ABIZeroBytes is the ABI type that packed encoding emits as no bytes, so any +// value is encodable under it. Padded encoding does not accept it. +const ABIZeroBytes = "bytes0" + +// SolidityInt is a Solidity integer type, uintN or intN with N a multiple of 8 +// up to 256. +type SolidityInt struct { + Name string + Signed bool + Bits uint + min *big.Int + max *big.Int +} + +// solidityInts holds every Solidity integer type with its bounds computed once, +// so range checks in the state transition do no parsing. +var solidityInts = func() map[string]SolidityInt { + m := make(map[string]SolidityInt, 64) + one := big.NewInt(1) + for bits := uint(8); bits <= 256; bits += 8 { + name := fmt.Sprintf("uint%d", bits) + m[name] = SolidityInt{ + Name: name, + Bits: bits, + min: new(big.Int), + max: new(big.Int).Sub(new(big.Int).Lsh(one, bits), one), + } + name = fmt.Sprintf("int%d", bits) + half := new(big.Int).Lsh(one, bits-1) + m[name] = SolidityInt{ + Name: name, + Signed: true, + Bits: bits, + min: new(big.Int).Neg(half), + max: new(big.Int).Sub(half, one), + } + } + return m +}() + +// ParseSolidityInt returns the Solidity integer type named t. +func ParseSolidityInt(t string) (SolidityInt, error) { + s, ok := solidityInts[t] + if !ok { + return SolidityInt{}, fmt.Errorf("invalid Solidity type: %s", t) + } + return s, nil +} + +// Check returns an error when v is outside the range of s. +func (s SolidityInt) Check(v *big.Int) error { + if !s.Signed && v.Sign() < 0 { + return fmt.Errorf("negative value provided for unsigned type %s", s.Name) + } + if v.Cmp(s.min) < 0 || v.Cmp(s.max) > 0 { + return fmt.Errorf("value %s out of range for type %s", v.String(), s.Name) + } + return nil +} + +// CheckSolidityInt returns an error when t is not a Solidity integer type or v +// is outside its range. +func CheckSolidityInt(v *big.Int, t string) error { + s, err := ParseSolidityInt(t) + if err != nil { + return err + } + return s.Check(v) +} + +// ABIElement is the part of an ABI encoder spec that decides whether a value +// can be encoded: its type and the multiplier applied before encoding. +type ABIElement struct { + Type string `json:"type"` + Multiplier *ubig.Big `json:"multiplier"` +} + +// Scale applies the multiplier to d and truncates toward zero, which is the +// integer the codecs encode. A nil multiplier is 1. A negative one flips the +// sign. +func (e ABIElement) Scale(d decimal.Decimal) *big.Int { + if e.Multiplier == nil { + return d.BigInt() + } + return d.Mul(decimal.NewFromBigInt(e.Multiplier.ToInt(), 0)).BigInt() +} + +// check returns an error when the scaled d does not fit the element's type. +// packed allows ABIZeroBytes, which packed encoding emits as nothing. +func (e ABIElement) check(d decimal.Decimal, packed bool) error { + if packed && e.Type == ABIZeroBytes { + return nil + } + return CheckSolidityInt(e.Scale(d), e.Type) +} + +// ABISpec is one entry of an ABI opt: a single element for a scalar value, or +// a list of elements for a composite value. JSON accepts either an object or an +// array of objects. +type ABISpec []ABIElement + +// UnmarshalJSON accepts a single element or an array of elements. +func (s *ABISpec) UnmarshalJSON(data []byte) error { + var elems []ABIElement + if err := json.Unmarshal(data, &elems); err == nil { + *s = elems + return nil + } + var elem ABIElement + if err := json.Unmarshal(data, &elem); err != nil { + return fmt.Errorf("failed to unmarshal ABI spec: %w", err) + } + *s = ABISpec{elem} + return nil +} + +// CheckValue returns an error when the codecs cannot encode sv under s. It +// follows the encoders' layout: a Decimal takes one element, a +// TimestampedStreamValue takes two, the observation time and then the nested +// Decimal. packed selects packed encoding, which also accepts ABIZeroBytes. +func (s ABISpec) CheckValue(sv StreamValue, packed bool) error { + switch v := sv.(type) { + case *Decimal: + if len(s) != 1 { + return fmt.Errorf("expected exactly one encoder; got: %d", len(s)) + } + if v == nil { + return errors.New("expected non-nil *Decimal") + } + return s[0].check(v.Decimal(), packed) + case *TimestampedStreamValue: + if len(s) != 2 { + return fmt.Errorf("expected exactly two encoders for *protocol.TimestampedStreamValue; got: %d", len(s)) + } + if v == nil { + return errors.New("expected non-nil *TimestampedStreamValue") + } + if err := s[0].check(decimal.NewFromBigInt(new(big.Int).SetUint64(v.ObservedAtNanoseconds), 0), packed); err != nil { + return fmt.Errorf("observation timestamp: %w", err) + } + d, ok := v.StreamValue.(*Decimal) + if !ok || d == nil { + return fmt.Errorf("nested value must be a non-nil *protocol.Decimal; got: %T", v.StreamValue) + } + if err := s[1].check(d.Decimal(), packed); err != nil { + return fmt.Errorf("nested value: %w", err) + } + return nil + default: + return fmt.Errorf("unhandled type; supported types are: *protocol.Decimal or *protocol.TimestampedStreamValue; got: %T", sv) + } +} diff --git a/llo/protocol/abi_test.go b/llo/protocol/abi_test.go new file mode 100644 index 0000000..f237523 --- /dev/null +++ b/llo/protocol/abi_test.go @@ -0,0 +1,97 @@ +package protocol + +import ( + "math/big" + "testing" + + "github.com/goccy/go-json" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-data-streams/llo/ubig" +) + +func TestSolidityInt_Check(t *testing.T) { + pow2 := func(n uint) *big.Int { return new(big.Int).Lsh(big.NewInt(1), n) } + sub := func(a *big.Int, b int64) *big.Int { return new(big.Int).Sub(a, big.NewInt(b)) } + neg := func(a *big.Int) *big.Int { return new(big.Int).Neg(a) } + + for _, tc := range []struct { + typ string + v *big.Int + err string + }{ + {"uint8", big.NewInt(0), ""}, + {"uint8", big.NewInt(255), ""}, + {"uint8", big.NewInt(256), "value 256 out of range for type uint8"}, + {"uint8", big.NewInt(-1), "negative value provided for unsigned type uint8"}, + {"int8", big.NewInt(127), ""}, + {"int8", big.NewInt(-128), ""}, + {"int8", big.NewInt(128), "out of range for type int8"}, + {"int8", big.NewInt(-129), "out of range for type int8"}, + {"uint256", sub(pow2(256), 1), ""}, + {"uint256", pow2(256), "out of range for type uint256"}, + {"int192", sub(pow2(191), 1), ""}, + {"int192", pow2(191), "out of range for type int192"}, + {"int192", neg(pow2(191)), ""}, + {"int192", sub(neg(pow2(191)), 1), "out of range for type int192"}, + {"uint7", big.NewInt(0), "invalid Solidity type: uint7"}, + {"int264", big.NewInt(0), "invalid Solidity type: int264"}, + {"bytes0", big.NewInt(0), "invalid Solidity type: bytes0"}, + {"bool", big.NewInt(0), "invalid Solidity type: bool"}, + } { + t.Run(tc.typ+"/"+tc.v.String(), func(t *testing.T) { + err := CheckSolidityInt(tc.v, tc.typ) + if tc.err == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, tc.err) + } + }) + } +} + +func TestABIElement_Scale(t *testing.T) { + d := decimal.RequireFromString("-1.239") + require.Equal(t, big.NewInt(-1), ABIElement{}.Scale(d), "nil multiplier is 1 and truncates toward zero") + require.Equal(t, big.NewInt(-123), ABIElement{Multiplier: ubig.NewI(100)}.Scale(d)) + require.Equal(t, big.NewInt(123), ABIElement{Multiplier: ubig.NewI(-100)}.Scale(d)) +} + +func TestABISpec_UnmarshalJSON(t *testing.T) { + var single ABISpec + require.NoError(t, json.Unmarshal([]byte(`{"type":"int192","multiplier":"100","expression":"s1"}`), &single)) + require.Equal(t, ABISpec{{Type: "int192", Multiplier: ubig.NewI(100)}}, single) + + var composite ABISpec + require.NoError(t, json.Unmarshal([]byte(`[{"type":"uint64"},{"type":"int192"}]`), &composite)) + require.Equal(t, ABISpec{{Type: "uint64"}, {Type: "int192"}}, composite) + + var bad ABISpec + require.Error(t, json.Unmarshal([]byte(`"int192"`), &bad)) +} + +func TestABISpec_CheckValue(t *testing.T) { + dec := func(s string) *Decimal { return ToDecimal(decimal.RequireFromString(s)) } + uint8Spec := ABISpec{{Type: "uint8"}} + + require.NoError(t, uint8Spec.CheckValue(dec("255.9"), false)) + require.ErrorContains(t, uint8Spec.CheckValue(dec("256"), false), "out of range") + require.ErrorContains(t, uint8Spec.CheckValue(dec("-1"), false), "negative value") + require.ErrorContains(t, ABISpec{{Type: "uint8", Multiplier: ubig.NewI(10)}}.CheckValue(dec("25.6"), false), "out of range") + require.ErrorContains(t, uint8Spec.CheckValue((*Decimal)(nil), false), "non-nil") + require.ErrorContains(t, uint8Spec.CheckValue(&Quote{}, false), "unhandled type") + require.ErrorContains(t, ABISpec{{Type: "uint8"}, {Type: "uint8"}}.CheckValue(dec("1"), false), "exactly one encoder") + + // Only packed encoding accepts bytes0, and then for any value. + zero := ABISpec{{Type: ABIZeroBytes}} + require.NoError(t, zero.CheckValue(dec("-1e100"), true)) + require.ErrorContains(t, zero.CheckValue(dec("1"), false), "invalid Solidity type") + + ts := ABISpec{{Type: "uint64"}, {Type: "int8"}} + require.NoError(t, ts.CheckValue(&TimestampedStreamValue{ObservedAtNanoseconds: 1_700_000_000_000_000_000, StreamValue: dec("-128")}, false)) + require.ErrorContains(t, ts.CheckValue(&TimestampedStreamValue{ObservedAtNanoseconds: 1, StreamValue: dec("128")}, false), "nested value") + require.ErrorContains(t, ABISpec{{Type: "uint32"}, {Type: "int8"}}.CheckValue(&TimestampedStreamValue{ObservedAtNanoseconds: 1_700_000_000_000_000_000, StreamValue: dec("1")}, false), "observation timestamp") + require.ErrorContains(t, ts.CheckValue(&TimestampedStreamValue{ObservedAtNanoseconds: 1}, false), "nested value must be") + require.ErrorContains(t, uint8Spec.CheckValue(&TimestampedStreamValue{ObservedAtNanoseconds: 1, StreamValue: dec("1")}, false), "exactly two encoders") +} diff --git a/llo/protocol/encodable.go b/llo/protocol/encodable.go new file mode 100644 index 0000000..04271c4 --- /dev/null +++ b/llo/protocol/encodable.go @@ -0,0 +1,109 @@ +package protocol + +import ( + "errors" + "fmt" + + "github.com/shopspring/decimal" + + llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" + + "github.com/smartcontractkit/chainlink-data-streams/llo/ubig" +) + +// abiPayloadOpts reads the ABI of the formats that encode a payload from it. +type abiPayloadOpts struct { + ABI []ABISpec `json:"abi"` +} + +// premiumLegacyOpts reads the price multiplier of ReportFormatEVMPremiumLegacy. +type premiumLegacyOpts struct { + Multiplier *ubig.Big `json:"multiplier"` +} + +// ABIPayload returns the report values a format encodes in its ABI payload, in +// ABI order, and nil for a format without one. The codecs slice their payload +// through it, so the reportability check reads the same values they encode. +func ABIPayload(format llotypes.ReportFormat, abiLen int, values []StreamValue) ([]StreamValue, error) { + switch format { + case llotypes.ReportFormatEVMABIEncodeUnpacked: + // Native and link prices come first. + if len(values) < 2 || len(values)-2 != abiLen { + return nil, fmt.Errorf("ABI and values length mismatch; ABI: %d, Values: %d", abiLen, len(values)-2) + } + return values[2:], nil + case llotypes.ReportFormatEVMABIEncodeUnpackedExpr: + // The trailing values are the calculated streams (see EffectiveStreams). + if abiLen == 0 || len(values) < abiLen { + return nil, fmt.Errorf("not enough values for calculated streams; expected at least: %d, got: %d", abiLen, len(values)) + } + return values[len(values)-abiLen:], nil + case llotypes.ReportFormatEVMStreamlined: + if len(values) != abiLen { + return nil, fmt.Errorf("ABI and values length mismatch; ABI: %d, Values: %d", abiLen, len(values)) + } + return values, nil + default: + return nil, nil + } +} + +// CheckEncodableValues returns an error when a value of a report for cd falls +// outside the integer range its format encodes it in. Such a value depends on +// the data, so it can come back into range in a later round. +// +// It reads only cd's opts and the values, so it is deterministic and safe in the +// state transition. Formats without an integer encoding always pass. +func CheckEncodableValues(optsCache *OptsCache, cd llotypes.ChannelDefinition, channelID llotypes.ChannelID, values []StreamValue) error { + switch cd.ReportFormat { + case llotypes.ReportFormatEVMABIEncodeUnpacked, llotypes.ReportFormatEVMABIEncodeUnpackedExpr, llotypes.ReportFormatEVMStreamlined: + o, err := GetOptsOrDecode[abiPayloadOpts](optsCache, cd, channelID) + if err != nil { + return fmt.Errorf("failed to decode ABI opts: %w", err) + } + payload, err := ABIPayload(cd.ReportFormat, len(o.ABI), values) + if err != nil { + return err + } + packed := cd.ReportFormat == llotypes.ReportFormatEVMStreamlined + for i, spec := range o.ABI { + if err := spec.CheckValue(payload[i], packed); err != nil { + return fmt.Errorf("ABI index %d: %w", i, err) + } + } + return nil + case llotypes.ReportFormatEVMPremiumLegacy: + return checkPremiumLegacyValues(optsCache, cd, channelID, values) + default: + return nil + } +} + +// checkPremiumLegacyValues checks the quote, which ReportFormatEVMPremiumLegacy +// encodes as int192 benchmark, bid and ask after the opts multiplier. +func checkPremiumLegacyValues(optsCache *OptsCache, cd llotypes.ChannelDefinition, channelID llotypes.ChannelID, values []StreamValue) error { + if len(values) != 3 { + return fmt.Errorf("expected exactly 3 values (NativePrice, LinkPrice, Quote); got: %d", len(values)) + } + q, ok := values[2].(*Quote) + if !ok || q == nil { + return fmt.Errorf("expected third value to be a non-nil *Quote; got: %T", values[2]) + } + o, err := GetOptsOrDecode[premiumLegacyOpts](optsCache, cd, channelID) + if err != nil { + return fmt.Errorf("failed to decode opts: %w", err) + } + if o.Multiplier != nil && o.Multiplier.IsZero() { + return errors.New("multiplier, if specified in channel opts, must be non-zero") + } + price := ABIElement{Type: "int192", Multiplier: o.Multiplier} + for _, f := range []struct { + name string + v decimal.Decimal + }{{"benchmark", q.Benchmark}, {"bid", q.Bid}, {"ask", q.Ask}} { + if err := price.check(f.v, false); err != nil { + return fmt.Errorf("quote %s: %w", f.name, err) + } + } + return nil +} diff --git a/llo/protocol/encodable_test.go b/llo/protocol/encodable_test.go new file mode 100644 index 0000000..eb7d879 --- /dev/null +++ b/llo/protocol/encodable_test.go @@ -0,0 +1,107 @@ +package protocol + +import ( + "testing" + + "github.com/shopspring/decimal" + "github.com/stretchr/testify/require" + + llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" +) + +func TestABIPayload(t *testing.T) { + v := func(n int64) StreamValue { return ToDecimal(decimal.NewFromInt(n)) } + values := []StreamValue{v(1), v(2), v(3), v(4)} + + got, err := ABIPayload(llotypes.ReportFormatEVMABIEncodeUnpacked, 2, values) + require.NoError(t, err) + require.Equal(t, values[2:], got) + _, err = ABIPayload(llotypes.ReportFormatEVMABIEncodeUnpacked, 3, values) + require.ErrorContains(t, err, "length mismatch") + _, err = ABIPayload(llotypes.ReportFormatEVMABIEncodeUnpacked, 0, values[:1]) + require.ErrorContains(t, err, "length mismatch") + + got, err = ABIPayload(llotypes.ReportFormatEVMABIEncodeUnpackedExpr, 1, values) + require.NoError(t, err) + require.Equal(t, values[3:], got) + _, err = ABIPayload(llotypes.ReportFormatEVMABIEncodeUnpackedExpr, 5, values) + require.ErrorContains(t, err, "not enough values") + _, err = ABIPayload(llotypes.ReportFormatEVMABIEncodeUnpackedExpr, 0, values) + require.ErrorContains(t, err, "not enough values") + + got, err = ABIPayload(llotypes.ReportFormatEVMStreamlined, 4, values) + require.NoError(t, err) + require.Equal(t, values, got) + _, err = ABIPayload(llotypes.ReportFormatEVMStreamlined, 3, values) + require.ErrorContains(t, err, "length mismatch") + + got, err = ABIPayload(llotypes.ReportFormatJSON, 0, values) + require.NoError(t, err) + require.Nil(t, got) +} + +func TestCheckEncodableValues(t *testing.T) { + dec := func(s string) StreamValue { return ToDecimal(decimal.RequireFromString(s)) } + quote := func(bid, benchmark, ask string) StreamValue { + return &Quote{Bid: decimal.RequireFromString(bid), Benchmark: decimal.RequireFromString(benchmark), Ask: decimal.RequireFromString(ask)} + } + // 2^191, the first value past int192. + const int192Overflow = "3138550867693340381917894711603833208051177722232017256448" + + for _, tc := range []struct { + name string + format llotypes.ReportFormat + opts string + values []StreamValue + err string + }{ + {"unpacked in range", llotypes.ReportFormatEVMABIEncodeUnpacked, `{"abi":[{"type":"uint32","multiplier":"100"}]}`, + []StreamValue{dec("1"), dec("1"), dec("42.5")}, ""}, + {"unpacked negative for unsigned", llotypes.ReportFormatEVMABIEncodeUnpacked, `{"abi":[{"type":"uint32"}]}`, + []StreamValue{dec("1"), dec("1"), dec("-1")}, "ABI index 0: negative value"}, + {"unpacked over range after multiplier", llotypes.ReportFormatEVMABIEncodeUnpacked, `{"abi":[{"type":"uint32","multiplier":"1000000000"}]}`, + []StreamValue{dec("1"), dec("1"), dec("5")}, "out of range for type uint32"}, + {"unpacked native and link prices are not range checked", llotypes.ReportFormatEVMABIEncodeUnpacked, `{"abi":[{"type":"uint8"}]}`, + []StreamValue{dec("-1"), dec("1e100"), dec("1")}, ""}, + {"unpacked length mismatch", llotypes.ReportFormatEVMABIEncodeUnpacked, `{"abi":[{"type":"uint8"}]}`, + []StreamValue{dec("1"), dec("1")}, "length mismatch"}, + {"unpacked bytes0 is padded", llotypes.ReportFormatEVMABIEncodeUnpacked, `{"abi":[{"type":"bytes0"}]}`, + []StreamValue{dec("1"), dec("1"), dec("1")}, "invalid Solidity type"}, + {"unpacked opts do not decode", llotypes.ReportFormatEVMABIEncodeUnpacked, `{"abi":`, + []StreamValue{dec("1"), dec("1"), dec("1")}, "failed to decode ABI opts"}, + {"expr checks the trailing values", llotypes.ReportFormatEVMABIEncodeUnpackedExpr, `{"abi":[{"type":"int8","expression":"s1","expressionStreamID":9}]}`, + []StreamValue{dec("1"), dec("1"), dec("1000"), dec("-128")}, ""}, + {"expr out of range", llotypes.ReportFormatEVMABIEncodeUnpackedExpr, `{"abi":[{"type":"int8","expression":"s1","expressionStreamID":9}]}`, + []StreamValue{dec("1"), dec("1"), dec("-129")}, "out of range for type int8"}, + {"streamlined bytes0 is packed", llotypes.ReportFormatEVMStreamlined, `{"abi":[{"type":"bytes0"},{"type":"uint8"}]}`, + []StreamValue{dec("-1"), dec("255")}, ""}, + {"streamlined out of range", llotypes.ReportFormatEVMStreamlined, `{"abi":[{"type":"bytes0"},{"type":"uint8"}]}`, + []StreamValue{dec("-1"), dec("256")}, "ABI index 1"}, + {"legacy in range", llotypes.ReportFormatEVMPremiumLegacy, ``, + []StreamValue{dec("1"), dec("1"), quote("1", "2", "3")}, ""}, + {"legacy multiplier pushes past int192", llotypes.ReportFormatEVMPremiumLegacy, `{"multiplier":"1000000000000000000"}`, + []StreamValue{dec("1"), dec("1"), quote("1", "1e40", "3")}, "quote benchmark"}, + {"legacy bid past int192", llotypes.ReportFormatEVMPremiumLegacy, ``, + []StreamValue{dec("1"), dec("1"), quote(int192Overflow, "1", "1")}, "quote bid"}, + {"legacy zero multiplier", llotypes.ReportFormatEVMPremiumLegacy, `{"multiplier":"0"}`, + []StreamValue{dec("1"), dec("1"), quote("1", "1", "1")}, "must be non-zero"}, + {"legacy not a quote", llotypes.ReportFormatEVMPremiumLegacy, ``, + []StreamValue{dec("1"), dec("1"), dec("1")}, "non-nil *Quote"}, + {"json has no integer encoding", llotypes.ReportFormatJSON, ``, + []StreamValue{dec("-1e100")}, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + cd := llotypes.ChannelDefinition{ReportFormat: tc.format, Opts: []byte(tc.opts)} + cache := NewOptsCache() + cache.ResetTo(llotypes.ChannelDefinitions{1: cd}) + for _, c := range []*OptsCache{cache, nil} { + err := CheckEncodableValues(c, cd, 1, tc.values) + if tc.err == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, tc.err) + } + } + }) + } +} diff --git a/llo/protocol/plugin_codecs.pb.go b/llo/protocol/plugin_codecs.pb.go index 28bf1b3..0502b80 100644 --- a/llo/protocol/plugin_codecs.pb.go +++ b/llo/protocol/plugin_codecs.pb.go @@ -1278,9 +1278,14 @@ func (x *LLOHotStateProto) GetStreamAggregates() []*LLOStreamAggregate { // from, so that consumers can tell whether their decoded-opts cache already // matches these definitions. // +// reportableChannelIDs is the reportability decision StateTransition made for +// the round, so Reports emits exactly the channels whose watermark advances. +// streamAggregates carries only the values those channels report. +// // NOTE: must serialize deterministically, hence use of repeated tuple instead of -// maps. channelDefinitions and validAfterNanoseconds MUST be sorted ascending by -// channelID; streamAggregates MUST be sorted ascending by (streamID, aggregator). +// maps. channelDefinitions, validAfterNanoseconds and reportableChannelIDs MUST +// be sorted ascending by channelID; streamAggregates MUST be sorted ascending by +// (streamID, aggregator). type LLOPrecursorProto struct { state protoimpl.MessageState `protogen:"open.v1"` LifeCycleStage string `protobuf:"bytes,1,opt,name=lifeCycleStage,proto3" json:"lifeCycleStage,omitempty"` @@ -1289,10 +1294,9 @@ type LLOPrecursorProto struct { ValidAfterNanoseconds []*LLOChannelIDAndValidAfterNanosecondsProto `protobuf:"bytes,4,rep,name=validAfterNanoseconds,proto3" json:"validAfterNanoseconds,omitempty"` StreamAggregates []*LLOStreamAggregate `protobuf:"bytes,5,rep,name=streamAggregates,proto3" json:"streamAggregates,omitempty"` ChannelStateSeqNr uint64 `protobuf:"varint,6,opt,name=channelStateSeqNr,proto3" json:"channelStateSeqNr,omitempty"` - // supportByFormat MUST be sorted ascending by reportFormat. - SupportByFormat []*LLOReportFormatSupportProto `protobuf:"bytes,7,rep,name=supportByFormat,proto3" json:"supportByFormat,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + ReportableChannelIDs []uint32 `protobuf:"varint,7,rep,packed,name=reportableChannelIDs,proto3" json:"reportableChannelIDs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *LLOPrecursorProto) Reset() { @@ -1367,67 +1371,13 @@ func (x *LLOPrecursorProto) GetChannelStateSeqNr() uint64 { return 0 } -func (x *LLOPrecursorProto) GetSupportByFormat() []*LLOReportFormatSupportProto { +func (x *LLOPrecursorProto) GetReportableChannelIDs() []uint32 { if x != nil { - return x.SupportByFormat + return x.ReportableChannelIDs } return nil } -// LLOReportFormatSupportProto is the number of oracles that advertised a report -// codec for one report format in the round that produced the precursor. -type LLOReportFormatSupportProto struct { - state protoimpl.MessageState `protogen:"open.v1"` - ReportFormat uint32 `protobuf:"varint,1,opt,name=reportFormat,proto3" json:"reportFormat,omitempty"` - OracleCount uint32 `protobuf:"varint,2,opt,name=oracleCount,proto3" json:"oracleCount,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *LLOReportFormatSupportProto) Reset() { - *x = LLOReportFormatSupportProto{} - mi := &file_plugin_codecs_proto_msgTypes[19] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *LLOReportFormatSupportProto) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*LLOReportFormatSupportProto) ProtoMessage() {} - -func (x *LLOReportFormatSupportProto) ProtoReflect() protoreflect.Message { - mi := &file_plugin_codecs_proto_msgTypes[19] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use LLOReportFormatSupportProto.ProtoReflect.Descriptor instead. -func (*LLOReportFormatSupportProto) Descriptor() ([]byte, []int) { - return file_plugin_codecs_proto_rawDescGZIP(), []int{19} -} - -func (x *LLOReportFormatSupportProto) GetReportFormat() uint32 { - if x != nil { - return x.ReportFormat - } - return 0 -} - -func (x *LLOReportFormatSupportProto) GetOracleCount() uint32 { - if x != nil { - return x.OracleCount - } - return 0 -} - // LLOCodecSupportProto is the v31 KeyValueState record (c/codecs) holding the // report formats each oracle last advertised a codec for. // NOTE: must serialize deterministically. oracles MUST be sorted ascending by @@ -1441,7 +1391,7 @@ type LLOCodecSupportProto struct { func (x *LLOCodecSupportProto) Reset() { *x = LLOCodecSupportProto{} - mi := &file_plugin_codecs_proto_msgTypes[20] + mi := &file_plugin_codecs_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1453,7 +1403,7 @@ func (x *LLOCodecSupportProto) String() string { func (*LLOCodecSupportProto) ProtoMessage() {} func (x *LLOCodecSupportProto) ProtoReflect() protoreflect.Message { - mi := &file_plugin_codecs_proto_msgTypes[20] + mi := &file_plugin_codecs_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1466,7 +1416,7 @@ func (x *LLOCodecSupportProto) ProtoReflect() protoreflect.Message { // Deprecated: Use LLOCodecSupportProto.ProtoReflect.Descriptor instead. func (*LLOCodecSupportProto) Descriptor() ([]byte, []int) { - return file_plugin_codecs_proto_rawDescGZIP(), []int{20} + return file_plugin_codecs_proto_rawDescGZIP(), []int{19} } func (x *LLOCodecSupportProto) GetOracles() []*LLOOracleCodecSupportProto { @@ -1488,7 +1438,7 @@ type LLOOracleCodecSupportProto struct { func (x *LLOOracleCodecSupportProto) Reset() { *x = LLOOracleCodecSupportProto{} - mi := &file_plugin_codecs_proto_msgTypes[21] + mi := &file_plugin_codecs_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1500,7 +1450,7 @@ func (x *LLOOracleCodecSupportProto) String() string { func (*LLOOracleCodecSupportProto) ProtoMessage() {} func (x *LLOOracleCodecSupportProto) ProtoReflect() protoreflect.Message { - mi := &file_plugin_codecs_proto_msgTypes[21] + mi := &file_plugin_codecs_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1513,7 +1463,7 @@ func (x *LLOOracleCodecSupportProto) ProtoReflect() protoreflect.Message { // Deprecated: Use LLOOracleCodecSupportProto.ProtoReflect.Descriptor instead. func (*LLOOracleCodecSupportProto) Descriptor() ([]byte, []int) { - return file_plugin_codecs_proto_rawDescGZIP(), []int{21} + return file_plugin_codecs_proto_rawDescGZIP(), []int{20} } func (x *LLOOracleCodecSupportProto) GetOracleID() uint32 { @@ -1627,18 +1577,15 @@ const file_plugin_codecs_proto_rawDesc = "" + "\x1fobservationTimestampNanoseconds\x18\x01 \x01(\x04R\x1fobservationTimestampNanoseconds\x12c\n" + "\x15validAfterNanoseconds\x18\x02 \x03(\v2-.v1.LLOChannelIDAndValidAfterNanosecondsProtoR\x15validAfterNanoseconds\x122\n" + "\x14reportableChannelIDs\x18\x03 \x03(\rR\x14reportableChannelIDs\x12B\n" + - "\x10streamAggregates\x18\x04 \x03(\v2\x16.v1.LLOStreamAggregateR\x10streamAggregates\"\xfb\x03\n" + + "\x10streamAggregates\x18\x04 \x03(\v2\x16.v1.LLOStreamAggregateR\x10streamAggregates\"\xe4\x03\n" + "\x11LLOPrecursorProto\x12&\n" + "\x0elifeCycleStage\x18\x01 \x01(\tR\x0elifeCycleStage\x12H\n" + "\x1fobservationTimestampNanoseconds\x18\x02 \x01(\x04R\x1fobservationTimestampNanoseconds\x12R\n" + "\x12channelDefinitions\x18\x03 \x03(\v2\".v1.LLOChannelIDAndDefinitionProtoR\x12channelDefinitions\x12c\n" + "\x15validAfterNanoseconds\x18\x04 \x03(\v2-.v1.LLOChannelIDAndValidAfterNanosecondsProtoR\x15validAfterNanoseconds\x12B\n" + "\x10streamAggregates\x18\x05 \x03(\v2\x16.v1.LLOStreamAggregateR\x10streamAggregates\x12,\n" + - "\x11channelStateSeqNr\x18\x06 \x01(\x04R\x11channelStateSeqNr\x12I\n" + - "\x0fsupportByFormat\x18\a \x03(\v2\x1f.v1.LLOReportFormatSupportProtoR\x0fsupportByFormat\"c\n" + - "\x1bLLOReportFormatSupportProto\x12\"\n" + - "\freportFormat\x18\x01 \x01(\rR\freportFormat\x12 \n" + - "\voracleCount\x18\x02 \x01(\rR\voracleCount\"P\n" + + "\x11channelStateSeqNr\x18\x06 \x01(\x04R\x11channelStateSeqNr\x122\n" + + "\x14reportableChannelIDs\x18\a \x03(\rR\x14reportableChannelIDs\"P\n" + "\x14LLOCodecSupportProto\x128\n" + "\aoracles\x18\x01 \x03(\v2\x1e.v1.LLOOracleCodecSupportProtoR\aoracles\"^\n" + "\x1aLLOOracleCodecSupportProto\x12\x1a\n" + @@ -1659,7 +1606,7 @@ func file_plugin_codecs_proto_rawDescGZIP() []byte { } var file_plugin_codecs_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_plugin_codecs_proto_msgTypes = make([]protoimpl.MessageInfo, 24) +var file_plugin_codecs_proto_msgTypes = make([]protoimpl.MessageInfo, 23) var file_plugin_codecs_proto_goTypes = []any{ (LLOStreamValue_Type)(0), // 0: v1.LLOStreamValue.Type (*LLOObservationProto)(nil), // 1: v1.LLOObservationProto @@ -1681,15 +1628,14 @@ var file_plugin_codecs_proto_goTypes = []any{ (*LLOChannelStateProto)(nil), // 17: v1.LLOChannelStateProto (*LLOHotStateProto)(nil), // 18: v1.LLOHotStateProto (*LLOPrecursorProto)(nil), // 19: v1.LLOPrecursorProto - (*LLOReportFormatSupportProto)(nil), // 20: v1.LLOReportFormatSupportProto - (*LLOCodecSupportProto)(nil), // 21: v1.LLOCodecSupportProto - (*LLOOracleCodecSupportProto)(nil), // 22: v1.LLOOracleCodecSupportProto - nil, // 23: v1.LLOObservationProto.UpdateChannelDefinitionsEntry - nil, // 24: v1.LLOObservationProto.StreamValuesEntry + (*LLOCodecSupportProto)(nil), // 20: v1.LLOCodecSupportProto + (*LLOOracleCodecSupportProto)(nil), // 21: v1.LLOOracleCodecSupportProto + nil, // 22: v1.LLOObservationProto.UpdateChannelDefinitionsEntry + nil, // 23: v1.LLOObservationProto.StreamValuesEntry } var file_plugin_codecs_proto_depIdxs = []int32{ - 23, // 0: v1.LLOObservationProto.updateChannelDefinitions:type_name -> v1.LLOObservationProto.UpdateChannelDefinitionsEntry - 24, // 1: v1.LLOObservationProto.streamValues:type_name -> v1.LLOObservationProto.StreamValuesEntry + 22, // 0: v1.LLOObservationProto.updateChannelDefinitions:type_name -> v1.LLOObservationProto.UpdateChannelDefinitionsEntry + 23, // 1: v1.LLOObservationProto.streamValues:type_name -> v1.LLOObservationProto.StreamValuesEntry 0, // 2: v1.LLOStreamValue.type:type_name -> v1.LLOStreamValue.Type 2, // 3: v1.LLOTimestampedStreamValue.streamValue:type_name -> v1.LLOStreamValue 2, // 4: v1.LLOStreamHistoryRecord.value:type_name -> v1.LLOStreamValue @@ -1709,15 +1655,14 @@ var file_plugin_codecs_proto_depIdxs = []int32{ 13, // 18: v1.LLOPrecursorProto.channelDefinitions:type_name -> v1.LLOChannelIDAndDefinitionProto 15, // 19: v1.LLOPrecursorProto.validAfterNanoseconds:type_name -> v1.LLOChannelIDAndValidAfterNanosecondsProto 16, // 20: v1.LLOPrecursorProto.streamAggregates:type_name -> v1.LLOStreamAggregate - 20, // 21: v1.LLOPrecursorProto.supportByFormat:type_name -> v1.LLOReportFormatSupportProto - 22, // 22: v1.LLOCodecSupportProto.oracles:type_name -> v1.LLOOracleCodecSupportProto - 8, // 23: v1.LLOObservationProto.UpdateChannelDefinitionsEntry.value:type_name -> v1.LLOChannelDefinitionProto - 2, // 24: v1.LLOObservationProto.StreamValuesEntry.value:type_name -> v1.LLOStreamValue - 25, // [25:25] is the sub-list for method output_type - 25, // [25:25] is the sub-list for method input_type - 25, // [25:25] is the sub-list for extension type_name - 25, // [25:25] is the sub-list for extension extendee - 0, // [0:25] is the sub-list for field type_name + 21, // 21: v1.LLOCodecSupportProto.oracles:type_name -> v1.LLOOracleCodecSupportProto + 8, // 22: v1.LLOObservationProto.UpdateChannelDefinitionsEntry.value:type_name -> v1.LLOChannelDefinitionProto + 2, // 23: v1.LLOObservationProto.StreamValuesEntry.value:type_name -> v1.LLOStreamValue + 24, // [24:24] is the sub-list for method output_type + 24, // [24:24] is the sub-list for method input_type + 24, // [24:24] is the sub-list for extension type_name + 24, // [24:24] is the sub-list for extension extendee + 0, // [0:24] is the sub-list for field type_name } func init() { file_plugin_codecs_proto_init() } @@ -1731,7 +1676,7 @@ func file_plugin_codecs_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_plugin_codecs_proto_rawDesc), len(file_plugin_codecs_proto_rawDesc)), NumEnums: 1, - NumMessages: 24, + NumMessages: 23, NumExtensions: 0, NumServices: 0, }, diff --git a/llo/protocol/plugin_codecs.proto b/llo/protocol/plugin_codecs.proto index 0d0919c..3a9cfc3 100644 --- a/llo/protocol/plugin_codecs.proto +++ b/llo/protocol/plugin_codecs.proto @@ -214,9 +214,14 @@ message LLOHotStateProto { // from, so that consumers can tell whether their decoded-opts cache already // matches these definitions. // +// reportableChannelIDs is the reportability decision StateTransition made for +// the round, so Reports emits exactly the channels whose watermark advances. +// streamAggregates carries only the values those channels report. +// // NOTE: must serialize deterministically, hence use of repeated tuple instead of -// maps. channelDefinitions and validAfterNanoseconds MUST be sorted ascending by -// channelID; streamAggregates MUST be sorted ascending by (streamID, aggregator). +// maps. channelDefinitions, validAfterNanoseconds and reportableChannelIDs MUST +// be sorted ascending by channelID; streamAggregates MUST be sorted ascending by +// (streamID, aggregator). message LLOPrecursorProto { string lifeCycleStage = 1; uint64 observationTimestampNanoseconds = 2; @@ -224,15 +229,7 @@ message LLOPrecursorProto { repeated LLOChannelIDAndValidAfterNanosecondsProto validAfterNanoseconds = 4; repeated LLOStreamAggregate streamAggregates = 5; uint64 channelStateSeqNr = 6; - // supportByFormat MUST be sorted ascending by reportFormat. - repeated LLOReportFormatSupportProto supportByFormat = 7; -} - -// LLOReportFormatSupportProto is the number of oracles that advertised a report -// codec for one report format in the round that produced the precursor. -message LLOReportFormatSupportProto { - uint32 reportFormat = 1; - uint32 oracleCount = 2; + repeated uint32 reportableChannelIDs = 7; } // LLOCodecSupportProto is the v31 KeyValueState record (c/codecs) holding the diff --git a/llo/reportcodec/evm/encodable_test.go b/llo/reportcodec/evm/encodable_test.go new file mode 100644 index 0000000..484563f --- /dev/null +++ b/llo/reportcodec/evm/encodable_test.go @@ -0,0 +1,212 @@ +package evm + +import ( + "fmt" + "math/big" + "strings" + "testing" + + "github.com/shopspring/decimal" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" + + protocol "github.com/smartcontractkit/chainlink-data-streams/llo/protocol" + v3 "github.com/smartcontractkit/chainlink-data-streams/llo/reportcodec/evm/v3" +) + +const testFeedID = `"0x0001000000000000000000000000000000000000000000000000000000000001"` + +// The reportability check and Encode must agree: a value the check accepts must +// encode, and one it rejects must fail Encode, or the watermark advances over a +// round that emitted nothing. +func TestCheckEncodableValues_AgreesWithEncode(t *testing.T) { + lggr := logger.Nop() + dec := func(s string) protocol.StreamValue { return protocol.ToDecimal(decimal.RequireFromString(s)) } + quote := func(benchmark string) protocol.StreamValue { + return &protocol.Quote{Bid: decimal.NewFromInt(1), Benchmark: decimal.RequireFromString(benchmark), Ask: decimal.NewFromInt(1)} + } + ts := func(observedAt uint64, v string) protocol.StreamValue { + return &protocol.TimestampedStreamValue{ObservedAtNanoseconds: observedAt, StreamValue: dec(v)} + } + unpackedOpts := func(abi string) string { + return `{"baseUSDFee":"1","expirationWindow":3600,"feedID":` + testFeedID + `,"abi":` + abi + `}` + } + payloads := []protocol.StreamValue{ + dec("0"), dec("127"), dec("127.9"), dec("128"), dec("-128"), dec("-129"), dec("255"), dec("256"), dec("-1"), + dec("1e80"), dec("-1e80"), dec("0.001"), + ts(1_700_000_000_000_000_000, "1"), ts(1, "-129"), &protocol.Quote{}, + } + + for _, tc := range []struct { + name string + format llotypes.ReportFormat + codec protocol.ReportCodec + opts []string + values func(v protocol.StreamValue) []protocol.StreamValue + inputs []protocol.StreamValue + }{ + { + name: "unpacked", + format: llotypes.ReportFormatEVMABIEncodeUnpacked, + codec: NewReportCodecEVMABIEncodeUnpacked(lggr, 1), + opts: []string{ + unpackedOpts(`[{"type":"int8"}]`), unpackedOpts(`[{"type":"uint8"}]`), + unpackedOpts(`[{"type":"uint8","multiplier":"10"}]`), unpackedOpts(`[{"type":"int256","multiplier":"-1"}]`), + unpackedOpts(`[[{"type":"uint64"},{"type":"int8"}]]`), unpackedOpts(`[[{"type":"uint32"},{"type":"int8"}]]`), + unpackedOpts(`[{"type":"bytes0"}]`), unpackedOpts(`[{"type":"bool"}]`), + }, + values: func(v protocol.StreamValue) []protocol.StreamValue { + return []protocol.StreamValue{dec("2"), dec("3"), v} + }, + inputs: payloads, + }, + { + name: "expr", + format: llotypes.ReportFormatEVMABIEncodeUnpackedExpr, + codec: NewReportCodecEVMABIEncodeUnpackedExpr(lggr, 1), + opts: []string{ + `{"baseUSDFee":"1","expirationWindow":3600,"feedID":` + testFeedID + `,"abi":[{"type":"int8","expression":"s1","expressionStreamID":9}]}`, + `{"baseUSDFee":"1","expirationWindow":3600,"feedID":` + testFeedID + `,"abi":[{"type":"uint8","multiplier":"10","expression":"s1","expressionStreamID":9}]}`, + }, + values: func(v protocol.StreamValue) []protocol.StreamValue { + return []protocol.StreamValue{dec("2"), dec("3"), dec("1e90"), v} + }, + inputs: payloads, + }, + { + name: "streamlined", + format: llotypes.ReportFormatEVMStreamlined, + codec: NewReportCodecStreamlined(lggr), + opts: []string{ + `{"abi":[{"type":"int8"},{"type":"uint8"}]}`, `{"abi":[{"type":"bytes0"},{"type":"uint8"}]}`, + `{"abi":[{"type":"int8"},[{"type":"uint64"},{"type":"int8"}]]}`, `{"abi":[{"type":"int8"},[{"type":"bytes0"},{"type":"uint8"}]]}`, + }, + values: func(v protocol.StreamValue) []protocol.StreamValue { return []protocol.StreamValue{v, v} }, + inputs: payloads, + }, + { + name: "premium legacy", + format: llotypes.ReportFormatEVMPremiumLegacy, + codec: NewReportCodecPremiumLegacy(lggr, 1), + opts: []string{ + `{"baseUSDFee":"1","expirationWindow":3600,"feedID":` + testFeedID + `}`, + `{"baseUSDFee":"1","expirationWindow":3600,"feedID":` + testFeedID + `,"multiplier":"1000000000000000000"}`, + `{"baseUSDFee":"1","expirationWindow":3600,"feedID":` + testFeedID + `,"multiplier":"0"}`, + }, + values: func(v protocol.StreamValue) []protocol.StreamValue { + return []protocol.StreamValue{dec("2"), dec("3"), v} + }, + inputs: []protocol.StreamValue{ + quote("1"), quote("-1"), quote("1e38"), quote("1e39"), quote("-1e39"), quote("1e57"), quote("1e58"), dec("1"), + }, + }, + } { + for _, opts := range tc.opts { + for _, in := range tc.inputs { + values := tc.values(in) + name := fmt.Sprintf("%s/%s/%v", tc.name, opts, in) + t.Run(name, func(t *testing.T) { + cd := llotypes.ChannelDefinition{ReportFormat: tc.format, Opts: []byte(opts)} + cache := protocol.NewOptsCache() + cache.ResetTo(llotypes.ChannelDefinitions{1: cd}) + checkErr := protocol.CheckEncodableValues(cache, cd, 1, values) + _, encErr := tc.codec.Encode(protocol.Report{ + ChannelID: 1, + ValidAfterNanoseconds: 1_700_000_000_000_000_000, + ObservationTimestampNanoseconds: 1_700_000_001_000_000_000, + Values: values, + }, cd, cache) + require.Equal(t, checkErr == nil, encErr == nil, "check: %v, encode: %v", checkErr, encErr) + }) + } + } + } +} + +func TestVerifyForAdmission(t *testing.T) { + lggr := logger.Nop() + for _, tc := range []struct { + name string + codec protocol.AdmissionVerifier + opts string + err string + }{ + {"unpacked ok", NewReportCodecEVMABIEncodeUnpacked(lggr, 1), `{"expirationWindow":31536000,"abi":[{"type":"int192"},[{"type":"uint64"},{"type":"int192"}]]}`, ""}, + {"unpacked bytes0", NewReportCodecEVMABIEncodeUnpacked(lggr, 1), `{"abi":[{"type":"bytes0"}]}`, "abi[0][0]: invalid Solidity type: bytes0"}, + {"unpacked non integer type", NewReportCodecEVMABIEncodeUnpacked(lggr, 1), `{"abi":[[{"type":"uint64"},{"type":"string"}]]}`, "abi[0][1]: invalid Solidity type: string"}, + {"unpacked expiration window", NewReportCodecEVMABIEncodeUnpacked(lggr, 1), `{"expirationWindow":31536001,"abi":[{"type":"int192"}]}`, "expirationWindow must not exceed"}, + {"expr bytes0", NewReportCodecEVMABIEncodeUnpackedExpr(lggr, 1), `{"abi":[{"type":"bytes0","expression":"s1","expressionStreamID":9}]}`, "invalid Solidity type: bytes0"}, + {"expr expiration window", NewReportCodecEVMABIEncodeUnpackedExpr(lggr, 1), `{"expirationWindow":4000000000,"abi":[{"type":"int192","expression":"s1","expressionStreamID":9}]}`, "expirationWindow must not exceed"}, + {"streamlined bytes0 ok", NewReportCodecStreamlined(lggr), `{"abi":[{"type":"bytes0"},{"type":"uint8"}]}`, ""}, + {"streamlined non integer type", NewReportCodecStreamlined(lggr), `{"abi":[{"type":"bool"}]}`, "invalid Solidity type: bool"}, + {"streamlined fits uint16 length", NewReportCodecStreamlined(lggr), streamlinedOpts(2047, false), ""}, + {"streamlined exceeds uint16 length", NewReportCodecStreamlined(lggr), streamlinedOpts(2048, false), "exceeds maximum size"}, + {"streamlined feed ID header", NewReportCodecStreamlined(lggr), streamlinedOpts(2047, true), "exceeds maximum size"}, + {"legacy ok", NewReportCodecPremiumLegacy(lggr, 1), `{"expirationWindow":3600,"multiplier":"100"}`, ""}, + {"legacy zero multiplier", NewReportCodecPremiumLegacy(lggr, 1), `{"multiplier":"0"}`, "must be non-zero"}, + {"legacy expiration window", NewReportCodecPremiumLegacy(lggr, 1), `{"expirationWindow":4000000000}`, "expirationWindow must not exceed"}, + } { + t.Run(tc.name, func(t *testing.T) { + // Expressions read stream 1. + err := tc.codec.VerifyForAdmission(llotypes.ChannelDefinition{ + Opts: []byte(tc.opts), + Streams: []llotypes.Stream{{StreamID: 1, Aggregator: llotypes.AggregatorMedian}}, + }) + if tc.err == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, tc.err) + } + }) + } +} + +// streamlinedOpts returns opts with n uint256 elements. Without a feed ID the +// header is 16 bytes, so 2047 elements make 65520 bytes and 2048 make 65552. +func streamlinedOpts(n int, feedID bool) string { + elems := make([]string, n) + for i := range elems { + elems[i] = `{"type":"uint256"}` + } + fid := "" + if feedID { + fid = `"feedID":` + testFeedID + `,` + } + return `{` + fid + `"abi":[` + strings.Join(elems, ",") + `]}` +} + +// abi.Pack does not range check, so BuildReport must, or an out of range price +// or fee would be encoded as a different value. +func TestV3BuildReport_RejectsOutOfRange(t *testing.T) { + codec := v3.NewReportCodec([32]byte{1}, logger.Nop()) + pow2 := func(n uint) *big.Int { return new(big.Int).Lsh(big.NewInt(1), n) } + one := big.NewInt(1) + ok := v3.ReportFields{BenchmarkPrice: one, Bid: one, Ask: one, NativeFee: one, LinkFee: one} + + _, err := codec.BuildReport(ok) + require.NoError(t, err) + + rf := ok + rf.BenchmarkPrice = pow2(191) + _, err = codec.BuildReport(rf) + require.ErrorContains(t, err, "benchmarkPrice: value") + + rf = ok + rf.Ask = new(big.Int).Neg(pow2(200)) + _, err = codec.BuildReport(rf) + require.ErrorContains(t, err, "ask: value") + + rf = ok + rf.LinkFee = pow2(192) + _, err = codec.BuildReport(rf) + require.ErrorContains(t, err, "linkFee: value") +} + +func TestCheckFee(t *testing.T) { + require.NoError(t, checkFee("linkFee", big.NewInt(0))) + require.ErrorContains(t, checkFee("linkFee", nil), "linkFee may not be nil") + require.ErrorContains(t, checkFee("linkFee", big.NewInt(-1)), "linkFee may not be negative") + require.ErrorContains(t, checkFee("nativeFee", new(big.Int).Lsh(big.NewInt(1), 192)), "nativeFee: value") +} diff --git a/llo/reportcodec/evm/report_codec_common.go b/llo/reportcodec/evm/report_codec_common.go index a7567e8..d30dcfb 100644 --- a/llo/reportcodec/evm/report_codec_common.go +++ b/llo/reportcodec/evm/report_codec_common.go @@ -4,8 +4,6 @@ import ( "fmt" "math" "math/big" - "regexp" - "strconv" "github.com/goccy/go-json" @@ -15,7 +13,7 @@ import ( llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" protocol "github.com/smartcontractkit/chainlink-data-streams/llo/protocol" - ubig "github.com/smartcontractkit/chainlink-data-streams/llo/reportcodec/evm/utils" + "github.com/smartcontractkit/chainlink-data-streams/llo/ubig" ) // Extracts nanosecond timestamps as uint32 number of seconds @@ -146,22 +144,10 @@ type singleABIEncoder struct { ExpressionStreamID llotypes.StreamID `json:"expressionStreamId,omitempty"` } -// getNormalizedMultiplier returns the multiplier as a decimal.Decimal, defaulting -// to 1 if the multiplier is nil. -// -// Negative multipliers are ok and will work as expected, flipping the sign of -// the value. -func (a singleABIEncoder) getNormalizedMultiplier() (multiplier decimal.Decimal) { - if a.Multiplier == nil { - multiplier = decimal.NewFromInt(1) - } else { - multiplier = decimal.NewFromBigInt(a.Multiplier.ToInt(), 0) - } - return -} - +// applyMultiplier scales d the same way protocol.ABIElement.Scale does, so the +// reportability check and the encoder range check the same integer. func (a singleABIEncoder) applyMultiplier(d decimal.Decimal) *big.Int { - return d.Mul(a.getNormalizedMultiplier()).BigInt() + return protocol.ABIElement{Type: a.Type, Multiplier: a.Multiplier}.Scale(d) } // EncodePadded uses standard ABI encoding to encode the stream value, padding @@ -219,7 +205,7 @@ func padWithOnes(b []byte) []byte { return padded } -const ZeroBytesSentinel = "bytes0" +const ZeroBytesSentinel = protocol.ABIZeroBytes func (a singleABIEncoder) encodeUint64Packed(v uint64) ([]byte, error) { if a.Type == ZeroBytesSentinel { @@ -246,58 +232,28 @@ func (a singleABIEncoder) encodePacked(sv protocol.StreamValue) ([]byte, error) return EncodePackedBigInt(v, a.Type) } -// regex to match Solidity integer types (uint/int) and extract bit width -var typeRegex = regexp.MustCompile(`^(u?int)(8|16|24|32|40|48|56|64|72|80|88|96|104|112|120|128|136|144|152|160|168|176|184|192|200|208|216|224|232|240|248|256)$`) - // EncodePackedBigInt converts a *big.Int to packed EVM bytes according to the Solidity type. // For unsigned types ("uintN"), the value must be non-negative and fit in N bits. // For signed types ("intN"), the value must be between -2^(N-1) and 2^(N-1)-1; then the function returns the two's complement representation in N bits. +// The range rule is protocol.SolidityInt.Check, which the reportability check also applies. func EncodePackedBigInt(value *big.Int, typeStr string) ([]byte, error) { - // Validate and extract type and bit width. - matches := typeRegex.FindStringSubmatch(typeStr) - if len(matches) != 3 { - return nil, fmt.Errorf("invalid Solidity type: %s", typeStr) - } - typePrefix := matches[1] // "uint" or "int" - bitWidthI, err := strconv.Atoi(matches[2]) + s, err := protocol.ParseSolidityInt(typeStr) if err != nil { - return nil, fmt.Errorf("failed to parse bit width: %w", err) + return nil, err } - bitWidth := uint(bitWidthI) //nolint:gosec // G115 // bit width is never above 256, so no overflow issues - byteLen := bitWidth / 8 - - // For unsigned integers, value must be non-negative and within range. - if typePrefix == "uint" { - if value.Sign() < 0 { - return nil, fmt.Errorf("negative value provided for unsigned type %s", typeStr) - } - // Check that value fits in bitWidth bits. - // maxSize = 2^bitWidth - maxSize := new(big.Int).Lsh(big.NewInt(1), bitWidth) - if value.Cmp(maxSize) >= 0 { - return nil, fmt.Errorf("value %s out of range for type %s", value.String(), typeStr) - } - // Create a byte slice of fixed length and fill it with the big-endian bytes. + if err := s.Check(value); err != nil { + return nil, err + } + byteLen := s.Bits / 8 + if !s.Signed { result := make([]byte, byteLen) value.FillBytes(result) return result, nil } - // For signed integers, first ensure that value fits in the representable range. - // The valid range is from -2^(bitWidth-1) to 2^(bitWidth-1)-1. - // 2^(bitWidth-1) - minInt := new(big.Int).Lsh(big.NewInt(1), bitWidth-1) - // -2^(bitWidth-1) - minInt.Neg(minInt) - // 2^(bitWidth-1) - 1 - maxInt := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), bitWidth-1), big.NewInt(1)) - if value.Cmp(minInt) < 0 || value.Cmp(maxInt) > 0 { - return nil, fmt.Errorf("value %s out of range for type %s", value.String(), typeStr) - } - // Solidity uses two's complement in fixed N-bit representation. // twoPow = 2^bitWidth - twoPow := new(big.Int).Lsh(big.NewInt(1), bitWidth) + twoPow := new(big.Int).Lsh(big.NewInt(1), s.Bits) // Compute the modulo to obtain the two's complement representation. modValue := new(big.Int).Mod(value, twoPow) result := make([]byte, byteLen) @@ -359,3 +315,47 @@ func ClampReportRange(r logger.Logger, report protocol.Report, maxReportRange pr return report.ValidAfterNanoseconds } + +// checkFee returns an error when fee cannot be encoded as a uint192. abi.Pack +// does not range check, so an out of range fee would wrap into a different one. +func checkFee(name string, fee *big.Int) error { + if fee == nil { + return fmt.Errorf("%s may not be nil", name) + } + if fee.Sign() < 0 { + return fmt.Errorf("%s may not be negative (got: %s)", name, fee) + } + if err := protocol.CheckSolidityInt(fee, "uint192"); err != nil { + return fmt.Errorf("%s: %w", name, err) + } + return nil +} + +// MaxExpirationWindow bounds the expirationWindow opt, in seconds. On seconds +// resolution the report encodes expiresAt as a uint32, so a larger window would +// overflow it long before the timestamp itself does. +const MaxExpirationWindow = 365 * 24 * 60 * 60 + +func verifyExpirationWindow(expirationWindow uint32) error { + if expirationWindow > MaxExpirationWindow { + return fmt.Errorf("expirationWindow must not exceed %d seconds; got: %d", MaxExpirationWindow, expirationWindow) + } + return nil +} + +// verifyABITypes rejects ABI element types the encoders cannot encode, which +// would fail every report of the channel. packed allows ZeroBytesSentinel, +// which only packed encoding accepts. +func verifyABITypes(abi []ABIEncoder, packed bool) error { + for i, enc := range abi { + for j, e := range enc.encoders { + if packed && e.Type == ZeroBytesSentinel { + continue + } + if _, err := protocol.ParseSolidityInt(e.Type); err != nil { + return fmt.Errorf("abi[%d][%d]: %w", i, j, err) + } + } + } + return nil +} diff --git a/llo/reportcodec/evm/report_codec_common_test.go b/llo/reportcodec/evm/report_codec_common_test.go index c7e6bc2..1a8bd06 100644 --- a/llo/reportcodec/evm/report_codec_common_test.go +++ b/llo/reportcodec/evm/report_codec_common_test.go @@ -17,7 +17,7 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/logger" protocol "github.com/smartcontractkit/chainlink-data-streams/llo/protocol" - ubig "github.com/smartcontractkit/chainlink-data-streams/llo/reportcodec/evm/utils" + "github.com/smartcontractkit/chainlink-data-streams/llo/ubig" ) func TestEncodePackedBigInt(t *testing.T) { diff --git a/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked.go b/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked.go index 0579041..cc9b46d 100644 --- a/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked.go +++ b/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked.go @@ -20,10 +20,9 @@ import ( ) var ( - _ protocol.ReportCodec = ReportCodecEVMABIEncodeUnpacked{} - _ protocol.FeedIDer = ReportCodecEVMABIEncodeUnpacked{} - - zero = big.NewInt(0) + _ protocol.ReportCodec = ReportCodecEVMABIEncodeUnpacked{} + _ protocol.FeedIDer = ReportCodecEVMABIEncodeUnpacked{} + _ protocol.AdmissionVerifier = ReportCodecEVMABIEncodeUnpacked{} ) type ReportCodecEVMABIEncodeUnpacked struct { @@ -127,7 +126,11 @@ func (r ReportCodecEVMABIEncodeUnpacked) Encode(report protocol.Report, cd lloty return nil, fmt.Errorf("failed to build base report; %w", err) } - payload, err := buildPayload(opts.ABI, report.Values[2:]) + values, err := protocol.ABIPayload(llotypes.ReportFormatEVMABIEncodeUnpacked, len(opts.ABI), report.Values) + if err != nil { + return nil, fmt.Errorf("failed to build payload; %w", err) + } + payload, err := buildPayload(opts.ABI, values) if err != nil { return nil, fmt.Errorf("failed to build payload; %w", err) } @@ -183,6 +186,24 @@ func (r ReportCodecEVMABIEncodeUnpacked) Verify(cd llotypes.ChannelDefinition) e return verifyMaxReportRange(opts.MaxReportRange) } +// VerifyForAdmission implements protocol.AdmissionVerifier: it rejects ABI +// types the payload encoder cannot encode and an expiration window that +// overflows the header. Either fails every report of the channel. +// +// This is not part of Verify because definitions committed before the check +// existed may fail it, and rejecting those would stop every oracle from +// observing rather than just stopping that one channel from reporting. +func (r ReportCodecEVMABIEncodeUnpacked) VerifyForAdmission(cd llotypes.ChannelDefinition) error { + opts := new(ReportFormatEVMABIEncodeOpts) + if err := opts.Decode(cd.Opts); err != nil { + return fmt.Errorf("invalid Opts, got: %q; %w", cd.Opts, err) + } + if err := verifyABITypes(opts.ABI, false); err != nil { + return err + } + return verifyExpirationWindow(opts.ExpirationWindow) +} + // BaseSchema represents the fixed base schema that remains unchanged for all // EVMABIEncodeUnpacked reports. // @@ -211,18 +232,7 @@ func getBaseSchema(timestampType string) abi.Arguments { } func (r ReportCodecEVMABIEncodeUnpacked) buildHeader(rf BaseReportFields, resolution protocol.TimeResolution) ([]byte, error) { - var merr error - if rf.LinkFee == nil { - merr = errors.Join(merr, errors.New("linkFee may not be nil")) - } else if rf.LinkFee.Cmp(zero) < 0 { - merr = errors.Join(merr, fmt.Errorf("linkFee may not be negative (got: %s)", rf.LinkFee)) - } - if rf.NativeFee == nil { - merr = errors.Join(merr, errors.New("nativeFee may not be nil")) - } else if rf.NativeFee.Cmp(zero) < 0 { - merr = errors.Join(merr, fmt.Errorf("nativeFee may not be negative (got: %s)", rf.NativeFee)) - } - if merr != nil { + if merr := errors.Join(checkFee("linkFee", rf.LinkFee), checkFee("nativeFee", rf.NativeFee)); merr != nil { return nil, merr } diff --git a/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr.go b/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr.go index 919a10b..8c6e7b6 100644 --- a/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr.go +++ b/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr.go @@ -82,7 +82,11 @@ func (r ReportCodecEVMABIEncodeUnpackedExpr) Encode(report protocol.Report, cd l return nil, fmt.Errorf("failed to build base report; %w", err) } - payload, err := buildPayload(opts.ABI, report.Values[len(report.Values)-len(opts.ABI):]) + values, err := protocol.ABIPayload(llotypes.ReportFormatEVMABIEncodeUnpackedExpr, len(opts.ABI), report.Values) + if err != nil { + return nil, fmt.Errorf("failed to build payload; %w", err) + } + payload, err := buildPayload(opts.ABI, values) if err != nil { return nil, fmt.Errorf("failed to build payload; %w", err) } @@ -131,7 +135,14 @@ func (r ReportCodecEVMABIEncodeUnpackedExpr) VerifyForAdmission(cd llotypes.Chan if err := calculated.ValidateChannelExpressions(nil, cd, 0); err != nil { return fmt.Errorf("invalid calculated stream expressions: %w", err) } - return nil + opts := new(ReportFormatEVMABIEncodeOpts) + if err := opts.Decode(cd.Opts); err != nil { + return fmt.Errorf("invalid Opts, got: %q; %w", cd.Opts, err) + } + if err := verifyABITypes(opts.ABI, false); err != nil { + return err + } + return verifyExpirationWindow(opts.ExpirationWindow) } // TWAPCalls implements protocol.TWAPCounter: it counts the TWAP calls across @@ -143,18 +154,7 @@ func (r ReportCodecEVMABIEncodeUnpackedExpr) TWAPCalls(cd llotypes.ChannelDefini } func (r ReportCodecEVMABIEncodeUnpackedExpr) buildHeader(rf BaseReportFields, resolution protocol.TimeResolution) ([]byte, error) { - var merr error - if rf.LinkFee == nil { - merr = errors.Join(merr, errors.New("linkFee may not be nil")) - } else if rf.LinkFee.Cmp(zero) < 0 { - merr = errors.Join(merr, fmt.Errorf("linkFee may not be negative (got: %s)", rf.LinkFee)) - } - if rf.NativeFee == nil { - merr = errors.Join(merr, errors.New("nativeFee may not be nil")) - } else if rf.NativeFee.Cmp(zero) < 0 { - merr = errors.Join(merr, fmt.Errorf("nativeFee may not be negative (got: %s)", rf.NativeFee)) - } - if merr != nil { + if merr := errors.Join(checkFee("linkFee", rf.LinkFee), checkFee("nativeFee", rf.NativeFee)); merr != nil { return nil, merr } diff --git a/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr_test.go b/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr_test.go index 8ccb3a9..f4a8575 100644 --- a/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr_test.go +++ b/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr_test.go @@ -17,7 +17,7 @@ import ( "github.com/smartcontractkit/libocr/offchainreporting2/types" protocol "github.com/smartcontractkit/chainlink-data-streams/llo/protocol" - ubig "github.com/smartcontractkit/chainlink-data-streams/llo/reportcodec/evm/utils" + "github.com/smartcontractkit/chainlink-data-streams/llo/ubig" "github.com/smartcontractkit/chainlink-common/pkg/logger" llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" diff --git a/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_test.go b/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_test.go index 76a0623..91ddc29 100644 --- a/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_test.go +++ b/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_test.go @@ -21,7 +21,7 @@ import ( "github.com/smartcontractkit/libocr/offchainreporting2/types" protocol "github.com/smartcontractkit/chainlink-data-streams/llo/protocol" - ubig "github.com/smartcontractkit/chainlink-data-streams/llo/reportcodec/evm/utils" + "github.com/smartcontractkit/chainlink-data-streams/llo/ubig" "github.com/smartcontractkit/chainlink-common/pkg/logger" llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" diff --git a/llo/reportcodec/evm/report_codec_evm_streamlined.go b/llo/reportcodec/evm/report_codec_evm_streamlined.go index 48fff99..ca060b8 100644 --- a/llo/reportcodec/evm/report_codec_evm_streamlined.go +++ b/llo/reportcodec/evm/report_codec_evm_streamlined.go @@ -22,8 +22,9 @@ import ( ) var ( - _ protocol.ReportCodec = ReportCodecEVMStreamlined{} - _ protocol.FeedIDer = ReportCodecEVMStreamlined{} + _ protocol.ReportCodec = ReportCodecEVMStreamlined{} + _ protocol.FeedIDer = ReportCodecEVMStreamlined{} + _ protocol.AdmissionVerifier = ReportCodecEVMStreamlined{} ) func NewReportCodecStreamlined(lggr logger.Logger) ReportCodecEVMStreamlined { @@ -55,12 +56,13 @@ func (rc ReportCodecEVMStreamlined) Encode(r protocol.Report, cd llotypes.Channe payload = opts.FeedID.Bytes() } payload = append(payload, encodePackedUint64(r.ValidAfterNanoseconds)...) - if len(opts.ABI) != len(r.Values) { - return nil, fmt.Errorf("ABI and values length mismatch; ABI: %d, Values: %d", len(opts.ABI), len(r.Values)) + values, err := protocol.ABIPayload(llotypes.ReportFormatEVMStreamlined, len(opts.ABI), r.Values) + if err != nil { + return nil, err } // Pack-encode the rest of the values for i, encoder := range opts.ABI { - b, err := encoder.EncodePacked(r.Values[i]) + b, err := encoder.EncodePacked(values[i]) if err != nil { return nil, fmt.Errorf("failed to encode stream value at index %d; %w", i, err) } @@ -99,6 +101,45 @@ func (rc ReportCodecEVMStreamlined) Verify(cd llotypes.ChannelDefinition) error return verifyMaxReportRange(opts.MaxReportRange) } +// VerifyForAdmission implements protocol.AdmissionVerifier: it rejects ABI +// types the packed encoder cannot encode, and an ABI whose largest report +// exceeds the uint16 length prefix Pack writes. Both fail every report of the +// channel, the second only after it was signed. +// +// This is not part of Verify because definitions committed before the check +// existed may fail it, and rejecting those would stop every oracle from +// observing rather than just stopping that one channel from reporting. +func (rc ReportCodecEVMStreamlined) VerifyForAdmission(cd llotypes.ChannelDefinition) error { + opts := ReportFormatEVMStreamlinedOpts{} + if err := (&opts).Decode(cd.Opts); err != nil { + return fmt.Errorf("failed to decode opts; got: '%s'; %w", cd.Opts, err) + } + if err := verifyABITypes(opts.ABI, true); err != nil { + return err + } + // Feed ID or report format and channel ID, then validAfter. + size := 4 + 4 + 8 + if opts.FeedID != nil { + size = 32 + 8 + } + for _, enc := range opts.ABI { + for _, e := range enc.encoders { + if e.Type == ZeroBytesSentinel { + continue + } + s, err := protocol.ParseSolidityInt(e.Type) + if err != nil { + return err + } + size += int(s.Bits / 8) //nolint:gosec // G115 at most 32 + } + } + if size > math.MaxUint16 { + return fmt.Errorf("largest report is %d bytes, exceeds maximum size %d", size, math.MaxUint16) + } + return nil +} + func (rc ReportCodecEVMStreamlined) Pack(digest types.ConfigDigest, seqNr uint64, report ocr2types.Report, sigs []ocr2types.AttributedOnchainSignature) ([]byte, error) { payload, err := rc.packPayload(digest, report, sigs) if err != nil { diff --git a/llo/reportcodec/evm/report_codec_premium_legacy.go b/llo/reportcodec/evm/report_codec_premium_legacy.go index 2801568..5089f75 100644 --- a/llo/reportcodec/evm/report_codec_premium_legacy.go +++ b/llo/reportcodec/evm/report_codec_premium_legacy.go @@ -20,14 +20,15 @@ import ( llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" protocol "github.com/smartcontractkit/chainlink-data-streams/llo/protocol" - ubig "github.com/smartcontractkit/chainlink-data-streams/llo/reportcodec/evm/utils" v3 "github.com/smartcontractkit/chainlink-data-streams/llo/reportcodec/evm/v3" + "github.com/smartcontractkit/chainlink-data-streams/llo/ubig" ) var ( - _ protocol.ReportCodec = ReportCodecPremiumLegacy{} - _ protocol.FeedIDer = ReportCodecPremiumLegacy{} - PayloadTypes = getPayloadTypes() + _ protocol.ReportCodec = ReportCodecPremiumLegacy{} + _ protocol.FeedIDer = ReportCodecPremiumLegacy{} + _ protocol.AdmissionVerifier = ReportCodecPremiumLegacy{} + PayloadTypes = getPayloadTypes() ) func getPayloadTypes() abi.Arguments { @@ -157,6 +158,24 @@ func (r ReportCodecPremiumLegacy) Verify(cd llotypes.ChannelDefinition) error { return verifyMaxReportRange(opts.MaxReportRange) } +// VerifyForAdmission implements protocol.AdmissionVerifier: it rejects a zero +// multiplier and an expiration window that overflows the uint32 expiresAt. +// Either fails every report of the channel. +// +// This is not part of Verify because definitions committed before the check +// existed may fail it, and rejecting those would stop every oracle from +// observing rather than just stopping that one channel from reporting. +func (r ReportCodecPremiumLegacy) VerifyForAdmission(cd llotypes.ChannelDefinition) error { + opts := ReportFormatEVMPremiumLegacyOpts{} + if err := (&opts).Decode(cd.Opts); err != nil { + return fmt.Errorf("invalid Opts, got: %q; %w", cd.Opts, err) + } + if opts.Multiplier != nil && opts.Multiplier.IsZero() { + return errors.New("multiplier, if specified in channel opts, must be non-zero") + } + return verifyExpirationWindow(opts.ExpirationWindow) +} + func (r ReportCodecPremiumLegacy) Decode(b []byte) (*v3.Report, error) { codec := v3.NewReportCodec([32]byte{}, r.Logger) return codec.Decode(b) diff --git a/llo/reportcodec/evm/v3/report_codec.go b/llo/reportcodec/evm/v3/report_codec.go index 101a70d..791e1b3 100644 --- a/llo/reportcodec/evm/v3/report_codec.go +++ b/llo/reportcodec/evm/v3/report_codec.go @@ -9,6 +9,8 @@ import ( ocrtypes "github.com/smartcontractkit/libocr/offchainreporting2plus/types" "github.com/smartcontractkit/chainlink-common/pkg/logger" + + protocol "github.com/smartcontractkit/chainlink-data-streams/llo/protocol" ) type ReportFields struct { @@ -57,6 +59,26 @@ func (r *ReportCodec) BuildReport(rf ReportFields) (ocrtypes.Report, error) { if merr != nil { return nil, merr } + // abi.Pack does not range check integers and would wrap an out of range + // value into a different one. + for _, f := range []struct { + name string + v *big.Int + t string + }{ + {"nativeFee", rf.NativeFee, "uint192"}, + {"linkFee", rf.LinkFee, "uint192"}, + {"benchmarkPrice", rf.BenchmarkPrice, "int192"}, + {"bid", rf.Bid, "int192"}, + {"ask", rf.Ask, "int192"}, + } { + if err := protocol.CheckSolidityInt(f.v, f.t); err != nil { + merr = errors.Join(merr, fmt.Errorf("%s: %w", f.name, err)) + } + } + if merr != nil { + return nil, merr + } reportBytes, err := Schema.Pack(r.feedID, rf.ValidFromTimestamp, rf.Timestamp, rf.NativeFee, rf.LinkFee, rf.ExpiresAt, rf.BenchmarkPrice, rf.Bid, rf.Ask) if err != nil { return nil, fmt.Errorf("failed to pack report blob: %w", err) diff --git a/llo/reportcodec/evm/utils/big.go b/llo/ubig/big.go similarity index 99% rename from llo/reportcodec/evm/utils/big.go rename to llo/ubig/big.go index a924958..e3da963 100644 --- a/llo/reportcodec/evm/utils/big.go +++ b/llo/ubig/big.go @@ -1,4 +1,4 @@ -package utils +package ubig import ( "database/sql/driver" From 924866a221831aed46fad2b0535f37af4d719f19 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Wed, 21 Oct 2026 12:08:57 +0100 Subject: [PATCH 096/103] llo/v31: test that a non reconciling cache only votes out tombstones --- llo/dev/v31/plugin_test.go | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/llo/dev/v31/plugin_test.go b/llo/dev/v31/plugin_test.go index e3301e6..069c07c 100644 --- a/llo/dev/v31/plugin_test.go +++ b/llo/dev/v31/plugin_test.go @@ -2149,6 +2149,39 @@ func Test_VoteOnChannels_WithholdsOnlyInadmissibleChannels(t *testing.T) { }) } +// Test_VoteOnChannels_NonReconcilingCacheRemovesOnlyTombstones covers a cache +// that ignores the committed set and returns its own set verbatim, like the +// static cache. Live channels missing from it get no remove vote, and only +// committed tombstones missing from it are voted out. +func Test_VoteOnChannels_NonReconcilingCacheRemovesOnlyTombstones(t *testing.T) { + live := llotypes.ChannelDefinition{ + ReportFormat: llotypes.ReportFormatJSON, + Streams: []llotypes.Stream{{StreamID: 100, Aggregator: llotypes.AggregatorMedian}}, + } + tombstone := llotypes.ChannelDefinition{ReportFormat: llotypes.ReportFormatJSON, Tombstone: true} + committed := llotypes.ChannelDefinitions{1: live, 2: live, 8: tombstone, 9: tombstone} + + vote := func(t *testing.T, desired llotypes.ChannelDefinitions) Observation { + t.Helper() + p := testPlugin(t) + p.ChannelDefinitionCache = &mockChannelDefinitionCache{defs: desired} + var obs Observation + p.voteOnChannels(&obs, &kvState{channelDefinitions: committed}) + return obs + } + + t.Run("an empty set removes only tombstones", func(t *testing.T) { + obs := vote(t, llotypes.ChannelDefinitions{}) + require.Equal(t, map[llotypes.ChannelID]struct{}{8: {}, 9: {}}, obs.RemoveChannelIDs) + require.Empty(t, obs.UpdateChannelDefinitions) + }) + t.Run("a partial set removes only absent tombstones", func(t *testing.T) { + obs := vote(t, llotypes.ChannelDefinitions{2: live, 9: tombstone}) + require.Equal(t, map[llotypes.ChannelID]struct{}{8: {}}, obs.RemoveChannelIDs) + require.Empty(t, obs.UpdateChannelDefinitions) + }) +} + // Test_Observation_RetirementCacheErrorsAreNotFatal guards the liveness fix: // both retirement caches read from node-local, asynchronously populated state, // so a transient failure must not fail the round. The vote is best-effort — a From bd9b54963776381ade74a16f66f4f8a7f7b24bee Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Wed, 21 Oct 2026 14:02:01 +0100 Subject: [PATCH 097/103] llo/v31: check time gates and codec support before value checks in isReportable --- llo/dev/v31/plugin_test.go | 53 ++++++++++++++++++++++++++++++++++++++ llo/dev/v31/reports.go | 52 +++++++++++++++++++++---------------- 2 files changed, 83 insertions(+), 22 deletions(-) diff --git a/llo/dev/v31/plugin_test.go b/llo/dev/v31/plugin_test.go index 069c07c..87d6a9a 100644 --- a/llo/dev/v31/plugin_test.go +++ b/llo/dev/v31/plugin_test.go @@ -2244,6 +2244,59 @@ func Test_UnreportableTally_CollapsesPerChannelWarnings(t *testing.T) { require.Empty(t, out.reportableChannels(0, 1, out.supportFor(0), protocol.NewOptsCache(), nil)) } +// Test_IsReportable_TimeGatesRunBeforeValueChecks covers a channel the time +// gates hold back not being tallied for a missing value it would not carry, +// and missing codec support being tallied ahead of a missing value. +func Test_IsReportable_TimeGatesRunBeforeValueChecks(t *testing.T) { + const ( + cid = llotypes.ChannelID(1) + second = uint64(1_000_000_000) + ) + // Seconds resolution, and no aggregate for the channel's stream. + out := precursor{ + LifeCycleStage: protocol.LifeCycleStageProduction, + ChannelDefinitions: llotypes.ChannelDefinitions{cid: { + ReportFormat: llotypes.ReportFormatEVMPremiumLegacy, + Streams: []llotypes.Stream{{StreamID: 1, Aggregator: llotypes.AggregatorMedian}}, + }}, + ValidAfterNanoseconds: map[llotypes.ChannelID]uint64{cid: 10 * second}, + StreamAggregates: protocol.StreamAggregates{}, + } + + t.Run("min report interval", func(t *testing.T) { + o := out + o.ObservationTimestampNanoseconds = 12 * second + tally := &unreportableTally{} + require.False(t, o.isReportable(cid, 5*second, 1, o.supportFor(4), protocol.NewOptsCache(), tally)) + require.Empty(t, tally.reasons) + }) + + t.Run("tick overlap", func(t *testing.T) { + o := out + o.ObservationTimestampNanoseconds = 10*second + 500_000_000 + tally := &unreportableTally{} + require.False(t, o.isReportable(cid, 0, 1, o.supportFor(4), protocol.NewOptsCache(), tally)) + require.Empty(t, tally.reasons) + }) + + t.Run("past the time gates", func(t *testing.T) { + o := out + o.ObservationTimestampNanoseconds = 11 * second + tally := &unreportableTally{} + require.False(t, o.isReportable(cid, 0, 1, o.supportFor(4), protocol.NewOptsCache(), tally)) + require.Contains(t, tally.reasons, "nil stream value") + }) + + t.Run("codec support before values", func(t *testing.T) { + o := out + o.ObservationTimestampNanoseconds = 11 * second + tally := &unreportableTally{} + require.False(t, o.isReportable(cid, 0, 1, o.supportFor(0), protocol.NewOptsCache(), tally)) + require.Len(t, tally.reasons, 1) + require.Contains(t, tally.reasons, "too few oracles advertise a report codec for this format") + }) +} + // Test_StateTransition_BackfillValidAfterRequiresPreviousReport covers the // backfill watermark advancing only over a row the previous round actually // reported. diff --git a/llo/dev/v31/reports.go b/llo/dev/v31/reports.go index 35de085..cedb82d 100644 --- a/llo/dev/v31/reports.go +++ b/llo/dev/v31/reports.go @@ -349,6 +349,35 @@ func (o precursor) isReportable(channelID llotypes.ChannelID, minReportInterval // above already established the target exists. return formatIsEncodable(support, o.ChannelDefinitions[opts.TargetChannelID].ReportFormat, f, channelID, tally) } + validAfter, ok := o.ValidAfterNanoseconds[channelID] + if !ok { + return false + } + if o.ObservationTimestampNanoseconds < protocol.SaturatingAdd(validAfter, minReportInterval) || o.ObservationTimestampNanoseconds <= validAfter { + return false + } + // The codec reads the same opts, so a channel whose opts do not decode + // cannot be encoded. Treat it as unreportable so validAfter does not advance + // over a round that emitted nothing. + res, ok := reportTimeResolution(cd, channelID, optsCache) + if !ok { + tally.note("channel opts cannot be decoded", channelID) + return false + } + // Require validAfter and the observation timestamp to fall in distinct ticks + // of the report format resolution, so the encoded validity window is not + // inverted. + if tickOverlap(validAfter, o.ObservationTimestampNanoseconds, res) { + return false + } + if !formatIsEncodable(support, cd.ReportFormat, f, channelID, tally) { + return false + } + // The time gates and codec support above depend only on agreed state. Run + // them before the value checks so a channel that cannot report this round + // is not tallied for values it would not carry, and a missing codec is not + // masked by a transient missing value. + // // Reportability and emission must agree on what the report carries. Reports // derives the report's values from protocol.EffectiveStreams, so the same // derivation has to succeed here: if it fails there, the report is skipped @@ -394,26 +423,5 @@ func (o precursor) isReportable(channelID llotypes.ChannelID, minReportInterval tally.note("stream value not encodable", channelID, "err", err) return false } - if !formatIsEncodable(support, cd.ReportFormat, f, channelID, tally) { - return false - } - validAfter, ok := o.ValidAfterNanoseconds[channelID] - if !ok { - return false - } - if o.ObservationTimestampNanoseconds < protocol.SaturatingAdd(validAfter, minReportInterval) || o.ObservationTimestampNanoseconds <= validAfter { - return false - } - // The codec reads the same opts, so a channel whose opts do not decode - // cannot be encoded. Treat it as unreportable so validAfter does not advance - // over a round that emitted nothing. - res, ok := reportTimeResolution(cd, channelID, optsCache) - if !ok { - tally.note("channel opts cannot be decoded", channelID) - return false - } - // Require validAfter and the observation timestamp to fall in distinct ticks - // of the report format resolution, so the encoded validity window is not - // inverted. - return !tickOverlap(validAfter, o.ObservationTimestampNanoseconds, res) + return true } From a167ec2042424c600313d169b7ebc3337dc624a3 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Wed, 21 Oct 2026 14:41:34 +0100 Subject: [PATCH 098/103] llo/v31: add StateTransition benchmark --- llo/dev/v31/statetransition_bench_test.go | 193 ++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 llo/dev/v31/statetransition_bench_test.go diff --git a/llo/dev/v31/statetransition_bench_test.go b/llo/dev/v31/statetransition_bench_test.go new file mode 100644 index 0000000..0098c32 --- /dev/null +++ b/llo/dev/v31/statetransition_bench_test.go @@ -0,0 +1,193 @@ +package llo + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/shopspring/decimal" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" + "github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3_1types" + ocrtypes "github.com/smartcontractkit/libocr/offchainreporting2plus/types" + + "github.com/smartcontractkit/chainlink-data-streams/llo/dev/v31/llotest" + protocol "github.com/smartcontractkit/chainlink-data-streams/llo/protocol" + "github.com/smartcontractkit/chainlink-data-streams/llo/reportcodec/evm" +) + +// StateTransition benchmarks for a steady round: no channel votes, every +// oracle observing every stream, every channel reportable. +// +// Channels are EVMABIEncodeUnpacked with production shaped opts: the native and +// LINK price streams shared by every channel, followed by +// payloadStreamsPerChannel int192 payload streams. +// +// Each oracle observes slightly different values, so their blob payloads are +// distinct and every one is decoded. The blob payload memo is disabled, which +// is the cost of a round whose observations ValidateObservation did not +// already decode. + +type stateTransitionBenchCase struct { + n, f int + channels, streams int + payloadStreamsPerChannel int +} + +func (c stateTransitionBenchCase) String() string { + return fmt.Sprintf("n=%d/channels=%d/streams=%d", c.n, c.channels, c.streams) +} + +var stateTransitionBenchCases = []stateTransitionBenchCase{ + {n: 4, f: 1, channels: 100, streams: 300, payloadStreamsPerChannel: 3}, + {n: 16, f: 5, channels: 1000, streams: 3000, payloadStreamsPerChannel: 3}, + {n: 31, f: 10, channels: 2000, streams: 5000, payloadStreamsPerChannel: 3}, +} + +func benchStateTransitionPlugin(c stateTransitionBenchCase) *Plugin { + return &Plugin{ + ConfigDigest: ocrtypes.ConfigDigest{1, 2, 3}, + Logger: logger.Nop(), + N: c.n, + F: c.f, + ReportCodecs: map[llotypes.ReportFormat]protocol.ReportCodec{llotypes.ReportFormatEVMABIEncodeUnpacked: evm.NewReportCodecEVMABIEncodeUnpacked(logger.Nop(), 1)}, + ChannelCache: protocol.NewChannelCache(), + DefaultMinReportIntervalNanoseconds: 0, + AggregationFaultTolerance: c.f, + } +} + +// The native and LINK price streams every channel reports first. +const ( + benchNativeStreamID = llotypes.StreamID(1) + benchLinkStreamID = llotypes.StreamID(2) +) + +// benchChannelDefinitions spreads payloadStreamsPerChannel median streams per +// channel over the payload stream pool, so streams are shared between +// channels. +func benchChannelDefinitions(c stateTransitionBenchCase) llotypes.ChannelDefinitions { + type abiEntry struct { + Multiplier string `json:"multiplier"` + StreamID llotypes.StreamID `json:"streamID"` + Type string `json:"type"` + } + type abiOpts struct { + ABI []abiEntry `json:"abi"` + BaseUSDFee string `json:"baseUSDFee"` + ExpirationWindow uint32 `json:"expirationWindow"` + FeedID string `json:"feedID"` + } + + defs := make(llotypes.ChannelDefinitions, c.channels) + for i := range c.channels { + streams := []llotypes.Stream{ + {StreamID: benchNativeStreamID, Aggregator: llotypes.AggregatorMedian}, + {StreamID: benchLinkStreamID, Aggregator: llotypes.AggregatorMedian}, + } + opts := abiOpts{ + BaseUSDFee: "0.32", + ExpirationWindow: 2592000, + FeedID: fmt.Sprintf("0x%064x", i+1), + } + for k := range c.payloadStreamsPerChannel { + sid := benchLinkStreamID + 1 + llotypes.StreamID((i*c.payloadStreamsPerChannel+k)%c.streams) //nolint:gosec // G115 bounded by the case + streams = append(streams, llotypes.Stream{StreamID: sid, Aggregator: llotypes.AggregatorMedian}) + opts.ABI = append(opts.ABI, abiEntry{Multiplier: "1000000000000000000", StreamID: sid, Type: "int192"}) + } + raw, err := json.Marshal(opts) + if err != nil { + panic(err) + } + defs[llotypes.ChannelID(i+1)] = llotypes.ChannelDefinition{ //nolint:gosec // G115 bounded by the case + ReportFormat: llotypes.ReportFormatEVMABIEncodeUnpacked, + Streams: streams, + Opts: raw, + } + } + return defs +} + +// benchObservationHandles broadcasts one stream value payload per oracle and +// returns each oracle's marshaled blob handle. +func benchObservationHandles(b *testing.B, c stateTransitionBenchCase, blobs *llotest.BlobBroadcastFetcher) [][]byte { + b.Helper() + handles := make([][]byte, c.n) + for o := range c.n { + values := make(protocol.StreamValues, c.streams+2) + for s := range c.streams + 2 { + values[llotypes.StreamID(s+1)] = protocol.ToDecimal(decimal.New(int64(1_000_000_000+s*1_000+o), -8)) //nolint:gosec // G115 bounded by the case + } + payload, err := marshalStreamValues(values) + require.NoError(b, err) + handle, err := blobs.BroadcastBlob(context.Background(), payload, ocr3_1types.BlobExpirationHintSequenceNumber{SeqNr: 0}) + require.NoError(b, err) + handles[o], err = handle.MarshalBinary() + require.NoError(b, err) + } + return handles +} + +func BenchmarkStateTransition(b *testing.B) { + for _, c := range stateTransitionBenchCases { + b.Run(c.String(), func(b *testing.B) { + ctx := context.Background() + p := benchStateTransitionPlugin(c) + blobs := llotest.NewBlobBroadcastFetcher() + handles := benchObservationHandles(b, c, blobs) + + const second = uint64(1_000_000_000) + ts := 1_000 * second + round := func() []ocrtypes.AttributedObservation { + aos := make([]ocrtypes.AttributedObservation, c.n) + for o := range c.n { + obs, err := encodeObservation(Observation{ + UnixTimestampNanoseconds: ts, + SupportedReportFormats: formatSet(llotypes.ReportFormatEVMABIEncodeUnpacked), + }, [][]byte{handles[o]}) + require.NoError(b, err) + aos[o] = ao(o, obs) + } + return aos + } + + // Bootstrap, then install the channels directly: voting them in + // takes MaxObservationUpdateChannelDefinitionsLength per round. + kv := newMemKV() + seqNr := uint64(1) + _, err := p.StateTransition(ctx, seqNr, ocrtypes.AttributedQuery{}, round(), kv, blobs) + require.NoError(b, err) + seqNr++ + require.NoError(b, writeChannelState(kv, seqNr, benchChannelDefinitions(c))) + + // Two warm-up rounds: the first gives every channel a watermark, + // the second is the first in which they report. + var reportable int + for range 2 { + ts += second + out, err := p.StateTransition(ctx, seqNr, ocrtypes.AttributedQuery{}, round(), kv, blobs) + require.NoError(b, err) + pre, err := decodePrecursor(out) + require.NoError(b, err) + reportable = len(pre.ReportableChannelIDs) + seqNr++ + } + require.Equal(b, c.channels, reportable, "steady round reports every channel") + + b.ReportAllocs() + for b.Loop() { + b.StopTimer() + ts += second + aos := round() + b.StartTimer() + if _, err := p.StateTransition(ctx, seqNr, ocrtypes.AttributedQuery{}, aos, kv, blobs); err != nil { + b.Fatal(err) + } + seqNr++ + } + }) + } +} From dc4b5fe0e7bdf1bffedfc82f286c0dc76a12fb11 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Wed, 21 Oct 2026 14:42:16 +0100 Subject: [PATCH 099/103] llo/v31: clone channel definitions only when a round votes on channels --- llo/dev/v31/statetransition.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/llo/dev/v31/statetransition.go b/llo/dev/v31/statetransition.go index 125a203..e0abe06 100644 --- a/llo/dev/v31/statetransition.go +++ b/llo/dev/v31/statetransition.go @@ -96,7 +96,9 @@ func (p *Plugin) StateTransition(ctx context.Context, seqNr uint64, _ ocrtypes.A // The definitions in effect for this round are the ones Observation read. // Changes agreed below land in pending and take effect next round. effective := cloneChannelDefinitions(prev.channelDefinitions) - pending := cloneChannelDefinitions(prev.channelDefinitions) + // pending stays nil unless this round applies channel votes. Most rounds + // carry none, so this saves a deep copy per round. + var pending llotypes.ChannelDefinitions out := precursor{ ObservationTimestampNanoseconds: p.agreedObservationTimestamp(tally.timestampsNanoseconds, prev.observationTimestampNs, seqNr), @@ -126,7 +128,8 @@ func (p *Plugin) StateTransition(ctx context.Context, seqNr uint64, _ ocrtypes.A // Channel definition changes (skipped once retired). These apply to pending // only: they take effect next round. - if out.LifeCycleStage != protocol.LifeCycleStageRetired { + if out.LifeCycleStage != protocol.LifeCycleStageRetired && tally.hasChannelVotes() { + pending = cloneChannelDefinitions(prev.channelDefinitions) applyChannelVotes(p.ReportCodecs, p.ChannelAnalysisCache.ReadOnly(), pending, tally.removeChannelVotesByID, tally.updateChannelDefinitionsByHash, tally.updateChannelVotesByHash, p.F) } @@ -291,6 +294,12 @@ type observationTally struct { attributed []attributedObservation } +// hasChannelVotes reports whether any observation voted to remove, add or +// update a channel. +func (t observationTally) hasChannelVotes() bool { + return len(t.removeChannelVotesByID) > 0 || len(t.updateChannelVotesByHash) > 0 +} + func (p *Plugin) decodeObservations(ctx context.Context, aos []ocrtypes.AttributedObservation, bf ocr3_1types.BlobFetcher, memo *roundBlobPayloads, collect bool) (observationTally, error) { tally := observationTally{ predConfigsByHash: make(map[[32]byte]predecessorConfig), @@ -750,8 +759,9 @@ func (p *Plugin) flushKV( // Channel definitions: persist the PENDING set, which becomes the effective // set of the next round. Rewrite the whole record only if it changed; - // removed channels disappear by not being part of pending. - if channelDefinitionsChanged(prev.channelDefinitions, pending) { + // removed channels disappear by not being part of pending. A nil pending + // means the round applied no votes. + if pending != nil && channelDefinitionsChanged(prev.channelDefinitions, pending) { if err := writeChannelState(kvRW, seqNr, pending); err != nil { return err } From ca780dffa5a86d2c833b163bb463a6c42667ddd5 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Wed, 21 Oct 2026 15:54:01 +0100 Subject: [PATCH 100/103] llo/calculated: bound TWAP gap tolerances by windowSeconds minus minSamples --- llo/protocol/calculated/functions_twap.go | 11 ++++--- .../calculated/functions_twap_test.go | 31 ++++++++++--------- ...codec_evm_abi_encode_unpacked_expr_test.go | 2 +- 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/llo/protocol/calculated/functions_twap.go b/llo/protocol/calculated/functions_twap.go index 83ef1df..7e37c7e 100644 --- a/llo/protocol/calculated/functions_twap.go +++ b/llo/protocol/calculated/functions_twap.go @@ -383,8 +383,11 @@ func parseTWAPConfig(raw any) (twapConfig, error) { return twapConfig{}, fmt.Errorf("%w: minSamples %d exceeds the %d one-second buckets in the window", ErrTWAPConfig, minSamples, windowSeconds) } - // A gap spans at most windowSeconds-1 buckets, so a larger tolerance can + // An accepted window holds at least minSamples observed buckets, so a gap + // spans at most windowSeconds-minSamples of them. A larger tolerance can // never be reached and is a misconfiguration rather than a looser rule. + // minSamples, not the tolerances, bounds the weight of a single sample. + maxGap := windowSeconds - int64(minSamples) for _, tolerance := range []struct { key string value int @@ -393,9 +396,9 @@ func parseTWAPConfig(raw any) (twapConfig, error) { {keyMaxInteriorGap, maxInteriorGap}, {keyMaxTailGap, maxTailGap}, } { - if int64(tolerance.value) >= windowSeconds { - return twapConfig{}, fmt.Errorf("%w: %s %d must be less than the %d one-second buckets in the window", - ErrTWAPConfig, tolerance.key, tolerance.value, windowSeconds) + if int64(tolerance.value) > maxGap { + return twapConfig{}, fmt.Errorf("%w: %s %d exceeds the %d buckets a gap can span in a window of %d with minSamples %d", + ErrTWAPConfig, tolerance.key, tolerance.value, maxGap, windowSeconds, minSamples) } } diff --git a/llo/protocol/calculated/functions_twap_test.go b/llo/protocol/calculated/functions_twap_test.go index 7182f5d..f7de8c6 100644 --- a/llo/protocol/calculated/functions_twap_test.go +++ b/llo/protocol/calculated/functions_twap_test.go @@ -132,7 +132,7 @@ func TestTWAP_FillThenAverage(t *testing.T) { { name: "insufficient samples: rejected even though every gap is within its threshold", prices: []int64{100, 0, 300, 0, 500}, // M=3, Gint=1 - minSamples: 4, maxInterior: 4, maxTail: 4, + minSamples: 4, maxInterior: 1, maxTail: 1, wantReasons: []TWAPRejectionReason{ReasonInsufficientSamples}, }, { @@ -165,7 +165,7 @@ func TestTWAP_FillThenAverage(t *testing.T) { { name: "multiple applicable reasons are all returned, not just the first", prices: []int64{100, 0, 0, 0, 0}, // M=1, Gtail=4 - minSamples: 3, maxInterior: 4, maxTail: 2, + minSamples: 3, maxInterior: 2, maxTail: 2, wantReasons: []TWAPRejectionReason{ReasonInsufficientSamples, ReasonTailGapTooLong}, }, { @@ -436,16 +436,19 @@ func TestTWAP_ConfigValidation(t *testing.T) { t.Run("gap tolerances must be reachable within the window", func(t *testing.T) { t.Parallel() - // A gap spans at most window-1 buckets. + // A gap spans at most window-minSamples buckets. for _, key := range []string{"maxHeadGap", "maxInteriorGap", "maxTailGap"} { - cfg := twapConfigMap(3, 1, 0, 0, 0) - cfg[key] = 3 - err := call(cfg) - require.ErrorIs(t, err, ErrTWAPConfig, key) - require.ErrorContains(t, err, key+" 3 must be less than", key) - - cfg[key] = 2 - require.NotErrorIs(t, call(cfg), ErrTWAPConfig, key) + for _, minSamples := range []int{1, 2} { + cfg := twapConfigMap(3, minSamples, 0, 0, 0) + reachable := 3 - minSamples + cfg[key] = reachable + 1 + err := call(cfg) + require.ErrorIs(t, err, ErrTWAPConfig, key) + require.ErrorContains(t, err, fmt.Sprintf("%s %d exceeds the %d buckets", key, reachable+1, reachable), key) + + cfg[key] = reachable + require.NotErrorIs(t, call(cfg), ErrTWAPConfig, key) + } } }) @@ -455,11 +458,11 @@ func TestTWAP_ConfigValidation(t *testing.T) { // property checked at configuration time. At runtime a thin window is a // data condition, and must come back with the measured statistics. shallow := twapSeries(t, []int64{100}) - _, err := twapFunc(anchorNs)(shallow, twapConfigMap(3, 3, 0, 2, 2)) + _, err := twapFunc(anchorNs)(shallow, twapConfigMap(3, 3, 0, 0, 0)) require.ErrorIs(t, err, ErrTWAPRejected) var rejection *TWAPRejection require.ErrorAs(t, err, &rejection) - assert.Equal(t, []TWAPRejectionReason{ReasonInsufficientSamples}, rejection.Reasons) + assert.Contains(t, rejection.Reasons, ReasonInsufficientSamples) }) t.Run("not a configuration map", func(t *testing.T) { @@ -519,7 +522,7 @@ func TestTWAP_RejectionMessage(t *testing.T) { series := twapSeries(t, prices) anchorNs := uint64(twapWindowStartSeconds+len(prices)) * uint64(time.Second) - _, err := twapFunc(anchorNs)(series, twapConfigMap(len(prices), 3, 0, 4, 2)) + _, err := twapFunc(anchorNs)(series, twapConfigMap(len(prices), 3, 0, 2, 2)) require.Error(t, err) var rejection *TWAPRejection diff --git a/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr_test.go b/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr_test.go index f4a8575..b972284 100644 --- a/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr_test.go +++ b/llo/reportcodec/evm/report_codec_evm_abi_encode_unpacked_expr_test.go @@ -478,7 +478,7 @@ func TestReportCodecEVMABIEncodeUnpackedExpr_Verify(t *testing.T) { Streams: []llotypes.Stream{{StreamID: 1, Aggregator: llotypes.AggregatorMedian}}, ReportFormat: llotypes.ReportFormatEVMABIEncodeUnpackedExpr, Opts: []byte(`{"baseUSDFee":"1","feedID":"0x1111111111111111111111111111111111111111111111111111111111111111","ABI":[` + - `{"type":"int192","expression":"TWAP(History(s1, 3), {window: Duration(\"3s\"), minSamples: 1, maxHeadGap: 3, maxInteriorGap: 3, maxTailGap: 3})","expressionStreamID":998},` + + `{"type":"int192","expression":"TWAP(History(s1, 3), {window: Duration(\"3s\"), minSamples: 1, maxHeadGap: 2, maxInteriorGap: 2, maxTailGap: 2})","expressionStreamID":998},` + `{"type":"int192","expression":"Add(s1, 1)","expressionStreamID":999}]}`), } require.NoError(t, c.VerifyForAdmission(cd)) From b2ad0fc6abd01a15216855ba3a3c09a0fa644344 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Wed, 21 Oct 2026 15:59:39 +0100 Subject: [PATCH 101/103] llo/calculated: validate literal TWAP configurations at admission --- llo/protocol/calculated/calculated_test.go | 2 +- llo/protocol/calculated/evaluate_fuzz_test.go | 2 +- .../calculated/functions_bench_test.go | 2 +- llo/protocol/calculated/history_ast.go | 88 +++++++++++++++---- llo/protocol/calculated/process_fuzz_test.go | 2 +- llo/protocol/calculated/validation_test.go | 73 ++++++++++++++- 6 files changed, 146 insertions(+), 23 deletions(-) diff --git a/llo/protocol/calculated/calculated_test.go b/llo/protocol/calculated/calculated_test.go index aac6f4e..b0b13b2 100644 --- a/llo/protocol/calculated/calculated_test.go +++ b/llo/protocol/calculated/calculated_test.go @@ -1857,7 +1857,7 @@ func TestEvaluationWeight_ChargesTWAPWindow(t *testing.T) { assert.Equal(t, 3, evaluationWeight(work("Count(History(s1, 2))"))) - twap := `TWAP(History(s1, 2), {window: Duration("24h"), minSamples: 1, maxHeadGap: 86400, maxInteriorGap: 86400, maxTailGap: 86400})` + twap := `TWAP(History(s1, 2), {window: Duration("24h"), minSamples: 1, maxHeadGap: 86399, maxInteriorGap: 86399, maxTailGap: 86399})` weight := evaluationWeight(work(twap)) assert.Equal(t, 3+twapMaxWindowSeconds, weight) assert.GreaterOrEqual(t, weight, minParallelEvaluationWeight) diff --git a/llo/protocol/calculated/evaluate_fuzz_test.go b/llo/protocol/calculated/evaluate_fuzz_test.go index 60dcf6c..c66d473 100644 --- a/llo/protocol/calculated/evaluate_fuzz_test.go +++ b/llo/protocol/calculated/evaluate_fuzz_test.go @@ -55,7 +55,7 @@ func FuzzEvaluateExpression(f *testing.F) { "Sum(History(s1, 3))", "Min(History(s1, 3))", "Max(History(s1, 3))", - `TWAP(History(s1, 3), {window: Duration("3s"), minSamples: 1, maxHeadGap: 3, maxInteriorGap: 3, maxTailGap: 3})`, + `TWAP(History(s1, 3), {window: Duration("3s"), minSamples: 1, maxHeadGap: 2, maxInteriorGap: 2, maxTailGap: 2})`, "Ln(s1)", "Log(s1, s2)", "Pow(s1, s2)", diff --git a/llo/protocol/calculated/functions_bench_test.go b/llo/protocol/calculated/functions_bench_test.go index 1909035..0d8dacf 100644 --- a/llo/protocol/calculated/functions_bench_test.go +++ b/llo/protocol/calculated/functions_bench_test.go @@ -270,7 +270,7 @@ func BenchmarkProcessCalculatedStreams(b *testing.B) { {"scalar", "Add(s1, s2)", 0}, {"avg/depth=300", "Avg(History(s1, 300))", 300}, {"ema/depth=300", "EMA(History(s1, 300), 20)", 300}, - {"twap/window=300", `TWAP(History(s1, 300), {window: Duration("5m"), minSamples: 150, maxHeadGap: 300, maxInteriorGap: 300, maxTailGap: 300})`, 300}, + {"twap/window=300", `TWAP(History(s1, 300), {window: Duration("5m"), minSamples: 150, maxHeadGap: 150, maxInteriorGap: 150, maxTailGap: 150})`, 300}, } { for _, channels := range []int{1, 32} { b.Run(fmt.Sprintf("%s/channels=%d", tc.name, channels), func(b *testing.B) { diff --git a/llo/protocol/calculated/history_ast.go b/llo/protocol/calculated/history_ast.go index c3c1c2d..6cb76bb 100644 --- a/llo/protocol/calculated/history_ast.go +++ b/llo/protocol/calculated/history_ast.go @@ -355,9 +355,9 @@ func (p *historyPatcher) rewrite(node *ast.Node, call *ast.CallNode) { // evaluation time, where the same condition would surface as a per-round // rejection and look like a data problem instead of a deployment mistake. // -// Only literal configuration can be checked. A configuration built at runtime is -// left to the runtime validation in functions_twap.go, which is stricter but -// later. +// Only literal configuration can be checked. A configuration made only of +// literals goes through the same parseTWAPConfig as evaluation. One with any +// value built at runtime is left to that runtime validation. func (p *historyPatcher) checkTWAP(call *ast.CallNode) { // Counted first, and counted whatever the call looks like: this is the one // TWAP check that does not depend on the configuration being literal, which @@ -382,23 +382,81 @@ func (p *historyPatcher) checkTWAP(call *ast.CallNode) { return // not a literal configuration } - minSamples, found := twapConfigLiteral(config, "minSamples") - if !found { + if minSamples, found := twapConfigLiteral(config, "minSamples"); found { + // Compared as int64: minSamples is a literal and can be any integer the + // parser accepted, so narrowing it to the width of ref.Count would let a + // value above 2^32 wrap into a small one and pass. The runtime validation + // still rejects it, but the diagnostic this check exists to give would be + // lost. + if minSamples < 1 { + p.errorf("%s requires minSamples to be at least 1, got %d", twapFunctionName, minSamples) + return + } + if minSamples > int64(ref.Count) { + p.errorf("%s requires at least %d observations but %s only keeps %d records; increase the history depth or lower minSamples", + twapFunctionName, minSamples, ref, ref.Count) + return + } + } + + // A configuration made only of literals is fixed by the expression, so the + // runtime rules apply to it here and a violation is a deployment mistake. + fields, err := twapLiteralConfig(config) + if err != nil { + p.errorf("%s: %s", twapFunctionName, err) return } - // Compared as int64: minSamples is a literal and can be any integer the - // parser accepted, so narrowing it to the width of ref.Count would let a - // value above 2^32 wrap into a small one and pass. The runtime validation - // still rejects it, but the diagnostic this check exists to give would be - // lost. - if minSamples < 1 { - p.errorf("%s requires minSamples to be at least 1, got %d", twapFunctionName, minSamples) + if fields == nil { return } - if minSamples > int64(ref.Count) { - p.errorf("%s requires at least %d observations but %s only keeps %d records; increase the history depth or lower minSamples", - twapFunctionName, minSamples, ref, ref.Count) + if _, err := parseTWAPConfig(fields); err != nil { + p.errorf("%s: %s", twapFunctionName, err) + } +} + +// twapLiteralConfig returns the values of a configuration map literal as +// evaluation would see them, or nil if any value is not a literal. A window +// written as Duration("...") counts as a literal, and its parse error is +// returned. +func twapLiteralConfig(config *ast.MapNode) (map[string]any, error) { + fields := make(map[string]any, len(config.Pairs)) + for _, pair := range config.Pairs { + kv, ok := pair.(*ast.PairNode) + if !ok { + return nil, nil + } + name, ok := kv.Key.(*ast.StringNode) + if !ok { + return nil, nil + } + if v, ok := integerLiteral(kv.Value); ok { + fields[name.Value] = v + continue + } + switch v := kv.Value.(type) { + case *ast.FloatNode: + fields[name.Value] = v.Value + case *ast.StringNode: + fields[name.Value] = v.Value + case *ast.CallNode: + callee, ok := v.Callee.(*ast.IdentifierNode) + if !ok || callee.Value != "Duration" || len(v.Arguments) != 1 { + return nil, nil + } + arg, ok := v.Arguments[0].(*ast.StringNode) + if !ok { + return nil, nil + } + d, err := ParseDuration(arg.Value) + if err != nil { + return nil, fmt.Errorf("%w: %s: %s", ErrTWAPConfig, name.Value, err) + } + fields[name.Value] = d + default: + return nil, nil + } } + return fields, nil } // checkWindowDepth rejects a window shallower than the function needs to diff --git a/llo/protocol/calculated/process_fuzz_test.go b/llo/protocol/calculated/process_fuzz_test.go index 27e8b52..18153e3 100644 --- a/llo/protocol/calculated/process_fuzz_test.go +++ b/llo/protocol/calculated/process_fuzz_test.go @@ -73,7 +73,7 @@ func FuzzProcessCalculatedStreams(f *testing.F) { "EMA(History(s1, 3), 2)", "SMA(History(s1, 3), 2)", "Stddev(History(s1, 3))", - `TWAP(History(s1, 3), {window: Duration("3s"), minSamples: 1, maxHeadGap: 3, maxInteriorGap: 3, maxTailGap: 3})`, + `TWAP(History(s1, 3), {window: Duration("3s"), minSamples: 1, maxHeadGap: 2, maxInteriorGap: 2, maxTailGap: 2})`, // Deeper than the reader serves: the whole round takes the warmup gate. "Avg(History(s1, 64))", // Two windows in one expression, so binding order is exercised. diff --git a/llo/protocol/calculated/validation_test.go b/llo/protocol/calculated/validation_test.go index 72f2265..0aff170 100644 --- a/llo/protocol/calculated/validation_test.go +++ b/llo/protocol/calculated/validation_test.go @@ -68,7 +68,7 @@ func TestValidateExpression_TWAPSatisfiability(t *testing.T) { // Exactly enough is fine. require.NoError(t, ValidateExpression( - `TWAP(History(s1, 240), {window: Duration("4m"), minSamples: 240, maxHeadGap: 30, maxInteriorGap: 10, maxTailGap: 30})`)) + `TWAP(History(s1, 240), {window: Duration("4m"), minSamples: 240, maxHeadGap: 0, maxInteriorGap: 0, maxTailGap: 0})`)) // A minSamples above the width of the record count must not wrap into a // small value and pass. 2^32+5 would narrow to 5. @@ -92,6 +92,71 @@ func TestValidateExpression_TWAPSatisfiability(t *testing.T) { require.Error(t, ValidateExpression("TWAP(History(s1, 10))")) } +func TestValidateExpression_TWAPLiteralConfig(t *testing.T) { + t.Parallel() + + twap := func(config string) string { + return `TWAP(History(s1, 600), {` + config + `})` + } + + t.Run("a valid literal configuration is accepted", func(t *testing.T) { + t.Parallel() + require.NoError(t, ValidateExpression(twap(`window: Duration("5m"), minSamples: 240, maxHeadGap: 60, maxInteriorGap: 10, maxTailGap: 60`))) + require.NoError(t, ValidateExpression(twap(`window: 300000000000, minSamples: 240, maxHeadGap: 60, maxInteriorGap: 10, maxTailGap: 60`))) + }) + + for _, tc := range []struct { + name string + config string + wantErr string + }{ + { + name: "gap tolerance the window cannot reach", + config: `window: Duration("5m"), minSamples: 240, maxHeadGap: 61, maxInteriorGap: 10, maxTailGap: 60`, + wantErr: "maxHeadGap 61 exceeds the 60 buckets", + }, + { + name: "window over the maximum", + config: `window: Duration("1000h"), minSamples: 240, maxHeadGap: 0, maxInteriorGap: 0, maxTailGap: 0`, + wantErr: "exceeds the maximum", + }, + { + name: "window not a whole number of seconds", + config: `window: Duration("1500ms"), minSamples: 1, maxHeadGap: 0, maxInteriorGap: 0, maxTailGap: 0`, + wantErr: "whole number of seconds", + }, + { + name: "unparseable duration", + config: `window: Duration("5 minutes"), minSamples: 240, maxHeadGap: 0, maxInteriorGap: 0, maxTailGap: 0`, + wantErr: "window: time: unknown unit", + }, + { + name: "missing key", + config: `window: Duration("5m"), minSamples: 240, maxHeadGap: 0, maxInteriorGap: 0`, + wantErr: "maxTailGap is required", + }, + { + name: "unknown key", + config: `window: Duration("5m"), minSamples: 240, maxHeadGap: 0, maxInteriorGap: 0, maxTailGap: 0, maxGap: 0`, + wantErr: "unknown keys maxGap", + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := ValidateExpression(twap(tc.config)) + require.ErrorIs(t, err, ErrHistoryExpression) + assert.Contains(t, err.Error(), ErrTWAPConfig.Error()) + assert.Contains(t, err.Error(), tc.wantErr) + }) + } + + t.Run("a configuration with a non-literal value is left to evaluation", func(t *testing.T) { + t.Parallel() + require.NoError(t, ValidateExpression(twap(`window: Duration("5m"), minSamples: 240, maxHeadGap: s1, maxInteriorGap: 10, maxTailGap: 60`))) + require.NoError(t, ValidateExpression(twap(`window: Duration(s1), minSamples: 240, maxHeadGap: 60, maxInteriorGap: 10, maxTailGap: 60`))) + }) +} + func TestValidateChannelExpressions(t *testing.T) { t.Parallel() @@ -210,7 +275,7 @@ func TestValidateChannelExpressions_Identifiers(t *testing.T) { "Add(s3_ask, s3_benchmark)", "Add(s4, s4_bid)", // mode carries whatever type its contributions agree on "Sub(observations_timestamp, s1_timestamp)", - `TWAP(History(s1, 3), {window: Duration("3s"), minSamples: 1, maxHeadGap: 3, maxInteriorGap: 3, maxTailGap: 3})`, + `TWAP(History(s1, 3), {window: Duration("3s"), minSamples: 1, maxHeadGap: 2, maxInteriorGap: 2, maxTailGap: 2})`, "let a = Mul(s1, s2); Add(a, 1)", } { t.Run("accepts "+expression, func(t *testing.T) { @@ -286,7 +351,7 @@ func twapCalls(count int) string { calls := make([]string, 0, count) for i := range count { calls = append(calls, fmt.Sprintf( - `TWAP(History(s%d, 1), {window: Duration("1s"), minSamples: 1, maxHeadGap: 1, maxInteriorGap: 1, maxTailGap: 1})`, + `TWAP(History(s%d, 1), {window: Duration("1s"), minSamples: 1, maxHeadGap: 0, maxInteriorGap: 0, maxTailGap: 0})`, i+1)) } return sumExpressions(calls) @@ -414,7 +479,7 @@ func TestValidateChannelExpressions_CountLimit(t *testing.T) { func TestChannelTWAPCalls(t *testing.T) { t.Parallel() - const twapCall = `TWAP(History(s1, 3), {window: Duration("3s"), minSamples: 1, maxHeadGap: 3, maxInteriorGap: 3, maxTailGap: 3})` + const twapCall = `TWAP(History(s1, 3), {window: Duration("3s"), minSamples: 1, maxHeadGap: 2, maxInteriorGap: 2, maxTailGap: 2})` channel := func(expressions ...string) llotypes.ChannelDefinition { abi := "" for i, expression := range expressions { From c8eb0b0bef3ccbe64994087d17483c6ec48646a3 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Wed, 21 Oct 2026 17:48:05 +0100 Subject: [PATCH 102/103] llo/v31: bound promoted predecessor watermarks by the observation timestamp --- llo/dev/v31/flow_test.go | 34 ++++++++++++++++++++++++++++++++++ llo/dev/v31/statetransition.go | 25 +++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/llo/dev/v31/flow_test.go b/llo/dev/v31/flow_test.go index f6f58f9..cf1e754 100644 --- a/llo/dev/v31/flow_test.go +++ b/llo/dev/v31/flow_test.go @@ -538,6 +538,40 @@ func Test_StateTransition_Promotion(t *testing.T) { require.Equal(t, uint64(500), kvHotState(t, kv).validAfterNanoseconds[1]) } +// Test_StateTransition_Promotion_BoundsPromotedValidAfter covers the bound on +// watermarks adopted from the predecessor: a lead within maxConsensusClockLead +// is clock skew and kept, a larger lead is capped at the observation timestamp. +func Test_StateTransition_Promotion_BoundsPromotedValidAfter(t *testing.T) { + ctx := tests.Context(t) + const promotionTs = uint64(10 * time.Second) + p := testPlugin(t) + predecessor := ocrtypes.ConfigDigest{0xAB} + p.PredecessorConfigDigest = &predecessor + p.PredecessorRetirementReportCache = &mockPredecessorRetirementReportCache{ + report: protocol.RetirementReport{ValidAfterNanoseconds: map[llotypes.ChannelID]uint64{ + 1: promotionTs + maxConsensusClockLead, + 2: promotionTs + maxConsensusClockLead + 1, + }}, + } + kv := newMemKV() + + _, err := p.StateTransition(ctx, 1, ocrtypes.AttributedQuery{}, []ocrtypes.AttributedObservation{ao(0, nil), ao(1, nil), ao(2, nil)}, kv, testBlobs) + require.NoError(t, err) + + promoObs := promotionObs(promotionTs) + aos := make([]ocrtypes.AttributedObservation, 0, 4) + for i := 0; i < 4; i++ { + aos = append(aos, ao(i, mustEncodeObs(t, promoObs))) + } + _, err = p.StateTransition(ctx, 2, ocrtypes.AttributedQuery{}, aos, kv, testBlobs) + require.NoError(t, err) + require.Equal(t, string(protocol.LifeCycleStageProduction), string(kv.m[string(keyLifecycle)])) + + validAfter := kvHotState(t, kv).validAfterNanoseconds + require.Equal(t, promotionTs+maxConsensusClockLead, validAfter[1], "a lead within clock skew is kept") + require.Equal(t, promotionTs, validAfter[2], "a larger lead is capped at the observation timestamp") +} + // Test_StateTransition_Promotion_StagingOnlyChannelTreatedAsNew guards the // promotion path (Finding 4): a channel the staging instance added itself, that // is absent from the predecessor's retirement report, was never covered by the diff --git a/llo/dev/v31/statetransition.go b/llo/dev/v31/statetransition.go index e0abe06..fe1aa85 100644 --- a/llo/dev/v31/statetransition.go +++ b/llo/dev/v31/statetransition.go @@ -143,7 +143,7 @@ func (p *Plugin) StateTransition(ctx context.Context, seqNr uint64, _ ocrtypes.A // This mirrors v30, which replaces ValidAfterNanoseconds wholesale with // the report's map and skips carry-forward during promotion. for id, va := range promotedValidAfter { - out.ValidAfterNanoseconds[id] = va + out.ValidAfterNanoseconds[id] = p.boundPromotedValidAfter(seqNr, id, va, out.ObservationTimestampNanoseconds) } } else { for channelID, prevValidAfter := range prev.validAfterNanoseconds { @@ -848,7 +848,8 @@ func (p *Plugin) agreedObservationTimestamp(timestampsNanoseconds []uint64, prev // maxConsensusClockLead bounds how far the agreed observation timestamp may // sit ahead of this node's clock before it is reported. It sits well above // NTP skew between oracle clocks, and low enough that a forward jump costing -// more than a few rounds of reporting is caught. +// more than a few rounds of reporting is caught. It also bounds how far a +// promoted predecessor watermark may lead the promotion round's timestamp. const maxConsensusClockLead = uint64(5 * time.Second) // warnIfAheadOfLocalClock logs when the agreed observation timestamp is ahead @@ -869,6 +870,26 @@ func aheadOfClock(agreedNanoseconds, nowNanoseconds uint64) bool { return agreedNanoseconds > nowNanoseconds+maxConsensusClockLead } +// boundPromotedValidAfter caps a watermark adopted from the predecessor's +// retirement report at the promotion round's observation timestamp when it +// leads it by more than maxConsensusClockLead. +// +// An honest predecessor never sets a watermark above its own agreed +// observation timestamp, so a legitimate entry leads only by the skew between +// the two instances' agreed clocks. That is kept, to preserve the gapless +// handover. A larger lead is a predecessor fault and would hold the channel +// until the clock catches up. Capping trades it for a one-off overlap with the +// predecessor's last report. Both inputs are agreed, so the result is +// deterministic. +func (p *Plugin) boundPromotedValidAfter(seqNr uint64, channelID llotypes.ChannelID, validAfterNanoseconds, observationTimestampNanoseconds uint64) uint64 { + if !aheadOfClock(validAfterNanoseconds, observationTimestampNanoseconds) { + return validAfterNanoseconds + } + p.Logger.Errorw("Capping promoted validAfter that leads the observation timestamp; the predecessor's retirement report carries a watermark it could not have reported", + "seqNr", seqNr, "channelID", channelID, "validAfterNanoseconds", validAfterNanoseconds, "observationTimestampNanoseconds", observationTimestampNanoseconds) + return observationTimestampNanoseconds +} + func medianTimestamp(timestampsNanoseconds []uint64) uint64 { sort.Slice(timestampsNanoseconds, func(i, j int) bool { return timestampsNanoseconds[i] < timestampsNanoseconds[j] }) return timestampsNanoseconds[len(timestampsNanoseconds)/2] From 050ed01556c2a959980ae5203f528724516efe75 Mon Sep 17 00:00:00 2001 From: Bruno Moura Date: Tue, 27 Oct 2026 18:29:29 +0000 Subject: [PATCH 103/103] llo: map report formats to codec time resolution in one place Move the format to truncation mapping into protocol.ReportTimeResolution with with a switch so an unmapped format is unreportable instead of silently skipping the overlap guard at nanosecond resolution. --- llo/dev/v31/plugin_test.go | 5 +- llo/dev/v31/reports.go | 11 +- llo/dev/v31/resolution.go | 41 +----- llo/protocol/history_backfill.go | 30 +++-- llo/protocol/time_resolution.go | 46 +++++++ llo/protocol/time_resolution_test.go | 88 +++++++++++++ llo/reportcodec/evm/time_resolution_test.go | 130 ++++++++++++++++++++ 7 files changed, 292 insertions(+), 59 deletions(-) create mode 100644 llo/protocol/time_resolution.go create mode 100644 llo/protocol/time_resolution_test.go create mode 100644 llo/reportcodec/evm/time_resolution_test.go diff --git a/llo/dev/v31/plugin_test.go b/llo/dev/v31/plugin_test.go index 87d6a9a..8199a67 100644 --- a/llo/dev/v31/plugin_test.go +++ b/llo/dev/v31/plugin_test.go @@ -556,6 +556,10 @@ func Test_TimeResolutionOverlap(t *testing.T) { {"legacy same second -> not reportable", llotypes.ReportFormatEVMPremiumLegacy, nil, sec1a, sec1b, false}, {"legacy next second -> reportable", llotypes.ReportFormatEVMPremiumLegacy, nil, sec1a, sec2, true}, {"json same second -> reportable (nanosecond)", llotypes.ReportFormatJSON, nil, sec1a, sec1b, true}, + {"streamlined same second -> reportable (nanosecond)", llotypes.ReportFormatEVMStreamlined, []byte(`{"abi":[{"type":"int192"},{"type":"int192"},{"type":"int192"}]}`), sec1a, sec1b, true}, + // A format without a resolution mapping is refused rather than guessed + // at nanoseconds, which would skip the overlap guard entirely. + {"unmapped format next second -> not reportable", llotypes.ReportFormat(1000), nil, sec1a, sec2, false}, {"unpacked default opts same second -> not reportable (defaults to seconds)", llotypes.ReportFormatEVMABIEncodeUnpacked, unpackedOpts(``), sec1a, sec1b, false}, {"unpacked explicit ns same second -> reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, unpackedOpts(`"TimeResolution":"ns",`), sec1a, sec1b, true}, {"unpacked explicit seconds next second -> reportable", llotypes.ReportFormatEVMABIEncodeUnpacked, unpackedOpts(`"TimeResolution":"s",`), sec1a, sec2, true}, @@ -683,7 +687,6 @@ func Test_IsReportable_NilStreamValue(t *testing.T) { llotypes.ReportFormatEVMPremiumLegacy, llotypes.ReportFormatEVMABIEncodeUnpacked, llotypes.ReportFormatEVMStreamlined, - llotypes.ReportFormatCapabilityTrigger, } { for _, tc := range []struct { missing llotypes.StreamID diff --git a/llo/dev/v31/reports.go b/llo/dev/v31/reports.go index cedb82d..4a14f8b 100644 --- a/llo/dev/v31/reports.go +++ b/llo/dev/v31/reports.go @@ -357,11 +357,12 @@ func (o precursor) isReportable(channelID llotypes.ChannelID, minReportInterval return false } // The codec reads the same opts, so a channel whose opts do not decode - // cannot be encoded. Treat it as unreportable so validAfter does not advance - // over a round that emitted nothing. - res, ok := reportTimeResolution(cd, channelID, optsCache) - if !ok { - tally.note("channel opts cannot be decoded", channelID) + // cannot be encoded. A format without a resolution mapping is refused the + // same way rather than guessed. Treat both as unreportable so validAfter + // does not advance over a round that emitted nothing. + res, err := protocol.ReportTimeResolution(optsCache, cd, channelID) + if err != nil { + tally.note("channel time resolution unavailable", channelID, "err", err) return false } // Require validAfter and the observation timestamp to fall in distinct ticks diff --git a/llo/dev/v31/resolution.go b/llo/dev/v31/resolution.go index d975752..5bddc0c 100644 --- a/llo/dev/v31/resolution.go +++ b/llo/dev/v31/resolution.go @@ -1,48 +1,15 @@ package llo import ( - llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" - protocol "github.com/smartcontractkit/chainlink-data-streams/llo/protocol" ) -// timeResolutionOpts reads TimeResolution from a channel's opts. -type timeResolutionOpts struct { - TimeResolution protocol.TimeResolution `json:"TimeResolution"` -} - -// reportTimeResolution returns the resolution a channel's report format -// truncates report timestamps to. Such channels must not emit a report whose -// validAfter and observation timestamp fall in the same tick, otherwise the -// encoded report would be not yet valid (ValidFrom after Timestamp). -// -// It reads opts through the round OptsCache and decodes the definition's raw -// opts on a cache miss, so the result is deterministic across oracles. -// -// - ReportFormatEVMPremiumLegacy always uses seconds. -// - ReportFormatEVMABIEncodeUnpacked and ReportFormatEVMABIEncodeUnpackedExpr -// use the TimeResolution opt, which defaults to seconds (the zero value) -// when unset. ok is false when the opts cannot be read, and the caller -// treats such a channel as unreportable. -// - all other formats use full (nanosecond) resolution. -func reportTimeResolution(cd llotypes.ChannelDefinition, channelID llotypes.ChannelID, optsCache *protocol.OptsCache) (res protocol.TimeResolution, ok bool) { - switch cd.ReportFormat { - case llotypes.ReportFormatEVMPremiumLegacy: - return protocol.ResolutionSeconds, true - case llotypes.ReportFormatEVMABIEncodeUnpacked, llotypes.ReportFormatEVMABIEncodeUnpackedExpr: - o, err := protocol.GetOptsOrDecode[timeResolutionOpts](optsCache, cd, channelID) - if err != nil { - return 0, false - } - return o.TimeResolution, true - default: - return protocol.ResolutionNanoseconds, true - } -} - // tickOverlap reports whether validAfterNs and obsTsNs collapse to the same // (or an inverted) tick at res. It truncates with protocol.ConvertTimestamp, -// the same conversion the codecs apply when encoding. +// the same conversion the codecs apply when encoding. A channel must not emit +// a report whose validAfter and observation timestamp fall in the same tick, +// otherwise the encoded report would be not yet valid (ValidFrom after +// Timestamp). func tickOverlap(validAfterNs, obsTsNs uint64, res protocol.TimeResolution) bool { return protocol.ConvertTimestamp(validAfterNs, res) >= protocol.ConvertTimestamp(obsTsNs, res) } diff --git a/llo/protocol/history_backfill.go b/llo/protocol/history_backfill.go index 9b7c066..6debd30 100644 --- a/llo/protocol/history_backfill.go +++ b/llo/protocol/history_backfill.go @@ -212,9 +212,13 @@ func ObservationTimestampKeyToNanoseconds(rawKey uint64, res TimeResolution) (na return rawKey * multiplier, true } -// TargetChannelTimeResolution returns the resolution the target channel's codec -// encodes timestamps at, which is also the unit of its backfill observation keys. -// It is the only place that maps a report format to a resolution, so an unknown +// TargetChannelTimeResolution returns the unit of a history_backfill target's +// observation keys, which is also the tick a backfill report spans. +// +// Formats whose codec truncates timestamps use that truncation, read through +// ReportTimeResolution. The other formats keep a fixed seconds unit: it is the +// key convention committed definitions were written in, not what their codec +// encodes, and changing it would reinterpret every committed key. An unknown // format is an error rather than a guess. // // The resolution is read from the target's current definition every round, not @@ -225,20 +229,14 @@ func ObservationTimestampKeyToNanoseconds(rawKey uint64, res TimeResolution) (na // keys must be rewritten in the new unit together with the target change. func TargetChannelTimeResolution(target llotypes.ChannelDefinition) (TimeResolution, error) { switch target.ReportFormat { - case llotypes.ReportFormatEVMABIEncodeUnpacked, llotypes.ReportFormatEVMABIEncodeUnpackedExpr: - if len(target.Opts) == 0 { - return ResolutionSeconds, nil - } - var aux struct { - TimeResolution TimeResolution `json:"TimeResolution"` - } - if err := json.Unmarshal(target.Opts, &aux); err != nil { - return 0, fmt.Errorf("target channel opts: %w", err) + case llotypes.ReportFormatEVMPremiumLegacy, llotypes.ReportFormatEVMABIEncodeUnpacked, llotypes.ReportFormatEVMABIEncodeUnpackedExpr: + res, err := ReportTimeResolution(nil, target, 0) + if err != nil { + return 0, fmt.Errorf("target %w", err) } - return aux.TimeResolution, nil - case llotypes.ReportFormatEVMPremiumLegacy, llotypes.ReportFormatJSON, - llotypes.ReportFormatRetirement, llotypes.ReportFormatCapabilityTrigger, - llotypes.ReportFormatEVMStreamlined: + return res, nil + case llotypes.ReportFormatJSON, llotypes.ReportFormatRetirement, + llotypes.ReportFormatCapabilityTrigger, llotypes.ReportFormatEVMStreamlined: return ResolutionSeconds, nil case llotypes.ReportFormatHistoryBackfill: // Rejected at admission by ValidateHistoryBackfillTarget. Resolving it diff --git a/llo/protocol/time_resolution.go b/llo/protocol/time_resolution.go new file mode 100644 index 0000000..78f9372 --- /dev/null +++ b/llo/protocol/time_resolution.go @@ -0,0 +1,46 @@ +package protocol + +import ( + "fmt" + + llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" +) + +// timeResolutionOpts reads TimeResolution from a channel's opts. +type timeResolutionOpts struct { + TimeResolution TimeResolution `json:"TimeResolution"` +} + +// ReportTimeResolution returns the resolution a channel's report codec +// truncates ValidAfterNanoseconds and ObservationTimestampNanoseconds to when +// it encodes a report. It is the single mapping from report format to codec +// truncation: the state transition cannot consult the node-local codecs, so +// the mapping is kept here and tied to each codec's Encode by test. +// +// Every format is listed on purpose. A format without an entry returns an +// error rather than a guess, so a codec added without a mapping is loud: the +// plugin treats its channels as unreportable until the format is classified. +// +// Opts are read through optsCache when it is non-nil and decoded from the +// definition's raw opts otherwise, so the result is the same on every oracle. +func ReportTimeResolution(optsCache *OptsCache, cd llotypes.ChannelDefinition, channelID llotypes.ChannelID) (TimeResolution, error) { + switch cd.ReportFormat { + case llotypes.ReportFormatEVMPremiumLegacy: + return ResolutionSeconds, nil + case llotypes.ReportFormatEVMABIEncodeUnpacked, llotypes.ReportFormatEVMABIEncodeUnpackedExpr: + // The zero value is seconds, which is the codec default when unset. + o, err := GetOptsOrDecode[timeResolutionOpts](optsCache, cd, channelID) + if err != nil { + return 0, fmt.Errorf("channel opts: %w", err) + } + return o.TimeResolution, nil + case llotypes.ReportFormatJSON, llotypes.ReportFormatEVMStreamlined: + return ResolutionNanoseconds, nil + case llotypes.ReportFormatHistoryBackfill: + return 0, fmt.Errorf("report format %s is encoded with the target channel's codec", cd.ReportFormat) + case llotypes.ReportFormatRetirement, llotypes.ReportFormatCapabilityTrigger: + return 0, fmt.Errorf("report format %s has no report codec", cd.ReportFormat) + default: + return 0, fmt.Errorf("report format %s has no time resolution mapping", cd.ReportFormat) + } +} diff --git a/llo/protocol/time_resolution_test.go b/llo/protocol/time_resolution_test.go new file mode 100644 index 0000000..cb1884d --- /dev/null +++ b/llo/protocol/time_resolution_test.go @@ -0,0 +1,88 @@ +package protocol + +import ( + "testing" + + "github.com/stretchr/testify/require" + + llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" +) + +func Test_ReportTimeResolution_ClassifiesEveryReportFormat(t *testing.T) { + // Every format chainlink-common defines must be classified here on + // purpose. A format missing from this table is a codec whose truncation + // the reportability guard would otherwise skip, so the test fails until + // someone maps it in ReportTimeResolution and records the decision below. + type want struct { + res TimeResolution + err string + } + table := map[llotypes.ReportFormat]want{ + llotypes.ReportFormatEVMPremiumLegacy: {res: ResolutionSeconds}, + llotypes.ReportFormatEVMABIEncodeUnpacked: {res: ResolutionSeconds}, + llotypes.ReportFormatEVMABIEncodeUnpackedExpr: {res: ResolutionSeconds}, + llotypes.ReportFormatJSON: {res: ResolutionNanoseconds}, + llotypes.ReportFormatEVMStreamlined: {res: ResolutionNanoseconds}, + llotypes.ReportFormatHistoryBackfill: {err: "encoded with the target channel's codec"}, + llotypes.ReportFormatRetirement: {err: "has no report codec"}, + llotypes.ReportFormatCapabilityTrigger: {err: "has no report codec"}, + } + for _, rf := range llotypes.ReportFormats { + t.Run(rf.String(), func(t *testing.T) { + w, ok := table[rf] + require.True(t, ok, "report format %s is not classified: add it to ReportTimeResolution and to this table", rf) + res, err := ReportTimeResolution(nil, llotypes.ChannelDefinition{ReportFormat: rf}, 1) + if w.err != "" { + require.ErrorContains(t, err, w.err) + return + } + require.NoError(t, err) + require.Equal(t, w.res, res) + }) + } + + _, err := ReportTimeResolution(nil, llotypes.ChannelDefinition{ReportFormat: llotypes.ReportFormat(1000)}, 1) + require.ErrorContains(t, err, "has no time resolution mapping") +} + +func Test_ReportTimeResolution_ReadsOpts(t *testing.T) { + for _, rf := range []llotypes.ReportFormat{llotypes.ReportFormatEVMABIEncodeUnpacked, llotypes.ReportFormatEVMABIEncodeUnpackedExpr} { + for _, tc := range []struct { + name string + opts string + want TimeResolution + }{ + {"unset defaults to seconds", `{"abi":[]}`, ResolutionSeconds}, + {"seconds", `{"TimeResolution":"s"}`, ResolutionSeconds}, + {"milliseconds", `{"TimeResolution":"ms"}`, ResolutionMilliseconds}, + {"microseconds", `{"TimeResolution":"us"}`, ResolutionMicroseconds}, + {"nanoseconds", `{"TimeResolution":"ns"}`, ResolutionNanoseconds}, + } { + t.Run(rf.String()+" "+tc.name, func(t *testing.T) { + cd := llotypes.ChannelDefinition{ReportFormat: rf, Opts: []byte(tc.opts)} + + res, err := ReportTimeResolution(nil, cd, 1) + require.NoError(t, err) + require.Equal(t, tc.want, res, "decoded from raw opts") + + cache := NewOptsCache() + cache.Set(1, cd.Opts) + res, err = ReportTimeResolution(cache, cd, 1) + require.NoError(t, err) + require.Equal(t, tc.want, res, "read through the opts cache") + }) + } + + t.Run(rf.String()+" undecodable opts", func(t *testing.T) { + for _, opts := range []string{`{"TimeResolution":"bogus"}`, `{"not":`} { + _, err := ReportTimeResolution(nil, llotypes.ChannelDefinition{ReportFormat: rf, Opts: []byte(opts)}, 1) + require.ErrorContains(t, err, "channel opts") + } + }) + } + + // Formats with a fixed resolution ignore opts entirely. + res, err := ReportTimeResolution(nil, llotypes.ChannelDefinition{ReportFormat: llotypes.ReportFormatEVMPremiumLegacy, Opts: []byte(`{"TimeResolution":"ns"}`)}, 1) + require.NoError(t, err) + require.Equal(t, ResolutionSeconds, res) +} diff --git a/llo/reportcodec/evm/time_resolution_test.go b/llo/reportcodec/evm/time_resolution_test.go new file mode 100644 index 0000000..e212fa7 --- /dev/null +++ b/llo/reportcodec/evm/time_resolution_test.go @@ -0,0 +1,130 @@ +package evm + +import ( + "encoding/binary" + "fmt" + "math/big" + "testing" + + "github.com/shopspring/decimal" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" + + protocol "github.com/smartcontractkit/chainlink-data-streams/llo/protocol" + "github.com/smartcontractkit/chainlink-data-streams/llo/reportcodec" + v3 "github.com/smartcontractkit/chainlink-data-streams/llo/reportcodec/evm/v3" +) + +// Test_ReportTimeResolution_MatchesCodecEncode ties protocol.ReportTimeResolution +// to what each codec writes. The reportability guard truncates with the mapped +// resolution and the codec truncates on Encode; if the two disagree, a report +// can pass the guard and still encode with ValidFrom after Timestamp. +// +// The timestamps share a second but differ at every finer resolution, so a +// codec truncating to the wrong unit changes at least one encoded field. +func Test_ReportTimeResolution_MatchesCodecEncode(t *testing.T) { + const ( + validAfterNs = 1_700_000_000_111_222_333 + obsTsNs = 1_700_000_000_444_555_666 + ) + + // encodedTimestamps returns the validAfter and observation timestamp a + // codec wrote, in the codec's unit. + type encodedTimestamps func(t *testing.T, encoded []byte) (validAfter, obsTs uint64) + + abiHeader := func(t *testing.T, encoded []byte) (uint64, uint64) { + // Header words: feedID, validFromTimestamp, timestamp, nativeFee, linkFee, expiresAt. + require.GreaterOrEqual(t, len(encoded), 6*32) + validFrom := new(big.Int).SetBytes(encoded[32:64]) + ts := new(big.Int).SetBytes(encoded[64:96]) + require.True(t, validFrom.IsUint64() && ts.IsUint64()) + return validFrom.Uint64() - 1, ts.Uint64() + } + abiOpts := func(resolution string) []byte { + return []byte(`{"baseUSDFee":"1","expirationWindow":60,"feedID":"0x0102030000000000000000000000000000000000000000000000000000000000",` + resolution + `"abi":[{"type":"int192"}]}`) + } + abiValues := []protocol.StreamValue{ + protocol.ToDecimal(decimal.NewFromInt(2)), + protocol.ToDecimal(decimal.NewFromInt(3)), + protocol.ToDecimal(decimal.NewFromInt(4)), + } + + type codecCase struct { + format llotypes.ReportFormat + opts []byte + codec protocol.ReportCodec + values []protocol.StreamValue + encoded encodedTimestamps + obsTsSet bool // whether the encoding carries the observation timestamp + } + cases := []codecCase{ + { + format: llotypes.ReportFormatEVMPremiumLegacy, + opts: []byte(`{"baseUSDFee":"1","expirationWindow":60,"feedId":"0x0102030000000000000000000000000000000000000000000000000000000000","multiplier":10}`), + codec: ReportCodecPremiumLegacy{logger.Nop(), 1}, + values: newValidPremiumLegacyReport().Values, + encoded: func(t *testing.T, encoded []byte) (uint64, uint64) { + decoded, err := v3.Decode(encoded) + require.NoError(t, err) + return uint64(decoded.ValidFromTimestamp) - 1, uint64(decoded.ObservationsTimestamp) + }, + obsTsSet: true, + }, + { + format: llotypes.ReportFormatJSON, + codec: reportcodec.JSONReportCodec{}, + values: abiValues, + encoded: func(t *testing.T, encoded []byte) (uint64, uint64) { + decoded, err := reportcodec.JSONReportCodec{}.Decode(encoded) + require.NoError(t, err) + return decoded.ValidAfterNanoseconds, decoded.ObservationTimestampNanoseconds + }, + obsTsSet: true, + }, + { + format: llotypes.ReportFormatEVMStreamlined, + opts: []byte(`{"abi":[{"type":"int192"}]}`), + codec: ReportCodecEVMStreamlined{logger.Nop()}, + values: []protocol.StreamValue{protocol.ToDecimal(decimal.NewFromInt(2))}, + encoded: func(t *testing.T, encoded []byte) (uint64, uint64) { + // Without a feed ID: report format, channel ID, then validAfter. + require.GreaterOrEqual(t, len(encoded), 16) + return binary.BigEndian.Uint64(encoded[8:16]), 0 + }, + }, + } + for _, resolution := range []string{``, `"TimeResolution":"s",`, `"TimeResolution":"ms",`, `"TimeResolution":"us",`, `"TimeResolution":"ns",`} { + cases = append(cases, + codecCase{llotypes.ReportFormatEVMABIEncodeUnpacked, abiOpts(resolution), ReportCodecEVMABIEncodeUnpacked{logger.Nop(), 1}, abiValues, abiHeader, true}, + codecCase{llotypes.ReportFormatEVMABIEncodeUnpackedExpr, abiOpts(resolution), ReportCodecEVMABIEncodeUnpackedExpr{logger.Nop(), 1}, abiValues, abiHeader, true}, + ) + } + + for _, tc := range cases { + t.Run(fmt.Sprintf("%s %s", tc.format, tc.opts), func(t *testing.T) { + cd := llotypes.ChannelDefinition{ReportFormat: tc.format, Opts: tc.opts} + cache := protocol.NewOptsCache() + cache.Set(1, cd.Opts) + + res, err := protocol.ReportTimeResolution(cache, cd, 1) + require.NoError(t, err) + + encoded, err := tc.codec.Encode(protocol.Report{ + SeqNr: 1, + ChannelID: 1, + ValidAfterNanoseconds: validAfterNs, + ObservationTimestampNanoseconds: obsTsNs, + Values: tc.values, + }, cd, cache) + require.NoError(t, err) + + gotValidAfter, gotObsTs := tc.encoded(t, encoded) + require.Equal(t, protocol.ConvertTimestamp(validAfterNs, res), gotValidAfter, "validAfter truncation differs from the mapped resolution") + if tc.obsTsSet { + require.Equal(t, protocol.ConvertTimestamp(obsTsNs, res), gotObsTs, "observation timestamp truncation differs from the mapped resolution") + } + }) + } +}