diff --git a/go/report/report_test.go b/go/report/report_test.go index b23f7f6..f2e30bf 100644 --- a/go/report/report_test.go +++ b/go/report/report_test.go @@ -517,20 +517,23 @@ var v13Data = v13.Data{ } 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", + 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: "2026-09-22", + FirstDayOfNotice: time.Unix(0, 1700000005000000000), + LastSeenTimestampNs: time.Unix(0, 1700000000000000000), + MarketStatus: common.MarketStatusOpen, + ContractMonth: 1, + GoldmanRollPrice: big.NewInt(102), + CurrentBusinessDay: 3, + InterpolatedGoldmanRollPrice: big.NewInt(103), } func mustPackData(d interface{}) []byte { @@ -729,11 +732,14 @@ func mustPackData(d interface{}) []byte { v.MidPrice, v.BidPrice, v.AskPrice, - uint64(v.ExpiryTime.UnixNano()), + v.ExpiryTime, uint64(v.FirstDayOfNotice.UnixNano()), uint64(v.LastSeenTimestampNs.UnixNano()), v.MarketStatus, v.ContractMonth, + v.GoldmanRollPrice, + v.CurrentBusinessDay, + v.InterpolatedGoldmanRollPrice, } default: panic(fmt.Sprintf("invalid type to pack: %#v", v)) diff --git a/go/report/v14/data.go b/go/report/v14/data.go index 33ce154..e21d45e 100644 --- a/go/report/v14/data.go +++ b/go/report/v14/data.go @@ -12,6 +12,9 @@ import ( var schema = Schema() +// expiryTimeLayout is the date layout of the expiryTime field, e.g. "2026-09-22". +const expiryTimeLayout = "2006-01-02" + // Schema returns this data version schema func Schema() abi.Arguments { mustNewType := func(t string) abi.Type { @@ -32,11 +35,14 @@ func Schema() abi.Arguments { {Name: "midPrice", Type: mustNewType("int192")}, {Name: "bidPrice", Type: mustNewType("int192")}, {Name: "askPrice", Type: mustNewType("int192")}, - {Name: "expiryTime", Type: mustNewType("uint64")}, + {Name: "expiryTime", Type: mustNewType("string")}, {Name: "firstDayOfNotice", Type: mustNewType("uint64")}, {Name: "lastSeenTimestampNs", Type: mustNewType("uint64")}, {Name: "marketStatus", Type: mustNewType("uint32")}, - {Name: "contractMonth", Type: mustNewType("string")}, + {Name: "contractMonth", Type: mustNewType("uint32")}, + {Name: "goldmanRollPrice", Type: mustNewType("int192")}, + {Name: "currentBusinessDay", Type: mustNewType("uint32")}, + {Name: "interpolatedGoldmanRollPrice", Type: mustNewType("int192")}, }) } @@ -49,14 +55,17 @@ type Data struct { 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. + MidPrice *big.Int + BidPrice *big.Int + AskPrice *big.Int + ExpiryTime string // Contract expiry date, formatted as YYYY-MM-DD, e.g. "2026-09-22" + 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 uint32 // The contract month, from 1 (Jan) to 12 (Dec). + GoldmanRollPrice *big.Int // The Goldman roll price (18 decimal precision) + CurrentBusinessDay uint32 // The current business day, numbered + InterpolatedGoldmanRollPrice *big.Int // The interpolated Goldman roll price (18 decimal precision) } // rawData is used internally for ABI decoding - types must match ABI schema @@ -68,14 +77,17 @@ type rawData struct { LinkFee *big.Int ExpiresAt uint64 - MidPrice *big.Int - BidPrice *big.Int - AskPrice *big.Int - ExpiryTime uint64 - FirstDayOfNotice uint64 - LastSeenTimestampNs uint64 - MarketStatus uint32 - ContractMonth string + MidPrice *big.Int + BidPrice *big.Int + AskPrice *big.Int + ExpiryTime string + FirstDayOfNotice uint64 + LastSeenTimestampNs uint64 + MarketStatus uint32 + ContractMonth uint32 + GoldmanRollPrice *big.Int + CurrentBusinessDay uint32 + InterpolatedGoldmanRollPrice *big.Int } // Schema returns this data version schema @@ -94,16 +106,18 @@ func Decode(data []byte) (*Data, error) { 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) + // contractMonth must be a number from 1 (Jan) to 12 (Dec). + if raw.ContractMonth < 1 || raw.ContractMonth > 12 { + return nil, fmt.Errorf("invalid contractMonth %d: must be a number from 1 to 12", raw.ContractMonth) + } + + // expiryTime must be a valid calendar date formatted as YYYY-MM-DD. + if _, err := time.Parse(expiryTimeLayout, raw.ExpiryTime); err != nil { + return nil, fmt.Errorf("invalid expiryTime %q: must be a date formatted as YYYY-MM-DD: %w", raw.ExpiryTime, err) } // Validate uint64 nanosecond timestamps do not overflow int64 const maxInt64Ns = int64(^uint64(0) >> 1) // 2^63 - 1 - if raw.ExpiryTime > uint64(maxInt64Ns) { - return nil, fmt.Errorf("ExpiryTime overflow: %d exceeds maximum nanosecond timestamp", raw.ExpiryTime) - } if raw.FirstDayOfNotice > uint64(maxInt64Ns) { return nil, fmt.Errorf("FirstDayOfNotice overflow: %d exceeds maximum nanosecond timestamp", raw.FirstDayOfNotice) } @@ -114,20 +128,23 @@ func Decode(data []byte) (*Data, error) { 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, + 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: raw.ExpiryTime, + FirstDayOfNotice: time.Unix(0, int64(raw.FirstDayOfNotice)), + LastSeenTimestampNs: time.Unix(0, int64(raw.LastSeenTimestampNs)), + MarketStatus: raw.MarketStatus, + ContractMonth: raw.ContractMonth, + GoldmanRollPrice: raw.GoldmanRollPrice, + CurrentBusinessDay: raw.CurrentBusinessDay, + InterpolatedGoldmanRollPrice: raw.InterpolatedGoldmanRollPrice, } return decoded, nil diff --git a/go/report/v14/data_test.go b/go/report/v14/data_test.go index 5bc974a..00f4542 100644 --- a/go/report/v14/data_test.go +++ b/go/report/v14/data_test.go @@ -17,11 +17,14 @@ func TestData(t *testing.T) { midPrice := big.NewInt(100) bidPrice := big.NewInt(99) askPrice := big.NewInt(101) - expiryTime := uint64(time.Now().UnixNano()) + 100 + expiryTime := "2026-09-22" firstDayOfNotice := uint64(time.Now().UnixNano()) + 50 lastSeenTimestampNs := uint64(time.Now().UnixNano()) - 100 marketStatus := uint32(1) - contractMonth := "N" + contractMonth := uint32(7) + goldmanRollPrice := big.NewInt(102) + currentBusinessDay := uint32(3) + interpolatedGoldmanRollPrice := big.NewInt(103) b, err := schema.Pack( feedID, @@ -38,6 +41,9 @@ func TestData(t *testing.T) { lastSeenTimestampNs, marketStatus, contractMonth, + goldmanRollPrice, + currentBusinessDay, + interpolatedGoldmanRollPrice, ) if err != nil { @@ -77,8 +83,8 @@ func TestData(t *testing.T) { 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.ExpiryTime != expiryTime { + t.Errorf("ExpiryTime mismatch: expected %s, got %s", expiryTime, d.ExpiryTime) } if d.FirstDayOfNotice.UnixNano() != int64(firstDayOfNotice) { t.Errorf("FirstDayOfNotice mismatch: expected %d, got %d", firstDayOfNotice, d.FirstDayOfNotice.UnixNano()) @@ -90,15 +96,24 @@ func TestData(t *testing.T) { 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) + t.Errorf("ContractMonth mismatch: expected %d, got %d", contractMonth, d.ContractMonth) + } + if d.GoldmanRollPrice.Cmp(goldmanRollPrice) != 0 { + t.Errorf("GoldmanRollPrice mismatch: expected %v, got %v", goldmanRollPrice, d.GoldmanRollPrice) + } + if d.CurrentBusinessDay != currentBusinessDay { + t.Errorf("CurrentBusinessDay mismatch: expected %d, got %d", currentBusinessDay, d.CurrentBusinessDay) + } + if d.InterpolatedGoldmanRollPrice.Cmp(interpolatedGoldmanRollPrice) != 0 { + t.Errorf("InterpolatedGoldmanRollPrice mismatch: expected %v, got %v", interpolatedGoldmanRollPrice, d.InterpolatedGoldmanRollPrice) } } 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"} { + // Values that are outside the valid 1..12 range must be rejected. + for _, cm := range []uint32{0, 13, 100, ^uint32(0)} { b, err := schema.Pack( feedID, uint64(time.Now().Unix()), @@ -109,18 +124,55 @@ func TestDecodeInvalidContractMonth(t *testing.T) { big.NewInt(100), big.NewInt(99), big.NewInt(101), - uint64(time.Now().UnixNano()), + "2026-09-22", uint64(time.Now().UnixNano()), uint64(time.Now().UnixNano()), uint32(1), cm, + big.NewInt(102), + uint32(3), + big.NewInt(103), + ) + if err != nil { + t.Fatalf("failed to serialize report: %s", err) + } + + if _, err := Decode(b); err == nil { + t.Errorf("expected error decoding contractMonth %d, got nil", cm) + } + } +} + +func TestDecodeInvalidExpiryTime(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 not a valid YYYY-MM-DD calendar date must be rejected. + for _, et := range []string{"", "2026-9-22", "22-09-2026", "2026/09/22", "2026-13-01", "2026-09-31", "not-a-date", "2026-09-22T00:00:00Z"} { + 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), + et, + uint64(time.Now().UnixNano()), + uint64(time.Now().UnixNano()), + uint32(1), + uint32(7), + big.NewInt(102), + uint32(3), + big.NewInt(103), ) 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) + t.Errorf("expected error decoding expiryTime %q, got nil", et) } } } diff --git a/rust/crates/report/src/report.rs b/rust/crates/report/src/report.rs index c73b96d..97b71c9 100644 --- a/rust/crates/report/src/report.rs +++ b/rust/crates/report/src/report.rs @@ -206,9 +206,12 @@ 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_EXPIRY_TIME: &str = "2026-09-22"; pub const MOCK_FIRST_DAY_OF_NOTICE: u64 = 1718885822000000000; - pub const MOCK_CONTRACT_MONTH: &str = "F"; + pub const MOCK_CONTRACT_MONTH: u32 = 1; + pub const MOCK_GOLDMAN_ROLL_PRICE: isize = 230; + pub const MOCK_CURRENT_BUSINESS_DAY: u32 = 3; + pub const MOCK_INTERPOLATED_GOLDMAN_ROLL_PRICE: isize = 231; pub fn generate_mock_report_data_v1() -> ReportDataV1 { let report_data = ReportDataV1 { @@ -470,11 +473,18 @@ mod tests { 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, + expiry_time: MOCK_EXPIRY_TIME.to_string(), 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(), + contract_month: MOCK_CONTRACT_MONTH, + goldman_roll_price: BigInt::from(MOCK_GOLDMAN_ROLL_PRICE) + .checked_mul(&multiplier) + .unwrap(), + current_business_day: MOCK_CURRENT_BUSINESS_DAY, + interpolated_goldman_roll_price: BigInt::from(MOCK_INTERPOLATED_GOLDMAN_ROLL_PRICE) + .checked_mul(&multiplier) + .unwrap(), }; report_data @@ -942,7 +952,7 @@ mod tests { let decoded_report = ReportDataV14::decode(&report_blob).unwrap(); - // V14 carries a dynamic `contractMonth` string, so assert the decoded values + // V14 carries a dynamic `expiryTime` 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); @@ -956,5 +966,23 @@ mod tests { ); assert_eq!(decoded_report.market_status, MOCK_MARKET_STATUS); assert_eq!(decoded_report.contract_month, MOCK_CONTRACT_MONTH); + + let multiplier: BigInt = "1000000000000000000".parse::().unwrap(); // 1.0 with 18 decimals + assert_eq!( + decoded_report.goldman_roll_price, + BigInt::from(MOCK_GOLDMAN_ROLL_PRICE) + .checked_mul(&multiplier) + .unwrap() + ); + assert_eq!( + decoded_report.current_business_day, + MOCK_CURRENT_BUSINESS_DAY + ); + assert_eq!( + decoded_report.interpolated_goldman_roll_price, + BigInt::from(MOCK_INTERPOLATED_GOLDMAN_ROLL_PRICE) + .checked_mul(&"1000000000000000000".parse::().unwrap()) + .unwrap() + ); } } diff --git a/rust/crates/report/src/report/v14.rs b/rust/crates/report/src/report/v14.rs index 71c55c8..7f4428b 100644 --- a/rust/crates/report/src/report/v14.rs +++ b/rust/crates/report/src/report/v14.rs @@ -3,6 +3,41 @@ use crate::report::base::{ReportBase, ReportError}; use num_bigint::BigInt; +/// Returns whether `value` is a valid calendar date formatted as `YYYY-MM-DD`. +/// +/// Rejects anything that is not exactly ten ASCII characters in that shape, as well as +/// month/day combinations that do not exist (including Feb 29 in non-leap years). +fn is_valid_iso_date(value: &str) -> bool { + let b = value.as_bytes(); + if b.len() != 10 || b[4] != b'-' || b[7] != b'-' { + return false; + } + if !b + .iter() + .enumerate() + .all(|(i, c)| matches!(i, 4 | 7) || c.is_ascii_digit()) + { + return false; + } + + let num = |s: &str| s.parse::().unwrap_or(0); + let (year, month, day) = (num(&value[0..4]), num(&value[5..7]), num(&value[8..10])); + + if !(1..=12).contains(&month) || day < 1 { + return false; + } + + let is_leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + let days_in_month = match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + _ if is_leap => 29, + _ => 28, + }; + + day <= days_in_month +} + /// Represents a Report Data V14 Schema (Continuous Commodities Futures). /// /// This schema provides mid/bid/ask pricing alongside futures contract metadata such as @@ -18,11 +53,14 @@ use num_bigint::BigInt; /// - `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. +/// - `expiry_time`: Contract expiry date, formatted as `YYYY-MM-DD`, e.g. `"2026-09-22"`. /// - `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. +/// - `contract_month`: The contract month, from `1` (Jan) to `12` (Dec). +/// - `goldman_roll_price`: The Goldman roll price (18 decimal precision). +/// - `current_business_day`: The current business day, numbered. +/// - `interpolated_goldman_roll_price`: The interpolated Goldman roll price (18 decimal precision). /// /// # Solidity Equivalent /// ```solidity @@ -36,11 +74,14 @@ use num_bigint::BigInt; /// int192 midPrice; /// int192 bidPrice; /// int192 askPrice; -/// uint64 expiryTime; +/// string expiryTime; /// uint64 firstDayOfNotice; /// uint64 lastSeenTimestampNs; /// uint32 marketStatus; -/// string contractMonth; +/// uint32 contractMonth; +/// int192 goldmanRollPrice; +/// uint32 currentBusinessDay; +/// int192 interpolatedGoldmanRollPrice; /// } /// ``` #[derive(Debug)] @@ -54,17 +95,20 @@ pub struct ReportDataV14 { pub mid_price: BigInt, pub bid_price: BigInt, pub ask_price: BigInt, - pub expiry_time: u64, + pub expiry_time: String, pub first_day_of_notice: u64, pub last_seen_timestamp_ns: u64, pub market_status: u32, - pub contract_month: String, + pub contract_month: u32, + pub goldman_roll_price: BigInt, + pub current_business_day: u32, + pub interpolated_goldman_roll_price: BigInt, } 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; + /// Number of 32-byte head words: 16 static fields plus one offset word for the + /// dynamic `expiryTime` string. + const HEAD_WORDS: usize = 17; /// Decodes an ABI-encoded `ReportDataV14` from bytes. /// @@ -96,18 +140,26 @@ impl ReportDataV14 { 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 expiry_time = ReportBase::read_string(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)?; + let contract_month = ReportBase::read_uint32(data, 13 * ReportBase::WORD_SIZE)?; + let goldman_roll_price = ReportBase::read_int192(data, 14 * ReportBase::WORD_SIZE)?; + let current_business_day = ReportBase::read_uint32(data, 15 * ReportBase::WORD_SIZE)?; + let interpolated_goldman_roll_price = + ReportBase::read_int192(data, 16 * 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]) { + // contract_month must be a number from 1 (Jan) to 12 (Dec). + if !(1..=12).contains(&contract_month) { return Err(ReportError::InvalidValue("contract_month")); } + // expiry_time must be a valid calendar date formatted as YYYY-MM-DD. + if !is_valid_iso_date(&expiry_time) { + return Err(ReportError::InvalidValue("expiry_time")); + } + Ok(Self { feed_id, valid_from_timestamp, @@ -123,6 +175,9 @@ impl ReportDataV14 { last_seen_timestamp_ns, market_status, contract_month, + goldman_roll_price, + current_business_day, + interpolated_goldman_roll_price, }) } @@ -138,7 +193,7 @@ impl ReportDataV14 { pub fn abi_encode(&self) -> Result, ReportError> { let mut buffer = Vec::with_capacity((Self::HEAD_WORDS + 2) * ReportBase::WORD_SIZE); - // Head: 13 static fields. + // Head: the 9 static fields preceding the dynamic `expiryTime` string. 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)?); @@ -148,17 +203,24 @@ impl ReportDataV14 { 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. + // Head: offset word pointing to the dynamic `expiryTime` 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)); + // Head: the static fields following the dynamic `expiryTime` string. + 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)?); + buffer.extend_from_slice(&ReportBase::encode_uint32(self.contract_month)?); + buffer.extend_from_slice(&ReportBase::encode_int192(&self.goldman_roll_price)?); + buffer.extend_from_slice(&ReportBase::encode_uint32(self.current_business_day)?); + buffer.extend_from_slice(&ReportBase::encode_int192( + &self.interpolated_goldman_roll_price, + )?); + + // Tail: the dynamic `expiryTime` string. + buffer.extend_from_slice(&ReportBase::encode_string_tail(&self.expiry_time)); Ok(buffer) } @@ -168,9 +230,10 @@ impl ReportDataV14 { 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, + generate_mock_report_data_v14, MARKET_STATUS_OPEN, MOCK_ASK, MOCK_BID, MOCK_CONTRACT_MONTH, + MOCK_CURRENT_BUSINESS_DAY, MOCK_EXPIRY_TIME, MOCK_FEE, MOCK_FIRST_DAY_OF_NOTICE, + MOCK_GOLDMAN_ROLL_PRICE, MOCK_INTERPOLATED_GOLDMAN_ROLL_PRICE, MOCK_LAST_SEEN_TIMESTAMP_NS, + MOCK_MID, MOCK_TIMESTAMP, }; const V14_FEED_ID_STR: &str = @@ -205,14 +268,27 @@ mod tests { 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); + assert_eq!( + decoded.goldman_roll_price, + BigInt::from(MOCK_GOLDMAN_ROLL_PRICE) + .checked_mul(&multiplier) + .unwrap() + ); + assert_eq!(decoded.current_business_day, MOCK_CURRENT_BUSINESS_DAY); + assert_eq!( + decoded.interpolated_goldman_roll_price, + BigInt::from(MOCK_INTERPOLATED_GOLDMAN_ROLL_PRICE) + .checked_mul(&multiplier) + .unwrap() + ); } #[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"] { + // Values outside the valid 1..=12 range must be rejected. + for invalid in [0, 13, 100, u32::MAX] { let mut report_data = generate_mock_report_data_v14(); - report_data.contract_month = invalid.to_string(); + report_data.contract_month = invalid; let encoded = report_data.abi_encode().unwrap(); let result = ReportDataV14::decode(&encoded); @@ -224,4 +300,58 @@ mod tests { ); } } + + #[test] + fn test_decode_report_data_v14_invalid_expiry_time() { + // Values that are not a valid YYYY-MM-DD calendar date must be rejected. + for invalid in [ + "", + "2026-9-22", + "22-09-2026", + "2026/09/22", + "2026-13-01", + "2026-00-01", + "2026-09-00", + "2026-09-31", + "2026-02-29", // 2026 is not a leap year + "not-a-date", + "2026-09-22T00:00:00Z", + ] { + let mut report_data = generate_mock_report_data_v14(); + report_data.expiry_time = invalid.to_string(); + let encoded = report_data.abi_encode().unwrap(); + + let result = ReportDataV14::decode(&encoded); + assert!( + matches!(result, Err(ReportError::InvalidValue("expiry_time"))), + "expected InvalidValue error for expiry_time {:?}, got {:?}", + invalid, + result + ); + } + } + + #[test] + fn test_valid_iso_dates_are_accepted() { + // Leap-year and month-length boundaries that must be accepted. + for valid in [ + "2026-09-22", + "2024-02-29", // leap year + "2000-02-29", // divisible by 400 + "2026-01-31", + "2026-04-30", + "2026-12-31", + ] { + let mut report_data = generate_mock_report_data_v14(); + report_data.expiry_time = valid.to_string(); + let encoded = report_data.abi_encode().unwrap(); + + let decoded = ReportDataV14::decode(&encoded) + .unwrap_or_else(|e| panic!("expected {:?} to decode, got {:?}", valid, e)); + assert_eq!(decoded.expiry_time, valid); + } + + // 1900 is divisible by 100 but not 400, so it is not a leap year. + assert!(!is_valid_iso_date("1900-02-29")); + } } diff --git a/typescript/README.md b/typescript/README.md index 82b9fc8..9e5b376 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -282,6 +282,7 @@ interface BaseFields { - **V11**: `mid: bigint, LastSeenTimestampNs: number, bid: bigint, vidVolume: number, ask: bigint, askVolume: number, lastTradedPrice: bigint, marketStatus: MarketStatus` - Deutsche Boerse - **V12**: `navPerShare: bigint, nextNavPerShare: bigint, navDate: number, ripcord: number` - NAV fund data + Next - **V13**: `bestAsk: bigint, bestBid: bigint, askVolume: number, bidVolume: number, lastTradedPrice: bigint` - Best Bid/Ask +- **V14**: `midPrice: bigint, bidPrice: bigint, askPrice: bigint, expiryTime: string, firstDayOfNotice: bigint, lastSeenTimestampNs: bigint, marketStatus: MarketStatus, contractMonth: number, goldmanRollPrice: bigint, currentBusinessDay: number, interpolatedGoldmanRollPrice: bigint` - Continuous Commodities Futures For complete field definitions, see the [documentation](https://docs.chain.link/data-streams/reference/report-schema-v3). diff --git a/typescript/src/decoder/implementation.ts b/typescript/src/decoder/implementation.ts index 3bf2e40..d995501 100644 --- a/typescript/src/decoder/implementation.ts +++ b/typescript/src/decoder/implementation.ts @@ -23,6 +23,29 @@ import { SDKLogger } from "../utils/logger"; const globalAbiCoder = new AbiCoder(); const outerReportAbiCoder = new AbiCoder(); +/** + * Check whether a value is a valid calendar date formatted as YYYY-MM-DD. + * + * Rejects anything not in that exact shape, as well as month/day combinations + * that do not exist (including Feb 29 in non-leap years). + */ +function isValidIsoDate(value: string): boolean { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!match) { + return false; + } + + const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])]; + if (month < 1 || month > 12 || day < 1) { + return false; + } + + const isLeap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const daysInMonth = [31, isLeap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + + return day <= daysInMonth[month - 1]; +} + const reportSchemaV2 = [ { type: "bytes32", name: "feedId" }, { type: "uint32", name: "validFromTimestamp" }, @@ -187,11 +210,14 @@ const reportSchemaV14 = [ { type: "int192", name: "midPrice" }, { type: "int192", name: "bidPrice" }, { type: "int192", name: "askPrice" }, - { type: "uint64", name: "expiryTime" }, + { type: "string", name: "expiryTime" }, { type: "uint64", name: "firstDayOfNotice" }, { type: "uint64", name: "lastSeenTimestampNs" }, { type: "uint32", name: "marketStatus" }, - { type: "string", name: "contractMonth" }, + { type: "uint32", name: "contractMonth" }, + { type: "int192", name: "goldmanRollPrice" }, + { type: "uint32", name: "currentBusinessDay" }, + { type: "int192", name: "interpolatedGoldmanRollPrice" }, ]; /** @@ -615,11 +641,19 @@ function decodeV14Report(reportBlob: string): DecodedV14Report { 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)) { + // contractMonth must be a number from 1 (Jan) to 12 (Dec). + const contractMonth = Number(decoded[13]); + if (!Number.isInteger(contractMonth) || contractMonth < 1 || contractMonth > 12) { + throw new ReportDecodingError( + `Invalid contract month: ${contractMonth}. Must be a number from 1 to 12` + ); + } + + // expiryTime must be a valid calendar date formatted as YYYY-MM-DD. + const expiryTime = decoded[9]; + if (typeof expiryTime !== "string" || !isValidIsoDate(expiryTime)) { throw new ReportDecodingError( - `Invalid contract month: ${contractMonth}. Must be a single letter from F to Z` + `Invalid expiry time: ${expiryTime}. Must be a date formatted as YYYY-MM-DD` ); } @@ -631,11 +665,14 @@ function decodeV14Report(reportBlob: string): DecodedV14Report { midPrice: decoded[6], bidPrice: decoded[7], askPrice: decoded[8], - expiryTime: decoded[9], + expiryTime, firstDayOfNotice: decoded[10], lastSeenTimestampNs: decoded[11], marketStatus: Number(decoded[12]), contractMonth, + goldmanRollPrice: decoded[14], + currentBusinessDay: Number(decoded[15]), + interpolatedGoldmanRollPrice: decoded[16], }; } catch (error) { throw new ReportDecodingError( diff --git a/typescript/src/types/report.ts b/typescript/src/types/report.ts index b3744d8..df2ff6b 100644 --- a/typescript/src/types/report.ts +++ b/typescript/src/types/report.ts @@ -221,7 +221,8 @@ export interface DecodedV13Report extends DecodedReportFields { * 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. + * the expiry time, first day of notice, contract month, Goldman roll prices + * and current business day. */ export interface DecodedV14Report extends DecodedReportFields { /** Report format version identifier */ @@ -229,16 +230,22 @@ export interface DecodedV14Report extends DecodedReportFields { midPrice: bigint; bidPrice: bigint; askPrice: bigint; - /** Contract expiry time in nanoseconds */ - expiryTime: bigint; + /** Contract expiry date, formatted as YYYY-MM-DD, e.g. "2026-09-22" */ + expiryTime: string; /** 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; + /** The contract month, from 1 (Jan) to 12 (Dec) */ + contractMonth: number; + /** The Goldman roll price (18 decimal precision) */ + goldmanRollPrice: bigint; + /** The current business day, numbered */ + currentBusinessDay: number; + /** The interpolated Goldman roll price (18 decimal precision) */ + interpolatedGoldmanRollPrice: bigint; } /** diff --git a/typescript/src/utils/report.ts b/typescript/src/utils/report.ts index 4e77b71..34c1810 100644 --- a/typescript/src/utils/report.ts +++ b/typescript/src/utils/report.ts @@ -180,11 +180,14 @@ export function formatReport( 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 += `Expiry Time: ${r.expiryTime}\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`; + output += `Contract Month: ${r.contractMonth.toString()}\n`; + output += `Goldman Roll Price: ${r.goldmanRollPrice.toString()}\n`; + output += `Current Business Day: ${r.currentBusinessDay.toString()}\n`; + output += `Interpolated Goldman Roll Price: ${r.interpolatedGoldmanRollPrice.toString()}\n`; break; } } diff --git a/typescript/tests/unit/decoder/decoder.test.ts b/typescript/tests/unit/decoder/decoder.test.ts index 99eb51d..1de4f59 100644 --- a/typescript/tests/unit/decoder/decoder.test.ts +++ b/typescript/tests/unit/decoder/decoder.test.ts @@ -312,11 +312,14 @@ const mockV14ReportBlob = abiCoder.encode( "int192", // mid price "int192", // bid price "int192", // ask price - "uint64", // expiry time (ns) + "string", // expiry time (YYYY-MM-DD) "uint64", // first day of notice (ns) "uint64", // last seen timestamp (ns) "uint32", // market status - "string", // contract month + "uint32", // contract month + "int192", // goldman roll price + "uint32", // current business day + "int192", // interpolated goldman roll price ], [ mockV14FeedId, @@ -328,11 +331,14 @@ const mockV14ReportBlob = abiCoder.encode( 100000000000000000000n, // mid price $100 99000000000000000000n, // bid price $99 101000000000000000000n, // ask price $101 - 1700000010000000000n, // expiry time (ns) + "2026-09-22", // expiry time 1700000005000000000n, // first day of notice (ns) 1700000000000000000n, // last seen timestamp (ns) 2, // market status (open) - "F", // contract month (Jan) + 1, // contract month (Jan) + 102000000000000000000n, // goldman roll price $102 + 3, // current business day + 103000000000000000000n, // interpolated goldman roll price $103 ] ); @@ -984,6 +990,9 @@ describe("Report Decoder", () => { expect(decoded.lastSeenTimestampNs).toBeDefined(); expect(decoded.marketStatus).toBeDefined(); expect(decoded.contractMonth).toBeDefined(); + expect(decoded.goldmanRollPrice).toBeDefined(); + expect(decoded.currentBusinessDay).toBeDefined(); + expect(decoded.interpolatedGoldmanRollPrice).toBeDefined(); }); it("should handle malformed v14 report", () => { @@ -998,25 +1007,32 @@ describe("Report Decoder", () => { 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.expiryTime).toBe("string"); expect(typeof decoded.firstDayOfNotice).toBe("bigint"); expect(typeof decoded.lastSeenTimestampNs).toBe("bigint"); expect(typeof decoded.marketStatus).toBe("number"); - expect(typeof decoded.contractMonth).toBe("string"); + expect(typeof decoded.contractMonth).toBe("number"); + expect(typeof decoded.goldmanRollPrice).toBe("bigint"); + expect(typeof decoded.currentBusinessDay).toBe("number"); + expect(typeof decoded.interpolatedGoldmanRollPrice).toBe("bigint"); // 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.expiryTime).toBe("2026-09-22"); expect(decoded.firstDayOfNotice).toBe(1700000005000000000n); expect(decoded.lastSeenTimestampNs).toBe(1700000000000000000n); expect(decoded.marketStatus).toBe(2); - expect(decoded.contractMonth).toBe("F"); + expect(decoded.contractMonth).toBe(1); + expect(decoded.goldmanRollPrice).toBe(102000000000000000000n); + expect(decoded.currentBusinessDay).toBe(3); + expect(decoded.interpolatedGoldmanRollPrice).toBe(103000000000000000000n); }); - it("should reject an invalid contract month", () => { - const invalidBlob = abiCoder.encode( + // Build a full V14 report, overriding expiryTime and/or contractMonth. + const buildV14FullReport = (expiryTime: string, contractMonth: number) => { + const blob = abiCoder.encode( [ "bytes32", "uint32", @@ -1027,11 +1043,14 @@ describe("Report Decoder", () => { "int192", "int192", "int192", - "uint64", + "string", "uint64", "uint64", "uint32", - "string", + "uint32", + "int192", + "uint32", + "int192", ], [ mockV14FeedId, @@ -1043,25 +1062,64 @@ describe("Report Decoder", () => { 100000000000000000000n, 99000000000000000000n, 101000000000000000000n, - 1700000010000000000n, + expiryTime, 1700000005000000000n, 1700000000000000000n, 2, - "A", // outside the valid F..Z range + contractMonth, + 102000000000000000000n, + 3, + 103000000000000000000n, ] ); - const invalidFullReport = abiCoder.encode( + return abiCoder.encode( ["bytes32[3]", "bytes", "bytes32[]", "bytes32[]", "bytes32"], [ mockReportContext, - invalidBlob, + blob, ["0x0000000000000000000000000000000000000000000000000000000000000013"], ["0x0000000000000000000000000000000000000000000000000000000000000014"], "0x0000000000000000000000000000000000000000000000000000000000000015", ] ); + }; + + it.each([0, 13, 100, 4294967295])( + "should reject contract month %s, outside the valid 1-12 range", + month => { + const report = buildV14FullReport("2026-09-22", month); + expect(() => decodeReport(report, mockV14FeedId)).toThrow("Invalid contract month"); + } + ); + + it.each([ + "", + "2026-9-22", + "22-09-2026", + "2026/09/22", + "2026-13-01", + "2026-00-01", + "2026-09-00", + "2026-09-31", + "2026-02-29", // 2026 is not a leap year + "not-a-date", + "2026-09-22T00:00:00Z", + ])("should reject expiry time %s, which is not a valid YYYY-MM-DD date", expiryTime => { + const report = buildV14FullReport(expiryTime, 1); + expect(() => decodeReport(report, mockV14FeedId)).toThrow("Invalid expiry time"); + }); - expect(() => decodeReport(invalidFullReport, mockV14FeedId)).toThrow("Invalid contract month"); + it.each([ + "2026-09-22", + "2024-02-29", // leap year + "2000-02-29", // divisible by 400 + "2026-01-31", + "2026-04-30", + "2026-12-31", + ])("should accept valid expiry time %s", expiryTime => { + const report = buildV14FullReport(expiryTime, 1); + const decoded = decodeReport(report, mockV14FeedId) as DecodedV14Report; + expect(decoded.expiryTime).toBe(expiryTime); }); });