Skip to main content

arete_server/
http_health.rs

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