Skip to content

Commit e7b4a1b

Browse files
committed
feat(pyth-lazer) Implement JRPC endpoint for the lazer agent
1 parent dac9d93 commit e7b4a1b

File tree

10 files changed

+611
-177
lines changed

10 files changed

+611
-177
lines changed

apps/pyth-lazer-agent/Cargo.lock

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

apps/pyth-lazer-agent/Cargo.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ version = "0.1.2"
44
edition = "2024"
55

66
[dependencies]
7-
pyth-lazer-publisher-sdk = "0.1.5"
8-
pyth-lazer-protocol = "0.7.2"
7+
pyth-lazer-publisher-sdk = { path = "../../lazer/publisher_sdk/rust" }
8+
pyth-lazer-protocol = {path = "../../lazer/sdk/rust/protocol"}
99

1010
anyhow = "1.0.98"
1111
backoff = "0.4.0"
@@ -20,7 +20,7 @@ futures-util = "0.3.31"
2020
http = "1.3.1"
2121
http-body-util = "0.1.3"
2222
humantime-serde = "1.1.1"
23-
hyper = { version = "1.6.0", features = ["http1", "server"] }
23+
hyper = { version = "1.6.0", features = ["http1", "server", "client"] }
2424
hyper-util = { version = "0.1.10", features = ["tokio"] }
2525
protobuf = "3.7.2"
2626
serde = { version = "1.0.219", features = ["derive"] }
@@ -33,6 +33,7 @@ tokio-util = { version = "0.7.14", features = ["compat"] }
3333
tracing = "0.1.41"
3434
tracing-subscriber = { version = "0.3.19", features = ["env-filter", "json"] }
3535
url = { version = "2.5.4", features = ["serde"] }
36+
reqwest = "0.12.22"
3637

3738
[dev-dependencies]
3839
tempfile = "3.20.0"

apps/pyth-lazer-agent/src/http_server.rs

Lines changed: 137 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,46 @@
1-
use anyhow::{Context, Result};
1+
use crate::jrpc_handle::jrpc_handler_inner;
2+
use crate::publisher_handle::publisher_inner_handler;
3+
use crate::websocket_utils::{handle_websocket_error, send_text};
4+
use crate::{
5+
config::Config, lazer_publisher::LazerPublisher, publisher_handle::PublisherConnectionContext,
6+
};
7+
use anyhow::{Context, Result, bail};
8+
use futures_util::io::{BufReader, BufWriter};
9+
use hyper::body::Incoming;
210
use hyper::{Response, StatusCode, body::Bytes, server::conn::http1, service::service_fn};
311
use hyper_util::rt::TokioIo;
12+
use pyth_lazer_protocol::publisher::{ServerResponse, UpdateDeserializationErrorResponse};
413
use soketto::{
514
BoxedError,
615
handshake::http::{Server, is_upgrade_request},
716
};
17+
use std::fmt::Debug;
18+
use std::pin::Pin;
819
use std::{io, net::SocketAddr};
920
use tokio::net::{TcpListener, TcpStream};
10-
use tracing::{debug, info, instrument, warn};
11-
12-
use crate::{
13-
config::Config,
14-
lazer_publisher::LazerPublisher,
15-
publisher_handle::{PublisherConnectionContext, handle_publisher},
16-
};
21+
use tokio::{pin, select};
22+
use tokio_util::compat::TokioAsyncReadCompatExt;
23+
use tracing::{debug, error, info, instrument, warn};
1724

1825
type FullBody = http_body_util::Full<Bytes>;
26+
pub type InnerHandlerResult = Pin<Box<dyn Future<Output = Result<Option<String>>> + Send>>;
1927

