1use crate::http::transactions::{self, TransactionState};
2use crate::{config::TransactionConfig, health::HealthMonitor, ProgramAccountReaderFn};
3use anyhow::Result;
4use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
5use base64::Engine as _;
6use dashmap::DashMap;
7use http_body_util::BodyExt;
8use http_body_util::Full;
9use hyper::body::Bytes;
10use hyper::header::{
11 HeaderValue, ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS,
12 ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_EXPOSE_HEADERS, ACCESS_CONTROL_MAX_AGE,
13};
14use hyper::server::conn::http1;
15use hyper::service::service_fn;
16use hyper::{Method, Request, Response, StatusCode};
17use hyper_util::rt::TokioIo;
18use reqwest::Client;
19use serde::Deserialize;
20use serde_json::{json, Value};
21use std::convert::Infallible;
22use std::env;
23use std::net::SocketAddr;
24use std::sync::Arc;
25use std::time::{Duration, SystemTime, UNIX_EPOCH};
26use tokio::net::TcpListener;
27use tracing::{error, info};
28
29use crate::websocket::auth::{AuthDecision, AuthDeny, ConnectionAuthRequest, WebSocketAuthPlugin};
30
31#[derive(Clone, Debug)]
33pub struct HttpHealthConfig {
34 pub bind_address: SocketAddr,
35}
36
37impl Default for HttpHealthConfig {
38 fn default() -> Self {
39 Self {
40 bind_address: "[::]:8081".parse().expect("valid socket address"),
41 }
42 }
43}
44
45impl HttpHealthConfig {
46 pub fn new(bind_address: impl Into<SocketAddr>) -> Self {
47 Self {
48 bind_address: bind_address.into(),
49 }
50 }
51}
52
53#[derive(Clone)]
54struct HttpRequestState {
55 health_monitor: Arc<Option<HealthMonitor>>,
56 rpc_url: Arc<Option<String>>,
57 rpc_client: Client,
58 program_account_reader: Arc<Option<ProgramAccountReaderFn>>,
59 auth_plugin: Arc<Option<Arc<dyn WebSocketAuthPlugin>>>,
60 limit_state: Arc<HttpLimitState>,
61 transaction_state: Arc<Option<TransactionState>>,
62}
63
64pub struct HttpHealthServer {
66 bind_addr: SocketAddr,
67 health_monitor: Option<HealthMonitor>,
68 program_account_reader: Option<ProgramAccountReaderFn>,
69 auth_plugin: Option<Arc<dyn WebSocketAuthPlugin>>,
70 transaction_config: Option<TransactionConfig>,
71 #[cfg(feature = "otel")]
72 metrics: Option<Arc<crate::metrics::Metrics>>,
73}
74
75impl HttpHealthServer {
76 pub fn new(bind_addr: SocketAddr) -> Self {
77 Self {
78 bind_addr,
79 health_monitor: None,
80 program_account_reader: None,
81 auth_plugin: None,
82 transaction_config: None,
83 #[cfg(feature = "otel")]
84 metrics: None,
85 }
86 }
87
88 pub fn with_health_monitor(mut self, monitor: HealthMonitor) -> Self {
89 self.health_monitor = Some(monitor);
90 self
91 }
92
93 pub fn with_program_account_reader(mut self, reader: ProgramAccountReaderFn) -> Self {
94 self.program_account_reader = Some(reader);
95 self
96 }
97
98 pub fn with_auth_plugin(mut self, plugin: Arc<dyn WebSocketAuthPlugin>) -> Self {
99 self.auth_plugin = Some(plugin);
100 self
101 }
102
103 pub fn with_transaction_config(mut self, config: TransactionConfig) -> Self {
104 self.transaction_config = Some(config);
105 self
106 }
107
108 #[cfg(feature = "otel")]
109 pub fn with_metrics(mut self, metrics: Option<Arc<crate::metrics::Metrics>>) -> Self {
110 self.metrics = metrics;
111 self
112 }
113
114 pub async fn start(self) -> Result<()> {
115 info!("Starting HTTP health server on {}", self.bind_addr);
116
117 let listener = TcpListener::bind(&self.bind_addr).await?;
118 info!("HTTP health server listening on {}", self.bind_addr);
119
120 let transaction_state = self
121 .transaction_config
122 .filter(|config| config.enabled)
123 .map(TransactionState::new)
124 .transpose()?;
125 #[cfg(feature = "otel")]
126 let transaction_state = transaction_state.map(|state| state.with_metrics(self.metrics));
127 let request_state = HttpRequestState {
128 health_monitor: Arc::new(self.health_monitor),
129 rpc_url: Arc::new(resolve_rpc_url()),
130 rpc_client: Client::builder().build()?,
131 program_account_reader: Arc::new(self.program_account_reader),
132 auth_plugin: Arc::new(self.auth_plugin),
133 limit_state: Arc::new(HttpLimitState::default()),
134 transaction_state: Arc::new(transaction_state),
135 };
136
137 loop {
138 match listener.accept().await {
139 Ok((stream, remote_addr)) => {
140 let io = TokioIo::new(stream);
141 let request_state = request_state.clone();
142
143 tokio::spawn(async move {
144 let service = service_fn(move |req| {
145 let request_state = request_state.clone();
146 async move { handle_request(remote_addr, req, request_state).await }
147 });
148
149 if let Err(e) = http1::Builder::new().serve_connection(io, service).await {
150 error!("HTTP connection error: {}", e);
151 }
152 });
153 }
154 Err(e) => {
155 error!("Failed to accept HTTP connection: {}", e);
156 }
157 }
158 }
159 }
160}
161
162async fn handle_request(
163 remote_addr: SocketAddr,
164 req: Request<hyper::body::Incoming>,
165 state: HttpRequestState,
166) -> Result<Response<Full<Bytes>>, Infallible> {
167 if req.method() == Method::OPTIONS {
168 return Ok(with_cors(
169 Response::builder()
170 .status(StatusCode::NO_CONTENT)
171 .body(Full::new(Bytes::new()))
172 .unwrap(),
173 ));
174 }
175
176 let response = handle_request_inner(remote_addr, req, state).await?;
177 Ok(with_cors(response))
178}
179
180fn with_cors(mut response: Response<Full<Bytes>>) -> Response<Full<Bytes>> {
181 let headers = response.headers_mut();
182 headers.insert(ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
183 headers.insert(
184 ACCESS_CONTROL_ALLOW_METHODS,
185 HeaderValue::from_static("GET, POST, OPTIONS"),
186 );
187 headers.insert(
188 ACCESS_CONTROL_ALLOW_HEADERS,
189 HeaderValue::from_static("Authorization, Content-Type"),
190 );
191 headers.insert(
192 ACCESS_CONTROL_EXPOSE_HEADERS,
193 HeaderValue::from_static(
194 "Retry-After, X-Error-Code, X-Request-Id, X-Arete-Upstream-Attempted",
195 ),
196 );
197 headers.insert(ACCESS_CONTROL_MAX_AGE, HeaderValue::from_static("86400"));
198 response
199}
200
201async fn handle_request_inner(
202 remote_addr: SocketAddr,
203 req: Request<hyper::body::Incoming>,
204 state: HttpRequestState,
205) -> Result<Response<Full<Bytes>>, Infallible> {
206 let HttpRequestState {
207 health_monitor,
208 rpc_url,
209 rpc_client,
210 program_account_reader,
211 auth_plugin,
212 limit_state,
213 transaction_state,
214 } = state;
215 let path = req.uri().path().to_string();
216
217 match path.as_str() {
218 "/health" | "/healthz" => {
219 Ok(Response::builder()
221 .status(StatusCode::OK)
222 .header("Content-Type", "text/plain")
223 .body(Full::new(Bytes::from("OK")))
224 .unwrap())
225 }
226 "/ready" | "/readiness" => {
227 if let Some(monitor) = health_monitor.as_ref() {
229 if monitor.is_healthy().await {
230 Ok(Response::builder()
231 .status(StatusCode::OK)
232 .header("Content-Type", "text/plain")
233 .body(Full::new(Bytes::from("READY")))
234 .unwrap())
235 } else {
236 Ok(Response::builder()
237 .status(StatusCode::SERVICE_UNAVAILABLE)
238 .header("Content-Type", "text/plain")
239 .body(Full::new(Bytes::from("NOT READY")))
240 .unwrap())
241 }
242 } else {
243 Ok(Response::builder()
245 .status(StatusCode::OK)
246 .header("Content-Type", "text/plain")
247 .body(Full::new(Bytes::from("READY")))
248 .unwrap())
249 }
250 }
251 "/status" => {
252 if let Some(monitor) = health_monitor.as_ref() {
254 let status = monitor.status().await;
255 let error_count = monitor.error_count().await;
256 let is_healthy = monitor.is_healthy().await;
257
258 let status_json = serde_json::json!({
259 "healthy": is_healthy,
260 "status": format!("{:?}", status),
261 "error_count": error_count
262 });
263
264 let status_code = if is_healthy {
265 StatusCode::OK
266 } else {
267 StatusCode::SERVICE_UNAVAILABLE
268 };
269
270 Ok(Response::builder()
271 .status(status_code)
272 .header("Content-Type", "application/json")
273 .body(Full::new(Bytes::from(status_json.to_string())))
274 .unwrap())
275 } else {
276 let status_json = serde_json::json!({
277 "healthy": true,
278 "status": "no_monitor",
279 "error_count": 0
280 });
281
282 Ok(Response::builder()
283 .status(StatusCode::OK)
284 .header("Content-Type", "application/json")
285 .body(Full::new(Bytes::from(status_json.to_string())))
286 .unwrap())
287 }
288 }
289 _ if path.starts_with("/transactions/") => {
290 let Some(transaction_state) = transaction_state.as_ref() else {
291 return Ok(error_response(StatusCode::NOT_FOUND, "Not Found"));
292 };
293 let client_addr = transaction_state.client_addr(remote_addr, req.headers());
294 let auth_context = match authorize_http_request(
295 client_addr,
296 &req,
297 auth_plugin.as_ref().as_ref(),
298 &limit_state,
299 None,
300 false,
301 )
302 .await
303 {
304 Ok(context) => context,
305 Err(response) => return Ok(transaction_auth_error(response, path.as_str())),
306 };
307 Ok(
308 transactions::handle(client_addr, req, auth_context, transaction_state.clone())
309 .await,
310 )
311 }
312 _ if path.starts_with("/chain/") => {
313 let auth_context = match authorize_http_request(
314 remote_addr,
315 &req,
316 auth_plugin.as_ref().as_ref(),
317 &limit_state,
318 Some("read"),
319 true,
320 )
321 .await
322 {
323 Ok(context) => context,
324 Err(response) => return Ok(response),
325 };
326 Ok(handle_chain_request(req, path.as_str(), rpc_url, rpc_client, auth_context).await)
327 }
328 _ if path.starts_with("/programs/") => {
329 let auth_context = match authorize_http_request(
330 remote_addr,
331 &req,
332 auth_plugin.as_ref().as_ref(),
333 &limit_state,
334 Some("read"),
335 true,
336 )
337 .await
338 {
339 Ok(context) => context,
340 Err(response) => return Ok(response),
341 };
342 Ok(handle_program_account_request(
343 req,
344 path.as_str(),
345 rpc_url,
346 rpc_client,
347 program_account_reader,
348 auth_context,
349 )
350 .await)
351 }
352 _ => Ok(Response::builder()
353 .status(StatusCode::NOT_FOUND)
354 .header("Content-Type", "text/plain")
355 .body(Full::new(Bytes::from("Not Found")))
356 .unwrap()),
357 }
358}
359
360fn transaction_auth_error(response: Response<Full<Bytes>>, path: &str) -> Response<Full<Bytes>> {
361 let status = response.status();
362 let code = response
363 .headers()
364 .get("X-Error-Code")
365 .and_then(|value| value.to_str().ok())
366 .unwrap_or("authentication_failed")
367 .to_string();
368 let request_id = uuid::Uuid::new_v4().to_string();
369 let mut value = json!({
370 "code": code,
371 "message": "Transaction request authentication failed",
372 "retryable": status == StatusCode::TOO_MANY_REQUESTS || status == StatusCode::UNAUTHORIZED,
373 "requestId": request_id,
374 });
375 if path == "/transactions/v1/send" {
376 value["submissionState"] = json!("not_submitted");
377 }
378 Response::builder()
379 .status(status)
380 .header("Content-Type", "application/json")
381 .header("X-Error-Code", code)
382 .header("X-Request-Id", request_id)
383 .header("X-Arete-Upstream-Attempted", "false")
384 .body(Full::new(Bytes::from(value.to_string())))
385 .expect("valid transaction authentication response")
386}
387
388#[derive(Debug, Deserialize)]
389struct AddressesBody {
390 addresses: Vec<String>,
391}
392
393#[derive(Debug, Deserialize)]
394struct BalanceBody {
395 owner: String,
396 mint: String,
397 #[serde(default, rename = "tokenProgram")]
398 token_program: Option<String>,
399 #[serde(default, rename = "minContextSlot")]
400 min_context_slot: Option<String>,
401}
402
403#[derive(Debug, Deserialize)]
404struct NativeBalanceBody {
405 address: String,
406 #[serde(default, rename = "minContextSlot")]
407 min_context_slot: Option<String>,
408}
409
410#[derive(Default)]
411struct HttpLimitState {
412 per_subject_per_minute: DashMap<String, (u64, u32)>,
413}
414
415fn resolve_rpc_url() -> Option<String> {
416 ["ARETE_READ_RPC_URL", "SOLANA_RPC_URL", "RPC_URL"]
417 .iter()
418 .find_map(|key| env::var(key).ok())
419 .filter(|value| !value.is_empty())
420}
421
422fn json_response(status: StatusCode, value: Value) -> Response<Full<Bytes>> {
423 Response::builder()
424 .status(status)
425 .header("Content-Type", "application/json")
426 .body(Full::new(Bytes::from(value.to_string())))
427 .unwrap()
428}
429
430fn error_response(status: StatusCode, message: impl Into<String>) -> Response<Full<Bytes>> {
431 json_response(status, json!({ "error": message.into() }))
432}
433
434fn parse_min_context_slot(value: Option<&str>) -> std::result::Result<Option<u64>, &'static str> {
435 value
436 .map(|slot| {
437 slot.parse::<u64>()
438 .map_err(|_| "minContextSlot must be a decimal u64 string")
439 })
440 .transpose()
441}
442
443fn auth_deny_response(deny: &AuthDeny) -> Response<Full<Bytes>> {
444 let mut builder = Response::builder()
445 .status(deny.http_status)
446 .header("Content-Type", "application/json")
447 .header("X-Error-Code", deny.code.as_str());
448
449 if let Some(reset_at) = deny.reset_at {
450 if let Ok(duration) = reset_at.duration_since(SystemTime::now()) {
451 builder = builder.header("Retry-After", duration.as_secs().to_string());
452 }
453 }
454
455 builder
456 .body(Full::new(Bytes::from(
457 json!({
458 "error": deny.reason,
459 "message": deny.reason,
460 "code": deny.code.as_str(),
461 "retryable": deny.code.should_retry(),
462 "fatal": !deny.code.should_retry() && !deny.code.should_refresh_token(),
463 })
464 .to_string(),
465 )))
466 .unwrap()
467}
468
469async fn authorize_http_request(
470 remote_addr: SocketAddr,
471 req: &Request<hyper::body::Incoming>,
472 auth_plugin: Option<&Arc<dyn WebSocketAuthPlugin>>,
473 limit_state: &HttpLimitState,
474 required_scope: Option<&str>,
475 enforce_read_limits: bool,
476) -> std::result::Result<Option<crate::websocket::auth::AuthContext>, Response<Full<Bytes>>> {
477 let Some(plugin) = auth_plugin else {
478 return Ok(None);
479 };
480
481 let mut auth_request = ConnectionAuthRequest::from_http_request(remote_addr, req);
482 auth_request.query = None;
484 let decision = plugin.authorize(&auth_request).await;
485 let context = match decision {
486 AuthDecision::Allow(context) => context,
487 AuthDecision::Deny(deny) => return Err(auth_deny_response(&deny)),
488 };
489
490 if let Some(required_scope) = required_scope {
491 if !context.has_scope(required_scope) {
492 return Err(json_response(
493 StatusCode::FORBIDDEN,
494 json!({
495 "error": "insufficient_scope",
496 "message": format!("Required scope: {required_scope}"),
497 "code": "insufficient_scope",
498 "retryable": false,
499 "fatal": true
500 }),
501 ));
502 }
503 }
504
505 if enforce_read_limits {
506 enforce_http_limits(&context, limit_state).map_err(|deny| auth_deny_response(&deny))?;
507 }
508 Ok(Some(context))
509}
510
511fn enforce_http_limits(
512 context: &crate::websocket::auth::AuthContext,
513 limit_state: &HttpLimitState,
514) -> std::result::Result<(), Box<AuthDeny>> {
515 let Some(limit) = context.limits.max_http_requests_per_minute else {
516 return Ok(());
517 };
518
519 let now_bucket = SystemTime::now()
520 .duration_since(UNIX_EPOCH)
521 .unwrap_or(Duration::from_secs(0))
522 .as_secs()
523 / 60;
524 let key = format!("{}:{}", context.subject, context.metering_key);
525 let mut entry = limit_state
526 .per_subject_per_minute
527 .entry(key)
528 .or_insert((now_bucket, 0));
529 if entry.0 != now_bucket {
530 *entry = (now_bucket, 0);
531 }
532 if entry.1 >= limit {
533 return Err(Box::new(AuthDeny::rate_limited(
534 Duration::from_secs(60),
535 "http reads",
536 )));
537 }
538 entry.1 += 1;
539 Ok(())
540}
541
542async fn read_json_body<T: for<'de> Deserialize<'de>>(
543 req: Request<hyper::body::Incoming>,
544) -> std::result::Result<T, Response<Full<Bytes>>> {
545 let collected = req
546 .into_body()
547 .collect()
548 .await
549 .map_err(|err| error_response(StatusCode::BAD_REQUEST, err.to_string()))?;
550 serde_json::from_slice::<T>(&collected.to_bytes())
551 .map_err(|err| error_response(StatusCode::BAD_REQUEST, err.to_string()))
552}
553
554async fn handle_chain_request(
555 req: Request<hyper::body::Incoming>,
556 path: &str,
557 rpc_url: Arc<Option<String>>,
558 rpc_client: Client,
559 _auth_context: Option<crate::websocket::auth::AuthContext>,
560) -> Response<Full<Bytes>> {
561 let Some(rpc_url) = rpc_url.as_ref() else {
562 return error_response(
563 StatusCode::SERVICE_UNAVAILABLE,
564 "No RPC URL configured for chain reads",
565 );
566 };
567
568 match (req.method().as_str(), path) {
569 ("GET", path) if path.starts_with("/chain/exists/") => {
570 let address = path.trim_start_matches("/chain/exists/");
571 match rpc_get_account_info(&rpc_client, rpc_url, address).await {
572 Ok(value) => json_response(StatusCode::OK, json!({ "exists": !value.is_null() })),
573 Err(err) => error_response(StatusCode::BAD_GATEWAY, err.to_string()),
574 }
575 }
576 ("GET", path) if path.starts_with("/chain/lamports/") => {
577 let address = path.trim_start_matches("/chain/lamports/");
578 match rpc_call(
579 &rpc_client,
580 rpc_url,
581 "getBalance",
582 json!([address, { "commitment": "confirmed" }]),
583 )
584 .await
585 {
586 Ok(value) => json_response(
587 StatusCode::OK,
588 json!({ "lamports": value.pointer("/value").and_then(Value::as_u64).unwrap_or(0) }),
589 ),
590 Err(err) => error_response(StatusCode::BAD_GATEWAY, err.to_string()),
591 }
592 }
593 ("POST", "/chain/native-balance") => match read_json_body::<NativeBalanceBody>(req).await {
594 Ok(body) => {
595 let min_context_slot =
596 match parse_min_context_slot(body.min_context_slot.as_deref()) {
597 Ok(slot) => slot,
598 Err(message) => return error_response(StatusCode::BAD_REQUEST, message),
599 };
600 match rpc_get_native_balance(&rpc_client, rpc_url, &body.address, min_context_slot)
601 .await
602 {
603 Ok(balance) => json_response(StatusCode::OK, balance),
604 Err(err) => error_response(StatusCode::BAD_GATEWAY, err.to_string()),
605 }
606 }
607 Err(response) => response,
608 },
609 ("GET", path) if path.starts_with("/chain/rent-exemption/") => {
610 let raw_space = path.trim_start_matches("/chain/rent-exemption/");
611 let Ok(space) = raw_space.parse::<u64>() else {
612 return error_response(
613 StatusCode::BAD_REQUEST,
614 "rent-exemption space must be an integer",
615 );
616 };
617 match rpc_call(
618 &rpc_client,
619 rpc_url,
620 "getMinimumBalanceForRentExemption",
621 json!([space, { "commitment": "confirmed" }]),
622 )
623 .await
624 {
625 Ok(value) => json_response(
626 StatusCode::OK,
627 json!({ "lamports": value.as_u64().unwrap_or(0) }),
628 ),
629 Err(err) => error_response(StatusCode::BAD_GATEWAY, err.to_string()),
630 }
631 }
632 ("GET", "/chain/clock") => {
633 let slot = rpc_call(
634 &rpc_client,
635 rpc_url,
636 "getSlot",
637 json!([{ "commitment": "confirmed" }]),
638 )
639 .await;
640 let epoch_info = rpc_call(
641 &rpc_client,
642 rpc_url,
643 "getEpochInfo",
644 json!([{ "commitment": "confirmed" }]),
645 )
646 .await;
647 match (slot, epoch_info) {
648 (Ok(slot_value), Ok(epoch_value)) => {
649 let slot_num = slot_value.as_u64().unwrap_or(0);
650 let unix_timestamp =
651 rpc_call(&rpc_client, rpc_url, "getBlockTime", json!([slot_num]))
652 .await
653 .ok()
654 .and_then(|value| value.as_i64())
655 .unwrap_or_default();
656 json_response(
657 StatusCode::OK,
658 json!({
659 "slot": slot_num,
660 "epoch": epoch_value.get("epoch").and_then(Value::as_u64),
661 "leaderScheduleEpoch": epoch_value.get("leaderScheduleSlotOffset").and_then(Value::as_u64),
662 "unixTimestamp": unix_timestamp,
663 }),
664 )
665 }
666 (Err(err), _) | (_, Err(err)) => {
667 error_response(StatusCode::BAD_GATEWAY, err.to_string())
668 }
669 }
670 }
671 ("GET", path) if path.starts_with("/chain/accounts/") => {
672 let address = path.trim_start_matches("/chain/accounts/");
673 match rpc_get_account_info(&rpc_client, rpc_url, address).await {
674 Ok(value) if value.is_null() => json_response(StatusCode::OK, Value::Null),
675 Ok(value) => json_response(StatusCode::OK, raw_account_json(address, &value)),
676 Err(err) => error_response(StatusCode::BAD_GATEWAY, err.to_string()),
677 }
678 }
679 ("GET", path) if path.starts_with("/chain/mints/") => {
680 let address = path.trim_start_matches("/chain/mints/");
681 match rpc_get_parsed_account_info(&rpc_client, rpc_url, address).await {
682 Ok(Some(value)) => json_response(StatusCode::OK, mint_info_json(address, &value)),
683 Ok(None) => json_response(StatusCode::OK, Value::Null),
684 Err(err) => error_response(StatusCode::BAD_GATEWAY, err.to_string()),
685 }
686 }
687 ("GET", path) if path.starts_with("/chain/token-accounts/") => {
688 let address = path.trim_start_matches("/chain/token-accounts/");
689 match rpc_get_parsed_account_info(&rpc_client, rpc_url, address).await {
690 Ok(Some(value)) => {
691 json_response(StatusCode::OK, token_account_json(address, &value))
692 }
693 Ok(None) => json_response(StatusCode::OK, Value::Null),
694 Err(err) => error_response(StatusCode::BAD_GATEWAY, err.to_string()),
695 }
696 }
697 ("POST", "/chain/balances") => match read_json_body::<BalanceBody>(req).await {
698 Ok(body) => {
699 let min_context_slot =
700 match parse_min_context_slot(body.min_context_slot.as_deref()) {
701 Ok(slot) => slot,
702 Err(message) => return error_response(StatusCode::BAD_REQUEST, message),
703 };
704 match rpc_get_token_balance(
705 &rpc_client,
706 rpc_url,
707 &body.owner,
708 &body.mint,
709 body.token_program.as_deref(),
710 min_context_slot,
711 )
712 .await
713 {
714 Ok(balance) => json_response(StatusCode::OK, balance),
715 Err(err) => error_response(StatusCode::BAD_GATEWAY, err.to_string()),
716 }
717 }
718 Err(response) => response,
719 },
720 _ => error_response(StatusCode::NOT_FOUND, "Not Found"),
721 }
722}
723
724async fn handle_program_account_request(
725 req: Request<hyper::body::Incoming>,
726 path: &str,
727 rpc_url: Arc<Option<String>>,
728 rpc_client: Client,
729 program_account_reader: Arc<Option<ProgramAccountReaderFn>>,
730 auth_context: Option<crate::websocket::auth::AuthContext>,
731) -> Response<Full<Bytes>> {
732 let Some(rpc_url) = rpc_url.as_ref() else {
733 return error_response(
734 StatusCode::SERVICE_UNAVAILABLE,
735 "No RPC URL configured for program account reads",
736 );
737 };
738 let Some(reader) = program_account_reader.as_ref() else {
739 return error_response(
740 StatusCode::NOT_IMPLEMENTED,
741 "Program account reader is not configured",
742 );
743 };
744
745 let segments: Vec<&str> = path.trim_start_matches('/').split('/').collect();
746 if segments.len() < 4
747 || segments.first() != Some(&"programs")
748 || segments.get(2) != Some(&"accounts")
749 {
750 return error_response(StatusCode::NOT_FOUND, "Not Found");
751 }
752 let program = segments[1];
753 let account = segments[3];
754
755 match (req.method().as_str(), segments.len()) {
756 ("GET", 5) => {
757 let address = segments[4];
758 match rpc_get_account_info(&rpc_client, rpc_url, address).await {
759 Ok(value) if value.is_null() => json_response(StatusCode::OK, Value::Null),
760 Ok(value) => match decode_account_bytes(&value)
761 .and_then(|data| reader(program, account, &data).ok())
762 {
763 Some(parsed) => json_response(StatusCode::OK, parsed),
764 None => {
765 error_response(StatusCode::BAD_REQUEST, "Unable to parse requested account")
766 }
767 },
768 Err(err) => error_response(StatusCode::BAD_GATEWAY, err.to_string()),
769 }
770 }
771 ("GET", 6) if segments[5] == "exists" => {
772 let address = segments[4];
773 match rpc_get_account_info(&rpc_client, rpc_url, address).await {
774 Ok(value) => json_response(StatusCode::OK, json!({ "exists": !value.is_null() })),
775 Err(err) => error_response(StatusCode::BAD_GATEWAY, err.to_string()),
776 }
777 }
778 ("POST", 4) => match read_json_body::<AddressesBody>(req).await {
779 Ok(body) => {
780 if let Some(limit) = auth_context
781 .as_ref()
782 .and_then(|ctx| ctx.limits.max_http_batch_addresses)
783 {
784 if body.addresses.len() > limit as usize {
785 return auth_deny_response(&AuthDeny::rate_limited(
786 Duration::from_secs(60),
787 &format!(
788 "http batch reads ({} addresses > {})",
789 body.addresses.len(),
790 limit
791 ),
792 ));
793 }
794 }
795 match rpc_get_multiple_accounts(&rpc_client, rpc_url, &body.addresses).await {
796 Ok(values) => {
797 let parsed: Vec<Value> = values
798 .iter()
799 .map(|value| {
800 if value.is_null() {
801 Value::Null
802 } else if let Some(data) = decode_account_bytes(value) {
803 reader(program, account, &data).unwrap_or(Value::Null)
804 } else {
805 Value::Null
806 }
807 })
808 .collect();
809 json_response(StatusCode::OK, Value::Array(parsed))
810 }
811 Err(err) => error_response(StatusCode::BAD_GATEWAY, err.to_string()),
812 }
813 }
814 Err(response) => response,
815 },
816 _ => error_response(StatusCode::NOT_FOUND, "Not Found"),
817 }
818}
819
820async fn rpc_call(
821 client: &Client,
822 rpc_url: &str,
823 method: &str,
824 params: Value,
825) -> anyhow::Result<Value> {
826 let response = client
827 .post(rpc_url)
828 .json(&json!({
829 "jsonrpc": "2.0",
830 "id": "arete-read",
831 "method": method,
832 "params": params,
833 }))
834 .send()
835 .await?
836 .error_for_status()?;
837 let value = response.json::<Value>().await?;
838 if let Some(error) = value.get("error") {
839 return Err(anyhow::anyhow!(error.to_string()));
840 }
841 Ok(value.get("result").cloned().unwrap_or(Value::Null))
842}
843
844fn rpc_read_config(encoding: Option<&str>, min_context_slot: Option<u64>) -> Value {
845 let mut config = json!({ "commitment": "confirmed" });
846 let object = config
847 .as_object_mut()
848 .expect("RPC read config is always an object");
849 if let Some(encoding) = encoding {
850 object.insert("encoding".to_string(), json!(encoding));
851 }
852 if let Some(min_context_slot) = min_context_slot {
853 object.insert("minContextSlot".to_string(), json!(min_context_slot));
854 }
855 config
856}
857
858async fn rpc_get_native_balance(
859 client: &Client,
860 rpc_url: &str,
861 address: &str,
862 min_context_slot: Option<u64>,
863) -> anyhow::Result<Value> {
864 let result = rpc_call(
865 client,
866 rpc_url,
867 "getBalance",
868 json!([address, rpc_read_config(None, min_context_slot)]),
869 )
870 .await?;
871 contextual_native_balance_json(&result)
872}
873
874fn contextual_native_balance_json(result: &Value) -> anyhow::Result<Value> {
875 let lamports = result
876 .get("value")
877 .and_then(Value::as_u64)
878 .ok_or_else(|| anyhow::anyhow!("getBalance response is missing a u64 value"))?;
879 let context_slot = result
880 .pointer("/context/slot")
881 .and_then(Value::as_u64)
882 .ok_or_else(|| anyhow::anyhow!("getBalance response is missing a u64 context slot"))?;
883 Ok(json!({
884 "lamports": lamports.to_string(),
885 "contextSlot": context_slot.to_string(),
886 }))
887}
888
889async fn rpc_get_account_info(
890 client: &Client,
891 rpc_url: &str,
892 address: &str,
893) -> anyhow::Result<Value> {
894 let result = rpc_call(
895 client,
896 rpc_url,
897 "getAccountInfo",
898 json!([address, { "encoding": "base64", "commitment": "confirmed" }]),
899 )
900 .await?;
901 Ok(result.get("value").cloned().unwrap_or(Value::Null))
902}
903
904async fn rpc_get_multiple_accounts(
905 client: &Client,
906 rpc_url: &str,
907 addresses: &[String],
908) -> anyhow::Result<Vec<Value>> {
909 let result = rpc_call(
910 client,
911 rpc_url,
912 "getMultipleAccounts",
913 json!([addresses, { "encoding": "base64", "commitment": "confirmed" }]),
914 )
915 .await?;
916 Ok(result
917 .get("value")
918 .and_then(Value::as_array)
919 .cloned()
920 .unwrap_or_default())
921}
922
923async fn rpc_get_parsed_account_info(
924 client: &Client,
925 rpc_url: &str,
926 address: &str,
927) -> anyhow::Result<Option<Value>> {
928 let result = rpc_call(
929 client,
930 rpc_url,
931 "getAccountInfo",
932 json!([address, { "encoding": "jsonParsed", "commitment": "confirmed" }]),
933 )
934 .await?;
935 Ok(result
936 .get("value")
937 .cloned()
938 .filter(|value| !value.is_null()))
939}
940
941async fn rpc_get_token_balance(
942 client: &Client,
943 rpc_url: &str,
944 owner: &str,
945 mint: &str,
946 token_program: Option<&str>,
947 min_context_slot: Option<u64>,
948) -> anyhow::Result<Value> {
949 let filter = token_program
950 .map(|program_id| json!({ "programId": program_id }))
951 .unwrap_or_else(|| json!({ "mint": mint }));
952 let result = rpc_call(
953 client,
954 rpc_url,
955 "getTokenAccountsByOwner",
956 json!([
957 owner,
958 filter,
959 rpc_read_config(Some("jsonParsed"), min_context_slot)
960 ]),
961 )
962 .await?;
963
964 contextual_token_balance_json(&result, owner, mint, token_program)
965}
966
967fn contextual_token_balance_json(
968 result: &Value,
969 owner: &str,
970 mint: &str,
971 token_program: Option<&str>,
972) -> anyhow::Result<Value> {
973 let context_slot = result
974 .pointer("/context/slot")
975 .and_then(Value::as_u64)
976 .ok_or_else(|| {
977 anyhow::anyhow!("getTokenAccountsByOwner response is missing a u64 context slot")
978 })?;
979
980 let account = result
981 .get("value")
982 .and_then(Value::as_array)
983 .and_then(|items| {
984 items.iter().find(|item| {
985 item.pointer("/account/data/parsed/info/mint")
986 .and_then(Value::as_str)
987 == Some(mint)
988 })
989 })
990 .cloned();
991
992 if let Some(account) = account {
993 let pubkey = account.get("pubkey").and_then(Value::as_str);
994 let info = account.pointer("/account/data/parsed/info");
995 return Ok(json!({
996 "exists": true,
997 "address": pubkey,
998 "owner": owner,
999 "mint": mint,
1000 "tokenProgram": token_program,
1001 "amount": info.and_then(|value| value.pointer("/tokenAmount/amount")).and_then(Value::as_str).unwrap_or("0"),
1002 "decimals": info.and_then(|value| value.pointer("/tokenAmount/decimals")).and_then(Value::as_u64),
1003 "uiAmountString": info.and_then(|value| value.pointer("/tokenAmount/uiAmountString")).and_then(Value::as_str),
1004 "contextSlot": context_slot.to_string(),
1005 }));
1006 }
1007
1008 Ok(json!({
1009 "exists": false,
1010 "address": Value::Null,
1011 "owner": owner,
1012 "mint": mint,
1013 "tokenProgram": token_program,
1014 "amount": "0",
1015 "decimals": Value::Null,
1016 "uiAmountString": Value::Null,
1017 "contextSlot": context_slot.to_string(),
1018 }))
1019}
1020
1021fn decode_account_bytes(value: &Value) -> Option<Vec<u8>> {
1022 let data = value.get("data")?.as_array()?;
1023 let encoded = data.first()?.as_str()?;
1024 BASE64_STANDARD.decode(encoded).ok()
1025}
1026
1027fn raw_account_json(address: &str, value: &Value) -> Value {
1028 json!({
1029 "address": address,
1030 "ownerProgram": value.get("owner").and_then(Value::as_str).unwrap_or_default(),
1031 "lamports": value.get("lamports").and_then(Value::as_u64).unwrap_or(0),
1032 "executable": value.get("executable").and_then(Value::as_bool).unwrap_or(false),
1033 "data": value
1034 .pointer("/data/0")
1035 .and_then(Value::as_str)
1036 .unwrap_or_default(),
1037 })
1038}
1039
1040fn mint_info_json(address: &str, value: &Value) -> Value {
1041 let owner_program = value
1042 .get("owner")
1043 .and_then(Value::as_str)
1044 .unwrap_or_default();
1045 let info = value.pointer("/data/parsed/info");
1046 json!({
1047 "address": address,
1048 "ownerProgram": owner_program,
1049 "decimals": info.and_then(|v| v.get("decimals")).and_then(Value::as_u64),
1050 "supply": info.and_then(|v| v.get("supply")).and_then(Value::as_str),
1051 "mintAuthority": info.and_then(|v| v.get("mintAuthority")).and_then(Value::as_str),
1052 "freezeAuthority": info.and_then(|v| v.get("freezeAuthority")).and_then(Value::as_str),
1053 })
1054}
1055
1056fn token_account_json(address: &str, value: &Value) -> Value {
1057 let owner_program = value
1058 .get("owner")
1059 .and_then(Value::as_str)
1060 .unwrap_or_default();
1061 let info = value.pointer("/data/parsed/info");
1062 json!({
1063 "address": address,
1064 "ownerProgram": owner_program,
1065 "mint": info.and_then(|v| v.get("mint")).and_then(Value::as_str),
1066 "owner": info.and_then(|v| v.get("owner")).and_then(Value::as_str),
1067 "amount": info.and_then(|v| v.pointer("/tokenAmount/amount")).and_then(Value::as_str),
1068 "uiAmountString": info.and_then(|v| v.pointer("/tokenAmount/uiAmountString")).and_then(Value::as_str),
1069 })
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074 use super::*;
1075
1076 #[test]
1077 fn cors_headers_allow_browser_sdk_reads() {
1078 let response = with_cors(
1079 Response::builder()
1080 .status(StatusCode::OK)
1081 .body(Full::new(Bytes::new()))
1082 .unwrap(),
1083 );
1084
1085 assert_eq!(response.headers()[ACCESS_CONTROL_ALLOW_ORIGIN], "*");
1086 assert_eq!(
1087 response.headers()[ACCESS_CONTROL_ALLOW_METHODS],
1088 "GET, POST, OPTIONS"
1089 );
1090 assert_eq!(
1091 response.headers()[ACCESS_CONTROL_ALLOW_HEADERS],
1092 "Authorization, Content-Type"
1093 );
1094 }
1095
1096 #[test]
1097 fn native_balance_serializes_u64_values_as_decimal_strings() {
1098 let value = contextual_native_balance_json(&json!({
1099 "context": { "slot": 9_007_199_254_740_995_u64 },
1100 "value": 9_007_199_254_740_993_u64,
1101 }))
1102 .unwrap();
1103
1104 assert_eq!(value["lamports"], "9007199254740993");
1105 assert_eq!(value["contextSlot"], "9007199254740995");
1106 }
1107
1108 #[test]
1109 fn rpc_read_config_propagates_min_context_slot() {
1110 let config = rpc_read_config(Some("jsonParsed"), Some(9_007_199_254_740_997));
1111
1112 assert_eq!(config["commitment"], "confirmed");
1113 assert_eq!(config["encoding"], "jsonParsed");
1114 assert_eq!(config["minContextSlot"], 9_007_199_254_740_997_u64);
1115 }
1116
1117 #[test]
1118 fn token_balance_preserves_raw_amount_and_stringifies_context_slot() {
1119 let value = contextual_token_balance_json(
1120 &json!({
1121 "context": { "slot": 9_007_199_254_740_995_u64 },
1122 "value": [{
1123 "pubkey": "token-account",
1124 "account": {
1125 "data": {
1126 "parsed": {
1127 "info": {
1128 "mint": "mint",
1129 "tokenAmount": {
1130 "amount": "18446744073709551615",
1131 "decimals": 9,
1132 "uiAmountString": "18446744073.709551615"
1133 }
1134 }
1135 }
1136 }
1137 }
1138 }]
1139 }),
1140 "owner",
1141 "mint",
1142 None,
1143 )
1144 .unwrap();
1145
1146 assert_eq!(value["amount"], "18446744073709551615");
1147 assert_eq!(value["contextSlot"], "9007199254740995");
1148 }
1149
1150 #[test]
1151 fn balance_bodies_accept_decimal_string_min_context_slots() {
1152 let native: NativeBalanceBody = serde_json::from_value(json!({
1153 "address": "owner",
1154 "minContextSlot": "9007199254740997",
1155 }))
1156 .unwrap();
1157 let token: BalanceBody = serde_json::from_value(json!({
1158 "owner": "owner",
1159 "mint": "mint",
1160 "minContextSlot": "9007199254740999",
1161 }))
1162 .unwrap();
1163
1164 assert_eq!(
1165 parse_min_context_slot(native.min_context_slot.as_deref()).unwrap(),
1166 Some(9_007_199_254_740_997)
1167 );
1168 assert_eq!(
1169 parse_min_context_slot(token.min_context_slot.as_deref()).unwrap(),
1170 Some(9_007_199_254_740_999)
1171 );
1172 }
1173}