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