From 438a2062ca26fbb480fd9ef6ac0dfdcab66b9561 Mon Sep 17 00:00:00 2001 From: Tejas Badadare Date: Mon, 6 Oct 2025 16:50:38 -0700 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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)); + } +}