diff --git a/Cargo.lock b/Cargo.lock index 6ce85720ee..474331d5e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5540,8 +5540,8 @@ dependencies = [ "hyper 1.6.0", "hyper-util", "protobuf", - "pyth-lazer-protocol 0.7.2", - "pyth-lazer-publisher-sdk 0.1.5", + "pyth-lazer-protocol 0.7.3", + "pyth-lazer-publisher-sdk 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)", "serde", "serde_json", "soketto", @@ -5569,7 +5569,7 @@ dependencies = [ "futures-util", "hex", "libsecp256k1 0.7.2", - "pyth-lazer-protocol 0.7.3", + "pyth-lazer-protocol 0.8.0", "serde", "serde_json", "tokio", @@ -5580,9 +5580,9 @@ dependencies = [ [[package]] name = "pyth-lazer-protocol" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9bdf4e2ba853a8b437309487542e742c7d094d8db189db194cb538f2be02ecd" +checksum = "6445dc5d2f7fff7c677fb8edc5a080a82ef7583c1bdb39daa95421788c23f695" dependencies = [ "anyhow", "base64 0.22.1", @@ -5597,17 +5597,17 @@ dependencies = [ [[package]] name = "pyth-lazer-protocol" -version = "0.7.3" +version = "0.8.0" dependencies = [ "alloy-primitives 0.8.25", "anyhow", - "base64 0.22.1", "bincode 1.3.3", "bs58", "byteorder", "derive_more 1.0.0", "ed25519-dalek 2.1.1", "hex", + "humantime-serde", "itertools 0.13.0", "libsecp256k1 0.7.2", "protobuf", @@ -5618,16 +5618,14 @@ dependencies = [ [[package]] name = "pyth-lazer-publisher-sdk" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e633db28ca38210de8ab3e99d5bd85ad8cae08a08bb0292506340ee9d1c718" +version = "0.1.6" dependencies = [ "anyhow", "fs-err", "humantime", "protobuf", "protobuf-codegen", - "pyth-lazer-protocol 0.7.2", + "pyth-lazer-protocol 0.8.0", "serde-value", "tracing", ] @@ -5635,6 +5633,8 @@ dependencies = [ [[package]] name = "pyth-lazer-publisher-sdk" version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6ef4052ebf2a7943259b3d52a10b2231ffc346717735c50e44d73fe92019d5" dependencies = [ "anyhow", "fs-err", diff --git a/lazer/contracts/solana/Cargo.lock b/lazer/contracts/solana/Cargo.lock index d689daad3a..a4b07b8930 100644 --- a/lazer/contracts/solana/Cargo.lock +++ b/lazer/contracts/solana/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "Inflector" @@ -616,12 +616,6 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - [[package]] name = "base64ct" version = "1.6.0" @@ -2006,6 +2000,16 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b112acc8b3adf4b107a8ec20977da0273a8c386765a3ec0229bd500a1443f9f" +[[package]] +name = "humantime-serde" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c" +dependencies = [ + "humantime", + "serde", +] + [[package]] name = "hyper" version = "0.14.31" @@ -3200,12 +3204,12 @@ dependencies = [ [[package]] name = "pyth-lazer-protocol" -version = "0.7.3" +version = "0.8.0" dependencies = [ "anyhow", - "base64 0.22.1", "byteorder", "derive_more", + "humantime-serde", "itertools 0.13.0", "protobuf", "rust_decimal", @@ -3213,20 +3217,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "pyth-lazer-publisher-sdk" -version = "0.1.6" -dependencies = [ - "anyhow", - "fs-err", - "humantime", - "protobuf", - "protobuf-codegen", - "pyth-lazer-protocol", - "serde-value", - "tracing", -] - [[package]] name = "pyth-lazer-solana-contract" version = "0.4.2" 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 aa3e2a9f85..4888f6669e 100644 --- a/lazer/contracts/solana/programs/pyth-lazer-solana-contract/Cargo.toml +++ b/lazer/contracts/solana/programs/pyth-lazer-solana-contract/Cargo.toml @@ -19,7 +19,7 @@ no-log-ix-name = [] idl-build = ["anchor-lang/idl-build"] [dependencies] -pyth-lazer-protocol = { path = "../../../../sdk/rust/protocol", version = "0.7.2" } +pyth-lazer-protocol = { path = "../../../../sdk/rust/protocol", version = "0.8.0" } anchor-lang = "0.30.1" bytemuck = "1.20.0" diff --git a/lazer/publisher_sdk/rust/Cargo.toml b/lazer/publisher_sdk/rust/Cargo.toml index 13f8bb6c61..25c00f42c3 100644 --- a/lazer/publisher_sdk/rust/Cargo.toml +++ b/lazer/publisher_sdk/rust/Cargo.toml @@ -7,7 +7,7 @@ license = "Apache-2.0" repository = "https://github.com/pyth-network/pyth-crosschain" [dependencies] -pyth-lazer-protocol = { version = "0.7.2", path = "../../sdk/rust/protocol" } +pyth-lazer-protocol = { version = "0.8.0", path = "../../sdk/rust/protocol" } anyhow = "1.0.98" protobuf = "3.7.2" serde-value = "0.7.0" diff --git a/lazer/publisher_sdk/rust/src/lib.rs b/lazer/publisher_sdk/rust/src/lib.rs index bc341e2c5d..d027371f97 100644 --- a/lazer/publisher_sdk/rust/src/lib.rs +++ b/lazer/publisher_sdk/rust/src/lib.rs @@ -1,9 +1,12 @@ use std::{collections::BTreeMap, time::Duration}; +use crate::publisher_update::feed_update::Update; +use crate::publisher_update::{FeedUpdate, FundingRateUpdate, PriceUpdate}; use ::protobuf::MessageField; use anyhow::{bail, ensure, Context}; use humantime::format_duration; use protobuf::dynamic_value::{dynamic_value, DynamicValue}; +use pyth_lazer_protocol::jrpc::{FeedUpdateParams, UpdateParams}; use pyth_lazer_protocol::router::TimestampUs; pub mod transaction_envelope { @@ -164,3 +167,38 @@ impl TryFrom for serde_value::Value { } } } + +impl From for FeedUpdate { + fn from(value: FeedUpdateParams) -> Self { + FeedUpdate { + feed_id: Some(value.feed_id.0), + source_timestamp: value.source_timestamp.into(), + update: Some(value.update.into()), + special_fields: Default::default(), + } + } +} + +impl From for Update { + fn from(value: UpdateParams) -> Self { + match value { + UpdateParams::PriceUpdate { + price, + best_bid_price, + best_ask_price, + } => Update::PriceUpdate(PriceUpdate { + price: Some(price.0.into()), + best_bid_price: best_bid_price.map(|p| p.0.into()), + best_ask_price: best_ask_price.map(|p| p.0.into()), + special_fields: Default::default(), + }), + UpdateParams::FundingRateUpdate { price, rate } => { + Update::FundingRateUpdate(FundingRateUpdate { + price: price.map(|p| p.0.into()), + rate: Some(rate.0), + special_fields: Default::default(), + }) + } + } + } +} diff --git a/lazer/sdk/rust/client/Cargo.toml b/lazer/sdk/rust/client/Cargo.toml index 4670ea301b..405556457f 100644 --- a/lazer/sdk/rust/client/Cargo.toml +++ b/lazer/sdk/rust/client/Cargo.toml @@ -6,7 +6,7 @@ description = "A Rust client for Pyth Lazer" license = "Apache-2.0" [dependencies] -pyth-lazer-protocol = { path = "../protocol", version = "0.7.2" } +pyth-lazer-protocol = { path = "../protocol", version = "0.8.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 75ba2f0d08..d0ef7aa904 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.7.3" +version = "0.8.0" edition = "2021" description = "Pyth Lazer SDK - protocol types." license = "Apache-2.0" @@ -14,8 +14,8 @@ serde_json = "1.0" derive_more = { version = "1.0.0", features = ["from"] } itertools = "0.13.0" rust_decimal = "1.36.0" -base64 = "0.22.1" protobuf = "3.7.2" +humantime-serde = "1.1.1" [dev-dependencies] bincode = "1.3.3" diff --git a/lazer/sdk/rust/protocol/src/jrpc.rs b/lazer/sdk/rust/protocol/src/jrpc.rs new file mode 100644 index 0000000000..fa3fdce72c --- /dev/null +++ b/lazer/sdk/rust/protocol/src/jrpc.rs @@ -0,0 +1,398 @@ +use crate::router::{Channel, Price, PriceFeedId, Rate, TimestampUs}; +use crate::symbol_state::SymbolState; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +pub struct PythLazerAgentJrpcV1 { + pub jsonrpc: JsonRpcVersion, + #[serde(flatten)] + pub params: JrpcCall, + pub id: i64, +} + +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +#[serde(tag = "method", content = "params")] +#[serde(rename_all = "snake_case")] +pub enum JrpcCall { + PushUpdate(FeedUpdateParams), + GetMetadata(GetMetadataParams), +} + +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +pub struct FeedUpdateParams { + pub feed_id: PriceFeedId, + pub source_timestamp: TimestampUs, + pub update: UpdateParams, +} + +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +#[serde(tag = "type")] +pub enum UpdateParams { + #[serde(rename = "price")] + PriceUpdate { + price: Price, + best_bid_price: Option, + best_ask_price: Option, + }, + #[serde(rename = "funding_rate")] + FundingRateUpdate { price: Option, rate: Rate }, +} + +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +pub struct Filter { + pub name: Option, + pub asset_type: Option, +} + +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +pub struct GetMetadataParams { + pub names: Option>, + pub asset_types: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +pub enum JsonRpcVersion { + #[serde(rename = "2.0")] + V2, +} + +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +pub enum JrpcResponse { + Success(JrpcSuccessResponse), + Error(JrpcErrorResponse), +} + +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +pub struct JrpcSuccessResponse { + pub jsonrpc: JsonRpcVersion, + pub result: T, + pub id: i64, +} + +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +pub struct JrpcErrorResponse { + pub jsonrpc: JsonRpcVersion, + pub error: JrpcErrorObject, + pub id: Option, +} + +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +pub struct JrpcErrorObject { + pub code: i64, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +#[derive(Debug, Eq, PartialEq)] +pub enum JrpcError { + ParseError, + InternalError, +} + +// note: error codes can be found in the rfc https://www.jsonrpc.org/specification#error_object +impl From for JrpcErrorObject { + fn from(error: JrpcError) -> Self { + match error { + JrpcError::ParseError => JrpcErrorObject { + code: -32700, + message: "Parse error".to_string(), + data: None, + }, + JrpcError::InternalError => JrpcErrorObject { + code: -32603, + message: "Internal error".to_string(), + data: None, + }, + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)] +pub struct SymbolMetadata { + pub pyth_lazer_id: PriceFeedId, + pub name: String, + pub symbol: String, + pub description: String, + pub asset_type: String, + pub exponent: i16, + pub cmc_id: Option, + #[serde(default, with = "humantime_serde", alias = "interval")] + pub funding_rate_interval: Option, + pub min_publishers: u16, + pub min_channel: Channel, + pub state: SymbolState, + pub hermes_id: Option, + pub quote_currency: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::jrpc::JrpcCall::{GetMetadata, PushUpdate}; + + #[test] + fn test_push_update_price() { + let json = r#" + { + "jsonrpc": "2.0", + "method": "push_update", + "params": { + "feed_id": 1, + "source_timestamp": 124214124124, + + "update": { + "type": "price", + "price": 1234567890, + "best_bid_price": 1234567891, + "best_ask_price": 1234567892 + } + }, + "id": 1 + } + "#; + + let expected = PythLazerAgentJrpcV1 { + jsonrpc: JsonRpcVersion::V2, + params: PushUpdate(FeedUpdateParams { + feed_id: PriceFeedId(1), + source_timestamp: TimestampUs(124214124124), + update: UpdateParams::PriceUpdate { + price: Price::from_integer(1234567890, 0).unwrap(), + best_bid_price: Some(Price::from_integer(1234567891, 0).unwrap()), + best_ask_price: Some(Price::from_integer(1234567892, 0).unwrap()), + }, + }), + id: 1, + }; + + assert_eq!( + serde_json::from_str::(json).unwrap(), + expected + ); + } + + #[test] + fn test_push_update_price_without_bid_ask() { + let json = r#" + { + "jsonrpc": "2.0", + "method": "push_update", + "params": { + "feed_id": 1, + "source_timestamp": 124214124124, + + "update": { + "type": "price", + "price": 1234567890 + } + }, + "id": 1 + } + "#; + + let expected = PythLazerAgentJrpcV1 { + jsonrpc: JsonRpcVersion::V2, + params: PushUpdate(FeedUpdateParams { + feed_id: PriceFeedId(1), + source_timestamp: TimestampUs(124214124124), + update: UpdateParams::PriceUpdate { + price: Price::from_integer(1234567890, 0).unwrap(), + best_bid_price: None, + best_ask_price: None, + }, + }), + id: 1, + }; + + assert_eq!( + serde_json::from_str::(json).unwrap(), + expected + ); + } + + #[test] + fn test_push_update_funding_rate() { + let json = r#" + { + "jsonrpc": "2.0", + "method": "push_update", + "params": { + "feed_id": 1, + "source_timestamp": 124214124124, + + "update": { + "type": "funding_rate", + "price": 1234567890, + "rate": 1234567891 + } + }, + "id": 1 + } + "#; + + let expected = PythLazerAgentJrpcV1 { + jsonrpc: JsonRpcVersion::V2, + params: PushUpdate(FeedUpdateParams { + feed_id: PriceFeedId(1), + source_timestamp: TimestampUs(124214124124), + update: UpdateParams::FundingRateUpdate { + price: Some(Price::from_integer(1234567890, 0).unwrap()), + rate: Rate::from_integer(1234567891, 0).unwrap(), + }, + }), + id: 1, + }; + + assert_eq!( + serde_json::from_str::(json).unwrap(), + expected + ); + } + #[test] + fn test_push_update_funding_rate_without_price() { + let json = r#" + { + "jsonrpc": "2.0", + "method": "push_update", + "params": { + "feed_id": 1, + "source_timestamp": 124214124124, + + "update": { + "type": "funding_rate", + "rate": 1234567891 + } + }, + "id": 1 + } + "#; + + let expected = PythLazerAgentJrpcV1 { + jsonrpc: JsonRpcVersion::V2, + params: PushUpdate(FeedUpdateParams { + feed_id: PriceFeedId(1), + source_timestamp: TimestampUs(124214124124), + update: UpdateParams::FundingRateUpdate { + price: None, + rate: Rate::from_integer(1234567891, 0).unwrap(), + }, + }), + id: 1, + }; + + assert_eq!( + serde_json::from_str::(json).unwrap(), + expected + ); + } + + #[test] + fn test_send_get_metadata() { + let json = r#" + { + "jsonrpc": "2.0", + "method": "get_metadata", + "params": { + "names": ["BTC/USD"], + "asset_types": ["crypto"] + }, + "id": 1 + } + "#; + + let expected = PythLazerAgentJrpcV1 { + jsonrpc: JsonRpcVersion::V2, + params: GetMetadata(GetMetadataParams { + names: Some(vec!["BTC/USD".to_string()]), + asset_types: Some(vec!["crypto".to_string()]), + }), + id: 1, + }; + + assert_eq!( + serde_json::from_str::(json).unwrap(), + expected + ); + } + + #[test] + fn test_get_metadata_without_filters() { + let json = r#" + { + "jsonrpc": "2.0", + "method": "get_metadata", + "params": {}, + "id": 1 + } + "#; + + let expected = PythLazerAgentJrpcV1 { + jsonrpc: JsonRpcVersion::V2, + params: GetMetadata(GetMetadataParams { + names: None, + asset_types: None, + }), + id: 1, + }; + + assert_eq!( + serde_json::from_str::(json).unwrap(), + expected + ); + } + + #[test] + fn test_response_format_error() { + let response = serde_json::from_str::( + r#" + { + "jsonrpc": "2.0", + "id": 2, + "error": { + "message": "Internal error", + "code": -32603 + } + } + "#, + ) + .unwrap(); + + assert_eq!( + response, + JrpcErrorResponse { + jsonrpc: JsonRpcVersion::V2, + error: JrpcErrorObject { + code: -32603, + message: "Internal error".to_string(), + data: None, + }, + id: Some(2), + } + ); + } + + #[test] + pub fn test_response_format_success() { + let response = serde_json::from_str::>( + r#" + { + "jsonrpc": "2.0", + "id": 2, + "result": "success" + } + "#, + ) + .unwrap(); + + assert_eq!( + response, + JrpcSuccessResponse:: { + jsonrpc: JsonRpcVersion::V2, + result: "success".to_string(), + id: 2, + } + ); + } +} diff --git a/lazer/sdk/rust/protocol/src/lib.rs b/lazer/sdk/rust/protocol/src/lib.rs index d10bedeebc..ded13bec8c 100644 --- a/lazer/sdk/rust/protocol/src/lib.rs +++ b/lazer/sdk/rust/protocol/src/lib.rs @@ -2,6 +2,7 @@ pub mod api; pub mod binary_update; +pub mod jrpc; pub mod message; pub mod payload; pub mod publisher; @@ -23,7 +24,7 @@ fn magics_in_big_endian() { }; // The values listed in this test can be used when reading the magic headers in BE format - // (e.g. on EVM). + // (e.g., on EVM). assert_eq!(u32::swap_bytes(BINARY_UPDATE_FORMAT_MAGIC), 1937213467); assert_eq!(u32::swap_bytes(PAYLOAD_FORMAT_MAGIC), 1976813459); @@ -44,6 +45,6 @@ fn magics_in_big_endian() { LE_UNSIGNED_FORMAT_MAGIC, ] { // Required to distinguish between byte orders. - assert!(u32::swap_bytes(magic) != magic); + assert_ne!(u32::swap_bytes(magic), magic); } } diff --git a/lazer/sdk/rust/protocol/src/router.rs b/lazer/sdk/rust/protocol/src/router.rs index 356b7dab88..29dda4f298 100644 --- a/lazer/sdk/rust/protocol/src/router.rs +++ b/lazer/sdk/rust/protocol/src/router.rs @@ -1,5 +1,6 @@ -//! WebSocket JSON protocol types for API the router provides to consumers and publishers. +//! WebSocket JSON protocol types for the API the router provides to consumers and publishers. +use protobuf::MessageField; use { crate::payload::AggregatedPriceFeedData, anyhow::{bail, Context}, @@ -37,6 +38,26 @@ impl TryFrom<&Timestamp> for TimestampUs { } } +impl From for Timestamp { + fn from(value: TimestampUs) -> Self { + Timestamp { + #[allow( + clippy::cast_possible_wrap, + reason = "u64 to i64 after this division can never overflow because the value cannot be too big" + )] + seconds: (value.0 / 1_000_000) as i64, + nanos: (value.0 % 1_000_000) as i32 * 1000, + special_fields: Default::default(), + } + } +} + +impl From for MessageField { + fn from(value: TimestampUs) -> Self { + MessageField::some(value.into()) + } +} + impl TimestampUs { pub fn now() -> Self { let value = SystemTime::now() @@ -304,7 +325,7 @@ impl<'de> Deserialize<'de> for Channel { D: serde::Deserializer<'de>, { let value = ::deserialize(deserializer)?; - parse_channel(&value).ok_or_else(|| D::Error::custom("unknown channel")) + parse_channel(&value).ok_or_else(|| Error::custom("unknown channel")) } } @@ -341,12 +362,14 @@ fn fixed_rate_values() { "values must be unique and sorted" ); for value in FixedRate::ALL { - assert!( - 1000 % value.ms == 0, + assert_eq!( + 1000 % value.ms, + 0, "1 s must contain whole number of intervals" ); - assert!( - value.value_us() % FixedRate::MIN.value_us() == 0, + assert_eq!( + value.value_us() % FixedRate::MIN.value_us(), + 0, "the interval's borders must be a subset of the minimal interval's borders" ); } @@ -383,7 +406,7 @@ impl<'de> Deserialize<'de> for SubscriptionParams { D: serde::Deserializer<'de>, { let value = SubscriptionParamsRepr::deserialize(deserializer)?; - Self::new(value).map_err(D::Error::custom) + Self::new(value).map_err(Error::custom) } }