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