Skip to main content

arete_server/
http_health.rs

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/// Configuration for the HTTP health server
32#[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
64/// HTTP server that exposes health endpoints
65pub 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            // Basic health check - server is running
220            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            // Readiness check - check if stream is healthy
228            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                // No health monitor configured, assume ready
244                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            // Detailed status endpoint
253            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    // HTTP reads are bearer-only. Do not allow query-param session tokens here.
483    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}