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