Skip to main content

pyth_lazer_protocol/
hermes.rs

1use {
2    crate::api::{Channel, MerklePriceFeedId},
3    anyhow::{anyhow, bail, Result},
4    serde::{Deserialize, Serialize},
5    serde_with::{serde_as, DisplayFromStr},
6    std::fmt,
7};
8
9#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
10#[cfg_attr(
11    feature = "utoipa",
12    schema(example = "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43")
13)]
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15#[serde(transparent)]
16/// A price id is a 32-byte hex string, optionally prefixed with "0x".
17/// Price ids are case insensitive.
18///
19/// Examples:
20/// * 0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43
21/// * e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43
22///
23/// See https://pyth.network/developers/price-feed-ids for a list of all price feed ids.
24pub struct PriceIdInput(pub String);
25
26impl PriceIdInput {
27    fn is_valid(&self) -> bool {
28        let normalized = self.normalize();
29        normalized.0.len() == 64 && normalized.0.bytes().all(|byte| byte.is_ascii_hexdigit())
30    }
31
32    fn normalize(&self) -> Self {
33        let normalized = self
34            .0
35            .strip_prefix("0x")
36            .or_else(|| self.0.strip_prefix("0X"))
37            .unwrap_or(&self.0);
38        Self(normalized.to_string())
39    }
40
41    pub fn parse(&self) -> Result<MerklePriceFeedId> {
42        if !self.is_valid() {
43            bail!("Invalid price id: {}", self.0);
44        }
45        let normalized = self.normalize();
46        let bytes = hex::decode(normalized.0)
47            .map_err(|e| anyhow!("Failed to decode price id: {}, error: {}", self.0, e))?;
48        bytes
49            .try_into()
50            .map_err(|_| anyhow!("Invalid price length: {}", self.0))
51    }
52}
53
54#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
55#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
56#[serde(rename_all = "snake_case")]
57pub enum AssetType {
58    Crypto,
59    Fx,
60    Equity,
61    Metal,
62    Rates,
63    CryptoRedemptionRate,
64    Commodities,
65    CryptoIndex,
66    CryptoNav,
67    Eco,
68    Kalshi,
69}
70
71impl fmt::Display for AssetType {
72    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
73        match self {
74            AssetType::Crypto => write!(f, "crypto"),
75            AssetType::Fx => write!(f, "fx"),
76            AssetType::Equity => write!(f, "equity"),
77            AssetType::Metal => write!(f, "metal"),
78            AssetType::Rates => write!(f, "rates"),
79            AssetType::CryptoRedemptionRate => write!(f, "crypto_redemption_rate"),
80            AssetType::Commodities => write!(f, "commodities"),
81            AssetType::CryptoIndex => write!(f, "crypto_index"),
82            AssetType::CryptoNav => write!(f, "crypto_nav"),
83            AssetType::Eco => write!(f, "eco"),
84            AssetType::Kalshi => write!(f, "kalshi"),
85        }
86    }
87}
88
89#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
90#[cfg_attr(
91    feature = "utoipa",
92    schema(example = "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43")
93)]
94#[derive(Debug, Clone, Hash, Serialize, Deserialize, PartialEq, Eq)]
95#[serde(transparent)]
96pub struct RpcPriceIdentifier(pub String);
97
98#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
99#[derive(Debug, Clone, Hash, Serialize, Deserialize)]
100pub struct ParsedPriceUpdate {
101    pub ema_price: RpcPrice,
102    pub id: RpcPriceIdentifier,
103    pub metadata: RpcPriceFeedMetadataV2,
104    pub price: RpcPrice,
105}
106
107#[serde_as]
108#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
109#[derive(Debug, Clone, Hash, Serialize, Deserialize)]
110/// A price with a degree of uncertainty at a certain time, represented as a price +- a confidence
111/// interval.
112///
113/// The confidence interval roughly corresponds to the standard error of a normal distribution.
114/// Both the price and confidence are stored in a fixed-point numeric representation, `x *
115/// 10^expo`, where `expo` is the exponent. For example:
116pub struct RpcPrice {
117    /// The confidence interval associated with the price, stored as a string to avoid precision loss
118    #[cfg_attr(feature = "utoipa", schema(value_type = String, example = "509500001"))]
119    #[serde_as(as = "DisplayFromStr")]
120    pub conf: u64,
121    /// The exponent associated with both the price and confidence interval. Multiply those values
122    /// by `10^expo` to get the real value.
123    #[cfg_attr(feature = "utoipa", schema(example = -8))]
124    pub expo: i32,
125    /// The price itself, stored as a string to avoid precision loss
126    #[cfg_attr(feature = "utoipa", schema(value_type = String, example = "2920679499999"))]
127    #[serde_as(as = "DisplayFromStr")]
128    pub price: i64,
129    /// When the price was published. The `publish_time` is a unix timestamp, i.e., the number of
130    /// seconds since the Unix epoch (00:00:00 UTC on 1 Jan 1970).
131    #[cfg_attr(feature = "utoipa", schema(example = 1717632000))]
132    pub publish_time: i64,
133}
134
135#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
136#[derive(Debug, Clone, Hash, Serialize, Deserialize)]
137pub struct RpcPriceFeedMetadataV2 {
138    #[cfg_attr(feature = "utoipa", schema(example = 1717632000))]
139    pub prev_publish_time: i64,
140    #[cfg_attr(feature = "utoipa", schema(example = 1717632000))]
141    pub proof_available_time: i64,
142    #[cfg_attr(feature = "utoipa", schema(minimum = 0, example = 85480034))]
143    pub slot: i64,
144}
145
146#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct PriceFeedAttributes {
149    pub asset_type: String,
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub base: Option<String>,
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub cms_symbol: Option<String>,
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub country: Option<String>,
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub cqs_symbol: Option<String>,
158    pub description: String,
159    pub display_symbol: String,
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub generic_symbol: Option<String>,
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub nasdaq_symbol: Option<String>,
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub publish_interval: Option<String>,
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub quote_currency: Option<String>,
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub schedule: Option<String>,
170    pub symbol: String,
171    pub min_channel: Channel,
172}
173
174#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct PriceFeedMetadata {
177    pub id: RpcPriceIdentifier,
178    pub attributes: PriceFeedAttributes,
179}
180
181#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
182#[derive(Debug, Clone, Hash, Serialize, Deserialize)]
183pub struct WsPriceFeed {
184    pub id: RpcPriceIdentifier,
185    pub price: RpcPrice,
186    pub ema_price: RpcPrice,
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub metadata: Option<WsPriceFeedMetadata>,
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub vaa: Option<String>,
191}
192
193#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
194#[derive(Debug, Clone, Hash, Serialize, Deserialize)]
195pub struct WsPriceFeedMetadata {
196    pub slot: u64,
197    pub emitter_chain: u16,
198    pub price_service_receive_time: i64,
199    pub prev_publish_time: i64,
200}
201
202/// Incoming JSON on Hermes WebSocket `/ws` (legacy Hermes wire format; flat `type` + fields).
203#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
204#[derive(Debug, Clone, Serialize, Deserialize)]
205#[serde(tag = "type")]
206pub enum HermesWsClientMessage {
207    #[serde(rename = "subscribe")]
208    Subscribe {
209        ids: Vec<PriceIdInput>,
210        #[serde(default)]
211        verbose: bool,
212        #[serde(default)]
213        binary: bool,
214        #[serde(default)]
215        #[allow(dead_code)]
216        // reason = Kept for backward compatibility. Lazer merkle updates cannot be out-of-order.
217        allow_out_of_order: bool,
218        #[serde(default)]
219        ignore_invalid_price_ids: bool,
220    },
221    #[serde(rename = "unsubscribe")]
222    Unsubscribe { ids: Vec<PriceIdInput> },
223}
224
225/// Outgoing JSON on Hermes WebSocket `/ws` (legacy Hermes wire format).
226#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
227#[derive(Debug, Clone, Hash, Serialize, Deserialize)]
228#[serde(tag = "type")]
229#[allow(clippy::large_enum_variant)]
230// PriceUpdate is the most frequent message
231pub enum HermesWsServerMessage {
232    #[serde(rename = "response")]
233    Response(HermesWsServerResponse),
234    #[serde(rename = "price_update")]
235    PriceUpdate { price_feed: WsPriceFeed },
236}
237
238/// Body of a `{"type":"response",...}` message (`status` + optional `error`).
239#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
240#[derive(Debug, Clone, Hash, Serialize, Deserialize)]
241#[serde(tag = "status")]
242pub enum HermesWsServerResponse {
243    #[serde(rename = "success")]
244    Success,
245    #[serde(rename = "error")]
246    Err { error: String },
247}
248
249#[cfg(test)]
250mod tests {
251    use super::PriceIdInput;
252
253    #[test]
254    fn validates_price_id_with_and_without_prefix() {
255        let valid = &PriceIdInput(
256            "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43".to_string(),
257        );
258        assert!(valid.is_valid());
259
260        let valid = &PriceIdInput(
261            "0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43".to_string(),
262        );
263        assert!(valid.is_valid());
264
265        let valid = &PriceIdInput(
266            "0Xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43".to_string(),
267        );
268        assert!(valid.is_valid());
269    }
270
271    #[test]
272    fn rejects_invalid_price_id() {
273        let invalid = &PriceIdInput("abc123".to_string());
274        assert!(!invalid.is_valid());
275
276        let invalid = &PriceIdInput(
277            "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b4z".to_string(),
278        );
279        assert!(!invalid.is_valid());
280    }
281}