Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions llo/plugin_outcome.go
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,7 @@ func (out *Outcome) IsReportable(channelID llotypes.ChannelID, protocolVersion u
// This keeps compatibility with old nodes that may not have nanosecond resolution
//
// Also use seconds resolution for report formats that require it to prevent overlap
if protocolVersion == 0 || IsSecondsResolution(cd.ReportFormat) {
if protocolVersion == 0 || IsSecondsResolution(cd.ReportFormat, cd.Opts) {
validAfterSeconds := validAfterNanos / 1e9
obsTsSeconds := obsTsNanos / 1e9
if validAfterSeconds >= obsTsSeconds {
Expand Down Expand Up @@ -494,13 +494,22 @@ func nilStreamValuesDisabled(opts []byte) bool {
return v.DisableNilStreamValues
}

func IsSecondsResolution(reportFormat llotypes.ReportFormat) bool {
func IsSecondsResolution(reportFormat llotypes.ReportFormat, opts llotypes.ChannelOpts) bool {
switch reportFormat {
// TODO: Might be cleaner to expose a TimeResolution() uint64 field on the
// ReportCodec so that the plugin doesn't have to have special knowledge of
// the report format details
case llotypes.ReportFormatEVMPremiumLegacy, llotypes.ReportFormatEVMABIEncodeUnpacked:
case llotypes.ReportFormatEVMPremiumLegacy:
return true
case llotypes.ReportFormatEVMABIEncodeUnpacked:
var parsed struct {
TimeResolution TimeResolution `json:"TimeResolution"`
}
if err := json.Unmarshal(opts, &parsed); err != nil {
// If we can't parse opts, default to seconds
return true
}
return parsed.TimeResolution == ResolutionSeconds
default:
return false
}
Expand Down
33 changes: 33 additions & 0 deletions llo/plugin_outcome_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1041,6 +1041,39 @@ func Test_Outcome_Methods(t *testing.T) {
assert.Equal(t, "ChannelID: 2; Reason: IsReportable=false; no ValidAfterNanoseconds entry yet, this must be a new channel", unreportable[0].Error())
})
})
t.Run("IsSecondsResolution", func(t *testing.T) {
testCases := []struct {
name string
reportFormat llotypes.ReportFormat
opts []byte
expected bool
}{
// EVMPremiumLegacy is always seconds resolution
{"EVMPremiumLegacy with nil opts", llotypes.ReportFormatEVMPremiumLegacy, nil, true},
{"EVMPremiumLegacy with empty JSON", llotypes.ReportFormatEVMPremiumLegacy, []byte(`{}`), true},
{"EVMPremiumLegacy ignores opts", llotypes.ReportFormatEVMPremiumLegacy, []byte(`{"TimeResolution":"ns"}`), true},

// EVMABIEncodeUnpacked defaults to seconds
{"Unpacked with empty JSON defaults to seconds", llotypes.ReportFormatEVMABIEncodeUnpacked, []byte(`{}`), true},
{"Unpacked with explicit seconds", llotypes.ReportFormatEVMABIEncodeUnpacked, []byte(`{"TimeResolution":"s"}`), true},

// EVMABIEncodeUnpacked non-seconds resolutions
{"Unpacked with milliseconds", llotypes.ReportFormatEVMABIEncodeUnpacked, []byte(`{"TimeResolution":"ms"}`), false},
{"Unpacked with microseconds", llotypes.ReportFormatEVMABIEncodeUnpacked, []byte(`{"TimeResolution":"us"}`), false},
{"Unpacked with nanoseconds", llotypes.ReportFormatEVMABIEncodeUnpacked, []byte(`{"TimeResolution":"ns"}`), false},

// Other formats are not seconds resolution
{"UnpackedExpr returns false", llotypes.ReportFormatEVMABIEncodeUnpackedExpr, []byte(`{}`), false},
{"JSON format", llotypes.ReportFormatJSON, nil, false},
{"unknown format", llotypes.ReportFormat(99), nil, false},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, IsSecondsResolution(tc.reportFormat, tc.opts))
})
}
})
t.Run("protocol version > 0", func(t *testing.T) {
t.Run("IsReportable", func(t *testing.T) {
defaultMinReportInterval := uint64(100 * time.Millisecond)
Expand Down
66 changes: 1 addition & 65 deletions llo/reportcodecs/evm/report_codec_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,70 +16,6 @@ import (
ubig "github.com/smartcontractkit/chainlink-data-streams/llo/reportcodecs/evm/utils"
)

// TimestampPrecision represents the precision for timestamp conversion
type TimestampPrecision uint8

const (
PrecisionSeconds TimestampPrecision = iota
PrecisionMilliseconds
PrecisionMicroseconds
PrecisionNanoseconds
)

func (tp TimestampPrecision) MarshalJSON() ([]byte, error) {
var s string
switch tp {
case PrecisionSeconds:
s = "s"
case PrecisionMilliseconds:
s = "ms"
case PrecisionMicroseconds:
s = "us"
case PrecisionNanoseconds:
s = "ns"
default:
return nil, fmt.Errorf("invalid timestamp precision %d", tp)
}
return json.Marshal(s)
}

// UnmarshalJSON unmarshals TimestampPrecision from JSON - used to unmarshal from the Opts structs.
func (tp *TimestampPrecision) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
switch s {
case "s":
*tp = PrecisionSeconds
case "ms":
*tp = PrecisionMilliseconds
case "us":
*tp = PrecisionMicroseconds
case "ns":
*tp = PrecisionNanoseconds
default:
return fmt.Errorf("invalid timestamp precision %q", s)
}
return nil
}

// ConvertTimestamp converts a nanosecond timestamp to a specified precision.
func ConvertTimestamp(timestampNanos uint64, precision TimestampPrecision) uint64 {
switch precision {
case PrecisionSeconds:
return timestampNanos / 1e9
case PrecisionMilliseconds:
return timestampNanos / 1e6
case PrecisionMicroseconds:
return timestampNanos / 1e3
case PrecisionNanoseconds:
return timestampNanos
default:
return timestampNanos
}
}

// Extracts nanosecond timestamps as uint32 number of seconds
func ExtractTimestamps(report llo.Report) (validAfterSeconds, observationTimestampSeconds uint32, err error) {
vas := report.ValidAfterNanoseconds / 1e9
Expand Down Expand Up @@ -187,7 +123,7 @@ func (a ABIEncoder) EncodePadded(sv llo.StreamValue) ([]byte, error) {
}
return append(encodedTimestamp, encodedDecimal...), nil
default:
return nil, fmt.Errorf("unhandled type; supported types are: *llo.Decimal or *llo.TimestampedStreamValue; got: %T", sv)
return nil, fmt.Errorf("unhandled type; supported types are: *llo.Decimal, *llo.TimestampedStreamValue, or *llo.Quote; got: %T", sv)
}
}

Expand Down
17 changes: 9 additions & 8 deletions llo/reportcodecs/evm/report_codec_evm_abi_encode_unpacked.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,10 @@ type ReportFormatEVMABIEncodeOpts struct {
// top-level elements in this ABI array (stream 0 is always the native
// token price and stream 1 is the link token price).
ABI []ABIEncoder `json:"abi"`
// TimestampPrecision is the precision of the timestamps in the report.
// TimeResolution is the resolution of the timestamps in the report.
// Seconds use uint32 ABI encoding, while milliseconds/microseconds/nanoseconds use uint64.
// Defaults to "s" (seconds) if not specified.
TimestampPrecision TimestampPrecision `json:"timestampPrecision,omitempty"`
TimeResolution llo.TimeResolution `json:"TimeResolution,omitempty"`
// DisableNilStreamValues controls whether channels with nil stream values
// are reportable. When false (default), nil stream values are allowed and
// channels are reportable. Set to true to make channels with missing
Expand Down Expand Up @@ -115,19 +115,20 @@ func (r ReportCodecEVMABIEncodeUnpacked) Encode(report llo.Report, cd llotypes.C
return nil, fmt.Errorf("failed to decode opts; got: '%s'; %w", cd.Opts, err)
}

validAfter := ConvertTimestamp(report.ValidAfterNanoseconds, opts.TimestampPrecision)
observationTimestamp := ConvertTimestamp(report.ObservationTimestampNanoseconds, opts.TimestampPrecision)
validAfter := llo.ConvertTimestamp(report.ValidAfterNanoseconds, opts.TimeResolution)
observationTimestamp := llo.ConvertTimestamp(report.ObservationTimestampNanoseconds, opts.TimeResolution)
expiresAt := observationTimestamp + llo.ScaleSeconds(opts.ExpirationWindow, opts.TimeResolution)

rf := BaseReportFields{
FeedID: opts.FeedID,
ValidFromTimestamp: validAfter + 1,
Timestamp: observationTimestamp,
NativeFee: CalculateFee(nativePrice, opts.BaseUSDFee),
LinkFee: CalculateFee(linkPrice, opts.BaseUSDFee),
ExpiresAt: observationTimestamp + uint64(opts.ExpirationWindow),
ExpiresAt: expiresAt,
}

header, err := r.buildHeader(rf, opts.TimestampPrecision)
header, err := r.buildHeader(rf, opts.TimeResolution)
if err != nil {
return nil, fmt.Errorf("failed to build base report; %w", err)
}
Expand Down Expand Up @@ -215,7 +216,7 @@ func getBaseSchema(timestampType string) abi.Arguments {
})
}

func (r ReportCodecEVMABIEncodeUnpacked) buildHeader(rf BaseReportFields, precision TimestampPrecision) ([]byte, error) {
func (r ReportCodecEVMABIEncodeUnpacked) buildHeader(rf BaseReportFields, resolution llo.TimeResolution) ([]byte, error) {
var merr error
if rf.LinkFee == nil {
merr = errors.Join(merr, errors.New("linkFee may not be nil"))
Expand All @@ -233,7 +234,7 @@ func (r ReportCodecEVMABIEncodeUnpacked) buildHeader(rf BaseReportFields, precis

var b []byte
var err error
if precision == PrecisionSeconds {
if resolution == llo.ResolutionSeconds {
if rf.ValidFromTimestamp > math.MaxUint32 {
return nil, fmt.Errorf("validFromTimestamp %d exceeds uint32 range", rf.ValidFromTimestamp)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,19 +58,20 @@ func (r ReportCodecEVMABIEncodeUnpackedExpr) Encode(report llo.Report, cd llotyp
return nil, fmt.Errorf("ReportCodecEVMABIEncodeUnpackedExpr not enough streams for calculated streams; expected: %d, got: %d", opts.ABI[len(opts.ABI)-1].encoders[0].ExpressionStreamID, len(cd.Streams))
}

validAfter := ConvertTimestamp(report.ValidAfterNanoseconds, opts.TimestampPrecision)
observationTimestamp := ConvertTimestamp(report.ObservationTimestampNanoseconds, opts.TimestampPrecision)
validAfter := llo.ConvertTimestamp(report.ValidAfterNanoseconds, opts.TimeResolution)
observationTimestamp := llo.ConvertTimestamp(report.ObservationTimestampNanoseconds, opts.TimeResolution)
expiresAt := observationTimestamp + llo.ScaleSeconds(opts.ExpirationWindow, opts.TimeResolution)

rf := BaseReportFields{
FeedID: opts.FeedID,
ValidFromTimestamp: validAfter + 1,
Timestamp: observationTimestamp,
NativeFee: CalculateFee(nativePrice, opts.BaseUSDFee),
LinkFee: CalculateFee(linkPrice, opts.BaseUSDFee),
ExpiresAt: observationTimestamp + uint64(opts.ExpirationWindow),
ExpiresAt: expiresAt,
}

header, err := r.buildHeader(rf, opts.TimestampPrecision)
header, err := r.buildHeader(rf, opts.TimeResolution)
if err != nil {
return nil, fmt.Errorf("failed to build base report; %w", err)
}
Expand Down Expand Up @@ -100,7 +101,7 @@ func (r ReportCodecEVMABIEncodeUnpackedExpr) Verify(cd llotypes.ChannelDefinitio
return nil
}

func (r ReportCodecEVMABIEncodeUnpackedExpr) buildHeader(rf BaseReportFields, precision TimestampPrecision) ([]byte, error) {
func (r ReportCodecEVMABIEncodeUnpackedExpr) buildHeader(rf BaseReportFields, resolution llo.TimeResolution) ([]byte, error) {
var merr error
if rf.LinkFee == nil {
merr = errors.Join(merr, errors.New("linkFee may not be nil"))
Expand All @@ -118,7 +119,7 @@ func (r ReportCodecEVMABIEncodeUnpackedExpr) buildHeader(rf BaseReportFields, pr

var b []byte
var err error
if precision == PrecisionSeconds {
if resolution == llo.ResolutionSeconds {
if rf.ValidFromTimestamp > math.MaxUint32 {
return nil, fmt.Errorf("validFromTimestamp %d exceeds uint32 range", rf.ValidFromTimestamp)
}
Expand Down
Loading
Loading