Skip to content

Commit 6c7165e

Browse files
feat: add hermes client (#2931)
1 parent 5a2d239 commit 6c7165e

File tree

11 files changed

+1069
-0
lines changed

11 files changed

+1069
-0
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: "Hermes Client Rust Test Suite"
2+
on:
3+
push:
4+
branches:
5+
- main
6+
pull_request:
7+
paths:
8+
- .github/workflows/ci-hermes-client-rust.yml
9+
- apps/hermes/client/rust/**
10+
11+
jobs:
12+
lazer-rust-test-suite:
13+
name: Hermes Client Rust Test Suite
14+
runs-on: ubuntu-22.04
15+
defaults:
16+
run:
17+
working-directory: apps/hermes/client/rust/
18+
steps:
19+
- uses: actions/checkout@v4
20+
with:
21+
submodules: recursive
22+
- uses: actions-rust-lang/setup-rust-toolchain@v1
23+
- name: install taplo
24+
run: cargo install --locked [email protected]
25+
- name: check Cargo.toml formatting
26+
run: find . -name Cargo.toml -exec taplo fmt --check --diff {} \;
27+
- name: Format check
28+
run: cargo fmt --all -- --check
29+
if: success() || failure()
30+
- name: Clippy check
31+
run: cargo clippy -p pyth-hermes-client --all-targets -- --deny warnings
32+
if: success() || failure()
33+
- name: test
34+
run: cargo test -p pyth-hermes-client
35+
if: success() || failure()
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
name: Publish Rust package pyth-lazer-client to crates.io
2+
3+
on:
4+
push:
5+
tags:
6+
- rust-pyth-hermes-client-v*
7+
jobs:
8+
publish-pyth-hermes-client:
9+
name: Publish Rust package pyth-hermes-client to crates.io
10+
runs-on: ubuntu-latest
11+
steps:
12+
- name: Checkout sources
13+
uses: actions/checkout@v2
14+
15+
- run: cargo publish --token ${CARGO_REGISTRY_TOKEN}
16+
env:
17+
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
18+
working-directory: "apps/hermes/client/rust"

Cargo.lock

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ members = [
66
"apps/fortuna",
77
"apps/pyth-lazer-agent",
88
"apps/quorum",
9+
"apps/hermes/client/rust",
910
"lazer/publisher_sdk/rust",
1011
"lazer/sdk/rust/client",
1112
"lazer/sdk/rust/protocol",

apps/hermes/client/rust/Cargo.toml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
[package]
2+
name = "pyth-hermes-client"
3+
version = "0.0.1"
4+
edition = "2021"
5+
description = "A Rust client for Pyth Hermes"
6+
license = "Apache-2.0"
7+
8+
[dependencies]
9+
pyth-sdk = { version = "0.8.0" }
10+
tokio = { version = "1", features = ["full"] }
11+
tokio-tungstenite = { version = "0.20", features = ["native-tls"] }
12+
futures-util = "0.3"
13+
serde = { version = "1.0", features = ["derive"] }
14+
serde_json = "1.0"
15+
anyhow = "1.0"
16+
tracing = "0.1"
17+
url = "2.4"
18+
derive_more = { version = "1.0.0", features = ["from"] }
19+
backoff = { version = "0.4.0", features = ["futures", "tokio"] }
20+
ttl_cache = "0.5.1"
21+
22+
23+
[dev-dependencies]
24+
bincode = "1.3.3"
25+
ed25519-dalek = { version = "2.1.1", features = ["rand_core"] }
26+
hex = "0.4.3"
27+
libsecp256k1 = "0.7.1"
28+
bs58 = "0.5.1"
29+
alloy-primitives = "0.8.19"
30+
tracing-subscriber = { version = "0.3.19", features = ["env-filter", "json"] }
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
use std::time::Duration;
2+
3+
use pyth_hermes_client::{
4+
backoff::HermesExponentialBackoffBuilder,
5+
client::HermesClientBuilder,
6+
ws_connection::{HermesClientMessageSubscribe, HermesClientMessageUnsubscribe},
7+
};
8+
use tokio::pin;
9+
use tracing::level_filters::LevelFilter;
10+
use tracing_subscriber::EnvFilter;
11+
12+
#[tokio::main]
13+
async fn main() -> anyhow::Result<()> {
14+
tracing_subscriber::fmt()
15+
.with_env_filter(
16+
EnvFilter::builder()
17+
.with_default_directive(LevelFilter::INFO.into())
18+
.from_env()?,
19+
)
20+
.json()
21+
.init();
22+
23+
// Create and start the client
24+
let mut client = HermesClientBuilder::default()
25+
// Optionally override the default endpoints
26+
.with_endpoints(vec!["wss://hermes.pyth.network/ws".parse()?])
27+
// Optionally set the number of connections
28+
.with_num_connections(4)
29+
// Optionally set the backoff strategy
30+
.with_backoff(HermesExponentialBackoffBuilder::default().build())
31+
// Optionally set the timeout for each connection
32+
.with_timeout(Duration::from_secs(5))
33+
// Optionally set the channel capacity for responses
34+
.with_channel_capacity(1000)
35+
.build()?;
36+
37+
let stream = client.start().await?;
38+
pin!(stream);
39+
40+
let subscribe_request = HermesClientMessageSubscribe {
41+
ids: vec!["2b89b9dc8fdf9f34709a5b106b472f0f39bb6ca9ce04b0fd7f2e971688e2e53b".to_string()],
42+
verbose: true,
43+
binary: true,
44+
allow_out_of_order: false,
45+
ignore_invalid_price_ids: false,
46+
};
47+
48+
client.subscribe(subscribe_request).await?;
49+
50+
println!("Subscribed to price feeds. Waiting for updates...");
51+
52+
// Process the first few updates
53+
let mut count = 0;
54+
while let Some(msg) = stream.recv().await {
55+
// The stream gives us base64-encoded binary messages. We need to decode, parse, and verify them.
56+
57+
println!("Received message: {msg:#?}");
58+
println!();
59+
60+
count += 1;
61+
if count >= 50 {
62+
break;
63+
}
64+
}
65+
66+
// Unsubscribe example
67+
68+
client
69+
.unsubscribe(HermesClientMessageUnsubscribe {
70+
ids: vec![
71+
"2b89b9dc8fdf9f34709a5b106b472f0f39bb6ca9ce04b0fd7f2e971688e2e53b".to_string(),
72+
],
73+
})
74+
.await?;
75+
println!("Unsubscribed from price feeds.");
76+
77+
Ok(())
78+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
//! Exponential backoff implementation for Pyth Lazer client.
2+
//!
3+
//! This module provides a wrapper around the [`backoff`] crate's exponential backoff functionality,
4+
//! offering a simplified interface tailored for Pyth Lazer client operations.
5+
6+
use std::time::Duration;
7+
8+
use backoff::{
9+
default::{INITIAL_INTERVAL_MILLIS, MAX_INTERVAL_MILLIS, MULTIPLIER, RANDOMIZATION_FACTOR},
10+
ExponentialBackoff, ExponentialBackoffBuilder,
11+
};
12+
13+
/// A wrapper around the backoff crate's exponential backoff configuration.
14+
///
15+
/// This struct encapsulates the parameters needed to configure exponential backoff
16+
/// behavior and can be converted into the backoff crate's [`ExponentialBackoff`] type.
17+
#[derive(Debug)]
18+
pub struct HermesExponentialBackoff {
19+
/// The initial retry interval.
20+
initial_interval: Duration,
21+
/// The randomization factor to use for creating a range around the retry interval.
22+
///
23+
/// A randomization factor of 0.5 results in a random period ranging between 50% below and 50%
24+
/// above the retry interval.
25+
randomization_factor: f64,
26+
/// The value to multiply the current interval with for each retry attempt.
27+
multiplier: f64,
28+
/// The maximum value of the back off period. Once the retry interval reaches this
29+
/// value it stops increasing.
30+
max_interval: Duration,
31+
}
32+
33+
impl From<HermesExponentialBackoff> for ExponentialBackoff {
34+
fn from(val: HermesExponentialBackoff) -> Self {
35+
ExponentialBackoffBuilder::default()
36+
.with_initial_interval(val.initial_interval)
37+
.with_randomization_factor(val.randomization_factor)
38+
.with_multiplier(val.multiplier)
39+
.with_max_interval(val.max_interval)
40+
.with_max_elapsed_time(None)
41+
.build()
42+
}
43+
}
44+
45+
/// Builder for [`PythLazerExponentialBackoff`].
46+
///
47+
/// Provides a fluent interface for configuring exponential backoff parameters
48+
/// with sensible defaults from the backoff crate.
49+
#[derive(Debug)]
50+
pub struct HermesExponentialBackoffBuilder {
51+
initial_interval: Duration,
52+
randomization_factor: f64,
53+
multiplier: f64,
54+
max_interval: Duration,
55+
}
56+
57+
impl Default for HermesExponentialBackoffBuilder {
58+
fn default() -> Self {
59+
Self {
60+
initial_interval: Duration::from_millis(INITIAL_INTERVAL_MILLIS),
61+
randomization_factor: RANDOMIZATION_FACTOR,
62+
multiplier: MULTIPLIER,
63+
max_interval: Duration::from_millis(MAX_INTERVAL_MILLIS),
64+
}
65+
}
66+
}
67+
68+
impl HermesExponentialBackoffBuilder {
69+
/// Creates a new builder with default values.
70+
pub fn new() -> Self {
71+
Default::default()
72+
}
73+
74+
/// Sets the initial retry interval.
75+
///
76+
/// This is the starting interval for the first retry attempt.
77+
pub fn with_initial_interval(&mut self, initial_interval: Duration) -> &mut Self {
78+
self.initial_interval = initial_interval;
79+
self
80+
}
81+
82+
/// Sets the randomization factor to use for creating a range around the retry interval.
83+
///
84+
/// A randomization factor of 0.5 results in a random period ranging between 50% below and 50%
85+
/// above the retry interval. This helps avoid the "thundering herd" problem when multiple
86+
/// clients retry at the same time.
87+
pub fn with_randomization_factor(&mut self, randomization_factor: f64) -> &mut Self {
88+
self.randomization_factor = randomization_factor;
89+
self
90+
}
91+
92+
/// Sets the value to multiply the current interval with for each retry attempt.
93+
///
94+
/// A multiplier of 2.0 means each retry interval will be double the previous one.
95+
pub fn with_multiplier(&mut self, multiplier: f64) -> &mut Self {
96+
self.multiplier = multiplier;
97+
self
98+
}
99+
100+
/// Sets the maximum value of the back off period.
101+
///
102+
/// Once the retry interval reaches this value it stops increasing, providing
103+
/// an upper bound on the wait time between retries.
104+
pub fn with_max_interval(&mut self, max_interval: Duration) -> &mut Self {
105+
self.max_interval = max_interval;
106+
self
107+
}
108+
109+
/// Builds the [`PythLazerExponentialBackoff`] configuration.
110+
pub fn build(&self) -> HermesExponentialBackoff {
111+
HermesExponentialBackoff {
112+
initial_interval: self.initial_interval,
113+
randomization_factor: self.randomization_factor,
114+
multiplier: self.multiplier,
115+
max_interval: self.max_interval,
116+
}
117+
}
118+
}

0 commit comments

Comments
 (0)