Skip to main content

arete_server/
http_health.rs

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