Skip to content

Commit 059b7b9

Browse files
feat(hermes): Add ignore_invalid_price_ids flag to Hermes v2 REST APIs (#2091)
* feat: add ignore_invalid_price_ids parameter to v2 apis * update readme * undo build hack * better names * apply precommit * refactor: address PR comments * test: add tests for validate_price_ids * docs: address PR comments * feat: include ignoreInvalidPriceIds flag in HermesClient * fix: bump ver, address PR comments * docs: update to reflect change from nightly to stable rust * fix: semver
1 parent f14cfaa commit 059b7b9

File tree

14 files changed

+224
-43
lines changed

14 files changed

+224
-43
lines changed

apps/hermes/client/js/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pythnetwork/hermes-client",
3-
"version": "1.1.0",
3+
"version": "1.2.0",
44
"description": "Pyth Hermes Client",
55
"author": {
66
"name": "Pyth Data Association"

apps/hermes/client/js/src/HermesClient.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ export class HermesClient {
157157
* @param options Optional parameters:
158158
* - encoding: Encoding type. If specified, return the price update in the encoding specified by the encoding parameter. Default is hex.
159159
* - parsed: Boolean to specify if the parsed price update should be included in the response. Default is false.
160+
* - ignoreInvalidPriceIds: Boolean to specify if invalid price IDs should be ignored instead of returning an error. Default is false.
160161
*
161162
* @returns PriceUpdate object containing the latest updates.
162163
*/
@@ -165,6 +166,7 @@ export class HermesClient {
165166
options?: {
166167
encoding?: EncodingType;
167168
parsed?: boolean;
169+
ignoreInvalidPriceIds?: boolean;
168170
}
169171
): Promise<PriceUpdate> {
170172
const url = new URL("v2/updates/price/latest", this.baseURL);
@@ -173,7 +175,8 @@ export class HermesClient {
173175
}
174176

175177
if (options) {
176-
this.appendUrlSearchParams(url, options);
178+
const transformedOptions = camelToSnakeCaseObject(options);
179+
this.appendUrlSearchParams(url, transformedOptions);
177180
}
178181

179182
return this.httpRequest(url.toString(), schemas.PriceUpdate);
@@ -189,6 +192,7 @@ export class HermesClient {
189192
* @param options Optional parameters:
190193
* - encoding: Encoding type. If specified, return the price update in the encoding specified by the encoding parameter. Default is hex.
191194
* - parsed: Boolean to specify if the parsed price update should be included in the response. Default is false.
195+
* - ignoreInvalidPriceIds: Boolean to specify if invalid price IDs should be ignored instead of returning an error. Default is false.
192196
*
193197
* @returns PriceUpdate object containing the updates at the specified timestamp.
194198
*/
@@ -198,6 +202,7 @@ export class HermesClient {
198202
options?: {
199203
encoding?: EncodingType;
200204
parsed?: boolean;
205+
ignoreInvalidPriceIds?: boolean;
201206
}
202207
): Promise<PriceUpdate> {
203208
const url = new URL(`v2/updates/price/${publishTime}`, this.baseURL);
@@ -206,7 +211,8 @@ export class HermesClient {
206211
}
207212

208213
if (options) {
209-
this.appendUrlSearchParams(url, options);
214+
const transformedOptions = camelToSnakeCaseObject(options);
215+
this.appendUrlSearchParams(url, transformedOptions);
210216
}
211217

212218
return this.httpRequest(url.toString(), schemas.PriceUpdate);
@@ -219,12 +225,14 @@ export class HermesClient {
219225
* This will return an EventSource that can be used to listen to streaming updates.
220226
* If an invalid hex-encoded ID is passed, it will throw an error.
221227
*
222-
*
223228
* @param ids Array of hex-encoded price feed IDs for which streaming updates are requested.
224-
* @param encoding Optional encoding type. If specified, updates are returned in the specified encoding. Default is hex.
225-
* @param parsed Optional boolean to specify if the parsed price update should be included in the response. Default is false.
226-
* @param allow_unordered Optional boolean to specify if unordered updates are allowed to be included in the stream. Default is false.
227-
* @param benchmarks_only Optional boolean to specify if only benchmark prices that are the initial price updates at a given timestamp (i.e., prevPubTime != pubTime) should be returned. Default is false.
229+
* @param options Optional parameters:
230+
* - encoding: Encoding type. If specified, updates are returned in the specified encoding. Default is hex.
231+
* - parsed: Boolean to specify if the parsed price update should be included in the response. Default is false.
232+
* - allowUnordered: Boolean to specify if unordered updates are allowed to be included in the stream. Default is false.
233+
* - benchmarksOnly: Boolean to specify if only benchmark prices should be returned. Default is false.
234+
* - ignoreInvalidPriceIds: Boolean to specify if invalid price IDs should be ignored instead of returning an error. Default is false.
235+
*
228236
* @returns An EventSource instance for receiving streaming updates.
229237
*/
230238
async getPriceUpdatesStream(
@@ -234,6 +242,7 @@ export class HermesClient {
234242
parsed?: boolean;
235243
allowUnordered?: boolean;
236244
benchmarksOnly?: boolean;
245+
ignoreInvalidPriceIds?: boolean;
237246
}
238247
): Promise<EventSource> {
239248
const url = new URL("v2/updates/price/stream", this.baseURL);

apps/hermes/server/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/hermes/server/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "hermes"
3-
version = "0.6.1"
3+
version = "0.7.0"
44
description = "Hermes is an agent that provides Verified Prices from the Pythnet Pyth Oracle."
55
edition = "2021"
66

apps/hermes/server/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ To set up and run a Hermes node, follow the steps below:
5252
can interact with the node using the REST and Websocket APIs on port 33999.
5353

5454
For local development, you can also run the node with [cargo watch](https://crates.io/crates/cargo-watch) to restart
55-
it automatically when the code changes:
55+
it automatically when the code changes.
5656

5757
```bash
5858
cargo watch -w src -x "run -- run --pythnet-http-addr https://pythnet-rpc/ --pythnet-ws-addr wss://pythnet-rpc/ --wormhole-spy-rpc-addr https://wormhole-spy-rpc/

apps/hermes/server/src/api/rest.rs

Lines changed: 166 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -94,25 +94,180 @@ impl IntoResponse for RestError {
9494
}
9595
}
9696

97-
/// Verify that the price ids exist in the aggregate state.
98-
pub async fn verify_price_ids_exist<S>(
97+
/// Validate that the passed in price_ids exist in the aggregate state. Return a Vec of valid price ids.
98+
/// # Returns
99+
/// If `remove_invalid` is true, invalid price ids are filtered out and only valid price ids are returned.
100+
/// If `remove_invalid` is false and any passed in IDs are invalid, an error is returned.
101+
pub async fn validate_price_ids<S>(
99102
state: &ApiState<S>,
100103
price_ids: &[PriceIdentifier],
101-
) -> Result<(), RestError>
104+
remove_invalid: bool,
105+
) -> Result<Vec<PriceIdentifier>, RestError>
102106
where
103107
S: Aggregates,
104108
{
105109
let state = &*state.state;
106-
let all_ids = Aggregates::get_price_feed_ids(state).await;
107-
let missing_ids = price_ids
110+
let available_ids = Aggregates::get_price_feed_ids(state).await;
111+
112+
// Partition into (valid_ids, invalid_ids)
113+
let (valid_ids, invalid_ids): (Vec<_>, Vec<_>) = price_ids
108114
.iter()
109-
.filter(|id| !all_ids.contains(id))
110-
.cloned()
111-
.collect::<Vec<_>>();
115+
.copied()
116+
.partition(|id| available_ids.contains(id));
117+
118+
if invalid_ids.is_empty() || remove_invalid {
119+
// All IDs are valid
120+
Ok(valid_ids)
121+
} else {
122+
// Return error with list of missing IDs
123+
Err(RestError::PriceIdsNotFound {
124+
missing_ids: invalid_ids,
125+
})
126+
}
127+
}
128+
#[cfg(test)]
129+
mod tests {
130+
use {
131+
super::*,
132+
crate::state::{
133+
aggregate::{
134+
AggregationEvent,
135+
PriceFeedsWithUpdateData,
136+
PublisherStakeCapsWithUpdateData,
137+
ReadinessMetadata,
138+
RequestTime,
139+
Update,
140+
},
141+
benchmarks::BenchmarksState,
142+
cache::CacheState,
143+
metrics::MetricsState,
144+
price_feeds_metadata::PriceFeedMetaState,
145+
},
146+
anyhow::Result,
147+
std::{
148+
collections::HashSet,
149+
sync::Arc,
150+
},
151+
tokio::sync::broadcast::Receiver,
152+
};
112153

113-
if !missing_ids.is_empty() {
114-
return Err(RestError::PriceIdsNotFound { missing_ids });
154+
// Simplified mock that only contains what we need
155+
struct MockAggregates {
156+
available_ids: HashSet<PriceIdentifier>,
157+
}
158+
159+
// Implement all required From traits with unimplemented!()
160+
impl<'a> From<&'a MockAggregates> for &'a CacheState {
161+
fn from(_: &'a MockAggregates) -> Self {
162+
unimplemented!("Not needed for this test")
163+
}
164+
}
165+
166+
impl<'a> From<&'a MockAggregates> for &'a BenchmarksState {
167+
fn from(_: &'a MockAggregates) -> Self {
168+
unimplemented!("Not needed for this test")
169+
}
115170
}
116171

117-
Ok(())
172+
impl<'a> From<&'a MockAggregates> for &'a PriceFeedMetaState {
173+
fn from(_: &'a MockAggregates) -> Self {
174+
unimplemented!("Not needed for this test")
175+
}
176+
}
177+
178+
impl<'a> From<&'a MockAggregates> for &'a MetricsState {
179+
fn from(_: &'a MockAggregates) -> Self {
180+
unimplemented!("Not needed for this test")
181+
}
182+
}
183+
184+
#[async_trait::async_trait]
185+
impl Aggregates for MockAggregates {
186+
async fn get_price_feed_ids(&self) -> HashSet<PriceIdentifier> {
187+
self.available_ids.clone()
188+
}
189+
190+
fn subscribe(&self) -> Receiver<AggregationEvent> {
191+
unimplemented!("Not needed for this test")
192+
}
193+
194+
async fn is_ready(&self) -> (bool, ReadinessMetadata) {
195+
unimplemented!("Not needed for this test")
196+
}
197+
198+
async fn store_update(&self, _update: Update) -> Result<()> {
199+
unimplemented!("Not needed for this test")
200+
}
201+
202+
async fn get_price_feeds_with_update_data(
203+
&self,
204+
_price_ids: &[PriceIdentifier],
205+
_request_time: RequestTime,
206+
) -> Result<PriceFeedsWithUpdateData> {
207+
unimplemented!("Not needed for this test")
208+
}
209+
210+
async fn get_latest_publisher_stake_caps_with_update_data(
211+
&self,
212+
) -> Result<PublisherStakeCapsWithUpdateData> {
213+
unimplemented!("Not needed for this test")
214+
}
215+
}
216+
217+
#[tokio::test]
218+
async fn validate_price_ids_accepts_all_valid_ids() {
219+
let id1 = PriceIdentifier::new([1; 32]);
220+
let id2 = PriceIdentifier::new([2; 32]);
221+
222+
let mut available_ids = HashSet::new();
223+
available_ids.insert(id1);
224+
available_ids.insert(id2);
225+
226+
let mock_state = MockAggregates { available_ids };
227+
let api_state = ApiState::new(Arc::new(mock_state), vec![], String::new());
228+
229+
let input_ids = vec![id1, id2];
230+
let result = validate_price_ids(&api_state, &input_ids, false).await;
231+
assert!(result.is_ok());
232+
assert_eq!(result.unwrap(), input_ids);
233+
}
234+
235+
#[tokio::test]
236+
async fn validate_price_ids_removes_invalid_ids_when_requested() {
237+
let id1 = PriceIdentifier::new([1; 32]);
238+
let id2 = PriceIdentifier::new([2; 32]);
239+
let id3 = PriceIdentifier::new([3; 32]);
240+
241+
let mut available_ids = HashSet::new();
242+
available_ids.insert(id1);
243+
available_ids.insert(id2);
244+
245+
let mock_state = MockAggregates { available_ids };
246+
let api_state = ApiState::new(Arc::new(mock_state), vec![], String::new());
247+
248+
let input_ids = vec![id1, id2, id3];
249+
let result = validate_price_ids(&api_state, &input_ids, true).await;
250+
assert!(result.is_ok());
251+
assert_eq!(result.unwrap(), vec![id1, id2]);
252+
}
253+
254+
#[tokio::test]
255+
async fn validate_price_ids_errors_on_invalid_ids() {
256+
let id1 = PriceIdentifier::new([1; 32]);
257+
let id2 = PriceIdentifier::new([2; 32]);
258+
let id3 = PriceIdentifier::new([3; 32]);
259+
260+
let mut available_ids = HashSet::new();
261+
available_ids.insert(id1);
262+
available_ids.insert(id2);
263+
264+
let mock_state = MockAggregates { available_ids };
265+
let api_state = ApiState::new(Arc::new(mock_state), vec![], String::new());
266+
267+
let input_ids = vec![id1, id2, id3];
268+
let result = validate_price_ids(&api_state, &input_ids, false).await;
269+
assert!(
270+
matches!(result, Err(RestError::PriceIdsNotFound { missing_ids }) if missing_ids == vec![id3])
271+
);
272+
}
118273
}

apps/hermes/server/src/api/rest/get_price_feed.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use {
2-
super::verify_price_ids_exist,
2+
super::validate_price_ids,
33
crate::{
44
api::{
55
doc_examples,
@@ -73,7 +73,7 @@ where
7373
S: Aggregates,
7474
{
7575
let price_id: PriceIdentifier = params.id.into();
76-
verify_price_ids_exist(&state, &[price_id]).await?;
76+
validate_price_ids(&state, &[price_id], false).await?;
7777

7878
let state = &*state.state;
7979
let price_feeds_with_update_data = Aggregates::get_price_feeds_with_update_data(

apps/hermes/server/src/api/rest/get_vaa.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use {
2-
super::verify_price_ids_exist,
2+
super::validate_price_ids,
33
crate::{
44
api::{
55
doc_examples,
@@ -80,7 +80,7 @@ where
8080
S: Aggregates,
8181
{
8282
let price_id: PriceIdentifier = params.id.into();
83-
verify_price_ids_exist(&state, &[price_id]).await?;
83+
validate_price_ids(&state, &[price_id], false).await?;
8484

8585
let state = &*state.state;
8686
let price_feeds_with_update_data = Aggregates::get_price_feeds_with_update_data(

apps/hermes/server/src/api/rest/get_vaa_ccip.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use {
2-
super::verify_price_ids_exist,
2+
super::validate_price_ids,
33
crate::{
44
api::{
55
rest::RestError,
@@ -75,7 +75,7 @@ where
7575
.try_into()
7676
.map_err(|_| RestError::InvalidCCIPInput)?,
7777
);
78-
verify_price_ids_exist(&state, &[price_id]).await?;
78+
validate_price_ids(&state, &[price_id], false).await?;
7979

8080
let publish_time = UnixTimestamp::from_be_bytes(
8181
params.data[32..40]

apps/hermes/server/src/api/rest/latest_price_feeds.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use {
2-
super::verify_price_ids_exist,
2+
super::validate_price_ids,
33
crate::{
44
api::{
55
rest::RestError,
@@ -74,7 +74,7 @@ where
7474
S: Aggregates,
7575
{
7676
let price_ids: Vec<PriceIdentifier> = params.ids.into_iter().map(|id| id.into()).collect();
77-
verify_price_ids_exist(&state, &price_ids).await?;
77+
validate_price_ids(&state, &price_ids, false).await?;
7878

7979
let state = &*state.state;
8080
let price_feeds_with_update_data =

apps/hermes/server/src/api/rest/latest_vaas.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use {
2-
super::verify_price_ids_exist,
2+
super::validate_price_ids,
33
crate::{
44
api::{
55
doc_examples,
@@ -69,7 +69,7 @@ where
6969
S: Aggregates,
7070
{
7171
let price_ids: Vec<PriceIdentifier> = params.ids.into_iter().map(|id| id.into()).collect();
72-
verify_price_ids_exist(&state, &price_ids).await?;
72+
validate_price_ids(&state, &price_ids, false).await?;
7373

7474
let state = &*state.state;
7575
let price_feeds_with_update_data =

0 commit comments

Comments
 (0)