Skip to content
Open
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
17 changes: 14 additions & 3 deletions llo/plugin_outcome.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"fmt"
"sort"

"github.com/goccy/go-json"

"github.com/smartcontractkit/libocr/offchainreporting2/types"
"github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3types"

Expand Down Expand Up @@ -445,7 +447,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 All @@ -456,13 +458,22 @@ func (out *Outcome) IsReportable(channelID llotypes.ChannelID, protocolVersion u
return nil
}

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
63 changes: 48 additions & 15 deletions llo/plugin_outcome_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,13 @@ func Test_Outcome_GoldenFiles(t *testing.T) {
DefaultMinReportIntervalNanoseconds: 1,
}.GetOutcomeCodec()
p := &Plugin{
Config: Config{true},
OutcomeCodec: codec,
Logger: logger.Test(t),
ObservationCodec: obsCodec,
DonID: 10000043,
ConfigDigest: types.ConfigDigest{1, 2, 3, 4},
ProtocolVersion: 1,
Config: Config{true},
OutcomeCodec: codec,
Logger: logger.Test(t),
ObservationCodec: obsCodec,
DonID: 10000043,
ConfigDigest: types.ConfigDigest{1, 2, 3, 4},
ProtocolVersion: 1,
DefaultMinReportIntervalNanoseconds: 1,
}
// Minimal observations (timestamp only) so the plugin advances from previous outcome without new channel defs or stream values.
Expand Down Expand Up @@ -91,13 +91,13 @@ func Test_Outcome_EncodedMatchesGolden(t *testing.T) {
DefaultMinReportIntervalNanoseconds: 1,
}.GetOutcomeCodec()
p := &Plugin{
Config: Config{true},
OutcomeCodec: codec,
Logger: logger.Test(t),
ObservationCodec: obsCodec,
DonID: 10000043,
ConfigDigest: types.ConfigDigest{1, 2, 3, 4},
ProtocolVersion: 1,
Config: Config{true},
OutcomeCodec: codec,
Logger: logger.Test(t),
ObservationCodec: obsCodec,
DonID: 10000043,
ConfigDigest: types.ConfigDigest{1, 2, 3, 4},
ProtocolVersion: 1,
DefaultMinReportIntervalNanoseconds: 1,
}

Expand Down Expand Up @@ -140,7 +140,7 @@ func Test_Outcome_EncodedMatchesGolden(t *testing.T) {
}
outcome, err = p.Outcome(ctx, ocr3types.OutcomeContext{
PreviousOutcome: fullGolden,
SeqNr: 2,
SeqNr: 2,
}, types.Query{}, aos)
require.NoError(t, err)
default:
Expand Down Expand Up @@ -955,6 +955,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
94 changes: 29 additions & 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 @@ -186,8 +122,36 @@ func (a ABIEncoder) EncodePadded(sv llo.StreamValue) ([]byte, error) {
return nil, fmt.Errorf("unhandled type; supported nested types for *llo.TimestampedStreamValue are: *llo.Decimal; got: %T", d)
}
return append(encodedTimestamp, encodedDecimal...), nil
case *llo.Quote:
if len(a.encoders) != 3 {
return nil, fmt.Errorf("expected exactly three encoders for *llo.Quote; got: %d", len(a.encoders))
}
if v == nil {
return nil, fmt.Errorf("expected non-nil *Quote")
}
// encode as three zero-padded 32 byte evm words:
// <type0> benchmark
// <type1> bid
// <type2> ask
encodedBenchmark, err := a.encoders[0].encodeDecimalStreamValuePadded(llo.ToDecimal(v.Benchmark))
if err != nil {
return nil, fmt.Errorf("failed to encode quote benchmark; %w", err)
}
encodedBid, err := a.encoders[1].encodeDecimalStreamValuePadded(llo.ToDecimal(v.Bid))
if err != nil {
return nil, fmt.Errorf("failed to encode quote bid; %w", err)
}
encodedAsk, err := a.encoders[2].encodeDecimalStreamValuePadded(llo.ToDecimal(v.Ask))
if err != nil {
return nil, fmt.Errorf("failed to encode quote ask; %w", err)
}
result := make([]byte, 0, len(encodedBenchmark)+len(encodedBid)+len(encodedAsk))
result = append(result, encodedBenchmark...)
result = append(result, encodedBid...)
result = append(result, encodedAsk...)
return result, 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
81 changes: 81 additions & 0 deletions llo/reportcodecs/evm/report_codec_common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -457,3 +457,84 @@ func Test_ABIEncoder_EncodePadded_EncodePacked(t *testing.T) {
}
})
}

