diff --git a/llo/datasource/datasource.go b/llo/datasource/datasource.go index 0435b097..49980ae5 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 } diff --git a/llo/dev/v31/backfill.go b/llo/dev/v31/backfill.go index 2b832e13..973d53aa 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" @@ -9,37 +11,48 @@ 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 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 } + // 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 { - return 0, 0, protocol.HistoryBackfillOpts{}, false + if !exists || target.Tombstone { + 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 +63,32 @@ 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 { + 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 + } + 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/blobcompress.go b/llo/dev/v31/blobcompress.go index 10f37245..d626cd53 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 9e0e8eff..9f9d48f8 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") diff --git a/llo/dev/v31/blobmemo.go b/llo/dev/v31/blobmemo.go index b8e0cbfd..92b54624 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 diff --git a/llo/dev/v31/blobpump.go b/llo/dev/v31/blobpump.go index 65c3ec9d..bd77944b 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) } @@ -482,6 +489,18 @@ 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 + } + dropUnobservable(sv, p.lggr, in.seqNr) payload, err := marshalStreamValues(sv) if err != nil { @@ -555,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 b84a697c..7c0ed55b 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" @@ -92,6 +94,45 @@ 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) +} + +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) { @@ -696,6 +737,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 +753,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/decode_fuzz_test.go b/llo/dev/v31/decode_fuzz_test.go index 7194689a..2232bce2 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" @@ -76,6 +77,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++ { @@ -115,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. @@ -175,7 +176,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/doc.go b/llo/dev/v31/doc.go index b7469a1c..3e764ab0 100644 --- a/llo/dev/v31/doc.go +++ b/llo/dev/v31/doc.go @@ -126,11 +126,24 @@ // 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. +// +// 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. // 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 +151,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 @@ -201,8 +213,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/factory.go b/llo/dev/v31/factory.go index 395adfae..0bf27e32 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" @@ -46,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 @@ -114,6 +116,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 @@ -123,6 +134,16 @@ 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) + } + + // 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 { @@ -165,6 +186,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 @@ -188,13 +210,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 @@ -210,8 +231,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/flow_test.go b/llo/dev/v31/flow_test.go index a5ad0cc8..cf1e7545 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" @@ -139,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 } @@ -154,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) { @@ -227,8 +234,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), MaxDurationObservation: time.Second}, 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), MaxDurationObservation: time.Second}, nil) require.NoError(t, err) pl, ok := p.(*Plugin) require.True(t, ok) @@ -258,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()) } @@ -277,6 +296,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)) } }) @@ -287,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{} @@ -317,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 }) @@ -470,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 @@ -578,6 +680,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 @@ -823,6 +972,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 @@ -870,11 +1042,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() @@ -882,8 +1054,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{} @@ -901,54 +1073,117 @@ 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() + 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})) + }) - _, 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("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})) + }) +} - // 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) +// 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(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) + + 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(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(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(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) + }) + + 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(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) + }) + + 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(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) }) } @@ -969,8 +1204,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 }) @@ -978,15 +1213,60 @@ 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) } - 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 +// 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/golden_test.go b/llo/dev/v31/golden_test.go index 3431c75d..9084cd32 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}, } } @@ -170,7 +167,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/handover_test.go b/llo/dev/v31/handover_test.go index c7837dd5..e122b93b 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/history.go b/llo/dev/v31/history.go index dc147393..9773c979 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) @@ -219,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_bench_test.go b/llo/dev/v31/history_bench_test.go index e6d0a6b4..70f04f5e 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 34011536..133d21a0 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), } } @@ -247,7 +249,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, nil) require.NotEmpty(t, got.denied) denied := got.sortedDenied()[0] @@ -281,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/history_metrics.go b/llo/dev/v31/history_metrics.go index 17661f33..d427ba87 100644 --- a/llo/dev/v31/history_metrics.go +++ b/llo/dev/v31/history_metrics.go @@ -217,10 +217,18 @@ func channelHistorySatisfied(expressions []string, aggByStream map[llotypes.Stre if !ok { continue } - // Reads through the memoized store, so this costs no extra state - // access. - 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 2d943e4c..b4fea124 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") +} diff --git a/llo/dev/v31/history_requirements.go b/llo/dev/v31/history_requirements.go index 04b4d303..b1e233a1 100644 --- a/llo/dev/v31/history_requirements.go +++ b/llo/dev/v31/history_requirements.go @@ -34,8 +34,11 @@ 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. -func computeHistoryRequirements(defs llotypes.ChannelDefinitions, optsCache *protocol.OptsCache, lggr logger.Logger) historyRequirements { +// dead windows alive. They are logged at debug level only, since evaluation +// reports the same channel at error level every round. +// +// 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 @@ -54,14 +57,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 +72,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,15 +93,19 @@ func computeHistoryRequirements(defs llotypes.ChannelDefinitions, optsCache *pro } } - return admitHistoryRequirements(required, lggr) + 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 @@ -107,22 +114,31 @@ 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 { +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 { 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 65992ef8..8591ec57 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, - }, logger.Test(t)) + }, 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, logger.Test(t)) + 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, logger.Test(t)) + 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, logger.Test(t)) + first := admitHistoryRequirements(required, nil) for range 20 { - again := admitHistoryRequirements(required, logger.Test(t)) + again := admitHistoryRequirements(required, nil) require.Equal(t, first.depths, again.depths) require.Equal(t, first.sortedDenied(), again.sortedDenied()) } diff --git a/llo/dev/v31/kv.go b/llo/dev/v31/kv.go index de4d3e25..a233cc53 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 @@ -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 @@ -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,8 +288,11 @@ func readChannelState(r ocr3_1types.KeyValueStateReader) (llotypes.ChannelDefini return defs, nil } -// readHotState reads and decodes the r/agg record into s. -func readHotState(r ocr3_1types.KeyValueStateReader, s *kvState) error { +// 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, lggr logger.Logger) error { b, err := r.Read(keyHotState) if err != nil { return fmt.Errorf("read hot state: %w", err) @@ -306,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{} } @@ -549,6 +566,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 { diff --git a/llo/dev/v31/kv_test.go b/llo/dev/v31/kv_test.go index 57fb3051..05580408 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 b843e3c7..e2209c61 100644 --- a/llo/dev/v31/limits_test.go +++ b/llo/dev/v31/limits_test.go @@ -1,6 +1,11 @@ package llo import ( + "fmt" + "maps" + "math" + "math/big" + "slices" "strings" "testing" @@ -13,6 +18,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 @@ -79,20 +85,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), } } @@ -103,17 +113,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 @@ -141,7 +153,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 @@ -185,6 +197,112 @@ 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(nil, alone, nil).Err()) + } + + pending := cloneChannelDefinitions(committed) + 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)) + + // 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(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(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 // 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 @@ -252,3 +370,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.MaxRetirementReportChannels) + for i := range protocol.MaxRetirementReportChannels { + 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) + 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 { + 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), + } + } + + // Every channel reportable, under the largest IDs. + reportable := slices.Sorted(maps.Keys(defs)) + + encoded, err := encodePrecursor(precursor{ + LifeCycleStage: llotypes.LifeCycleStage(strings.Repeat("x", 32)), + ObservationTimestampNanoseconds: math.MaxUint64, + ChannelDefinitions: defs, + ValidAfterNanoseconds: validAfter, + StreamAggregates: aggregates, + ChannelStateSeqNr: math.MaxUint64, + ReportableChannelIDs: reportable, + }) + 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 0bfe164b..632cc6ed 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 @@ -123,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() } @@ -188,6 +198,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) @@ -244,6 +260,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, @@ -276,25 +300,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.PredecessorSigners) > protocol.MaxObservationPredecessorSignersLength { - return Observation{}, fmt.Errorf("observation carries too many predecessor signers: %d (max %d)", len(main.PredecessorSigners), protocol.MaxObservationPredecessorSignersLength) + 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) } - for i, signer := range main.PredecessorSigners { + obs.PredecessorSigners = main.PredecessorSigners + obs.PredecessorF = uint8(main.PredecessorF) + + obs.SupportedReportFormats = formatsFromWire(main.SupportedReportFormats) + return obs, nil +} + +// 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) + } + 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 eacd9881..181e6760 100644 --- a/llo/dev/v31/plugin.go +++ b/llo/dev/v31/plugin.go @@ -13,12 +13,14 @@ 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" "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. @@ -47,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 @@ -63,9 +67,17 @@ 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 + // 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 @@ -156,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) @@ -298,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 @@ -325,50 +344,31 @@ 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{}{} 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 - } - // 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. 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 { + 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) @@ -376,20 +376,84 @@ func (p *Plugin) voteOnChannels(obs *Observation, state *kvState) { obs.RemoveChannelIDs[channelID] = struct{}{} } - obs.UpdateChannelDefinitions = make(llotypes.ChannelDefinitions) - expectedChannelIDs := maps.Keys(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 +// 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 @@ -420,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 @@ -441,7 +496,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) @@ -467,7 +523,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) } @@ -506,6 +562,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/plugin_test.go b/llo/dev/v31/plugin_test.go index a71f6bbb..8199a678 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" @@ -78,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 } @@ -392,25 +393,35 @@ 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 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. - 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) + + // 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) + require.Len(t, reports, 1) } func Test_Precursor_RoundTrip_And_Determinism(t *testing.T) { @@ -492,20 +503,46 @@ 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) { + // 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 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 { @@ -519,14 +556,67 @@ func Test_SecondsResolutionOverlap(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}, + {"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}, + {"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, unpackedOpts(`"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) { 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.reportableChannels(0, 0, p.supportFor(1), cache, nil) + if tc.reportable { + require.Equal(t, []llotypes.ChannelID{1}, got) + } else { + require.Empty(t, got) + } + }) + } + + // 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}]}`) + } + 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}, + {"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) { + p := mkPrec(llotypes.ReportFormatEVMABIEncodeUnpackedExpr, tc.opts, sec1a, tc.obsTs) + p.StreamAggregates[exprStreamID] = map[llotypes.Aggregator]protocol.StreamValue{ + llotypes.AggregatorCalculated: protocol.ToDecimal(decimal.NewFromInt(3)), + } + cache := protocol.NewOptsCache() + cache.ResetTo(p.ChannelDefinitions) + got := p.reportableChannels(0, 0, p.supportFor(1), cache, nil) if tc.reportable { require.Equal(t, []llotypes.ChannelID{1}, got) } else { @@ -554,14 +644,75 @@ 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 +// 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}, + } + // 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 { + 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, + llotypes.ReportFormatEVMABIEncodeUnpacked, + llotypes.ReportFormatEVMStreamlined, + } { + 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, Opts: optsFor[format]}}, + ValidAfterNanoseconds: map[llotypes.ChannelID]uint64{1: uint64(time.Second)}, + StreamAggregates: allExcept(format, tc.missing), + } + got := o.reportableChannels(0, 0, o.supportFor(1), 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) { @@ -570,7 +721,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}, @@ -618,17 +770,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) { @@ -638,32 +790,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.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)) }) } @@ -744,6 +903,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) @@ -876,17 +1111,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. @@ -897,7 +1132,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) @@ -990,6 +1225,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() @@ -1058,7 +1325,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) @@ -1114,7 +1381,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) { @@ -1122,20 +1389,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{ @@ -1143,19 +1412,63 @@ 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 -// 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)) +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") +} + +// 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 } @@ -1165,36 +1478,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) { @@ -1210,25 +1522,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) { @@ -1250,13 +1559,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) { @@ -1307,17 +1614,74 @@ func Test_Observation_SupportedReportFormats_RoundTrip(t *testing.T) { require.ErrorContains(t, err, "advertises too many report formats") } -func Test_Precursor_SupportByFormat_RoundTrip(t *testing.T) { +// 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) + + 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_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) @@ -1326,7 +1690,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) { @@ -1344,12 +1741,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) + _, 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) - 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) { @@ -1382,6 +1776,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) @@ -1424,35 +1849,33 @@ 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. - 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) - require.NoError(t, err) - prec, err := decodePrecursor(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) 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) @@ -1474,12 +1897,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 @@ -1531,8 +1951,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) @@ -1563,12 +1983,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()) @@ -1578,10 +2000,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 @@ -1592,7 +2015,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) @@ -1625,10 +2048,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))}, } @@ -1637,14 +2079,112 @@ 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)) } +// 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_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 @@ -1681,7 +2221,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 @@ -1694,7 +2234,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"] @@ -1704,7 +2244,60 @@ 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_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 @@ -1837,6 +2430,93 @@ 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 } + +// 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 b3363aa7..ab3340a0 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) @@ -118,6 +107,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 { @@ -140,17 +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. - 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 { @@ -165,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/report_metrics.go b/llo/dev/v31/report_metrics.go new file mode 100644 index 00000000..a2669737 --- /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 f06c2412..4a14f8bc 100644 --- a/llo/dev/v31/reports.go +++ b/llo/dev/v31/reports.go @@ -3,8 +3,8 @@ package llo import ( "context" "fmt" - "math" "sort" + "strconv" "github.com/smartcontractkit/chainlink-common/pkg/logger" llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" @@ -63,30 +63,34 @@ 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 { - 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. @@ -107,15 +111,15 @@ 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.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,13 +140,10 @@ 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)) - for _, strm := range streams { - values = append(values, out.StreamAggregates[strm.StreamID][strm.Aggregator]) - } + values := out.reportValues(streams) report := protocol.Report{ ConfigDigest: p.ConfigDigest, @@ -154,16 +155,16 @@ 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 { - 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]{ @@ -270,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 @@ -282,29 +286,23 @@ func (o precursor) formatIsEncodable(format llotypes.ReportFormat, f int, channe return true } -// 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 +// 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 math.MaxUint64 + return values } -func (o precursor) reportableChannels(minReportInterval uint64, f int, optsCache *protocol.OptsCache, tally *unreportableTally) []llotypes.ChannelID { +// reportableChannels returns the sorted set of channels reportable in this +// (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) } } @@ -312,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 } @@ -339,24 +337,48 @@ 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 // 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) } - // 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 - } - } + 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. 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 + // 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 @@ -366,39 +388,40 @@ 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. 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) 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. - 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) + // 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. + 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) { - return false - } - validAfter, ok := o.ValidAfterNanoseconds[channelID] - if !ok { - return false - } - 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) && secondsOverlap(validAfter, o.ObservationTimestampNanoseconds) { + // 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 } return true diff --git a/llo/dev/v31/resolution.go b/llo/dev/v31/resolution.go index baa1bcc8..5bddc0c8 100644 --- a/llo/dev/v31/resolution.go +++ b/llo/dev/v31/resolution.go @@ -1,52 +1,15 @@ 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" ) -// timeResolutionOpts reads TimeResolution from a channel's opts. -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. -// -// 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. -// -// - 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. -// - all other formats use full (nanosecond) resolution. -func isSecondsResolution(cd llotypes.ChannelDefinition) 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 - default: - return false - } -} - -// 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. 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/dev/v31/statetransition.go b/llo/dev/v31/statetransition.go index 91458396..fe1aa853 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" @@ -69,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) } @@ -78,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 @@ -95,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), @@ -103,8 +106,8 @@ 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) // Lifecycle stage & promotion. promotedValidAfter := map[llotypes.ChannelID]uint64(nil) @@ -115,6 +118,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 @@ -122,8 +128,9 @@ 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) + 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) } // validAfter. @@ -136,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 { @@ -161,7 +168,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 } } @@ -197,14 +204,19 @@ 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 } - 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 @@ -227,8 +239,16 @@ 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) + // 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 @@ -243,6 +263,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) } @@ -271,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), @@ -305,13 +334,17 @@ 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) } - // 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}) @@ -365,6 +398,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 { @@ -415,12 +454,39 @@ 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. +// +// 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. +// +// 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. +// +// 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, @@ -442,25 +508,43 @@ 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]++ + } + } + var budget *protocol.ChannelDefinitionsBudget for _, hwid := range ordered { - if updateVotesByHash[hwid.hash] <= f { + if updateVotesByHash[hwid.hash] <= f || passing[hwid.def.ChannelID] > 1 { continue } + if budget == nil { + budget = protocol.NewChannelDefinitionsBudget(codecs, defs, cache) + } 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(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 + } defs[defWithID.ChannelID] = defWithID.ChannelDefinition } } @@ -474,6 +558,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 @@ -528,8 +617,14 @@ 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.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 @@ -563,6 +658,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 @@ -587,6 +694,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(10 * time.Second) + +// 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 @@ -641,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 } @@ -659,12 +778,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 @@ -696,7 +814,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] @@ -709,6 +827,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 { @@ -719,6 +845,51 @@ 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. 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 +// 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 +} + +// 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] @@ -784,7 +955,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) @@ -794,9 +977,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. diff --git a/llo/dev/v31/statetransition_bench_test.go b/llo/dev/v31/statetransition_bench_test.go new file mode 100644 index 00000000..0098c322 --- /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++ + } + }) + } +} diff --git a/llo/dev/v31/telemetry.go b/llo/dev/v31/telemetry.go index aa6cf51b..956c99fc 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), } @@ -104,8 +105,8 @@ func makeReportTelemetry(r protocol.Report, cd llotypes.ChannelDefinition, donID 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. 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 } @@ -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 3ed37ce1..6a9ad1bb 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) @@ -634,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 e93d657b..9e6e9f8f 100644 Binary files a/llo/dev/v31/testdata/golden/precursor_full.bin and b/llo/dev/v31/testdata/golden/precursor_full.bin differ diff --git a/llo/pluginconfig/v31config.go b/llo/pluginconfig/v31config.go index 3e1e73bb..75fafd71 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 diff --git a/llo/protocol/abi.go b/llo/protocol/abi.go new file mode 100644 index 00000000..b5d39527 --- /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 00000000..f237523a --- /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/aggregators.go b/llo/protocol/aggregators.go index a6a7060d..d622cc73 100644 --- a/llo/protocol/aggregators.go +++ b/llo/protocol/aggregators.go @@ -2,6 +2,7 @@ package protocol import ( "fmt" + "math/big" "slices" "sort" @@ -15,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: @@ -28,31 +30,53 @@ 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) } - 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 // 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 } @@ -95,18 +119,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 +168,79 @@ 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 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 +// 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 623d3fd2..3d4b7422 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,53 @@ 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("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") @@ -174,6 +223,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 +354,34 @@ 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)) + }) +} + +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) + } +} diff --git a/llo/protocol/calculated/calculated.go b/llo/protocol/calculated/calculated.go index 16e9da9d..3f9b453c 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. @@ -424,7 +426,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 +441,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 +450,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") } @@ -491,8 +493,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 +505,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,20 +515,50 @@ 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) } -// 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 // 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: @@ -810,17 +842,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 @@ -842,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 { @@ -863,9 +913,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) @@ -891,6 +942,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 @@ -1181,6 +1251,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), } @@ -1203,7 +1278,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 { @@ -1215,5 +1290,3 @@ func (v *visitor) Visit(node *ast.Node) { } } } - -var streamMatch = regexp.MustCompile(`s(\d+)(?:_(bid|ask|benchmark|timestamp))?`) diff --git a/llo/protocol/calculated/calculated_test.go b/llo/protocol/calculated/calculated_test.go index b8e7df0f..b0b13b2e 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. @@ -1774,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: 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/decimalmath.go b/llo/protocol/calculated/decimalmath.go index 1b0c959a..45da5993 100644 --- a/llo/protocol/calculated/decimalmath.go +++ b/llo/protocol/calculated/decimalmath.go @@ -2,58 +2,24 @@ package calculated import ( "fmt" - "sync" + "math/big" + "strconv" "github.com/shopspring/decimal" -) - -// 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. -// -// TWAP makes it far more likely by calling ln and exp 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) -} -// 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) -} + "github.com/smartcontractkit/chainlink-data-streams/llo/protocol" +) -// recoverTranscendental converts a panic from shopspring/decimal into an error. +// decimalPow is decimal.PowWithPrecision with its logarithm and exponential +// replaced by lnDecimal and expDecimal. // -// 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 under the transcendental lock. Powers -// with a non-integer exponent are evaluated via the same logarithm and -// exponential machinery. +// 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 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 @@ -61,30 +27,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 + } + + // 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 := lnDecimal(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 +} - // 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. +// decimalIntPow is decimal.PowWithPrecision for an integer exponent or a zero +// 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 +// 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) } @@ -109,13 +111,17 @@ 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, -// 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) @@ -125,7 +131,13 @@ func checkPow(base, exponent decimal.Decimal) error { if abs.IsZero() { return nil } - lnBase, err := decimalLn(abs, powEstimatePrecision) + // 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 := lnDecimal(abs, powEstimatePrecision) if err != nil { return fmt.Errorf("power of %s by %s could not be bounded: %w", base, exponent, err) } @@ -136,11 +148,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]. // @@ -173,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 @@ -218,21 +268,12 @@ 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. -// -// 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. @@ -242,6 +283,163 @@ 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() + // 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 + 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 +} + +// 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. 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. 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 bf6f6bba..ce4031f2 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" @@ -97,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() @@ -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,189 @@ 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) + }) +} + +// 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() + + 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) + libraryMu.Lock() + want, err := d.Abs().ExpTaylor(places + 30) + libraryMu.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) +} diff --git a/llo/protocol/calculated/doc.go b/llo/protocol/calculated/doc.go index ac621a3e..04a339f1 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/evaluate_fuzz_test.go b/llo/protocol/calculated/evaluate_fuzz_test.go index 60dcf6c3..c66d4732 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/expression_pins.go b/llo/protocol/calculated/expression_pins.go new file mode 100644 index 00000000..22d2e959 --- /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/functions_bench_test.go b/llo/protocol/calculated/functions_bench_test.go index acfa85b1..0d8dacf6 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) @@ -176,9 +174,69 @@ func BenchmarkTWAPRealisticGaps(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. +// 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 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 _, 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, + "maxHeadGap": windowSeconds, + "maxInteriorGap": windowSeconds, + "maxTailGap": windowSeconds, + } + twap := twapFunc(anchorNs) + + b.Run(tc.name, func(b *testing.B) { + b.ReportAllocs() + for range b.N { + if _, err := twap(tc.window, cfg); err != nil { + b.Fatal(err) + } + } + }) + } +} + +// 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. func BenchmarkTWAPParallel(b *testing.B) { const windowSeconds = 300 window := benchSeries(windowSeconds, 1) @@ -212,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/functions_series.go b/llo/protocol/calculated/functions_series.go index da222f7e..bdd2cf5e 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 e42e2d0a..6d23197d 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/functions_twap.go b/llo/protocol/calculated/functions_twap.go index c5686f05..7e37c7e1 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" @@ -74,8 +76,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 @@ -89,15 +98,15 @@ 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 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 logarithm +// and one exponential 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 +144,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 +160,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 +200,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. @@ -284,38 +270,58 @@ func twapFillThenAverage(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. +// 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. -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) { + ratio, err := twapStepRatio(left.price, right.price, right.offset-left.offset) if err != nil { - return fmt.Errorf("TWAP: bucket %d: %w", leftIdx, err) + return decimal.Decimal{}, fmt.Errorf("TWAP: interpolating buckets %d..%d: %w", left.offset+1, right.offset-1, err) } - exponent, err := divRoundByInt(decimal.NewFromInt(1), rightIdx-leftIdx, doublePrecision) + + sum := decimal.Zero + price := left.price + for range right.offset - left.offset - 1 { + price = price.Mul(ratio).Round(doublePrecision) + sum = sum.Add(price) + } + 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 err + return decimal.Decimal{}, err } - ratio, err := decimalPow(growth, exponent, doublePrecision) + logGrowth, err := lnDecimal(growth, working) if err != nil { - return fmt.Errorf("TWAP: interpolating buckets %d..%d: %w", runStart, rightIdx-1, err) + return decimal.Decimal{}, err } - - price := left - for k := runStart; k < rightIdx; k++ { - price = price.Mul(ratio).Round(doublePrecision) - filled[k] = price + logRatio, err := divRoundByInt(logGrowth, span, working) + if err != nil { + return decimal.Decimal{}, err } - return nil + return expDecimal(logRatio, doublePrecision) } // parseTWAPConfig decodes and validates the configuration map. @@ -377,6 +383,24 @@ 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) } + // 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 + }{ + {keyMaxHeadGap, maxHeadGap}, + {keyMaxInteriorGap, maxInteriorGap}, + {keyMaxTailGap, maxTailGap}, + } { + 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) + } + } return twapConfig{ windowSeconds: windowSeconds, @@ -414,7 +438,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 +455,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 00000000..c5211c70 --- /dev/null +++ b/llo/protocol/calculated/functions_twap_reference_test.go @@ -0,0 +1,246 @@ +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 +// +// 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 + + ratio, err := twapStepRatio(left, right, rightIdx-leftIdx) + 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 7b68817f..f7de8c6e 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}, @@ -117,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: 5, maxTail: 5, + minSamples: 4, maxInterior: 1, maxTail: 1, wantReasons: []TWAPRejectionReason{ReasonInsufficientSamples}, }, { @@ -150,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: 5, maxTail: 2, + minSamples: 3, maxInterior: 2, maxTail: 2, wantReasons: []TWAPRejectionReason{ReasonInsufficientSamples, ReasonTailGapTooLong}, }, { @@ -185,6 +200,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. @@ -219,11 +272,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") @@ -239,7 +294,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( @@ -379,17 +434,35 @@ 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-minSamples buckets. + for _, key := range []string{"maxHeadGap", "maxInteriorGap", "maxTailGap"} { + 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) + } + } + }) + 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 // 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) { @@ -449,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, 5, 2)) + _, err := twapFunc(anchorNs)(series, twapConfigMap(len(prices), 3, 0, 2, 2)) require.Error(t, err) var rejection *TWAPRejection diff --git a/llo/protocol/calculated/history_ast.go b/llo/protocol/calculated/history_ast.go index 747581f7..6cb76bbb 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. @@ -110,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 @@ -148,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 @@ -184,6 +200,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 { @@ -191,6 +212,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{}, } } @@ -208,10 +231,23 @@ 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. + 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 +257,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] { @@ -230,6 +274,7 @@ func (p *historyPatcher) Visit(node *ast.Node) { if callee.Value == twapFunctionName { p.checkTWAP(n) } + p.checkWindowDepth(callee.Value, n) } } } @@ -248,7 +293,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 @@ -310,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 @@ -337,22 +382,98 @@ 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 + } + if fields == nil { + return + } + 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 +// 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 } - // 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) + ref, ok := p.refByNode[call.Arguments[0]] + if !ok { 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 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) } } @@ -377,6 +498,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. // @@ -418,6 +553,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. @@ -447,23 +597,24 @@ 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, 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) ([]HistoryRef, error) { +func analyzeHistoryExpression(expression string) (refs []HistoryRef, twapCalls int, names []string, err error) { tree, err := parseExpression(expression) if err != nil { - return nil, 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, err + return nil, 0, nil, err } - return p.sortedRefs(), 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 dfb987f8..67c30094 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": { @@ -110,7 +114,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) }) @@ -220,10 +224,30 @@ 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() - 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 +268,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 +284,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 +297,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 +475,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 @@ -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/process_fuzz_test.go b/llo/protocol/calculated/process_fuzz_test.go index 27e8b523..18153e35 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/program.go b/llo/protocol/calculated/program.go index b23f8895..cba1be26 100644 --- a/llo/protocol/calculated/program.go +++ b/llo/protocol/calculated/program.go @@ -4,6 +4,8 @@ import ( "container/list" "errors" "fmt" + "strconv" + "strings" "sync" "github.com/expr-lang/expr/conf" @@ -15,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 @@ -45,16 +48,31 @@ 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 { expression string refs []HistoryRef + twapCalls int + names []string err error // program is the compiled expression, filled in on first successful @@ -74,6 +92,7 @@ func newAnalysisCache(max int) *analysisCache { entries: make(map[string]*list.Element, max), order: list.New(), max: max, + pinned: make(map[string]*pinnedEntry), } } @@ -95,6 +114,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 @@ -135,11 +182,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 field of 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 @@ -149,29 +206,87 @@ 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)) } } } 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. +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 + } + 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) { + 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 } + return c.put(newAnalysisEntry(expression)) +} - refs, err := analyzeHistoryExpression(expression) - c.put(&analysisEntry{expression: expression, refs: refs, err: err}) - return refs, err +func newAnalysisEntry(expression string) *analysisEntry { + refs, twapCalls, names, err := analyzeHistoryExpression(expression) + 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 @@ -180,21 +295,27 @@ 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 + 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() if oldest == nil { - return + break } c.order.Remove(oldest) delete(c.entries, oldest.Value.(*analysisEntry).expression) @@ -207,12 +328,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 @@ -222,16 +342,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 8845d10c..66421c4d 100644 --- a/llo/protocol/calculated/program_test.go +++ b/llo/protocol/calculated/program_test.go @@ -5,8 +5,12 @@ import ( "sync" "testing" + "github.com/expr-lang/expr" + "github.com/expr-lang/expr/vm" "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) { @@ -91,6 +95,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) { @@ -127,3 +150,163 @@ 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_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) { + 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/protocol/calculated/series.go b/llo/protocol/calculated/series.go index e5ac10dc..9d5fc7fb 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) diff --git a/llo/protocol/calculated/series_test.go b/llo/protocol/calculated/series_test.go index 8cc9f473..a84dbcb0 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/calculated/validation_test.go b/llo/protocol/calculated/validation_test.go index 68b331c8..0aff1709 100644 --- a/llo/protocol/calculated/validation_test.go +++ b/llo/protocol/calculated/validation_test.go @@ -5,6 +5,8 @@ import ( "strings" "testing" + "github.com/expr-lang/expr/ast" + "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -66,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. @@ -90,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() @@ -129,6 +196,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{ @@ -171,6 +249,65 @@ 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: 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)), + } + } + + for _, expression := range []string{ + "Add(s1, s2)", + "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: 2, maxInteriorGap: 2, maxTailGap: 2})`, + "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(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"}, + {"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) { @@ -214,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) @@ -262,6 +399,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. @@ -292,3 +475,52 @@ 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: 2, maxInteriorGap: 2, maxTailGap: 2})` + 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") +} + +// 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) +} diff --git a/llo/protocol/calculated_streams.go b/llo/protocol/calculated_streams.go index 9d53f7f0..3010e036 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) } } @@ -117,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/calculated_streams_test.go b/llo/protocol/calculated_streams_test.go index a59a89eb..db530e0d 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_analysis_cache.go b/llo/protocol/channel_analysis_cache.go index 3e51a0a4..1e3a291b 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 @@ -45,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 } @@ -61,29 +73,41 @@ 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 // a set does not by itself mean the channels it omits are gone, so this is for // 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) } } } @@ -93,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 } @@ -104,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 @@ -122,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 @@ -168,5 +191,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_analysis_cache_test.go b/llo/protocol/channel_analysis_cache_test.go index 014dc8c6..a70b56f3 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) } diff --git a/llo/protocol/channel_definitions.go b/llo/protocol/channel_definitions.go index 2df61ebe..5765c80a 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 @@ -231,20 +279,22 @@ 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 - // (tombstones excluded: they carry neither streams nor opts that anything - // reads) and checked once at the end. - 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)) - // 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 + // 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, factsOf) uniqueStreamIDs := make(map[llotypes.StreamID]struct{}, len(channelDefs)) // Owners of every stream ID that will hold an aggregate: observed streams @@ -260,7 +310,21 @@ 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) + } + + // 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) + } continue } @@ -272,15 +336,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) - // 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) @@ -295,12 +350,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 @@ -312,13 +361,15 @@ func analyzeChannelDefinitions(codecs map[llotypes.ReportFormat]ReportCodec, cha } } } - facts := channelFactsFor(cache, codecs, channelID, cd) + facts := factsOf(channelID, cd) if HasCalculatedStreams(cd) { if facts.calculatedErr != nil { admit(fmt.Errorf("invalid ChannelDefinition with ID %d: %w", channelID, facts.calculatedErr), channelID) } - totalCalculatedStreams += len(facts.calculatedIDs) + // 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. 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) @@ -348,7 +399,16 @@ 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 { + 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) } } @@ -367,19 +427,10 @@ 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. - 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 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)) + // record, the precursor and the carry-forward record, or the per-round + // evaluation work, actually depend on; see the limits they name. + if err := budget.Err(); err != nil { + admitSet(err) } res.uniqueStreamIDs = len(uniqueStreamIDs) @@ -403,6 +454,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 { diff --git a/llo/protocol/channel_definitions_budget.go b/llo/protocol/channel_definitions_budget.go new file mode 100644 index 00000000..fe1e9d75 --- /dev/null +++ b/llo/protocol/channel_definitions_budget.go @@ -0,0 +1,206 @@ +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 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 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 { + 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) + }) +} + +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 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.apply(channelID, *prev, -1) + } + b.apply(channelID, next, 1) + if b.totals().grows(before) { + b.apply(channelID, next, -1) + if prev != nil { + b.apply(channelID, *prev, 1) + } + return false + } + return true +} + +// 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 { + 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)) + } + 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 +} + +// 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) { + 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) + } +} + +// 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) + } +} + +// 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 00000000..aa613130 --- /dev/null +++ b/llo/protocol/channel_definitions_budget_test.go @@ -0,0 +1,166 @@ +package protocol + +import ( + "fmt" + "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(nil, defs, nil) + require.NoError(t, b.Err()) + + require.False(t, b.Replace(1000, nil, channel(1, 1))) + prev := defs[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(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(nil, defs, nil) + require.ErrorContains(t, b.Err(), "too many opts bytes across all channels") + + prev := defs[1000] + require.False(t, b.Replace(1000, &prev, channel(MaxChannelOptsBytes+1, 1))) + replaced := channel(MaxChannelOptsBytes, 2) + require.True(t, b.Replace(1000, &prev, replaced)) + + prev = replaced + trimmed := channel(0, 2) + trimmed.Tombstone = true + 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(nil, llotypes.ChannelDefinitions{1: shared, 2: shared}, nil) + require.Len(t, b.aggregatedPairs, 1) + + require.True(t, b.Replace(1, &shared, channel(0, 2))) + require.Len(t, b.aggregatedPairs, 2) + require.True(t, b.Replace(2, &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(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/channel_definitions_test.go b/llo/protocol/channel_definitions_test.go index 39af3601..f662d686 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 { @@ -266,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", } { @@ -274,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 @@ -391,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{}} @@ -523,6 +600,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 @@ -545,14 +648,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})) }) } diff --git a/llo/protocol/channel_hash.go b/llo/protocol/channel_hash.go index fd1077ef..ea48408f 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 814a0195..b4f9f9e0 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/encodable.go b/llo/protocol/encodable.go new file mode 100644 index 00000000..04271c49 --- /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 00000000..eb7d879e --- /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/history_backfill.go b/llo/protocol/history_backfill.go index 04a72ac5..6debd304 100644 --- a/llo/protocol/history_backfill.go +++ b/llo/protocol/history_backfill.go @@ -3,13 +3,14 @@ package protocol import ( "errors" "fmt" + "maps" "math" + "slices" "sort" "strconv" "github.com/goccy/go-json" - "github.com/smartcontractkit/chainlink-common/pkg/logger" llotypes "github.com/smartcontractkit/chainlink-common/pkg/types/llo" ) @@ -167,26 +168,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 } } @@ -200,40 +205,45 @@ 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 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 +// 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: - 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 - default: + 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 + // keeps already committed definitions passing base validation. return ResolutionSeconds, nil + default: + return 0, fmt.Errorf("unsupported history_backfill target report format %s", target.ReportFormat) } } @@ -315,33 +325,48 @@ func ValidateHistoryBackfillTarget(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 +// 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) } - 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) + 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 out + return nil } // StreamValueFromBackfillString parses a backfill observation string for the given aggregator. 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/history_backfill_test.go b/llo/protocol/history_backfill_test.go index 2e2db568..9db7500b 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" @@ -27,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) @@ -44,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} @@ -120,4 +162,71 @@ 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) { + 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") + }) } diff --git a/llo/protocol/lifecycle.go b/llo/protocol/lifecycle.go index be9cb9c4..b601d131 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 ab4fd26d..65bc46db 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") + }) } diff --git a/llo/protocol/limits.go b/llo/protocol/limits.go index b0ede898..bdfc92c6 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 @@ -54,13 +61,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. @@ -79,6 +90,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. // @@ -162,17 +180,19 @@ 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 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 @@ -187,8 +207,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,11 +222,26 @@ 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. + // + // 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. 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 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 @@ -234,7 +269,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. @@ -251,6 +286,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 @@ -270,6 +308,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. @@ -279,10 +320,14 @@ 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 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/llo_plugin_telemetry.pb.go b/llo/protocol/llo_plugin_telemetry.pb.go index b50afa35..a6d7ce0e 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 116d84dd..c1311345 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; diff --git a/llo/protocol/offchain_config.go b/llo/protocol/offchain_config.go index 5f1282a1..067149b8 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. @@ -29,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 } diff --git a/llo/protocol/onchain_config_codec.go b/llo/protocol/onchain_config_codec.go index 66ddd3fe..4dbef122 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 47dac105..c0f9d23f 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) { diff --git a/llo/protocol/opts_cache.go b/llo/protocol/opts_cache.go index a88069f3..4873382d 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" @@ -19,7 +22,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 @@ -112,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 @@ -148,9 +156,13 @@ 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 +// 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) { @@ -172,13 +184,98 @@ 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 } + +// 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 +// 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 64745805..5102a319 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/protocol/plugin_codecs.pb.go b/llo/protocol/plugin_codecs.pb.go index 28bf1b3e..0502b807 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 0d0919c8..3a9cfc39 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/protocol/retirement_report_codec.go b/llo/protocol/retirement_report_codec.go index 7abbea6e..5959e28c 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 67f81937..33981209 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)) + }) } diff --git a/llo/protocol/stream_history.go b/llo/protocol/stream_history.go index 95541495..63c994e3 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 diff --git a/llo/protocol/stream_value.go b/llo/protocol/stream_value.go index ef645d9d..56ea5fba 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 @@ -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 { @@ -121,6 +127,12 @@ func checkDecimalCoefficient(d decimal.Decimal) error { return nil } +// CheckDecimalExponent bounds the exponent of a decimal parsed from an +// expression literal or about to be written into state. 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 { @@ -303,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 @@ -323,7 +338,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 { @@ -443,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 @@ -457,7 +475,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 9e71bd5b..a3074268 100644 --- a/llo/protocol/stream_value_test.go +++ b/llo/protocol/stream_value_test.go @@ -27,6 +27,37 @@ 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_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 @@ -99,6 +130,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) diff --git a/llo/protocol/time_resolution.go b/llo/protocol/time_resolution.go new file mode 100644 index 00000000..78f93728 --- /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 00000000..cb1884de --- /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/protocol/types.go b/llo/protocol/types.go index 3c37fb2c..97f9a3b4 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" @@ -13,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 { @@ -56,6 +59,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 @@ -160,6 +174,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 449752c3..543dc207 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) { diff --git a/llo/reportcodec/evm/encodable_test.go b/llo/reportcodec/evm/encodable_test.go new file mode 100644 index 00000000..484563f2 --- /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 d295c2e5..d30dcfb0 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) @@ -305,17 +261,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 @@ -343,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 f29f0140..1a8bd06e 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) { @@ -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 1b0e539b..cc9b46d6 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) } @@ -180,10 +183,25 @@ 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 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) } - return nil + 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 @@ -214,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 77c91c8d..8c6e7b69 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 { @@ -81,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) } @@ -103,10 +108,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 @@ -123,26 +125,36 @@ 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) } - 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 +// 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 { - 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 321abec7..b9722841 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" @@ -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: 2, maxInteriorGap: 2, maxTailGap: 2})","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. @@ -503,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{ 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 76a0623b..91ddc29e 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 41403660..ca060b81 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" @@ -21,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 { @@ -34,6 +36,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) @@ -51,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) } @@ -92,8 +98,44 @@ 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 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 } diff --git a/llo/reportcodec/evm/report_codec_evm_streamlined_test.go b/llo/reportcodec/evm/report_codec_evm_streamlined_test.go index 13c613dd..a2712043 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) { @@ -113,6 +128,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 f0e9a9a6..5089f750 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 { @@ -154,10 +155,25 @@ 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 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 nil + return verifyExpirationWindow(opts.ExpirationWindow) } func (r ReportCodecPremiumLegacy) Decode(b []byte) (*v3.Report, error) { @@ -225,6 +241,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: diff --git a/llo/reportcodec/evm/time_resolution_test.go b/llo/reportcodec/evm/time_resolution_test.go new file mode 100644 index 00000000..e212fa7a --- /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") + } + }) + } +} diff --git a/llo/reportcodec/evm/v3/report_codec.go b/llo/reportcodec/evm/v3/report_codec.go index 101a70d3..791e1b38 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/retirement/plugin_scoped_retirement_report_cache.go b/llo/retirement/plugin_scoped_retirement_report_cache.go index f76e4ddf..09e8453f 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 60492cc6..407507f0 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") 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 a9249581..e3da9630 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" diff --git a/llo/v30/history_backfill_test.go b/llo/v30/history_backfill_test.go index c1329b83..50fa8dc3 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) { diff --git a/llo/v30/plugin.go b/llo/v30/plugin.go index 4b2d3af4..f31dcd34 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/plugin_outcome.go b/llo/v30/plugin_outcome.go index d133099a..1706d6d5 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 { diff --git a/llo/v30/plugin_reports.go b/llo/v30/plugin_reports.go index 3a4c9bdb..d22af6fb 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 d9d40035..afba0df3 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) { diff --git a/llo/v30/stream_calculated.go b/llo/v30/stream_calculated.go index c02cc759..0a2abee5 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) }