diff --git a/Cargo.lock b/Cargo.lock index 2a5502b186..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", @@ -5767,6 +5767,7 @@ dependencies = [ "rust_decimal", "serde", "serde_json", + "strum 0.27.2", "thiserror 2.0.12", ] @@ -5786,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", ] @@ -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/contracts/solana/programs/pyth-lazer-solana-contract/Cargo.toml b/lazer/contracts/solana/programs/pyth-lazer-solana-contract/Cargo.toml index 6de20c6870..fe1e913376 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.3" 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.18.1" } +pyth-lazer-protocol = { path = "../../../../sdk/rust/protocol", version = "0.19.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 1e71719e7d..ec488e4190 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.16.1" +version = "0.16.2" 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.18.1", path = "../../sdk/rust/protocol" } +pyth-lazer-protocol = { version = "0.19.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 a86a2bcfec..5bf3e7e14c 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.4.1" +version = "8.4.2" edition = "2021" description = "A Rust client for Pyth Lazer" license = "Apache-2.0" [dependencies] -pyth-lazer-protocol = { path = "../protocol", version = "0.18.1" } +pyth-lazer-protocol = { path = "../protocol", version = "0.19.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 6d018af070..104f4fcf5d 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.18.1" +version = "0.19.0" edition = "2021" description = "Pyth Lazer SDK - protocol types." license = "Apache-2.0" @@ -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 ff6f16da8f..2e21601d65 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; @@ -19,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; @@ -28,9 +32,11 @@ 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, + symbol_v3::SymbolV3, }; #[derive( diff --git a/lazer/sdk/rust/protocol/src/metadata.rs b/lazer/sdk/rust/protocol/src/metadata.rs new file mode 100644 index 0000000000..a6c0f8eb56 --- /dev/null +++ b/lazer/sdk/rust/protocol/src/metadata.rs @@ -0,0 +1,209 @@ +//! Types describing Lazer's metadata APIs. + +use crate::time::{DurationUs, TimestampUs}; +use crate::PriceFeedId; +use serde::{Deserialize, Serialize}; + +/// The pricing context or type of instrument for a feed. +/// 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")] +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. +/// 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 { + /// Cryptocurrency + Crypto, + /// Foreign exchange + Fx, + /// Equity + Equity, + /// Metal + Metal, + /// Rates + Rates, + /// Commodity + Commodity, +} + +/// 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"` + #[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, + /// Aggregator or producer of the prices. + /// 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;"` + pub schedule: String, + /// Power-of-ten exponent. Scale the `price` mantissa value by `10^exponent` to get the decimal representation. + /// Example: `-8` + pub exponent: i16, + /// Funding rate interval. Only applies to feeds with instrument type `funding_rate`. + /// Example: `10` + #[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: u16, + /// Status of the feed. + /// Example: `"active"` + 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"` + pub asset_type: String, + /// 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: String, +} + +/// 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: `"crypto"` + pub class: String, + /// 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 primary_exchange: Option, +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use crate::SymbolV3; + + use super::*; + + #[test] + fn test_feed_response_v3_json_serde_roundtrip() { + use crate::PriceFeedId; + + let symbol = SymbolV3::new( + "pyth".to_string(), + "spot".to_string(), + "btc".to_string(), + Some("usd".to_string()), + ); + + let feed_response = FeedResponseV3 { + id: PriceFeedId(1), + name: "Bitcoin / US Dollar".to_string(), + 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()), + 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: Some(DurationUs::from_secs_u32(10)), + min_publishers: 3, + 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: "price".to_string(), + }; + + // Test JSON serialization + let json = + serde_json::to_string(&feed_response).expect("Failed to serialize FeedResponseV3"); + 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"); + + // 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())); + } +} 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..fc8b159c09 --- /dev/null +++ b/lazer/sdk/rust/protocol/src/symbol_v3.rs @@ -0,0 +1,249 @@ +//! SymbolV3 type for validated symbol strings. + +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::str::FromStr; + +/// 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 (e.g., "spot", "redemptionrate", "fundingrate") + pub instrument_type: String, + /// The base asset ID (e.g., "btc", "alp") + pub base: 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: String, + base: String, + quote: Option, + ) -> 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 { + match &self.quote { + Some(quote) => format!( + "{}.{}.{}/{}", + self.source, self.instrument_type, self.base, quote + ), + None => format!("{}.{}.{}", self.source, self.instrument_type, self.base), + } + } +} + +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 = parts[1].to_string(); + let base_quote_part = parts[2]; + + // Split base/quote part + let base_quote: Vec<&str> = base_quote_part.split('/').collect(); + + 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()); + } + + Ok(Self::new(source, instrument_type, base, quote)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parsing_and_roundtrip() { + // Test parsing with quote + let cases_with_quote = vec![ + ("pyth.spot.btc/usd", "pyth", "spot", "btc", "usd"), + ( + "binance.fundingrate.eth/usdt", + "binance", + "fundingrate", + "eth", + "usdt", + ), + ( + "pyth.redemptionrate.alp/usd", + "pyth", + "redemptionrate", + "alp", + "usd", + ), + ]; + + 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); + } + + // 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"), + ]; + + 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 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 input in invalid { + assert!( + input.parse::().is_err(), + "Expected parsing to fail for: {input}" + ); + } + } + + #[test] + fn test_construction_and_display() { + // With quote + let with_quote = SymbolV3::new( + "pyth".to_string(), + "spot".to_string(), + "btc".to_string(), + Some("usd".to_string()), + ); + assert_eq!(with_quote.as_string(), "pyth.spot.btc/usd"); + + // Without quote + let without_quote = SymbolV3::new( + "pyth".to_string(), + "redemptionrate".to_string(), + "alp".to_string(), + None, + ); + assert_eq!(without_quote.as_string(), "pyth.redemptionrate.alp"); + } + + #[test] + fn test_serialization() { + // Test with quote + let symbol_with_quote = SymbolV3::new( + "pyth".to_string(), + "spot".to_string(), + "btc".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); + + // Test without quote + let symbol_without_quote = SymbolV3::new( + "pyth".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); + + // Test invalid deserialization + assert!(serde_json::from_str::("\"invalid.format\"").is_err()); + } +}