func Test_ABIEncoder_EncodePadded_Quote(t *testing.T) {
multiplier := ubig.NewI(1e18)

t.Run("encodes quote as three padded words (benchmark, bid, ask)", func(t *testing.T) {
enc := ABIEncoder{
encoders: []singleABIEncoder{
{Type: "int192", Multiplier: multiplier},
{Type: "int192", Multiplier: multiplier},
{Type: "int192", Multiplier: multiplier},
},
}
quote := &llo.Quote{
Benchmark: decimal.NewFromFloat(100.5),
Bid: decimal.NewFromFloat(100.0),
Ask: decimal.NewFromFloat(101.0),
}
padded, err := enc.EncodePadded(quote)
require.NoError(t, err)
// 3 words x 32 bytes = 96 bytes
assert.Len(t, padded, 96)

benchmarkExpected := decimal.NewFromFloat(100.5).Mul(decimal.NewFromBigInt(multiplier.ToInt(), 0)).BigInt()
bidExpected := decimal.NewFromFloat(100.0).Mul(decimal.NewFromBigInt(multiplier.ToInt(), 0)).BigInt()
askExpected := decimal.NewFromFloat(101.0).Mul(decimal.NewFromBigInt(multiplier.ToInt(), 0)).BigInt()

benchmarkBytes := padded[0:32]
bidBytes := padded[32:64]
askBytes := padded[64:96]

assert.Equal(t, benchmarkExpected, new(big.Int).SetBytes(benchmarkBytes))
assert.Equal(t, bidExpected, new(big.Int).SetBytes(bidBytes))
assert.Equal(t, askExpected, new(big.Int).SetBytes(askBytes))
})

t.Run("applies different multipliers per field", func(t *testing.T) {
enc := ABIEncoder{
encoders: []singleABIEncoder{
{Type: "int192", Multiplier: ubig.NewI(1e18)},
{Type: "int192", Multiplier: ubig.NewI(1e8)},
{Type: "int192", Multiplier: ubig.NewI(1e8)},
},
}
quote := &llo.Quote{
Benchmark: decimal.NewFromInt(50),
Bid: decimal.NewFromInt(49),
Ask: decimal.NewFromInt(51),
}
padded, err := enc.EncodePadded(quote)
require.NoError(t, err)
assert.Len(t, padded, 96)

assert.Equal(t, decimal.NewFromInt(50).Mul(decimal.NewFromInt(1e18)).BigInt(), new(big.Int).SetBytes(padded[0:32]))
assert.Equal(t, decimal.NewFromInt(49).Mul(decimal.NewFromInt(1e8)).BigInt(), new(big.Int).SetBytes(padded[32:64]))
assert.Equal(t, decimal.NewFromInt(51).Mul(decimal.NewFromInt(1e8)).BigInt(), new(big.Int).SetBytes(padded[64:96]))
})

t.Run("errors when encoder count is not 3", func(t *testing.T) {
enc := ABIEncoder{
encoders: []singleABIEncoder{
{Type: "int192", Multiplier: multiplier},
},
}
_, err := enc.EncodePadded(&llo.Quote{})
require.Error(t, err)
assert.Contains(t, err.Error(), "expected exactly three encoders for *llo.Quote; got: 1")
})

t.Run("errors on nil quote", func(t *testing.T) {
enc := ABIEncoder{
encoders: []singleABIEncoder{
{Type: "int192", Multiplier: multiplier},
{Type: "int192", Multiplier: multiplier},
{Type: "int192", Multiplier: multiplier},
},
}
_, err := enc.EncodePadded((*llo.Quote)(nil))
require.Error(t, err)
assert.Contains(t, err.Error(), "expected non-nil *Quote")
})
}
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"`
}

func (r *ReportFormatEVMABIEncodeOpts) Decode(opts []byte) error {
Expand Down Expand Up @@ -105,19 +105,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 @@ -205,7 +206,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 @@ -223,7 +224,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
Loading
Loading