diff --git a/rust/Cargo.lock b/rust/Cargo.lock index a9cccfd..e7a15da 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -127,7 +127,7 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chainlink-data-streams-report" -version = "1.0.3" +version = "1.0.4" dependencies = [ "hex", "num-bigint", @@ -139,7 +139,7 @@ dependencies = [ [[package]] name = "chainlink-data-streams-sdk" -version = "1.0.3" +version = "1.0.4" dependencies = [ "byteorder", "chainlink-data-streams-report", diff --git a/rust/crates/report/Cargo.toml b/rust/crates/report/Cargo.toml index df9046d..af5cac8 100644 --- a/rust/crates/report/Cargo.toml +++ b/rust/crates/report/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "chainlink-data-streams-report" -version = "1.0.3" +version = "1.0.4" edition = "2021" description = "Chainlink Data Streams Report" license = "MIT" diff --git a/rust/crates/report/src/report.rs b/rust/crates/report/src/report.rs index 0ffb49a..689d8cb 100644 --- a/rust/crates/report/src/report.rs +++ b/rust/crates/report/src/report.rs @@ -10,6 +10,7 @@ pub mod v7; pub mod v8; pub mod v9; pub mod v10; +pub mod v12; use base::{ReportBase, ReportError}; @@ -123,7 +124,7 @@ pub fn decode_full_report(payload: &[u8]) -> Result<(Vec<[u8; 32]>, Vec), Re #[cfg(test)] mod tests { use super::*; - use crate::report::{v1::ReportDataV1, v2::ReportDataV2, v3::ReportDataV3, v4::ReportDataV4, v5::ReportDataV5, v6::ReportDataV6, v7::ReportDataV7, v8::ReportDataV8, v9::ReportDataV9, v10::ReportDataV10}; + use crate::report::{v1::ReportDataV1, v2::ReportDataV2, v3::ReportDataV3, v4::ReportDataV4, v5::ReportDataV5, v6::ReportDataV6, v7::ReportDataV7, v8::ReportDataV8, v9::ReportDataV9, v10::ReportDataV10, v12::ReportDataV12}; use num_bigint::BigInt; const V1_FEED_ID: ID = ID([ @@ -166,6 +167,10 @@ mod tests { 00, 10, 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, ]); + const V12_FEED_ID: ID = ID([ + 00, 12, 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_FEE: usize = 10; @@ -347,6 +352,27 @@ mod tests { report_data } + pub fn generate_mock_report_data_v12() -> ReportDataV12 { + const MOCK_NAV_PER_SHARE: isize = 1; + const MOCK_NEXT_NAV_PER_SHARE: isize = 2; + const RIPCORD_NORMAL: u32 = 0; + + let report_data = ReportDataV12 { + feed_id: V12_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, + nav_per_share: BigInt::from(MOCK_NAV_PER_SHARE), + next_nav_per_share: BigInt::from(MOCK_NEXT_NAV_PER_SHARE), + nav_date: MOCK_TIMESTAMP as i64, + ripcord: RIPCORD_NORMAL, + }; + + report_data + } + fn generate_mock_report(encoded_report_data: &[u8]) -> Vec { let mut payload = Vec::new(); @@ -696,4 +722,36 @@ mod tests { assert_eq!(decoded_report.feed_id, V10_FEED_ID); } + + #[test] + fn test_decode_report_v12() { + let report_data = generate_mock_report_data_v12(); + 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 expected_report_blob = vec![ + "000c6b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472", + "0000000000000000000000000000000000000000000000000000000066741d8c", + "0000000000000000000000000000000000000000000000000000000066741d8c", + "000000000000000000000000000000000000000000000000000000000000000a", + "000000000000000000000000000000000000000000000000000000000000000a", + "0000000000000000000000000000000000000000000000000000000066741df0", + "0000000000000000000000000000000000000000000000000000000000000001", // NAV per share + "0000000000000000000000000000000000000000000000000000000000000002", // Next NAV per share + "0000000000000000000000000000000000000000000000000000000066741d8c", // NAV date + "0000000000000000000000000000000000000000000000000000000000000000", // Ripcord: Normal + ]; + + assert_eq!( + report_blob, + bytes(&format!("0x{}", expected_report_blob.join(""))) + ); + + let decoded_report = ReportDataV12::decode(&report_blob).unwrap(); + + assert_eq!(decoded_report.feed_id, V12_FEED_ID); + } } diff --git a/rust/crates/report/src/report/base.rs b/rust/crates/report/src/report/base.rs index 5ad7923..77cbda3 100644 --- a/rust/crates/report/src/report/base.rs +++ b/rust/crates/report/src/report/base.rs @@ -64,9 +64,9 @@ impl ReportBase { if offset + Self::WORD_SIZE > data.len() { return Err(ReportError::DataTooShort("uint32")); } - let value_bytes = &data[offset + 28..offset + 32]; + let value_bytes = &data[offset..offset + Self::WORD_SIZE]; Ok(u32::from_be_bytes( - value_bytes + value_bytes[28..32] .try_into() .map_err(|_| ReportError::InvalidLength("uint32"))?, )) @@ -75,7 +75,13 @@ impl ReportBase { pub(crate) fn encode_uint32(value: u32) -> Result<[u8; 32], ReportError> { let mut buffer = [0u8; 32]; let bytes_value = value.to_be_bytes(); - buffer[28..32].copy_from_slice(&bytes_value); // Place at the end of the 32 bytes word + let len = bytes_value.len(); + + if len > 4 { + return Err(ReportError::InvalidLength("uint32")); + } + + buffer[32 - len..32].copy_from_slice(&bytes_value); Ok(buffer) } @@ -83,9 +89,9 @@ impl ReportBase { if offset + Self::WORD_SIZE > data.len() { return Err(ReportError::DataTooShort("uint64")); } - let value_bytes = &data[offset + 24..offset + 32]; + let value_bytes = &data[offset..offset + Self::WORD_SIZE]; Ok(u64::from_be_bytes( - value_bytes + value_bytes[24..32] .try_into() .map_err(|_| ReportError::InvalidLength("uint64"))?, )) @@ -94,7 +100,38 @@ impl ReportBase { pub(crate) fn encode_uint64(value: u64) -> Result<[u8; 32], ReportError> { let mut buffer = [0u8; 32]; let bytes_value = value.to_be_bytes(); - buffer[24..32].copy_from_slice(&bytes_value); // Place at the end of the 32 bytes word + let len = bytes_value.len(); + + if len > 8 { + return Err(ReportError::InvalidLength("uint64")); + } + + buffer[32 - len..32].copy_from_slice(&bytes_value); + Ok(buffer) + } + + pub(crate) fn read_int64(data: &[u8], offset: usize) -> Result { + if offset + Self::WORD_SIZE > data.len() { + return Err(ReportError::DataTooShort("int64")); + } + let value_bytes = &data[offset..offset + Self::WORD_SIZE]; + Ok(i64::from_be_bytes( + value_bytes[24..32] + .try_into() + .map_err(|_| ReportError::InvalidLength("int64"))?, + )) + } + + pub(crate) fn encode_int64(value: i64) -> Result<[u8; 32], ReportError> { + let mut buffer = [0u8; 32]; + let bytes_value = value.to_be_bytes(); + let len = bytes_value.len(); + + if len > 8 { + return Err(ReportError::InvalidLength("int64")); + } + + buffer[32 - len..32].copy_from_slice(&bytes_value); Ok(buffer) } } diff --git a/rust/crates/report/src/report/v12.rs b/rust/crates/report/src/report/v12.rs new file mode 100644 index 0000000..10e1395 --- /dev/null +++ b/rust/crates/report/src/report/v12.rs @@ -0,0 +1,165 @@ +use crate::feed_id::ID; +use crate::report::base::{ReportBase, ReportError}; +use num_bigint::BigInt; + +/// Represents a Report Data V12 Schema. +/// +/// # Parameters +/// - `feed_id`: Unique identifier for the Data Streams feed. +/// - `valid_from_timestamp`: Earliest timestamp when the price is valid (seconds). +/// - `observations_timestamp`: Latest timestamp when the price is valid (seconds). +/// - `native_fee`: Verification cost in native blockchain tokens. +/// - `link_fee`: Verification cost in LINK tokens. +/// - `expires_at`: Expiration date of the report (seconds). +/// - `nav_per_share`: DON consensus NAV Per Share value as reported by the Fund Manager. +/// - `next_nav_per_share`: DON consensus next NAV Per Share value as reported by the Fund Manager. +/// - `nav_date`: Timestamp for the publication date of the NAV Report (nanoseconds). +/// - `ripcord`: Whether the provider paused NAV reporting. +/// +/// # Ripcord Flag +/// - `0` (false): Feed's data provider is OK. Fund's data provider and accuracy is as expected. +/// - `1` (true): Feed's data provider is flagging a pause. Data provider detected outliers, +/// deviated thresholds, or operational issues. **DO NOT consume NAV data when ripcord=1.** +/// +/// # Solidity Equivalent +/// ```solidity +/// struct ReportDataV12 { +/// bytes32 feedId; +/// uint32 validFromTimestamp; +/// uint32 observationsTimestamp; +/// uint192 nativeFee; +/// uint192 linkFee; +/// uint32 expiresAt; +/// int192 navPerShare; +/// int192 nextNavPerShare; +/// int64 navDate; +/// uint32 ripcord; +/// } +/// ``` +#[derive(Debug)] +pub struct ReportDataV12 { + 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 nav_per_share: BigInt, + pub next_nav_per_share: BigInt, + pub nav_date: i64, + pub ripcord: u32, +} + +impl ReportDataV12 { + /// Decodes an ABI-encoded `ReportDataV12` from bytes. + /// + /// # Parameters + /// + /// - `data`: The encoded report data. + /// + /// # Returns + /// + /// The decoded `ReportDataV12`. + /// + /// # 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() < 10 * ReportBase::WORD_SIZE { + return Err(ReportError::DataTooShort("ReportDataV12")); + } + + 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 nav_per_share = ReportBase::read_int192(data, 6 * ReportBase::WORD_SIZE)?; + let next_nav_per_share = ReportBase::read_int192(data, 7 * ReportBase::WORD_SIZE)?; + let nav_date = ReportBase::read_int64(data, 8 * ReportBase::WORD_SIZE)?; + let ripcord = ReportBase::read_uint32(data, 9 * ReportBase::WORD_SIZE)?; + + Ok(Self { + feed_id, + valid_from_timestamp, + observations_timestamp, + native_fee, + link_fee, + expires_at, + nav_per_share, + next_nav_per_share, + nav_date, + ripcord, + }) + } + + /// Encodes the `ReportDataV12` 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(10 * ReportBase::WORD_SIZE); + + 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.nav_per_share)?); + buffer.extend_from_slice(&ReportBase::encode_int192(&self.next_nav_per_share)?); + buffer.extend_from_slice(&ReportBase::encode_int64(self.nav_date)?); + buffer.extend_from_slice(&ReportBase::encode_uint32(self.ripcord)?); + + Ok(buffer) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::report::tests::{ + generate_mock_report_data_v12, MOCK_FEE, MOCK_TIMESTAMP, + }; + + const V12_FEED_ID_STR: &str = + "0x000c6b4aa7e57ca7b68ae1bf45653f56b656fd3aa335ef7fae696b663f1b8472"; + + const MOCK_NAV_PER_SHARE: isize = 1; + const MOCK_NEXT_NAV_PER_SHARE: isize = 2; + const RIPCORD_NORMAL: u32 = 0; + + #[test] + fn test_decode_report_data_v12() { + let report_data = generate_mock_report_data_v12(); + let encoded = report_data.abi_encode().unwrap(); + let decoded = ReportDataV12::decode(&encoded).unwrap(); + + let expected_feed_id = ID::from_hex_str(V12_FEED_ID_STR).unwrap(); + let expected_timestamp: u32 = MOCK_TIMESTAMP; + let expected_fee = BigInt::from(MOCK_FEE); + let expected_nav_per_share = BigInt::from(MOCK_NAV_PER_SHARE); + let expected_next_nav_per_share = BigInt::from(MOCK_NEXT_NAV_PER_SHARE); + let expected_ripcord = RIPCORD_NORMAL; + + 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.nav_per_share, expected_nav_per_share); + assert_eq!(decoded.next_nav_per_share, expected_next_nav_per_share); + assert_eq!(decoded.nav_date, expected_timestamp as i64); + assert_eq!(decoded.ripcord, expected_ripcord); + } +} diff --git a/rust/crates/sdk/Cargo.toml b/rust/crates/sdk/Cargo.toml index b320254..7ff0abb 100644 --- a/rust/crates/sdk/Cargo.toml +++ b/rust/crates/sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "chainlink-data-streams-sdk" -version = "1.0.3" +version = "1.0.4" edition = "2021" rust-version = "1.70" description = "Chainlink Data Streams client SDK" @@ -11,7 +11,7 @@ exclude = ["/target/*", "examples/*", "tests/*", "docs/*", "book/*"] keywords = ["chainlink"] [dependencies] -chainlink-data-streams-report = { path = "../report", version = "1.0.3" } +chainlink-data-streams-report = { path = "../report", version = "1.0.4" } reqwest = { version = "0.11.20", features = ["json", "rustls-tls"] } tokio = { version = "1.29.1", features = ["full"] } tokio-tungstenite = { version = "0.20.1", features = [