Skip to main content

arete_server/
http_health.rs

1use crate::http::fork;
2use crate::http::transactions::{self, TransactionState};
3use crate::{
4    config::{HttpLimitsConfig, RuntimePlan, TransactionConfig},
5    health::HealthMonitor,
6    ProgramRuntimeCatalog, ProgramRuntimeDefinition, SolanaGatewayUsageObservation,
7    SolanaGatewayUsageObserver, SolanaGatewayUsageSurface,
8};
9use anyhow::Result;
10use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
11use base64::Engine as _;
12use dashmap::DashMap;
13use http_body_util::BodyExt;
14use http_body_util::Full;
15use hyper::body::Bytes;
16use hyper::header::{
17    HeaderValue, ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS,
18    ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_EXPOSE_HEADERS, ACCESS_CONTROL_MAX_AGE,
19};
20use hyper::server::conn::http1;
21use hyper::service::service_fn;
22use hyper::{Method, Request, Response, StatusCode};
23use hyper_util::rt::{TokioIo, TokioTimer};
24use reqwest::Client;
25use serde::Deserialize;
26use serde_json::{json, Value};
27use std::convert::Infallible;
28use std::env;
29use std::net::SocketAddr;
30use std::sync::Arc;
31use std::time::{Duration, SystemTime, UNIX_EPOCH};
32use tokio::net::TcpListener;
33use tokio::task::JoinSet;
34use tokio_util::sync::CancellationToken;
35use tracing::{error, info, warn};
36
37use crate::websocket::auth::{AuthDecision, AuthDeny, ConnectionAuthRequest, WebSocketAuthPlugin};
38use arete_auth::{SCOPE_FORK_READ, SCOPE_READ};
39
40use crate::token_discovery::{OwnerTokenAccountsProvider, TokenDiscoveryError};
41use arete_solana_contracts::{Contextual, OwnerTokenAccountsRequest, ReadContext, ReadOptions};
42
43const HTTP_CONNECTION_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
44
45/// Configuration for the HTTP health server
46#[derive(Clone, Debug)]
47pub struct HttpHealthConfig {
48    pub bind_address: SocketAddr,
49}
50
51impl Default for HttpHealthConfig {
52    fn default() -> Self {
53        Self {
54            bind_address: "[::]:8081".parse().expect("valid socket address"),
55        }
56    }
57}
58
59impl HttpHealthConfig {
60    pub fn new(bind_address: impl Into<SocketAddr>) -> Self {
61        Self {
62            bind_address: bind_address.into(),
63        }
64    }
65}
66
67#[derive(Clone)]
68struct HttpRequestState {
69    health_monitor: Arc<Option<HealthMonitor>>,
70    snapshot_runtime: Option<crate::snapshot::SnapshotRuntime>,
71    runtime_plan: RuntimePlan,
72    rpc_url: Arc<Option<String>>,
73    rpc_client: ReadRpcClient,
74    body_limits: BodyLimits,
75    program_runtime_catalog: Arc<ProgramRuntimeCatalog>,
76    auth_plugin: Arc<Option<Arc<dyn WebSocketAuthPlugin>>>,
77    limit_state: Arc<HttpLimitState>,
78    transaction_state: Arc<Option<TransactionState>>,
79    token_accounts_provider: Option<Arc<dyn OwnerTokenAccountsProvider>>,
80    solana_gateway_usage_observer: Option<Arc<dyn SolanaGatewayUsageObserver>>,
81    solana_gateway_target_id: Arc<Option<String>>,
82    program_read_binding_target_id: Arc<Option<String>>,
83}
84
85/// HTTP server that exposes health endpoints
86pub struct HttpHealthServer {
87    /// Cancelled to stop the accept loop; see [`HttpHealthServer::with_shutdown`].
88    shutdown: Option<CancellationToken>,
89    bind_addr: SocketAddr,
90    health_monitor: Option<HealthMonitor>,
91    snapshot_runtime: Option<crate::snapshot::SnapshotRuntime>,
92    runtime_plan: RuntimePlan,
93    program_runtime_catalog: ProgramRuntimeCatalog,
94    auth_plugin: Option<Arc<dyn WebSocketAuthPlugin>>,
95    transaction_config: Option<TransactionConfig>,
96    token_accounts_provider: Option<Arc<dyn OwnerTokenAccountsProvider>>,
97    solana_gateway_usage_observer: Option<Arc<dyn SolanaGatewayUsageObserver>>,
98    solana_gateway_target_id: Option<String>,
99    program_read_binding_target_id: Option<String>,
100    /// Read RPC used instead of the process environment; set only by tests.
101    read_rpc_url: Option<String>,
102    limits: HttpLimitsConfig,
103    #[cfg(feature = "otel")]
104    metrics: Option<Arc<crate::metrics::Metrics>>,
105}
106
107impl HttpHealthServer {
108    pub fn new(bind_addr: SocketAddr) -> Self {
109        Self {
110            bind_addr,
111            shutdown: None,
112            health_monitor: None,
113            snapshot_runtime: None,
114            runtime_plan: RuntimePlan::http(),
115            program_runtime_catalog: ProgramRuntimeCatalog::default(),
116            auth_plugin: None,
117            transaction_config: None,
118            token_accounts_provider: None,
119            solana_gateway_usage_observer: None,
120            solana_gateway_target_id: None,
121            program_read_binding_target_id: None,
122            read_rpc_url: None,
123            limits: HttpLimitsConfig::default(),
124            #[cfg(feature = "otel")]
125            metrics: None,
126        }
127    }
128
129    #[cfg(test)]
130    pub(crate) fn with_read_rpc_url(mut self, rpc_url: impl Into<String>) -> Self {
131        self.read_rpc_url = Some(rpc_url.into());
132        self
133    }
134
135    /// Body, header and read RPC limits; see [`HttpLimitsConfig`].
136    pub fn with_limits(mut self, limits: HttpLimitsConfig) -> Self {
137        self.limits = limits;
138        self
139    }
140
141    pub fn with_health_monitor(mut self, monitor: HealthMonitor) -> Self {
142        self.health_monitor = Some(monitor);
143        self
144    }
145
146    pub fn with_snapshot_runtime(
147        mut self,
148        snapshot_runtime: crate::snapshot::SnapshotRuntime,
149    ) -> Self {
150        self.snapshot_runtime = Some(snapshot_runtime);
151        self
152    }
153
154    pub fn with_runtime_plan(mut self, runtime_plan: RuntimePlan) -> Self {
155        self.runtime_plan = runtime_plan;
156        self
157    }
158
159    pub fn with_program_runtime_catalog(mut self, catalog: ProgramRuntimeCatalog) -> Self {
160        self.program_runtime_catalog = catalog;
161        self
162    }
163
164    pub fn with_auth_plugin(mut self, plugin: Arc<dyn WebSocketAuthPlugin>) -> Self {
165        self.auth_plugin = Some(plugin);
166        self
167    }
168
169    pub fn with_transaction_config(mut self, config: TransactionConfig) -> Self {
170        self.runtime_plan.transactions = config.enabled;
171        self.transaction_config = Some(config);
172        self
173    }
174
175    pub fn with_owner_token_accounts_provider(
176        mut self,
177        provider: Arc<dyn OwnerTokenAccountsProvider>,
178    ) -> Self {
179        self.token_accounts_provider = Some(provider);
180        self
181    }
182
183    pub fn with_solana_gateway_usage_observer(
184        mut self,
185        observer: Arc<dyn SolanaGatewayUsageObserver>,
186    ) -> Self {
187        self.solana_gateway_usage_observer = Some(observer);
188        self
189    }
190
191    pub fn with_solana_gateway_target(mut self, target_id: impl Into<String>) -> Self {
192        self.solana_gateway_target_id = Some(target_id.into());
193        self
194    }
195
196    pub fn with_program_read_binding_target(mut self, target_id: impl Into<String>) -> Self {
197        self.program_read_binding_target_id = Some(target_id.into());
198        self
199    }
200
201    #[cfg(feature = "otel")]
202    pub fn with_metrics(mut self, metrics: Option<Arc<crate::metrics::Metrics>>) -> Self {
203        self.metrics = metrics;
204        self
205    }
206
207    /// Stop accepting and return from [`start`](Self::start) when `token` is
208    /// cancelled. Without one the server accepts until the process exits.
209    pub fn with_shutdown(mut self, token: CancellationToken) -> Self {
210        self.shutdown = Some(token);
211        self
212    }
213
214    pub async fn start(self) -> Result<()> {
215        info!("Starting HTTP health server on {}", self.bind_addr);
216        self.limits.validate()?;
217
218        let listener = TcpListener::bind(&self.bind_addr).await?;
219        info!("HTTP health server listening on {}", self.bind_addr);
220
221        let transaction_state = self
222            .transaction_config
223            .filter(|config| config.enabled)
224            .map(TransactionState::new)
225            .transpose()?;
226        let transaction_state = match (
227            transaction_state,
228            self.solana_gateway_usage_observer.as_ref(),
229            self.solana_gateway_target_id.as_ref(),
230        ) {
231            (Some(state), Some(observer), Some(target_id)) => {
232                Some(state.with_solana_gateway_usage_observer(observer.clone(), target_id.clone()))
233            }
234            (state, _, _) => state,
235        };
236        #[cfg(feature = "otel")]
237        let transaction_state = transaction_state.map(|state| state.with_metrics(self.metrics));
238        let request_state = HttpRequestState {
239            health_monitor: Arc::new(self.health_monitor),
240            snapshot_runtime: self.snapshot_runtime,
241            runtime_plan: self.runtime_plan,
242            rpc_url: Arc::new(self.read_rpc_url.or_else(resolve_rpc_url)),
243            rpc_client: ReadRpcClient {
244                client: Client::builder()
245                    .connect_timeout(self.limits.upstream_connect_timeout)
246                    .timeout(self.limits.upstream_timeout)
247                    .build()?,
248                max_response_bytes: self.limits.max_upstream_response_bytes,
249            },
250            body_limits: BodyLimits {
251                max_bytes: self.limits.max_body_bytes,
252                read_timeout: self.limits.body_read_timeout,
253            },
254            program_runtime_catalog: Arc::new(self.program_runtime_catalog),
255            auth_plugin: Arc::new(self.auth_plugin),
256            limit_state: Arc::new(HttpLimitState::default()),
257            transaction_state: Arc::new(transaction_state),
258            token_accounts_provider: self.token_accounts_provider,
259            solana_gateway_usage_observer: self.solana_gateway_usage_observer,
260            solana_gateway_target_id: Arc::new(self.solana_gateway_target_id),
261            program_read_binding_target_id: Arc::new(self.program_read_binding_target_id),
262        };
263
264        let shutdown = self.shutdown.unwrap_or_default();
265        let mut connections = JoinSet::new();
266        loop {
267            let accepted = tokio::select! {
268                _ = shutdown.cancelled() => {
269                    info!("HTTP health server on {} stopping", self.bind_addr);
270                    break;
271                }
272                accepted = listener.accept() => accepted,
273            };
274            match accepted {
275                Ok((stream, remote_addr)) => {
276                    let io = TokioIo::new(stream);
277                    let request_state = request_state.clone();
278                    let connection_shutdown = shutdown.clone();
279                    let header_read_timeout = self.limits.header_read_timeout;
280
281                    connections.spawn(async move {
282                        let service = service_fn(move |req| {
283                            let request_state = request_state.clone();
284                            async move { handle_request(remote_addr, req, request_state).await }
285                        });
286                        // A client that never finishes its headers is dropped at the deadline.
287                        let connection = http1::Builder::new()
288                            .timer(TokioTimer::new())
289                            .header_read_timeout(header_read_timeout)
290                            .serve_connection(io, service);
291                        tokio::pin!(connection);
292                        let result = tokio::select! {
293                            result = &mut connection => result,
294                            _ = connection_shutdown.cancelled() => {
295                                connection.as_mut().graceful_shutdown();
296                                connection.await
297                            }
298                        };
299                        if let Err(e) = result {
300                            error!("HTTP connection error: {}", e);
301                        }
302                    });
303                }
304                Err(e) => {
305                    error!("Failed to accept HTTP connection: {}", e);
306                }
307            }
308        }
309
310        if tokio::time::timeout(HTTP_CONNECTION_DRAIN_TIMEOUT, async {
311            while connections.join_next().await.is_some() {}
312        })
313        .await
314        .is_err()
315        {
316            warn!(
317                "HTTP connections did not drain within {:?}; aborting them",
318                HTTP_CONNECTION_DRAIN_TIMEOUT
319            );
320            connections.abort_all();
321            while connections.join_next().await.is_some() {}
322        }
323
324        Ok(())
325    }
326}
327
328async fn handle_request(
329    remote_addr: SocketAddr,
330    mut req: Request<hyper::body::Incoming>,
331    state: HttpRequestState,
332) -> Result<Response<Full<Bytes>>, Infallible> {
333    // Every body reader finds its limits on the request itself.
334    req.extensions_mut().insert(state.body_limits);
335    if req.method() == Method::OPTIONS {
336        return Ok(with_cors(
337            Response::builder()
338                .status(StatusCode::NO_CONTENT)
339                .body(Full::new(Bytes::new()))
340                .unwrap(),
341        ));
342    }
343
344    let response = handle_request_inner(remote_addr, req, state).await?;
345    Ok(with_cors(response))
346}
347
348fn with_cors(mut response: Response<Full<Bytes>>) -> Response<Full<Bytes>> {
349    let headers = response.headers_mut();
350    headers.insert(ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
351    headers.insert(
352        ACCESS_CONTROL_ALLOW_METHODS,
353        HeaderValue::from_static("GET, POST, OPTIONS"),
354    );
355    headers.insert(
356        ACCESS_CONTROL_ALLOW_HEADERS,
357        HeaderValue::from_static("Authorization, Content-Type"),
358    );
359    headers.insert(
360        ACCESS_CONTROL_EXPOSE_HEADERS,
361        HeaderValue::from_static(
362            "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",
363        ),
364    );
365    headers.insert(ACCESS_CONTROL_MAX_AGE, HeaderValue::from_static("86400"));
366    response
367}
368
369async fn handle_request_inner(
370    remote_addr: SocketAddr,
371    req: Request<hyper::body::Incoming>,
372    state: HttpRequestState,
373) -> Result<Response<Full<Bytes>>, Infallible> {
374    let HttpRequestState {
375        health_monitor,
376        snapshot_runtime,
377        runtime_plan,
378        rpc_url,
379        rpc_client,
380        body_limits,
381        program_runtime_catalog,
382        auth_plugin,
383        limit_state,
384        transaction_state,
385        token_accounts_provider,
386        solana_gateway_usage_observer,
387        solana_gateway_target_id,
388        program_read_binding_target_id,
389    } = state;
390    let path = req.uri().path().to_string();
391
392    match path.as_str() {
393        "/health" | "/healthz" if runtime_plan.health => {
394            // Basic health check - server is running
395            Ok(Response::builder()
396                .status(StatusCode::OK)
397                .header("Content-Type", "text/plain")
398                .body(Full::new(Bytes::from("OK")))
399                .unwrap())
400        }
401        "/ready" | "/readiness" if runtime_plan.health => {
402            // Readiness check - stream must be healthy, and after a snapshot
403            // resume the parser must have caught back up to the slot tip
404            // before this pod should take traffic.
405            let stream_ready = match health_monitor.as_ref() {
406                Some(monitor) => monitor.is_healthy().await,
407                // No health monitor configured, assume ready
408                None => true,
409            };
410            let snapshot_ready = snapshot_runtime
411                .as_ref()
412                .is_none_or(crate::snapshot::SnapshotRuntime::resume_gate_ready);
413            if stream_ready && snapshot_ready {
414                Ok(Response::builder()
415                    .status(StatusCode::OK)
416                    .header("Content-Type", "text/plain")
417                    .body(Full::new(Bytes::from("READY")))
418                    .unwrap())
419            } else {
420                Ok(Response::builder()
421                    .status(StatusCode::SERVICE_UNAVAILABLE)
422                    .header("Content-Type", "text/plain")
423                    .body(Full::new(Bytes::from("NOT READY")))
424                    .unwrap())
425            }
426        }
427        "/status" if runtime_plan.health => {
428            // Detailed status endpoint
429            if let Some(monitor) = health_monitor.as_ref() {
430                let status = monitor.status().await;
431                let error_count = monitor.error_count().await;
432                let is_healthy = monitor.is_healthy().await;
433
434                let status_json = serde_json::json!({
435                    "healthy": is_healthy,
436                    "status": format!("{:?}", status),
437                    "error_count": error_count
438                });
439
440                let status_code = if is_healthy {
441                    StatusCode::OK
442                } else {
443                    StatusCode::SERVICE_UNAVAILABLE
444                };
445
446                Ok(Response::builder()
447                    .status(status_code)
448                    .header("Content-Type", "application/json")
449                    .body(Full::new(Bytes::from(status_json.to_string())))
450                    .unwrap())
451            } else {
452                let status_json = serde_json::json!({
453                    "healthy": true,
454                    "status": "no_monitor",
455                    "error_count": 0
456                });
457
458                Ok(Response::builder()
459                    .status(StatusCode::OK)
460                    .header("Content-Type", "application/json")
461                    .body(Full::new(Bytes::from(status_json.to_string())))
462                    .unwrap())
463            }
464        }
465        _ if runtime_plan.transactions && path.starts_with("/transactions/") => {
466            let Some(transaction_state) = transaction_state.as_ref() else {
467                return Ok(error_response(StatusCode::NOT_FOUND, "Not Found"));
468            };
469            let client_addr = transaction_state.client_addr(remote_addr, req.headers());
470            let auth_context = match authorize_http_request(
471                client_addr,
472                &req,
473                auth_plugin.as_ref().as_ref(),
474                &limit_state,
475                None,
476                false,
477                solana_gateway_target_id.as_deref(),
478            )
479            .await
480            {
481                Ok(context) => context,
482                Err(response) => return Ok(transaction_auth_error(response, path.as_str())),
483            };
484            Ok(
485                transactions::handle(client_addr, req, auth_context, transaction_state.clone())
486                    .await,
487            )
488        }
489        _ if runtime_plan.chain_reads && path.starts_with("/chain/") => {
490            let auth_context = match authorize_http_request(
491                remote_addr,
492                &req,
493                auth_plugin.as_ref().as_ref(),
494                &limit_state,
495                Some(SCOPE_READ),
496                true,
497                solana_gateway_target_id.as_deref(),
498            )
499            .await
500            {
501                Ok(context) => context,
502                Err(response) => return Ok(response),
503            };
504            Ok(handle_chain_request(
505                req,
506                path.as_str(),
507                ChainRequestServices {
508                    rpc_url,
509                    rpc_client,
510                    auth_context,
511                    usage_observer: solana_gateway_usage_observer.as_ref(),
512                    solana_gateway_target_id: solana_gateway_target_id.as_deref(),
513                    provider: token_accounts_provider.as_ref(),
514                },
515            )
516            .await)
517        }
518        _ if runtime_plan.chain_reads && path == fork::FORK_RPC_PATH => {
519            if req.method() != Method::POST {
520                return Ok(error_response(
521                    StatusCode::METHOD_NOT_ALLOWED,
522                    "The fork endpoint accepts POST only",
523                ));
524            }
525            let auth_context = match authorize_http_request(
526                remote_addr,
527                &req,
528                auth_plugin.as_ref().as_ref(),
529                &limit_state,
530                Some(SCOPE_FORK_READ),
531                true,
532                solana_gateway_target_id.as_deref(),
533            )
534            .await
535            {
536                Ok(context) => context,
537                Err(response) => return Ok(response),
538            };
539            let body =
540                match tokio::time::timeout(body_limits.read_timeout, fork::read_body(req)).await {
541                    Ok(Ok(body)) => body,
542                    Ok(Err(response)) => return Ok(response),
543                    Err(_) => return Ok(body_timeout_response()),
544                };
545            let payload = fork::parse_payload(&body);
546            // A batch forwards up to one upstream call per entry, so it is charged per entry.
547            // `authorize_http_request` already charged the first.
548            if let Some(context) = auth_context.as_ref() {
549                if let Err(deny) =
550                    enforce_http_limits_weighted(context, &limit_state, payload.weight() - 1)
551                {
552                    return Ok(auth_deny_response(&deny));
553                }
554            }
555            let services = fork::ForkServices {
556                rpc_url: rpc_url.as_ref().as_deref(),
557                client: &rpc_client,
558                auth_context: auth_context.as_ref(),
559                usage_observer: solana_gateway_usage_observer.as_ref(),
560                target_id: solana_gateway_target_id.as_deref(),
561                max_response_bytes: fork::MAX_FORK_RESPONSE_BYTES,
562            };
563            Ok(fork::execute(payload, &services).await)
564        }
565        _ if path.starts_with("/v1/releases/") => {
566            if !runtime_plan.program_reads {
567                return Ok(program_read_error_response(
568                    ProgramReadError::ProgramReadsDisabled,
569                ));
570            }
571            let auth_context = match authorize_http_request(
572                remote_addr,
573                &req,
574                auth_plugin.as_ref().as_ref(),
575                &limit_state,
576                Some(SCOPE_READ),
577                true,
578                None,
579            )
580            .await
581            {
582                Ok(context) => context,
583                Err(response) => return Ok(response),
584            };
585            Ok(handle_program_account_request(
586                req,
587                path.as_str(),
588                rpc_url,
589                rpc_client,
590                program_runtime_catalog,
591                auth_context,
592                program_read_binding_target_id,
593            )
594            .await)
595        }
596        _ => Ok(Response::builder()
597            .status(StatusCode::NOT_FOUND)
598            .header("Content-Type", "text/plain")
599            .body(Full::new(Bytes::from("Not Found")))
600            .unwrap()),
601    }
602}
603
604fn transaction_auth_error(response: Response<Full<Bytes>>, path: &str) -> Response<Full<Bytes>> {
605    let status = response.status();
606    let code = response
607        .headers()
608        .get("X-Error-Code")
609        .and_then(|value| value.to_str().ok())
610        .unwrap_or("authentication_failed")
611        .to_string();
612    let request_id = uuid::Uuid::new_v4().to_string();
613    let mut value = json!({
614        "code": code,
615        "message": "Transaction request authentication failed",
616        "retryable": status == StatusCode::TOO_MANY_REQUESTS || status == StatusCode::UNAUTHORIZED,
617        "requestId": request_id,
618    });
619    if path == "/transactions/v1/send" {
620        value["submissionState"] = json!("not_submitted");
621    }
622    Response::builder()
623        .status(status)
624        .header("Content-Type", "application/json")
625        .header("X-Error-Code", code)
626        .header("X-Request-Id", request_id)
627        .header("X-Arete-Upstream-Attempted", "false")
628        .body(Full::new(Bytes::from(value.to_string())))
629        .expect("valid transaction authentication response")
630}
631
632fn capability_error(
633    status: StatusCode,
634    code: &str,
635    message: impl Into<String>,
636) -> Response<Full<Bytes>> {
637    let mut response = json_response(status, json!({ "code": code, "error": message.into() }));
638    response.headers_mut().insert(
639        "X-Error-Code",
640        HeaderValue::from_str(code).expect("static error code"),
641    );
642    response
643}
644
645async fn handle_owner_token_accounts(
646    req: Request<hyper::body::Incoming>,
647    provider: Option<&Arc<dyn OwnerTokenAccountsProvider>>,
648    auth: Option<&crate::websocket::auth::AuthContext>,
649) -> Response<Full<Bytes>> {
650    let request = match read_json_body::<OwnerTokenAccountsRequest>(req).await {
651        Ok(request) => request,
652        Err(response) if response.status() != StatusCode::BAD_REQUEST => return response,
653        Err(_) => {
654            return capability_error(
655                StatusCode::BAD_REQUEST,
656                "invalid_request",
657                "Invalid JSON request",
658            )
659        }
660    };
661    if let Err(error) = request.validate() {
662        return capability_error(StatusCode::BAD_REQUEST, "invalid_request", error);
663    }
664    if request.limit as usize
665        > batch_address_limit(auth, arete_solana_contracts::MAX_PAGE_SIZE as usize)
666    {
667        return capability_error(
668            StatusCode::BAD_REQUEST,
669            "invalid_request",
670            "limit exceeds the session's address limit",
671        );
672    }
673    let Some(provider) = provider else {
674        return capability_error(
675            StatusCode::NOT_IMPLEMENTED,
676            "unsupported_capability",
677            "Owner token discovery is not configured",
678        );
679    };
680    match provider.owner_token_accounts(&request).await {
681        Ok(page) => match page.validate(&request) {
682            Ok(()) => json_response(
683                StatusCode::OK,
684                serde_json::to_value(page).expect("serializable page"),
685            ),
686            Err(error) => {
687                capability_error(StatusCode::BAD_GATEWAY, "invalid_provider_response", error)
688            }
689        },
690        Err(TokenDiscoveryError::InvalidCursor(error)) => {
691            capability_error(StatusCode::BAD_REQUEST, "invalid_cursor", error)
692        }
693        Err(TokenDiscoveryError::Unsupported(error)) => {
694            capability_error(StatusCode::NOT_IMPLEMENTED, "unsupported_capability", error)
695        }
696        Err(TokenDiscoveryError::Unavailable(error)) => {
697            capability_error(StatusCode::BAD_GATEWAY, "provider_unavailable", error)
698        }
699    }
700}
701
702#[derive(Deserialize)]
703#[serde(deny_unknown_fields)]
704struct ContextualAccountBody {
705    address: String,
706    #[serde(default)]
707    options: ReadOptions,
708}
709
710#[derive(Deserialize)]
711#[serde(deny_unknown_fields)]
712struct ContextualAccountsBody {
713    addresses: Vec<String>,
714    #[serde(default)]
715    options: ReadOptions,
716}
717
718async fn handle_contextual_accounts(
719    req: Request<hyper::body::Incoming>,
720    path: &str,
721    rpc_url: Option<&str>,
722    client: &ReadRpcClient,
723    auth: Option<&crate::websocket::auth::AuthContext>,
724) -> Response<Full<Bytes>> {
725    let single = path == "/chain/v1/account";
726    let (addresses, options) = if single {
727        match read_json_body::<ContextualAccountBody>(req).await {
728            Ok(body) => (vec![body.address], body.options),
729            Err(response) if response.status() != StatusCode::BAD_REQUEST => return response,
730            Err(_) => {
731                return capability_error(
732                    StatusCode::BAD_REQUEST,
733                    "invalid_request",
734                    "Invalid JSON request",
735                )
736            }
737        }
738    } else {
739        match read_json_body::<ContextualAccountsBody>(req).await {
740            Ok(body) => (body.addresses, body.options),
741            Err(response) if response.status() != StatusCode::BAD_REQUEST => return response,
742            Err(_) => {
743                return capability_error(
744                    StatusCode::BAD_REQUEST,
745                    "invalid_request",
746                    "Invalid JSON request",
747                )
748            }
749        }
750    };
751    if addresses.len() > batch_address_limit(auth, MAX_CHAIN_BATCH_ADDRESSES) {
752        return capability_error(
753            StatusCode::BAD_REQUEST,
754            "invalid_request",
755            "addresses exceeds the batch limit",
756        );
757    }
758    for address in &addresses {
759        if let Err(error) = arete_solana_contracts::validate_address(address) {
760            return capability_error(StatusCode::BAD_REQUEST, "invalid_request", error);
761        }
762    }
763    if addresses.is_empty() {
764        return json_response(StatusCode::OK, json!({ "context": null, "value": [] }));
765    }
766    let Some(rpc_url) = rpc_url else {
767        return capability_error(
768            StatusCode::SERVICE_UNAVAILABLE,
769            "rpc_unavailable",
770            "No RPC URL configured for chain reads",
771        );
772    };
773    let (method, address) = if single {
774        ("getAccountInfo", json!(addresses[0]))
775    } else {
776        ("getMultipleAccounts", json!(addresses))
777    };
778    match rpc_call(
779        client,
780        rpc_url,
781        method,
782        json!([address, options.rpc_config()]),
783    )
784    .await
785    .and_then(|result| contextual_accounts_json(&addresses, single, options, &result))
786    {
787        Ok(value) => json_response(StatusCode::OK, value),
788        Err(error) => {
789            let (message, correlation_id) = upstream_error_message(&error);
790            let code = "invalid_upstream_response";
791            let mut response = json_response(
792                StatusCode::BAD_GATEWAY,
793                json!({ "code": code, "error": message, "correlationId": correlation_id }),
794            );
795            let headers = response.headers_mut();
796            headers.insert("X-Error-Code", HeaderValue::from_static(code));
797            if let Ok(value) = HeaderValue::from_str(&correlation_id) {
798                headers.insert(CORRELATION_ID_HEADER, value);
799            }
800            response
801        }
802    }
803}
804
805fn contextual_accounts_json(
806    addresses: &[String],
807    single: bool,
808    options: ReadOptions,
809    result: &Value,
810) -> anyhow::Result<Value> {
811    let slot = result
812        .pointer("/context/slot")
813        .and_then(Value::as_u64)
814        .ok_or_else(|| anyhow::anyhow!("Missing actual read context"))?;
815    if options.min_context_slot.is_some_and(|min| slot < min) {
816        anyhow::bail!("Read context is below minContextSlot");
817    }
818    let value = result
819        .get("value")
820        .ok_or_else(|| anyhow::anyhow!("Missing account result"))?;
821    let values = if single {
822        vec![value.clone()]
823    } else {
824        value
825            .as_array()
826            .ok_or_else(|| anyhow::anyhow!("Invalid batch result"))?
827            .clone()
828    };
829    if values.len() != addresses.len() {
830        anyhow::bail!("Account result count does not match addresses");
831    }
832    let mut items = Vec::new();
833    for (address, account) in addresses.iter().zip(values) {
834        if account.is_null() {
835            items.push(Value::Null);
836            continue;
837        }
838        if account.get("lamports").and_then(Value::as_u64).is_none()
839            || account.get("owner").and_then(Value::as_str).is_none()
840            || account.get("executable").and_then(Value::as_bool).is_none()
841            || account.pointer("/data/0").and_then(Value::as_str).is_none()
842            || account.pointer("/data/1").and_then(Value::as_str) != Some("base64")
843        {
844            anyhow::bail!("Malformed raw account result");
845        }
846        items.push(raw_account_json(address, &account));
847    }
848    Ok(serde_json::to_value(Contextual {
849        context: Some(ReadContext { slot }),
850        value: if single {
851            items.remove(0)
852        } else {
853            json!(items)
854        },
855    })?)
856}
857
858#[derive(Debug, Deserialize)]
859struct AddressesBody {
860    addresses: Vec<String>,
861}
862
863#[derive(Debug, Deserialize)]
864struct BalanceBody {
865    owner: String,
866    mint: String,
867    #[serde(default, rename = "tokenProgram")]
868    token_program: Option<String>,
869    #[serde(default, rename = "minContextSlot")]
870    min_context_slot: Option<String>,
871}
872
873#[derive(Debug, Deserialize)]
874struct NativeBalanceBody {
875    address: String,
876    #[serde(default, rename = "minContextSlot")]
877    min_context_slot: Option<String>,
878}
879
880#[derive(Debug, Deserialize)]
881struct AccountsBody {
882    addresses: Vec<String>,
883}
884
885#[derive(Default)]
886struct HttpLimitState {
887    per_subject_per_minute: DashMap<String, (u64, u32)>,
888}
889
890fn resolve_rpc_url() -> Option<String> {
891    ["ARETE_READ_RPC_URL", "SOLANA_RPC_URL", "RPC_URL"]
892        .iter()
893        .find_map(|key| env::var(key).ok())
894        .filter(|value| !value.is_empty())
895}
896
897fn json_response(status: StatusCode, value: Value) -> Response<Full<Bytes>> {
898    Response::builder()
899        .status(status)
900        .header("Content-Type", "application/json")
901        .body(Full::new(Bytes::from(value.to_string())))
902        .unwrap()
903}
904
905fn error_response(status: StatusCode, message: impl Into<String>) -> Response<Full<Bytes>> {
906    json_response(status, json!({ "error": message.into() }))
907}
908
909const CORRELATION_ID_HEADER: &str = "X-Correlation-Id";
910const UPSTREAM_RPC_FAILED: &str = "Upstream RPC request failed";
911
912/// A failure that came from the read RPC itself: transport, HTTP status, body or JSON-RPC error.
913///
914/// Its detail can name the upstream endpoint, which may carry a credential in its path or
915/// query, so it is logged server-side and never returned to the caller.
916#[derive(Debug)]
917struct UpstreamRpcError(String);
918
919impl std::fmt::Display for UpstreamRpcError {
920    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
921        f.write_str(&self.0)
922    }
923}
924
925impl std::error::Error for UpstreamRpcError {}
926
927impl From<reqwest::Error> for UpstreamRpcError {
928    fn from(error: reqwest::Error) -> Self {
929        // reqwest's Display includes the request URL; drop it before anything formats it.
930        let error = error.without_url();
931        Self(format!(
932            "{error} (timeout: {}, connect: {}, status: {:?})",
933            error.is_timeout(),
934            error.is_connect(),
935            error.status().map(|status| status.as_u16()),
936        ))
937    }
938}
939
940/// The caller-safe message for a failed chain read, plus a correlation id for the server log.
941fn upstream_error_message(error: &anyhow::Error) -> (String, String) {
942    let correlation_id = uuid::Uuid::new_v4().to_string();
943    warn!(
944        correlation_id = %correlation_id,
945        error = %error,
946        "Chain read upstream request failed"
947    );
948    let message = if error.downcast_ref::<UpstreamRpcError>().is_some() {
949        UPSTREAM_RPC_FAILED.to_string()
950    } else {
951        // Our own validation of the upstream result; these messages are static.
952        error.to_string()
953    };
954    (message, correlation_id)
955}
956
957fn upstream_error_response(error: &anyhow::Error) -> Response<Full<Bytes>> {
958    let (message, correlation_id) = upstream_error_message(error);
959    let mut response = json_response(
960        StatusCode::BAD_GATEWAY,
961        json!({ "error": message, "correlationId": correlation_id }),
962    );
963    if let Ok(value) = HeaderValue::from_str(&correlation_id) {
964        response.headers_mut().insert(CORRELATION_ID_HEADER, value);
965    }
966    response
967}
968
969fn parse_min_context_slot(value: Option<&str>) -> std::result::Result<Option<u64>, &'static str> {
970    value
971        .map(|slot| {
972            slot.parse::<u64>()
973                .map_err(|_| "minContextSlot must be a decimal u64 string")
974        })
975        .transpose()
976}
977
978fn auth_deny_response(deny: &AuthDeny) -> Response<Full<Bytes>> {
979    let mut builder = Response::builder()
980        .status(deny.http_status)
981        .header("Content-Type", "application/json")
982        .header("X-Error-Code", deny.code.as_str());
983
984    if let Some(reset_at) = deny.reset_at {
985        if let Ok(duration) = reset_at.duration_since(SystemTime::now()) {
986            builder = builder.header("Retry-After", duration.as_secs().to_string());
987        }
988    }
989
990    builder
991        .body(Full::new(Bytes::from(
992            json!({
993                "error": deny.reason,
994                "message": deny.reason,
995                "code": deny.code.as_str(),
996                "retryable": deny.code.should_retry(),
997                "fatal": !deny.code.should_retry() && !deny.code.should_refresh_token(),
998            })
999            .to_string(),
1000        )))
1001        .unwrap()
1002}
1003
1004#[allow(clippy::result_large_err)]
1005async fn authorize_http_request(
1006    remote_addr: SocketAddr,
1007    req: &Request<hyper::body::Incoming>,
1008    auth_plugin: Option<&Arc<dyn WebSocketAuthPlugin>>,
1009    limit_state: &HttpLimitState,
1010    required_scope: Option<&str>,
1011    enforce_read_limits: bool,
1012    solana_gateway_target_id: Option<&str>,
1013) -> std::result::Result<Option<crate::websocket::auth::AuthContext>, Response<Full<Bytes>>> {
1014    let Some(plugin) = auth_plugin else {
1015        return Ok(None);
1016    };
1017
1018    let mut auth_request = ConnectionAuthRequest::from_http_request(remote_addr, req);
1019    // HTTP reads are bearer-only. Do not allow query-param session tokens here.
1020    auth_request.query = None;
1021    let decision = plugin.authorize(&auth_request).await;
1022    let context = match decision {
1023        AuthDecision::Allow(context) => context,
1024        AuthDecision::Deny(deny) => return Err(auth_deny_response(&deny)),
1025    };
1026
1027    if let Some(target_id) = solana_gateway_target_id {
1028        if let Err(error) =
1029            arete_auth::SolanaGatewayAuthorization::validate_target(&context, target_id)
1030        {
1031            return Err(Response::builder()
1032                .status(StatusCode::FORBIDDEN)
1033                .header("Content-Type", "application/json")
1034                .header("X-Error-Code", "invalid_gateway_target")
1035                .body(Full::new(Bytes::from(
1036                    json!({
1037                        "error": "invalid_gateway_target",
1038                        "message": error.to_string(),
1039                        "code": "invalid_gateway_target",
1040                        "retryable": false,
1041                        "fatal": true
1042                    })
1043                    .to_string(),
1044                )))
1045                .expect("valid gateway target error response"));
1046        }
1047    }
1048
1049    if let Some(required_scope) = required_scope {
1050        if !context.has_scope(required_scope) {
1051            return Err(json_response(
1052                StatusCode::FORBIDDEN,
1053                json!({
1054                    "error": "insufficient_scope",
1055                    "message": format!("Required scope: {required_scope}"),
1056                    "code": "insufficient_scope",
1057                    "retryable": false,
1058                    "fatal": true
1059                }),
1060            ));
1061        }
1062    }
1063
1064    if enforce_read_limits {
1065        enforce_http_limits(&context, limit_state).map_err(|deny| auth_deny_response(&deny))?;
1066    }
1067    Ok(Some(context))
1068}
1069
1070fn enforce_http_limits(
1071    context: &crate::websocket::auth::AuthContext,
1072    limit_state: &HttpLimitState,
1073) -> std::result::Result<(), Box<AuthDeny>> {
1074    enforce_http_limits_weighted(context, limit_state, 1)
1075}
1076
1077/// Charge `weight` requests against the session's per-minute HTTP limit, all or nothing.
1078fn enforce_http_limits_weighted(
1079    context: &crate::websocket::auth::AuthContext,
1080    limit_state: &HttpLimitState,
1081    weight: usize,
1082) -> std::result::Result<(), Box<AuthDeny>> {
1083    let Some(limit) = context.limits.max_http_requests_per_minute else {
1084        return Ok(());
1085    };
1086    if weight == 0 {
1087        return Ok(());
1088    }
1089    let weight = u32::try_from(weight).unwrap_or(u32::MAX);
1090
1091    let now_bucket = SystemTime::now()
1092        .duration_since(UNIX_EPOCH)
1093        .unwrap_or(Duration::from_secs(0))
1094        .as_secs()
1095        / 60;
1096    let key = format!("{}:{}", context.subject, context.metering_key);
1097    let mut entry = limit_state
1098        .per_subject_per_minute
1099        .entry(key)
1100        .or_insert((now_bucket, 0));
1101    if entry.0 != now_bucket {
1102        *entry = (now_bucket, 0);
1103    }
1104    if entry.1.saturating_add(weight) > limit {
1105        return Err(Box::new(AuthDeny::rate_limited(
1106            Duration::from_secs(60),
1107            "http reads",
1108        )));
1109    }
1110    entry.1 += weight;
1111    Ok(())
1112}
1113
1114/// How much request body a handler may read, and for how long. Attached to every request by
1115/// [`handle_request`].
1116#[derive(Clone, Copy, Debug)]
1117struct BodyLimits {
1118    max_bytes: usize,
1119    read_timeout: Duration,
1120}
1121
1122impl Default for BodyLimits {
1123    fn default() -> Self {
1124        let limits = HttpLimitsConfig::default();
1125        Self {
1126            max_bytes: limits.max_body_bytes,
1127            read_timeout: limits.body_read_timeout,
1128        }
1129    }
1130}
1131
1132fn body_too_large_response() -> Response<Full<Bytes>> {
1133    error_response(
1134        StatusCode::PAYLOAD_TOO_LARGE,
1135        "Request body exceeds the endpoint limit",
1136    )
1137}
1138
1139fn body_timeout_response() -> Response<Full<Bytes>> {
1140    error_response(
1141        StatusCode::REQUEST_TIMEOUT,
1142        "Request body was not received in time",
1143    )
1144}
1145
1146/// Read and parse a JSON body within the request's [`BodyLimits`]: `413` past the size limit,
1147/// `408` past the read deadline, `400` for anything that is not the expected JSON.
1148#[allow(clippy::result_large_err)]
1149async fn read_json_body<T: for<'de> Deserialize<'de>>(
1150    req: Request<hyper::body::Incoming>,
1151) -> std::result::Result<T, Response<Full<Bytes>>> {
1152    let limits = req
1153        .extensions()
1154        .get::<BodyLimits>()
1155        .copied()
1156        .unwrap_or_default();
1157    if req
1158        .headers()
1159        .get(hyper::header::CONTENT_LENGTH)
1160        .and_then(|value| value.to_str().ok())
1161        .and_then(|value| value.parse::<u64>().ok())
1162        .is_some_and(|length| length > limits.max_bytes as u64)
1163    {
1164        return Err(body_too_large_response());
1165    }
1166    let body = http_body_util::Limited::new(req.into_body(), limits.max_bytes);
1167    let collected = match tokio::time::timeout(limits.read_timeout, body.collect()).await {
1168        Ok(Ok(collected)) => collected,
1169        Ok(Err(error)) if error.is::<http_body_util::LengthLimitError>() => {
1170            return Err(body_too_large_response())
1171        }
1172        Ok(Err(_)) => {
1173            return Err(error_response(
1174                StatusCode::BAD_REQUEST,
1175                "Request body could not be read",
1176            ))
1177        }
1178        Err(_) => return Err(body_timeout_response()),
1179    };
1180    serde_json::from_slice::<T>(&collected.to_bytes())
1181        .map_err(|err| error_response(StatusCode::BAD_REQUEST, err.to_string()))
1182}
1183
1184#[derive(Clone, Copy)]
1185enum ChainUsageOperation {
1186    Exists,
1187    Lamports,
1188    NativeBalance,
1189    RentExemption,
1190    Clock,
1191    Account,
1192    Mint,
1193    TokenAccount,
1194    Accounts,
1195    Balances,
1196    OwnerTokenAccounts,
1197    ContextualAccounts,
1198}
1199
1200impl ChainUsageOperation {
1201    fn from_request(method: &Method, path: &str) -> Option<Self> {
1202        match (method.as_str(), path) {
1203            ("GET", path) if path.starts_with("/chain/exists/") => Some(Self::Exists),
1204            ("GET", path) if path.starts_with("/chain/lamports/") => Some(Self::Lamports),
1205            ("POST", "/chain/native-balance") => Some(Self::NativeBalance),
1206            ("GET", path) if path.starts_with("/chain/rent-exemption/") => {
1207                Some(Self::RentExemption)
1208            }
1209            ("GET", "/chain/clock") => Some(Self::Clock),
1210            ("GET", path) if path.starts_with("/chain/accounts/") => Some(Self::Account),
1211            ("GET", path) if path.starts_with("/chain/mints/") => Some(Self::Mint),
1212            ("GET", path) if path.starts_with("/chain/token-accounts/") => Some(Self::TokenAccount),
1213            ("POST", "/chain/accounts") => Some(Self::Accounts),
1214            ("POST", "/chain/v1/account") => Some(Self::Account),
1215            ("POST", "/chain/v1/accounts") => Some(Self::ContextualAccounts),
1216            ("POST", "/chain/v1/owner-token-accounts") => Some(Self::OwnerTokenAccounts),
1217            ("POST", "/chain/balances") => Some(Self::Balances),
1218            _ => None,
1219        }
1220    }
1221
1222    fn name(self) -> &'static str {
1223        match self {
1224            Self::Exists => "exists",
1225            Self::Lamports => "lamports",
1226            Self::NativeBalance => "native_balance",
1227            Self::RentExemption => "rent_exemption",
1228            Self::Clock => "clock",
1229            Self::Account => "account",
1230            Self::Mint => "mint",
1231            Self::TokenAccount => "token_account",
1232            Self::Accounts => "accounts",
1233            Self::Balances => "balances",
1234            Self::OwnerTokenAccounts => "owner_token_accounts",
1235            Self::ContextualAccounts => "contextual_accounts",
1236        }
1237    }
1238
1239    fn address_count(self, status: StatusCode, body: &[u8]) -> u64 {
1240        if !status.is_success() {
1241            return 0;
1242        }
1243        match self {
1244            Self::RentExemption | Self::Clock => 0,
1245            Self::Balances => 2,
1246            Self::ContextualAccounts | Self::OwnerTokenAccounts => {
1247                serde_json::from_slice::<Value>(body)
1248                    .ok()
1249                    .and_then(|value| {
1250                        value
1251                            .get(if matches!(self, Self::ContextualAccounts) {
1252                                "value"
1253                            } else {
1254                                "items"
1255                            })?
1256                            .as_array()
1257                            .map(|items| items.len() as u64)
1258                    })
1259                    .unwrap_or_default()
1260            }
1261            Self::Accounts => serde_json::from_slice::<Value>(body)
1262                .ok()
1263                .and_then(|value| value.get("items")?.as_array().map(|items| items.len()))
1264                .and_then(|count| count.try_into().ok())
1265                .unwrap_or_default(),
1266            _ => 1,
1267        }
1268    }
1269}
1270
1271struct ChainRequestServices<'a> {
1272    rpc_url: Arc<Option<String>>,
1273    rpc_client: ReadRpcClient,
1274    auth_context: Option<crate::websocket::auth::AuthContext>,
1275    usage_observer: Option<&'a Arc<dyn SolanaGatewayUsageObserver>>,
1276    solana_gateway_target_id: Option<&'a str>,
1277    provider: Option<&'a Arc<dyn OwnerTokenAccountsProvider>>,
1278}
1279
1280async fn handle_chain_request(
1281    req: Request<hyper::body::Incoming>,
1282    path: &str,
1283    services: ChainRequestServices<'_>,
1284) -> Response<Full<Bytes>> {
1285    let ChainRequestServices {
1286        rpc_url,
1287        rpc_client,
1288        auth_context,
1289        usage_observer,
1290        solana_gateway_target_id,
1291        provider,
1292    } = services;
1293    let operation = ChainUsageOperation::from_request(req.method(), path);
1294    let response = handle_chain_request_inner(
1295        req,
1296        path,
1297        rpc_url,
1298        rpc_client,
1299        auth_context.clone(),
1300        provider,
1301    )
1302    .await;
1303    let status = response.status();
1304    let (parts, body) = response.into_parts();
1305    let collected = match body.collect().await {
1306        Ok(collected) => collected,
1307        Err(error) => match error {},
1308    };
1309    let bytes = collected.to_bytes();
1310    if let (Some(operation), Some(observer), Some(target_id)) =
1311        (operation, usage_observer, solana_gateway_target_id)
1312    {
1313        observer
1314            .observe(SolanaGatewayUsageObservation {
1315                target_id: target_id.to_string(),
1316                auth_context,
1317                surface: SolanaGatewayUsageSurface::ChainRead,
1318                operation: operation.name(),
1319                result: if status.is_success() { "ok" } else { "error" },
1320                address_count: operation.address_count(status, &bytes),
1321                response_bytes: bytes.len().try_into().unwrap_or(u64::MAX),
1322            })
1323            .await;
1324    }
1325    Response::from_parts(parts, Full::new(bytes))
1326}
1327
1328async fn handle_chain_request_inner(
1329    req: Request<hyper::body::Incoming>,
1330    path: &str,
1331    rpc_url: Arc<Option<String>>,
1332    rpc_client: ReadRpcClient,
1333    auth_context: Option<crate::websocket::auth::AuthContext>,
1334    provider: Option<&Arc<dyn OwnerTokenAccountsProvider>>,
1335) -> Response<Full<Bytes>> {
1336    if req.method() == Method::POST && path == "/chain/v1/owner-token-accounts" {
1337        return handle_owner_token_accounts(req, provider, auth_context.as_ref()).await;
1338    }
1339    if req.method() == Method::POST && matches!(path, "/chain/v1/account" | "/chain/v1/accounts") {
1340        return handle_contextual_accounts(
1341            req,
1342            path,
1343            rpc_url.as_ref().as_deref(),
1344            &rpc_client,
1345            auth_context.as_ref(),
1346        )
1347        .await;
1348    }
1349    let Some(rpc_url) = rpc_url.as_ref() else {
1350        return error_response(
1351            StatusCode::SERVICE_UNAVAILABLE,
1352            "No RPC URL configured for chain reads",
1353        );
1354    };
1355
1356    match (req.method().as_str(), path) {
1357        ("GET", path) if path.starts_with("/chain/exists/") => {
1358            let address = path.trim_start_matches("/chain/exists/");
1359            match rpc_get_account_info(&rpc_client, rpc_url, address).await {
1360                Ok(value) => json_response(StatusCode::OK, json!({ "exists": !value.is_null() })),
1361                Err(err) => upstream_error_response(&err),
1362            }
1363        }
1364        ("GET", path) if path.starts_with("/chain/lamports/") => {
1365            let address = path.trim_start_matches("/chain/lamports/");
1366            match rpc_call(
1367                &rpc_client,
1368                rpc_url,
1369                "getBalance",
1370                json!([address, { "commitment": "confirmed" }]),
1371            )
1372            .await
1373            {
1374                Ok(value) => json_response(
1375                    StatusCode::OK,
1376                    json!({ "lamports": value.pointer("/value").and_then(Value::as_u64).unwrap_or(0) }),
1377                ),
1378                Err(err) => upstream_error_response(&err),
1379            }
1380        }
1381        ("POST", "/chain/native-balance") => match read_json_body::<NativeBalanceBody>(req).await {
1382            Ok(body) => {
1383                let min_context_slot =
1384                    match parse_min_context_slot(body.min_context_slot.as_deref()) {
1385                        Ok(slot) => slot,
1386                        Err(message) => return error_response(StatusCode::BAD_REQUEST, message),
1387                    };
1388                match rpc_get_native_balance(&rpc_client, rpc_url, &body.address, min_context_slot)
1389                    .await
1390                {
1391                    Ok(balance) => json_response(StatusCode::OK, balance),
1392                    Err(err) => upstream_error_response(&err),
1393                }
1394            }
1395            Err(response) => response,
1396        },
1397        ("GET", path) if path.starts_with("/chain/rent-exemption/") => {
1398            let raw_space = path.trim_start_matches("/chain/rent-exemption/");
1399            let Ok(space) = raw_space.parse::<u64>() else {
1400                return error_response(
1401                    StatusCode::BAD_REQUEST,
1402                    "rent-exemption space must be an integer",
1403                );
1404            };
1405            match rpc_call(
1406                &rpc_client,
1407                rpc_url,
1408                "getMinimumBalanceForRentExemption",
1409                json!([space, { "commitment": "confirmed" }]),
1410            )
1411            .await
1412            {
1413                Ok(value) => json_response(
1414                    StatusCode::OK,
1415                    json!({ "lamports": value.as_u64().unwrap_or(0) }),
1416                ),
1417                Err(err) => upstream_error_response(&err),
1418            }
1419        }
1420        ("GET", "/chain/clock") => {
1421            let slot = rpc_call(
1422                &rpc_client,
1423                rpc_url,
1424                "getSlot",
1425                json!([{ "commitment": "confirmed" }]),
1426            )
1427            .await;
1428            let epoch_info = rpc_call(
1429                &rpc_client,
1430                rpc_url,
1431                "getEpochInfo",
1432                json!([{ "commitment": "confirmed" }]),
1433            )
1434            .await;
1435            match (slot, epoch_info) {
1436                (Ok(slot_value), Ok(epoch_value)) => {
1437                    let slot_num = slot_value.as_u64().unwrap_or(0);
1438                    let unix_timestamp =
1439                        rpc_call(&rpc_client, rpc_url, "getBlockTime", json!([slot_num]))
1440                            .await
1441                            .ok()
1442                            .and_then(|value| value.as_i64())
1443                            .unwrap_or_default();
1444                    json_response(
1445                        StatusCode::OK,
1446                        json!({
1447                            "slot": slot_num,
1448                            "epoch": epoch_value.get("epoch").and_then(Value::as_u64),
1449                            "leaderScheduleEpoch": epoch_value.get("leaderScheduleSlotOffset").and_then(Value::as_u64),
1450                            "unixTimestamp": unix_timestamp,
1451                        }),
1452                    )
1453                }
1454                (Err(err), _) | (_, Err(err)) => upstream_error_response(&err),
1455            }
1456        }
1457        ("GET", path) if path.starts_with("/chain/accounts/") => {
1458            let address = path.trim_start_matches("/chain/accounts/");
1459            match rpc_get_account_info(&rpc_client, rpc_url, address).await {
1460                Ok(value) if value.is_null() => json_response(StatusCode::OK, Value::Null),
1461                Ok(value) => json_response(StatusCode::OK, raw_account_json(address, &value)),
1462                Err(err) => upstream_error_response(&err),
1463            }
1464        }
1465        ("GET", path) if path.starts_with("/chain/mints/") => {
1466            let address = path.trim_start_matches("/chain/mints/");
1467            match rpc_get_parsed_account_info(&rpc_client, rpc_url, address).await {
1468                Ok(Some(value)) => json_response(StatusCode::OK, mint_info_json(address, &value)),
1469                Ok(None) => json_response(StatusCode::OK, Value::Null),
1470                Err(err) => upstream_error_response(&err),
1471            }
1472        }
1473        ("GET", path) if path.starts_with("/chain/token-accounts/") => {
1474            let address = path.trim_start_matches("/chain/token-accounts/");
1475            match rpc_get_parsed_account_info(&rpc_client, rpc_url, address).await {
1476                Ok(Some(value)) => {
1477                    json_response(StatusCode::OK, token_account_json(address, &value))
1478                }
1479                Ok(None) => json_response(StatusCode::OK, Value::Null),
1480                Err(err) => upstream_error_response(&err),
1481            }
1482        }
1483        ("POST", "/chain/accounts") => match read_json_body::<AccountsBody>(req).await {
1484            Ok(body) => {
1485                let configured_limit =
1486                    batch_address_limit(auth_context.as_ref(), MAX_CHAIN_BATCH_ADDRESSES);
1487                if body.addresses.len() > configured_limit {
1488                    return error_response(
1489                        StatusCode::BAD_REQUEST,
1490                        format!(
1491                            "addresses exceeds the {configured_limit}-address limit for one batch"
1492                        ),
1493                    );
1494                }
1495                if body.addresses.is_empty() {
1496                    return json_response(StatusCode::OK, json!({ "items": [] }));
1497                }
1498                match rpc_get_multiple_accounts(&rpc_client, rpc_url, &body.addresses).await {
1499                    // A short array would silently misalign items with addresses, so treat a
1500                    // length mismatch as an upstream fault rather than padding it.
1501                    Ok(values) if values.len() == body.addresses.len() => json_response(
1502                        StatusCode::OK,
1503                        json!({ "items": batch_accounts_json(&body.addresses, &values) }),
1504                    ),
1505                    Ok(values) => error_response(
1506                        StatusCode::BAD_GATEWAY,
1507                        format!(
1508                            "getMultipleAccounts returned {} entries for {} addresses",
1509                            values.len(),
1510                            body.addresses.len()
1511                        ),
1512                    ),
1513                    Err(err) => upstream_error_response(&err),
1514                }
1515            }
1516            Err(response) => response,
1517        },
1518        ("POST", "/chain/balances") => match read_json_body::<BalanceBody>(req).await {
1519            Ok(body) => {
1520                let min_context_slot =
1521                    match parse_min_context_slot(body.min_context_slot.as_deref()) {
1522                        Ok(slot) => slot,
1523                        Err(message) => return error_response(StatusCode::BAD_REQUEST, message),
1524                    };
1525                match rpc_get_token_balance(
1526                    &rpc_client,
1527                    rpc_url,
1528                    &body.owner,
1529                    &body.mint,
1530                    body.token_program.as_deref(),
1531                    min_context_slot,
1532                )
1533                .await
1534                {
1535                    Ok(balance) => json_response(StatusCode::OK, balance),
1536                    Err(err) => upstream_error_response(&err),
1537                }
1538            }
1539            Err(response) => response,
1540        },
1541        _ => error_response(StatusCode::NOT_FOUND, "Not Found"),
1542    }
1543}
1544
1545async fn handle_program_account_request(
1546    req: Request<hyper::body::Incoming>,
1547    path: &str,
1548    rpc_url: Arc<Option<String>>,
1549    rpc_client: ReadRpcClient,
1550    program_runtime_catalog: Arc<ProgramRuntimeCatalog>,
1551    auth_context: Option<crate::websocket::auth::AuthContext>,
1552    program_read_binding_target_id: Arc<Option<String>>,
1553) -> Response<Full<Bytes>> {
1554    let route = match ProgramReadRoute::parse(req.method(), path) {
1555        Ok(route) => route,
1556        Err(error) => return program_read_error_response(error),
1557    };
1558    let release_hash = match route.release_hash.parse() {
1559        Ok(release_hash) => release_hash,
1560        Err(_) => return program_read_error_response(ProgramReadError::InvalidReleaseHash),
1561    };
1562    let Some(definition) = program_runtime_catalog.get(&release_hash).cloned() else {
1563        return program_read_error_response(ProgramReadError::ReleaseNotFound);
1564    };
1565    if let Err(error) = authorize_program_read(
1566        auth_context.as_ref(),
1567        program_read_binding_target_id.as_deref(),
1568        &definition,
1569    ) {
1570        return with_release_metadata(program_read_error_response(error), &definition, None, None);
1571    }
1572    if matches!(route.operation, ProgramReadOperation::NativeQuery) {
1573        return with_release_metadata(
1574            capability_error(
1575                StatusCode::NOT_IMPLEMENTED,
1576                "unsupported_capability",
1577                "Native position query requires a platform service for this release/type/filter",
1578            ),
1579            &definition,
1580            None,
1581            None,
1582        );
1583    }
1584    let Some(rpc_url) = rpc_url.as_ref() else {
1585        return with_release_metadata(
1586            program_read_error_response(ProgramReadError::RpcNotConfigured),
1587            &definition,
1588            None,
1589            None,
1590        );
1591    };
1592
1593    match route.operation {
1594        ProgramReadOperation::NativeQuery => unreachable!("handled before RPC admission"),
1595        ProgramReadOperation::ContextualFetch { address } => {
1596            let options = match read_json_body::<ProgramContextOptionsBody>(req).await {
1597                Ok(body) => body.options,
1598                Err(response) if response.status() != StatusCode::BAD_REQUEST => return response,
1599                Err(_) => return program_read_error_response(ProgramReadError::InvalidRequest),
1600            };
1601            handle_contextual_program_accounts(
1602                &definition,
1603                &route.account,
1604                ContextualProgramRead {
1605                    addresses: vec![address],
1606                    single: true,
1607                    options,
1608                },
1609                rpc_url,
1610                &rpc_client,
1611                auth_context.as_ref(),
1612            )
1613            .await
1614        }
1615        ProgramReadOperation::ContextualBatch => {
1616            let body = match read_json_body::<ContextualAccountsBody>(req).await {
1617                Ok(body) => body,
1618                Err(response) if response.status() != StatusCode::BAD_REQUEST => return response,
1619                Err(_) => return program_read_error_response(ProgramReadError::InvalidRequest),
1620            };
1621            handle_contextual_program_accounts(
1622                &definition,
1623                &route.account,
1624                ContextualProgramRead {
1625                    addresses: body.addresses,
1626                    single: false,
1627                    options: body.options,
1628                },
1629                rpc_url,
1630                &rpc_client,
1631                auth_context.as_ref(),
1632            )
1633            .await
1634        }
1635        ProgramReadOperation::Fetch { address } => {
1636            let account_value = match rpc_get_account_info(&rpc_client, rpc_url, &address).await {
1637                Ok(value) => value,
1638                Err(error) => {
1639                    error!("Program account RPC read failed: {}", error);
1640                    return with_release_metadata(
1641                        program_read_error_response(ProgramReadError::RpcFailed),
1642                        &definition,
1643                        Some(&address),
1644                        None,
1645                    );
1646                }
1647            };
1648            match decode_release_account(&definition, &route.account, &account_value).await {
1649                AccountReadOutcome::Missing => with_release_metadata(
1650                    json_response(StatusCode::OK, Value::Null),
1651                    &definition,
1652                    Some(&address),
1653                    Some(false),
1654                ),
1655                AccountReadOutcome::Value(value) => with_release_metadata(
1656                    json_response(StatusCode::OK, value),
1657                    &definition,
1658                    Some(&address),
1659                    Some(true),
1660                ),
1661                AccountReadOutcome::Error(error) => with_release_metadata(
1662                    program_read_error_response(error),
1663                    &definition,
1664                    Some(&address),
1665                    Some(true),
1666                ),
1667            }
1668        }
1669        ProgramReadOperation::Exists { address } => {
1670            let account_value = match rpc_get_account_info(&rpc_client, rpc_url, &address).await {
1671                Ok(value) => value,
1672                Err(error) => {
1673                    error!("Program account existence RPC read failed: {}", error);
1674                    return with_release_metadata(
1675                        program_read_error_response(ProgramReadError::RpcFailed),
1676                        &definition,
1677                        Some(&address),
1678                        None,
1679                    );
1680                }
1681            };
1682            let exists = !account_value.is_null();
1683            if exists && !account_owner_matches(&definition, &account_value) {
1684                return with_release_metadata(
1685                    program_read_error_response(ProgramReadError::AccountOwnerMismatch),
1686                    &definition,
1687                    Some(&address),
1688                    Some(true),
1689                );
1690            }
1691            with_release_metadata(
1692                json_response(StatusCode::OK, json!({ "exists": exists })),
1693                &definition,
1694                Some(&address),
1695                Some(exists),
1696            )
1697        }
1698        ProgramReadOperation::Batch => {
1699            let body = match read_json_body::<AddressesBody>(req).await {
1700                Ok(body) => body,
1701                Err(response) if response.status() != StatusCode::BAD_REQUEST => return response,
1702                Err(_) => return program_read_error_response(ProgramReadError::InvalidRequest),
1703            };
1704            let configured_limit =
1705                batch_address_limit(auth_context.as_ref(), MAX_PROGRAM_BATCH_ADDRESSES);
1706            if body.addresses.len() > configured_limit {
1707                return with_release_metadata(
1708                    program_read_error_response(ProgramReadError::BatchLimitExceeded),
1709                    &definition,
1710                    None,
1711                    None,
1712                );
1713            }
1714            if body.addresses.is_empty() {
1715                return with_release_metadata(
1716                    json_response(StatusCode::OK, json!({ "items": [] })),
1717                    &definition,
1718                    None,
1719                    None,
1720                );
1721            }
1722
1723            let values =
1724                match rpc_get_multiple_accounts(&rpc_client, rpc_url, &body.addresses).await {
1725                    Ok(values) if values.len() == body.addresses.len() => values,
1726                    Ok(_) => {
1727                        return with_release_metadata(
1728                            program_read_error_response(ProgramReadError::RpcResponseInvalid),
1729                            &definition,
1730                            None,
1731                            None,
1732                        )
1733                    }
1734                    Err(error) => {
1735                        error!("Program account batch RPC read failed: {}", error);
1736                        return with_release_metadata(
1737                            program_read_error_response(ProgramReadError::RpcFailed),
1738                            &definition,
1739                            None,
1740                            None,
1741                        );
1742                    }
1743                };
1744
1745            let outcomes = futures_util::future::join_all(
1746                values
1747                    .iter()
1748                    .map(|value| decode_release_account(&definition, &route.account, value)),
1749            )
1750            .await;
1751            let mut items = Vec::with_capacity(outcomes.len());
1752            for (address, outcome) in body.addresses.iter().zip(outcomes) {
1753                let item = match outcome {
1754                    AccountReadOutcome::Missing => {
1755                        json!({ "address": address, "status": "missing" })
1756                    }
1757                    AccountReadOutcome::Value(value) => {
1758                        json!({ "address": address, "status": "ok", "value": value })
1759                    }
1760                    AccountReadOutcome::Error(error) => json!({
1761                        "address": address,
1762                        "status": "error",
1763                        "error": { "code": error.code() }
1764                    }),
1765                };
1766                items.push(item);
1767            }
1768            with_release_metadata(
1769                json_response(StatusCode::OK, json!({ "items": items })),
1770                &definition,
1771                None,
1772                None,
1773            )
1774        }
1775    }
1776}
1777
1778const MAX_PROGRAM_BATCH_ADDRESSES: usize = 100;
1779#[derive(Deserialize)]
1780#[serde(deny_unknown_fields)]
1781struct ProgramContextOptionsBody {
1782    #[serde(default)]
1783    options: ReadOptions,
1784}
1785
1786struct ContextualProgramRead {
1787    addresses: Vec<String>,
1788    single: bool,
1789    options: ReadOptions,
1790}
1791
1792async fn handle_contextual_program_accounts(
1793    definition: &ProgramRuntimeDefinition,
1794    account: &str,
1795    read: ContextualProgramRead,
1796    rpc_url: &str,
1797    client: &ReadRpcClient,
1798    auth: Option<&crate::websocket::auth::AuthContext>,
1799) -> Response<Full<Bytes>> {
1800    let ContextualProgramRead {
1801        addresses,
1802        single,
1803        options,
1804    } = read;
1805    if addresses.len() > batch_address_limit(auth, MAX_PROGRAM_BATCH_ADDRESSES) {
1806        return with_release_metadata(
1807            program_read_error_response(ProgramReadError::BatchLimitExceeded),
1808            definition,
1809            None,
1810            None,
1811        );
1812    }
1813    for address in &addresses {
1814        if arete_solana_contracts::validate_address(address).is_err() {
1815            return program_read_error_response(ProgramReadError::InvalidRequest);
1816        }
1817    }
1818    if addresses.is_empty() {
1819        return with_release_metadata(
1820            json_response(
1821                StatusCode::OK,
1822                json!({ "context": null, "value": { "items": [] } }),
1823            ),
1824            definition,
1825            None,
1826            None,
1827        );
1828    }
1829    let method = if single {
1830        "getAccountInfo"
1831    } else {
1832        "getMultipleAccounts"
1833    };
1834    let input = if single {
1835        json!(addresses[0])
1836    } else {
1837        json!(addresses)
1838    };
1839    let result = match rpc_call(
1840        client,
1841        rpc_url,
1842        method,
1843        json!([input, options.rpc_config()]),
1844    )
1845    .await
1846    {
1847        Ok(result) => result,
1848        Err(_) => {
1849            return with_release_metadata(
1850                program_read_error_response(ProgramReadError::RpcFailed),
1851                definition,
1852                None,
1853                None,
1854            )
1855        }
1856    };
1857    let context = match result.pointer("/context/slot").and_then(Value::as_u64) {
1858        Some(slot) if options.min_context_slot.is_none_or(|min| slot >= min) => {
1859            ReadContext { slot }
1860        }
1861        _ => {
1862            return with_release_metadata(
1863                program_read_error_response(ProgramReadError::RpcResponseInvalid),
1864                definition,
1865                None,
1866                None,
1867            )
1868        }
1869    };
1870    let values = match result.get("value") {
1871        Some(value) if single => vec![value.clone()],
1872        Some(Value::Array(values)) if values.len() == addresses.len() => values.clone(),
1873        _ => {
1874            return with_release_metadata(
1875                program_read_error_response(ProgramReadError::RpcResponseInvalid),
1876                definition,
1877                None,
1878                None,
1879            )
1880        }
1881    };
1882    let outcomes = futures_util::future::join_all(
1883        values
1884            .iter()
1885            .map(|value| decode_release_account(definition, account, value)),
1886    )
1887    .await;
1888    let mut items = Vec::new();
1889    for (address, outcome) in addresses.iter().zip(outcomes) {
1890        if single {
1891            let (value, exists) = match outcome {
1892                AccountReadOutcome::Missing => (Value::Null, false),
1893                AccountReadOutcome::Value(value) => (value, true),
1894                AccountReadOutcome::Error(error) => {
1895                    return with_release_metadata(
1896                        program_read_error_response(error),
1897                        definition,
1898                        Some(address),
1899                        Some(true),
1900                    )
1901                }
1902            };
1903            return with_release_metadata(
1904                json_response(
1905                    StatusCode::OK,
1906                    json!({ "context": context, "value": value }),
1907                ),
1908                definition,
1909                Some(address),
1910                Some(exists),
1911            );
1912        }
1913        items.push(match outcome {
1914            AccountReadOutcome::Missing => json!({ "address": address, "status": "missing" }),
1915            AccountReadOutcome::Value(value) => {
1916                json!({ "address": address, "status": "ok", "value": value })
1917            }
1918            AccountReadOutcome::Error(error) => {
1919                json!({ "address": address, "status": "error", "error": { "code": error.code() } })
1920            }
1921        });
1922    }
1923    with_release_metadata(
1924        json_response(
1925            StatusCode::OK,
1926            json!({ "context": context, "value": { "items": items } }),
1927        ),
1928        definition,
1929        None,
1930        None,
1931    )
1932}
1933
1934const MAX_PROGRAM_ACCOUNT_BYTES: usize = 10 * 1024 * 1024;
1935const PROGRAM_DECODE_TIMEOUT: Duration = Duration::from_secs(2);
1936
1937fn authorize_program_read(
1938    auth_context: Option<&crate::websocket::auth::AuthContext>,
1939    expected_target_id: Option<&str>,
1940    definition: &ProgramRuntimeDefinition,
1941) -> std::result::Result<(), ProgramReadError> {
1942    let Some(context) = auth_context else {
1943        return Ok(());
1944    };
1945    let Some(expected_target_id) = expected_target_id.filter(|target_id| !target_id.is_empty())
1946    else {
1947        return Err(ProgramReadError::AuthorizationNotConfigured);
1948    };
1949
1950    arete_auth::ProgramReadAuthorization::try_from_context(
1951        context,
1952        expected_target_id,
1953        &definition.program_id,
1954        &definition.program_release_hash.to_string(),
1955    )
1956    .map(|_| ())
1957    .map_err(|_| ProgramReadError::Unauthorized)
1958}
1959
1960#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1961enum ProgramReadError {
1962    NotFound,
1963    ProgramReadsDisabled,
1964    InvalidReleaseHash,
1965    ReleaseNotFound,
1966    InvalidRequest,
1967    Unauthorized,
1968    AuthorizationNotConfigured,
1969    BatchLimitExceeded,
1970    RpcNotConfigured,
1971    RpcFailed,
1972    RpcResponseInvalid,
1973    AccountOwnerMismatch,
1974    AccountDataInvalid,
1975    AccountDataTooLarge,
1976    AccountDecodeFailed,
1977    AccountDecodeTimeout,
1978}
1979
1980impl ProgramReadError {
1981    fn code(self) -> &'static str {
1982        match self {
1983            Self::NotFound => "NOT_FOUND",
1984            Self::ProgramReadsDisabled => "PROGRAM_READS_DISABLED",
1985            Self::InvalidReleaseHash => "INVALID_PROGRAM_RELEASE_HASH",
1986            Self::ReleaseNotFound => "PROGRAM_RELEASE_NOT_FOUND",
1987            Self::InvalidRequest => "INVALID_REQUEST",
1988            Self::Unauthorized => "PROGRAM_READ_UNAUTHORIZED",
1989            Self::AuthorizationNotConfigured => "PROGRAM_READ_AUTH_NOT_CONFIGURED",
1990            Self::BatchLimitExceeded => "BATCH_LIMIT_EXCEEDED",
1991            Self::RpcNotConfigured => "READ_RPC_NOT_CONFIGURED",
1992            Self::RpcFailed => "RPC_REQUEST_FAILED",
1993            Self::RpcResponseInvalid => "RPC_RESPONSE_INVALID",
1994            Self::AccountOwnerMismatch => "ACCOUNT_OWNER_MISMATCH",
1995            Self::AccountDataInvalid => "ACCOUNT_DATA_INVALID",
1996            Self::AccountDataTooLarge => "ACCOUNT_DATA_TOO_LARGE",
1997            Self::AccountDecodeFailed => "ACCOUNT_DECODE_FAILED",
1998            Self::AccountDecodeTimeout => "ACCOUNT_DECODE_TIMEOUT",
1999        }
2000    }
2001
2002    fn status(self) -> StatusCode {
2003        match self {
2004            Self::NotFound | Self::ReleaseNotFound => StatusCode::NOT_FOUND,
2005            Self::InvalidReleaseHash | Self::InvalidRequest => StatusCode::BAD_REQUEST,
2006            Self::Unauthorized => StatusCode::FORBIDDEN,
2007            Self::AuthorizationNotConfigured => StatusCode::SERVICE_UNAVAILABLE,
2008            Self::BatchLimitExceeded | Self::AccountDataTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
2009            Self::ProgramReadsDisabled | Self::RpcNotConfigured => StatusCode::SERVICE_UNAVAILABLE,
2010            Self::RpcFailed | Self::RpcResponseInvalid | Self::AccountDataInvalid => {
2011                StatusCode::BAD_GATEWAY
2012            }
2013            Self::AccountOwnerMismatch | Self::AccountDecodeFailed => {
2014                StatusCode::UNPROCESSABLE_ENTITY
2015            }
2016            Self::AccountDecodeTimeout => StatusCode::GATEWAY_TIMEOUT,
2017        }
2018    }
2019}
2020
2021fn program_read_error_response(error: ProgramReadError) -> Response<Full<Bytes>> {
2022    Response::builder()
2023        .status(error.status())
2024        .header("Content-Type", "application/json")
2025        .header("X-Error-Code", error.code())
2026        .body(Full::new(Bytes::from(
2027            json!({ "error": { "code": error.code() } }).to_string(),
2028        )))
2029        .expect("valid program read error response")
2030}
2031
2032#[derive(Debug)]
2033struct ProgramReadRoute {
2034    release_hash: String,
2035    account: String,
2036    operation: ProgramReadOperation,
2037}
2038
2039#[derive(Debug)]
2040enum ProgramReadOperation {
2041    ContextualFetch { address: String },
2042    ContextualBatch,
2043    NativeQuery,
2044    Fetch { address: String },
2045    Batch,
2046    Exists { address: String },
2047}
2048
2049impl ProgramReadRoute {
2050    fn parse(method: &Method, path: &str) -> std::result::Result<Self, ProgramReadError> {
2051        let segments: Vec<&str> = path.trim_start_matches('/').split('/').collect();
2052        if segments.len() < 5
2053            || segments[0] != "v1"
2054            || segments[1] != "releases"
2055            || segments[2].is_empty()
2056            || segments[3] != "accounts"
2057            || segments[4].is_empty()
2058        {
2059            return Err(ProgramReadError::NotFound);
2060        }
2061        let operation = match (method, segments.as_slice()) {
2062            (&Method::POST, [_, _, _, _, _]) => ProgramReadOperation::Batch,
2063            (&Method::POST, [_, _, _, _, _, "query"]) => ProgramReadOperation::NativeQuery,
2064            (&Method::POST, [_, _, _, _, _, "context"]) => ProgramReadOperation::ContextualBatch,
2065            (&Method::POST, [_, _, _, _, _, address, "context"]) if !address.is_empty() => {
2066                ProgramReadOperation::ContextualFetch {
2067                    address: (*address).to_string(),
2068                }
2069            }
2070            (&Method::GET, [_, _, _, _, _, address]) if !address.is_empty() => {
2071                ProgramReadOperation::Fetch {
2072                    address: (*address).to_string(),
2073                }
2074            }
2075            (&Method::GET, [_, _, _, _, _, address, "exists"]) if !address.is_empty() => {
2076                ProgramReadOperation::Exists {
2077                    address: (*address).to_string(),
2078                }
2079            }
2080            _ => return Err(ProgramReadError::NotFound),
2081        };
2082        Ok(Self {
2083            release_hash: segments[2].to_string(),
2084            account: segments[4].to_string(),
2085            operation,
2086        })
2087    }
2088}
2089
2090enum AccountReadOutcome {
2091    Missing,
2092    Value(Value),
2093    Error(ProgramReadError),
2094}
2095
2096fn account_owner_matches(definition: &ProgramRuntimeDefinition, value: &Value) -> bool {
2097    value.get("owner").and_then(Value::as_str) == Some(definition.program_id.as_str())
2098}
2099
2100async fn decode_release_account(
2101    definition: &ProgramRuntimeDefinition,
2102    account: &str,
2103    value: &Value,
2104) -> AccountReadOutcome {
2105    if value.is_null() {
2106        return AccountReadOutcome::Missing;
2107    }
2108    if !account_owner_matches(definition, value) {
2109        return AccountReadOutcome::Error(ProgramReadError::AccountOwnerMismatch);
2110    }
2111    let data = match decode_account_bytes(value) {
2112        Some(data) => data,
2113        None => return AccountReadOutcome::Error(ProgramReadError::AccountDataInvalid),
2114    };
2115    if data.len() > MAX_PROGRAM_ACCOUNT_BYTES {
2116        return AccountReadOutcome::Error(ProgramReadError::AccountDataTooLarge);
2117    }
2118
2119    let reader = definition.account_reader.clone();
2120    let account = account.to_string();
2121    let decode = tokio::task::spawn_blocking(move || reader(&account, &data));
2122    match tokio::time::timeout(PROGRAM_DECODE_TIMEOUT, decode).await {
2123        Ok(Ok(Ok(value))) => AccountReadOutcome::Value(value),
2124        Ok(Ok(Err(_))) | Ok(Err(_)) => {
2125            AccountReadOutcome::Error(ProgramReadError::AccountDecodeFailed)
2126        }
2127        Err(_) => AccountReadOutcome::Error(ProgramReadError::AccountDecodeTimeout),
2128    }
2129}
2130
2131fn with_release_metadata(
2132    mut response: Response<Full<Bytes>>,
2133    definition: &ProgramRuntimeDefinition,
2134    address: Option<&str>,
2135    exists: Option<bool>,
2136) -> Response<Full<Bytes>> {
2137    let headers = response.headers_mut();
2138    if let Ok(value) = HeaderValue::from_str(&definition.program_release_hash.to_string()) {
2139        headers.insert("X-Arete-Program-Release-Hash", value);
2140    }
2141    if let Ok(value) = HeaderValue::from_str(&definition.idl_content_hash.to_string()) {
2142        headers.insert("X-Arete-Idl-Content-Hash", value);
2143    }
2144    if let Some(address) = address {
2145        if let Ok(value) = HeaderValue::from_str(address) {
2146            headers.insert("X-Arete-Account-Address", value);
2147        }
2148    }
2149    if let Some(exists) = exists {
2150        headers.insert(
2151            "X-Arete-Account-Exists",
2152            HeaderValue::from_static(if exists { "true" } else { "false" }),
2153        );
2154    }
2155    response
2156}
2157
2158/// The read RPC client and the largest response body it accepts. Connect and total timeouts
2159/// are set on the client itself.
2160#[derive(Clone)]
2161struct ReadRpcClient {
2162    client: Client,
2163    max_response_bytes: usize,
2164}
2165
2166impl std::ops::Deref for ReadRpcClient {
2167    type Target = Client;
2168
2169    fn deref(&self) -> &Client {
2170        &self.client
2171    }
2172}
2173
2174/// Read a response body, failing once it passes `max_bytes` instead of buffering the rest.
2175async fn read_capped_response(
2176    response: reqwest::Response,
2177    max_bytes: usize,
2178) -> std::result::Result<Vec<u8>, UpstreamRpcError> {
2179    let too_large = || UpstreamRpcError(format!("response exceeds {max_bytes} bytes"));
2180    if response
2181        .content_length()
2182        .is_some_and(|length| length > max_bytes as u64)
2183    {
2184        return Err(too_large());
2185    }
2186    let mut stream = response.bytes_stream();
2187    let mut bytes = Vec::new();
2188    while let Some(chunk) = futures_util::StreamExt::next(&mut stream).await {
2189        let chunk = chunk.map_err(UpstreamRpcError::from)?;
2190        if bytes.len().saturating_add(chunk.len()) > max_bytes {
2191            return Err(too_large());
2192        }
2193        bytes.extend_from_slice(&chunk);
2194    }
2195    Ok(bytes)
2196}
2197
2198async fn rpc_call(
2199    client: &ReadRpcClient,
2200    rpc_url: &str,
2201    method: &str,
2202    params: Value,
2203) -> anyhow::Result<Value> {
2204    let response = client
2205        .post(rpc_url)
2206        .json(&json!({
2207            "jsonrpc": "2.0",
2208            "id": "arete-read",
2209            "method": method,
2210            "params": params,
2211        }))
2212        .send()
2213        .await
2214        .and_then(reqwest::Response::error_for_status)
2215        .map_err(UpstreamRpcError::from)?;
2216    let body = read_capped_response(response, client.max_response_bytes).await?;
2217    let value = serde_json::from_slice::<Value>(&body)
2218        .map_err(|error| UpstreamRpcError(format!("invalid JSON-RPC response: {error}")))?;
2219    if let Some(error) = value.get("error") {
2220        return Err(UpstreamRpcError(format!("JSON-RPC error: {error}")).into());
2221    }
2222    Ok(value.get("result").cloned().unwrap_or(Value::Null))
2223}
2224
2225fn rpc_read_config(encoding: Option<&str>, min_context_slot: Option<u64>) -> Value {
2226    let mut config = json!({ "commitment": "confirmed" });
2227    let object = config
2228        .as_object_mut()
2229        .expect("RPC read config is always an object");
2230    if let Some(encoding) = encoding {
2231        object.insert("encoding".to_string(), json!(encoding));
2232    }
2233    if let Some(min_context_slot) = min_context_slot {
2234        object.insert("minContextSlot".to_string(), json!(min_context_slot));
2235    }
2236    config
2237}
2238
2239async fn rpc_get_native_balance(
2240    client: &ReadRpcClient,
2241    rpc_url: &str,
2242    address: &str,
2243    min_context_slot: Option<u64>,
2244) -> anyhow::Result<Value> {
2245    let result = rpc_call(
2246        client,
2247        rpc_url,
2248        "getBalance",
2249        json!([address, rpc_read_config(None, min_context_slot)]),
2250    )
2251    .await?;
2252    contextual_native_balance_json(&result)
2253}
2254
2255fn contextual_native_balance_json(result: &Value) -> anyhow::Result<Value> {
2256    let lamports = result
2257        .get("value")
2258        .and_then(Value::as_u64)
2259        .ok_or_else(|| anyhow::anyhow!("getBalance response is missing a u64 value"))?;
2260    let context_slot = result
2261        .pointer("/context/slot")
2262        .and_then(Value::as_u64)
2263        .ok_or_else(|| anyhow::anyhow!("getBalance response is missing a u64 context slot"))?;
2264    Ok(json!({
2265        "lamports": lamports.to_string(),
2266        "contextSlot": context_slot.to_string(),
2267    }))
2268}
2269
2270async fn rpc_get_account_info(
2271    client: &ReadRpcClient,
2272    rpc_url: &str,
2273    address: &str,
2274) -> anyhow::Result<Value> {
2275    let result = rpc_call(
2276        client,
2277        rpc_url,
2278        "getAccountInfo",
2279        json!([address, { "encoding": "base64", "commitment": "confirmed" }]),
2280    )
2281    .await?;
2282    Ok(result.get("value").cloned().unwrap_or(Value::Null))
2283}
2284
2285async fn rpc_get_multiple_accounts(
2286    client: &ReadRpcClient,
2287    rpc_url: &str,
2288    addresses: &[String],
2289) -> anyhow::Result<Vec<Value>> {
2290    let result = rpc_call(
2291        client,
2292        rpc_url,
2293        "getMultipleAccounts",
2294        json!([addresses, { "encoding": "base64", "commitment": "confirmed" }]),
2295    )
2296    .await?;
2297    Ok(result
2298        .get("value")
2299        .and_then(Value::as_array)
2300        .cloned()
2301        .unwrap_or_default())
2302}
2303
2304async fn rpc_get_parsed_account_info(
2305    client: &ReadRpcClient,
2306    rpc_url: &str,
2307    address: &str,
2308) -> anyhow::Result<Option<Value>> {
2309    let result = rpc_call(
2310        client,
2311        rpc_url,
2312        "getAccountInfo",
2313        json!([address, { "encoding": "jsonParsed", "commitment": "confirmed" }]),
2314    )
2315    .await?;
2316    Ok(result
2317        .get("value")
2318        .cloned()
2319        .filter(|value| !value.is_null()))
2320}
2321
2322async fn rpc_get_token_balance(
2323    client: &ReadRpcClient,
2324    rpc_url: &str,
2325    owner: &str,
2326    mint: &str,
2327    token_program: Option<&str>,
2328    min_context_slot: Option<u64>,
2329) -> anyhow::Result<Value> {
2330    let filter = token_program
2331        .map(|program_id| json!({ "programId": program_id }))
2332        .unwrap_or_else(|| json!({ "mint": mint }));
2333    let result = rpc_call(
2334        client,
2335        rpc_url,
2336        "getTokenAccountsByOwner",
2337        json!([
2338            owner,
2339            filter,
2340            rpc_read_config(Some("jsonParsed"), min_context_slot)
2341        ]),
2342    )
2343    .await?;
2344
2345    contextual_token_balance_json(&result, owner, mint, token_program)
2346}
2347
2348fn contextual_token_balance_json(
2349    result: &Value,
2350    owner: &str,
2351    mint: &str,
2352    token_program: Option<&str>,
2353) -> anyhow::Result<Value> {
2354    let context_slot = result
2355        .pointer("/context/slot")
2356        .and_then(Value::as_u64)
2357        .ok_or_else(|| {
2358            anyhow::anyhow!("getTokenAccountsByOwner response is missing a u64 context slot")
2359        })?;
2360
2361    let account = result
2362        .get("value")
2363        .and_then(Value::as_array)
2364        .and_then(|items| {
2365            items.iter().find(|item| {
2366                item.pointer("/account/data/parsed/info/mint")
2367                    .and_then(Value::as_str)
2368                    == Some(mint)
2369            })
2370        })
2371        .cloned();
2372
2373    if let Some(account) = account {
2374        let pubkey = account.get("pubkey").and_then(Value::as_str);
2375        let info = account.pointer("/account/data/parsed/info");
2376        return Ok(json!({
2377            "exists": true,
2378            "address": pubkey,
2379            "owner": owner,
2380            "mint": mint,
2381            "tokenProgram": token_program,
2382            "amount": info.and_then(|value| value.pointer("/tokenAmount/amount")).and_then(Value::as_str).unwrap_or("0"),
2383            "decimals": info.and_then(|value| value.pointer("/tokenAmount/decimals")).and_then(Value::as_u64),
2384            "uiAmountString": info.and_then(|value| value.pointer("/tokenAmount/uiAmountString")).and_then(Value::as_str),
2385            "contextSlot": context_slot.to_string(),
2386        }));
2387    }
2388
2389    Ok(json!({
2390        "exists": false,
2391        "address": Value::Null,
2392        "owner": owner,
2393        "mint": mint,
2394        "tokenProgram": token_program,
2395        "amount": "0",
2396        "decimals": Value::Null,
2397        "uiAmountString": Value::Null,
2398        "contextSlot": context_slot.to_string(),
2399    }))
2400}
2401
2402fn decode_account_bytes(value: &Value) -> Option<Vec<u8>> {
2403    let data = value.get("data")?.as_array()?;
2404    let encoded = data.first()?.as_str()?;
2405    BASE64_STANDARD.decode(encoded).ok()
2406}
2407
2408/// `lamports` is a decimal string, not a JSON number.
2409///
2410/// A `u64` above 2^53 does not survive a JSON number in every client: JavaScript rounds
2411/// `9007199254740993` to `...92` during `JSON.parse`, before any SDK can intervene. The
2412/// native-balance route already answers with a string for this reason, and the batch route makes it
2413/// matter — a custody sweep reading many accounts at once is exactly where a silently rounded
2414/// balance would be acted on.
2415fn raw_account_json(address: &str, value: &Value) -> Value {
2416    json!({
2417        "address": address,
2418        "ownerProgram": value.get("owner").and_then(Value::as_str).unwrap_or_default(),
2419        "lamports": value
2420            .get("lamports")
2421            .and_then(Value::as_u64)
2422            .unwrap_or(0)
2423            .to_string(),
2424        "executable": value.get("executable").and_then(Value::as_bool).unwrap_or(false),
2425        "data": value
2426            .pointer("/data/0")
2427            .and_then(Value::as_str)
2428            .unwrap_or_default(),
2429    })
2430}
2431
2432/// How many addresses one batch may carry: the session's own ceiling, bounded by the protocol's.
2433///
2434/// Shared by every batch route rather than repeated at each one. Rate limiting charges a batch as a
2435/// single HTTP request, so a route that consulted only the protocol limit would let a session
2436/// capped at ten addresses read a hundred for the same cost. That is exactly what
2437/// `POST /chain/accounts` did while the program-account batch computed it correctly, so the two
2438/// call sites now derive it from one place and cannot drift apart again.
2439fn batch_address_limit(
2440    auth_context: Option<&crate::websocket::auth::AuthContext>,
2441    protocol_max: usize,
2442) -> usize {
2443    auth_context
2444        .and_then(|ctx| ctx.limits.max_http_batch_addresses)
2445        .map(|limit| limit as usize)
2446        .unwrap_or(protocol_max)
2447        .min(protocol_max)
2448}
2449
2450/// Solana's own `getMultipleAccounts` ceiling, so one batch is one upstream call.
2451const MAX_CHAIN_BATCH_ADDRESSES: usize = 100;
2452
2453/// Positionally aligned with `addresses`; `null` where the account is absent, matching
2454/// both `getMultipleAccounts` and the single-address route.
2455fn batch_accounts_json(addresses: &[String], values: &[Value]) -> Vec<Value> {
2456    addresses
2457        .iter()
2458        .zip(values)
2459        .map(|(address, value)| {
2460            if value.is_null() {
2461                Value::Null
2462            } else {
2463                raw_account_json(address, value)
2464            }
2465        })
2466        .collect()
2467}
2468
2469fn mint_info_json(address: &str, value: &Value) -> Value {
2470    let owner_program = value
2471        .get("owner")
2472        .and_then(Value::as_str)
2473        .unwrap_or_default();
2474    let info = value.pointer("/data/parsed/info");
2475    json!({
2476        "address": address,
2477        "ownerProgram": owner_program,
2478        "decimals": info.and_then(|v| v.get("decimals")).and_then(Value::as_u64),
2479        "supply": info.and_then(|v| v.get("supply")).and_then(Value::as_str),
2480        "mintAuthority": info.and_then(|v| v.get("mintAuthority")).and_then(Value::as_str),
2481        "freezeAuthority": info.and_then(|v| v.get("freezeAuthority")).and_then(Value::as_str),
2482    })
2483}
2484
2485fn token_account_json(address: &str, value: &Value) -> Value {
2486    let owner_program = value
2487        .get("owner")
2488        .and_then(Value::as_str)
2489        .unwrap_or_default();
2490    let info = value.pointer("/data/parsed/info");
2491    json!({
2492        "address": address,
2493        "ownerProgram": owner_program,
2494        "mint": info.and_then(|v| v.get("mint")).and_then(Value::as_str),
2495        "owner": info.and_then(|v| v.get("owner")).and_then(Value::as_str),
2496        "amount": info.and_then(|v| v.pointer("/tokenAmount/amount")).and_then(Value::as_str),
2497        "uiAmountString": info.and_then(|v| v.pointer("/tokenAmount/uiAmountString")).and_then(Value::as_str),
2498    })
2499}
2500
2501#[cfg(test)]
2502mod tests {
2503    use super::*;
2504
2505    #[test]
2506    fn cors_headers_allow_browser_sdk_reads() {
2507        let response = with_cors(
2508            Response::builder()
2509                .status(StatusCode::OK)
2510                .body(Full::new(Bytes::new()))
2511                .unwrap(),
2512        );
2513
2514        assert_eq!(response.headers()[ACCESS_CONTROL_ALLOW_ORIGIN], "*");
2515        assert_eq!(
2516            response.headers()[ACCESS_CONTROL_ALLOW_METHODS],
2517            "GET, POST, OPTIONS"
2518        );
2519        assert_eq!(
2520            response.headers()[ACCESS_CONTROL_ALLOW_HEADERS],
2521            "Authorization, Content-Type"
2522        );
2523    }
2524
2525    #[test]
2526    fn native_balance_serializes_u64_values_as_decimal_strings() {
2527        let value = contextual_native_balance_json(&json!({
2528            "context": { "slot": 9_007_199_254_740_995_u64 },
2529            "value": 9_007_199_254_740_993_u64,
2530        }))
2531        .unwrap();
2532
2533        assert_eq!(value["lamports"], "9007199254740993");
2534        assert_eq!(value["contextSlot"], "9007199254740995");
2535    }
2536
2537    /// A missing account must hold its slot, or every later address is attributed to the
2538    /// wrong account — silently, and with real money downstream.
2539    #[test]
2540    fn batch_accounts_keep_position_when_an_account_is_absent() {
2541        let addresses = vec!["a".to_string(), "b".to_string(), "c".to_string()];
2542        let values = vec![
2543            json!({ "owner": "prog", "lamports": 7u64, "executable": false, "data": ["AQI=", "base64"] }),
2544            Value::Null,
2545            json!({ "owner": "prog", "lamports": 9u64, "executable": false, "data": ["AwQ=", "base64"] }),
2546        ];
2547
2548        let items = batch_accounts_json(&addresses, &values);
2549
2550        assert_eq!(items.len(), 3);
2551        assert_eq!(items[0]["address"], "a");
2552        assert_eq!(items[0]["lamports"], "7");
2553        assert_eq!(items[0]["data"], "AQI=");
2554        assert_eq!(items[1], Value::Null, "absent account keeps its slot");
2555        assert_eq!(items[2]["address"], "c");
2556        assert_eq!(items[2]["lamports"], "9");
2557    }
2558
2559    /// The batch is where a rounded balance would be acted on, so the wire must carry a `u64`
2560    /// no JavaScript client can round. 9007199254740993 is the first integer a JSON number
2561    /// cannot represent: `JSON.parse` returns ...92.
2562    #[test]
2563    fn batch_accounts_serialize_lamports_as_decimal_strings() {
2564        let addresses = vec!["big".to_string()];
2565        let values = vec![json!({
2566            "owner": "prog",
2567            "lamports": 9_007_199_254_740_993_u64,
2568            "executable": false,
2569            "data": ["AQI=", "base64"],
2570        })];
2571
2572        let items = batch_accounts_json(&addresses, &values);
2573
2574        assert_eq!(items[0]["lamports"], "9007199254740993");
2575    }
2576
2577    fn auth_context_allowing(
2578        max_batch_addresses: Option<u32>,
2579    ) -> crate::websocket::auth::AuthContext {
2580        crate::websocket::auth::AuthContext {
2581            subject: "test".to_string(),
2582            issuer: "test-issuer".to_string(),
2583            audience: "test-audience".to_string(),
2584            key_class: arete_auth::KeyClass::Publishable,
2585            metering_key: "meter-test".to_string(),
2586            deployment_id: None,
2587            target_kind: None,
2588            target_id: None,
2589            program_id: None,
2590            program_release_hash: None,
2591            expires_at: u64::MAX,
2592            scope: "read".to_string(),
2593            limits: arete_auth::Limits {
2594                max_http_batch_addresses: max_batch_addresses,
2595                ..Default::default()
2596            },
2597            plan: None,
2598            origin: None,
2599            client_ip: None,
2600            jti: "test-jti".to_string(),
2601            actor_key: None,
2602            account_key: None,
2603            consumer_key: None,
2604            policy_version: None,
2605            account_limits: arete_auth::Limits::default(),
2606            row_scopes: None,
2607        }
2608    }
2609
2610    /// A batch is charged as one HTTP request, so a session's address ceiling is the only thing
2611    /// standing between a ten-address plan and a hundred-address read. `POST /chain/accounts`
2612    /// ignored it while the program-account batch honoured it; both now share this computation.
2613    #[test]
2614    fn a_session_ceiling_bounds_the_batch_below_the_protocol_limit() {
2615        let context = auth_context_allowing(Some(10));
2616
2617        assert_eq!(
2618            batch_address_limit(Some(&context), MAX_CHAIN_BATCH_ADDRESSES),
2619            10
2620        );
2621    }
2622
2623    /// A generous claim must not raise the protocol ceiling: one batch is still one upstream call.
2624    #[test]
2625    fn a_session_ceiling_cannot_exceed_the_protocol_limit() {
2626        let context = auth_context_allowing(Some(1_000));
2627
2628        assert_eq!(
2629            batch_address_limit(Some(&context), MAX_CHAIN_BATCH_ADDRESSES),
2630            MAX_CHAIN_BATCH_ADDRESSES
2631        );
2632    }
2633
2634    /// No claim, and no auth at all, both fall back to the protocol ceiling.
2635    #[test]
2636    fn an_absent_ceiling_falls_back_to_the_protocol_limit() {
2637        let context = auth_context_allowing(None);
2638
2639        assert_eq!(
2640            batch_address_limit(Some(&context), MAX_CHAIN_BATCH_ADDRESSES),
2641            MAX_CHAIN_BATCH_ADDRESSES
2642        );
2643        assert_eq!(
2644            batch_address_limit(None, MAX_PROGRAM_BATCH_ADDRESSES),
2645            MAX_PROGRAM_BATCH_ADDRESSES
2646        );
2647    }
2648
2649    #[test]
2650    fn batch_accounts_json_is_empty_for_no_addresses() {
2651        assert!(batch_accounts_json(&[], &[]).is_empty());
2652    }
2653
2654    /// The batch item shape must stay identical to the single-address route's, or a client
2655    /// needs two parsers for one concept.
2656    #[test]
2657    fn batch_item_matches_the_single_address_shape() {
2658        let value = json!({ "owner": "prog", "lamports": 5u64, "executable": true, "data": ["BQY=", "base64"] });
2659        let addresses = vec!["solo".to_string()];
2660
2661        assert_eq!(
2662            batch_accounts_json(&addresses, std::slice::from_ref(&value))[0],
2663            raw_account_json("solo", &value)
2664        );
2665    }
2666
2667    #[test]
2668    fn rpc_read_config_propagates_min_context_slot() {
2669        let config = rpc_read_config(Some("jsonParsed"), Some(9_007_199_254_740_997));
2670
2671        assert_eq!(config["commitment"], "confirmed");
2672        assert_eq!(config["encoding"], "jsonParsed");
2673        assert_eq!(config["minContextSlot"], 9_007_199_254_740_997_u64);
2674    }
2675
2676    #[test]
2677    fn token_balance_preserves_raw_amount_and_stringifies_context_slot() {
2678        let value = contextual_token_balance_json(
2679            &json!({
2680                "context": { "slot": 9_007_199_254_740_995_u64 },
2681                "value": [{
2682                    "pubkey": "token-account",
2683                    "account": {
2684                        "data": {
2685                            "parsed": {
2686                                "info": {
2687                                    "mint": "mint",
2688                                    "tokenAmount": {
2689                                        "amount": "18446744073709551615",
2690                                        "decimals": 9,
2691                                        "uiAmountString": "18446744073.709551615"
2692                                    }
2693                                }
2694                            }
2695                        }
2696                    }
2697                }]
2698            }),
2699            "owner",
2700            "mint",
2701            None,
2702        )
2703        .unwrap();
2704
2705        assert_eq!(value["amount"], "18446744073709551615");
2706        assert_eq!(value["contextSlot"], "9007199254740995");
2707    }
2708
2709    #[test]
2710    fn balance_bodies_accept_decimal_string_min_context_slots() {
2711        let native: NativeBalanceBody = serde_json::from_value(json!({
2712            "address": "owner",
2713            "minContextSlot": "9007199254740997",
2714        }))
2715        .unwrap();
2716        let token: BalanceBody = serde_json::from_value(json!({
2717            "owner": "owner",
2718            "mint": "mint",
2719            "minContextSlot": "9007199254740999",
2720        }))
2721        .unwrap();
2722
2723        assert_eq!(
2724            parse_min_context_slot(native.min_context_slot.as_deref()).unwrap(),
2725            Some(9_007_199_254_740_997)
2726        );
2727        assert_eq!(
2728            parse_min_context_slot(token.min_context_slot.as_deref()).unwrap(),
2729            Some(9_007_199_254_740_999)
2730        );
2731    }
2732
2733    fn runtime_definition(reader: crate::ProgramAccountReaderFn) -> ProgramRuntimeDefinition {
2734        let program_spec_hash = crate::ProgramSpecHash::from_digest([1; 32]);
2735        let idl_content_hash = crate::IdlContentHash::from_digest([2; 32]);
2736        let normalized_idl_hash = crate::NormalizedIdlHash::from_digest([3; 32]);
2737        let program_release_hash = arete_hash::OssGeneratedProgramReleaseV1::new(
2738            "Program111",
2739            program_spec_hash,
2740            idl_content_hash,
2741            normalized_idl_hash,
2742        )
2743        .hash()
2744        .unwrap();
2745        ProgramRuntimeDefinition {
2746            program_id: "Program111".to_string(),
2747            program_spec_hash,
2748            idl_content_hash,
2749            normalized_idl_hash,
2750            program_release_hash,
2751            account_reader: reader,
2752        }
2753    }
2754
2755    fn program_read_context(
2756        target_id: &str,
2757        program_id: &str,
2758        release_hash: &str,
2759    ) -> crate::websocket::auth::AuthContext {
2760        crate::websocket::auth::AuthContext::from_claims(
2761            arete_auth::SessionClaims::program_read_builder(
2762                "issuer",
2763                "user:1",
2764                target_id,
2765                program_id,
2766                release_hash,
2767            )
2768            .build(),
2769        )
2770    }
2771
2772    #[test]
2773    fn program_read_auth_is_bound_to_target_program_and_release() {
2774        let definition = runtime_definition(Arc::new(|_, _| Ok(Value::Null)));
2775        let release_hash = definition.program_release_hash.to_string();
2776        let valid = program_read_context("binding-1", &definition.program_id, &release_hash);
2777
2778        assert_eq!(
2779            authorize_program_read(Some(&valid), Some("binding-1"), &definition),
2780            Ok(())
2781        );
2782
2783        let wrong_target = program_read_context("binding-2", &definition.program_id, &release_hash);
2784        let wrong_program = program_read_context("binding-1", "Program222", &release_hash);
2785        let wrong_release = program_read_context(
2786            "binding-1",
2787            &definition.program_id,
2788            "arete:h1:program-release:sha256:different",
2789        );
2790
2791        for context in [&wrong_target, &wrong_program, &wrong_release] {
2792            assert_eq!(
2793                authorize_program_read(Some(context), Some("binding-1"), &definition),
2794                Err(ProgramReadError::Unauthorized)
2795            );
2796        }
2797        assert_eq!(
2798            authorize_program_read(Some(&valid), None, &definition),
2799            Err(ProgramReadError::AuthorizationNotConfigured)
2800        );
2801        assert_eq!(authorize_program_read(None, None, &definition), Ok(()));
2802    }
2803
2804    #[test]
2805    fn release_routes_are_exact_and_legacy_program_routes_are_not_accepted() {
2806        let hash = crate::ProgramReleaseHash::from_digest([4; 32]);
2807        let fetch_path = format!("/v1/releases/{hash}/accounts/Vault/address");
2808        let exists_path = format!("{fetch_path}/exists");
2809        let batch_path = format!("/v1/releases/{hash}/accounts/Vault");
2810
2811        assert!(matches!(
2812            ProgramReadRoute::parse(&Method::GET, &fetch_path)
2813                .unwrap()
2814                .operation,
2815            ProgramReadOperation::Fetch { .. }
2816        ));
2817        assert!(matches!(
2818            ProgramReadRoute::parse(&Method::GET, &exists_path)
2819                .unwrap()
2820                .operation,
2821            ProgramReadOperation::Exists { .. }
2822        ));
2823        assert!(matches!(
2824            ProgramReadRoute::parse(&Method::POST, &batch_path)
2825                .unwrap()
2826                .operation,
2827            ProgramReadOperation::Batch
2828        ));
2829        assert_eq!(
2830            ProgramReadRoute::parse(&Method::GET, "/programs/demo/accounts/Vault/address")
2831                .unwrap_err(),
2832            ProgramReadError::NotFound
2833        );
2834    }
2835
2836    #[tokio::test]
2837    async fn owner_mismatch_is_rejected_before_decoder_execution() {
2838        use std::sync::atomic::{AtomicBool, Ordering};
2839
2840        let called = Arc::new(AtomicBool::new(false));
2841        let called_by_reader = called.clone();
2842        let definition = runtime_definition(Arc::new(move |_, _| {
2843            called_by_reader.store(true, Ordering::SeqCst);
2844            Ok(json!({ "decoded": true }))
2845        }));
2846        let value = json!({
2847            "owner": "DifferentProgram",
2848            "data": [BASE64_STANDARD.encode([1, 2, 3]), "base64"]
2849        });
2850
2851        assert!(matches!(
2852            decode_release_account(&definition, "Vault", &value).await,
2853            AccountReadOutcome::Error(ProgramReadError::AccountOwnerMismatch)
2854        ));
2855        assert!(!called.load(Ordering::SeqCst));
2856    }
2857
2858    #[tokio::test]
2859    async fn decode_failures_remain_typed_errors_and_metadata_is_public_only() {
2860        let definition = runtime_definition(Arc::new(|_, _| anyhow::bail!("private diagnostic")));
2861        let value = json!({
2862            "owner": "Program111",
2863            "data": [BASE64_STANDARD.encode([1, 2, 3]), "base64"]
2864        });
2865        assert!(matches!(
2866            decode_release_account(&definition, "Vault", &value).await,
2867            AccountReadOutcome::Error(ProgramReadError::AccountDecodeFailed)
2868        ));
2869
2870        let response = with_release_metadata(
2871            program_read_error_response(ProgramReadError::AccountDecodeFailed),
2872            &definition,
2873            Some("address"),
2874            Some(true),
2875        );
2876        assert_eq!(response.headers()["X-Error-Code"], "ACCOUNT_DECODE_FAILED");
2877        assert_eq!(
2878            response.headers()["X-Arete-Program-Release-Hash"],
2879            definition.program_release_hash.to_string()
2880        );
2881        assert_eq!(
2882            response.headers()["X-Arete-Idl-Content-Hash"],
2883            definition.idl_content_hash.to_string()
2884        );
2885        let body = response.into_body().collect().await.unwrap().to_bytes();
2886        let body = std::str::from_utf8(&body).unwrap();
2887        assert_eq!(body, r#"{"error":{"code":"ACCOUNT_DECODE_FAILED"}}"#);
2888        assert!(!body.contains("private diagnostic"));
2889        assert!(!body.contains("decoder"));
2890    }
2891
2892    /// Maps a bearer token to a gateway session carrying exactly the scopes it names.
2893    struct ScopeTokenPlugin;
2894
2895    #[async_trait::async_trait]
2896    impl WebSocketAuthPlugin for ScopeTokenPlugin {
2897        async fn authorize(&self, request: &ConnectionAuthRequest) -> AuthDecision {
2898            let Some(scope) = request.bearer_token() else {
2899                return AuthDecision::Deny(AuthDeny::token_missing());
2900            };
2901            let mut claims =
2902                arete_auth::SessionClaims::solana_gateway_builder("issuer", "user:1", "gateway-1")
2903                    .with_scope(scope.replace('+', " "))
2904                    .with_metering_key("account:1")
2905                    .build();
2906            if scope.contains("limited") {
2907                claims.scope = SCOPE_FORK_READ.to_string();
2908                claims.limits = Some(arete_auth::Limits {
2909                    max_http_requests_per_minute: Some(3),
2910                    ..Default::default()
2911                });
2912            }
2913            AuthDecision::Allow(crate::websocket::auth::AuthContext::from_claims(claims))
2914        }
2915
2916        fn as_any(&self) -> &dyn std::any::Any {
2917            self
2918        }
2919    }
2920
2921    async fn start_gateway(rpc_url: &str) -> (String, CancellationToken) {
2922        start_gateway_with_limits(rpc_url, HttpLimitsConfig::default()).await
2923    }
2924
2925    async fn start_gateway_with_limits(
2926        rpc_url: &str,
2927        limits: HttpLimitsConfig,
2928    ) -> (String, CancellationToken) {
2929        let port = std::net::TcpListener::bind("127.0.0.1:0")
2930            .unwrap()
2931            .local_addr()
2932            .unwrap()
2933            .port();
2934        let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
2935        let shutdown = CancellationToken::new();
2936        let server = HttpHealthServer::new(addr)
2937            .with_runtime_plan(RuntimePlan::solana_gateway())
2938            .with_auth_plugin(Arc::new(ScopeTokenPlugin))
2939            .with_solana_gateway_target("gateway-1")
2940            .with_read_rpc_url(rpc_url)
2941            .with_limits(limits)
2942            .with_shutdown(shutdown.clone());
2943        tokio::spawn(server.start());
2944        for _ in 0..100 {
2945            if tokio::net::TcpStream::connect(addr).await.is_ok() {
2946                break;
2947            }
2948            tokio::time::sleep(Duration::from_millis(10)).await;
2949        }
2950        (format!("http://{addr}"), shutdown)
2951    }
2952
2953    #[tokio::test]
2954    async fn the_fork_route_requires_the_exact_fork_read_scope() {
2955        let rpc = crate::http::fork::tests::mock_rpc(crate::http::fork::tests::result_responder(
2956            json!(77),
2957        ))
2958        .await;
2959        let (base, shutdown) = start_gateway(&rpc.url).await;
2960        let client = Client::new();
2961        let body = json!({ "jsonrpc": "2.0", "id": 1, "method": "getSlot" });
2962        let post = |token: Option<&str>| {
2963            let mut request = client.post(format!("{base}/rpc/v1/fork")).json(&body);
2964            if let Some(token) = token {
2965                request = request.bearer_auth(token);
2966            }
2967            request.send()
2968        };
2969
2970        let missing = post(None).await.unwrap();
2971        assert_eq!(missing.status(), StatusCode::UNAUTHORIZED);
2972
2973        for scope in [
2974            "read",
2975            "read+transaction:inspect+transaction:send",
2976            "fork:read-extra",
2977        ] {
2978            let denied = post(Some(scope)).await.unwrap();
2979            assert_eq!(denied.status(), StatusCode::FORBIDDEN, "{scope}");
2980            let value: Value = denied.json().await.unwrap();
2981            assert_eq!(value["code"], "insufficient_scope");
2982        }
2983        assert!(rpc.requests.lock().unwrap().is_empty());
2984
2985        let allowed = post(Some("fork:read")).await.unwrap();
2986        assert_eq!(allowed.status(), StatusCode::OK);
2987        let value: Value = allowed.json().await.unwrap();
2988        assert_eq!(value, json!({ "jsonrpc": "2.0", "id": 1, "result": 77 }));
2989
2990        // fork:read does not open the chain-read routes.
2991        let chain = client
2992            .get(format!("{base}/chain/clock"))
2993            .bearer_auth("fork:read")
2994            .send()
2995            .await
2996            .unwrap();
2997        assert_eq!(chain.status(), StatusCode::FORBIDDEN);
2998
2999        let get = client
3000            .get(format!("{base}/rpc/v1/fork"))
3001            .bearer_auth("fork:read")
3002            .send()
3003            .await
3004            .unwrap();
3005        assert_eq!(get.status(), StatusCode::METHOD_NOT_ALLOWED);
3006        shutdown.cancel();
3007    }
3008
3009    #[tokio::test]
3010    async fn a_fork_batch_is_charged_per_entry() {
3011        let rpc = crate::http::fork::tests::mock_rpc(crate::http::fork::tests::result_responder(
3012            json!(1),
3013        ))
3014        .await;
3015        let (base, shutdown) = start_gateway(&rpc.url).await;
3016        let client = Client::new();
3017        let call = |id: u64| json!({ "jsonrpc": "2.0", "id": id, "method": "getSlot" });
3018        let batch = json!([call(1), call(2), call(3), call(4)]);
3019
3020        let refused = client
3021            .post(format!("{base}/rpc/v1/fork"))
3022            .bearer_auth("limited")
3023            .json(&batch)
3024            .send()
3025            .await
3026            .unwrap();
3027        assert_eq!(refused.status(), StatusCode::TOO_MANY_REQUESTS);
3028        assert!(rpc.requests.lock().unwrap().is_empty());
3029
3030        let within = client
3031            .post(format!("{base}/rpc/v1/fork"))
3032            .bearer_auth("limited")
3033            .json(&json!([call(1), call(2)]))
3034            .send()
3035            .await
3036            .unwrap();
3037        assert_eq!(within.status(), StatusCode::OK);
3038        assert_eq!(rpc.requests.lock().unwrap().len(), 2);
3039        shutdown.cancel();
3040    }
3041
3042    #[tokio::test]
3043    async fn chain_read_upstream_failures_never_reveal_the_rpc_url() {
3044        type Responder = crate::http::fork::tests::Responder;
3045        let leaky: [(&str, Responder); 3] = [
3046            (
3047                "http status",
3048                Arc::new(|_| (429, "rate limited at /secret-token".into())),
3049            ),
3050            (
3051                "json-rpc error",
3052                Arc::new(|_| {
3053                    (
3054                        200,
3055                        json!({
3056                            "jsonrpc": "2.0",
3057                            "id": "arete-read",
3058                            "error": { "code": -32005, "message": "see /secret-token" }
3059                        })
3060                        .to_string(),
3061                    )
3062                }),
3063            ),
3064            (
3065                "malformed body",
3066                Arc::new(|_| (200, "<html>/secret-token</html>".into())),
3067            ),
3068        ];
3069        let unreachable = {
3070            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3071            let url = format!("http://{}", listener.local_addr().unwrap());
3072            drop(listener);
3073            url
3074        };
3075        let mut upstreams = Vec::new();
3076        for (label, responder) in leaky {
3077            let rpc = crate::http::fork::tests::mock_rpc(responder).await;
3078            upstreams.push((label, rpc.url));
3079        }
3080        upstreams.push(("unreachable", unreachable));
3081
3082        let client = Client::new();
3083        for (label, upstream) in upstreams {
3084            let rpc_url = format!("{upstream}/secret-token?api-key=hunter2");
3085            let port = upstream.rsplit(':').next().unwrap().to_string();
3086            let (base, shutdown) = start_gateway(&rpc_url).await;
3087            for request in [
3088                client.get(format!("{base}/chain/clock")),
3089                client.get(format!("{base}/chain/exists/{}", "1".repeat(32))),
3090                client
3091                    .post(format!("{base}/chain/v1/account"))
3092                    .json(&json!({ "address": "11111111111111111111111111111111" })),
3093            ] {
3094                let response = request.bearer_auth("read").send().await.unwrap();
3095                assert_eq!(response.status(), StatusCode::BAD_GATEWAY, "{label}");
3096                let correlation_id = response.headers()[CORRELATION_ID_HEADER]
3097                    .to_str()
3098                    .unwrap()
3099                    .to_string();
3100                let text = response.text().await.unwrap();
3101                for secret in ["secret-token", "hunter2", "api-key", "127.0.0.1", &port] {
3102                    assert!(!text.contains(secret), "{label}: {secret} in {text}");
3103                }
3104                let value: Value = serde_json::from_str(&text).unwrap();
3105                assert_eq!(value["error"], UPSTREAM_RPC_FAILED, "{label}");
3106                assert!(text.contains(&correlation_id), "{label}");
3107            }
3108            shutdown.cancel();
3109        }
3110    }
3111
3112    fn small_limits() -> HttpLimitsConfig {
3113        HttpLimitsConfig {
3114            max_body_bytes: 1024,
3115            header_read_timeout: Duration::from_millis(300),
3116            body_read_timeout: Duration::from_millis(300),
3117            upstream_connect_timeout: Duration::from_millis(300),
3118            upstream_timeout: Duration::from_millis(300),
3119            max_upstream_response_bytes: 1024,
3120        }
3121    }
3122
3123    #[tokio::test]
3124    async fn oversized_request_bodies_are_refused_with_413() {
3125        let rpc = crate::http::fork::tests::mock_rpc(crate::http::fork::tests::result_responder(
3126            json!(1),
3127        ))
3128        .await;
3129        let (base, shutdown) = start_gateway_with_limits(&rpc.url, small_limits()).await;
3130        let client = Client::new();
3131        let padding = " ".repeat(4096);
3132        let oversized = format!(r#"{{"address":"11111111111111111111111111111111"{padding}}}"#);
3133        for path in [
3134            "/chain/v1/account",
3135            "/chain/native-balance",
3136            "/chain/accounts",
3137        ] {
3138            // Declared length over the limit.
3139            let response = client
3140                .post(format!("{base}{path}"))
3141                .bearer_auth("read")
3142                .header("Content-Type", "application/json")
3143                .body(oversized.clone())
3144                .send()
3145                .await
3146                .unwrap();
3147            assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE, "{path}");
3148
3149            // No declared length: refused once the streamed bytes pass the limit.
3150            let chunks = futures_util::stream::iter(
3151                oversized
3152                    .as_bytes()
3153                    .chunks(512)
3154                    .map(|chunk| Ok::<_, std::io::Error>(Bytes::copy_from_slice(chunk)))
3155                    .collect::<Vec<_>>(),
3156            );
3157            let response = client
3158                .post(format!("{base}{path}"))
3159                .bearer_auth("read")
3160                .body(reqwest::Body::wrap_stream(chunks))
3161                .send()
3162                .await
3163                .unwrap();
3164            assert_eq!(
3165                response.status(),
3166                StatusCode::PAYLOAD_TOO_LARGE,
3167                "{path} chunked"
3168            );
3169        }
3170        assert!(rpc.requests.lock().unwrap().is_empty());
3171        shutdown.cancel();
3172    }
3173
3174    #[tokio::test]
3175    async fn a_body_that_stalls_is_answered_with_408() {
3176        use tokio::io::{AsyncReadExt, AsyncWriteExt};
3177
3178        let rpc = crate::http::fork::tests::mock_rpc(crate::http::fork::tests::result_responder(
3179            json!(1),
3180        ))
3181        .await;
3182        let (base, shutdown) = start_gateway_with_limits(&rpc.url, small_limits()).await;
3183        let addr = base.trim_start_matches("http://");
3184        let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
3185        stream
3186            .write_all(
3187                format!(
3188                    "POST /chain/v1/account HTTP/1.1\r\nHost: {addr}\r\nAuthorization: Bearer read\r\nContent-Type: application/json\r\nContent-Length: 200\r\n\r\n{{\"addr"
3189                )
3190                .as_bytes(),
3191            )
3192            .await
3193            .unwrap();
3194        let mut response = vec![0; 1024];
3195        let read = tokio::time::timeout(Duration::from_secs(5), stream.read(&mut response))
3196            .await
3197            .expect("the server answers a stalled body before the test deadline")
3198            .unwrap();
3199        let response = String::from_utf8_lossy(&response[..read]);
3200        assert!(response.starts_with("HTTP/1.1 408"), "{response}");
3201        shutdown.cancel();
3202    }
3203
3204    #[tokio::test]
3205    async fn a_client_that_never_finishes_its_headers_is_disconnected() {
3206        use tokio::io::{AsyncReadExt, AsyncWriteExt};
3207
3208        let (base, shutdown) =
3209            start_gateway_with_limits("http://127.0.0.1:9/unused", small_limits()).await;
3210        let addr = base.trim_start_matches("http://");
3211        let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
3212        stream.write_all(b"GET /health HTTP/1.1\r\n").await.unwrap();
3213        let mut buffer = vec![0; 1024];
3214        let closed = tokio::time::timeout(Duration::from_secs(5), async {
3215            loop {
3216                match stream.read(&mut buffer).await {
3217                    Ok(0) | Err(_) => break,
3218                    Ok(_) => continue,
3219                }
3220            }
3221        })
3222        .await;
3223        assert!(
3224            closed.is_ok(),
3225            "the connection is closed at the header deadline"
3226        );
3227        shutdown.cancel();
3228    }
3229
3230    #[tokio::test]
3231    async fn a_slow_upstream_times_out_as_a_502() {
3232        // Accepts connections and never answers them.
3233        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3234        let upstream = format!("http://{}", listener.local_addr().unwrap());
3235        tokio::spawn(async move {
3236            let mut held = Vec::new();
3237            while let Ok((stream, _)) = listener.accept().await {
3238                held.push(stream);
3239            }
3240        });
3241        let (base, shutdown) = start_gateway_with_limits(&upstream, small_limits()).await;
3242        let started = std::time::Instant::now();
3243        let response = Client::new()
3244            .get(format!("{base}/chain/clock"))
3245            .bearer_auth("read")
3246            .send()
3247            .await
3248            .unwrap();
3249        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
3250        assert!(started.elapsed() < Duration::from_secs(5));
3251        let value: Value = response.json().await.unwrap();
3252        assert_eq!(value["error"], UPSTREAM_RPC_FAILED);
3253        shutdown.cancel();
3254    }
3255
3256    #[tokio::test]
3257    async fn an_oversized_upstream_response_is_refused() {
3258        let rpc = crate::http::fork::tests::mock_rpc(crate::http::fork::tests::result_responder(
3259            json!("x".repeat(4096)),
3260        ))
3261        .await;
3262        let (base, shutdown) = start_gateway_with_limits(&rpc.url, small_limits()).await;
3263        let response = Client::new()
3264            .get(format!("{base}/chain/clock"))
3265            .bearer_auth("read")
3266            .send()
3267            .await
3268            .unwrap();
3269        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
3270        let value: Value = response.json().await.unwrap();
3271        assert_eq!(value["error"], UPSTREAM_RPC_FAILED);
3272        shutdown.cancel();
3273    }
3274}