20-
#[derive(Debug)]
21-
pub enum Request {
28+
#[derive(Debug, Copy, Clone)]
29+
pub enum PublisherRequest {
2230
PublisherV1,
2331
PublisherV2,
2432
}
2533

26-
pub struct RelayerRequest(pub http::Request<hyper::body::Incoming>);
34+
pub enum Request {
35+
PublisherRequest(PublisherRequest),
36+
JrpcV1,
37+
}
2738

28-
const PUBLISHER_WS_URI: &str = "/v1/publisher";
39+
pub struct RelayerRequest(pub http::Request<Incoming>);
40+
41+
const PUBLISHER_WS_URI_V1: &str = "/v1/publisher";
2942
const PUBLISHER_WS_URI_V2: &str = "/v2/publisher";
43+
const JRPC_WS_URI_V1: &str = "/v1/jprc";
3044

3145
const READINESS_PROBE_PATH: &str = "/ready";
3246
const LIVENESS_PROBE_PATH: &str = "/live";
@@ -38,15 +52,17 @@ pub async fn run(config: Config, lazer_publisher: LazerPublisher) -> Result<()>
3852
loop {
3953
let stream_addr = listener.accept().await;
4054
let lazer_publisher_clone = lazer_publisher.clone();
55+
let config = config.clone();
4156
tokio::spawn(async {
42-
if let Err(err) = try_handle_connection(stream_addr, lazer_publisher_clone).await {
57+
if let Err(err) = try_handle_connection(config, stream_addr, lazer_publisher_clone).await {
4358
warn!("error while handling connection: {err:?}");
4459
}
4560
});
4661
}
4762
}
4863

4964
async fn try_handle_connection(
65+
config: Config,
5066
stream_addr: io::Result<(TcpStream, SocketAddr)>,
5167
lazer_publisher: LazerPublisher,
5268
) -> Result<()> {
@@ -58,7 +74,7 @@ async fn try_handle_connection(
5874
TokioIo::new(stream),
5975
service_fn(move |r| {
6076
let request = RelayerRequest(r);
61-
request_handler(request, remote_addr, lazer_publisher.clone())
77+
request_handler(config.clone(), request, remote_addr, lazer_publisher.clone())
6278
}),
6379
)
6480
.with_upgrades()
@@ -68,15 +84,17 @@ async fn try_handle_connection(
6884

6985
#[instrument(skip_all, fields(component = "http_server", remote_addr = remote_addr.to_string()))]
7086
async fn request_handler(
87+
config: Config,
7188
request: RelayerRequest,
7289
remote_addr: SocketAddr,
7390
lazer_publisher: LazerPublisher,
7491
) -> Result<Response<FullBody>, BoxedError> {
7592
let path = request.0.uri().path();
7693

7794
let request_type = match path {
78-
PUBLISHER_WS_URI => Request::PublisherV1,
79-
PUBLISHER_WS_URI_V2 => Request::PublisherV2,
95+
PUBLISHER_WS_URI_V1 => Request::PublisherRequest(PublisherRequest::PublisherV1),
96+
PUBLISHER_WS_URI_V2 => Request::PublisherRequest(PublisherRequest::PublisherV2),
97+
JRPC_WS_URI_V1 => Request::JrpcV1,
8098
LIVENESS_PROBE_PATH => {
8199
let response = Response::builder().status(StatusCode::OK);
82100
return Ok(response.body(FullBody::default())?);
@@ -113,17 +131,32 @@ async fn request_handler(
113131
Ok(response) => {
114132
info!("accepted connection from publisher");
115133
match request_type {
116-
Request::PublisherV1 | Request::PublisherV2 => {
134+
Request::PublisherRequest(publisher_request_type) => {
117135
let publisher_connection_context = PublisherConnectionContext {
118-
request_type,
136+
request_type: publisher_request_type,
119137
_remote_addr: remote_addr,
120138
};
121-
tokio::spawn(handle_publisher(
139+
140+
tokio::spawn(handle_ws(
141+
config,
122142
server,
123143
request.0,
144+
lazer_publisher,
124145
publisher_connection_context,
146+
publisher_inner_handler,
147+
));
148+
Ok(response.map(|()| FullBody::default()))
149+
}
150+
Request::JrpcV1 => {
151+
tokio::spawn(handle_ws(
152+
config,
153+
server,
154+
request.0,
125155
lazer_publisher,
156+
(),
157+
jrpc_handler_inner,
126158
));
159+
127160
Ok(response.map(|()| FullBody::default()))
128161
}
129162
}
@@ -137,3 +170,88 @@ async fn request_handler(
137170
}
138171
}
139172
}
173+
174+
#[instrument(
175+
skip(server, request, lazer_publisher),
176+
fields(component = "publisher_ws")
177+
)]
178+
async fn handle_ws<T: Debug + Copy>(
179+
config: Config,
180+
server: Server,
181+
request: http::Request<Incoming>,
182+
lazer_publisher: LazerPublisher,
183+
context: T,
184+
inner_handler: fn(Config, Vec<u8>, LazerPublisher, T) -> InnerHandlerResult,
185+
) {
186+
if let Err(err) = try_handle_ws(config, server, request, lazer_publisher, context, inner_handler).await
187+
{
188+
handle_websocket_error(err);
189+
}
190+
}
191+
192+
#[instrument(
193+
skip(server, request, lazer_publisher),
194+
fields(component = "publisher_ws")
195+
)]
196+
async fn try_handle_ws<T: Debug + Copy>(
197+
config: Config,
198+
server: Server,
199+
request: http::Request<Incoming>,
200+
lazer_publisher: LazerPublisher,
201+
context: T,
202+
inner_handler: fn(Config, Vec<u8>, LazerPublisher, T) -> InnerHandlerResult,
203+
) -> Result<()> {
204+
let stream = hyper::upgrade::on(request).await?;
205+
let io = TokioIo::new(stream);
206+
let stream = BufReader::new(BufWriter::new(io.compat()));
207+
let (mut ws_sender, mut ws_receiver) = server.into_builder(stream).finish();
208+
209+
let mut receive_buf = Vec::new();
210+
211+
let mut error_count = 0u32;
212+
const MAX_ERROR_LOG: u32 = 10u32;
213+
const MAX_ERROR_DISCONNECT: u32 = 100u32;
214+
215+
loop {
216+
receive_buf.clear();
217+
{
218+
// soketto is not cancel-safe, so we need to store the future and poll it
219+
// in the inner loop.
220+
let receive = async { ws_receiver.receive(&mut receive_buf).await };
221+
pin!(receive);
222+
loop {
223+
select! {
224+
_result = &mut receive => {
225+
break
226+
}
227+
}
228+
}
229+
}
230+
231+
match inner_handler(config.clone(), receive_buf.clone(), lazer_publisher.clone(), context).await {
232+
Ok(response) => {
233+
if let Some(response) = response {
234+
send_text(&mut ws_sender, &response).await?;
235+
}
236+
}
237+
Err(err) => {
238+
error_count += 1;
239+
if error_count <= MAX_ERROR_LOG {
240+
warn!("Error decoding message error: {err}");
241+
}
242+
if error_count >= MAX_ERROR_DISCONNECT {
243+
error!("Error threshold reached; disconnecting");
244+
bail!("Error threshold reached");
245+
}
246+
let error_json = &serde_json::to_string::<ServerResponse>(
247+
&UpdateDeserializationErrorResponse {
248+
error: format!("failed to parse a binary message: {err}"),
249+
}
250+
.into(),
251+
)?;
252+
send_text(&mut ws_sender, error_json).await?;
253+
continue;
254+
}
255+
}
256+
}
257+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
use anyhow::{bail, Error};
2+
use futures_util::future::err;
3+
use reqwest::Client;
4+
use pyth_lazer_protocol::jrpc::{GetMetadataParams, JrpcResponse, JsonRpcVersion, JrpcParams, PythLazerAgentJrpcV1};
5+
use crate::config::Config;
6+
use crate::http_server::InnerHandlerResult;
7+
use crate::lazer_publisher::LazerPublisher;
8+
9+
pub fn jrpc_handler_inner(config: Config, receive_buf: Vec<u8>, lazer_publisher: LazerPublisher, _context: ()) -> InnerHandlerResult {
10+
Box::pin(async move {
11+
let (data, _) = bincode::serde::decode_from_slice::<PythLazerAgentJrpcV1, _>(
12+
&receive_buf,
13+
bincode::config::legacy(),
14+
)?;
15+
16+
match data.params {
17+
JrpcParams::SendUpdates(update_params) => {
18+
lazer_publisher
19+
.push_feed_update(update_params.into())
20+
.await?;
21+
22+
return Ok(Some(serde_json::to_string::<JrpcResponse<()>>(&JrpcResponse {
23+
jsonrpc: JsonRpcVersion::V2,
24+
result: (),
25+
id: data.id,
26+
})?))
27+
}
28+
JrpcParams::GetMetadata(params) => {
29+
match get_metadata(config, params).await {
30+
Ok(result) => {
31+
return Ok(Some(serde_json::to_string::<JrpcResponse<()>>(&JrpcResponse {
32+
jsonrpc: JsonRpcVersion::V2,
33+
result: result,
34+
id: data.id,
35+
})?))
36+
}
37+
Err(_) => {}
38+
}
39+
}
40+
}
41+
42+
Ok(None)
43+
})
44+
}
45+
46+
async fn get_metadata(config: Config, params: GetMetadataParams) -> Result<Option<String>, anyhow::Error> {
47+
let client = Client::new();
48+
49+
let resp = client
50+
.post("https://httpbin.org/post")
51+
.json(&payload)
52+
.send()
53+
.await?
54+
.json::<HttpBinPostResponse>()
55+
.await?;
56+
57+
let
58+
Ok(None)
59+
}

apps/pyth-lazer-agent/src/lazer_publisher.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ use tokio::{
2424
};
2525
use tracing::error;
2626

27-
#[derive(Clone)]
27+
#[derive(Clone, Debug)]
2828
pub struct LazerPublisher {
2929
sender: Sender<FeedUpdate>,
3030
pub(crate) is_ready: Arc<AtomicBool>,

apps/pyth-lazer-agent/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ mod lazer_publisher;
1212
mod publisher_handle;
1313
mod relayer_session;
1414
mod websocket_utils;
15+
mod jrpc_handle;
1516

1617
#[derive(Parser)]
1718
#[command(version)]

0 commit comments

Comments
 (0)