diff --git a/hermes/src/api.rs b/hermes/src/api.rs index 900144ee..cce10edc 100644 --- a/hermes/src/api.rs +++ b/hermes/src/api.rs @@ -121,6 +121,7 @@ pub async fn run(opts: RunOptions, state: ApiState) -> Result<()> { rest::latest_price_feeds, rest::latest_vaas, rest::price_feed_ids, + rest::latest_price_updates, ), components( schemas( @@ -152,6 +153,7 @@ pub async fn run(opts: RunOptions, state: ApiState) -> Result<()> { .route("/api/latest_price_feeds", get(rest::latest_price_feeds)) .route("/api/latest_vaas", get(rest::latest_vaas)) .route("/api/price_feed_ids", get(rest::price_feed_ids)) + .route("/v2/updates/price/latest", get(rest::latest_price_updates)) .route("/live", get(rest::live)) .route("/ready", get(rest::ready)) .route("/ws", get(ws::ws_route_handler)) diff --git a/hermes/src/api/rest.rs b/hermes/src/api/rest.rs index d553459d..7c29e441 100644 --- a/hermes/src/api/rest.rs +++ b/hermes/src/api/rest.rs @@ -19,6 +19,7 @@ mod latest_vaas; mod live; mod price_feed_ids; mod ready; +mod v2; pub use { get_price_feed::*, @@ -30,6 +31,7 @@ pub use { live::*, price_feed_ids::*, ready::*, + v2::latest_price_updates::*, }; pub enum RestError { diff --git a/hermes/src/api/rest/index.rs b/hermes/src/api/rest/index.rs index 96152aa4..5acdd636 100644 --- a/hermes/src/api/rest/index.rs +++ b/hermes/src/api/rest/index.rs @@ -16,5 +16,6 @@ pub async fn index() -> impl IntoResponse { "/api/get_price_feed?id=&publish_time=(&verbose=true)(&binary=true)", "/api/get_vaa?id=&publish_time=", "/api/get_vaa_ccip?data=<0x+>", + "/v2/updates/price/latest?ids[]=&ids[]=&..(&encoding=hex|base64)(&parsed=false)", ]) } diff --git a/hermes/src/api/rest/v2/latest_price_updates.rs b/hermes/src/api/rest/v2/latest_price_updates.rs new file mode 100644 index 00000000..3ad7ec13 --- /dev/null +++ b/hermes/src/api/rest/v2/latest_price_updates.rs @@ -0,0 +1,129 @@ +use { + crate::{ + aggregate::RequestTime, + api::{ + rest::{ + verify_price_ids_exist, + RestError, + }, + types::{ + BinaryPriceUpdate, + EncodingType, + ParsedPriceUpdate, + PriceIdInput, + PriceUpdate, + }, + }, + }, + anyhow::Result, + axum::{ + extract::State, + Json, + }, + base64::{ + engine::general_purpose::STANDARD as base64_standard_engine, + Engine as _, + }, + pyth_sdk::PriceIdentifier, + serde_qs::axum::QsQuery, + utoipa::IntoParams, +}; + + +#[derive(Debug, serde::Deserialize, IntoParams)] +#[into_params(parameter_in=Query)] +pub struct LatestPriceUpdatesQueryParams { + /// Get the most recent price update for this set of price feed ids. + /// + /// This parameter can be provided multiple times to retrieve multiple price updates, + /// for example see the following query string: + /// + /// ``` + /// ?ids[]=a12...&ids[]=b4c... + /// ``` + #[param(rename = "ids[]")] + #[param(example = "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43")] + ids: Vec, + + /// If true, include the parsed price update in the `parsed` field of each returned feed. + #[serde(default)] + encoding: EncodingType, + + /// If true, include the parsed price update in the `parsed` field of each returned feed. + #[serde(default = "default_true")] + parsed: bool, +} + +fn default_true() -> bool { + true +} + +/// Get the latest price updates by price feed id. +/// +/// Given a collection of price feed ids, retrieve the latest Pyth price for each price feed. +#[utoipa::path( + get, + path = "/v2/updates/price/latest", + responses( + (status = 200, description = "Price updates retrieved successfully", body = Vec), + (status = 404, description = "Price ids not found", body = String) + ), + params( + LatestPriceUpdatesQueryParams + ) +)] +pub async fn latest_price_updates( + State(state): State, + QsQuery(params): QsQuery, +) -> Result>, RestError> { + let price_ids: Vec = params.ids.into_iter().map(|id| id.into()).collect(); + + verify_price_ids_exist(&state, &price_ids).await?; + + let price_feeds_with_update_data = crate::aggregate::get_price_feeds_with_update_data( + &*state.state, + &price_ids, + RequestTime::Latest, + ) + .await + .map_err(|e| { + tracing::warn!( + "Error getting price feeds {:?} with update data: {:?}", + price_ids, + e + ); + RestError::UpdateDataNotFound + })?; + + let price_update_data = price_feeds_with_update_data.update_data; + let encoded_data: Vec = price_update_data + .into_iter() + .map(|data| match params.encoding { + EncodingType::Base64 => base64_standard_engine.encode(data), + EncodingType::Hex => hex::encode(data), + }) + .collect(); + let binary_price_update = BinaryPriceUpdate { + encoding: params.encoding, + data: encoded_data, + }; + let parsed_price_updates: Option> = if params.parsed { + Some( + price_feeds_with_update_data + .price_feeds + .into_iter() + .map(|price_feed| price_feed.into()) + .collect(), + ) + } else { + None + }; + + let compressed_price_update = PriceUpdate { + binary: binary_price_update, + parsed: parsed_price_updates, + }; + + + Ok(Json(vec![compressed_price_update])) +} diff --git a/hermes/src/api/rest/v2/mod.rs b/hermes/src/api/rest/v2/mod.rs new file mode 100644 index 00000000..413ee04f --- /dev/null +++ b/hermes/src/api/rest/v2/mod.rs @@ -0,0 +1 @@ +pub mod latest_price_updates; diff --git a/hermes/src/api/types.rs b/hermes/src/api/types.rs index 09676b2c..fe0a66ea 100644 --- a/hermes/src/api/types.rs +++ b/hermes/src/api/types.rs @@ -58,6 +58,16 @@ pub struct RpcPriceFeedMetadata { pub prev_publish_time: Option, } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema)] +pub struct RpcPriceFeedMetadataV2 { + #[schema(value_type = Option, example=85480034)] + pub slot: Option, + #[schema(value_type = Option, example=doc_examples::timestamp_example)] + pub proof_available_time: Option, + #[schema(value_type = Option, example=doc_examples::timestamp_example)] + pub prev_publish_time: Option, +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema)] pub struct RpcPriceFeed { pub id: RpcPriceIdentifier, @@ -179,3 +189,60 @@ impl RpcPriceIdentifier { RpcPriceIdentifier(id.to_bytes()) } } + +#[derive(Clone, Copy, Debug, Default, serde::Deserialize, serde::Serialize)] +pub enum EncodingType { + #[default] + #[serde(rename = "hex")] + Hex, + #[serde(rename = "base64")] + Base64, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema)] +pub struct BinaryPriceUpdate { + pub encoding: EncodingType, + pub data: Vec, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema)] +pub struct ParsedPriceUpdate { + pub id: String, + pub price: RpcPrice, + pub ema_price: RpcPrice, + pub metadata: RpcPriceFeedMetadataV2, +} + +impl From for ParsedPriceUpdate { + fn from(price_feed_update: PriceFeedUpdate) -> Self { + let price_feed = price_feed_update.price_feed; + + Self { + id: price_feed.id.to_string(), + price: RpcPrice { + price: price_feed.get_price_unchecked().price, + conf: price_feed.get_price_unchecked().conf, + expo: price_feed.get_price_unchecked().expo, + publish_time: price_feed.get_price_unchecked().publish_time, + }, + ema_price: RpcPrice { + price: price_feed.get_ema_price_unchecked().price, + conf: price_feed.get_ema_price_unchecked().conf, + expo: price_feed.get_ema_price_unchecked().expo, + publish_time: price_feed.get_ema_price_unchecked().publish_time, + }, + metadata: RpcPriceFeedMetadataV2 { + proof_available_time: price_feed_update.received_at, + slot: price_feed_update.slot, + prev_publish_time: price_feed_update.prev_publish_time, + }, + } + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema)] +pub struct PriceUpdate { + pub binary: BinaryPriceUpdate, + #[serde(skip_serializing_if = "Option::is_none")] + pub parsed: Option>, +}