From 438a2062ca26fbb480fd9ef6ac0dfdcab66b9561 Mon Sep 17 00:00:00 2001 From: Tejas Badadare Date: Mon, 6 Oct 2025 16:50:38 -0700 Subject: [PATCH 01/10] feat(lazer-protocol): add metadata v3 response types --- Cargo.lock | 10 +- .../pyth-lazer-solana-contract/Cargo.toml | 4 +- lazer/publisher_sdk/rust/Cargo.toml | 4 +- lazer/sdk/rust/client/Cargo.toml | 4 +- lazer/sdk/rust/protocol/Cargo.toml | 2 +- lazer/sdk/rust/protocol/src/lib.rs | 3 + lazer/sdk/rust/protocol/src/metadata.rs | 138 ++++++++++++++++++ 7 files changed, 153 insertions(+), 12 deletions(-) create mode 100644 lazer/sdk/rust/protocol/src/metadata.rs diff --git a/Cargo.lock b/Cargo.lock index 645c318842..44c5d6386c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5693,7 +5693,7 @@ dependencies = [ [[package]] name = "pyth-lazer-client" -version = "8.2.2" +version = "8.2.3" dependencies = [ "alloy-primitives 0.8.25", "anyhow", @@ -5711,7 +5711,7 @@ dependencies = [ "hex", "humantime-serde", "libsecp256k1 0.7.2", - "pyth-lazer-protocol 0.16.0", + "pyth-lazer-protocol 0.17.0", "reqwest 0.12.23", "serde", "serde_json", @@ -5746,7 +5746,7 @@ dependencies = [ [[package]] name = "pyth-lazer-protocol" -version = "0.16.0" +version = "0.17.0" dependencies = [ "alloy-primitives 0.8.25", "anyhow", @@ -5786,13 +5786,13 @@ dependencies = [ [[package]] name = "pyth-lazer-publisher-sdk" -version = "0.14.0" +version = "0.15.0" dependencies = [ "anyhow", "fs-err", "protobuf", "protobuf-codegen", - "pyth-lazer-protocol 0.16.0", + "pyth-lazer-protocol 0.17.0", "serde_json", ] diff --git a/lazer/contracts/solana/programs/pyth-lazer-solana-contract/Cargo.toml b/lazer/contracts/solana/programs/pyth-lazer-solana-contract/Cargo.toml index 18504df77f..ed5f2ddb58 100644 --- a/lazer/contracts/solana/programs/pyth-lazer-solana-contract/Cargo.toml +++ b/lazer/contracts/solana/programs/pyth-lazer-solana-contract/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pyth-lazer-solana-contract" -version = "0.7.1" +version = "0.7.2" edition = "2021" description = "Pyth Lazer Solana contract and SDK." license = "Apache-2.0" @@ -19,7 +19,7 @@ no-log-ix-name = [] idl-build = ["anchor-lang/idl-build"] [dependencies] -pyth-lazer-protocol = { path = "../../../../sdk/rust/protocol", version = "0.16.0" } +pyth-lazer-protocol = { path = "../../../../sdk/rust/protocol", version = "0.17.0" } anchor-lang = "0.31.1" bytemuck = { version = "1.20.0", features = ["derive"] } diff --git a/lazer/publisher_sdk/rust/Cargo.toml b/lazer/publisher_sdk/rust/Cargo.toml index 3e9b852275..b90e1e8a19 100644 --- a/lazer/publisher_sdk/rust/Cargo.toml +++ b/lazer/publisher_sdk/rust/Cargo.toml @@ -1,13 +1,13 @@ [package] name = "pyth-lazer-publisher-sdk" -version = "0.14.0" +version = "0.15.0" edition = "2021" description = "Pyth Lazer Publisher SDK types." license = "Apache-2.0" repository = "https://github.com/pyth-network/pyth-crosschain" [dependencies] -pyth-lazer-protocol = { version = "0.16.0", path = "../../sdk/rust/protocol" } +pyth-lazer-protocol = { version = "0.17.0", path = "../../sdk/rust/protocol" } anyhow = "1.0.98" protobuf = "3.7.2" serde_json = "1.0.140" diff --git a/lazer/sdk/rust/client/Cargo.toml b/lazer/sdk/rust/client/Cargo.toml index 56ca1aea89..5c6259eb70 100644 --- a/lazer/sdk/rust/client/Cargo.toml +++ b/lazer/sdk/rust/client/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "pyth-lazer-client" -version = "8.2.2" +version = "8.2.3" edition = "2021" description = "A Rust client for Pyth Lazer" license = "Apache-2.0" [dependencies] -pyth-lazer-protocol = { path = "../protocol", version = "0.16.0" } +pyth-lazer-protocol = { path = "../protocol", version = "0.17.0" } tokio = { version = "1", features = ["full"] } tokio-tungstenite = { version = "0.20", features = ["native-tls"] } futures-util = "0.3" diff --git a/lazer/sdk/rust/protocol/Cargo.toml b/lazer/sdk/rust/protocol/Cargo.toml index be3dd81682..bc05f638c6 100644 --- a/lazer/sdk/rust/protocol/Cargo.toml +++ b/lazer/sdk/rust/protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pyth-lazer-protocol" -version = "0.16.0" +version = "0.17.0" edition = "2021" description = "Pyth Lazer SDK - protocol types." license = "Apache-2.0" diff --git a/lazer/sdk/rust/protocol/src/lib.rs b/lazer/sdk/rust/protocol/src/lib.rs index ff6f16da8f..f046f5ca88 100644 --- a/lazer/sdk/rust/protocol/src/lib.rs +++ b/lazer/sdk/rust/protocol/src/lib.rs @@ -10,6 +10,8 @@ mod feed_kind; pub mod jrpc; /// Types describing Lazer's verifiable messages containing signature and payload. pub mod message; +/// Types describing Lazer's feed & asset metadata catalog APIs. +pub mod metadata; /// Types describing Lazer's message payload. pub mod payload; mod price; @@ -28,6 +30,7 @@ use serde::{Deserialize, Serialize}; pub use crate::{ dynamic_value::DynamicValue, feed_kind::FeedKind, + metadata::{AssetClass, AssetResponseV3, FeedResponseV3, InstrumentType}, price::{Price, PriceError}, rate::{Rate, RateError}, symbol_state::SymbolState, diff --git a/lazer/sdk/rust/protocol/src/metadata.rs b/lazer/sdk/rust/protocol/src/metadata.rs new file mode 100644 index 0000000000..2c871ab257 --- /dev/null +++ b/lazer/sdk/rust/protocol/src/metadata.rs @@ -0,0 +1,138 @@ +//! Types describing Lazer's metadata APIs. + +use crate::FeedKind; +use crate::{symbol_state::SymbolState, PriceFeedId}; +use serde::{Deserialize, Serialize}; + +/// The pricing context or type of instrument for a feed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum InstrumentType { + /// Spot price + Spot, + /// Redemption rate + #[serde(rename = "redemptionrate")] + RedemptionRate, + /// Funding rate + #[serde(rename = "fundingrate")] + FundingRate, + /// Future price + Future, + /// Net Asset Value + Nav, + /// Time-weighted average price + Twap, +} + +/// High-level asset class. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum AssetClass { + /// Cryptocurrency + Crypto, + /// Foreign exchange + Fx, + /// Equity + Equity, + /// Metal + Metal, + /// Rates + Rates, + /// Net Asset Value + Nav, + /// Commodity + Commodity, + /// Funding rate + FundingRate, +} + +/// Feed metadata as returned by the v3 metadata API. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct FeedResponseV3 { + /// Unique integer identifier for a feed. Known as `pyth_lazer_id` in V1 API. + /// Example: `1` + pub id: PriceFeedId, + /// Short feed name. + /// Example: `"Bitcoin / US Dollar"` + pub name: String, + /// Unique human-readable identifier for a feed. + /// Format: `source.instrument_type.base/quote` + /// Examples: `"pyth.spot.btc/usd"`, `"pyth.redemptionrate.alp/usd"`, `"binance.fundingrate.btc/usdt"`, `"pyth.future.emz5/usd"` + pub symbol: String, + /// Description of the feed pair. + /// Example: `"Pyth Network Aggregate Price for spot BTC/USD"` + pub description: String, + /// The Asset ID of the base asset. + /// Example: `"BTC"` + pub base_asset_id: String, + /// The Asset ID of the quote asset. + /// Example: `"USD"` + pub quote_asset_id: String, + /// The pricing context. + /// Example: `InstrumentType::Spot` + pub instrument_type: InstrumentType, + /// Aggregator or producer of the prices. + /// Examples: `"pyth"` for our aggregations, `"binance"` for their funding rates + pub source: String, + /// The trading schedule of the feed's market, in Pythnet format. + /// Example: `"America/New_York;O,O,O,O,O,O,O;"` + pub schedule: String, + /// Power-of-ten exponent. Scale the `price` mantissa value by `10^exponent` to get the decimal representation. + /// Example: `-8` + pub exponent: i32, + /// Funding rate interval. Only applies to feeds with instrument type `funding_rate`. + /// Example: `10` + pub update_interval_seconds: i32, + /// The minimum number of publishers contributing component prices to the aggregate price. + /// Example: `3` + pub min_publishers: i32, + /// Status of the feed. + /// Example: `SymbolState::Active` + pub state: SymbolState, + /// High-level asset class. One of crypto, fx, equity, metal, rates, nav, commodity, funding-rate. + /// Example: `AssetClass::Crypto` + pub asset_type: AssetClass, + /// CoinMarketCap asset identifier. + /// Example: `"123"` + #[serde(skip_serializing_if = "Option::is_none")] + pub cmc_id: Option, + /// Pythnet feed identifier. 32 bytes, represented in hex. + /// Example: `"e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43"` + pub pythnet_id: String, + /// Nasdaq symbol identifier. + /// Example: `"ADSK"` + #[serde(skip_serializing_if = "Option::is_none")] + pub nasdaq_symbol: Option, + /// ISO datetime after which the feed will no longer produce prices because the underlying market has expired. + /// Example: `"2025-10-03T11:08:10.089998603Z"` + #[serde(skip_serializing_if = "Option::is_none")] + pub feed_expiry: Option, + /// The nature of the data produced by the feed. + /// Examples: `"price"`, `"fundingRate"` + pub feed_kind: FeedKind, +} + +/// Asset metadata as returned by the v3 metadata API. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct AssetResponseV3 { + /// Unique identifier for an asset. + /// Example: `"BTC"` + pub id: String, + /// A short, human-readable code that identifies an asset. Not guaranteed to be unique. + /// Example: `"BTC"` + pub ticker: String, + /// Full human-readable name of the asset. + /// Example: `"Bitcoin"` + pub full_name: String, + /// High-level asset class. + /// Example: `AssetClass::Crypto` + pub class: AssetClass, + /// More granular categorization within class. + /// Example: `"stablecoin"` + #[serde(skip_serializing_if = "Option::is_none")] + pub subclass: Option, + /// Primary or canonical listing exchange, when applicable. + /// Example: `"NASDAQ"` + #[serde(skip_serializing_if = "Option::is_none")] + pub listing_exchange: Option, +} From ebf5caf553b41c784c22d4d5d718cedd7e254def Mon Sep 17 00:00:00 2001 From: Tejas Badadare Date: Mon, 6 Oct 2025 16:52:56 -0700 Subject: [PATCH 02/10] doc(lazer-protocol): tweak examples --- lazer/sdk/rust/protocol/src/metadata.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lazer/sdk/rust/protocol/src/metadata.rs b/lazer/sdk/rust/protocol/src/metadata.rs index 2c871ab257..382f80f3f4 100644 --- a/lazer/sdk/rust/protocol/src/metadata.rs +++ b/lazer/sdk/rust/protocol/src/metadata.rs @@ -69,10 +69,10 @@ pub struct FeedResponseV3 { /// Example: `"USD"` pub quote_asset_id: String, /// The pricing context. - /// Example: `InstrumentType::Spot` + /// Example: `"spot"` pub instrument_type: InstrumentType, /// Aggregator or producer of the prices. - /// Examples: `"pyth"` for our aggregations, `"binance"` for their funding rates + /// Examples: `"pyth"`, `"binance"` pub source: String, /// The trading schedule of the feed's market, in Pythnet format. /// Example: `"America/New_York;O,O,O,O,O,O,O;"` @@ -87,10 +87,10 @@ pub struct FeedResponseV3 { /// Example: `3` pub min_publishers: i32, /// Status of the feed. - /// Example: `SymbolState::Active` + /// Example: `"active"` pub state: SymbolState, /// High-level asset class. One of crypto, fx, equity, metal, rates, nav, commodity, funding-rate. - /// Example: `AssetClass::Crypto` + /// Example: `"crypto"` pub asset_type: AssetClass, /// CoinMarketCap asset identifier. /// Example: `"123"` @@ -125,7 +125,7 @@ pub struct AssetResponseV3 { /// Example: `"Bitcoin"` pub full_name: String, /// High-level asset class. - /// Example: `AssetClass::Crypto` + /// Example: `"crypto"` pub class: AssetClass, /// More granular categorization within class. /// Example: `"stablecoin"` From 05e2dbaf5de4e2196074b336023a4deee671546d Mon Sep 17 00:00:00 2001 From: Tejas Badadare Date: Tue, 7 Oct 2025 16:08:55 -0700 Subject: [PATCH 03/10] feat(lazer-protocol): add SymbolV3 type & validation, add strum::EnumString to InstrumentType for string parsing, add tests --- Cargo.lock | 22 ++ lazer/sdk/rust/protocol/Cargo.toml | 1 + lazer/sdk/rust/protocol/src/lib.rs | 3 + lazer/sdk/rust/protocol/src/metadata.rs | 94 ++++++- lazer/sdk/rust/protocol/src/symbol_v3.rs | 322 +++++++++++++++++++++++ 5 files changed, 437 insertions(+), 5 deletions(-) create mode 100644 lazer/sdk/rust/protocol/src/symbol_v3.rs diff --git a/Cargo.lock b/Cargo.lock index 44c5d6386c..d6b852c21d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5767,6 +5767,7 @@ dependencies = [ "rust_decimal", "serde", "serde_json", + "strum 0.27.2", "thiserror 2.0.12", ] @@ -9672,6 +9673,15 @@ dependencies = [ "strum_macros 0.26.4", ] +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] + [[package]] name = "strum_macros" version = "0.24.3" @@ -9698,6 +9708,18 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "subtle" version = "2.6.1" diff --git a/lazer/sdk/rust/protocol/Cargo.toml b/lazer/sdk/rust/protocol/Cargo.toml index bc05f638c6..024c6014ca 100644 --- a/lazer/sdk/rust/protocol/Cargo.toml +++ b/lazer/sdk/rust/protocol/Cargo.toml @@ -21,6 +21,7 @@ chrono = "0.4.41" humantime = "2.2.0" hex = "0.4.3" thiserror = "2.0.12" +strum = { version = "0.27.2", features = ["derive"] } [dev-dependencies] bincode = "1.3.3" diff --git a/lazer/sdk/rust/protocol/src/lib.rs b/lazer/sdk/rust/protocol/src/lib.rs index f046f5ca88..2e21601d65 100644 --- a/lazer/sdk/rust/protocol/src/lib.rs +++ b/lazer/sdk/rust/protocol/src/lib.rs @@ -21,6 +21,8 @@ mod rate; mod serde_price_as_i64; mod serde_str; mod symbol_state; +/// Validated symbol type for `source.instrument_type.base/quote` format. +pub mod symbol_v3; /// Lazer's types for time representation. pub mod time; @@ -34,6 +36,7 @@ pub use crate::{ price::{Price, PriceError}, rate::{Rate, RateError}, symbol_state::SymbolState, + symbol_v3::SymbolV3, }; #[derive( diff --git a/lazer/sdk/rust/protocol/src/metadata.rs b/lazer/sdk/rust/protocol/src/metadata.rs index 382f80f3f4..23435f7622 100644 --- a/lazer/sdk/rust/protocol/src/metadata.rs +++ b/lazer/sdk/rust/protocol/src/metadata.rs @@ -1,20 +1,25 @@ //! Types describing Lazer's metadata APIs. +use crate::time::TimestampUs; use crate::FeedKind; -use crate::{symbol_state::SymbolState, PriceFeedId}; +use crate::{symbol_state::SymbolState, PriceFeedId, SymbolV3}; use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString}; /// The pricing context or type of instrument for a feed. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString)] #[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] pub enum InstrumentType { /// Spot price Spot, /// Redemption rate #[serde(rename = "redemptionrate")] + #[strum(serialize = "redemptionrate")] RedemptionRate, /// Funding rate #[serde(rename = "fundingrate")] + #[strum(serialize = "fundingrate")] FundingRate, /// Future price Future, @@ -58,7 +63,7 @@ pub struct FeedResponseV3 { /// Unique human-readable identifier for a feed. /// Format: `source.instrument_type.base/quote` /// Examples: `"pyth.spot.btc/usd"`, `"pyth.redemptionrate.alp/usd"`, `"binance.fundingrate.btc/usdt"`, `"pyth.future.emz5/usd"` - pub symbol: String, + pub symbol: SymbolV3, /// Description of the feed pair. /// Example: `"Pyth Network Aggregate Price for spot BTC/USD"` pub description: String, @@ -106,7 +111,7 @@ pub struct FeedResponseV3 { /// ISO datetime after which the feed will no longer produce prices because the underlying market has expired. /// Example: `"2025-10-03T11:08:10.089998603Z"` #[serde(skip_serializing_if = "Option::is_none")] - pub feed_expiry: Option, + pub feed_expiry: Option, /// The nature of the data produced by the feed. /// Examples: `"price"`, `"fundingRate"` pub feed_kind: FeedKind, @@ -134,5 +139,84 @@ pub struct AssetResponseV3 { /// Primary or canonical listing exchange, when applicable. /// Example: `"NASDAQ"` #[serde(skip_serializing_if = "Option::is_none")] - pub listing_exchange: Option, + pub primary_exchange: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_instrument_type_roundtrip() { + let types = vec![ + InstrumentType::Spot, + InstrumentType::RedemptionRate, + InstrumentType::FundingRate, + InstrumentType::Future, + InstrumentType::Nav, + InstrumentType::Twap, + ]; + + for instrument_type in types { + let string_repr = instrument_type.to_string(); + let parsed = string_repr.parse::().unwrap(); + assert_eq!(parsed, instrument_type); + } + + // Test invalid values + assert!("invalid".parse::().is_err()); + assert!("SPOT".parse::().is_err()); // case sensitive + } + + #[test] + fn test_feed_response_v3_roundtrip() { + use crate::{symbol_state::SymbolState, FeedKind, PriceFeedId}; + + let symbol = SymbolV3::new( + "pyth".to_string(), + InstrumentType::Spot, + "btc".to_string(), + "usd".to_string(), + ); + + let feed_response = FeedResponseV3 { + id: PriceFeedId(1), + name: "Bitcoin / US Dollar".to_string(), + symbol, + description: "Pyth Network Aggregate Price for spot BTC/USD".to_string(), + base_asset_id: "BTC".to_string(), + quote_asset_id: "USD".to_string(), + instrument_type: InstrumentType::Spot, + source: "pyth".to_string(), + schedule: "America/New_York;O,O,O,O,O,O,O;".to_string(), + exponent: -8, + update_interval_seconds: 10, + min_publishers: 3, + state: SymbolState::Stable, + asset_type: AssetClass::Crypto, + cmc_id: Some("1".to_string()), + pythnet_id: "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43" + .to_string(), + nasdaq_symbol: None, + feed_expiry: None, + feed_kind: FeedKind::Price, + }; + + // Test JSON serialization + let json = + serde_json::to_string(&feed_response).expect("Failed to serialize FeedResponseV3"); + assert!(json.contains("\"symbol\":\"pyth.spot.btc/usd\"")); + + // Test JSON deserialization + let deserialized: FeedResponseV3 = + serde_json::from_str(&json).expect("Failed to deserialize FeedResponseV3"); + assert_eq!(deserialized.symbol.as_string(), "pyth.spot.btc/usd"); + assert_eq!(deserialized.symbol.source, "pyth"); + assert_eq!(deserialized.symbol.instrument_type, InstrumentType::Spot); + assert_eq!(deserialized.symbol.base, "btc"); + assert_eq!(deserialized.symbol.quote, "usd"); + + // Ensure the entire structure matches + assert_eq!(deserialized, feed_response); + } } diff --git a/lazer/sdk/rust/protocol/src/symbol_v3.rs b/lazer/sdk/rust/protocol/src/symbol_v3.rs new file mode 100644 index 0000000000..ea36ca38b4 --- /dev/null +++ b/lazer/sdk/rust/protocol/src/symbol_v3.rs @@ -0,0 +1,322 @@ +//! SymbolV3 type for validated symbol strings. + +use crate::InstrumentType; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::str::FromStr; + +/// A validated symbol that conforms to the format `source.instrument_type.base/quote`. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct SymbolV3 { + /// The data source (e.g., "pyth", "binance") + pub source: String, + /// The instrument type + pub instrument_type: InstrumentType, + /// The base asset (e.g., "btc", "alp") + pub base: String, + /// The quote asset (e.g., "usd", "usdt") + pub quote: String, +} + +impl SymbolV3 { + /// Creates a new SymbolV3 from components. + pub fn new( + source: String, + instrument_type: InstrumentType, + base: String, + quote: String, + ) -> Self { + Self { + source, + instrument_type, + base, + quote, + } + } + + /// Returns the symbol as a string in the format `source.instrument_type.base/quote`. + pub fn as_string(&self) -> String { + format!( + "{}.{}.{}/{}", + self.source, self.instrument_type, self.base, self.quote + ) + } +} + +impl fmt::Display for SymbolV3 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_string()) + } +} + +impl From for String { + fn from(symbol: SymbolV3) -> Self { + symbol.as_string() + } +} + +impl TryFrom for SymbolV3 { + type Error = String; + + fn try_from(s: String) -> Result { + s.parse() + } +} + +impl FromStr for SymbolV3 { + type Err = String; + + fn from_str(s: &str) -> Result { + // Split by dots to get parts + let parts: Vec<&str> = s.split('.').collect(); + if parts.len() != 3 { + return Err(format!( + "Invalid symbol format: expected 3 dot-separated parts, got {}", + parts.len() + )); + } + + let source = parts[0].to_string(); + let instrument_type_str = parts[1]; + let base_quote_part = parts[2]; + + // Parse instrument type using its FromStr implementation + let instrument_type = instrument_type_str + .parse::() + .map_err(|e| format!("Invalid instrument type '{}': {}", instrument_type_str, e))?; + + // Split base/quote part + let base_quote: Vec<&str> = base_quote_part.split('/').collect(); + if base_quote.len() != 2 { + return Err(format!( + "Invalid base/quote format: expected format 'base/quote', got '{}'", + base_quote_part + )); + } + + let base = base_quote[0].to_string(); + let quote = base_quote[1].to_string(); + + // Validate that parts are not empty + if source.is_empty() { + return Err("Source cannot be empty".to_string()); + } + if base.is_empty() { + return Err("Base asset cannot be empty".to_string()); + } + if quote.is_empty() { + return Err("Quote asset cannot be empty".to_string()); + } + + Ok(Self::new(source, instrument_type, base, quote)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_symbol_parsing() { + // Test various valid symbol formats + let test_cases = vec![ + ( + "pyth.spot.btc/usd", + "pyth", + InstrumentType::Spot, + "btc", + "usd", + ), + ( + "pyth.redemptionrate.alp/usd", + "pyth", + InstrumentType::RedemptionRate, + "alp", + "usd", + ), + ( + "binance.fundingrate.btc/usdt", + "binance", + InstrumentType::FundingRate, + "btc", + "usdt", + ), + ( + "pyth.future.emz5/usd", + "pyth", + InstrumentType::Future, + "emz5", + "usd", + ), + ( + "exchange.nav.asset/quote", + "exchange", + InstrumentType::Nav, + "asset", + "quote", + ), + ( + "source.twap.base/quote", + "source", + InstrumentType::Twap, + "base", + "quote", + ), + ]; + + for (symbol_str, expected_source, expected_instrument, expected_base, expected_quote) in + test_cases + { + let symbol: SymbolV3 = symbol_str + .parse() + .expect(&format!("Failed to parse: {}", symbol_str)); + + assert_eq!(symbol.source, expected_source); + assert_eq!(symbol.instrument_type, expected_instrument); + assert_eq!(symbol.base, expected_base); + assert_eq!(symbol.quote, expected_quote); + + // Test round-trip conversion + assert_eq!(symbol.as_string(), symbol_str); + assert_eq!(symbol.to_string(), symbol_str); + } + } + + #[test] + fn test_invalid_symbol_parsing() { + let invalid_cases = vec![ + // Wrong number of parts + "pyth.spot", + "pyth.spot.btc.usd.extra", + "pyth", + "", + // Invalid instrument type + "pyth.invalid.btc/usd", + "pyth.SPOT.btc/usd", // case sensitive + // Missing slash in base/quote + "pyth.spot.btcusd", + "pyth.spot.btc-usd", + // Empty components + ".spot.btc/usd", + "pyth..btc/usd", + "pyth.spot./usd", + "pyth.spot.btc/", + "pyth.spot.btc/", + // Multiple slashes + "pyth.spot.btc/usd/extra", + ]; + + for invalid_symbol in invalid_cases { + assert!( + invalid_symbol.parse::().is_err(), + "Expected parsing to fail for: {}", + invalid_symbol + ); + } + } + + #[test] + fn test_symbol_construction() { + let symbol = SymbolV3::new( + "pyth".to_string(), + InstrumentType::Spot, + "btc".to_string(), + "usd".to_string(), + ); + + assert_eq!(symbol.source, "pyth"); + assert_eq!(symbol.instrument_type, InstrumentType::Spot); + assert_eq!(symbol.base, "btc"); + assert_eq!(symbol.quote, "usd"); + assert_eq!(symbol.as_string(), "pyth.spot.btc/usd"); + } + + #[test] + fn test_symbol_serialization() { + let symbol = SymbolV3::new( + "pyth".to_string(), + InstrumentType::FundingRate, + "eth".to_string(), + "usd".to_string(), + ); + + // Test JSON serialization + let json = serde_json::to_string(&symbol).expect("Failed to serialize"); + assert_eq!(json, "\"pyth.fundingrate.eth/usd\""); + + // Test JSON deserialization + let deserialized: SymbolV3 = serde_json::from_str(&json).expect("Failed to deserialize"); + assert_eq!(deserialized, symbol); + } + + #[test] + fn test_symbol_deserialization_invalid() { + // Test that invalid JSON strings fail to deserialize + let invalid_json = "\"invalid.format\""; + assert!(serde_json::from_str::(invalid_json).is_err()); + } + + #[test] + fn test_instrument_type_string_mapping() { + let test_cases = vec![ + (InstrumentType::Spot, "spot"), + (InstrumentType::RedemptionRate, "redemptionrate"), + (InstrumentType::FundingRate, "fundingrate"), + (InstrumentType::Future, "future"), + (InstrumentType::Nav, "nav"), + (InstrumentType::Twap, "twap"), + ]; + + for (instrument_type, expected_str) in test_cases { + let symbol = SymbolV3::new( + "test".to_string(), + instrument_type, + "base".to_string(), + "quote".to_string(), + ); + + let symbol_str = symbol.as_string(); + assert!( + symbol_str.contains(expected_str), + "Expected symbol '{}' to contain '{}'", + symbol_str, + expected_str + ); + } + } + + #[test] + fn test_symbol_equality_and_hash() { + let symbol1 = SymbolV3::new( + "pyth".to_string(), + InstrumentType::Spot, + "btc".to_string(), + "usd".to_string(), + ); + + let symbol2 = SymbolV3::new( + "pyth".to_string(), + InstrumentType::Spot, + "btc".to_string(), + "usd".to_string(), + ); + + let symbol3 = SymbolV3::new( + "binance".to_string(), + InstrumentType::Spot, + "btc".to_string(), + "usd".to_string(), + ); + + assert_eq!(symbol1, symbol2); + assert_ne!(symbol1, symbol3); + + // Test that equal symbols have the same hash + use std::collections::HashSet; + let mut set = HashSet::new(); + set.insert(symbol1.clone()); + assert!(set.contains(&symbol2)); + assert!(!set.contains(&symbol3)); + } +} From ead58536660668e239393ec15de489d23ce6e1d1 Mon Sep 17 00:00:00 2001 From: Tejas Badadare Date: Wed, 8 Oct 2025 14:25:26 -0700 Subject: [PATCH 04/10] feat(lazer-protocol): use Strings instead of enums in the public API, make SymbolV3.quote optional --- lazer/sdk/rust/protocol/src/metadata.rs | 57 ++--- lazer/sdk/rust/protocol/src/symbol_v3.rs | 313 +++++++++-------------- 2 files changed, 137 insertions(+), 233 deletions(-) diff --git a/lazer/sdk/rust/protocol/src/metadata.rs b/lazer/sdk/rust/protocol/src/metadata.rs index 23435f7622..23bc427a37 100644 --- a/lazer/sdk/rust/protocol/src/metadata.rs +++ b/lazer/sdk/rust/protocol/src/metadata.rs @@ -4,22 +4,20 @@ use crate::time::TimestampUs; use crate::FeedKind; use crate::{symbol_state::SymbolState, PriceFeedId, SymbolV3}; use serde::{Deserialize, Serialize}; -use strum::{Display, EnumString}; /// The pricing context or type of instrument for a feed. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString)] +/// This is an internal type and should not be used by clients as it is non-exhaustive. +/// The API response can evolve to contain additional variants that are not listed here. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] -#[strum(serialize_all = "lowercase")] pub enum InstrumentType { /// Spot price Spot, /// Redemption rate #[serde(rename = "redemptionrate")] - #[strum(serialize = "redemptionrate")] RedemptionRate, /// Funding rate #[serde(rename = "fundingrate")] - #[strum(serialize = "fundingrate")] FundingRate, /// Future price Future, @@ -30,6 +28,8 @@ pub enum InstrumentType { } /// High-level asset class. +/// This is an internal type and should not be used by clients as it is non-exhaustive. +/// The API response can evolve to contain additional variants that are not listed here. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum AssetClass { @@ -43,12 +43,8 @@ pub enum AssetClass { Metal, /// Rates Rates, - /// Net Asset Value - Nav, /// Commodity Commodity, - /// Funding rate - FundingRate, } /// Feed metadata as returned by the v3 metadata API. @@ -73,9 +69,9 @@ pub struct FeedResponseV3 { /// The Asset ID of the quote asset. /// Example: `"USD"` pub quote_asset_id: String, - /// The pricing context. + /// The pricing context. Should be one of the values in the InstrumentType enum. /// Example: `"spot"` - pub instrument_type: InstrumentType, + pub instrument_type: String, /// Aggregator or producer of the prices. /// Examples: `"pyth"`, `"binance"` pub source: String, @@ -95,8 +91,9 @@ pub struct FeedResponseV3 { /// Example: `"active"` pub state: SymbolState, /// High-level asset class. One of crypto, fx, equity, metal, rates, nav, commodity, funding-rate. + /// Should be one of the values in the AssetClass enum. /// Example: `"crypto"` - pub asset_type: AssetClass, + pub asset_type: String, /// CoinMarketCap asset identifier. /// Example: `"123"` #[serde(skip_serializing_if = "Option::is_none")] @@ -131,7 +128,7 @@ pub struct AssetResponseV3 { pub full_name: String, /// High-level asset class. /// Example: `"crypto"` - pub class: AssetClass, + pub class: String, /// More granular categorization within class. /// Example: `"stablecoin"` #[serde(skip_serializing_if = "Option::is_none")] @@ -146,37 +143,15 @@ pub struct AssetResponseV3 { mod tests { use super::*; - #[test] - fn test_instrument_type_roundtrip() { - let types = vec![ - InstrumentType::Spot, - InstrumentType::RedemptionRate, - InstrumentType::FundingRate, - InstrumentType::Future, - InstrumentType::Nav, - InstrumentType::Twap, - ]; - - for instrument_type in types { - let string_repr = instrument_type.to_string(); - let parsed = string_repr.parse::().unwrap(); - assert_eq!(parsed, instrument_type); - } - - // Test invalid values - assert!("invalid".parse::().is_err()); - assert!("SPOT".parse::().is_err()); // case sensitive - } - #[test] fn test_feed_response_v3_roundtrip() { use crate::{symbol_state::SymbolState, FeedKind, PriceFeedId}; let symbol = SymbolV3::new( "pyth".to_string(), - InstrumentType::Spot, + "spot".to_string(), "btc".to_string(), - "usd".to_string(), + Some("usd".to_string()), ); let feed_response = FeedResponseV3 { @@ -186,14 +161,14 @@ mod tests { description: "Pyth Network Aggregate Price for spot BTC/USD".to_string(), base_asset_id: "BTC".to_string(), quote_asset_id: "USD".to_string(), - instrument_type: InstrumentType::Spot, + instrument_type: "spot".to_string(), source: "pyth".to_string(), schedule: "America/New_York;O,O,O,O,O,O,O;".to_string(), exponent: -8, update_interval_seconds: 10, min_publishers: 3, state: SymbolState::Stable, - asset_type: AssetClass::Crypto, + asset_type: "crypto".to_string(), cmc_id: Some("1".to_string()), pythnet_id: "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43" .to_string(), @@ -212,9 +187,9 @@ mod tests { serde_json::from_str(&json).expect("Failed to deserialize FeedResponseV3"); assert_eq!(deserialized.symbol.as_string(), "pyth.spot.btc/usd"); assert_eq!(deserialized.symbol.source, "pyth"); - assert_eq!(deserialized.symbol.instrument_type, InstrumentType::Spot); + assert_eq!(deserialized.symbol.instrument_type, "spot"); assert_eq!(deserialized.symbol.base, "btc"); - assert_eq!(deserialized.symbol.quote, "usd"); + assert_eq!(deserialized.symbol.quote, Some("usd".to_string())); // Ensure the entire structure matches assert_eq!(deserialized, feed_response); diff --git a/lazer/sdk/rust/protocol/src/symbol_v3.rs b/lazer/sdk/rust/protocol/src/symbol_v3.rs index ea36ca38b4..84f2c39b2b 100644 --- a/lazer/sdk/rust/protocol/src/symbol_v3.rs +++ b/lazer/sdk/rust/protocol/src/symbol_v3.rs @@ -1,31 +1,30 @@ //! SymbolV3 type for validated symbol strings. -use crate::InstrumentType; use serde::{Deserialize, Serialize}; use std::fmt; use std::str::FromStr; -/// A validated symbol that conforms to the format `source.instrument_type.base/quote`. +/// A symbol that conforms to the format `source.instrument_type.base[/quote]`. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(try_from = "String", into = "String")] pub struct SymbolV3 { /// The data source (e.g., "pyth", "binance") pub source: String, - /// The instrument type - pub instrument_type: InstrumentType, - /// The base asset (e.g., "btc", "alp") + /// The instrument type (e.g., "spot", "redemptionrate", "fundingrate") + pub instrument_type: String, + /// The base asset ID (e.g., "btc", "alp") pub base: String, - /// The quote asset (e.g., "usd", "usdt") - pub quote: String, + /// The quote asset ID (e.g., "usd", "usdt"), optional + pub quote: Option, } impl SymbolV3 { /// Creates a new SymbolV3 from components. pub fn new( source: String, - instrument_type: InstrumentType, + instrument_type: String, base: String, - quote: String, + quote: Option, ) -> Self { Self { source, @@ -35,12 +34,15 @@ impl SymbolV3 { } } - /// Returns the symbol as a string in the format `source.instrument_type.base/quote`. + /// Returns the symbol as a string in the format `source.instrument_type.base[/quote]`. pub fn as_string(&self) -> String { - format!( - "{}.{}.{}/{}", - self.source, self.instrument_type, self.base, self.quote - ) + match &self.quote { + Some(quote) => format!( + "{}.{}.{}/{}", + self.source, self.instrument_type, self.base, quote + ), + None => format!("{}.{}.{}", self.source, self.instrument_type, self.base), + } } } @@ -78,36 +80,44 @@ impl FromStr for SymbolV3 { } let source = parts[0].to_string(); - let instrument_type_str = parts[1]; + let instrument_type = parts[1].to_string(); let base_quote_part = parts[2]; - // Parse instrument type using its FromStr implementation - let instrument_type = instrument_type_str - .parse::() - .map_err(|e| format!("Invalid instrument type '{}': {}", instrument_type_str, e))?; - // Split base/quote part let base_quote: Vec<&str> = base_quote_part.split('/').collect(); - if base_quote.len() != 2 { - return Err(format!( - "Invalid base/quote format: expected format 'base/quote', got '{}'", - base_quote_part - )); - } - let base = base_quote[0].to_string(); - let quote = base_quote[1].to_string(); + let (base, quote) = match base_quote.len() { + 1 => { + // No quote provided + (base_quote[0].to_string(), None) + } + 2 => { + // Quote provided + let base = base_quote[0].to_string(); + let quote_str = base_quote[1].to_string(); + if quote_str.is_empty() { + return Err("Quote asset cannot be empty when slash is present".to_string()); + } + (base, Some(quote_str)) + } + _ => { + return Err(format!( + "Invalid base/quote format: expected format 'base' or 'base/quote', got '{}'", + base_quote_part + )); + } + }; // Validate that parts are not empty if source.is_empty() { return Err("Source cannot be empty".to_string()); } + if instrument_type.is_empty() { + return Err("Instrument type cannot be empty".to_string()); + } if base.is_empty() { return Err("Base asset cannot be empty".to_string()); } - if quote.is_empty() { - return Err("Quote asset cannot be empty".to_string()); - } Ok(Self::new(source, instrument_type, base, quote)) } @@ -118,205 +128,124 @@ mod tests { use super::*; #[test] - fn test_valid_symbol_parsing() { - // Test various valid symbol formats - let test_cases = vec![ - ( - "pyth.spot.btc/usd", - "pyth", - InstrumentType::Spot, - "btc", - "usd", - ), - ( - "pyth.redemptionrate.alp/usd", - "pyth", - InstrumentType::RedemptionRate, - "alp", - "usd", - ), + fn test_parsing_and_roundtrip() { + // Test parsing with quote + let cases_with_quote = vec![ + ("pyth.spot.btc/usd", "pyth", "spot", "btc", "usd"), ( - "binance.fundingrate.btc/usdt", + "binance.fundingrate.eth/usdt", "binance", - InstrumentType::FundingRate, - "btc", + "fundingrate", + "eth", "usdt", ), ( - "pyth.future.emz5/usd", + "pyth.redemptionrate.alp/usd", "pyth", - InstrumentType::Future, - "emz5", + "redemptionrate", + "alp", "usd", ), - ( - "exchange.nav.asset/quote", - "exchange", - InstrumentType::Nav, - "asset", - "quote", - ), - ( - "source.twap.base/quote", - "source", - InstrumentType::Twap, - "base", - "quote", - ), ]; - for (symbol_str, expected_source, expected_instrument, expected_base, expected_quote) in - test_cases - { - let symbol: SymbolV3 = symbol_str - .parse() - .expect(&format!("Failed to parse: {}", symbol_str)); + for (input, source, instrument, base, quote) in cases_with_quote { + let symbol: SymbolV3 = input.parse().expect("Failed to parse"); + assert_eq!(symbol.source, source); + assert_eq!(symbol.instrument_type, instrument); + assert_eq!(symbol.base, base); + assert_eq!(symbol.quote, Some(quote.to_string())); + assert_eq!(symbol.as_string(), input); + assert_eq!(symbol.to_string(), input); + } - assert_eq!(symbol.source, expected_source); - assert_eq!(symbol.instrument_type, expected_instrument); - assert_eq!(symbol.base, expected_base); - assert_eq!(symbol.quote, expected_quote); + // Test parsing without quote + let cases_without_quote = vec![ + ("pyth.redemptionrate.alp", "pyth", "redemptionrate", "alp"), + ("pyth.nav.fund", "pyth", "nav", "fund"), + ("source.index.btc", "source", "index", "btc"), + ]; - // Test round-trip conversion - assert_eq!(symbol.as_string(), symbol_str); - assert_eq!(symbol.to_string(), symbol_str); + for (input, source, instrument, base) in cases_without_quote { + let symbol: SymbolV3 = input.parse().expect("Failed to parse"); + assert_eq!(symbol.source, source); + assert_eq!(symbol.instrument_type, instrument); + assert_eq!(symbol.base, base); + assert_eq!(symbol.quote, None); + assert_eq!(symbol.as_string(), input); + assert_eq!(symbol.to_string(), input); } - } - #[test] - fn test_invalid_symbol_parsing() { - let invalid_cases = vec![ - // Wrong number of parts - "pyth.spot", - "pyth.spot.btc.usd.extra", - "pyth", - "", - // Invalid instrument type - "pyth.invalid.btc/usd", - "pyth.SPOT.btc/usd", // case sensitive - // Missing slash in base/quote - "pyth.spot.btcusd", - "pyth.spot.btc-usd", - // Empty components - ".spot.btc/usd", - "pyth..btc/usd", - "pyth.spot./usd", - "pyth.spot.btc/", - "pyth.spot.btc/", - // Multiple slashes - "pyth.spot.btc/usd/extra", + // Test invalid formats + let invalid = vec![ + "pyth.spot", // Missing base + "pyth.spot.btc.usd.extra", // Too many parts + "pyth", // Too few parts + "", // Empty + ".spot.btc/usd", // Empty source + "pyth..btc/usd", // Empty instrument + "pyth.spot./usd", // Empty base + "pyth.spot.btc/", // Empty quote with slash + "pyth.spot.", // Empty base with dot + "pyth.spot.btc/usd/extra", // Multiple slashes ]; - for invalid_symbol in invalid_cases { + for input in invalid { assert!( - invalid_symbol.parse::().is_err(), + input.parse::().is_err(), "Expected parsing to fail for: {}", - invalid_symbol + input ); } } #[test] - fn test_symbol_construction() { - let symbol = SymbolV3::new( + fn test_construction_and_display() { + // With quote + let with_quote = SymbolV3::new( "pyth".to_string(), - InstrumentType::Spot, + "spot".to_string(), "btc".to_string(), - "usd".to_string(), + Some("usd".to_string()), ); + assert_eq!(with_quote.as_string(), "pyth.spot.btc/usd"); - assert_eq!(symbol.source, "pyth"); - assert_eq!(symbol.instrument_type, InstrumentType::Spot); - assert_eq!(symbol.base, "btc"); - assert_eq!(symbol.quote, "usd"); - assert_eq!(symbol.as_string(), "pyth.spot.btc/usd"); - } - - #[test] - fn test_symbol_serialization() { - let symbol = SymbolV3::new( + // Without quote + let without_quote = SymbolV3::new( "pyth".to_string(), - InstrumentType::FundingRate, - "eth".to_string(), - "usd".to_string(), + "redemptionrate".to_string(), + "alp".to_string(), + None, ); - - // Test JSON serialization - let json = serde_json::to_string(&symbol).expect("Failed to serialize"); - assert_eq!(json, "\"pyth.fundingrate.eth/usd\""); - - // Test JSON deserialization - let deserialized: SymbolV3 = serde_json::from_str(&json).expect("Failed to deserialize"); - assert_eq!(deserialized, symbol); + assert_eq!(without_quote.as_string(), "pyth.redemptionrate.alp"); } #[test] - fn test_symbol_deserialization_invalid() { - // Test that invalid JSON strings fail to deserialize - let invalid_json = "\"invalid.format\""; - assert!(serde_json::from_str::(invalid_json).is_err()); - } - - #[test] - fn test_instrument_type_string_mapping() { - let test_cases = vec![ - (InstrumentType::Spot, "spot"), - (InstrumentType::RedemptionRate, "redemptionrate"), - (InstrumentType::FundingRate, "fundingrate"), - (InstrumentType::Future, "future"), - (InstrumentType::Nav, "nav"), - (InstrumentType::Twap, "twap"), - ]; - - for (instrument_type, expected_str) in test_cases { - let symbol = SymbolV3::new( - "test".to_string(), - instrument_type, - "base".to_string(), - "quote".to_string(), - ); - - let symbol_str = symbol.as_string(); - assert!( - symbol_str.contains(expected_str), - "Expected symbol '{}' to contain '{}'", - symbol_str, - expected_str - ); - } - } - - #[test] - fn test_symbol_equality_and_hash() { - let symbol1 = SymbolV3::new( + fn test_serialization() { + // Test with quote + let symbol_with_quote = SymbolV3::new( "pyth".to_string(), - InstrumentType::Spot, + "spot".to_string(), "btc".to_string(), - "usd".to_string(), + Some("usd".to_string()), ); + let json = serde_json::to_string(&symbol_with_quote).unwrap(); + assert_eq!(json, "\"pyth.spot.btc/usd\""); + let deserialized: SymbolV3 = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, symbol_with_quote); - let symbol2 = SymbolV3::new( + // Test without quote + let symbol_without_quote = SymbolV3::new( "pyth".to_string(), - InstrumentType::Spot, - "btc".to_string(), - "usd".to_string(), - ); - - let symbol3 = SymbolV3::new( - "binance".to_string(), - InstrumentType::Spot, - "btc".to_string(), - "usd".to_string(), + "nav".to_string(), + "fund".to_string(), + None, ); + let json = serde_json::to_string(&symbol_without_quote).unwrap(); + assert_eq!(json, "\"pyth.nav.fund\""); + let deserialized: SymbolV3 = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, symbol_without_quote); - assert_eq!(symbol1, symbol2); - assert_ne!(symbol1, symbol3); - - // Test that equal symbols have the same hash - use std::collections::HashSet; - let mut set = HashSet::new(); - set.insert(symbol1.clone()); - assert!(set.contains(&symbol2)); - assert!(!set.contains(&symbol3)); + // Test invalid deserialization + assert!(serde_json::from_str::("\"invalid.format\"").is_err()); } } From 6dd6551470fe22fd8c2daa8241abe927f95e93ea Mon Sep 17 00:00:00 2001 From: Tejas Badadare Date: Wed, 8 Oct 2025 14:33:24 -0700 Subject: [PATCH 05/10] fix lints --- lazer/sdk/rust/protocol/src/symbol_v3.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lazer/sdk/rust/protocol/src/symbol_v3.rs b/lazer/sdk/rust/protocol/src/symbol_v3.rs index 84f2c39b2b..fc8b159c09 100644 --- a/lazer/sdk/rust/protocol/src/symbol_v3.rs +++ b/lazer/sdk/rust/protocol/src/symbol_v3.rs @@ -102,8 +102,7 @@ impl FromStr for SymbolV3 { } _ => { return Err(format!( - "Invalid base/quote format: expected format 'base' or 'base/quote', got '{}'", - base_quote_part + "Invalid base/quote format: expected format 'base' or 'base/quote', got '{base_quote_part}'" )); } }; @@ -192,8 +191,7 @@ mod tests { for input in invalid { assert!( input.parse::().is_err(), - "Expected parsing to fail for: {}", - input + "Expected parsing to fail for: {input}" ); } } From c2c1de8d398f942ab12cd4264f4549d68b26993f Mon Sep 17 00:00:00 2001 From: Tejas Badadare Date: Thu, 9 Oct 2025 13:52:53 -0700 Subject: [PATCH 06/10] fix types --- lazer/sdk/rust/protocol/src/metadata.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lazer/sdk/rust/protocol/src/metadata.rs b/lazer/sdk/rust/protocol/src/metadata.rs index 23bc427a37..230b7c4b65 100644 --- a/lazer/sdk/rust/protocol/src/metadata.rs +++ b/lazer/sdk/rust/protocol/src/metadata.rs @@ -1,6 +1,6 @@ //! Types describing Lazer's metadata APIs. -use crate::time::TimestampUs; +use crate::time::{DurationUs, TimestampUs}; use crate::FeedKind; use crate::{symbol_state::SymbolState, PriceFeedId, SymbolV3}; use serde::{Deserialize, Serialize}; @@ -68,7 +68,8 @@ pub struct FeedResponseV3 { pub base_asset_id: String, /// The Asset ID of the quote asset. /// Example: `"USD"` - pub quote_asset_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub quote_asset_id: Option, /// The pricing context. Should be one of the values in the InstrumentType enum. /// Example: `"spot"` pub instrument_type: String, @@ -83,10 +84,10 @@ pub struct FeedResponseV3 { pub exponent: i32, /// Funding rate interval. Only applies to feeds with instrument type `funding_rate`. /// Example: `10` - pub update_interval_seconds: i32, + pub update_interval: DurationUs, /// The minimum number of publishers contributing component prices to the aggregate price. /// Example: `3` - pub min_publishers: i32, + pub min_publishers: u32, /// Status of the feed. /// Example: `"active"` pub state: SymbolState, @@ -160,12 +161,12 @@ mod tests { symbol, description: "Pyth Network Aggregate Price for spot BTC/USD".to_string(), base_asset_id: "BTC".to_string(), - quote_asset_id: "USD".to_string(), + quote_asset_id: Some("USD".to_string()), instrument_type: "spot".to_string(), source: "pyth".to_string(), schedule: "America/New_York;O,O,O,O,O,O,O;".to_string(), exponent: -8, - update_interval_seconds: 10, + update_interval: DurationUs::from_secs_u32(10), min_publishers: 3, state: SymbolState::Stable, asset_type: "crypto".to_string(), From 61906e13c60207e2a09179ee95122f1117dd9f33 Mon Sep 17 00:00:00 2001 From: Tejas Badadare Date: Thu, 9 Oct 2025 14:40:24 -0700 Subject: [PATCH 07/10] fix types --- lazer/sdk/rust/protocol/src/metadata.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lazer/sdk/rust/protocol/src/metadata.rs b/lazer/sdk/rust/protocol/src/metadata.rs index 230b7c4b65..7d2322653c 100644 --- a/lazer/sdk/rust/protocol/src/metadata.rs +++ b/lazer/sdk/rust/protocol/src/metadata.rs @@ -81,13 +81,14 @@ pub struct FeedResponseV3 { pub schedule: String, /// Power-of-ten exponent. Scale the `price` mantissa value by `10^exponent` to get the decimal representation. /// Example: `-8` - pub exponent: i32, + pub exponent: i16, /// Funding rate interval. Only applies to feeds with instrument type `funding_rate`. /// Example: `10` - pub update_interval: DurationUs, + #[serde(skip_serializing_if = "Option::is_none")] + pub update_interval: Option, /// The minimum number of publishers contributing component prices to the aggregate price. /// Example: `3` - pub min_publishers: u32, + pub min_publishers: u16, /// Status of the feed. /// Example: `"active"` pub state: SymbolState, @@ -98,7 +99,7 @@ pub struct FeedResponseV3 { /// CoinMarketCap asset identifier. /// Example: `"123"` #[serde(skip_serializing_if = "Option::is_none")] - pub cmc_id: Option, + pub cmc_id: Option, /// Pythnet feed identifier. 32 bytes, represented in hex. /// Example: `"e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43"` pub pythnet_id: String, @@ -166,11 +167,11 @@ mod tests { source: "pyth".to_string(), schedule: "America/New_York;O,O,O,O,O,O,O;".to_string(), exponent: -8, - update_interval: DurationUs::from_secs_u32(10), + update_interval: Some(DurationUs::from_secs_u32(10)), min_publishers: 3, state: SymbolState::Stable, asset_type: "crypto".to_string(), - cmc_id: Some("1".to_string()), + cmc_id: Some(1), pythnet_id: "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43" .to_string(), nasdaq_symbol: None, From 103690fed486a5085aff29debbc9196df6ca0e0f Mon Sep 17 00:00:00 2001 From: Tejas Badadare Date: Thu, 9 Oct 2025 16:29:33 -0700 Subject: [PATCH 08/10] fix types --- lazer/sdk/rust/protocol/src/metadata.rs | 31 +++++++++++++++++-------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/lazer/sdk/rust/protocol/src/metadata.rs b/lazer/sdk/rust/protocol/src/metadata.rs index 7d2322653c..3ff14f4987 100644 --- a/lazer/sdk/rust/protocol/src/metadata.rs +++ b/lazer/sdk/rust/protocol/src/metadata.rs @@ -2,7 +2,7 @@ use crate::time::{DurationUs, TimestampUs}; use crate::FeedKind; -use crate::{symbol_state::SymbolState, PriceFeedId, SymbolV3}; +use crate::{symbol_state::SymbolState, PriceFeedId}; use serde::{Deserialize, Serialize}; /// The pricing context or type of instrument for a feed. @@ -59,7 +59,7 @@ pub struct FeedResponseV3 { /// Unique human-readable identifier for a feed. /// Format: `source.instrument_type.base/quote` /// Examples: `"pyth.spot.btc/usd"`, `"pyth.redemptionrate.alp/usd"`, `"binance.fundingrate.btc/usdt"`, `"pyth.future.emz5/usd"` - pub symbol: SymbolV3, + pub symbol: String, /// Description of the feed pair. /// Example: `"Pyth Network Aggregate Price for spot BTC/USD"` pub description: String, @@ -143,10 +143,14 @@ pub struct AssetResponseV3 { #[cfg(test)] mod tests { + use std::str::FromStr; + + use crate::SymbolV3; + use super::*; #[test] - fn test_feed_response_v3_roundtrip() { + fn test_feed_response_v3_json_serde_roundtrip() { use crate::{symbol_state::SymbolState, FeedKind, PriceFeedId}; let symbol = SymbolV3::new( @@ -159,7 +163,7 @@ mod tests { let feed_response = FeedResponseV3 { id: PriceFeedId(1), name: "Bitcoin / US Dollar".to_string(), - symbol, + symbol: symbol.as_string(), description: "Pyth Network Aggregate Price for spot BTC/USD".to_string(), base_asset_id: "BTC".to_string(), quote_asset_id: Some("USD".to_string()), @@ -182,18 +186,25 @@ mod tests { // Test JSON serialization let json = serde_json::to_string(&feed_response).expect("Failed to serialize FeedResponseV3"); - assert!(json.contains("\"symbol\":\"pyth.spot.btc/usd\"")); + let expected_json = r#"{"id":1,"name":"Bitcoin / US Dollar","symbol":"pyth.spot.btc/usd","description":"Pyth Network Aggregate Price for spot BTC/USD","base_asset_id":"BTC","quote_asset_id":"USD","instrument_type":"spot","source":"pyth","schedule":"America/New_York;O,O,O,O,O,O,O;","exponent":-8,"update_interval":10000000,"min_publishers":3,"state":"stable","asset_type":"crypto","cmc_id":1,"pythnet_id":"e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43","feed_kind":"price"}"#; + assert_eq!( + json, expected_json, + "Serialized JSON does not match expected output" + ); // Test JSON deserialization let deserialized: FeedResponseV3 = serde_json::from_str(&json).expect("Failed to deserialize FeedResponseV3"); - assert_eq!(deserialized.symbol.as_string(), "pyth.spot.btc/usd"); - assert_eq!(deserialized.symbol.source, "pyth"); - assert_eq!(deserialized.symbol.instrument_type, "spot"); - assert_eq!(deserialized.symbol.base, "btc"); - assert_eq!(deserialized.symbol.quote, Some("usd".to_string())); // Ensure the entire structure matches assert_eq!(deserialized, feed_response); + + // Test SymbolV3 deserialization + assert_eq!(deserialized.symbol, "pyth.spot.btc/usd"); + let symbol = SymbolV3::from_str(&deserialized.symbol).unwrap(); + assert_eq!(symbol.source, "pyth"); + assert_eq!(symbol.instrument_type, "spot"); + assert_eq!(symbol.base, "btc"); + assert_eq!(symbol.quote, Some("usd".to_string())); } } From 416bc1c70e74812166ccb5481e6e5d2faf5bd7fb Mon Sep 17 00:00:00 2001 From: Tejas Badadare Date: Fri, 10 Oct 2025 14:16:06 -0700 Subject: [PATCH 09/10] update cargo lock --- Cargo.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e6b89291a2..2562bbbb4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5693,7 +5693,7 @@ dependencies = [ [[package]] name = "pyth-lazer-client" -version = "8.4.1" +version = "8.4.2" dependencies = [ "alloy-primitives 0.8.25", "anyhow", @@ -5711,7 +5711,7 @@ dependencies = [ "hex", "humantime-serde", "libsecp256k1 0.7.2", - "pyth-lazer-protocol 0.18.1", + "pyth-lazer-protocol 0.19.0", "reqwest 0.12.23", "serde", "serde_json", @@ -5746,7 +5746,7 @@ dependencies = [ [[package]] name = "pyth-lazer-protocol" -version = "0.18.1" +version = "0.19.0" dependencies = [ "alloy-primitives 0.8.25", "anyhow", @@ -5787,13 +5787,13 @@ dependencies = [ [[package]] name = "pyth-lazer-publisher-sdk" -version = "0.16.1" +version = "0.16.2" dependencies = [ "anyhow", "fs-err", "protobuf", "protobuf-codegen", - "pyth-lazer-protocol 0.18.1", + "pyth-lazer-protocol 0.19.0", "serde_json", ] From 2896662669440b55f21772be3d24d0dfc47de6e3 Mon Sep 17 00:00:00 2001 From: Tejas Badadare Date: Fri, 10 Oct 2025 16:45:24 -0700 Subject: [PATCH 10/10] use strings instead of enums --- lazer/sdk/rust/protocol/src/metadata.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/lazer/sdk/rust/protocol/src/metadata.rs b/lazer/sdk/rust/protocol/src/metadata.rs index 3ff14f4987..a6c0f8eb56 100644 --- a/lazer/sdk/rust/protocol/src/metadata.rs +++ b/lazer/sdk/rust/protocol/src/metadata.rs @@ -1,8 +1,7 @@ //! Types describing Lazer's metadata APIs. use crate::time::{DurationUs, TimestampUs}; -use crate::FeedKind; -use crate::{symbol_state::SymbolState, PriceFeedId}; +use crate::PriceFeedId; use serde::{Deserialize, Serialize}; /// The pricing context or type of instrument for a feed. @@ -91,7 +90,7 @@ pub struct FeedResponseV3 { pub min_publishers: u16, /// Status of the feed. /// Example: `"active"` - pub state: SymbolState, + pub state: String, /// High-level asset class. One of crypto, fx, equity, metal, rates, nav, commodity, funding-rate. /// Should be one of the values in the AssetClass enum. /// Example: `"crypto"` @@ -113,7 +112,7 @@ pub struct FeedResponseV3 { pub feed_expiry: Option, /// The nature of the data produced by the feed. /// Examples: `"price"`, `"fundingRate"` - pub feed_kind: FeedKind, + pub feed_kind: String, } /// Asset metadata as returned by the v3 metadata API. @@ -151,7 +150,7 @@ mod tests { #[test] fn test_feed_response_v3_json_serde_roundtrip() { - use crate::{symbol_state::SymbolState, FeedKind, PriceFeedId}; + use crate::PriceFeedId; let symbol = SymbolV3::new( "pyth".to_string(), @@ -173,14 +172,14 @@ mod tests { exponent: -8, update_interval: Some(DurationUs::from_secs_u32(10)), min_publishers: 3, - state: SymbolState::Stable, + state: "stable".to_string(), asset_type: "crypto".to_string(), cmc_id: Some(1), pythnet_id: "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43" .to_string(), nasdaq_symbol: None, feed_expiry: None, - feed_kind: FeedKind::Price, + feed_kind: "price".to_string(), }; // Test JSON serialization