diff --git a/go/report/report.go b/go/report/report.go index 6b0f428..ce16f9c 100644 --- a/go/report/report.go +++ b/go/report/report.go @@ -10,6 +10,7 @@ import ( v11 "github.com/smartcontractkit/data-streams-sdk/go/v2/report/v11" v12 "github.com/smartcontractkit/data-streams-sdk/go/v2/report/v12" v13 "github.com/smartcontractkit/data-streams-sdk/go/v2/report/v13" + v14 "github.com/smartcontractkit/data-streams-sdk/go/v2/report/v14" v2 "github.com/smartcontractkit/data-streams-sdk/go/v2/report/v2" v3 "github.com/smartcontractkit/data-streams-sdk/go/v2/report/v3" v4 "github.com/smartcontractkit/data-streams-sdk/go/v2/report/v4" @@ -22,7 +23,7 @@ import ( // Data represents the actual report data and attributes type Data interface { - v1.Data | v2.Data | v3.Data | v4.Data | v5.Data | v6.Data | v7.Data | v8.Data | v9.Data | v10.Data | v11.Data | v12.Data | v13.Data + v1.Data | v2.Data | v3.Data | v4.Data | v5.Data | v6.Data | v7.Data | v8.Data | v9.Data | v10.Data | v11.Data | v12.Data | v13.Data | v14.Data Schema() abi.Arguments } @@ -76,6 +77,8 @@ func Decode[T Data](fullReport []byte) (r *Report[T], err error) { data, err = v12.Decode(r.ReportBlob) case v13.Data: data, err = v13.Decode(r.ReportBlob) + case v14.Data: + data, err = v14.Decode(r.ReportBlob) default: return nil, fmt.Errorf("report: unsupported data type") } diff --git a/go/report/report_test.go b/go/report/report_test.go index ca2b5fc..b23f7f6 100644 --- a/go/report/report_test.go +++ b/go/report/report_test.go @@ -15,6 +15,7 @@ import ( v11 "github.com/smartcontractkit/data-streams-sdk/go/v2/report/v11" v12 "github.com/smartcontractkit/data-streams-sdk/go/v2/report/v12" v13 "github.com/smartcontractkit/data-streams-sdk/go/v2/report/v13" + v14 "github.com/smartcontractkit/data-streams-sdk/go/v2/report/v14" v2 "github.com/smartcontractkit/data-streams-sdk/go/v2/report/v2" v3 "github.com/smartcontractkit/data-streams-sdk/go/v2/report/v3" v4 "github.com/smartcontractkit/data-streams-sdk/go/v2/report/v4" @@ -207,6 +208,20 @@ func TestReport(t *testing.T) { if !reflect.DeepEqual(v13Report, rv13) { t.Errorf("expected: %#v, got: %#v", v13Report, rv13) } + + b, err = schema.Pack(v14Report.ReportContext, v14Report.ReportBlob, v14Report.RawRs, v14Report.RawSs, v14Report.RawVs) + if err != nil { + t.Errorf("failed to encode report: %s", err) + } + + rv14, err := Decode[v14.Data](b) + if err != nil { + t.Errorf("failed to decode report: %s", err) + } + + if !reflect.DeepEqual(v14Report, rv14) { + t.Errorf("expected: %#v, got: %#v", v14Report, rv14) + } } var v1Report = &Report[v1.Data]{ @@ -326,6 +341,15 @@ var v13Report = &Report[v13.Data]{ RawVs: [32]uint8{00, 01, 10, 74, 67, 29, 24, 17, 12, 18, 22, 11, 69, 11, 63, 86, 12, 86, 23, 58, 13, 53, 29, 12, 17, 10, 17, 12, 63, 27, 12, 14}, } +var v14Report = &Report[v14.Data]{ + Data: v14Data, + ReportContext: [3][32]uint8{}, + ReportBlob: mustPackData(v14Data), + RawRs: [][32]uint8{{00, 01, 10, 74, 67, 29, 24, 17, 12, 18, 22, 11, 69, 11, 63, 86, 12, 86, 23, 58, 13, 53, 29, 12, 17, 10, 17, 12, 63, 27, 12, 14}}, + RawSs: [][32]uint8{{01, 02, 10, 73, 65, 19, 14, 27, 42, 48, 52, 18, 39, 116, 67, 85, 13, 82, 33, 48, 23, 33, 49, 32, 67, 50, 37, 32, 63, 77, 14, 64}}, + RawVs: [32]uint8{00, 01, 10, 74, 67, 29, 24, 17, 12, 18, 22, 11, 69, 11, 63, 86, 12, 86, 23, 58, 13, 53, 29, 12, 17, 10, 17, 12, 63, 27, 12, 14}, +} + var v1Data = v1.Data{ FeedID: [32]uint8{00, 01, 107, 74, 167, 229, 124, 167, 182, 138, 225, 191, 69, 101, 63, 86, 182, 86, 253, 58, 163, 53, 239, 127, 174, 105, 107, 102, 63, 27, 132, 114}, ObservationsTimestamp: time.Unix(1700000000, 0), @@ -492,6 +516,23 @@ var v13Data = v13.Data{ LastTradedPrice: big.NewInt(76), } +var v14Data = v14.Data{ + FeedID: [32]uint8{00, 14, 107, 74, 167, 229, 124, 167, 182, 138, 225, 191, 69, 101, 63, 86, 182, 86, 253, 58, 163, 53, 239, 127, 174, 105, 107, 102, 63, 27, 132, 114}, + ValidFromTimestamp: time.Unix(1700000000, 0), + ObservationsTimestamp: time.Unix(1700000000, 0), + NativeFee: big.NewInt(10), + LinkFee: big.NewInt(10), + ExpiresAt: time.Unix(1700000100, 0), + MidPrice: big.NewInt(100), + BidPrice: big.NewInt(99), + AskPrice: big.NewInt(101), + ExpiryTime: time.Unix(0, 1700000010000000000), + FirstDayOfNotice: time.Unix(0, 1700000005000000000), + LastSeenTimestampNs: time.Unix(0, 1700000000000000000), + MarketStatus: common.MarketStatusOpen, + ContractMonth: "F", +} + func mustPackData(d interface{}) []byte { var args []interface{} var dataSchema abi.Arguments @@ -676,6 +717,24 @@ func mustPackData(d interface{}) []byte { v.BidVolume, v.LastTradedPrice, } + case v14.Data: + dataSchema = v14.Schema() + args = []interface{}{ + v.FeedID, + uint64(v.ValidFromTimestamp.Unix()), + uint64(v.ObservationsTimestamp.Unix()), + v.NativeFee, + v.LinkFee, + uint64(v.ExpiresAt.Unix()), + v.MidPrice, + v.BidPrice, + v.AskPrice, + uint64(v.ExpiryTime.UnixNano()), + uint64(v.FirstDayOfNotice.UnixNano()), + uint64(v.LastSeenTimestampNs.UnixNano()), + v.MarketStatus, + v.ContractMonth, + } default: panic(fmt.Sprintf("invalid type to pack: %#v", v)) } diff --git a/go/report/v10/data.go b/go/report/v10/data.go index 4c4cc1e..2bb3f0c 100644 --- a/go/report/v10/data.go +++ b/go/report/v10/data.go @@ -6,6 +6,7 @@ import ( "time" "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/smartcontractkit/data-streams-sdk/go/v2/feed" ) @@ -26,6 +27,7 @@ func Schema() abi.Arguments { {Name: "observationsTimestamp", Type: mustNewType("uint64")}, {Name: "nativeFee", Type: mustNewType("uint192")}, {Name: "linkFee", Type: mustNewType("uint192")}, + {Name: "expiresAt", Type: mustNewType("uint64")}, {Name: "lastUpdateTimestamp", Type: mustNewType("uint64")}, {Name: "price", Type: mustNewType("int192")}, diff --git a/go/report/v14/data.go b/go/report/v14/data.go new file mode 100644 index 0000000..21c8bd5 --- /dev/null +++ b/go/report/v14/data.go @@ -0,0 +1,122 @@ +package v14 + +import ( + "fmt" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi" + + "github.com/smartcontractkit/data-streams-sdk/go/v2/feed" +) + +var schema = Schema() + +// Schema returns this data version schema +func Schema() abi.Arguments { + mustNewType := func(t string) abi.Type { + result, err := abi.NewType(t, "", []abi.ArgumentMarshaling{}) + if err != nil { + panic(fmt.Sprintf("Unexpected error during abi.NewType: %s", err)) + } + return result + } + return abi.Arguments([]abi.Argument{ + {Name: "feedId", Type: mustNewType("bytes32")}, + {Name: "validFromTimestamp", Type: mustNewType("uint64")}, + {Name: "observationsTimestamp", Type: mustNewType("uint64")}, + {Name: "nativeFee", Type: mustNewType("uint192")}, + {Name: "linkFee", Type: mustNewType("uint192")}, + {Name: "expiresAt", Type: mustNewType("uint64")}, + + {Name: "midPrice", Type: mustNewType("int192")}, + {Name: "bidPrice", Type: mustNewType("int192")}, + {Name: "askPrice", Type: mustNewType("int192")}, + {Name: "expiryTime", Type: mustNewType("uint64")}, + {Name: "firstDayOfNotice", Type: mustNewType("uint64")}, + {Name: "lastSeenTimestampNs", Type: mustNewType("uint64")}, + {Name: "marketStatus", Type: mustNewType("uint32")}, + {Name: "contractMonth", Type: mustNewType("string")}, + }) +} + +// Data is the container for this schema's attributes +type Data struct { + FeedID feed.ID `abi:"feedId"` + ValidFromTimestamp time.Time + ObservationsTimestamp time.Time + NativeFee *big.Int + LinkFee *big.Int + ExpiresAt time.Time + + MidPrice *big.Int + BidPrice *big.Int + AskPrice *big.Int + ExpiryTime time.Time // nanoseconds precision + FirstDayOfNotice time.Time // roll_date converted to UNIX timestamp, nanoseconds precision + LastSeenTimestampNs time.Time // Should reflect the timestamp of the last update from the DP, nanoseconds precision + MarketStatus uint32 + ContractMonth string // A single capital letter F to Z for Jan to Dec. +} + +// rawData is used internally for ABI decoding - types must match ABI schema +type rawData struct { + FeedID feed.ID `abi:"feedId"` + ValidFromTimestamp uint64 + ObservationsTimestamp uint64 + NativeFee *big.Int + LinkFee *big.Int + ExpiresAt uint64 + + MidPrice *big.Int + BidPrice *big.Int + AskPrice *big.Int + ExpiryTime uint64 + FirstDayOfNotice uint64 + LastSeenTimestampNs uint64 + MarketStatus uint32 + ContractMonth string +} + +// Schema returns this data version schema +func (Data) Schema() abi.Arguments { + return Schema() +} + +// Decode decodes the serialized data bytes +func Decode(data []byte) (*Data, error) { + values, err := schema.Unpack(data) + if err != nil { + return nil, fmt.Errorf("failed to decode report: %w", err) + } + raw := new(rawData) + if err = schema.Copy(raw, values); err != nil { + return nil, fmt.Errorf("failed to copy report values to struct: %w", err) + } + + // contractMonth must be a single letter from F to Z (Jan to Dec). + if len(raw.ContractMonth) != 1 || raw.ContractMonth[0] < 'F' || raw.ContractMonth[0] > 'Z' { + return nil, fmt.Errorf("invalid contractMonth %q: must be a single letter from F to Z", raw.ContractMonth) + } + + res := raw.FeedID.Resolution() + + decoded := &Data{ + FeedID: raw.FeedID, + ValidFromTimestamp: feed.ParseTimestamp(raw.ValidFromTimestamp, res), + ObservationsTimestamp: feed.ParseTimestamp(raw.ObservationsTimestamp, res), + NativeFee: raw.NativeFee, + LinkFee: raw.LinkFee, + ExpiresAt: feed.ParseTimestamp(raw.ExpiresAt, res), + MidPrice: raw.MidPrice, + BidPrice: raw.BidPrice, + AskPrice: raw.AskPrice, + ExpiryTime: time.Unix(0, int64(raw.ExpiryTime)), + FirstDayOfNotice: time.Unix(0, int64(raw.FirstDayOfNotice)), + LastSeenTimestampNs: time.Unix(0, int64(raw.LastSeenTimestampNs)), + MarketStatus: raw.MarketStatus, + ContractMonth: raw.ContractMonth, + } + + return decoded, nil +} diff --git a/go/report/v14/data_test.go b/go/report/v14/data_test.go new file mode 100644 index 0000000..5bc974a --- /dev/null +++ b/go/report/v14/data_test.go @@ -0,0 +1,126 @@ +package v14 + +import ( + "math/big" + "testing" + "time" +) + +func TestData(t *testing.T) { + // Raw values for packing + feedID := [32]uint8{00, 14, 107, 74, 167, 229, 124, 167, 182, 138, 225, 191, 69, 101, 63, 86, 182, 86, 253, 58, 163, 53, 239, 127, 174, 105, 107, 102, 63, 27, 132, 114} + validFromTS := uint64(time.Now().Unix()) + observationsTS := uint64(time.Now().Unix()) + nativeFee := big.NewInt(10) + linkFee := big.NewInt(10) + expiresAt := uint64(time.Now().Unix()) + 100 + midPrice := big.NewInt(100) + bidPrice := big.NewInt(99) + askPrice := big.NewInt(101) + expiryTime := uint64(time.Now().UnixNano()) + 100 + firstDayOfNotice := uint64(time.Now().UnixNano()) + 50 + lastSeenTimestampNs := uint64(time.Now().UnixNano()) - 100 + marketStatus := uint32(1) + contractMonth := "N" + + b, err := schema.Pack( + feedID, + validFromTS, + observationsTS, + nativeFee, + linkFee, + expiresAt, + midPrice, + bidPrice, + askPrice, + expiryTime, + firstDayOfNotice, + lastSeenTimestampNs, + marketStatus, + contractMonth, + ) + + if err != nil { + t.Fatalf("failed to serialize report: %s", err) + } + + d, err := Decode(b) + if err != nil { + t.Fatalf("failed to deserialize report: %s", err) + } + + // Verify decoded values + if d.FeedID != feedID { + t.Errorf("FeedID mismatch: expected %v, got %v", feedID, d.FeedID) + } + if d.ValidFromTimestamp.Unix() != int64(validFromTS) { + t.Errorf("ValidFromTimestamp mismatch: expected %d, got %d", validFromTS, d.ValidFromTimestamp.Unix()) + } + if d.ObservationsTimestamp.Unix() != int64(observationsTS) { + t.Errorf("ObservationsTimestamp mismatch: expected %d, got %d", observationsTS, d.ObservationsTimestamp.Unix()) + } + if d.NativeFee.Cmp(nativeFee) != 0 { + t.Errorf("NativeFee mismatch: expected %v, got %v", nativeFee, d.NativeFee) + } + if d.LinkFee.Cmp(linkFee) != 0 { + t.Errorf("LinkFee mismatch: expected %v, got %v", linkFee, d.LinkFee) + } + if d.ExpiresAt.Unix() != int64(expiresAt) { + t.Errorf("ExpiresAt mismatch: expected %d, got %d", expiresAt, d.ExpiresAt.Unix()) + } + if d.MidPrice.Cmp(midPrice) != 0 { + t.Errorf("MidPrice mismatch: expected %v, got %v", midPrice, d.MidPrice) + } + if d.BidPrice.Cmp(bidPrice) != 0 { + t.Errorf("BidPrice mismatch: expected %v, got %v", bidPrice, d.BidPrice) + } + if d.AskPrice.Cmp(askPrice) != 0 { + t.Errorf("AskPrice mismatch: expected %v, got %v", askPrice, d.AskPrice) + } + if d.ExpiryTime.UnixNano() != int64(expiryTime) { + t.Errorf("ExpiryTime mismatch: expected %d, got %d", expiryTime, d.ExpiryTime.UnixNano()) + } + if d.FirstDayOfNotice.UnixNano() != int64(firstDayOfNotice) { + t.Errorf("FirstDayOfNotice mismatch: expected %d, got %d", firstDayOfNotice, d.FirstDayOfNotice.UnixNano()) + } + if d.LastSeenTimestampNs.UnixNano() != int64(lastSeenTimestampNs) { + t.Errorf("LastSeenTimestampNs mismatch: expected %d, got %d", lastSeenTimestampNs, d.LastSeenTimestampNs.UnixNano()) + } + if d.MarketStatus != marketStatus { + t.Errorf("MarketStatus mismatch: expected %d, got %d", marketStatus, d.MarketStatus) + } + if d.ContractMonth != contractMonth { + t.Errorf("ContractMonth mismatch: expected %s, got %s", contractMonth, d.ContractMonth) + } +} + +func TestDecodeInvalidContractMonth(t *testing.T) { + feedID := [32]uint8{00, 14, 107, 74, 167, 229, 124, 167, 182, 138, 225, 191, 69, 101, 63, 86, 182, 86, 253, 58, 163, 53, 239, 127, 174, 105, 107, 102, 63, 27, 132, 114} + + // Values that are outside the valid single-letter F..Z range must be rejected. + for _, cm := range []string{"", "A", "E", "AB", "n", "1"} { + b, err := schema.Pack( + feedID, + uint64(time.Now().Unix()), + uint64(time.Now().Unix()), + big.NewInt(10), + big.NewInt(10), + uint64(time.Now().Unix())+100, + big.NewInt(100), + big.NewInt(99), + big.NewInt(101), + uint64(time.Now().UnixNano()), + uint64(time.Now().UnixNano()), + uint64(time.Now().UnixNano()), + uint32(1), + cm, + ) + if err != nil { + t.Fatalf("failed to serialize report: %s", err) + } + + if _, err := Decode(b); err == nil { + t.Errorf("expected error decoding contractMonth %q, got nil", cm) + } + } +} diff --git a/rust/crates/report/src/report.rs b/rust/crates/report/src/report.rs index 3d60444..c73b96d 100644 --- a/rust/crates/report/src/report.rs +++ b/rust/crates/report/src/report.rs @@ -5,6 +5,7 @@ pub mod v10; pub mod v11; pub mod v12; pub mod v13; +pub mod v14; pub mod v2; pub mod v3; pub mod v4; @@ -128,8 +129,9 @@ mod tests { use super::*; use crate::report::{ v1::ReportDataV1, v10::ReportDataV10, v11::ReportDataV11, v12::ReportDataV12, - v13::ReportDataV13, v2::ReportDataV2, v3::ReportDataV3, v4::ReportDataV4, v5::ReportDataV5, - v6::ReportDataV6, v7::ReportDataV7, v8::ReportDataV8, v9::ReportDataV9, + v13::ReportDataV13, v14::ReportDataV14, v2::ReportDataV2, v3::ReportDataV3, + v4::ReportDataV4, v5::ReportDataV5, v6::ReportDataV6, v7::ReportDataV7, v8::ReportDataV8, + v9::ReportDataV9, }; use num_bigint::BigInt; @@ -185,6 +187,10 @@ mod tests { 00, 13, 19, 169, 185, 197, 227, 122, 9, 159, 55, 78, 146, 195, 121, 20, 175, 92, 38, 143, 58, 138, 151, 33, 241, 114, 81, 53, 191, 180, 203, 184, ]); + const V14_FEED_ID: ID = ID([ + 00, 14, 107, 74, 167, 229, 124, 167, 182, 138, 225, 191, 69, 101, 63, 86, 182, 86, 253, 58, + 163, 53, 239, 127, 174, 105, 107, 102, 63, 27, 132, 114, + ]); pub const MOCK_TIMESTAMP: u32 = 1718885772; pub const MOCK_LAST_SEEN_TIMESTAMP_NS: u64 = 1718885772000000000; @@ -200,6 +206,9 @@ mod tests { pub const MOCK_LAST_TRADED_PRICE: isize = 228; pub const MOCK_MID: isize = 228; pub const MOCK_MARKET_STATUS: u32 = 2; + pub const MOCK_EXPIRY_TIME: u64 = 1718885872000000000; + pub const MOCK_FIRST_DAY_OF_NOTICE: u64 = 1718885822000000000; + pub const MOCK_CONTRACT_MONTH: &str = "F"; pub fn generate_mock_report_data_v1() -> ReportDataV1 { let report_data = ReportDataV1 { @@ -448,6 +457,29 @@ mod tests { report_data } + pub fn generate_mock_report_data_v14() -> ReportDataV14 { + let multiplier: BigInt = "1000000000000000000".parse::().unwrap(); // 1.0 with 18 decimals + + let report_data = ReportDataV14 { + feed_id: V14_FEED_ID, + valid_from_timestamp: MOCK_TIMESTAMP, + observations_timestamp: MOCK_TIMESTAMP, + native_fee: BigInt::from(MOCK_FEE), + link_fee: BigInt::from(MOCK_FEE), + expires_at: MOCK_TIMESTAMP + 100, + mid_price: BigInt::from(MOCK_MID).checked_mul(&multiplier).unwrap(), + bid_price: BigInt::from(MOCK_BID).checked_mul(&multiplier).unwrap(), + ask_price: BigInt::from(MOCK_ASK).checked_mul(&multiplier).unwrap(), + expiry_time: MOCK_EXPIRY_TIME, + first_day_of_notice: MOCK_FIRST_DAY_OF_NOTICE, + last_seen_timestamp_ns: MOCK_LAST_SEEN_TIMESTAMP_NS, + market_status: MOCK_MARKET_STATUS, + contract_month: MOCK_CONTRACT_MONTH.to_string(), + }; + + report_data + } + fn generate_mock_report(encoded_report_data: &[u8]) -> Vec { let mut payload = Vec::new(); @@ -898,4 +930,31 @@ mod tests { assert_eq!(decoded_report.feed_id, V13_FEED_ID); } + + #[test] + fn test_decode_report_v14() { + let report_data = generate_mock_report_data_v14(); + let encoded_report_data = report_data.abi_encode().unwrap(); + + let report = generate_mock_report(&encoded_report_data); + + let (_report_context, report_blob) = decode_full_report(&report).unwrap(); + + let decoded_report = ReportDataV14::decode(&report_blob).unwrap(); + + // V14 carries a dynamic `contractMonth` string, so assert the decoded values + // round-trip through the full-report path rather than comparing a fixed hex blob. + assert_eq!(decoded_report.feed_id, V14_FEED_ID); + assert_eq!(decoded_report.valid_from_timestamp, MOCK_TIMESTAMP); + assert_eq!(decoded_report.observations_timestamp, MOCK_TIMESTAMP); + assert_eq!(decoded_report.expires_at, MOCK_TIMESTAMP + 100); + assert_eq!(decoded_report.expiry_time, MOCK_EXPIRY_TIME); + assert_eq!(decoded_report.first_day_of_notice, MOCK_FIRST_DAY_OF_NOTICE); + assert_eq!( + decoded_report.last_seen_timestamp_ns, + MOCK_LAST_SEEN_TIMESTAMP_NS + ); + assert_eq!(decoded_report.market_status, MOCK_MARKET_STATUS); + assert_eq!(decoded_report.contract_month, MOCK_CONTRACT_MONTH); + } } diff --git a/rust/crates/report/src/report/base.rs b/rust/crates/report/src/report/base.rs index 77cbda3..982d32f 100644 --- a/rust/crates/report/src/report/base.rs +++ b/rust/crates/report/src/report/base.rs @@ -11,6 +11,9 @@ pub enum ReportError { #[error("Failed to parse {0}")] ParseError(&'static str), + + #[error("Invalid value for {0}")] + InvalidValue(&'static str), } pub(crate) struct ReportBase; @@ -134,4 +137,62 @@ impl ReportBase { buffer[32 - len..32].copy_from_slice(&bytes_value); Ok(buffer) } + + /// Reads an ABI dynamic `string` referenced by the head word at `offset`. + /// + /// The head word holds a byte offset (relative to the start of `data`) pointing to the + /// string's tail, where the first word is the byte length followed by the UTF-8 data + /// padded to a multiple of `WORD_SIZE`. + pub(crate) fn read_string(data: &[u8], offset: usize) -> Result { + if offset + Self::WORD_SIZE > data.len() { + return Err(ReportError::DataTooShort("string offset")); + } + + // The offset value is stored in the low 8 bytes of the head word. + let ptr = usize::from_be_bytes( + data[offset..offset + Self::WORD_SIZE][24..Self::WORD_SIZE] + .try_into() + .map_err(|_| ReportError::ParseError("string offset as usize"))?, + ); + + if ptr + Self::WORD_SIZE > data.len() { + return Err(ReportError::InvalidLength("string offset")); + } + + let length = usize::from_be_bytes( + data[ptr..ptr + Self::WORD_SIZE][24..Self::WORD_SIZE] + .try_into() + .map_err(|_| ReportError::ParseError("string length as usize"))?, + ); + + let start = ptr + Self::WORD_SIZE; + if start + length > data.len() { + return Err(ReportError::InvalidLength("string data")); + } + + String::from_utf8(data[start..start + length].to_vec()) + .map_err(|_| ReportError::ParseError("string (utf8)")) + } + + /// Encodes an ABI dynamic `string` tail: a `WORD_SIZE` length word followed by the UTF-8 + /// bytes right-padded with zeros to a multiple of `WORD_SIZE`. + /// + /// The caller is responsible for writing the corresponding head offset word. + pub(crate) fn encode_string_tail(value: &str) -> Vec { + let bytes = value.as_bytes(); + + let mut length_word = [0u8; Self::WORD_SIZE]; + length_word[24..Self::WORD_SIZE].copy_from_slice(&(bytes.len() as u64).to_be_bytes()); + + let mut buffer = length_word.to_vec(); + + if !bytes.is_empty() { + let padded_len = bytes.len().div_ceil(Self::WORD_SIZE) * Self::WORD_SIZE; + let mut data_words = vec![0u8; padded_len]; + data_words[..bytes.len()].copy_from_slice(bytes); + buffer.extend_from_slice(&data_words); + } + + buffer + } } diff --git a/rust/crates/report/src/report/v14.rs b/rust/crates/report/src/report/v14.rs new file mode 100644 index 0000000..71c55c8 --- /dev/null +++ b/rust/crates/report/src/report/v14.rs @@ -0,0 +1,227 @@ +use crate::feed_id::ID; +use crate::report::base::{ReportBase, ReportError}; + +use num_bigint::BigInt; + +/// Represents a Report Data V14 Schema (Continuous Commodities Futures). +/// +/// This schema provides mid/bid/ask pricing alongside futures contract metadata such as +/// the expiry time, first day of notice and contract month. +/// +/// # Parameters +/// - `feed_id`: The feed ID the report has data for. +/// - `valid_from_timestamp`: Earliest timestamp for which price is applicable. +/// - `observations_timestamp`: Latest timestamp for which price is applicable. +/// - `native_fee`: Base cost to validate a transaction using the report, denominated in the chain's native token (e.g., WETH/ETH). +/// - `link_fee`: Base cost to validate a transaction using the report, denominated in LINK. +/// - `expires_at`: Latest timestamp where the report can be verified onchain. +/// - `mid_price`: The mid price (18 decimal precision). +/// - `bid_price`: The bid price (18 decimal precision). +/// - `ask_price`: The ask price (18 decimal precision). +/// - `expiry_time`: Contract expiry time in nanoseconds. +/// - `first_day_of_notice`: First day of notice, converted to a UNIX timestamp in nanoseconds. +/// - `last_seen_timestamp_ns`: Timestamp of the last update seen from the data provider, in nanoseconds. +/// - `market_status`: The DON's consensus on whether the market is currently open. Possible values: `0` (`Unknown`), `1` (`Closed`), `2` (`Open`). +/// - `contract_month`: Contract month code: a single capital letter F to Z for Jan to Dec. +/// +/// # Solidity Equivalent +/// ```solidity +/// struct ReportDataV14 { +/// bytes32 feedId; +/// uint32 validFromTimestamp; +/// uint32 observationsTimestamp; +/// uint192 nativeFee; +/// uint192 linkFee; +/// uint32 expiresAt; +/// int192 midPrice; +/// int192 bidPrice; +/// int192 askPrice; +/// uint64 expiryTime; +/// uint64 firstDayOfNotice; +/// uint64 lastSeenTimestampNs; +/// uint32 marketStatus; +/// string contractMonth; +/// } +/// ``` +#[derive(Debug)] +pub struct ReportDataV14 { + pub feed_id: ID, + pub valid_from_timestamp: u32, + pub observations_timestamp: u32, + pub native_fee: BigInt, + pub link_fee: BigInt, + pub expires_at: u32, + pub mid_price: BigInt, + pub bid_price: BigInt, + pub ask_price: BigInt, + pub expiry_time: u64, + pub first_day_of_notice: u64, + pub last_seen_timestamp_ns: u64, + pub market_status: u32, + pub contract_month: String, +} + +impl ReportDataV14 { + /// Number of 32-byte head words: 13 static fields plus one offset word for the + /// dynamic `contractMonth` string. + const HEAD_WORDS: usize = 14; + + /// Decodes an ABI-encoded `ReportDataV14` from bytes. + /// + /// # Parameters + /// + /// - `data`: The encoded report data. + /// + /// # Returns + /// + /// The decoded `ReportDataV14`. + /// + /// # Errors + /// + /// Returns a `ReportError` if the data is too short or if the data is invalid. + pub fn decode(data: &[u8]) -> Result { + if data.len() < Self::HEAD_WORDS * ReportBase::WORD_SIZE { + return Err(ReportError::DataTooShort("ReportDataV14")); + } + + let feed_id = ID(data[..ReportBase::WORD_SIZE] + .try_into() + .map_err(|_| ReportError::InvalidLength("feed_id (bytes32)"))?); + + let valid_from_timestamp = ReportBase::read_uint32(data, ReportBase::WORD_SIZE)?; + let observations_timestamp = ReportBase::read_uint32(data, 2 * ReportBase::WORD_SIZE)?; + let native_fee = ReportBase::read_uint192(data, 3 * ReportBase::WORD_SIZE)?; + let link_fee = ReportBase::read_uint192(data, 4 * ReportBase::WORD_SIZE)?; + let expires_at = ReportBase::read_uint32(data, 5 * ReportBase::WORD_SIZE)?; + let mid_price = ReportBase::read_int192(data, 6 * ReportBase::WORD_SIZE)?; + let bid_price = ReportBase::read_int192(data, 7 * ReportBase::WORD_SIZE)?; + let ask_price = ReportBase::read_int192(data, 8 * ReportBase::WORD_SIZE)?; + let expiry_time = ReportBase::read_uint64(data, 9 * ReportBase::WORD_SIZE)?; + let first_day_of_notice = ReportBase::read_uint64(data, 10 * ReportBase::WORD_SIZE)?; + let last_seen_timestamp_ns = ReportBase::read_uint64(data, 11 * ReportBase::WORD_SIZE)?; + let market_status = ReportBase::read_uint32(data, 12 * ReportBase::WORD_SIZE)?; + let contract_month = ReportBase::read_string(data, 13 * ReportBase::WORD_SIZE)?; + + // contract_month must be a single letter from F to Z (Jan to Dec). + let month_bytes = contract_month.as_bytes(); + if month_bytes.len() != 1 || !(b'F'..=b'Z').contains(&month_bytes[0]) { + return Err(ReportError::InvalidValue("contract_month")); + } + + Ok(Self { + feed_id, + valid_from_timestamp, + observations_timestamp, + native_fee, + link_fee, + expires_at, + mid_price, + bid_price, + ask_price, + expiry_time, + first_day_of_notice, + last_seen_timestamp_ns, + market_status, + contract_month, + }) + } + + /// Encodes the `ReportDataV14` into an ABI-encoded byte array. + /// + /// # Returns + /// + /// The ABI-encoded report data. + /// + /// # Errors + /// + /// Returns a `ReportError` if the data is invalid. + pub fn abi_encode(&self) -> Result, ReportError> { + let mut buffer = Vec::with_capacity((Self::HEAD_WORDS + 2) * ReportBase::WORD_SIZE); + + // Head: 13 static fields. + buffer.extend_from_slice(&self.feed_id.0); + buffer.extend_from_slice(&ReportBase::encode_uint32(self.valid_from_timestamp)?); + buffer.extend_from_slice(&ReportBase::encode_uint32(self.observations_timestamp)?); + buffer.extend_from_slice(&ReportBase::encode_uint192(&self.native_fee)?); + buffer.extend_from_slice(&ReportBase::encode_uint192(&self.link_fee)?); + buffer.extend_from_slice(&ReportBase::encode_uint32(self.expires_at)?); + buffer.extend_from_slice(&ReportBase::encode_int192(&self.mid_price)?); + buffer.extend_from_slice(&ReportBase::encode_int192(&self.bid_price)?); + buffer.extend_from_slice(&ReportBase::encode_int192(&self.ask_price)?); + buffer.extend_from_slice(&ReportBase::encode_uint64(self.expiry_time)?); + buffer.extend_from_slice(&ReportBase::encode_uint64(self.first_day_of_notice)?); + buffer.extend_from_slice(&ReportBase::encode_uint64(self.last_seen_timestamp_ns)?); + buffer.extend_from_slice(&ReportBase::encode_uint32(self.market_status)?); + + // Head: offset word pointing to the dynamic `contractMonth` string tail. + let offset = (Self::HEAD_WORDS * ReportBase::WORD_SIZE) as u64; + buffer.extend_from_slice(&ReportBase::encode_uint64(offset)?); + + // Tail: the dynamic `contractMonth` string. + buffer.extend_from_slice(&ReportBase::encode_string_tail(&self.contract_month)); + + Ok(buffer) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::report::tests::{ + generate_mock_report_data_v14, MARKET_STATUS_OPEN, MOCK_ASK, MOCK_BID, + MOCK_CONTRACT_MONTH, MOCK_EXPIRY_TIME, MOCK_FEE, MOCK_FIRST_DAY_OF_NOTICE, + MOCK_LAST_SEEN_TIMESTAMP_NS, MOCK_MID, MOCK_TIMESTAMP, + }; + + const V14_FEED_ID_STR: &str = + "0x000e6b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472"; + + #[test] + fn test_decode_report_data_v14() { + let multiplier: BigInt = "1000000000000000000".parse::().unwrap(); // 1.0 with 18 decimals + + let report_data = generate_mock_report_data_v14(); + let encoded = report_data.abi_encode().unwrap(); + let decoded = ReportDataV14::decode(&encoded).unwrap(); + + let expected_feed_id = ID::from_hex_str(V14_FEED_ID_STR).unwrap(); + let expected_timestamp: u32 = MOCK_TIMESTAMP; + let expected_fee = BigInt::from(MOCK_FEE); + let expected_mid = BigInt::from(MOCK_MID).checked_mul(&multiplier).unwrap(); + let expected_bid = BigInt::from(MOCK_BID).checked_mul(&multiplier).unwrap(); + let expected_ask = BigInt::from(MOCK_ASK).checked_mul(&multiplier).unwrap(); + + assert_eq!(decoded.feed_id, expected_feed_id); + assert_eq!(decoded.valid_from_timestamp, expected_timestamp); + assert_eq!(decoded.observations_timestamp, expected_timestamp); + assert_eq!(decoded.native_fee, expected_fee); + assert_eq!(decoded.link_fee, expected_fee); + assert_eq!(decoded.expires_at, expected_timestamp + 100); + assert_eq!(decoded.mid_price, expected_mid); + assert_eq!(decoded.bid_price, expected_bid); + assert_eq!(decoded.ask_price, expected_ask); + assert_eq!(decoded.expiry_time, MOCK_EXPIRY_TIME); + assert_eq!(decoded.first_day_of_notice, MOCK_FIRST_DAY_OF_NOTICE); + assert_eq!(decoded.last_seen_timestamp_ns, MOCK_LAST_SEEN_TIMESTAMP_NS); + assert_eq!(decoded.market_status, MARKET_STATUS_OPEN); + assert_eq!(decoded.contract_month, MOCK_CONTRACT_MONTH); + } + + #[test] + fn test_decode_report_data_v14_invalid_contract_month() { + // Values outside the valid single-letter F..Z range must be rejected. + for invalid in ["", "A", "E", "AB", "n", "1"] { + let mut report_data = generate_mock_report_data_v14(); + report_data.contract_month = invalid.to_string(); + let encoded = report_data.abi_encode().unwrap(); + + let result = ReportDataV14::decode(&encoded); + assert!( + matches!(result, Err(ReportError::InvalidValue("contract_month"))), + "expected InvalidValue error for contract_month {:?}, got {:?}", + invalid, + result + ); + } + } +} diff --git a/typescript/src/decoder/implementation.ts b/typescript/src/decoder/implementation.ts index 71bfce2..3bf2e40 100644 --- a/typescript/src/decoder/implementation.ts +++ b/typescript/src/decoder/implementation.ts @@ -5,6 +5,7 @@ import { DecodedV11Report, DecodedV12Report, DecodedV13Report, + DecodedV14Report, DecodedV2Report, DecodedV3Report, DecodedV4Report, @@ -176,6 +177,23 @@ const reportSchemaV13 = [ { type: "int192", name: "lastTradedPrice" }, ]; +const reportSchemaV14 = [ + { type: "bytes32", name: "feedId" }, + { type: "uint32", name: "validFromTimestamp" }, + { type: "uint32", name: "observationsTimestamp" }, + { type: "uint192", name: "nativeFee" }, + { type: "uint192", name: "linkFee" }, + { type: "uint32", name: "expiresAt" }, + { type: "int192", name: "midPrice" }, + { type: "int192", name: "bidPrice" }, + { type: "int192", name: "askPrice" }, + { type: "uint64", name: "expiryTime" }, + { type: "uint64", name: "firstDayOfNotice" }, + { type: "uint64", name: "lastSeenTimestampNs" }, + { type: "uint32", name: "marketStatus" }, + { type: "string", name: "contractMonth" }, +]; + /** * Decode a report from its hex string representation * @param reportHex The hex string representation of the report @@ -199,7 +217,8 @@ export function decodeReport( | DecodedV10Report | DecodedV11Report | DecodedV12Report - | DecodedV13Report { + | DecodedV13Report + | DecodedV14Report { logger?.debug(`Decoding report for feed ${feedId}`); try { @@ -253,6 +272,8 @@ export function decodeReport( return decodeV12Report(reportBlob); case "000d": return decodeV13Report(reportBlob); + case "000e": + return decodeV14Report(reportBlob); default: throw new ReportDecodingError(`Unknown report version: 0x${version}`); } @@ -562,7 +583,6 @@ function decodeV12Report(reportBlob: string): DecodedV12Report { } } - function decodeV13Report(reportBlob: string): DecodedV13Report { try { const decoded = globalAbiCoder.decode( @@ -587,3 +607,39 @@ function decodeV13Report(reportBlob: string): DecodedV13Report { ); } } + +function decodeV14Report(reportBlob: string): DecodedV14Report { + try { + const decoded = globalAbiCoder.decode( + reportSchemaV14.map(item => item.type), + getBytes(reportBlob) + ); + + // contractMonth must be a single letter from F to Z (Jan to Dec). + const contractMonth = decoded[13]; + if (typeof contractMonth !== "string" || !/^[F-Z]$/.test(contractMonth)) { + throw new ReportDecodingError( + `Invalid contract month: ${contractMonth}. Must be a single letter from F to Z` + ); + } + + return { + version: "V14", + nativeFee: decoded[3], + linkFee: decoded[4], + expiresAt: Number(decoded[5]), + midPrice: decoded[6], + bidPrice: decoded[7], + askPrice: decoded[8], + expiryTime: decoded[9], + firstDayOfNotice: decoded[10], + lastSeenTimestampNs: decoded[11], + marketStatus: Number(decoded[12]), + contractMonth, + }; + } catch (error) { + throw new ReportDecodingError( + `Failed to decode V14 report: ${error instanceof Error ? error.message : String(error)}` + ); + } +} diff --git a/typescript/src/index.ts b/typescript/src/index.ts index 44ff60d..4c62d9d 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -62,6 +62,7 @@ export type { DecodedV11Report, DecodedV12Report, DecodedV13Report, + DecodedV14Report, MarketStatus, } from "./types/report"; export type { Logger, LoggingConfig } from "./types/logger"; diff --git a/typescript/src/stream/connection-manager.ts b/typescript/src/stream/connection-manager.ts index 85d5ee9..3f23a77 100644 --- a/typescript/src/stream/connection-manager.ts +++ b/typescript/src/stream/connection-manager.ts @@ -223,9 +223,7 @@ export class ConnectionManager extends EventEmitter { return; } - this.logger.connectionDebug( - `Cleaning up WebSocket for ${connection.id} (readyState: ${connection.ws.readyState})` - ); + this.logger.connectionDebug(`Cleaning up WebSocket for ${connection.id} (readyState: ${connection.ws.readyState})`); // Store reference to WebSocket before cleanup const ws = connection.ws; diff --git a/typescript/src/stream/deduplication.ts b/typescript/src/stream/deduplication.ts index e0194be..e39a5b9 100644 --- a/typescript/src/stream/deduplication.ts +++ b/typescript/src/stream/deduplication.ts @@ -93,7 +93,7 @@ export class ReportDeduplicator { if (ts > state.watermark) { state.watermark = ts; } - + return Verdict.Accept; } diff --git a/typescript/src/types/report.ts b/typescript/src/types/report.ts index ada9083..b3744d8 100644 --- a/typescript/src/types/report.ts +++ b/typescript/src/types/report.ts @@ -217,6 +217,30 @@ export interface DecodedV13Report extends DecodedReportFields { lastTradedPrice: bigint; } +/** + * Decoded V14 report format (Continuous Commodities Futures). + * + * Provides mid/bid/ask pricing alongside futures contract metadata such as + * the expiry time, first day of notice and contract month. + */ +export interface DecodedV14Report extends DecodedReportFields { + /** Report format version identifier */ + version: "V14"; + midPrice: bigint; + bidPrice: bigint; + askPrice: bigint; + /** Contract expiry time in nanoseconds */ + expiryTime: bigint; + /** First day of notice, converted to a UNIX timestamp in nanoseconds */ + firstDayOfNotice: bigint; + /** Timestamp of the last update seen from the data provider, in nanoseconds */ + lastSeenTimestampNs: bigint; + /** Market status - 0 (Unknown), 1 (Closed), 2 (Open) */ + marketStatus: number; + /** Contract month code: a single capital letter F to Z for Jan to Dec */ + contractMonth: string; +} + /** * Complete decoded report structure received from Data Streams. * @@ -236,7 +260,8 @@ export type DecodedReport = ( | DecodedV11Report | DecodedV12Report | DecodedV13Report - ) & { + | DecodedV14Report +) & { /** Feed ID this report belongs to */ feedID: string; /** Earliest timestamp this report is valid for */ diff --git a/typescript/src/utils/constants.ts b/typescript/src/utils/constants.ts index 7e4a1f7..b27a00d 100644 --- a/typescript/src/utils/constants.ts +++ b/typescript/src/utils/constants.ts @@ -22,8 +22,8 @@ export const WS_CONSTANTS = { export const VALIDATION_REGEX = { /** Matches valid feed IDs (0x followed by 64 hex characters) */ FEED_ID: /^0x[0-9a-fA-F]{64}$/, - /** Matches valid schema versions (0x0002-0x0009, 0x000a, 0x000d) */ - SCHEMA_VERSION: /^0x000([2-9]|a|b|c|d)$/, + /** Matches valid schema versions (0x0002-0x0009, 0x000a-0x000e) */ + SCHEMA_VERSION: /^0x000([2-9]|a|b|c|d|e)$/, } as const; // Request timeout constants diff --git a/typescript/src/utils/report.ts b/typescript/src/utils/report.ts index 0b440d5..4e77b71 100644 --- a/typescript/src/utils/report.ts +++ b/typescript/src/utils/report.ts @@ -4,6 +4,7 @@ import { DecodedV11Report, DecodedV12Report, DecodedV13Report, + DecodedV14Report, DecodedV2Report, DecodedV3Report, DecodedV4Report, @@ -18,9 +19,11 @@ import { /** * Determines the version of a feed based on its ID * @param feedId The feed ID to check - * @returns "V2", "V3", "V4", "V5", "V6", "V7", "V8", "V9", "V10", "V11", "V12" or "V13 depending on the feed ID schema version + * @returns "V2", "V3", "V4", "V5", "V6", "V7", "V8", "V9", "V10", "V11", "V12", "V13" or "V14" depending on the feed ID schema version */ -export function getReportVersion(feedId: string): "V2" | "V3" | "V4" | "V5" | "V6" | "V7" | "V8" | "V9" | "V10" | "V11" | "V12" | "V13" { +export function getReportVersion( + feedId: string +): "V2" | "V3" | "V4" | "V5" | "V6" | "V7" | "V8" | "V9" | "V10" | "V11" | "V12" | "V13" | "V14" { const schemaVersion = feedId.slice(2, 6); switch (schemaVersion) { case "0002": @@ -47,6 +50,8 @@ export function getReportVersion(feedId: string): "V2" | "V3" | "V4" | "V5" | "V return "V12"; case "000d": return "V13"; + case "000e": + return "V14"; default: throw new Error(`Unknown schema version: 0x${schemaVersion}`); } @@ -55,12 +60,12 @@ export function getReportVersion(feedId: string): "V2" | "V3" | "V4" | "V5" | "V /** * Formats a report as a human-readable string * @param report The report object to format - * @param version The version of the report (V2, V3, V4, V5, V6, V7, V8, V9, V10, V11, V12 or V13) + * @param version The version of the report (V2, V3, V4, V5, V6, V7, V8, V9, V10, V11, V12, V13 or V14) * @returns Formatted string representation of the report */ export function formatReport( report: DecodedReport, - version: "V2" | "V3" | "V4" | "V5" | "V6" | "V7" | "V8" | "V9" | "V10" | "V11" | "V12" | "V13" + version: "V2" | "V3" | "V4" | "V5" | "V6" | "V7" | "V8" | "V9" | "V10" | "V11" | "V12" | "V13" | "V14" ): string { let output = ""; @@ -170,6 +175,18 @@ export function formatReport( output += `Last Traded Price: ${r.lastTradedPrice.toString()}\n`; break; } + case "V14": { + const r = report as DecodedV14Report; + output += `Mid Price: ${r.midPrice.toString()}\n`; + output += `Bid Price: ${r.bidPrice.toString()}\n`; + output += `Ask Price: ${r.askPrice.toString()}\n`; + output += `Expiry Time: ${r.expiryTime.toString()}\n`; + output += `First Day of Notice: ${r.firstDayOfNotice.toString()}\n`; + output += `Last Seen Timestamp Nanos: ${r.lastSeenTimestampNs.toString()}\n`; + output += `Market Status: ${r.marketStatus.toString()}\n`; + output += `Contract Month: ${r.contractMonth}\n`; + break; + } } output += "-".repeat(50); diff --git a/typescript/src/utils/validation.ts b/typescript/src/utils/validation.ts index 4012584..59a24ef 100644 --- a/typescript/src/utils/validation.ts +++ b/typescript/src/utils/validation.ts @@ -16,7 +16,7 @@ export function validateFeedId(feedId: string): void { const version = feedId.slice(2, 6); if (!VALIDATION_REGEX.SCHEMA_VERSION.test(`0x${version}`)) { throw new ValidationError( - "Invalid feed ID version. Must start with 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000a, 0x000b, 0x000c or 0x000d" + "Invalid feed ID version. Must start with 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000a, 0x000b, 0x000c, 0x000d or 0x000e" ); } } diff --git a/typescript/tests/integration/client.integration.test.ts b/typescript/tests/integration/client.integration.test.ts index c14ac9e..72f76e6 100644 --- a/typescript/tests/integration/client.integration.test.ts +++ b/typescript/tests/integration/client.integration.test.ts @@ -529,10 +529,7 @@ describe("DataStreams Client", () => { Promise.resolve({ ok: true, status: 200, - json: () => - Promise.resolve({ - /* missing report field */ - }), + json: () => Promise.resolve({/* missing report field */}), } as Response) ) as FetchMock; @@ -842,7 +839,15 @@ describe("DataStreams Client", () => { }); it("should support mixed schema versions in bulk operations", async () => { - const mixedFeedIds = [TEST_FEED_IDS.V3, TEST_FEED_IDS.V8, TEST_FEED_IDS.V9, TEST_FEED_IDS.V10, TEST_FEED_IDS.V11, TEST_FEED_IDS.V12, TEST_FEED_IDS.V13]; + const mixedFeedIds = [ + TEST_FEED_IDS.V3, + TEST_FEED_IDS.V8, + TEST_FEED_IDS.V9, + TEST_FEED_IDS.V10, + TEST_FEED_IDS.V11, + TEST_FEED_IDS.V12, + TEST_FEED_IDS.V13, + ]; const timestamp = Math.floor(Date.now() / 1000); const mockReports = mixedFeedIds.map(feedId => ({ diff --git a/typescript/tests/integration/websocket-cleanup.test.ts b/typescript/tests/integration/websocket-cleanup.test.ts index 294d14d..a07f960 100644 --- a/typescript/tests/integration/websocket-cleanup.test.ts +++ b/typescript/tests/integration/websocket-cleanup.test.ts @@ -326,4 +326,3 @@ describe("WebSocket Connection Cleanup Tests", () => { await mockServer2.close(); }, 15000); }); - diff --git a/typescript/tests/unit/decoder/decoder.test.ts b/typescript/tests/unit/decoder/decoder.test.ts index 20c07a9..99eb51d 100644 --- a/typescript/tests/unit/decoder/decoder.test.ts +++ b/typescript/tests/unit/decoder/decoder.test.ts @@ -18,6 +18,7 @@ import { DecodedV11Report, DecodedV12Report, DecodedV13Report, + DecodedV14Report, DecodedV2Report, DecodedV3Report, DecodedV4Report, @@ -45,6 +46,7 @@ const mockV10FeedId = "0x000a" + "7".repeat(60); const mockV11FeedId = "0x000b" + "7".repeat(60); const mockV12FeedId = "0x000c" + "7".repeat(60); const mockV13FeedId = "0x000d" + "7".repeat(60); +const mockV14FeedId = "0x000e" + "7".repeat(60); // Create a properly encoded full report const mockReportContext = [ @@ -251,7 +253,6 @@ const mockV11ReportBlob = abiCoder.encode( ] ); - // Create V12 report blob const mockV12ReportBlob = abiCoder.encode( ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192", "int192", "uint64", "uint32"], @@ -273,16 +274,16 @@ const mockV12ReportBlob = abiCoder.encode( const mockV13ReportBlob = abiCoder.encode( [ "bytes32", // feed id - "uint32", // valid from ts - "uint32", // observation ts + "uint32", // valid from ts + "uint32", // observation ts "uint192", // native fee "uint192", // link fee - "uint32", // expires at - "int192", // best ask - "int192", // best bid - "uint64", // ask volume - "uint64", // bid volume - "int192", // last traded price + "uint32", // expires at + "int192", // best ask + "int192", // best bid + "uint64", // ask volume + "uint64", // bid volume + "int192", // last traded price ], [ mockV13FeedId, @@ -299,6 +300,42 @@ const mockV13ReportBlob = abiCoder.encode( ] ); +// Create V14 report blob +const mockV14ReportBlob = abiCoder.encode( + [ + "bytes32", // feed id + "uint32", // valid from ts + "uint32", // observation ts + "uint192", // native fee + "uint192", // link fee + "uint32", // expires at + "int192", // mid price + "int192", // bid price + "int192", // ask price + "uint64", // expiry time (ns) + "uint64", // first day of notice (ns) + "uint64", // last seen timestamp (ns) + "uint32", // market status + "string", // contract month + ], + [ + mockV14FeedId, + Math.floor(Date.now() / 1000), + Math.floor(Date.now() / 1000), + 1000000000000000000n, // 1 native token + 2000000000000000000n, // 2 LINK + Math.floor(Date.now() / 1000) + 3600, // expires in 1 hour + 100000000000000000000n, // mid price $100 + 99000000000000000000n, // bid price $99 + 101000000000000000000n, // ask price $101 + 1700000010000000000n, // expiry time (ns) + 1700000005000000000n, // first day of notice (ns) + 1700000000000000000n, // last seen timestamp (ns) + 2, // market status (open) + "F", // contract month (Jan) + ] +); + // Create full reports const mockV2FullReport = abiCoder.encode( ["bytes32[3]", "bytes", "bytes32[]", "bytes32[]", "bytes32"], @@ -432,6 +469,17 @@ const mockV13FullReport = abiCoder.encode( ] ); +const mockV14FullReport = abiCoder.encode( + ["bytes32[3]", "bytes", "bytes32[]", "bytes32[]", "bytes32"], + [ + mockReportContext, + mockV14ReportBlob, + ["0x0000000000000000000000000000000000000000000000000000000000000013"], + ["0x0000000000000000000000000000000000000000000000000000000000000014"], + "0x0000000000000000000000000000000000000000000000000000000000000015", + ] +); + describe("Report Decoder", () => { describe("v2 reports", () => { it("should decode valid v2 report", () => { @@ -791,7 +839,6 @@ describe("Report Decoder", () => { expect(typeof decoded.askVolume).toBe("bigint"); expect(typeof decoded.lastTradedPrice).toBe("bigint"); expect(typeof decoded.marketStatus).toBe("number"); - }); }); @@ -920,6 +967,104 @@ describe("Report Decoder", () => { }); }); + describe("v14 reports", () => { + it("should decode valid v14 report", () => { + const decoded = decodeReport(mockV14FullReport, mockV14FeedId) as DecodedV14Report; + + expect(decoded).toBeDefined(); + expect(decoded.version).toBe("V14"); + expect(decoded.nativeFee).toBeDefined(); + expect(decoded.linkFee).toBeDefined(); + expect(decoded.expiresAt).toBeDefined(); + expect(decoded.midPrice).toBeDefined(); + expect(decoded.bidPrice).toBeDefined(); + expect(decoded.askPrice).toBeDefined(); + expect(decoded.expiryTime).toBeDefined(); + expect(decoded.firstDayOfNotice).toBeDefined(); + expect(decoded.lastSeenTimestampNs).toBeDefined(); + expect(decoded.marketStatus).toBeDefined(); + expect(decoded.contractMonth).toBeDefined(); + }); + + it("should handle malformed v14 report", () => { + const malformedReport = "0xinvalid"; + expect(() => decodeReport(malformedReport, mockV14FeedId)).toThrow(); + }); + + it("should decode all v14 fields correctly", () => { + const decoded = decodeReport(mockV14FullReport, mockV14FeedId) as DecodedV14Report; + + // Verify field types + expect(typeof decoded.midPrice).toBe("bigint"); + expect(typeof decoded.bidPrice).toBe("bigint"); + expect(typeof decoded.askPrice).toBe("bigint"); + expect(typeof decoded.expiryTime).toBe("bigint"); + expect(typeof decoded.firstDayOfNotice).toBe("bigint"); + expect(typeof decoded.lastSeenTimestampNs).toBe("bigint"); + expect(typeof decoded.marketStatus).toBe("number"); + expect(typeof decoded.contractMonth).toBe("string"); + + // Verify values round-trip from the encoded blob + expect(decoded.midPrice).toBe(100000000000000000000n); + expect(decoded.bidPrice).toBe(99000000000000000000n); + expect(decoded.askPrice).toBe(101000000000000000000n); + expect(decoded.expiryTime).toBe(1700000010000000000n); + expect(decoded.firstDayOfNotice).toBe(1700000005000000000n); + expect(decoded.lastSeenTimestampNs).toBe(1700000000000000000n); + expect(decoded.marketStatus).toBe(2); + expect(decoded.contractMonth).toBe("F"); + }); + + it("should reject an invalid contract month", () => { + const invalidBlob = abiCoder.encode( + [ + "bytes32", + "uint32", + "uint32", + "uint192", + "uint192", + "uint32", + "int192", + "int192", + "int192", + "uint64", + "uint64", + "uint64", + "uint32", + "string", + ], + [ + mockV14FeedId, + Math.floor(Date.now() / 1000), + Math.floor(Date.now() / 1000), + 1000000000000000000n, + 2000000000000000000n, + Math.floor(Date.now() / 1000) + 3600, + 100000000000000000000n, + 99000000000000000000n, + 101000000000000000000n, + 1700000010000000000n, + 1700000005000000000n, + 1700000000000000000n, + 2, + "A", // outside the valid F..Z range + ] + ); + const invalidFullReport = abiCoder.encode( + ["bytes32[3]", "bytes", "bytes32[]", "bytes32[]", "bytes32"], + [ + mockReportContext, + invalidBlob, + ["0x0000000000000000000000000000000000000000000000000000000000000013"], + ["0x0000000000000000000000000000000000000000000000000000000000000014"], + "0x0000000000000000000000000000000000000000000000000000000000000015", + ] + ); + + expect(() => decodeReport(invalidFullReport, mockV14FeedId)).toThrow("Invalid contract month"); + }); + }); + describe("edge cases", () => { it("should handle empty report", () => { expect(() => decodeReport("", mockV3FeedId)).toThrow(); diff --git a/typescript/tests/unit/utils/validation/feed-validation.test.ts b/typescript/tests/unit/utils/validation/feed-validation.test.ts index f4c880e..b4d3b8b 100644 --- a/typescript/tests/unit/utils/validation/feed-validation.test.ts +++ b/typescript/tests/unit/utils/validation/feed-validation.test.ts @@ -39,6 +39,7 @@ describe("Feed ID Validation Tests", () => { V11: "0x000bfb6d135897e4aaf5657bffd3b0b48f8e2a5131214c9ec2d62eac5d532067", V12: "0x000c6b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472", V13: "0x000d13a9b9c5e37a099f374e92c37914af5c268f3a8a9721f1725135bfb4cbb8", + V14: "0x000e6b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472", }; const REAL_WORLD_FEED_IDS = { @@ -101,6 +102,10 @@ describe("Feed ID Validation Tests", () => { expect(() => validateFeedId(VALID_FEED_IDS.V13)).not.toThrow(); }); + it("should accept valid V14 feed ID", () => { + expect(() => validateFeedId(VALID_FEED_IDS.V14)).not.toThrow(); + }); + it("should accept real-world feed IDs", () => { expect(() => validateFeedId(REAL_WORLD_FEED_IDS.FEED1)).not.toThrow(); expect(() => validateFeedId(REAL_WORLD_FEED_IDS.FEED2)).not.toThrow(); @@ -342,7 +347,7 @@ describe("Feed ID Validation Tests", () => { it("should provide specific error for unsupported version", () => { expect(() => validateFeedId(VALID_FEED_IDS.V1)).toThrow( - "Invalid feed ID version. Must start with 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000a, 0x000b, 0x000c or 0x000d" + "Invalid feed ID version. Must start with 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000a, 0x000b, 0x000c, 0x000d or 0x000e" ); }); @@ -406,6 +411,7 @@ describe("Feed ID Validation Tests", () => { expect(() => validateFeedId(VALID_FEED_IDS.V11)).not.toThrow(); expect(() => validateFeedId(VALID_FEED_IDS.V12)).not.toThrow(); expect(() => validateFeedId(VALID_FEED_IDS.V13)).not.toThrow(); + expect(() => validateFeedId(VALID_FEED_IDS.V14)).not.toThrow(); expect(() => validateFeedId(VALID_FEED_IDS.V1)).toThrow(); // V1 not supported in our implementation }); }); diff --git a/typescript/tests/unit/utils/validation/report-validation.test.ts b/typescript/tests/unit/utils/validation/report-validation.test.ts index 45b2aad..7bb29fa 100644 --- a/typescript/tests/unit/utils/validation/report-validation.test.ts +++ b/typescript/tests/unit/utils/validation/report-validation.test.ts @@ -298,24 +298,11 @@ describe("Report Validation Tests", () => { ); } - // Helper function to create a valid V12 report blob function createV12ReportBlob(): string { const abiCoder = new AbiCoder(); return abiCoder.encode( - [ - "bytes32", - "uint32", - "uint32", - "uint192", - "uint192", - "uint32", - - "int192", - "int192", - "uint64", - "uint32", - ], + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192", "int192", "uint64", "uint32"], [ FEED_IDS.V12, 1640995200, @@ -336,19 +323,7 @@ describe("Report Validation Tests", () => { function createV13ReportBlob(): string { const abiCoder = new AbiCoder(); return abiCoder.encode( - [ - "bytes32", - "uint32", - "uint32", - "uint192", - "uint192", - "uint32", - "int192", - "int192", - "uint64", - "uint64", - "int192", - ], + ["bytes32", "uint32", "uint32", "uint192", "uint192", "uint32", "int192", "int192", "uint64", "uint64", "int192"], [ FEED_IDS.V13, 1640995200,