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