Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
102 commits
Select commit Hold shift + click to select a range
632d184
llo/protocol: count tombstones toward size budgets on admission
brunotm Oct 9, 2026
c2a60c6
llo/protocol: validate history backfill values on admission
brunotm Oct 9, 2026
b30ec15
llo/protocol: attribute backfill target finding to the target channel
brunotm Oct 9, 2026
69406e6
llo/calculated: bound Round precision and decimal literal exponents
brunotm Oct 9, 2026
7b3e878
llo/calculated: bound TWAP work across the definition set
brunotm Oct 9, 2026
02b9fcf
llo/calculated: bound operand size and exact integer power size
brunotm Oct 9, 2026
293b397
llo/calculated: compute exponentials with argument reduction
brunotm Oct 10, 2026
3b22f28
llo/calculated: compute logarithms with Halley's method
brunotm Oct 10, 2026
eada2a9
llo/protocol: compare mode votes in canonical form
brunotm Oct 10, 2026
914167b
llo/protocol: v31 median timestamp used contributions only
brunotm Oct 10, 2026
0544625
llo/protocol: reject out of range numeric durations
brunotm Oct 12, 2026
b039122
llo/protocol: verify peer channel definitions through a read-only cac…
brunotm Oct 12, 2026
a72a5e3
llo/calculated: bound calculated stream exponents before commit
brunotm Oct 12, 2026
34789fd
llo/calculated: budget MaxTotalTWAPCalls against the gap count worst …
brunotm Oct 12, 2026
e16f315
llo/protocol: anchor quote text regex and accept negative values
brunotm Oct 12, 2026
8b1c777
llo/protocol: document read-only contract of cached decoded opts
brunotm Oct 12, 2026
6fb213d
llo/calculated: keep the existing analysis cache entry on a racing put
brunotm Oct 12, 2026
d7bc5c3
llo/protocol: resolve backfill target resolution from one format mapping
brunotm Oct 12, 2026
97d37fb
llo/dev/v31: apply the seconds overlap check to expr channels via the…
brunotm Oct 12, 2026
355725a
llo/dev/v31: cap channel definition votes by encoded size
brunotm Oct 12, 2026
2b01339
llo/protocol: hash channel definitions through the unexported determi…
brunotm Oct 12, 2026
6237394
llo/protocol: decode calculated stream opts through the opts cache de…
brunotm Oct 12, 2026
14d2914
llo/protocol: return ErrNilStreamValue for a nil inner timestamped st…
brunotm Oct 12, 2026
2f2313f
llo/protocol: make OptsCache.SyncTo a no-op on a sealed cache
brunotm Oct 12, 2026
938339a
llo/protocol: document the reachable history record worst case
brunotm Oct 12, 2026
e47fb30
llo/protocol: document why SubtractChannelDefinitions keeps the lowes…
brunotm Oct 12, 2026
34962ad
llo/protocol: bound retirement report channel count on decode
brunotm Oct 12, 2026
5169a55
llo/protocol: reject unbound identifiers at calculated channel admission
brunotm Oct 12, 2026
b218d07
llo/protocol: reject single-record windows for comparison functions
brunotm Oct 12, 2026
cb1c309
llo/protocol: reject stream fields the aggregator cannot produce at a…
brunotm Oct 12, 2026
9adf460
llo/calculated: pin evaluated expressions in the analysis cache
brunotm Oct 12, 2026
c259a6f
llo/calculated: test pinned set larger than the analysis cache bound
brunotm Oct 12, 2026
362fffc
llo/calculated: reject TWAP gap tolerances the window cannot reach
brunotm Oct 12, 2026
f8e39cf
llo/protocol: keep calculated streams declared before a zero-ID expre…
brunotm Oct 12, 2026
ee268c8
llo/reportcodec/evm: cap maxReportRange
brunotm Oct 12, 2026
5c5a115
llo/protocol: OnchainConfig version codec docs, error message and dea…
brunotm Oct 12, 2026
5cddfaf
llo/protocol: remove unused DropInvalidHistoryBackfillChannels
brunotm Oct 12, 2026
87e10b1
llo/calculated: document minSamples as the cumulative TWAP gap bound
brunotm Oct 12, 2026
b8796b4
llo/protocol: return ErrNilStreamValue on nil receiver in binary unma…
brunotm Oct 12, 2026
70064aa
llo/v31: document ReportCodecs as fixed after construction
brunotm Oct 12, 2026
7d6c76f
llo/protocol: assert history pair cap fits byte budget at compile time
brunotm Oct 12, 2026
3b78b51
llo/protocol: assert history chunk size divides max depth at compile …
brunotm Oct 12, 2026
7610950
llo/v31: document blob handles as unbound to the citing oracle
brunotm Oct 13, 2026
e4b0409
llo/v31: document f+1 retirement threshold and pin it with a boundary…
brunotm Oct 13, 2026
710241e
llo/v31: bound timestamped aggregates to consensus time plus skew
brunotm Oct 13, 2026
3fed106
llo/v31: warn when agreed observation timestamp leads the local clock
brunotm Oct 13, 2026
ed3ab15
llo/v31: skip unemittable backfill rows and surface unemitted reports
brunotm Oct 13, 2026
222b901
llo/v31: check report overlap at the channel time resolution
brunotm Oct 13, 2026
ce88ef1
llo/v31: vote to remove only committed tombstones
brunotm Oct 13, 2026
f19288f
llo/v31: cap the default report cadence at 5s
brunotm Oct 13, 2026
23c3269
llo/v31: enforce whole-set definition budgets when merging channel votes
brunotm Oct 13, 2026
8a76626
llo/v31: refuse a zero aggregation fault tolerance on a byzantine DON…
brunotm Oct 13, 2026
66c204b
llo/v31: document that a same-round update wins over a tombstone removal
brunotm Oct 13, 2026
f17ac8b
llo/v31: cap the attested predecessor retirement an observation may c…
brunotm Oct 13, 2026
69981bf
llo/reportcodec: document that a nil fee price encodes a zero fee
brunotm Oct 13, 2026
4ddfdcf
llo/v31: require 2f+1 decoded observations before taking the timestam…
brunotm Oct 13, 2026
ee8cadd
llo/v31: treat a channel with undecodable time resolution opts as unr…
brunotm Oct 13, 2026
fb32f6c
llo/v31: apply the peer decoder bounds before broadcasting predecesso…
brunotm Oct 13, 2026
0bfa02a
llo/v31: document why decodePrecursor skips per-definition caps
brunotm Oct 13, 2026
b13b402
llo/v31: document why codec support entries never expire
brunotm Oct 13, 2026
ab5efa9
llo/v31: skip channel updates with competing definitions past the thr…
brunotm Oct 13, 2026
94755c4
llo/v31: withhold votes only for inadmissible channels
brunotm Oct 13, 2026
38486aa
llo/v31: reject a non-positive MaxDurationObservation
brunotm Oct 13, 2026
347c843
llo/v31: document why decodePrecursor bounds only support entries
brunotm Oct 13, 2026
56f954e
llo/v31: bound channel votes in observationFromProto
brunotm Oct 13, 2026
144f35a
llo/v31: refuse promotion on divergent predecessor retirements
brunotm Oct 13, 2026
ec65f53
llo/protocol: support protocol version 2 in retirement report handover
brunotm Oct 13, 2026
55fc179
llo/v31: describe libocr handling of a blob fetch error in StateTrans…
brunotm Oct 14, 2026
487d813
llo/reportcodec: reject specimen reports in ReportCodecEVMStreamlined
brunotm Oct 14, 2026
4410298
llo/v30: select backfill candidate from the outcome synced opts cache…
brunotm Oct 14, 2026
d22631c
llo/v31: require MaxSnapshotRounds of at least 2 in ResolveBlobRounds
brunotm Oct 14, 2026
5969b1c
llo/v31: stop backfill selection when the target channel is tombstoned
brunotm Oct 14, 2026
930615e
llo/v31: bound zstd blob decompression by the per call budget while d…
brunotm Oct 14, 2026
c9cf36c
llo/v31: broadcast only the requested streams from the blob pump
brunotm Oct 14, 2026
5f4368f
llo/v31: describe report telemetry values with the streams they were …
brunotm Oct 14, 2026
2d6f98e
llo/retirement: remove unused NullRetirementReportCache
brunotm Oct 14, 2026
deecfff
llo/v31: log denied history pairs once per round and demote duplicate…
brunotm Oct 14, 2026
792e694
llo/v31: admit pairs already holding history before new pairs at the …
brunotm Oct 14, 2026
6b621c7
llo/datasource: document that Observe must not touch streamValues aft…
brunotm Oct 14, 2026
d955b4c
llo/v31: document blob memo behaviour when rounds overlap
brunotm Oct 14, 2026
e8af155
llo/v31: measure the hot state record at the adversarial worst case
brunotm Oct 14, 2026
dcf1350
llo/v30: document the node local retirement report verdict in Outcome
brunotm Oct 14, 2026
e108d9a
llo/v31: document why KV state decode trusts its counts and fails closed
brunotm Oct 14, 2026
d5e2a78
llo/v31: correct the history read cost described in channelHistorySat…
brunotm Oct 14, 2026
8bb8aa2
llo/protocol: document that backfill keys follow the target current t…
brunotm Oct 14, 2026
ae9c333
llo/protocol: document the missing value encoding in report telemetry
brunotm Oct 14, 2026
51af47d
llo/v31: drop stream values peers would reject before broadcasting th…
brunotm Oct 14, 2026
5b565eb
llo/v31: treat any nil stream value as unreportable and deprecate Dis…
brunotm Oct 16, 2026
bea9456
llo/v31: enforce calculated stream, TWAP, unique stream ID and stream…
brunotm Oct 16, 2026
6d556ea
llo/v31: document that a history reset drops the round's appended record
brunotm Oct 16, 2026
4f8e800
llo/v31: read only memoized history windows in insufficient history t…
brunotm Oct 16, 2026
50bdfbb
llo/v31: log carry-forward aggregates discarded when reading hot state
brunotm Oct 16, 2026
6bf2af9
llo/calculated: document the v30 trailing stream append check and its…
brunotm Oct 16, 2026
80eda38
llo/retirement: add NullRetirementReportCache for testing
brunotm Oct 19, 2026
a31ebad
llo: gate reportability on encodable value ranges and carry the decis…
brunotm Oct 20, 2026
924866a
llo/v31: test that a non reconciling cache only votes out tombstones
brunotm Oct 21, 2026
bd9b549
llo/v31: check time gates and codec support before value checks in is…
brunotm Oct 21, 2026
a167ec2
llo/v31: add StateTransition benchmark
brunotm Oct 21, 2026
dc4b5fe
llo/v31: clone channel definitions only when a round votes on channels
brunotm Oct 21, 2026
ca780df
llo/calculated: bound TWAP gap tolerances by windowSeconds minus minS…
brunotm Oct 21, 2026
b2ad0fc
llo/calculated: validate literal TWAP configurations at admission
brunotm Oct 21, 2026
c8eb0b0
llo/v31: bound promoted predecessor watermarks by the observation tim…
brunotm Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions llo/datasource/datasource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
83 changes: 51 additions & 32 deletions llo/dev/v31/backfill.go
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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 {
Expand All @@ -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
}
23 changes: 17 additions & 6 deletions llo/dev/v31/blobcompress.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
}
Expand Down
35 changes: 35 additions & 0 deletions llo/dev/v31/blobcompress_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package llo

import (
"bytes"
"strings"
"testing"

Expand Down Expand Up @@ -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")
Expand Down
7 changes: 7 additions & 0 deletions llo/dev/v31/blobmemo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions llo/dev/v31/blobpump.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
48 changes: 47 additions & 1 deletion llo/dev/v31/blobpump_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"math"
"math/big"
"sync"
"sync/atomic"
"testing"
Expand All @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
Expand All @@ -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")
})

Expand Down
Loading
Loading