diff --git a/Cargo.lock b/Cargo.lock index 92df432fe6..31f3fadb99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5698,6 +5698,7 @@ dependencies = [ "derive_more 1.0.0", "ed25519-dalek 2.1.1", "hex", + "humantime", "humantime-serde", "itertools 0.13.0", "libsecp256k1 0.7.2", @@ -5713,12 +5714,15 @@ name = "pyth-lazer-publisher-sdk" version = "0.1.7" dependencies = [ "anyhow", + "derive_more 2.0.1", "fs-err", + "hex", "humantime", "protobuf", "protobuf-codegen", "pyth-lazer-protocol 0.9.0", - "serde-value", + "serde", + "serde_json", "tracing", ] diff --git a/lazer/publisher_sdk/proto/dynamic_value.proto b/lazer/publisher_sdk/proto/dynamic_value.proto index 6d15d73a6b..14800e784f 100644 --- a/lazer/publisher_sdk/proto/dynamic_value.proto +++ b/lazer/publisher_sdk/proto/dynamic_value.proto @@ -3,7 +3,7 @@ syntax = "proto3"; import "google/protobuf/timestamp.proto"; import "google/protobuf/duration.proto"; -package pyth_lazer_transaction; +package pyth_lazer; // A dynamically typed value similar to `google.protobuf.Value` // but supporting more types. diff --git a/lazer/publisher_sdk/proto/governance_instruction.proto b/lazer/publisher_sdk/proto/governance_instruction.proto index 9f655a32e6..eada3b465f 100644 --- a/lazer/publisher_sdk/proto/governance_instruction.proto +++ b/lazer/publisher_sdk/proto/governance_instruction.proto @@ -5,11 +5,12 @@ import "google/protobuf/duration.proto"; import "google/protobuf/empty.proto"; import "dynamic_value.proto"; +import "state.proto"; // If any field documented as `[required]` is not present in the instruction, // the instruction will be rejected. -package pyth_lazer_transaction; +package pyth_lazer; // Representation of a complete governance instruction. This value will be signed // by a governance source. @@ -130,9 +131,10 @@ message Permissions { enum UpdateFeedAction { // Required by protobuf. Instruction will be rejected if this value is encountered. UPDATE_FEED_ACTION_UNSPECIFIED = 0; - UPDATE_FEED_METADATA = 101; - ACTIVATE_FEED = 102; - DEACTIVATE_FEED = 103; + UPDATE_FEED_PROPERTIES = 101; + UPDATE_FEED_METADATA = 102; + ENABLE_FEED_IN_SHARD = 103; + DISABLE_FEED_IN_SHARD = 104; REMOVE_FEED = 199; } @@ -302,17 +304,33 @@ message SetPublisherActive { optional bool is_active = 1; } -// Feed is inactive when added, meaning that it will be available to publishers but not to consumers. +// Add a new feed. Refer to `Feed` message fields documentation. message AddFeed { - // [required] ID of the feed. Must be unique (within the shard). + // [required] optional uint32 feed_id = 1; - // [required] Feed metadata. Some properties are required (name, exponent, etc.). - // Known properties must have the expected type. - // Additional arbitrary properties are allowed. - // (TODO: document known metadata properties) - optional DynamicValue.Map metadata = 2; - // IDs of publishers enabled for this feed. - repeated uint32 permissioned_publishers = 3; + // [required] + optional DynamicValue.Map metadata = 3; + // [required] + optional string name = 101; + // [required] + optional sint32 exponent = 102; + // [required] + optional uint32 min_publishers = 103; + // [required] + optional google.protobuf.Duration min_rate = 104; + // [required] + optional google.protobuf.Duration expiry_time = 105; + // [required] + optional string market_schedule = 106; + // [required] + optional FeedState state = 107; + // [required] + optional FeedKind kind = 108; + // [required] + optional bool is_enabled_in_shard = 201; + + // TODO: IDs of publishers enabled for this feed. + // repeated uint32 permissioned_publishers = 3; } message UpdateFeed { @@ -321,13 +339,38 @@ message UpdateFeed { // [required] // Note: when adding a new variant here, update `Permissions` as well. oneof action { - UpdateFeedMetadata update_feed_metadata = 101; - ActivateFeed activate_feed = 102; - DeactivateFeed deactivate_feed = 103; + UpdateFeedProperties update_feed_properties = 101; + UpdateFeedMetadata update_feed_metadata = 102; + EnableFeedInShard enable_feed_in_shard = 103; + DisableFeedInShard disable_feed_in_shard = 104; google.protobuf.Empty remove_feed = 199; } } +// Update a feed's properties. The feed will be updated with values present in each field. +// If a value is not supplied, the corresponding property will remain unchanged. +// Refer to `Feed` message fields documentation. +message UpdateFeedProperties { + // [optional] + optional DynamicValue.Map metadata = 3; + // [optional] + optional string name = 101; + // [optional] + optional sint32 exponent = 102; + // [optional] + optional uint32 min_publishers = 103; + // [optional] + optional google.protobuf.Duration min_rate = 104; + // [optional] + optional google.protobuf.Duration expiry_time = 105; + // [optional] + optional string market_schedule = 106; + // [optional] + optional FeedState state = 107; + // [optional] + optional bool is_enabled_in_shard = 201; +} + message UpdateFeedMetadata { // [required] Property name. optional string name = 1; @@ -335,29 +378,29 @@ message UpdateFeedMetadata { optional DynamicValue value = 2; } -// Set the feed as active or shedule an activation. -// If there was already a pending activation or deactivation, it will be cleared +// Set the feed as enabled in this shard or shedule it for a certain timestamp. +// If there was already a pending status change, it will be cleared // when this governance instruction is processed. -// Warning: there must never be two feeds with the same name active at the same time +// Warning: there must never be two feeds with the same name enabled at the same time // within a shard group. This cannot be enforced within a shard. When a feed needs to be -// moved between shards, use `activation_timestamp` and `deactivation_timestamp` -// to deactivate it in the old shard and activate it in the new shard at the same time. -message ActivateFeed { - // [optional] If provided, the feed will activate at the specified timestamp. - // If `activation_timestamp` is already passed or if it's unset, - // the feed will be activated immediately when this +// moved between shards, use `enable_in_shard_timestamp` and `disable_in_shard_timestamp` +// to disable it in the old shard and enable it in the new shard at the same time. +message EnableFeedInShard { + // [optional] If provided, the feed will be enabled at the specified timestamp. + // If `enable_in_shard_timestamp` is already passed or if it's unset, + // the feed will be enabled immediately when this // governance instruction is processed. - optional google.protobuf.Timestamp activation_timestamp = 1; + optional google.protobuf.Timestamp enable_in_shard_timestamp = 1; } -// Set the feed as inactive or shedule a deactivation. -// If there was already a pending activation or deactivation, it will be cleared +// Set the feed as disabled in this shard or shedule it for a certain timestamp. +// If there was already a pending status change, it will be cleared // when this governance instruction is processed. -// See also: `ActivateFeed` docs. -message DeactivateFeed { - // [optional] If provided, the feed will deactivate at the specified timestamp. - // If `deactivation_timestamp` is already passed or if it's unset, - // the feed will be deactivated immediately when this +// See also: `EnableFeedInShard` docs. +message DisableFeedInShard { + // [optional] If provided, the feed will be disabled at the specified timestamp. + // If `disable_in_shard_timestamp` is already passed or if it's unset, + // the feed will be disabled immediately when this // governance instruction is processed. - optional google.protobuf.Timestamp deactivation_timestamp = 1; + optional google.protobuf.Timestamp disable_in_shard_timestamp = 1; } diff --git a/lazer/publisher_sdk/proto/publisher_update.proto b/lazer/publisher_sdk/proto/publisher_update.proto index 916837dedc..6bf249ce8d 100644 --- a/lazer/publisher_sdk/proto/publisher_update.proto +++ b/lazer/publisher_sdk/proto/publisher_update.proto @@ -1,5 +1,5 @@ syntax = "proto3"; -package pyth_lazer_transaction; +package pyth_lazer; import "google/protobuf/timestamp.proto"; @@ -28,7 +28,7 @@ message FeedUpdate { // [required] timestamp when this data was first acquired or generated optional google.protobuf.Timestamp source_timestamp = 2; - // [required] one type of update containing specific data + // [required] one type of update containing specific data oneof update { PriceUpdate price_update = 3; FundingRateUpdate funding_rate_update = 4; diff --git a/lazer/publisher_sdk/proto/pyth_lazer_transaction.proto b/lazer/publisher_sdk/proto/pyth_lazer_transaction.proto index f755c4b7a6..deb75adef3 100644 --- a/lazer/publisher_sdk/proto/pyth_lazer_transaction.proto +++ b/lazer/publisher_sdk/proto/pyth_lazer_transaction.proto @@ -1,5 +1,5 @@ syntax = "proto3"; -package pyth_lazer_transaction; +package pyth_lazer; import "publisher_update.proto"; import "governance_instruction.proto"; diff --git a/lazer/publisher_sdk/proto/state.proto b/lazer/publisher_sdk/proto/state.proto index d98e42f246..b13569e211 100644 --- a/lazer/publisher_sdk/proto/state.proto +++ b/lazer/publisher_sdk/proto/state.proto @@ -1,9 +1,11 @@ syntax = "proto3"; -package lazer; +package pyth_lazer; import "google/protobuf/duration.proto"; import "google/protobuf/timestamp.proto"; +import "dynamic_value.proto"; + // All optional fields should always be set unless documented otherwise. // State of a Pyth Lazer shard. @@ -45,67 +47,76 @@ message Publisher { } enum FeedState { - COMING_SOON = 0; // Default value + // Default value. Feeds in this state are not available to consumers. + // `COMING_SOON` feeds are expected to become stable in the future. + COMING_SOON = 0; + // A fully available feed. STABLE = 1; + // Inactive feeds are not available to consumers. + // `INACTIVE` feeds are not expected to become stable again. INACTIVE = 2; } -// Static data for a feed. -message FeedMetadata { - // [required] ID of the price feed. - optional uint32 price_feed_id = 1; - // [required] Feed name. - optional string name = 2; - // [required] Feed symbol. - optional string symbol = 3; - // [required] Feed description. - optional string description = 4; - // [required] Feed asset type. - optional string asset_type = 5; +// Feed kind determines the set of data fields available in the feed. +// It also determines the kind of data accepted from publishers for this feed +// (`PriceUpdate` or `FundingRateUpdate`). +enum FeedKind { + // Fields: price, best_bid_price, best_ask_price + PRICE = 0; + // Fields: price, rate. + FUNDING_RATE = 1; +} + +// An item of the state describing a feed. +message Feed { + // [required] ID of the feed. + optional uint32 feed_id = 1; + // Additional state per publisher. + // If an eligible publisher is not listed here, the corresponding state should be considered empty. + repeated FeedPublisherState per_publisher = 2; + // [required] Additional metadata values. These values will be exposed in the APIs, but + // are not directly used in the aggregator. + optional DynamicValue.Map metadata = 3; + + // [required] A readable feed name. It must be unique across all feeds in the shard. + // Used for logs, metrics, feed search API, TradingView API. + optional string name = 101; // [required] Exponent applied to all price and rate values for this feed. // Actual value is `mantissa * 10 ^ exponent`. // Restricted to int16. - optional sint32 exponent = 6; - // [optional] CoinMarketCap ID. Can be absent if there is no CoinMarketCap ID for this symbol. - optional uint32 cmc_id = 7; - // [optional] Funding rate interval. Only present for funding rate feeds. - optional google.protobuf.Duration funding_rate_interval = 8; + optional sint32 exponent = 102; // [required] Minimal number of publisher prices required to produce an aggregate. - optional uint32 min_publishers = 9; + optional uint32 min_publishers = 103; // [required] Minimal rate of aggregation performed by the aggregator for this feed. // Cannot be lower than the shard's top level `State.min_rate`. - optional google.protobuf.Duration min_rate = 10; + optional google.protobuf.Duration min_rate = 104; // [required] Time after which the publisher update is discarded. - optional google.protobuf.Duration expiry_time = 11; - // [required] If true, the feed is visible to the consumers. This can be used to prepare and verify - // new feeds before releasing them. This can also be used to migrate a feed from - // one shard to another. If a feed is present in + optional google.protobuf.Duration expiry_time = 105; + // [required] Market schedule in Pythnet format. + optional string market_schedule = 106; + // [required] Feed state. + optional FeedState state = 107; + // [required] Feed kind. + optional FeedKind kind = 108; + + + // [required] Feed status in the current shard. Disabled feeds will not be visible in + // the consumer API for the current shard. This setting should only be used + // to migrate a feed from one shard to another. + // + // If a feed is present in // multiple shards, it must only be active in one of them at each time. - // To enforce this, `pending_activation` and `pending_deactivation` fields + // To enforce this, `enable_in_shard_timestamp` and `disable_in_shard_timestamp` fields // can be used to deactivate a feed in one shard and activate it in another shard // at the same instant. - optional bool is_activated = 12; - // [optional] ID of the corresponding price feed in Hermes (Pythnet). - optional string hermes_id = 13; - // [optional] Quote currency of the asset. - optional string quote_currency = 14; - // [optional] Market schedule in Pythnet format. - // If absent, the default schedule is used (market is always open). - optional string market_schedule = 15; - // [required] Feed state - optional FeedState state = 16; -} + optional bool is_enabled_in_shard = 201; + // [optional] If present, the aggregator will enable the feed in the current shard + // at the specified instant. + optional google.protobuf.Timestamp enable_in_shard_timestamp = 202; + // [optional] If present, the aggregator will disable the feed in the current shard + // at the specified instant. + optional google.protobuf.Timestamp disable_in_shard_timestamp = 203; -// An item of the state describing a feed. -message Feed { - optional FeedMetadata metadata = 1; - // [optional] If present, the aggregator will activate the feed at the specified instant. - optional google.protobuf.Timestamp pending_activation = 2; - // [optional] If present, the aggregator will deactivate the feed at the specified instant. - optional google.protobuf.Timestamp pending_deactivation = 3; - // Additional state per publisher. - // If an eligible publisher is not listed here, the corresponding state should be considered empty. - repeated FeedPublisherState per_publisher = 4; // TODO: list of permissioned publisher IDs. } diff --git a/lazer/publisher_sdk/proto/transaction_envelope.proto b/lazer/publisher_sdk/proto/transaction_envelope.proto index 750f98606d..d6c5f1056f 100644 --- a/lazer/publisher_sdk/proto/transaction_envelope.proto +++ b/lazer/publisher_sdk/proto/transaction_envelope.proto @@ -1,5 +1,5 @@ syntax = "proto3"; -package pyth_lazer_transaction; +package pyth_lazer; import "google/protobuf/timestamp.proto"; import "pyth_lazer_transaction.proto"; diff --git a/lazer/publisher_sdk/rust/Cargo.toml b/lazer/publisher_sdk/rust/Cargo.toml index aaa919aae5..3e88658b1a 100644 --- a/lazer/publisher_sdk/rust/Cargo.toml +++ b/lazer/publisher_sdk/rust/Cargo.toml @@ -10,9 +10,12 @@ repository = "https://github.com/pyth-network/pyth-crosschain" pyth-lazer-protocol = { version = "0.9.0", path = "../../sdk/rust/protocol" } anyhow = "1.0.98" protobuf = "3.7.2" -serde-value = "0.7.0" humantime = "2.2.0" tracing = "0.1.41" +serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.140" +derive_more = { version = "2.0.1", features = ["from"] } +hex = "0.4.3" [build-dependencies] fs-err = "3.1.0" diff --git a/lazer/publisher_sdk/rust/src/convert_dynamic_value.rs b/lazer/publisher_sdk/rust/src/convert_dynamic_value.rs new file mode 100644 index 0000000000..2936f8d6af --- /dev/null +++ b/lazer/publisher_sdk/rust/src/convert_dynamic_value.rs @@ -0,0 +1,178 @@ +#[cfg(test)] +mod tests; + +use std::collections::BTreeMap; + +use crate::protobuf::dynamic_value::{dynamic_value, DynamicValue as ProtobufDynamicValue}; +use ::protobuf::MessageField; +use anyhow::{ensure, Context}; +use pyth_lazer_protocol::{ + time::{DurationUs, TimestampUs}, + DynamicValue, +}; + +impl From for ProtobufDynamicValue { + fn from(value: DynamicValue) -> Self { + let converted = match value { + DynamicValue::Bool(value) => dynamic_value::Value::BoolValue(value), + DynamicValue::U64(value) => dynamic_value::Value::UintValue(value), + DynamicValue::I64(value) => dynamic_value::Value::IntValue(value), + DynamicValue::F64(value) => dynamic_value::Value::DoubleValue(value), + DynamicValue::String(value) => dynamic_value::Value::StringValue(value), + DynamicValue::Bytes(value) => dynamic_value::Value::BytesValue(value), + DynamicValue::Timestamp(value) => dynamic_value::Value::TimestampValue(value.into()), + DynamicValue::Duration(value) => dynamic_value::Value::DurationValue(value.into()), + DynamicValue::List(values) => dynamic_value::Value::List(values.into()), + DynamicValue::Map(values) => dynamic_value::Value::Map(values.into()), + }; + ProtobufDynamicValue { + value: Some(converted), + special_fields: Default::default(), + } + } +} + +impl From<&DynamicValue> for ProtobufDynamicValue { + fn from(value: &DynamicValue) -> Self { + let converted = match value { + DynamicValue::Bool(value) => dynamic_value::Value::BoolValue(*value), + DynamicValue::U64(value) => dynamic_value::Value::UintValue(*value), + DynamicValue::I64(value) => dynamic_value::Value::IntValue(*value), + DynamicValue::F64(value) => dynamic_value::Value::DoubleValue(*value), + DynamicValue::String(value) => dynamic_value::Value::StringValue(value.clone()), + DynamicValue::Bytes(value) => dynamic_value::Value::BytesValue(value.clone()), + DynamicValue::Timestamp(value) => dynamic_value::Value::TimestampValue((*value).into()), + DynamicValue::Duration(value) => dynamic_value::Value::DurationValue((*value).into()), + DynamicValue::List(values) => dynamic_value::Value::List(values.into()), + DynamicValue::Map(values) => dynamic_value::Value::Map(values.into()), + }; + ProtobufDynamicValue { + value: Some(converted), + special_fields: Default::default(), + } + } +} + +impl From> for dynamic_value::Map { + fn from(values: BTreeMap) -> Self { + let mut items = Vec::new(); + for (key, value) in values { + items.push(dynamic_value::MapItem { + key: Some(key), + value: MessageField::some(value.into()), + special_fields: Default::default(), + }) + } + dynamic_value::Map { + items, + special_fields: Default::default(), + } + } +} + +impl From<&BTreeMap> for dynamic_value::Map { + fn from(values: &BTreeMap) -> Self { + let mut items = Vec::new(); + for (key, value) in values { + items.push(dynamic_value::MapItem { + key: Some(key.clone()), + value: MessageField::some(value.into()), + special_fields: Default::default(), + }) + } + dynamic_value::Map { + items, + special_fields: Default::default(), + } + } +} + +impl From> for dynamic_value::List { + fn from(values: Vec) -> Self { + let mut items = Vec::new(); + for value in values { + items.push(value.into()); + } + dynamic_value::List { + items, + special_fields: Default::default(), + } + } +} + +impl From<&[DynamicValue]> for dynamic_value::List { + fn from(values: &[DynamicValue]) -> Self { + let mut items = Vec::new(); + for value in values { + items.push(value.into()); + } + dynamic_value::List { + items, + special_fields: Default::default(), + } + } +} + +impl From<&Vec> for dynamic_value::List { + fn from(value: &Vec) -> Self { + let value: &[DynamicValue] = value; + value.into() + } +} + +impl TryFrom for DynamicValue { + type Error = anyhow::Error; + + fn try_from(value: ProtobufDynamicValue) -> Result { + let value = value.value.context("missing DynamicValue.value")?; + match value { + dynamic_value::Value::StringValue(value) => Ok(DynamicValue::String(value)), + dynamic_value::Value::DoubleValue(value) => Ok(DynamicValue::F64(value)), + dynamic_value::Value::UintValue(value) => Ok(DynamicValue::U64(value)), + dynamic_value::Value::IntValue(value) => Ok(DynamicValue::I64(value)), + dynamic_value::Value::BoolValue(value) => Ok(DynamicValue::Bool(value)), + dynamic_value::Value::BytesValue(value) => Ok(DynamicValue::Bytes(value)), + dynamic_value::Value::DurationValue(value) => { + let v: DurationUs = value.try_into()?; + Ok(DynamicValue::Duration(v)) + } + dynamic_value::Value::TimestampValue(ts) => { + let ts = TimestampUs::try_from(&ts)?; + Ok(DynamicValue::Timestamp(ts)) + } + dynamic_value::Value::List(list) => Ok(DynamicValue::List(list.try_into()?)), + dynamic_value::Value::Map(map) => Ok(DynamicValue::Map(map.try_into()?)), + } + } +} + +impl TryFrom for BTreeMap { + type Error = anyhow::Error; + + fn try_from(value: dynamic_value::Map) -> Result { + let mut output = BTreeMap::new(); + for item in value.items { + let key = item.key.context("missing DynamicValue.MapItem.key")?; + let value = item + .value + .into_option() + .context("missing DynamicValue.MapItem.value")? + .try_into()?; + let old = output.insert(key, value); + ensure!(old.is_none(), "duplicate DynamicValue.MapItem.key"); + } + Ok(output) + } +} + +impl TryFrom for Vec { + type Error = anyhow::Error; + + fn try_from(value: dynamic_value::List) -> Result { + let mut output = Vec::new(); + for item in value.items { + output.push(item.try_into()?); + } + Ok(output) + } +} diff --git a/lazer/publisher_sdk/rust/src/convert_dynamic_value/tests.rs b/lazer/publisher_sdk/rust/src/convert_dynamic_value/tests.rs new file mode 100644 index 0000000000..7928b002e4 --- /dev/null +++ b/lazer/publisher_sdk/rust/src/convert_dynamic_value/tests.rs @@ -0,0 +1,145 @@ +use std::collections::BTreeMap; + +use protobuf::{ + well_known_types::{duration::Duration, timestamp::Timestamp}, + MessageField, +}; +use pyth_lazer_protocol::DynamicValue; + +use crate::protobuf::dynamic_value::{ + dynamic_value::{List, Map, MapItem, Value}, + DynamicValue as ProtobufDynamicValue, +}; + +#[test] +fn dynamic_value_serializes() { + let mut map = BTreeMap::new(); + map.insert( + "int1".to_owned(), + ProtobufDynamicValue { + value: Some(Value::IntValue(42)), + special_fields: Default::default(), + }, + ); + + map.insert( + "bool2".to_owned(), + ProtobufDynamicValue { + value: Some(Value::BoolValue(true)), + special_fields: Default::default(), + }, + ); + + map.insert( + "str3".to_owned(), + ProtobufDynamicValue { + value: Some(Value::StringValue("abc".into())), + special_fields: Default::default(), + }, + ); + + map.insert( + "double4".to_owned(), + ProtobufDynamicValue { + value: Some(Value::DoubleValue(42.0)), + special_fields: Default::default(), + }, + ); + + map.insert( + "uint5".to_owned(), + ProtobufDynamicValue { + value: Some(Value::UintValue(42)), + special_fields: Default::default(), + }, + ); + + map.insert( + "bytes6".to_owned(), + ProtobufDynamicValue { + value: Some(Value::BytesValue(b"\xAB\xCD\xEF".into())), + special_fields: Default::default(), + }, + ); + + map.insert( + "duration7".to_owned(), + ProtobufDynamicValue { + value: Some(Value::DurationValue(Duration { + seconds: 12, + nanos: 345678000, + special_fields: Default::default(), + })), + special_fields: Default::default(), + }, + ); + + map.insert( + "timestamp8".to_owned(), + ProtobufDynamicValue { + value: Some(Value::TimestampValue(Timestamp { + seconds: 12, + nanos: 345678000, + special_fields: Default::default(), + })), + special_fields: Default::default(), + }, + ); + + map.insert( + "list9".to_owned(), + ProtobufDynamicValue { + value: Some(Value::List(List { + items: vec![ + ProtobufDynamicValue { + value: Some(Value::StringValue("item1".into())), + special_fields: Default::default(), + }, + ProtobufDynamicValue { + value: Some(Value::StringValue("item2".into())), + special_fields: Default::default(), + }, + ], + special_fields: Default::default(), + })), + special_fields: Default::default(), + }, + ); + let map = Map { + items: map + .into_iter() + .map(|(k, v)| MapItem { + key: Some(k), + value: MessageField::some(v), + special_fields: Default::default(), + }) + .collect(), + special_fields: Default::default(), + }; + + let converted: BTreeMap = map.clone().try_into().unwrap(); + + let json = serde_json::to_string_pretty(&converted).unwrap(); + println!("{json}"); + assert_eq!( + json, + r#"{ + "bool2": true, + "bytes6": "abcdef", + "double4": 42.0, + "duration7": "12s 345ms 678us", + "int1": 42, + "list9": [ + "item1", + "item2" + ], + "str3": "abc", + "timestamp8": 12345678, + "uint5": 42 +}"# + ); + + // Check roundtrip + let reversed: Map = converted.into(); + assert_eq!(map, reversed); +} diff --git a/lazer/publisher_sdk/rust/src/lib.rs b/lazer/publisher_sdk/rust/src/lib.rs index a88260c643..81ac139ed0 100644 --- a/lazer/publisher_sdk/rust/src/lib.rs +++ b/lazer/publisher_sdk/rust/src/lib.rs @@ -1,13 +1,9 @@ -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 crate::state::FeedState; use pyth_lazer_protocol::jrpc::{FeedUpdateParams, UpdateParams}; -use pyth_lazer_protocol::time::TimestampUs; +use pyth_lazer_protocol::symbol_state::SymbolState; +use pyth_lazer_protocol::FeedKind; pub mod transaction_envelope { pub use crate::protobuf::transaction_envelope::*; @@ -29,144 +25,16 @@ pub mod state { pub use crate::protobuf::state::*; } +pub mod dynamic_value { + pub use crate::protobuf::dynamic_value::*; +} + #[allow(rustdoc::broken_intra_doc_links)] mod protobuf { include!(concat!(env!("OUT_DIR"), "/protobuf/mod.rs")); } -impl DynamicValue { - pub fn try_option_from_serde(value: serde_value::Value) -> anyhow::Result> { - match value { - serde_value::Value::Option(value) => { - if let Some(value) = value { - Ok(Some((*value).try_into()?)) - } else { - Ok(None) - } - } - value => Ok(Some(value.try_into()?)), - } - } - - pub fn to_timestamp(&self) -> anyhow::Result { - let value = self.value.as_ref().context("missing DynamicValue.value")?; - match value { - dynamic_value::Value::TimestampValue(ts) => Ok(ts.try_into()?), - _ => bail!("expected timestamp, got {:?}", self), - } - } - - pub fn to_duration(&self) -> anyhow::Result { - let value = self.value.as_ref().context("missing DynamicValue.value")?; - match value { - dynamic_value::Value::DurationValue(duration) => Ok(duration.clone().into()), - _ => bail!("expected duration, got {:?}", self), - } - } -} - -impl TryFrom for DynamicValue { - type Error = anyhow::Error; - - fn try_from(value: serde_value::Value) -> Result { - let converted = match value { - serde_value::Value::Bool(value) => dynamic_value::Value::BoolValue(value), - serde_value::Value::U8(value) => dynamic_value::Value::UintValue(value.into()), - serde_value::Value::U16(value) => dynamic_value::Value::UintValue(value.into()), - serde_value::Value::U32(value) => dynamic_value::Value::UintValue(value.into()), - serde_value::Value::U64(value) => dynamic_value::Value::UintValue(value), - serde_value::Value::I8(value) => dynamic_value::Value::IntValue(value.into()), - serde_value::Value::I16(value) => dynamic_value::Value::IntValue(value.into()), - serde_value::Value::I32(value) => dynamic_value::Value::IntValue(value.into()), - serde_value::Value::I64(value) => dynamic_value::Value::IntValue(value), - serde_value::Value::F32(value) => dynamic_value::Value::DoubleValue(value.into()), - serde_value::Value::F64(value) => dynamic_value::Value::DoubleValue(value), - serde_value::Value::Char(value) => dynamic_value::Value::StringValue(value.to_string()), - serde_value::Value::String(value) => dynamic_value::Value::StringValue(value), - serde_value::Value::Bytes(value) => dynamic_value::Value::BytesValue(value), - serde_value::Value::Seq(values) => { - let mut items = Vec::new(); - for value in values { - items.push(value.try_into()?); - } - dynamic_value::Value::List(dynamic_value::List { - items, - special_fields: Default::default(), - }) - } - serde_value::Value::Map(values) => { - let mut items = Vec::new(); - for (key, value) in values { - let key = match key { - serde_value::Value::String(key) => key, - _ => bail!("unsupported key type: expected string, got {:?}", key), - }; - items.push(dynamic_value::MapItem { - key: Some(key), - value: MessageField::some(value.try_into()?), - special_fields: Default::default(), - }) - } - dynamic_value::Value::Map(dynamic_value::Map { - items, - special_fields: Default::default(), - }) - } - serde_value::Value::Unit - | serde_value::Value::Option(_) - | serde_value::Value::Newtype(_) => bail!("unsupported type: {:?}", value), - }; - Ok(DynamicValue { - value: Some(converted), - special_fields: Default::default(), - }) - } -} - -impl TryFrom for serde_value::Value { - type Error = anyhow::Error; - - fn try_from(value: DynamicValue) -> Result { - let value = value.value.context("missing DynamicValue.value")?; - match value { - dynamic_value::Value::StringValue(value) => Ok(serde_value::Value::String(value)), - dynamic_value::Value::DoubleValue(value) => Ok(serde_value::Value::F64(value)), - dynamic_value::Value::UintValue(value) => Ok(serde_value::Value::U64(value)), - dynamic_value::Value::IntValue(value) => Ok(serde_value::Value::I64(value)), - dynamic_value::Value::BoolValue(value) => Ok(serde_value::Value::Bool(value)), - dynamic_value::Value::BytesValue(value) => Ok(serde_value::Value::Bytes(value)), - dynamic_value::Value::DurationValue(duration) => { - let s: Duration = duration.into(); - Ok(serde_value::Value::String(format_duration(s).to_string())) - } - dynamic_value::Value::TimestampValue(ts) => { - let ts = TimestampUs::try_from(&ts)?; - Ok(serde_value::Value::U64(ts.as_micros())) - } - dynamic_value::Value::List(list) => { - let mut output = Vec::new(); - for item in list.items { - output.push(item.try_into()?); - } - Ok(serde_value::Value::Seq(output)) - } - dynamic_value::Value::Map(map) => { - let mut output = BTreeMap::new(); - for item in map.items { - let key = item.key.context("missing DynamicValue.MapItem.key")?; - let value = item - .value - .into_option() - .context("missing DynamicValue.MapItem.value")? - .try_into()?; - let old = output.insert(serde_value::Value::String(key), value); - ensure!(old.is_none(), "duplicate DynamicValue.MapItem.key"); - } - Ok(serde_value::Value::Map(output)) - } - } - } -} +mod convert_dynamic_value; impl From for FeedUpdate { fn from(value: FeedUpdateParams) -> Self { @@ -202,3 +70,41 @@ impl From for Update { } } } + +impl From for SymbolState { + fn from(value: FeedState) -> Self { + match value { + FeedState::COMING_SOON => SymbolState::ComingSoon, + FeedState::STABLE => SymbolState::Stable, + FeedState::INACTIVE => SymbolState::Inactive, + } + } +} + +impl From for FeedState { + fn from(value: SymbolState) -> Self { + match value { + SymbolState::ComingSoon => FeedState::COMING_SOON, + SymbolState::Stable => FeedState::STABLE, + SymbolState::Inactive => FeedState::INACTIVE, + } + } +} + +impl From for protobuf::state::FeedKind { + fn from(value: FeedKind) -> Self { + match value { + FeedKind::Price => protobuf::state::FeedKind::PRICE, + FeedKind::FundingRate => protobuf::state::FeedKind::FUNDING_RATE, + } + } +} + +impl From for FeedKind { + fn from(value: protobuf::state::FeedKind) -> Self { + match value { + protobuf::state::FeedKind::PRICE => FeedKind::Price, + protobuf::state::FeedKind::FUNDING_RATE => FeedKind::FundingRate, + } + } +} diff --git a/lazer/sdk/rust/protocol/Cargo.toml b/lazer/sdk/rust/protocol/Cargo.toml index 283e5ea170..cd1fef3aae 100644 --- a/lazer/sdk/rust/protocol/Cargo.toml +++ b/lazer/sdk/rust/protocol/Cargo.toml @@ -11,13 +11,15 @@ byteorder = "1.5.0" anyhow = "1.0.89" serde = { version = "1.0.210", features = ["derive"] } serde_json = "1.0" -derive_more = { version = "1.0.0", features = ["from"] } +derive_more = { version = "1.0.0", features = ["from", "into"] } itertools = "0.13.0" rust_decimal = "1.36.0" protobuf = "3.7.2" humantime-serde = "1.1.1" mry = { version = "0.13.0", features = ["serde"], optional = true } chrono = "0.4.41" +humantime = "2.2.0" +hex = "0.4.3" [dev-dependencies] bincode = "1.3.3" diff --git a/lazer/sdk/rust/protocol/src/dynamic_value.rs b/lazer/sdk/rust/protocol/src/dynamic_value.rs new file mode 100644 index 0000000000..ab954311f1 --- /dev/null +++ b/lazer/sdk/rust/protocol/src/dynamic_value.rs @@ -0,0 +1,56 @@ +use std::collections::BTreeMap; + +use crate::time::{DurationUs, TimestampUs}; +use derive_more::From; +use serde::{ + ser::{SerializeMap, SerializeSeq}, + Serialize, +}; + +#[derive(Debug, Clone, PartialEq, From)] +pub enum DynamicValue { + String(String), + F64(f64), + U64(u64), + I64(i64), + Bool(bool), + Timestamp(TimestampUs), + Duration(DurationUs), + Bytes(Vec), + List(Vec), + Map(BTreeMap), +} + +impl Serialize for DynamicValue { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + DynamicValue::String(v) => serializer.serialize_str(v), + DynamicValue::F64(v) => serializer.serialize_f64(*v), + DynamicValue::U64(v) => serializer.serialize_u64(*v), + DynamicValue::I64(v) => serializer.serialize_i64(*v), + DynamicValue::Bool(v) => serializer.serialize_bool(*v), + DynamicValue::Timestamp(v) => serializer.serialize_u64(v.as_micros()), + DynamicValue::Duration(v) => { + serializer.serialize_str(&humantime::format_duration((*v).into()).to_string()) + } + DynamicValue::Bytes(v) => serializer.serialize_str(&hex::encode(v)), + DynamicValue::List(v) => { + let mut seq_serializer = serializer.serialize_seq(Some(v.len()))?; + for element in v { + seq_serializer.serialize_element(element)?; + } + seq_serializer.end() + } + DynamicValue::Map(map) => { + let mut map_serializer = serializer.serialize_map(Some(map.len()))?; + for (k, v) in map { + map_serializer.serialize_entry(k, v)?; + } + map_serializer.end() + } + } + } +} diff --git a/lazer/sdk/rust/protocol/src/feed_kind.rs b/lazer/sdk/rust/protocol/src/feed_kind.rs new file mode 100644 index 0000000000..7e8a85eb92 --- /dev/null +++ b/lazer/sdk/rust/protocol/src/feed_kind.rs @@ -0,0 +1,20 @@ +use { + serde::{Deserialize, Serialize}, + std::fmt::Display, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FeedKind { + Price, + FundingRate, +} + +impl Display for FeedKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FeedKind::Price => write!(f, "price"), + FeedKind::FundingRate => write!(f, "fundingRate"), + } + } +} diff --git a/lazer/sdk/rust/protocol/src/lib.rs b/lazer/sdk/rust/protocol/src/lib.rs index cec19e11de..21e94b4a4b 100644 --- a/lazer/sdk/rust/protocol/src/lib.rs +++ b/lazer/sdk/rust/protocol/src/lib.rs @@ -2,6 +2,8 @@ pub mod api; pub mod binary_update; +mod dynamic_value; +mod feed_kind; pub mod jrpc; pub mod message; pub mod payload; @@ -13,6 +15,8 @@ pub mod subscription; pub mod symbol_state; pub mod time; +pub use crate::{dynamic_value::DynamicValue, feed_kind::FeedKind}; + #[test] fn magics_in_big_endian() { use crate::{ diff --git a/lazer/sdk/rust/protocol/src/router.rs b/lazer/sdk/rust/protocol/src/router.rs index 3150260bf6..dd0decb0a7 100644 --- a/lazer/sdk/rust/protocol/src/router.rs +++ b/lazer/sdk/rust/protocol/src/router.rs @@ -6,7 +6,7 @@ use { time::{DurationUs, TimestampUs}, }, anyhow::{bail, Context}, - derive_more::derive::From, + derive_more::derive::{From, Into}, itertools::Itertools, protobuf::well_known_types::duration::Duration as ProtobufDuration, rust_decimal::{prelude::FromPrimitive, Decimal}, @@ -18,13 +18,19 @@ use { }, }; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, From, Into, +)] pub struct PublisherId(pub u16); -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, From, Into, +)] pub struct PriceFeedId(pub u32); -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, From, Into, +)] pub struct ChannelId(pub u8); #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]