Skip to main content

arete_server/
http_health.rs

1use crate::http::transactions::{self, TransactionState};
2use crate::{
3    config::{RuntimePlan, TransactionConfig},
4    health::HealthMonitor,
5    ProgramRuntimeCatalog, ProgramRuntimeDefinition,
6};
7use anyhow::Result;
8use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
9use base64::Engine as _;
10use dashmap::DashMap;
11use http_body_util::BodyExt;
12use http_body_util::Full;
13use hyper::body::Bytes;
14use hyper::header::{
15    HeaderValue, ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS,
16    ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_EXPOSE_HEADERS, ACCESS_CONTROL_MAX_AGE,
17};
18use hyper::server::conn::http1;
19use hyper::service::service_fn;
20use hyper::{Method, Request, Response, StatusCode};
21use hyper_util::rt::TokioIo;
22use reqwest::Client;
23use serde::Deserialize;
24use serde_json::{json, Value};
25use std::convert::Infallible;
26use std::env;
27use std::net::SocketAddr;
28use std::sync::Arc;
29use std::time::{Duration, SystemTime, UNIX_EPOCH};
30use tokio::net::TcpListener;
31use tokio_util::sync::CancellationToken;
32use tracing::{error, info};
33
34use crate::websocket::auth::{AuthDecision, AuthDeny, ConnectionAuthRequest, WebSocketAuthPlugin};
35use arete_auth::SCOPE_READ;
36
37/// Configuration for the HTTP health server
38#[derive(Clone, Debug)]
39pub struct HttpHealthConfig {
40    pub bind_address: SocketAddr,
41}
42
43impl Default for HttpHealthConfig {
44    fn default() -> Self {
45        Self {
46            bind_address: "[::]:8081".parse().expect("valid socket address"),
47        }
48    }
49}
50
51impl HttpHealthConfig {
52    pub fn new(bind_address: impl Into<SocketAddr>) -> Self {
53        Self {
54            bind_address: bind_address.into(),
55        }
56    }
57}
58
59#[derive(Clone)]
60struct HttpRequestState {
61    health_monitor: Arc<Option<HealthMonitor>>,
62    snapshot_runtime: Option<crate::snapshot::SnapshotRuntime>,
63    runtime_plan: RuntimePlan,
64    rpc_url: Arc<Option<String>>,
65    rpc_client: Client,
66    program_runtime_catalog: Arc<ProgramRuntimeCatalog>,
67    auth_plugin: Arc<Option<Arc<dyn WebSocketAuthPlugin>>>,
68    limit_state: Arc<HttpLimitState>,
69    transaction_state: Arc<Option<TransactionState>>,
70    solana_gateway_target_id: Arc<Option<String>>,
71    program_read_binding_target_id: Arc<Option<String>>,
72}
73
74/// HTTP server that exposes health endpoints
75pub struct HttpHealthServer {
76    /// Cancelled to stop the accept loop; see [`HttpHealthServer::with_shutdown`].
77    shutdown: Option<CancellationToken>,
78    bind_addr: SocketAddr,
79    health_monitor: Option<HealthMonitor>,
80    snapshot_runtime: Option<crate::snapshot::SnapshotRuntime>,
81    runtime_plan: RuntimePlan,
82    program_runtime_catalog: ProgramRuntimeCatalog,
83    auth_plugin: Option<Arc<dyn WebSocketAuthPlugin>>,
84    transaction_config: Option<TransactionConfig>,
85    solana_gateway_target_id: Option<String>,
86    program_read_binding_target_id: Option<String>,
87    #[cfg(feature = "otel")]
88    metrics: Option<Arc<crate::metrics::Metrics>>,
89}
90
91impl HttpHealthServer {
92    pub fn new(bind_addr: SocketAddr) -> Self {
93        Self {
94            bind_addr,
95            shutdown: None,
96            health_monitor: None,
97            snapshot_runtime: None,
98            runtime_plan: RuntimePlan::http(),
99            program_runtime_catalog: ProgramRuntimeCatalog::default(),
100            auth_plugin: None,
101            transaction_config: None,
102            solana_gateway_target_id: None,
103            program_read_binding_target_id: None,
104            #[cfg(feature = "otel")]
105            metrics: None,
106        }
107    }
108
109    pub fn with_health_monitor(mut self, monitor: HealthMonitor) -> Self {
110        self.health_monitor = Some(monitor);
111        self
112    }
113
114    pub fn with_snapshot_runtime(
115        mut self,
116        snapshot_runtime: crate::snapshot::SnapshotRuntime,
117    ) -> Self {
118        self.snapshot_runtime = Some(snapshot_runtime);
119        self
120    }
121
122    pub fn with_runtime_plan(mut self, runtime_plan: RuntimePlan) -> Self {
123        self.runtime_plan = runtime_plan;
124        self
125    }
126
127    pub fn with_program_runtime_catalog(mut self, catalog: ProgramRuntimeCatalog) -> Self {
128        self.program_runtime_catalog = catalog;
129        self
130    }
131
132    pub fn with_auth_plugin(mut self, plugin: Arc<dyn WebSocketAuthPlugin>) -> Self {
133        self.auth_plugin = Some(plugin);
134        self
135    }
136
137    pub fn with_transaction_config(mut self, config: TransactionConfig) -> Self {
138        self.runtime_plan.transactions = config.enabled;
139        self.transaction_config = Some(config);
140        self
141    }
142
143    pub fn with_solana_gateway_target(mut self, target_id: impl Into<String>) -> Self {
144        self.solana_gateway_target_id = Some(target_id.into());
145        self
146    }
147
148    pub fn with_program_read_binding_target(mut self, target_id: impl Into<String>) -> Self {
149        self.program_read_binding_target_id = Some(target_id.into());
150        self
151    }
152
153    #[cfg(feature = "otel")]
154    pub fn with_metrics(mut self, metrics: Option<Arc<crate::metrics::Metrics>>) -> Self {
155        self.metrics = metrics;
156        self
157    }
158
159    /// Stop accepting and return from [`start`](Self::start) when `token` is
160    /// cancelled. Without one the server accepts until the process exits.
161    pub fn with_shutdown(mut self, token: CancellationToken) -> Self {
162        self.shutdown = Some(token);
163        self
164    }
165
166    pub async fn start(self) -> Result<()> {
167        info!("Starting HTTP health server on {}", self.bind_addr);
168
169        let listener = TcpListener::bind(&self.bind_addr).await?;
170        info!("HTTP health server listening on {}", self.bind_addr);
171
172        let transaction_state = self
173            .transaction_config
174            .filter(|config| config.enabled)
175            .map(TransactionState::new)
176            .transpose()?;
177        #[cfg(feature = "otel")]
178        let transaction_state = transaction_state.map(|state| state.with_metrics(self.metrics));
179        let request_state = HttpRequestState {
180            health_monitor: Arc::new(self.health_monitor),
181            snapshot_runtime: self.snapshot_runtime,
182            runtime_plan: self.runtime_plan,
183            rpc_url: Arc::new(resolve_rpc_url()),
184            rpc_client: Client::builder().build()?,
185            program_runtime_catalog: Arc::new(self.program_runtime_catalog),
186            auth_plugin: Arc::new(self.auth_plugin),
187            limit_state: Arc::new(HttpLimitState::default()),
188            transaction_state: Arc::new(transaction_state),
189            solana_gateway_target_id: Arc::new(self.solana_gateway_target_id),
190            program_read_binding_target_id: Arc::new(self.program_read_binding_target_id),
191        };
192
193        let shutdown = self.shutdown.unwrap_or_default();
194        loop {
195            let accepted = tokio::select! {
196                _ = shutdown.cancelled() => {
197                    info!("HTTP health server on {} stopping", self.bind_addr);
198                    return Ok(());
199                }
200                accepted = listener.accept() => accepted,
201            };
202            match accepted {
203                Ok((stream, remote_addr)) => {
204                    let io = TokioIo::new(stream);
205                    let request_state = request_state.clone();
206
207                    tokio::spawn(async move {
208                        let service = service_fn(move |req| {
209                            let request_state = request_state.clone();
210                            async move { handle_request(remote_addr, req, request_state).await }
211                        });
212
213                        if let Err(e) = http1::Builder::new().serve_connection(io, service).await {
214                            error!("HTTP connection error: {}", e);
215                        }
216                    });
217                }
218                Err(e) => {
219                    error!("Failed to accept HTTP connection: {}", e);
220                }
221            }
222        }
223    }
224}
225
226async fn handle_request(
227    remote_addr: SocketAddr,
228    req: Request<hyper::body::Incoming>,
229    state: HttpRequestState,
230) -> Result<Response<Full<Bytes>>, Infallible> {
231    if req.method() == Method::OPTIONS {
232        return Ok(with_cors(
233            Response::builder()
234                .status(StatusCode::NO_CONTENT)
235                .body(Full::new(Bytes::new()))
236                .unwrap(),
237        ));
238    }
239
240    let response = handle_request_inner(remote_addr, req, state).await?;
241    Ok(with_cors(response))
242}
243
244fn with_cors(mut response: Response<Full<Bytes>>) -> Response<Full<Bytes>> {
245    let headers = response.headers_mut();
246    headers.insert(ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
247    headers.insert(
248        ACCESS_CONTROL_ALLOW_METHODS,
249        HeaderValue::from_static("GET, POST, OPTIONS"),
250    );
251    headers.insert(
252        ACCESS_CONTROL_ALLOW_HEADERS,
253        HeaderValue::from_static("Authorization, Content-Type"),
254    );
255    headers.insert(
256        ACCESS_CONTROL_EXPOSE_HEADERS,
257        HeaderValue::from_static(
258            "Retry-After, X-Error-Code, X-Request-Id, X-Arete-Upstream-Attempted, X-Arete-Program-Release-Hash, X-Arete-Idl-Content-Hash, X-Arete-Account-Address, X-Arete-Account-Exists",
259        ),
260    );
261    headers.insert(ACCESS_CONTROL_MAX_AGE, HeaderValue::from_static("86400"));
262    response
263}
264
265async fn handle_request_inner(
266    remote_addr: SocketAddr,
267    req: Request<hyper::body::Incoming>,
268    state: HttpRequestState,
269) -> Result<Response<Full<Bytes>>, Infallible> {
270    let HttpRequestState {
271        health_monitor,
272        snapshot_runtime,
273        runtime_plan,
274        rpc_url,
275        rpc_client,
276        program_runtime_catalog,
277        auth_plugin,
278        limit_state,
279        transaction_state,
280        solana_gateway_target_id,
281        program_read_binding_target_id,
282    } = state;
283    let path = req.uri().path().to_string();
284
285    match path.as_str() {
286        "/health" | "/healthz" if runtime_plan.health => {
287            // Basic health check - server is running
288            Ok(Response::builder()
289                .status(StatusCode::OK)
290                .header("Content-Type", "text/plain")
291                .body(Full::new(Bytes::from("OK")))
292                .unwrap())
293        }
294        "/ready" | "/readiness" if runtime_plan.health => {
295            // Readiness check - stream must be healthy, and after a snapshot
296            // resume the parser must have caught back up to the slot tip
297            // before this pod should take traffic.
298            let stream_ready = match health_monitor.as_ref() {
299                Some(monitor) => monitor.is_healthy().await,
300                // No health monitor configured, assume ready
301                None => true,
302            };
303            let snapshot_ready = snapshot_runtime
304                .as_ref()
305                .is_none_or(crate::snapshot::SnapshotRuntime::resume_gate_ready);
306            if stream_ready && snapshot_ready {
307                Ok(Response::builder()
308                    .status(StatusCode::OK)
309                    .header("Content-Type", "text/plain")
310                    .body(Full::new(Bytes::from("READY")))
311                    .unwrap())
312            } else {
313                Ok(Response::builder()
314                    .status(StatusCode::SERVICE_UNAVAILABLE)
315                    .header("Content-Type", "text/plain")
316                    .body(Full::new(Bytes::from("NOT READY")))
317                    .unwrap())
318            }
319        }
320        "/status" if runtime_plan.health => {
321            // Detailed status endpoint
322            if let Some(monitor) = health_monitor.as_ref() {
323                let status = monitor.status().await;
324                let error_count = monitor.error_count().await;
325                let is_healthy = monitor.is_healthy().await;
326
327                let status_json = serde_json::json!({
328                    "healthy": is_healthy,
329                    "status": format!("{:?}", status),
330                    "error_count": error_count
331                });
332
333                let status_code = if is_healthy {
334                    StatusCode::OK
335                } else {
336                    StatusCode::SERVICE_UNAVAILABLE
337                };
338
339                Ok(Response::builder()
340                    .status(status_code)
341                    .header("Content-Type", "application/json")
342                    .body(Full::new(Bytes::from(status_json.to_string())))
343                    .unwrap())
344            } else {
345                let status_json = serde_json::json!({
346                    "healthy": true,
347                    "status": "no_monitor",
348                    "error_count": 0
349                });
350
351                Ok(Response::builder()
352                    .status(StatusCode::OK)
353                    .header("Content-Type", "application/json")
354                    .body(Full::new(Bytes::from(status_json.to_string())))
355                    .unwrap())
356            }
357        }
358        _ if runtime_plan.transactions && path.starts_with("/transactions/") => {
359            let Some(transaction_state) = transaction_state.as_ref() else {
360                return Ok(error_response(StatusCode::NOT_FOUND, "Not Found"));
361            };
362            let client_addr = transaction_state.client_addr(remote_addr, req.headers());
363            let auth_context = match authorize_http_request(
364                client_addr,
365                &req,
366                auth_plugin.as_ref().as_ref(),
367                &limit_state,
368                None,
369                false,
370                solana_gateway_target_id.as_deref(),
371            )
372            .await
373            {
374                Ok(context) => context,
375                Err(response) => return Ok(transaction_auth_error(response, path.as_str())),
376            };
377            Ok(
378                transactions::handle(client_addr, req, auth_context, transaction_state.clone())
379                    .await,
380            )
381        }
382        _ if runtime_plan.chain_reads && path.starts_with("/chain/") => {
383            let auth_context = match authorize_http_request(
384                remote_addr,
385                &req,
386                auth_plugin.as_ref().as_ref(),
387                &limit_state,
388                Some(SCOPE_READ),
389                true,
390                solana_gateway_target_id.as_deref(),
391            )
392            .await
393            {
394                Ok(context) => context,
395                Err(response) => return Ok(response),
396            };
397            Ok(handle_chain_request(req, path.as_str(), rpc_url, rpc_client, auth_context).await)
398        }
399        _ if path.starts_with("/v1/releases/") => {
400            if !runtime_plan.program_reads {
401                return Ok(program_read_error_response(
402                    ProgramReadError::ProgramReadsDisabled,
403                ));
404            }
405            let auth_context = match authorize_http_request(
406                remote_addr,
407                &req,
408                auth_plugin.as_ref().as_ref(),
409                &limit_state,
410                Some(SCOPE_READ),
411                true,
412                None,
413            )
414            .await
415            {
416                Ok(context) => context,
417                Err(response) => return Ok(response),
418            };
419            Ok(handle_program_account_request(
420                req,
421                path.as_str(),
422                rpc_url,
423                rpc_client,
424                program_runtime_catalog,
425                auth_context,
426                program_read_binding_target_id,
427            )
428            .await)
429        }
430        _ => Ok(Response::builder()
431            .status(StatusCode::NOT_FOUND)
432            .header("Content-Type", "text/plain")
433            .body(Full::new(Bytes::from("Not Found")))
434            .unwrap()),
435    }
436}
437
438fn transaction_auth_error(response: Response<Full<Bytes>>, path: &str) -> Response<Full<Bytes>> {
439    let status = response.status();
440    let code = response
441        .headers()
442        .get("X-Error-Code")
443        .and_then(|value| value.to_str().ok())
444        .unwrap_or("authentication_failed")
445        .to_string();
446    let request_id = uuid::Uuid::new_v4().to_string();
447    let mut value = json!({
448        "code": code,
449        "message": "Transaction request authentication failed",
450        "retryable": status == StatusCode::TOO_MANY_REQUESTS || status == StatusCode::UNAUTHORIZED,
451        "requestId": request_id,
452    });
453    if path == "/transactions/v1/send" {
454        value["submissionState"] = json!("not_submitted");
455    }
456    Response::builder()
457        .status(status)
458        .header("Content-Type", "application/json")
459        .header("X-Error-Code", code)
460        .header("X-Request-Id", request_id)
461        .header("X-Arete-Upstream-Attempted", "false")
462        .body(Full::new(Bytes::from(value.to_string())))
463        .expect("valid transaction authentication response")
464}
465
466#[derive(Debug, Deserialize)]
467struct AddressesBody {
468    addresses: Vec<String>,
469}
470
471#[derive(Debug, Deserialize)]
472struct BalanceBody {
473    owner: String,
474    mint: String,
475    #[serde(default, rename = "tokenProgram")]
476    token_program: Option<String>,
477    #[serde(default, rename = "minContextSlot")]
478    min_context_slot: Option<String>,
479}
480
481#[derive(Debug, Deserialize)]
482struct NativeBalanceBody {
483    address: String,
484    #[serde(default, rename = "minContextSlot")]
485    min_context_slot: Option<String>,
486}
487
488#[derive(Debug, Deserialize)]
489struct AccountsBody {
490    addresses: Vec<String>,
491}
492
493#[derive(Default)]
494struct HttpLimitState {
495    per_subject_per_minute: DashMap<String, (u64, u32)>,
496}
497
498fn resolve_rpc_url() -> Option<String> {
499    ["ARETE_READ_RPC_URL", "SOLANA_RPC_URL", "RPC_URL"]
500        .iter()
501        .find_map(|key| env::var(key).ok())
502        .filter(|value| !value.is_empty())
503}
504
505fn json_response(status: StatusCode, value: Value) -> Response<Full<Bytes>> {
506    Response::builder()
507        .status(status)
508        .header("Content-Type", "application/json")
509        .body(Full::new(Bytes::from(value.to_string())))
510        .unwrap()
511}
512
513fn error_response(status: StatusCode, message: impl Into<String>) -> Response<Full<Bytes>> {
514    json_response(status, json!({ "error": message.into() }))
515}
516
517fn parse_min_context_slot(value: Option<&str>) -> std::result::Result<Option<u64>, &'static str> {
518    value
519        .map(|slot| {
520            slot.parse::<u64>()
521                .map_err(|_| "minContextSlot must be a decimal u64 string")
522        })
523        .transpose()
524}
525
526fn auth_deny_response(deny: &AuthDeny) -> Response<Full<Bytes>> {
527    let mut builder = Response::builder()
528        .status(deny.http_status)
529        .header("Content-Type", "application/json")
530        .header("X-Error-Code", deny.code.as_str());
531
532    if let Some(reset_at) = deny.reset_at {
533        if let Ok(duration) = reset_at.duration_since(SystemTime::now()) {
534            builder = builder.header("Retry-After", duration.as_secs().to_string());
535        }
536    }
537
538    builder
539        .body(Full::new(Bytes::from(
540            json!({
541                "error": deny.reason,
542                "message": deny.reason,
543                "code": deny.code.as_str(),
544                "retryable": deny.code.should_retry(),
545                "fatal": !deny.code.should_retry() && !deny.code.should_refresh_token(),
546            })
547            .to_string(),
548        )))
549        .unwrap()
550}
551
552#[allow(clippy::result_large_err)]
553async fn authorize_http_request(
554    remote_addr: SocketAddr,
555    req: &Request<hyper::body::Incoming>,
556    auth_plugin: Option<&Arc<dyn WebSocketAuthPlugin>>,
557    limit_state: &HttpLimitState,
558    required_scope: Option<&str>,
559    enforce_read_limits: bool,
560    solana_gateway_target_id: Option<&str>,
561) -> std::result::Result<Option<crate::websocket::auth::AuthContext>, Response<Full<Bytes>>> {
562    let Some(plugin) = auth_plugin else {
563        return Ok(None);
564    };
565
566    let mut auth_request = ConnectionAuthRequest::from_http_request(remote_addr, req);
567    // HTTP reads are bearer-only. Do not allow query-param session tokens here.
568    auth_request.query = None;
569    let decision = plugin.authorize(&auth_request).await;
570    let context = match decision {
571        AuthDecision::Allow(context) => context,
572        AuthDecision::Deny(deny) => return Err(auth_deny_response(&deny)),
573    };
574
575    if let Some(target_id) = solana_gateway_target_id {
576        if let Err(error) =
577            arete_auth::SolanaGatewayAuthorization::validate_target(&context, target_id)
578        {
579            return Err(Response::builder()
580                .status(StatusCode::FORBIDDEN)
581                .header("Content-Type", "application/json")
582                .header("X-Error-Code", "invalid_gateway_target")
583                .body(Full::new(Bytes::from(
584                    json!({
585                        "error": "invalid_gateway_target",
586                        "message": error.to_string(),
587                        "code": "invalid_gateway_target",
588                        "retryable": false,
589                        "fatal": true
590                    })
591                    .to_string(),
592                )))
593                .expect("valid gateway target error response"));
594        }
595    }
596
597    if let Some(required_scope) = required_scope {
598        if !context.has_scope(required_scope) {
599            return Err(json_response(
600                StatusCode::FORBIDDEN,
601                json!({
602                    "error": "insufficient_scope",
603                    "message": format!("Required scope: {required_scope}"),
604                    "code": "insufficient_scope",
605                    "retryable": false,
606                    "fatal": true
607                }),
608            ));
609        }
610    }
611
612    if enforce_read_limits {
613        enforce_http_limits(&context, limit_state).map_err(|deny| auth_deny_response(&deny))?;
614    }
615    Ok(Some(context))
616}
617
618fn enforce_http_limits(
619    context: &crate::websocket::auth::AuthContext,
620    limit_state: &HttpLimitState,
621) -> std::result::Result<(), Box<AuthDeny>> {
622    let Some(limit) = context.limits.max_http_requests_per_minute else {
623        return Ok(());
624    };
625
626    let now_bucket = SystemTime::now()
627        .duration_since(UNIX_EPOCH)
628        .unwrap_or(Duration::from_secs(0))
629        .as_secs()
630        / 60;
631    let key = format!("{}:{}", context.subject, context.metering_key);
632    let mut entry = limit_state
633        .per_subject_per_minute
634        .entry(key)
635        .or_insert((now_bucket, 0));
636    if entry.0 != now_bucket {
637        *entry = (now_bucket, 0);
638    }
639    if entry.1 >= limit {
640        return Err(Box::new(AuthDeny::rate_limited(
641            Duration::from_secs(60),
642            "http reads",
643        )));
644    }
645    entry.1 += 1;
646    Ok(())
647}
648
649#[allow(clippy::result_large_err)]
650async fn read_json_body<T: for<'de> Deserialize<'de>>(
651    req: Request<hyper::body::Incoming>,
652) -> std::result::Result<T, Response<Full<Bytes>>> {
653    let collected = req
654        .into_body()
655        .collect()
656        .await
657        .map_err(|err| error_response(StatusCode::BAD_REQUEST, err.to_string()))?;
658    serde_json::from_slice::<T>(&collected.to_bytes())
659        .map_err(|err| error_response(StatusCode::BAD_REQUEST, err.to_string()))
660}
661
662async fn handle_chain_request(
663    req: Request<hyper::body::Incoming>,
664    path: &str,
665    rpc_url: Arc<Option<String>>,
666    rpc_client: Client,
667    auth_context: Option<crate::websocket::auth::AuthContext>,
668) -> Response<Full<Bytes>> {
669    let Some(rpc_url) = rpc_url.as_ref() else {
670        return error_response(
671            StatusCode::SERVICE_UNAVAILABLE,
672            "No RPC URL configured for chain reads",
673        );
674    };
675
676    match (req.method().as_str(), path) {
677        ("GET", path) if path.starts_with("/chain/exists/") => {
678            let address = path.trim_start_matches("/chain/exists/");
679            match rpc_get_account_info(&rpc_client, rpc_url, address).await {
680                Ok(value) => json_response(StatusCode::OK, json!({ "exists": !value.is_null() })),
681                Err(err) => error_response(StatusCode::BAD_GATEWAY, err.to_string()),
682            }
683        }
684        ("GET", path) if path.starts_with("/chain/lamports/") => {
685            let address = path.trim_start_matches("/chain/lamports/");
686            match rpc_call(
687                &rpc_client,
688                rpc_url,
689                "getBalance",
690                json!([address, { "commitment": "confirmed" }]),
691            )
692            .await
693            {
694                Ok(value) => json_response(
695                    StatusCode::OK,
696                    json!({ "lamports": value.pointer("/value").and_then(Value::as_u64).unwrap_or(0) }),
697                ),
698                Err(err) => error_response(StatusCode::BAD_GATEWAY, err.to_string()),
699            }
700        }
701        ("POST", "/chain/native-balance") => match read_json_body::<NativeBalanceBody>(req).await {
702            Ok(body) => {
703                let min_context_slot =
704                    match parse_min_context_slot(body.min_context_slot.as_deref()) {
705                        Ok(slot) => slot,
706                        Err(message) => return error_response(StatusCode::BAD_REQUEST, message),
707                    };
708                match rpc_get_native_balance(&rpc_client, rpc_url, &body.address, min_context_slot)
709                    .await
710                {
711                    Ok(balance) => json_response(StatusCode::OK, balance),
712                    Err(err) => error_response(StatusCode::BAD_GATEWAY, err.to_string()),
713                }
714            }
715            Err(response) => response,
716        },
717        ("GET", path) if path.starts_with("/chain/rent-exemption/") => {
718            let raw_space = path.trim_start_matches("/chain/rent-exemption/");
719            let Ok(space) = raw_space.parse::<u64>() else {
720                return error_response(
721                    StatusCode::BAD_REQUEST,
722                    "rent-exemption space must be an integer",
723                );
724            };
725            match rpc_call(
726                &rpc_client,
727                rpc_url,
728                "getMinimumBalanceForRentExemption",
729                json!([space, { "commitment": "confirmed" }]),
730            )
731            .await
732            {
733                Ok(value) => json_response(
734                    StatusCode::OK,
735                    json!({ "lamports": value.as_u64().unwrap_or(0) }),
736                ),
737                Err(err) => error_response(StatusCode::BAD_GATEWAY, err.to_string()),
738            }
739        }
740        ("GET", "/chain/clock") => {
741            let slot = rpc_call(
742                &rpc_client,
743                rpc_url,
744                "getSlot",
745                json!([{ "commitment": "confirmed" }]),
746            )
747            .await;
748            let epoch_info = rpc_call(
749                &rpc_client,
750                rpc_url,
751                "getEpochInfo",
752                json!([{ "commitment": "confirmed" }]),
753            )
754            .await;
755            match (slot, epoch_info) {
756                (Ok(slot_value), Ok(epoch_value)) => {
757                    let slot_num = slot_value.as_u64().unwrap_or(0);
758                    let unix_timestamp =
759                        rpc_call(&rpc_client, rpc_url, "getBlockTime", json!([slot_num]))
760                            .await
761                            .ok()
762                            .and_then(|value| value.as_i64())
763                            .unwrap_or_default();
764                    json_response(
765                        StatusCode::OK,
766                        json!({
767                            "slot": slot_num,
768                            "epoch": epoch_value.get("epoch").and_then(Value::as_u64),
769                            "leaderScheduleEpoch": epoch_value.get("leaderScheduleSlotOffset").and_then(Value::as_u64),
770                            "unixTimestamp": unix_timestamp,
771                        }),
772                    )
773                }
774                (Err(err), _) | (_, Err(err)) => {
775                    error_response(StatusCode::BAD_GATEWAY, err.to_string())
776                }
777            }
778        }
779        ("GET", path) if path.starts_with("/chain/accounts/") => {
780            let address = path.trim_start_matches("/chain/accounts/");
781            match rpc_get_account_info(&rpc_client, rpc_url, address).await {
782                Ok(value) if value.is_null() => json_response(StatusCode::OK, Value::Null),
783                Ok(value) => json_response(StatusCode::OK, raw_account_json(address, &value)),
784                Err(err) => error_response(StatusCode::BAD_GATEWAY, err.to_string()),
785            }
786        }
787        ("GET", path) if path.starts_with("/chain/mints/") => {
788            let address = path.trim_start_matches("/chain/mints/");
789            match rpc_get_parsed_account_info(&rpc_client, rpc_url, address).await {
790                Ok(Some(value)) => json_response(StatusCode::OK, mint_info_json(address, &value)),
791                Ok(None) => json_response(StatusCode::OK, Value::Null),
792                Err(err) => error_response(StatusCode::BAD_GATEWAY, err.to_string()),
793            }
794        }
795        ("GET", path) if path.starts_with("/chain/token-accounts/") => {
796            let address = path.trim_start_matches("/chain/token-accounts/");
797            match rpc_get_parsed_account_info(&rpc_client, rpc_url, address).await {
798                Ok(Some(value)) => {
799                    json_response(StatusCode::OK, token_account_json(address, &value))
800                }
801                Ok(None) => json_response(StatusCode::OK, Value::Null),
802                Err(err) => error_response(StatusCode::BAD_GATEWAY, err.to_string()),
803            }
804        }
805        ("POST", "/chain/accounts") => match read_json_body::<AccountsBody>(req).await {
806            Ok(body) => {
807                let configured_limit =
808                    batch_address_limit(auth_context.as_ref(), MAX_CHAIN_BATCH_ADDRESSES);
809                if body.addresses.len() > configured_limit {
810                    return error_response(
811                        StatusCode::BAD_REQUEST,
812                        format!(
813                            "addresses exceeds the {configured_limit}-address limit for one batch"
814                        ),
815                    );
816                }
817                if body.addresses.is_empty() {
818                    return json_response(StatusCode::OK, json!({ "items": [] }));
819                }
820                match rpc_get_multiple_accounts(&rpc_client, rpc_url, &body.addresses).await {
821                    // A short array would silently misalign items with addresses, so treat a
822                    // length mismatch as an upstream fault rather than padding it.
823                    Ok(values) if values.len() == body.addresses.len() => json_response(
824                        StatusCode::OK,
825                        json!({ "items": batch_accounts_json(&body.addresses, &values) }),
826                    ),
827                    Ok(values) => error_response(
828                        StatusCode::BAD_GATEWAY,
829                        format!(
830                            "getMultipleAccounts returned {} entries for {} addresses",
831                            values.len(),
832                            body.addresses.len()
833                        ),
834                    ),
835                    Err(err) => error_response(StatusCode::BAD_GATEWAY, err.to_string()),
836                }
837            }
838            Err(response) => response,
839        },
840        ("POST", "/chain/balances") => match read_json_body::<BalanceBody>(req).await {
841            Ok(body) => {
842                let min_context_slot =
843                    match parse_min_context_slot(body.min_context_slot.as_deref()) {
844                        Ok(slot) => slot,
845                        Err(message) => return error_response(StatusCode::BAD_REQUEST, message),
846                    };
847                match rpc_get_token_balance(
848                    &rpc_client,
849                    rpc_url,
850                    &body.owner,
851                    &body.mint,
852                    body.token_program.as_deref(),
853                    min_context_slot,
854                )
855                .await
856                {
857                    Ok(balance) => json_response(StatusCode::OK, balance),
858                    Err(err) => error_response(StatusCode::BAD_GATEWAY, err.to_string()),
859                }
860            }
861            Err(response) => response,
862        },
863        _ => error_response(StatusCode::NOT_FOUND, "Not Found"),
864    }
865}
866
867async fn handle_program_account_request(
868    req: Request<hyper::body::Incoming>,
869    path: &str,
870    rpc_url: Arc<Option<String>>,
871    rpc_client: Client,
872    program_runtime_catalog: Arc<ProgramRuntimeCatalog>,
873    auth_context: Option<crate::websocket::auth::AuthContext>,
874    program_read_binding_target_id: Arc<Option<String>>,
875) -> Response<Full<Bytes>> {
876    let route = match ProgramReadRoute::parse(req.method(), path) {
877        Ok(route) => route,
878        Err(error) => return program_read_error_response(error),
879    };
880    let release_hash = match route.release_hash.parse() {
881        Ok(release_hash) => release_hash,
882        Err(_) => return program_read_error_response(ProgramReadError::InvalidReleaseHash),
883    };
884    let Some(definition) = program_runtime_catalog.get(&release_hash).cloned() else {
885        return program_read_error_response(ProgramReadError::ReleaseNotFound);
886    };
887    if let Err(error) = authorize_program_read(
888        auth_context.as_ref(),
889        program_read_binding_target_id.as_deref(),
890        &definition,
891    ) {
892        return with_release_metadata(program_read_error_response(error), &definition, None, None);
893    }
894    let Some(rpc_url) = rpc_url.as_ref() else {
895        return with_release_metadata(
896            program_read_error_response(ProgramReadError::RpcNotConfigured),
897            &definition,
898            None,
899            None,
900        );
901    };
902
903    match route.operation {
904        ProgramReadOperation::Fetch { address } => {
905            let account_value = match rpc_get_account_info(&rpc_client, rpc_url, &address).await {
906                Ok(value) => value,
907                Err(error) => {
908                    error!("Program account RPC read failed: {}", error);
909                    return with_release_metadata(
910                        program_read_error_response(ProgramReadError::RpcFailed),
911                        &definition,
912                        Some(&address),
913                        None,
914                    );
915                }
916            };
917            match decode_release_account(&definition, &route.account, &account_value).await {
918                AccountReadOutcome::Missing => with_release_metadata(
919                    json_response(StatusCode::OK, Value::Null),
920                    &definition,
921                    Some(&address),
922                    Some(false),
923                ),
924                AccountReadOutcome::Value(value) => with_release_metadata(
925                    json_response(StatusCode::OK, value),
926                    &definition,
927                    Some(&address),
928                    Some(true),
929                ),
930                AccountReadOutcome::Error(error) => with_release_metadata(
931                    program_read_error_response(error),
932                    &definition,
933                    Some(&address),
934                    Some(true),
935                ),
936            }
937        }
938        ProgramReadOperation::Exists { address } => {
939            let account_value = match rpc_get_account_info(&rpc_client, rpc_url, &address).await {
940                Ok(value) => value,
941                Err(error) => {
942                    error!("Program account existence RPC read failed: {}", error);
943                    return with_release_metadata(
944                        program_read_error_response(ProgramReadError::RpcFailed),
945                        &definition,
946                        Some(&address),
947                        None,
948                    );
949                }
950            };
951            let exists = !account_value.is_null();
952            if exists && !account_owner_matches(&definition, &account_value) {
953                return with_release_metadata(
954                    program_read_error_response(ProgramReadError::AccountOwnerMismatch),
955                    &definition,
956                    Some(&address),
957                    Some(true),
958                );
959            }
960            with_release_metadata(
961                json_response(StatusCode::OK, json!({ "exists": exists })),
962                &definition,
963                Some(&address),
964                Some(exists),
965            )
966        }
967        ProgramReadOperation::Batch => {
968            let body = match read_json_body::<AddressesBody>(req).await {
969                Ok(body) => body,
970                Err(_) => return program_read_error_response(ProgramReadError::InvalidRequest),
971            };
972            let configured_limit =
973                batch_address_limit(auth_context.as_ref(), MAX_PROGRAM_BATCH_ADDRESSES);
974            if body.addresses.len() > configured_limit {
975                return with_release_metadata(
976                    program_read_error_response(ProgramReadError::BatchLimitExceeded),
977                    &definition,
978                    None,
979                    None,
980                );
981            }
982            if body.addresses.is_empty() {
983                return with_release_metadata(
984                    json_response(StatusCode::OK, json!({ "items": [] })),
985                    &definition,
986                    None,
987                    None,
988                );
989            }
990
991            let values =
992                match rpc_get_multiple_accounts(&rpc_client, rpc_url, &body.addresses).await {
993                    Ok(values) if values.len() == body.addresses.len() => values,
994                    Ok(_) => {
995                        return with_release_metadata(
996                            program_read_error_response(ProgramReadError::RpcResponseInvalid),
997                            &definition,
998                            None,
999                            None,
1000                        )
1001                    }
1002                    Err(error) => {
1003                        error!("Program account batch RPC read failed: {}", error);
1004                        return with_release_metadata(
1005                            program_read_error_response(ProgramReadError::RpcFailed),
1006                            &definition,
1007                            None,
1008                            None,
1009                        );
1010                    }
1011                };
1012
1013            let outcomes = futures_util::future::join_all(
1014                values
1015                    .iter()
1016                    .map(|value| decode_release_account(&definition, &route.account, value)),
1017            )
1018            .await;
1019            let mut items = Vec::with_capacity(outcomes.len());
1020            for (address, outcome) in body.addresses.iter().zip(outcomes) {
1021                let item = match outcome {
1022                    AccountReadOutcome::Missing => {
1023                        json!({ "address": address, "status": "missing" })
1024                    }
1025                    AccountReadOutcome::Value(value) => {
1026                        json!({ "address": address, "status": "ok", "value": value })
1027                    }
1028                    AccountReadOutcome::Error(error) => json!({
1029                        "address": address,
1030                        "status": "error",
1031                        "error": { "code": error.code() }
1032                    }),
1033                };
1034                items.push(item);
1035            }
1036            with_release_metadata(
1037                json_response(StatusCode::OK, json!({ "items": items })),
1038                &definition,
1039                None,
1040                None,
1041            )
1042        }
1043    }
1044}
1045
1046const MAX_PROGRAM_BATCH_ADDRESSES: usize = 100;
1047const MAX_PROGRAM_ACCOUNT_BYTES: usize = 10 * 1024 * 1024;
1048const PROGRAM_DECODE_TIMEOUT: Duration = Duration::from_secs(2);
1049
1050fn authorize_program_read(
1051    auth_context: Option<&crate::websocket::auth::AuthContext>,
1052    expected_target_id: Option<&str>,
1053    definition: &ProgramRuntimeDefinition,
1054) -> std::result::Result<(), ProgramReadError> {
1055    let Some(context) = auth_context else {
1056        return Ok(());
1057    };
1058    let Some(expected_target_id) = expected_target_id.filter(|target_id| !target_id.is_empty())
1059    else {
1060        return Err(ProgramReadError::AuthorizationNotConfigured);
1061    };
1062
1063    arete_auth::ProgramReadAuthorization::try_from_context(
1064        context,
1065        expected_target_id,
1066        &definition.program_id,
1067        &definition.program_release_hash.to_string(),
1068    )
1069    .map(|_| ())
1070    .map_err(|_| ProgramReadError::Unauthorized)
1071}
1072
1073#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1074enum ProgramReadError {
1075    NotFound,
1076    ProgramReadsDisabled,
1077    InvalidReleaseHash,
1078    ReleaseNotFound,
1079    InvalidRequest,
1080    Unauthorized,
1081    AuthorizationNotConfigured,
1082    BatchLimitExceeded,
1083    RpcNotConfigured,
1084    RpcFailed,
1085    RpcResponseInvalid,
1086    AccountOwnerMismatch,
1087    AccountDataInvalid,
1088    AccountDataTooLarge,
1089    AccountDecodeFailed,
1090    AccountDecodeTimeout,
1091}
1092
1093impl ProgramReadError {
1094    fn code(self) -> &'static str {
1095        match self {
1096            Self::NotFound => "NOT_FOUND",
1097            Self::ProgramReadsDisabled => "PROGRAM_READS_DISABLED",
1098            Self::InvalidReleaseHash => "INVALID_PROGRAM_RELEASE_HASH",
1099            Self::ReleaseNotFound => "PROGRAM_RELEASE_NOT_FOUND",
1100            Self::InvalidRequest => "INVALID_REQUEST",
1101            Self::Unauthorized => "PROGRAM_READ_UNAUTHORIZED",
1102            Self::AuthorizationNotConfigured => "PROGRAM_READ_AUTH_NOT_CONFIGURED",
1103            Self::BatchLimitExceeded => "BATCH_LIMIT_EXCEEDED",
1104            Self::RpcNotConfigured => "READ_RPC_NOT_CONFIGURED",
1105            Self::RpcFailed => "RPC_REQUEST_FAILED",
1106            Self::RpcResponseInvalid => "RPC_RESPONSE_INVALID",
1107            Self::AccountOwnerMismatch => "ACCOUNT_OWNER_MISMATCH",
1108            Self::AccountDataInvalid => "ACCOUNT_DATA_INVALID",
1109            Self::AccountDataTooLarge => "ACCOUNT_DATA_TOO_LARGE",
1110            Self::AccountDecodeFailed => "ACCOUNT_DECODE_FAILED",
1111            Self::AccountDecodeTimeout => "ACCOUNT_DECODE_TIMEOUT",
1112        }
1113    }
1114
1115    fn status(self) -> StatusCode {
1116        match self {
1117            Self::NotFound | Self::ReleaseNotFound => StatusCode::NOT_FOUND,
1118            Self::InvalidReleaseHash | Self::InvalidRequest => StatusCode::BAD_REQUEST,
1119            Self::Unauthorized => StatusCode::FORBIDDEN,
1120            Self::AuthorizationNotConfigured => StatusCode::SERVICE_UNAVAILABLE,
1121            Self::BatchLimitExceeded | Self::AccountDataTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
1122            Self::ProgramReadsDisabled | Self::RpcNotConfigured => StatusCode::SERVICE_UNAVAILABLE,
1123            Self::RpcFailed | Self::RpcResponseInvalid | Self::AccountDataInvalid => {
1124                StatusCode::BAD_GATEWAY
1125            }
1126            Self::AccountOwnerMismatch | Self::AccountDecodeFailed => {
1127                StatusCode::UNPROCESSABLE_ENTITY
1128            }
1129            Self::AccountDecodeTimeout => StatusCode::GATEWAY_TIMEOUT,
1130        }
1131    }
1132}
1133
1134fn program_read_error_response(error: ProgramReadError) -> Response<Full<Bytes>> {
1135    Response::builder()
1136        .status(error.status())
1137        .header("Content-Type", "application/json")
1138        .header("X-Error-Code", error.code())
1139        .body(Full::new(Bytes::from(
1140            json!({ "error": { "code": error.code() } }).to_string(),
1141        )))
1142        .expect("valid program read error response")
1143}
1144
1145#[derive(Debug)]
1146struct ProgramReadRoute {
1147    release_hash: String,
1148    account: String,
1149    operation: ProgramReadOperation,
1150}
1151
1152#[derive(Debug)]
1153enum ProgramReadOperation {
1154    Fetch { address: String },
1155    Batch,
1156    Exists { address: String },
1157}
1158
1159impl ProgramReadRoute {
1160    fn parse(method: &Method, path: &str) -> std::result::Result<Self, ProgramReadError> {
1161        let segments: Vec<&str> = path.trim_start_matches('/').split('/').collect();
1162        if segments.len() < 5
1163            || segments[0] != "v1"
1164            || segments[1] != "releases"
1165            || segments[2].is_empty()
1166            || segments[3] != "accounts"
1167            || segments[4].is_empty()
1168        {
1169            return Err(ProgramReadError::NotFound);
1170        }
1171        let operation = match (method, segments.as_slice()) {
1172            (&Method::POST, [_, _, _, _, _]) => ProgramReadOperation::Batch,
1173            (&Method::GET, [_, _, _, _, _, address]) if !address.is_empty() => {
1174                ProgramReadOperation::Fetch {
1175                    address: (*address).to_string(),
1176                }
1177            }
1178            (&Method::GET, [_, _, _, _, _, address, "exists"]) if !address.is_empty() => {
1179                ProgramReadOperation::Exists {
1180                    address: (*address).to_string(),
1181                }
1182            }
1183            _ => return Err(ProgramReadError::NotFound),
1184        };
1185        Ok(Self {
1186            release_hash: segments[2].to_string(),
1187            account: segments[4].to_string(),
1188            operation,
1189        })
1190    }
1191}
1192
1193enum AccountReadOutcome {
1194    Missing,
1195    Value(Value),
1196    Error(ProgramReadError),
1197}
1198
1199fn account_owner_matches(definition: &ProgramRuntimeDefinition, value: &Value) -> bool {
1200    value.get("owner").and_then(Value::as_str) == Some(definition.program_id.as_str())
1201}
1202
1203async fn decode_release_account(
1204    definition: &ProgramRuntimeDefinition,
1205    account: &str,
1206    value: &Value,
1207) -> AccountReadOutcome {
1208    if value.is_null() {
1209        return AccountReadOutcome::Missing;
1210    }
1211    if !account_owner_matches(definition, value) {
1212        return AccountReadOutcome::Error(ProgramReadError::AccountOwnerMismatch);
1213    }
1214    let data = match decode_account_bytes(value) {
1215        Some(data) => data,
1216        None => return AccountReadOutcome::Error(ProgramReadError::AccountDataInvalid),
1217    };
1218    if data.len() > MAX_PROGRAM_ACCOUNT_BYTES {
1219        return AccountReadOutcome::Error(ProgramReadError::AccountDataTooLarge);
1220    }
1221
1222    let reader = definition.account_reader.clone();
1223    let account = account.to_string();
1224    let decode = tokio::task::spawn_blocking(move || reader(&account, &data));
1225    match tokio::time::timeout(PROGRAM_DECODE_TIMEOUT, decode).await {
1226        Ok(Ok(Ok(value))) => AccountReadOutcome::Value(value),
1227        Ok(Ok(Err(_))) | Ok(Err(_)) => {
1228            AccountReadOutcome::Error(ProgramReadError::AccountDecodeFailed)
1229        }
1230        Err(_) => AccountReadOutcome::Error(ProgramReadError::AccountDecodeTimeout),
1231    }
1232}
1233
1234fn with_release_metadata(
1235    mut response: Response<Full<Bytes>>,
1236    definition: &ProgramRuntimeDefinition,
1237    address: Option<&str>,
1238    exists: Option<bool>,
1239) -> Response<Full<Bytes>> {
1240    let headers = response.headers_mut();
1241    if let Ok(value) = HeaderValue::from_str(&definition.program_release_hash.to_string()) {
1242        headers.insert("X-Arete-Program-Release-Hash", value);
1243    }
1244    if let Ok(value) = HeaderValue::from_str(&definition.idl_content_hash.to_string()) {
1245        headers.insert("X-Arete-Idl-Content-Hash", value);
1246    }
1247    if let Some(address) = address {
1248        if let Ok(value) = HeaderValue::from_str(address) {
1249            headers.insert("X-Arete-Account-Address", value);
1250        }
1251    }
1252    if let Some(exists) = exists {
1253        headers.insert(
1254            "X-Arete-Account-Exists",
1255            HeaderValue::from_static(if exists { "true" } else { "false" }),
1256        );
1257    }
1258    response
1259}
1260
1261async fn rpc_call(
1262    client: &Client,
1263    rpc_url: &str,
1264    method: &str,
1265    params: Value,
1266) -> anyhow::Result<Value> {
1267    let response = client
1268        .post(rpc_url)
1269        .json(&json!({
1270            "jsonrpc": "2.0",
1271            "id": "arete-read",
1272            "method": method,
1273            "params": params,
1274        }))
1275        .send()
1276        .await?
1277        .error_for_status()?;
1278    let value = response.json::<Value>().await?;
1279    if let Some(error) = value.get("error") {
1280        return Err(anyhow::anyhow!(error.to_string()));
1281    }
1282    Ok(value.get("result").cloned().unwrap_or(Value::Null))
1283}
1284
1285fn rpc_read_config(encoding: Option<&str>, min_context_slot: Option<u64>) -> Value {
1286    let mut config = json!({ "commitment": "confirmed" });
1287    let object = config
1288        .as_object_mut()
1289        .expect("RPC read config is always an object");
1290    if let Some(encoding) = encoding {
1291        object.insert("encoding".to_string(), json!(encoding));
1292    }
1293    if let Some(min_context_slot) = min_context_slot {
1294        object.insert("minContextSlot".to_string(), json!(min_context_slot));
1295    }
1296    config
1297}
1298
1299async fn rpc_get_native_balance(
1300    client: &Client,
1301    rpc_url: &str,
1302    address: &str,
1303    min_context_slot: Option<u64>,
1304) -> anyhow::Result<Value> {
1305    let result = rpc_call(
1306        client,
1307        rpc_url,
1308        "getBalance",
1309        json!([address, rpc_read_config(None, min_context_slot)]),
1310    )
1311    .await?;
1312    contextual_native_balance_json(&result)
1313}
1314
1315fn contextual_native_balance_json(result: &Value) -> anyhow::Result<Value> {
1316    let lamports = result
1317        .get("value")
1318        .and_then(Value::as_u64)
1319        .ok_or_else(|| anyhow::anyhow!("getBalance response is missing a u64 value"))?;
1320    let context_slot = result
1321        .pointer("/context/slot")
1322        .and_then(Value::as_u64)
1323        .ok_or_else(|| anyhow::anyhow!("getBalance response is missing a u64 context slot"))?;
1324    Ok(json!({
1325        "lamports": lamports.to_string(),
1326        "contextSlot": context_slot.to_string(),
1327    }))
1328}
1329
1330async fn rpc_get_account_info(
1331    client: &Client,
1332    rpc_url: &str,
1333    address: &str,
1334) -> anyhow::Result<Value> {
1335    let result = rpc_call(
1336        client,
1337        rpc_url,
1338        "getAccountInfo",
1339        json!([address, { "encoding": "base64", "commitment": "confirmed" }]),
1340    )
1341    .await?;
1342    Ok(result.get("value").cloned().unwrap_or(Value::Null))
1343}
1344
1345async fn rpc_get_multiple_accounts(
1346    client: &Client,
1347    rpc_url: &str,
1348    addresses: &[String],
1349) -> anyhow::Result<Vec<Value>> {
1350    let result = rpc_call(
1351        client,
1352        rpc_url,
1353        "getMultipleAccounts",
1354        json!([addresses, { "encoding": "base64", "commitment": "confirmed" }]),
1355    )
1356    .await?;
1357    Ok(result
1358        .get("value")
1359        .and_then(Value::as_array)
1360        .cloned()
1361        .unwrap_or_default())
1362}
1363
1364async fn rpc_get_parsed_account_info(
1365    client: &Client,
1366    rpc_url: &str,
1367    address: &str,
1368) -> anyhow::Result<Option<Value>> {
1369    let result = rpc_call(
1370        client,
1371        rpc_url,
1372        "getAccountInfo",
1373        json!([address, { "encoding": "jsonParsed", "commitment": "confirmed" }]),
1374    )
1375    .await?;
1376    Ok(result
1377        .get("value")
1378        .cloned()
1379        .filter(|value| !value.is_null()))
1380}
1381
1382async fn rpc_get_token_balance(
1383    client: &Client,
1384    rpc_url: &str,
1385    owner: &str,
1386    mint: &str,
1387    token_program: Option<&str>,
1388    min_context_slot: Option<u64>,
1389) -> anyhow::Result<Value> {
1390    let filter = token_program
1391        .map(|program_id| json!({ "programId": program_id }))
1392        .unwrap_or_else(|| json!({ "mint": mint }));
1393    let result = rpc_call(
1394        client,
1395        rpc_url,
1396        "getTokenAccountsByOwner",
1397        json!([
1398            owner,
1399            filter,
1400            rpc_read_config(Some("jsonParsed"), min_context_slot)
1401        ]),
1402    )
1403    .await?;
1404
1405    contextual_token_balance_json(&result, owner, mint, token_program)
1406}
1407
1408fn contextual_token_balance_json(
1409    result: &Value,
1410    owner: &str,
1411    mint: &str,
1412    token_program: Option<&str>,
1413) -> anyhow::Result<Value> {
1414    let context_slot = result
1415        .pointer("/context/slot")
1416        .and_then(Value::as_u64)
1417        .ok_or_else(|| {
1418            anyhow::anyhow!("getTokenAccountsByOwner response is missing a u64 context slot")
1419        })?;
1420
1421    let account = result
1422        .get("value")
1423        .and_then(Value::as_array)
1424        .and_then(|items| {
1425            items.iter().find(|item| {
1426                item.pointer("/account/data/parsed/info/mint")
1427                    .and_then(Value::as_str)
1428                    == Some(mint)
1429            })
1430        })
1431        .cloned();
1432
1433    if let Some(account) = account {
1434        let pubkey = account.get("pubkey").and_then(Value::as_str);
1435        let info = account.pointer("/account/data/parsed/info");
1436        return Ok(json!({
1437            "exists": true,
1438            "address": pubkey,
1439            "owner": owner,
1440            "mint": mint,
1441            "tokenProgram": token_program,
1442            "amount": info.and_then(|value| value.pointer("/tokenAmount/amount")).and_then(Value::as_str).unwrap_or("0"),
1443            "decimals": info.and_then(|value| value.pointer("/tokenAmount/decimals")).and_then(Value::as_u64),
1444            "uiAmountString": info.and_then(|value| value.pointer("/tokenAmount/uiAmountString")).and_then(Value::as_str),
1445            "contextSlot": context_slot.to_string(),
1446        }));
1447    }
1448
1449    Ok(json!({
1450        "exists": false,
1451        "address": Value::Null,
1452        "owner": owner,
1453        "mint": mint,
1454        "tokenProgram": token_program,
1455        "amount": "0",
1456        "decimals": Value::Null,
1457        "uiAmountString": Value::Null,
1458        "contextSlot": context_slot.to_string(),
1459    }))
1460}
1461
1462fn decode_account_bytes(value: &Value) -> Option<Vec<u8>> {
1463    let data = value.get("data")?.as_array()?;
1464    let encoded = data.first()?.as_str()?;
1465    BASE64_STANDARD.decode(encoded).ok()
1466}
1467
1468/// `lamports` is a decimal string, not a JSON number.
1469///
1470/// A `u64` above 2^53 does not survive a JSON number in every client: JavaScript rounds
1471/// `9007199254740993` to `...92` during `JSON.parse`, before any SDK can intervene. The
1472/// native-balance route already answers with a string for this reason, and the batch route makes it
1473/// matter — a custody sweep reading many accounts at once is exactly where a silently rounded
1474/// balance would be acted on.
1475fn raw_account_json(address: &str, value: &Value) -> Value {
1476    json!({
1477        "address": address,
1478        "ownerProgram": value.get("owner").and_then(Value::as_str).unwrap_or_default(),
1479        "lamports": value
1480            .get("lamports")
1481            .and_then(Value::as_u64)
1482            .unwrap_or(0)
1483            .to_string(),
1484        "executable": value.get("executable").and_then(Value::as_bool).unwrap_or(false),
1485        "data": value
1486            .pointer("/data/0")
1487            .and_then(Value::as_str)
1488            .unwrap_or_default(),
1489    })
1490}
1491
1492/// How many addresses one batch may carry: the session's own ceiling, bounded by the protocol's.
1493///
1494/// Shared by every batch route rather than repeated at each one. Rate limiting charges a batch as a
1495/// single HTTP request, so a route that consulted only the protocol limit would let a session
1496/// capped at ten addresses read a hundred for the same cost. That is exactly what
1497/// `POST /chain/accounts` did while the program-account batch computed it correctly, so the two
1498/// call sites now derive it from one place and cannot drift apart again.
1499fn batch_address_limit(
1500    auth_context: Option<&crate::websocket::auth::AuthContext>,
1501    protocol_max: usize,
1502) -> usize {
1503    auth_context
1504        .and_then(|ctx| ctx.limits.max_http_batch_addresses)
1505        .map(|limit| limit as usize)
1506        .unwrap_or(protocol_max)
1507        .min(protocol_max)
1508}
1509
1510/// Solana's own `getMultipleAccounts` ceiling, so one batch is one upstream call.
1511const MAX_CHAIN_BATCH_ADDRESSES: usize = 100;
1512
1513/// Positionally aligned with `addresses`; `null` where the account is absent, matching
1514/// both `getMultipleAccounts` and the single-address route.
1515fn batch_accounts_json(addresses: &[String], values: &[Value]) -> Vec<Value> {
1516    addresses
1517        .iter()
1518        .zip(values)
1519        .map(|(address, value)| {
1520            if value.is_null() {
1521                Value::Null
1522            } else {
1523                raw_account_json(address, value)
1524            }
1525        })
1526        .collect()
1527}
1528
1529fn mint_info_json(address: &str, value: &Value) -> Value {
1530    let owner_program = value
1531        .get("owner")
1532        .and_then(Value::as_str)
1533        .unwrap_or_default();
1534    let info = value.pointer("/data/parsed/info");
1535    json!({
1536        "address": address,
1537        "ownerProgram": owner_program,
1538        "decimals": info.and_then(|v| v.get("decimals")).and_then(Value::as_u64),
1539        "supply": info.and_then(|v| v.get("supply")).and_then(Value::as_str),
1540        "mintAuthority": info.and_then(|v| v.get("mintAuthority")).and_then(Value::as_str),
1541        "freezeAuthority": info.and_then(|v| v.get("freezeAuthority")).and_then(Value::as_str),
1542    })
1543}
1544
1545fn token_account_json(address: &str, value: &Value) -> Value {
1546    let owner_program = value
1547        .get("owner")
1548        .and_then(Value::as_str)
1549        .unwrap_or_default();
1550    let info = value.pointer("/data/parsed/info");
1551    json!({
1552        "address": address,
1553        "ownerProgram": owner_program,
1554        "mint": info.and_then(|v| v.get("mint")).and_then(Value::as_str),
1555        "owner": info.and_then(|v| v.get("owner")).and_then(Value::as_str),
1556        "amount": info.and_then(|v| v.pointer("/tokenAmount/amount")).and_then(Value::as_str),
1557        "uiAmountString": info.and_then(|v| v.pointer("/tokenAmount/uiAmountString")).and_then(Value::as_str),
1558    })
1559}
1560
1561#[cfg(test)]
1562mod tests {
1563    use super::*;
1564
1565    #[test]
1566    fn cors_headers_allow_browser_sdk_reads() {
1567        let response = with_cors(
1568            Response::builder()
1569                .status(StatusCode::OK)
1570                .body(Full::new(Bytes::new()))
1571                .unwrap(),
1572        );
1573
1574        assert_eq!(response.headers()[ACCESS_CONTROL_ALLOW_ORIGIN], "*");
1575        assert_eq!(
1576            response.headers()[ACCESS_CONTROL_ALLOW_METHODS],
1577            "GET, POST, OPTIONS"
1578        );
1579        assert_eq!(
1580            response.headers()[ACCESS_CONTROL_ALLOW_HEADERS],
1581            "Authorization, Content-Type"
1582        );
1583    }
1584
1585    #[test]
1586    fn native_balance_serializes_u64_values_as_decimal_strings() {
1587        let value = contextual_native_balance_json(&json!({
1588            "context": { "slot": 9_007_199_254_740_995_u64 },
1589            "value": 9_007_199_254_740_993_u64,
1590        }))
1591        .unwrap();
1592
1593        assert_eq!(value["lamports"], "9007199254740993");
1594        assert_eq!(value["contextSlot"], "9007199254740995");
1595    }
1596
1597    /// A missing account must hold its slot, or every later address is attributed to the
1598    /// wrong account — silently, and with real money downstream.
1599    #[test]
1600    fn batch_accounts_keep_position_when_an_account_is_absent() {
1601        let addresses = vec!["a".to_string(), "b".to_string(), "c".to_string()];
1602        let values = vec![
1603            json!({ "owner": "prog", "lamports": 7u64, "executable": false, "data": ["AQI=", "base64"] }),
1604            Value::Null,
1605            json!({ "owner": "prog", "lamports": 9u64, "executable": false, "data": ["AwQ=", "base64"] }),
1606        ];
1607
1608        let items = batch_accounts_json(&addresses, &values);
1609
1610        assert_eq!(items.len(), 3);
1611        assert_eq!(items[0]["address"], "a");
1612        assert_eq!(items[0]["lamports"], "7");
1613        assert_eq!(items[0]["data"], "AQI=");
1614        assert_eq!(items[1], Value::Null, "absent account keeps its slot");
1615        assert_eq!(items[2]["address"], "c");
1616        assert_eq!(items[2]["lamports"], "9");
1617    }
1618
1619    /// The batch is where a rounded balance would be acted on, so the wire must carry a `u64`
1620    /// no JavaScript client can round. 9007199254740993 is the first integer a JSON number
1621    /// cannot represent: `JSON.parse` returns ...92.
1622    #[test]
1623    fn batch_accounts_serialize_lamports_as_decimal_strings() {
1624        let addresses = vec!["big".to_string()];
1625        let values = vec![json!({
1626            "owner": "prog",
1627            "lamports": 9_007_199_254_740_993_u64,
1628            "executable": false,
1629            "data": ["AQI=", "base64"],
1630        })];
1631
1632        let items = batch_accounts_json(&addresses, &values);
1633
1634        assert_eq!(items[0]["lamports"], "9007199254740993");
1635    }
1636
1637    fn auth_context_allowing(
1638        max_batch_addresses: Option<u32>,
1639    ) -> crate::websocket::auth::AuthContext {
1640        crate::websocket::auth::AuthContext {
1641            subject: "test".to_string(),
1642            issuer: "test-issuer".to_string(),
1643            audience: "test-audience".to_string(),
1644            key_class: arete_auth::KeyClass::Publishable,
1645            metering_key: "meter-test".to_string(),
1646            deployment_id: None,
1647            target_kind: None,
1648            target_id: None,
1649            program_id: None,
1650            program_release_hash: None,
1651            expires_at: u64::MAX,
1652            scope: "read".to_string(),
1653            limits: arete_auth::Limits {
1654                max_http_batch_addresses: max_batch_addresses,
1655                ..Default::default()
1656            },
1657            plan: None,
1658            origin: None,
1659            client_ip: None,
1660            jti: "test-jti".to_string(),
1661            actor_key: None,
1662            account_key: None,
1663            consumer_key: None,
1664            policy_version: None,
1665            account_limits: arete_auth::Limits::default(),
1666        }
1667    }
1668
1669    /// A batch is charged as one HTTP request, so a session's address ceiling is the only thing
1670    /// standing between a ten-address plan and a hundred-address read. `POST /chain/accounts`
1671    /// ignored it while the program-account batch honoured it; both now share this computation.
1672    #[test]
1673    fn a_session_ceiling_bounds_the_batch_below_the_protocol_limit() {
1674        let context = auth_context_allowing(Some(10));
1675
1676        assert_eq!(
1677            batch_address_limit(Some(&context), MAX_CHAIN_BATCH_ADDRESSES),
1678            10
1679        );
1680    }
1681
1682    /// A generous claim must not raise the protocol ceiling: one batch is still one upstream call.
1683    #[test]
1684    fn a_session_ceiling_cannot_exceed_the_protocol_limit() {
1685        let context = auth_context_allowing(Some(1_000));
1686
1687        assert_eq!(
1688            batch_address_limit(Some(&context), MAX_CHAIN_BATCH_ADDRESSES),
1689            MAX_CHAIN_BATCH_ADDRESSES
1690        );
1691    }
1692
1693    /// No claim, and no auth at all, both fall back to the protocol ceiling.
1694    #[test]
1695    fn an_absent_ceiling_falls_back_to_the_protocol_limit() {
1696        let context = auth_context_allowing(None);
1697
1698        assert_eq!(
1699            batch_address_limit(Some(&context), MAX_CHAIN_BATCH_ADDRESSES),
1700            MAX_CHAIN_BATCH_ADDRESSES
1701        );
1702        assert_eq!(
1703            batch_address_limit(None, MAX_PROGRAM_BATCH_ADDRESSES),
1704            MAX_PROGRAM_BATCH_ADDRESSES
1705        );
1706    }
1707
1708    #[test]
1709    fn batch_accounts_json_is_empty_for_no_addresses() {
1710        assert!(batch_accounts_json(&[], &[]).is_empty());
1711    }
1712
1713    /// The batch item shape must stay identical to the single-address route's, or a client
1714    /// needs two parsers for one concept.
1715    #[test]
1716    fn batch_item_matches_the_single_address_shape() {
1717        let value = json!({ "owner": "prog", "lamports": 5u64, "executable": true, "data": ["BQY=", "base64"] });
1718        let addresses = vec!["solo".to_string()];
1719
1720        assert_eq!(
1721            batch_accounts_json(&addresses, std::slice::from_ref(&value))[0],
1722            raw_account_json("solo", &value)
1723        );
1724    }
1725
1726    #[test]
1727    fn rpc_read_config_propagates_min_context_slot() {
1728        let config = rpc_read_config(Some("jsonParsed"), Some(9_007_199_254_740_997));
1729
1730        assert_eq!(config["commitment"], "confirmed");
1731        assert_eq!(config["encoding"], "jsonParsed");
1732        assert_eq!(config["minContextSlot"], 9_007_199_254_740_997_u64);
1733    }
1734
1735    #[test]
1736    fn token_balance_preserves_raw_amount_and_stringifies_context_slot() {
1737        let value = contextual_token_balance_json(
1738            &json!({
1739                "context": { "slot": 9_007_199_254_740_995_u64 },
1740                "value": [{
1741                    "pubkey": "token-account",
1742                    "account": {
1743                        "data": {
1744                            "parsed": {
1745                                "info": {
1746                                    "mint": "mint",
1747                                    "tokenAmount": {
1748                                        "amount": "18446744073709551615",
1749                                        "decimals": 9,
1750                                        "uiAmountString": "18446744073.709551615"
1751                                    }
1752                                }
1753                            }
1754                        }
1755                    }
1756                }]
1757            }),
1758            "owner",
1759            "mint",
1760            None,
1761        )
1762        .unwrap();
1763
1764        assert_eq!(value["amount"], "18446744073709551615");
1765        assert_eq!(value["contextSlot"], "9007199254740995");
1766    }
1767
1768    #[test]
1769    fn balance_bodies_accept_decimal_string_min_context_slots() {
1770        let native: NativeBalanceBody = serde_json::from_value(json!({
1771            "address": "owner",
1772            "minContextSlot": "9007199254740997",
1773        }))
1774        .unwrap();
1775        let token: BalanceBody = serde_json::from_value(json!({
1776            "owner": "owner",
1777            "mint": "mint",
1778            "minContextSlot": "9007199254740999",
1779        }))
1780        .unwrap();
1781
1782        assert_eq!(
1783            parse_min_context_slot(native.min_context_slot.as_deref()).unwrap(),
1784            Some(9_007_199_254_740_997)
1785        );
1786        assert_eq!(
1787            parse_min_context_slot(token.min_context_slot.as_deref()).unwrap(),
1788            Some(9_007_199_254_740_999)
1789        );
1790    }
1791
1792    fn runtime_definition(reader: crate::ProgramAccountReaderFn) -> ProgramRuntimeDefinition {
1793        let program_spec_hash = crate::ProgramSpecHash::from_digest([1; 32]);
1794        let idl_content_hash = crate::IdlContentHash::from_digest([2; 32]);
1795        let normalized_idl_hash = crate::NormalizedIdlHash::from_digest([3; 32]);
1796        let program_release_hash = arete_hash::OssGeneratedProgramReleaseV1::new(
1797            "Program111",
1798            program_spec_hash,
1799            idl_content_hash,
1800            normalized_idl_hash,
1801        )
1802        .hash()
1803        .unwrap();
1804        ProgramRuntimeDefinition {
1805            program_id: "Program111".to_string(),
1806            program_spec_hash,
1807            idl_content_hash,
1808            normalized_idl_hash,
1809            program_release_hash,
1810            account_reader: reader,
1811        }
1812    }
1813
1814    fn program_read_context(
1815        target_id: &str,
1816        program_id: &str,
1817        release_hash: &str,
1818    ) -> crate::websocket::auth::AuthContext {
1819        crate::websocket::auth::AuthContext::from_claims(
1820            arete_auth::SessionClaims::program_read_builder(
1821                "issuer",
1822                "user:1",
1823                target_id,
1824                program_id,
1825                release_hash,
1826            )
1827            .build(),
1828        )
1829    }
1830
1831    #[test]
1832    fn program_read_auth_is_bound_to_target_program_and_release() {
1833        let definition = runtime_definition(Arc::new(|_, _| Ok(Value::Null)));
1834        let release_hash = definition.program_release_hash.to_string();
1835        let valid = program_read_context("binding-1", &definition.program_id, &release_hash);
1836
1837        assert_eq!(
1838            authorize_program_read(Some(&valid), Some("binding-1"), &definition),
1839            Ok(())
1840        );
1841
1842        let wrong_target = program_read_context("binding-2", &definition.program_id, &release_hash);
1843        let wrong_program = program_read_context("binding-1", "Program222", &release_hash);
1844        let wrong_release = program_read_context(
1845            "binding-1",
1846            &definition.program_id,
1847            "arete:h1:program-release:sha256:different",
1848        );
1849
1850        for context in [&wrong_target, &wrong_program, &wrong_release] {
1851            assert_eq!(
1852                authorize_program_read(Some(context), Some("binding-1"), &definition),
1853                Err(ProgramReadError::Unauthorized)
1854            );
1855        }
1856        assert_eq!(
1857            authorize_program_read(Some(&valid), None, &definition),
1858            Err(ProgramReadError::AuthorizationNotConfigured)
1859        );
1860        assert_eq!(authorize_program_read(None, None, &definition), Ok(()));
1861    }
1862
1863    #[test]
1864    fn release_routes_are_exact_and_legacy_program_routes_are_not_accepted() {
1865        let hash = crate::ProgramReleaseHash::from_digest([4; 32]);
1866        let fetch_path = format!("/v1/releases/{hash}/accounts/Vault/address");
1867        let exists_path = format!("{fetch_path}/exists");
1868        let batch_path = format!("/v1/releases/{hash}/accounts/Vault");
1869
1870        assert!(matches!(
1871            ProgramReadRoute::parse(&Method::GET, &fetch_path)
1872                .unwrap()
1873                .operation,
1874            ProgramReadOperation::Fetch { .. }
1875        ));
1876        assert!(matches!(
1877            ProgramReadRoute::parse(&Method::GET, &exists_path)
1878                .unwrap()
1879                .operation,
1880            ProgramReadOperation::Exists { .. }
1881        ));
1882        assert!(matches!(
1883            ProgramReadRoute::parse(&Method::POST, &batch_path)
1884                .unwrap()
1885                .operation,
1886            ProgramReadOperation::Batch
1887        ));
1888        assert_eq!(
1889            ProgramReadRoute::parse(&Method::GET, "/programs/demo/accounts/Vault/address")
1890                .unwrap_err(),
1891            ProgramReadError::NotFound
1892        );
1893    }
1894
1895    #[tokio::test]
1896    async fn owner_mismatch_is_rejected_before_decoder_execution() {
1897        use std::sync::atomic::{AtomicBool, Ordering};
1898
1899        let called = Arc::new(AtomicBool::new(false));
1900        let called_by_reader = called.clone();
1901        let definition = runtime_definition(Arc::new(move |_, _| {
1902            called_by_reader.store(true, Ordering::SeqCst);
1903            Ok(json!({ "decoded": true }))
1904        }));
1905        let value = json!({
1906            "owner": "DifferentProgram",
1907            "data": [BASE64_STANDARD.encode([1, 2, 3]), "base64"]
1908        });
1909
1910        assert!(matches!(
1911            decode_release_account(&definition, "Vault", &value).await,
1912            AccountReadOutcome::Error(ProgramReadError::AccountOwnerMismatch)
1913        ));
1914        assert!(!called.load(Ordering::SeqCst));
1915    }
1916
1917    #[tokio::test]
1918    async fn decode_failures_remain_typed_errors_and_metadata_is_public_only() {
1919        let definition = runtime_definition(Arc::new(|_, _| anyhow::bail!("private diagnostic")));
1920        let value = json!({
1921            "owner": "Program111",
1922            "data": [BASE64_STANDARD.encode([1, 2, 3]), "base64"]
1923        });
1924        assert!(matches!(
1925            decode_release_account(&definition, "Vault", &value).await,
1926            AccountReadOutcome::Error(ProgramReadError::AccountDecodeFailed)
1927        ));
1928
1929        let response = with_release_metadata(
1930            program_read_error_response(ProgramReadError::AccountDecodeFailed),
1931            &definition,
1932            Some("address"),
1933            Some(true),
1934        );
1935        assert_eq!(response.headers()["X-Error-Code"], "ACCOUNT_DECODE_FAILED");
1936        assert_eq!(
1937            response.headers()["X-Arete-Program-Release-Hash"],
1938            definition.program_release_hash.to_string()
1939        );
1940        assert_eq!(
1941            response.headers()["X-Arete-Idl-Content-Hash"],
1942            definition.idl_content_hash.to_string()
1943        );
1944        let body = response.into_body().collect().await.unwrap().to_bytes();
1945        let body = std::str::from_utf8(&body).unwrap();
1946        assert_eq!(body, r#"{"error":{"code":"ACCOUNT_DECODE_FAILED"}}"#);
1947        assert!(!body.contains("private diagnostic"));
1948        assert!(!body.contains("decoder"));
1949    }
1950}