1use crate::http::transactions::{self, TransactionState};
2use crate::{
3 config::{RuntimePlan, TransactionConfig},
4 health::HealthMonitor,
5 ProgramRuntimeCatalog, ProgramRuntimeDefinition,
6};
7use anyhow::Result;
8use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
9use base64::Engine as _;
10use dashmap::DashMap;
11use http_body_util::BodyExt;
12use http_body_util::Full;
13use hyper::body::Bytes;
14use hyper::header::{
15 HeaderValue, ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS,
16 ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_EXPOSE_HEADERS, ACCESS_CONTROL_MAX_AGE,
17};
18use hyper::server::conn::http1;
19use hyper::service::service_fn;
20use hyper::{Method, Request, Response, StatusCode};
21use hyper_util::rt::TokioIo;
22use reqwest::Client;
23use serde::Deserialize;
24use serde_json::{json, Value};
25use std::convert::Infallible;
26use std::env;
27use std::net::SocketAddr;
28use std::sync::Arc;
29use std::time::{Duration, SystemTime, UNIX_EPOCH};
30use tokio::net::TcpListener;
31use tokio_util::sync::CancellationToken;
32use tracing::{error, info};
33
34use crate::websocket::auth::{AuthDecision, AuthDeny, ConnectionAuthRequest, WebSocketAuthPlugin};
35use arete_auth::SCOPE_READ;
36
37#[derive(Clone, Debug)]
39pub struct HttpHealthConfig {
40 pub bind_address: SocketAddr,
41}
42
43impl Default for HttpHealthConfig {
44 fn default() -> Self {
45 Self {
46 bind_address: "[::]:8081".parse().expect("valid socket address"),
47 }
48 }
49}
50
51impl HttpHealthConfig {
52 pub fn new(bind_address: impl Into<SocketAddr>) -> Self {
53 Self {
54 bind_address: bind_address.into(),
55 }
56 }
57}
58
59#[derive(Clone)]
60struct HttpRequestState {
61 health_monitor: Arc<Option<HealthMonitor>>,
62 snapshot_runtime: Option<crate::snapshot::SnapshotRuntime>,
63 runtime_plan: RuntimePlan,
64 rpc_url: Arc<Option<String>>,
65 rpc_client: Client,
66 program_runtime_catalog: Arc<ProgramRuntimeCatalog>,
67 auth_plugin: Arc<Option<Arc<dyn WebSocketAuthPlugin>>>,
68 limit_state: Arc<HttpLimitState>,
69 transaction_state: Arc<Option<TransactionState>>,
70 solana_gateway_target_id: Arc<Option<String>>,
71 program_read_binding_target_id: Arc<Option<String>>,
72}
73
74pub struct HttpHealthServer {
76 shutdown: Option<CancellationToken>,
78 bind_addr: SocketAddr,
79 health_monitor: Option<HealthMonitor>,
80 snapshot_runtime: Option<crate::snapshot::SnapshotRuntime>,
81 runtime_plan: RuntimePlan,
82 program_runtime_catalog: ProgramRuntimeCatalog,
83 auth_plugin: Option<Arc<dyn WebSocketAuthPlugin>>,
84 transaction_config: Option<TransactionConfig>,
85 solana_gateway_target_id: Option<String>,
86 program_read_binding_target_id: Option<String>,
87 #[cfg(feature = "otel")]
88 metrics: Option<Arc<crate::metrics::Metrics>>,
89}
90
91impl HttpHealthServer {
92 pub fn new(bind_addr: SocketAddr) -> Self {
93 Self {
94 bind_addr,
95 shutdown: None,
96 health_monitor: None,
97 snapshot_runtime: None,
98 runtime_plan: RuntimePlan::http(),
99 program_runtime_catalog: ProgramRuntimeCatalog::default(),
100 auth_plugin: None,
101 transaction_config: None,
102 solana_gateway_target_id: None,
103 program_read_binding_target_id: None,
104 #[cfg(feature = "otel")]
105 metrics: None,
106 }
107 }
108
109 pub fn with_health_monitor(mut self, monitor: HealthMonitor) -> Self {
110 self.health_monitor = Some(monitor);
111 self
112 }
113
114 pub fn with_snapshot_runtime(
115 mut self,
116 snapshot_runtime: crate::snapshot::SnapshotRuntime,
117 ) -> Self {
118 self.snapshot_runtime = Some(snapshot_runtime);
119 self
120 }
121
122 pub fn with_runtime_plan(mut self, runtime_plan: RuntimePlan) -> Self {
123 self.runtime_plan = runtime_plan;
124 self
125 }
126
127 pub fn with_program_runtime_catalog(mut self, catalog: ProgramRuntimeCatalog) -> Self {
128 self.program_runtime_catalog = catalog;
129 self
130 }
131
132 pub fn with_auth_plugin(mut self, plugin: Arc<dyn WebSocketAuthPlugin>) -> Self {
133 self.auth_plugin = Some(plugin);
134 self
135 }
136
137 pub fn with_transaction_config(mut self, config: TransactionConfig) -> Self {
138 self.runtime_plan.transactions = config.enabled;
139 self.transaction_config = Some(config);
140 self
141 }
142
143 pub fn with_solana_gateway_target(mut self, target_id: impl Into<String>) -> Self {
144 self.solana_gateway_target_id = Some(target_id.into());
145 self
146 }
147
148 pub fn with_program_read_binding_target(mut self, target_id: impl Into<String>) -> Self {
149 self.program_read_binding_target_id = Some(target_id.into());
150 self
151 }
152
153 #[cfg(feature = "otel")]
154 pub fn with_metrics(mut self, metrics: Option<Arc<crate::metrics::Metrics>>) -> Self {
155 self.metrics = metrics;
156 self
157 }
158
159 pub fn with_shutdown(mut self, token: CancellationToken) -> Self {
162 self.shutdown = Some(token);
163 self
164 }
165
166 pub async fn start(self) -> Result<()> {
167 info!("Starting HTTP health server on {}", self.bind_addr);
168
169 let listener = TcpListener::bind(&self.bind_addr).await?;
170 info!("HTTP health server listening on {}", self.bind_addr);
171
172 let transaction_state = self
173 .transaction_config
174 .filter(|config| config.enabled)
175 .map(TransactionState::new)
176 .transpose()?;
177 #[cfg(feature = "otel")]
178 let transaction_state = transaction_state.map(|state| state.with_metrics(self.metrics));
179 let request_state = HttpRequestState {
180 health_monitor: Arc::new(self.health_monitor),
181 snapshot_runtime: self.snapshot_runtime,
182 runtime_plan: self.runtime_plan,
183 rpc_url: Arc::new(resolve_rpc_url()),
184 rpc_client: Client::builder().build()?,
185 program_runtime_catalog: Arc::new(self.program_runtime_catalog),
186 auth_plugin: Arc::new(self.auth_plugin),
187 limit_state: Arc::new(HttpLimitState::default()),
188 transaction_state: Arc::new(transaction_state),
189 solana_gateway_target_id: Arc::new(self.solana_gateway_target_id),
190 program_read_binding_target_id: Arc::new(self.program_read_binding_target_id),
191 };
192
193 let shutdown = self.shutdown.unwrap_or_default();
194 loop {
195 let accepted = tokio::select! {
196 _ = shutdown.cancelled() => {
197 info!("HTTP health server on {} stopping", self.bind_addr);
198 return Ok(());
199 }
200 accepted = listener.accept() => accepted,
201 };
202 match accepted {
203 Ok((stream, remote_addr)) => {
204 let io = TokioIo::new(stream);
205 let request_state = request_state.clone();
206
207 tokio::spawn(async move {
208 let service = service_fn(move |req| {
209 let request_state = request_state.clone();
210 async move { handle_request(remote_addr, req, request_state).await }
211 });
212
213 if let Err(e) = http1::Builder::new().serve_connection(io, service).await {
214 error!("HTTP connection error: {}", e);
215 }
216 });
217 }
218 Err(e) => {
219 error!("Failed to accept HTTP connection: {}", e);
220 }
221 }
222 }
223 }
224}
225
226async fn handle_request(
227 remote_addr: SocketAddr,
228 req: Request<hyper::body::Incoming>,
229 state: HttpRequestState,
230) -> Result<Response<Full<Bytes>>, Infallible> {
231 if req.method() == Method::OPTIONS {
232 return Ok(with_cors(
233 Response::builder()
234 .status(StatusCode::NO_CONTENT)
235 .body(Full::new(Bytes::new()))
236 .unwrap(),
237 ));
238 }
239
240 let response = handle_request_inner(remote_addr, req, state).await?;
241 Ok(with_cors(response))
242}
243
244fn with_cors(mut response: Response<Full<Bytes>>) -> Response<Full<Bytes>> {
245 let headers = response.headers_mut();
246 headers.insert(ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
247 headers.insert(
248 ACCESS_CONTROL_ALLOW_METHODS,
249 HeaderValue::from_static("GET, POST, OPTIONS"),
250 );
251 headers.insert(
252 ACCESS_CONTROL_ALLOW_HEADERS,
253 HeaderValue::from_static("Authorization, Content-Type"),
254 );
255 headers.insert(
256 ACCESS_CONTROL_EXPOSE_HEADERS,
257 HeaderValue::from_static(
258 "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",
259 ),
260 );
261 headers.insert(ACCESS_CONTROL_MAX_AGE, HeaderValue::from_static("86400"));
262 response
263}
264
265async fn handle_request_inner(
266 remote_addr: SocketAddr,
267 req: Request<hyper::body::Incoming>,
268 state: HttpRequestState,
269) -> Result<Response<Full<Bytes>>, Infallible> {
270 let HttpRequestState {
271 health_monitor,
272 snapshot_runtime,
273 runtime_plan,
274 rpc_url,
275 rpc_client,
276 program_runtime_catalog,
277 auth_plugin,
278 limit_state,
279 transaction_state,
280 solana_gateway_target_id,
281 program_read_binding_target_id,
282 } = state;
283 let path = req.uri().path().to_string();
284
285 match path.as_str() {
286 "/health" | "/healthz" if runtime_plan.health => {
287 Ok(Response::builder()
289 .status(StatusCode::OK)
290 .header("Content-Type", "text/plain")
291 .body(Full::new(Bytes::from("OK")))
292 .unwrap())
293 }
294 "/ready" | "/readiness" if runtime_plan.health => {
295 let stream_ready = match health_monitor.as_ref() {
299 Some(monitor) => monitor.is_healthy().await,
300 None => true,
302 };
303 let snapshot_ready = snapshot_runtime
304 .as_ref()
305 .is_none_or(crate::snapshot::SnapshotRuntime::resume_gate_ready);
306 if stream_ready && snapshot_ready {
307 Ok(Response::builder()
308 .status(StatusCode::OK)
309 .header("Content-Type", "text/plain")
310 .body(Full::new(Bytes::from("READY")))
311 .unwrap())
312 } else {
313 Ok(Response::builder()
314 .status(StatusCode::SERVICE_UNAVAILABLE)
315 .header("Content-Type", "text/plain")
316 .body(Full::new(Bytes::from("NOT READY")))
317 .unwrap())
318 }
319 }
320 "/status" if runtime_plan.health => {
321 if let Some(monitor) = health_monitor.as_ref() {
323 let status = monitor.status().await;
324 let error_count = monitor.error_count().await;
325 let is_healthy = monitor.is_healthy().await;
326
327 let status_json = serde_json::json!({
328 "healthy": is_healthy,
329 "status": format!("{:?}", status),
330 "error_count": error_count
331 });
332
333 let status_code = if is_healthy {
334 StatusCode::OK
335 } else {
336 StatusCode::SERVICE_UNAVAILABLE
337 };
338
339 Ok(Response::builder()
340 .status(status_code)
341 .header("Content-Type", "application/json")
342 .body(Full::new(Bytes::from(status_json.to_string())))
343 .unwrap())
344 } else {
345 let status_json = serde_json::json!({
346 "healthy": true,
347 "status": "no_monitor",
348 "error_count": 0
349 });
350
351 Ok(Response::builder()
352 .status(StatusCode::OK)
353 .header("Content-Type", "application/json")
354 .body(Full::new(Bytes::from(status_json.to_string())))
355 .unwrap())
356 }
357 }
358 _ if runtime_plan.transactions && path.starts_with("/transactions/") => {
359 let Some(transaction_state) = transaction_state.as_ref() else {
360 return Ok(error_response(StatusCode::NOT_FOUND, "Not Found"));
361 };
362 let client_addr = transaction_state.client_addr(remote_addr, req.headers());
363 let auth_context = match authorize_http_request(
364 client_addr,
365 &req,
366 auth_plugin.as_ref().as_ref(),
367 &limit_state,
368 None,
369 false,
370 solana_gateway_target_id.as_deref(),
371 )
372 .await
373 {
374 Ok(context) => context,
375 Err(response) => return Ok(transaction_auth_error(response, path.as_str())),
376 };
377 Ok(
378 transactions::handle(client_addr, req, auth_context, transaction_state.clone())
379 .await,
380 )
381 }
382 _ if runtime_plan.chain_reads && path.starts_with("/chain/") => {
383 let auth_context = match authorize_http_request(
384 remote_addr,
385 &req,
386 auth_plugin.as_ref().as_ref(),
387 &limit_state,
388 Some(SCOPE_READ),
389 true,
390 solana_gateway_target_id.as_deref(),
391 )
392 .await
393 {
394 Ok(context) => context,
395 Err(response) => return Ok(response),
396 };
397 Ok(handle_chain_request(req, path.as_str(), rpc_url, rpc_client, auth_context).await)
398 }
399 _ if path.starts_with("/v1/releases/") => {
400 if !runtime_plan.program_reads {
401 return Ok(program_read_error_response(
402 ProgramReadError::ProgramReadsDisabled,
403 ));
404 }
405 let auth_context = match authorize_http_request(
406 remote_addr,
407 &req,
408 auth_plugin.as_ref().as_ref(),
409 &limit_state,
410 Some(SCOPE_READ),
411 true,
412 None,
413 )
414 .await
415 {
416 Ok(context) => context,
417 Err(response) => return Ok(response),
418 };
419 Ok(handle_program_account_request(
420 req,
421 path.as_str(),
422 rpc_url,
423 rpc_client,
424 program_runtime_catalog,
425 auth_context,
426 program_read_binding_target_id,
427 )
428 .await)
429 }
430 _ => Ok(Response::builder()
431 .status(StatusCode::NOT_FOUND)
432 .header("Content-Type", "text/plain")
433 .body(Full::new(Bytes::from("Not Found")))
434 .unwrap()),
435 }
436}
437
438fn transaction_auth_error(response: Response<Full<Bytes>>, path: &str) -> Response<Full<Bytes>> {
439 let status = response.status();
440 let code = response
441 .headers()
442 .get("X-Error-Code")
443 .and_then(|value| value.to_str().ok())
444 .unwrap_or("authentication_failed")
445 .to_string();
446 let request_id = uuid::Uuid::new_v4().to_string();
447 let mut value = json!({
448 "code": code,
449 "message": "Transaction request authentication failed",
450 "retryable": status == StatusCode::TOO_MANY_REQUESTS || status == StatusCode::UNAUTHORIZED,
451 "requestId": request_id,
452 });
453 if path == "/transactions/v1/send" {
454 value["submissionState"] = json!("not_submitted");
455 }
456 Response::builder()
457 .status(status)
458 .header("Content-Type", "application/json")
459 .header("X-Error-Code", code)
460 .header("X-Request-Id", request_id)
461 .header("X-Arete-Upstream-Attempted", "false")
462 .body(Full::new(Bytes::from(value.to_string())))
463 .expect("valid transaction authentication response")
464}
465
466#[derive(Debug, Deserialize)]
467struct AddressesBody {
468 addresses: Vec<String>,
469}
470
471#[derive(Debug, Deserialize)]
472struct BalanceBody {
473 owner: String,
474 mint: String,
475 #[serde(default, rename = "tokenProgram")]
476 token_program: Option<String>,
477 #[serde(default, rename = "minContextSlot")]
478 min_context_slot: Option<String>,
479}
480
481#[derive(Debug, Deserialize)]
482struct NativeBalanceBody {
483 address: String,
484 #[serde(default, rename = "minContextSlot")]
485 min_context_slot: Option<String>,
486}
487
488#[derive(Debug, Deserialize)]
489struct AccountsBody {
490 addresses: Vec<String>,
491}
492
493#[derive(Default)]
494struct HttpLimitState {
495 per_subject_per_minute: DashMap<String, (u64, u32)>,
496}
497
498fn resolve_rpc_url() -> Option<String> {
499 ["ARETE_READ_RPC_URL", "SOLANA_RPC_URL", "RPC_URL"]
500 .iter()
501 .find_map(|key| env::var(key).ok())
502 .filter(|value| !value.is_empty())
503}
504
505fn json_response(status: StatusCode, value: Value) -> Response<Full<Bytes>> {
506 Response::builder()
507 .status(status)
508 .header("Content-Type", "application/json")
509 .body(Full::new(Bytes::from(value.to_string())))
510 .unwrap()
511}
512
513fn error_response(status: StatusCode, message: impl Into<String>) -> Response<Full<Bytes>> {
514 json_response(status, json!({ "error": message.into() }))
515}
516
517fn parse_min_context_slot(value: Option<&str>) -> std::result::Result<Option<u64>, &'static str> {
518 value
519 .map(|slot| {
520 slot.parse::<u64>()
521 .map_err(|_| "minContextSlot must be a decimal u64 string")
522 })
523 .transpose()
524}
525
526fn auth_deny_response(deny: &AuthDeny) -> Response<Full<Bytes>> {
527 let mut builder = Response::builder()
528 .status(deny.http_status)
529 .header("Content-Type", "application/json")
530 .header("X-Error-Code", deny.code.as_str());
531
532 if let Some(reset_at) = deny.reset_at {
533 if let Ok(duration) = reset_at.duration_since(SystemTime::now()) {
534 builder = builder.header("Retry-After", duration.as_secs().to_string());
535 }
536 }
537
538 builder
539 .body(Full::new(Bytes::from(
540 json!({
541 "error": deny.reason,
542 "message": deny.reason,
543 "code": deny.code.as_str(),
544 "retryable": deny.code.should_retry(),
545 "fatal": !deny.code.should_retry() && !deny.code.should_refresh_token(),
546 })
547 .to_string(),
548 )))
549 .unwrap()
550}
551
552#[allow(clippy::result_large_err)]
553async fn authorize_http_request(
554 remote_addr: SocketAddr,
555 req: &Request<hyper::body::Incoming>,
556 auth_plugin: Option<&Arc<dyn WebSocketAuthPlugin>>,
557 limit_state: &HttpLimitState,
558 required_scope: Option<&str>,
559 enforce_read_limits: bool,
560 solana_gateway_target_id: Option<&str>,
561) -> std::result::Result<Option<crate::websocket::auth::AuthContext>, Response<Full<Bytes>>> {
562 let Some(plugin) = auth_plugin else {
563 return Ok(None);
564 };
565
566 let mut auth_request = ConnectionAuthRequest::from_http_request(remote_addr, req);
567 auth_request.query = None;
569 let decision = plugin.authorize(&auth_request).await;
570 let context = match decision {
571 AuthDecision::Allow(context) => context,
572 AuthDecision::Deny(deny) => return Err(auth_deny_response(&deny)),
573 };
574
575 if let Some(target_id) = solana_gateway_target_id {
576 if let Err(error) =
577 arete_auth::SolanaGatewayAuthorization::validate_target(&context, target_id)
578 {
579 return Err(Response::builder()
580 .status(StatusCode::FORBIDDEN)
581 .header("Content-Type", "application/json")
582 .header("X-Error-Code", "invalid_gateway_target")
583 .body(Full::new(Bytes::from(
584 json!({
585 "error": "invalid_gateway_target",
586 "message": error.to_string(),
587 "code": "invalid_gateway_target",
588 "retryable": false,
589 "fatal": true
590 })
591 .to_string(),
592 )))
593 .expect("valid gateway target error response"));
594 }
595 }
596
597 if let Some(required_scope) = required_scope {
598 if !context.has_scope(required_scope) {
599 return Err(json_response(
600 StatusCode::FORBIDDEN,
601 json!({
602 "error": "insufficient_scope",
603 "message": format!("Required scope: {required_scope}"),
604 "code": "insufficient_scope",
605 "retryable": false,
606 "fatal": true
607 }),
608 ));
609 }
610 }
611
612 if enforce_read_limits {
613 enforce_http_limits(&context, limit_state).map_err(|deny| auth_deny_response(&deny))?;
614 }
615 Ok(Some(context))
616}
617
618fn enforce_http_limits(
619 context: &crate::websocket::auth::AuthContext,
620 limit_state: &HttpLimitState,
621) -> std::result::Result<(), Box<AuthDeny>> {
622 let Some(limit) = context.limits.max_http_requests_per_minute else {
623 return Ok(());
624 };
625
626 let now_bucket = SystemTime::now()
627 .duration_since(UNIX_EPOCH)
628 .unwrap_or(Duration::from_secs(0))
629 .as_secs()
630 / 60;
631 let key = format!("{}:{}", context.subject, context.metering_key);
632 let mut entry = limit_state
633 .per_subject_per_minute
634 .entry(key)
635 .or_insert((now_bucket, 0));
636 if entry.0 != now_bucket {
637 *entry = (now_bucket, 0);
638 }
639 if entry.1 >= limit {
640 return Err(Box::new(AuthDeny::rate_limited(
641 Duration::from_secs(60),
642 "http reads",
643 )));
644 }
645 entry.1 += 1;
646 Ok(())
647}
648
649#[allow(clippy::result_large_err)]
650async fn read_json_body<T: for<'de> Deserialize<'de>>(
651 req: Request<hyper::body::Incoming>,
652) -> std::result::Result<T, Response<Full<Bytes>>> {
653 let collected = req
654 .into_body()
655 .collect()
656 .await
657 .map_err(|err| error_response(StatusCode::BAD_REQUEST, err.to_string()))?;
658 serde_json::from_slice::<T>(&collected.to_bytes())
659 .map_err(|err| error_response(StatusCode::BAD_REQUEST, err.to_string()))
660}
661
662async fn handle_chain_request(
663 req: Request<hyper::body::Incoming>,
664 path: &str,
665 rpc_url: Arc<Option<String>>,
666 rpc_client: Client,
667 auth_context: Option<crate::websocket::auth::AuthContext>,
668) -> Response<Full<Bytes>> {
669 let Some(rpc_url) = rpc_url.as_ref() else {
670 return error_response(
671 StatusCode::SERVICE_UNAVAILABLE,
672 "No RPC URL configured for chain reads",
673 );
674 };
675
676 match (req.method().as_str(), path) {
677 ("GET", path) if path.starts_with("/chain/exists/") => {
678 let address = path.trim_start_matches("/chain/exists/");
679 match rpc_get_account_info(&rpc_client, rpc_url, address).await {
680 Ok(value) => json_response(StatusCode::OK, json!({ "exists": !value.is_null() })),
681 Err(err) => error_response(StatusCode::BAD_GATEWAY, err.to_string()),
682 }
683 }
684 ("GET", path) if path.starts_with("/chain/lamports/") => {
685 let address = path.trim_start_matches("/chain/lamports/");
686 match rpc_call(
687 &rpc_client,
688 rpc_url,
689 "getBalance",
690 json!([address, { "commitment": "confirmed" }]),
691 )
692 .await
693 {
694 Ok(value) => json_response(
695 StatusCode::OK,
696 json!({ "lamports": value.pointer("/value").and_then(Value::as_u64).unwrap_or(0) }),
697 ),
698 Err(err) => error_response(StatusCode::BAD_GATEWAY, err.to_string()),
699 }
700 }
701 ("POST", "/chain/native-balance") => match read_json_body::<NativeBalanceBody>(req).await {
702 Ok(body) => {
703 let min_context_slot =
704 match parse_min_context_slot(body.min_context_slot.as_deref()) {
705 Ok(slot) => slot,
706 Err(message) => return error_response(StatusCode::BAD_REQUEST, message),
707 };
708 match rpc_get_native_balance(&rpc_client, rpc_url, &body.address, min_context_slot)
709 .await
710 {
711 Ok(balance) => json_response(StatusCode::OK, balance),
712 Err(err) => error_response(StatusCode::BAD_GATEWAY, err.to_string()),
713 }
714 }
715 Err(response) => response,
716 },
717 ("GET", path) if path.starts_with("/chain/rent-exemption/") => {
718 let raw_space = path.trim_start_matches("/chain/rent-exemption/");
719 let Ok(space) = raw_space.parse::<u64>() else {
720 return error_response(
721 StatusCode::BAD_REQUEST,
722 "rent-exemption space must be an integer",
723 );
724 };
725 match rpc_call(
726 &rpc_client,
727 rpc_url,
728 "getMinimumBalanceForRentExemption",
729 json!([space, { "commitment": "confirmed" }]),
730 )
731 .await
732 {
733 Ok(value) => json_response(
734 StatusCode::OK,
735 json!({ "lamports": value.as_u64().unwrap_or(0) }),
736 ),
737 Err(err) => error_response(StatusCode::BAD_GATEWAY, err.to_string()),
738 }
739 }
740 ("GET", "/chain/clock") => {
741 let slot = rpc_call(
742 &rpc_client,
743 rpc_url,
744 "getSlot",
745 json!([{ "commitment": "confirmed" }]),
746 )
747 .await;
748 let epoch_info = rpc_call(
749 &rpc_client,
750 rpc_url,
751 "getEpochInfo",
752 json!([{ "commitment": "confirmed" }]),
753 )
754 .await;
755 match (slot, epoch_info) {
756 (Ok(slot_value), Ok(epoch_value)) => {
757 let slot_num = slot_value.as_u64().unwrap_or(0);
758 let unix_timestamp =
759 rpc_call(&rpc_client, rpc_url, "getBlockTime", json!([slot_num]))
760 .await
761 .ok()
762 .and_then(|value| value.as_i64())
763 .unwrap_or_default();
764 json_response(
765 StatusCode::OK,
766 json!({
767 "slot": slot_num,
768 "epoch": epoch_value.get("epoch").and_then(Value::as_u64),
769 "leaderScheduleEpoch": epoch_value.get("leaderScheduleSlotOffset").and_then(Value::as_u64),
770 "unixTimestamp": unix_timestamp,
771 }),
772 )
773 }
774 (Err(err), _) | (_, Err(err)) => {
775 error_response(StatusCode::BAD_GATEWAY, err.to_string())
776 }
777 }
778 }
779 ("GET", path) if path.starts_with("/chain/accounts/") => {
780 let address = path.trim_start_matches("/chain/accounts/");
781 match rpc_get_account_info(&rpc_client, rpc_url, address).await {
782 Ok(value) if value.is_null() => json_response(StatusCode::OK, Value::Null),
783 Ok(value) => json_response(StatusCode::OK, raw_account_json(address, &value)),
784 Err(err) => error_response(StatusCode::BAD_GATEWAY, err.to_string()),
785 }
786 }
787 ("GET", path) if path.starts_with("/chain/mints/") => {
788 let address = path.trim_start_matches("/chain/mints/");
789 match rpc_get_parsed_account_info(&rpc_client, rpc_url, address).await {
790 Ok(Some(value)) => json_response(StatusCode::OK, mint_info_json(address, &value)),
791 Ok(None) => json_response(StatusCode::OK, Value::Null),
792 Err(err) => error_response(StatusCode::BAD_GATEWAY, err.to_string()),
793 }
794 }
795 ("GET", path) if path.starts_with("/chain/token-accounts/") => {
796 let address = path.trim_start_matches("/chain/token-accounts/");
797 match rpc_get_parsed_account_info(&rpc_client, rpc_url, address).await {
798 Ok(Some(value)) => {
799 json_response(StatusCode::OK, token_account_json(address, &value))
800 }
801 Ok(None) => json_response(StatusCode::OK, Value::Null),
802 Err(err) => error_response(StatusCode::BAD_GATEWAY, err.to_string()),
803 }
804 }
805 ("POST", "/chain/accounts") => match read_json_body::<AccountsBody>(req).await {
806 Ok(body) => {
807 let configured_limit =
808 batch_address_limit(auth_context.as_ref(), MAX_CHAIN_BATCH_ADDRESSES);
809 if body.addresses.len() > configured_limit {
810 return error_response(
811 StatusCode::BAD_REQUEST,
812 format!(
813 "addresses exceeds the {configured_limit}-address limit for one batch"
814 ),
815 );
816 }
817 if body.addresses.is_empty() {
818 return json_response(StatusCode::OK, json!({ "items": [] }));
819 }
820 match rpc_get_multiple_accounts(&rpc_client, rpc_url, &body.addresses).await {
821 Ok(values) if values.len() == body.addresses.len() => json_response(
824 StatusCode::OK,
825 json!({ "items": batch_accounts_json(&body.addresses, &values) }),
826 ),
827 Ok(values) => error_response(
828 StatusCode::BAD_GATEWAY,
829 format!(
830 "getMultipleAccounts returned {} entries for {} addresses",
831 values.len(),
832 body.addresses.len()
833 ),
834 ),
835 Err(err) => error_response(StatusCode::BAD_GATEWAY, err.to_string()),
836 }
837 }
838 Err(response) => response,
839 },
840 ("POST", "/chain/balances") => match read_json_body::<BalanceBody>(req).await {
841 Ok(body) => {
842 let min_context_slot =
843 match parse_min_context_slot(body.min_context_slot.as_deref()) {
844 Ok(slot) => slot,
845 Err(message) => return error_response(StatusCode::BAD_REQUEST, message),
846 };
847 match rpc_get_token_balance(
848 &rpc_client,
849 rpc_url,
850 &body.owner,
851 &body.mint,
852 body.token_program.as_deref(),
853 min_context_slot,
854 )
855 .await
856 {
857 Ok(balance) => json_response(StatusCode::OK, balance),
858 Err(err) => error_response(StatusCode::BAD_GATEWAY, err.to_string()),
859 }
860 }
861 Err(response) => response,
862 },
863 _ => error_response(StatusCode::NOT_FOUND, "Not Found"),
864 }
865}
866
867async fn handle_program_account_request(
868 req: Request<hyper::body::Incoming>,
869 path: &str,
870 rpc_url: Arc<Option<String>>,
871 rpc_client: Client,
872 program_runtime_catalog: Arc<ProgramRuntimeCatalog>,
873 auth_context: Option<crate::websocket::auth::AuthContext>,
874 program_read_binding_target_id: Arc<Option<String>>,
875) -> Response<Full<Bytes>> {
876 let route = match ProgramReadRoute::parse(req.method(), path) {
877 Ok(route) => route,
878 Err(error) => return program_read_error_response(error),
879 };
880 let release_hash = match route.release_hash.parse() {
881 Ok(release_hash) => release_hash,
882 Err(_) => return program_read_error_response(ProgramReadError::InvalidReleaseHash),
883 };
884 let Some(definition) = program_runtime_catalog.get(&release_hash).cloned() else {
885 return program_read_error_response(ProgramReadError::ReleaseNotFound);
886 };
887 if let Err(error) = authorize_program_read(
888 auth_context.as_ref(),
889 program_read_binding_target_id.as_deref(),
890 &definition,
891 ) {
892 return with_release_metadata(program_read_error_response(error), &definition, None, None);
893 }
894 let Some(rpc_url) = rpc_url.as_ref() else {
895 return with_release_metadata(
896 program_read_error_response(ProgramReadError::RpcNotConfigured),
897 &definition,
898 None,
899 None,
900 );
901 };
902
903 match route.operation {
904 ProgramReadOperation::Fetch { address } => {
905 let account_value = match rpc_get_account_info(&rpc_client, rpc_url, &address).await {
906 Ok(value) => value,
907 Err(error) => {
908 error!("Program account RPC read failed: {}", error);
909 return with_release_metadata(
910 program_read_error_response(ProgramReadError::RpcFailed),
911 &definition,
912 Some(&address),
913 None,
914 );
915 }
916 };
917 match decode_release_account(&definition, &route.account, &account_value).await {
918 AccountReadOutcome::Missing => with_release_metadata(
919 json_response(StatusCode::OK, Value::Null),
920 &definition,
921 Some(&address),
922 Some(false),
923 ),
924 AccountReadOutcome::Value(value) => with_release_metadata(
925 json_response(StatusCode::OK, value),
926 &definition,
927 Some(&address),
928 Some(true),
929 ),
930 AccountReadOutcome::Error(error) => with_release_metadata(
931 program_read_error_response(error),
932 &definition,
933 Some(&address),
934 Some(true),
935 ),
936 }
937 }
938 ProgramReadOperation::Exists { address } => {
939 let account_value = match rpc_get_account_info(&rpc_client, rpc_url, &address).await {
940 Ok(value) => value,
941 Err(error) => {
942 error!("Program account existence RPC read failed: {}", error);
943 return with_release_metadata(
944 program_read_error_response(ProgramReadError::RpcFailed),
945 &definition,
946 Some(&address),
947 None,
948 );
949 }
950 };
951 let exists = !account_value.is_null();
952 if exists && !account_owner_matches(&definition, &account_value) {
953 return with_release_metadata(
954 program_read_error_response(ProgramReadError::AccountOwnerMismatch),
955 &definition,
956 Some(&address),
957 Some(true),
958 );
959 }
960 with_release_metadata(
961 json_response(StatusCode::OK, json!({ "exists": exists })),
962 &definition,
963 Some(&address),
964 Some(exists),
965 )
966 }
967 ProgramReadOperation::Batch => {
968 let body = match read_json_body::<AddressesBody>(req).await {
969 Ok(body) => body,
970 Err(_) => return program_read_error_response(ProgramReadError::InvalidRequest),
971 };
972 let configured_limit =
973 batch_address_limit(auth_context.as_ref(), MAX_PROGRAM_BATCH_ADDRESSES);
974 if body.addresses.len() > configured_limit {
975 return with_release_metadata(
976 program_read_error_response(ProgramReadError::BatchLimitExceeded),
977 &definition,
978 None,
979 None,
980 );
981 }
982 if body.addresses.is_empty() {
983 return with_release_metadata(
984 json_response(StatusCode::OK, json!({ "items": [] })),
985 &definition,
986 None,
987 None,
988 );
989 }
990
991 let values =
992 match rpc_get_multiple_accounts(&rpc_client, rpc_url, &body.addresses).await {
993 Ok(values) if values.len() == body.addresses.len() => values,
994 Ok(_) => {
995 return with_release_metadata(
996 program_read_error_response(ProgramReadError::RpcResponseInvalid),
997 &definition,
998 None,
999 None,
1000 )
1001 }
1002 Err(error) => {
1003 error!("Program account batch RPC read failed: {}", error);
1004 return with_release_metadata(
1005 program_read_error_response(ProgramReadError::RpcFailed),
1006 &definition,
1007 None,
1008 None,
1009 );
1010 }
1011 };
1012
1013 let outcomes = futures_util::future::join_all(
1014 values
1015 .iter()
1016 .map(|value| decode_release_account(&definition, &route.account, value)),
1017 )
1018 .await;
1019 let mut items = Vec::with_capacity(outcomes.len());
1020 for (address, outcome) in body.addresses.iter().zip(outcomes) {
1021 let item = match outcome {
1022 AccountReadOutcome::Missing => {
1023 json!({ "address": address, "status": "missing" })
1024 }
1025 AccountReadOutcome::Value(value) => {
1026 json!({ "address": address, "status": "ok", "value": value })
1027 }
1028 AccountReadOutcome::Error(error) => json!({
1029 "address": address,
1030 "status": "error",
1031 "error": { "code": error.code() }
1032 }),
1033 };
1034 items.push(item);
1035 }
1036 with_release_metadata(
1037 json_response(StatusCode::OK, json!({ "items": items })),
1038 &definition,
1039 None,
1040 None,
1041 )
1042 }
1043 }
1044}
1045
1046const MAX_PROGRAM_BATCH_ADDRESSES: usize = 100;
1047const MAX_PROGRAM_ACCOUNT_BYTES: usize = 10 * 1024 * 1024;
1048const PROGRAM_DECODE_TIMEOUT: Duration = Duration::from_secs(2);
1049
1050fn authorize_program_read(
1051 auth_context: Option<&crate::websocket::auth::AuthContext>,
1052 expected_target_id: Option<&str>,
1053 definition: &ProgramRuntimeDefinition,
1054) -> std::result::Result<(), ProgramReadError> {
1055 let Some(context) = auth_context else {
1056 return Ok(());
1057 };
1058 let Some(expected_target_id) = expected_target_id.filter(|target_id| !target_id.is_empty())
1059 else {
1060 return Err(ProgramReadError::AuthorizationNotConfigured);
1061 };
1062
1063 arete_auth::ProgramReadAuthorization::try_from_context(
1064 context,
1065 expected_target_id,
1066 &definition.program_id,
1067 &definition.program_release_hash.to_string(),
1068 )
1069 .map(|_| ())
1070 .map_err(|_| ProgramReadError::Unauthorized)
1071}
1072
1073#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1074enum ProgramReadError {
1075 NotFound,
1076 ProgramReadsDisabled,
1077 InvalidReleaseHash,
1078 ReleaseNotFound,
1079 InvalidRequest,
1080 Unauthorized,
1081 AuthorizationNotConfigured,
1082 BatchLimitExceeded,
1083 RpcNotConfigured,
1084 RpcFailed,
1085 RpcResponseInvalid,
1086 AccountOwnerMismatch,
1087 AccountDataInvalid,
1088 AccountDataTooLarge,
1089 AccountDecodeFailed,
1090 AccountDecodeTimeout,
1091}
1092
1093impl ProgramReadError {
1094 fn code(self) -> &'static str {
1095 match self {
1096 Self::NotFound => "NOT_FOUND",
1097 Self::ProgramReadsDisabled => "PROGRAM_READS_DISABLED",
1098 Self::InvalidReleaseHash => "INVALID_PROGRAM_RELEASE_HASH",
1099 Self::ReleaseNotFound => "PROGRAM_RELEASE_NOT_FOUND",
1100 Self::InvalidRequest => "INVALID_REQUEST",
1101 Self::Unauthorized => "PROGRAM_READ_UNAUTHORIZED",
1102 Self::AuthorizationNotConfigured => "PROGRAM_READ_AUTH_NOT_CONFIGURED",
1103 Self::BatchLimitExceeded => "BATCH_LIMIT_EXCEEDED",
1104 Self::RpcNotConfigured => "READ_RPC_NOT_CONFIGURED",
1105 Self::RpcFailed => "RPC_REQUEST_FAILED",
1106 Self::RpcResponseInvalid => "RPC_RESPONSE_INVALID",
1107 Self::AccountOwnerMismatch => "ACCOUNT_OWNER_MISMATCH",
1108 Self::AccountDataInvalid => "ACCOUNT_DATA_INVALID",
1109 Self::AccountDataTooLarge => "ACCOUNT_DATA_TOO_LARGE",
1110 Self::AccountDecodeFailed => "ACCOUNT_DECODE_FAILED",
1111 Self::AccountDecodeTimeout => "ACCOUNT_DECODE_TIMEOUT",
1112 }
1113 }
1114
1115 fn status(self) -> StatusCode {
1116 match self {
1117 Self::NotFound | Self::ReleaseNotFound => StatusCode::NOT_FOUND,
1118 Self::InvalidReleaseHash | Self::InvalidRequest => StatusCode::BAD_REQUEST,
1119 Self::Unauthorized => StatusCode::FORBIDDEN,
1120 Self::AuthorizationNotConfigured => StatusCode::SERVICE_UNAVAILABLE,
1121 Self::BatchLimitExceeded | Self::AccountDataTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
1122 Self::ProgramReadsDisabled | Self::RpcNotConfigured => StatusCode::SERVICE_UNAVAILABLE,
1123 Self::RpcFailed | Self::RpcResponseInvalid | Self::AccountDataInvalid => {
1124 StatusCode::BAD_GATEWAY
1125 }
1126 Self::AccountOwnerMismatch | Self::AccountDecodeFailed => {
1127 StatusCode::UNPROCESSABLE_ENTITY
1128 }
1129 Self::AccountDecodeTimeout => StatusCode::GATEWAY_TIMEOUT,
1130 }
1131 }
1132}
1133
1134fn program_read_error_response(error: ProgramReadError) -> Response<Full<Bytes>> {
1135 Response::builder()
1136 .status(error.status())
1137 .header("Content-Type", "application/json")
1138 .header("X-Error-Code", error.code())
1139 .body(Full::new(Bytes::from(
1140 json!({ "error": { "code": error.code() } }).to_string(),
1141 )))
1142 .expect("valid program read error response")
1143}
1144
1145#[derive(Debug)]
1146struct ProgramReadRoute {
1147 release_hash: String,
1148 account: String,
1149 operation: ProgramReadOperation,
1150}
1151
1152#[derive(Debug)]
1153enum ProgramReadOperation {
1154 Fetch { address: String },
1155 Batch,
1156 Exists { address: String },
1157}
1158
1159impl ProgramReadRoute {
1160 fn parse(method: &Method, path: &str) -> std::result::Result<Self, ProgramReadError> {
1161 let segments: Vec<&str> = path.trim_start_matches('/').split('/').collect();
1162 if segments.len() < 5
1163 || segments[0] != "v1"
1164 || segments[1] != "releases"
1165 || segments[2].is_empty()
1166 || segments[3] != "accounts"
1167 || segments[4].is_empty()
1168 {
1169 return Err(ProgramReadError::NotFound);
1170 }
1171 let operation = match (method, segments.as_slice()) {
1172 (&Method::POST, [_, _, _, _, _]) => ProgramReadOperation::Batch,
1173 (&Method::GET, [_, _, _, _, _, address]) if !address.is_empty() => {
1174 ProgramReadOperation::Fetch {
1175 address: (*address).to_string(),
1176 }
1177 }
1178 (&Method::GET, [_, _, _, _, _, address, "exists"]) if !address.is_empty() => {
1179 ProgramReadOperation::Exists {
1180 address: (*address).to_string(),
1181 }
1182 }
1183 _ => return Err(ProgramReadError::NotFound),
1184 };
1185 Ok(Self {
1186 release_hash: segments[2].to_string(),
1187 account: segments[4].to_string(),
1188 operation,
1189 })
1190 }
1191}
1192
1193enum AccountReadOutcome {
1194 Missing,
1195 Value(Value),
1196 Error(ProgramReadError),
1197}
1198
1199fn account_owner_matches(definition: &ProgramRuntimeDefinition, value: &Value) -> bool {
1200 value.get("owner").and_then(Value::as_str) == Some(definition.program_id.as_str())
1201}
1202
1203async fn decode_release_account(
1204 definition: &ProgramRuntimeDefinition,
1205 account: &str,
1206 value: &Value,
1207) -> AccountReadOutcome {
1208 if value.is_null() {
1209 return AccountReadOutcome::Missing;
1210 }
1211 if !account_owner_matches(definition, value) {
1212 return AccountReadOutcome::Error(ProgramReadError::AccountOwnerMismatch);
1213 }
1214 let data = match decode_account_bytes(value) {
1215 Some(data) => data,
1216 None => return AccountReadOutcome::Error(ProgramReadError::AccountDataInvalid),
1217 };
1218 if data.len() > MAX_PROGRAM_ACCOUNT_BYTES {
1219 return AccountReadOutcome::Error(ProgramReadError::AccountDataTooLarge);
1220 }
1221
1222 let reader = definition.account_reader.clone();
1223 let account = account.to_string();
1224 let decode = tokio::task::spawn_blocking(move || reader(&account, &data));
1225 match tokio::time::timeout(PROGRAM_DECODE_TIMEOUT, decode).await {
1226 Ok(Ok(Ok(value))) => AccountReadOutcome::Value(value),
1227 Ok(Ok(Err(_))) | Ok(Err(_)) => {
1228 AccountReadOutcome::Error(ProgramReadError::AccountDecodeFailed)
1229 }
1230 Err(_) => AccountReadOutcome::Error(ProgramReadError::AccountDecodeTimeout),
1231 }
1232}
1233
1234fn with_release_metadata(
1235 mut response: Response<Full<Bytes>>,
1236 definition: &ProgramRuntimeDefinition,
1237 address: Option<&str>,
1238 exists: Option<bool>,
1239) -> Response<Full<Bytes>> {
1240 let headers = response.headers_mut();
1241 if let Ok(value) = HeaderValue::from_str(&definition.program_release_hash.to_string()) {
1242 headers.insert("X-Arete-Program-Release-Hash", value);
1243 }
1244 if let Ok(value) = HeaderValue::from_str(&definition.idl_content_hash.to_string()) {
1245 headers.insert("X-Arete-Idl-Content-Hash", value);
1246 }
1247 if let Some(address) = address {
1248 if let Ok(value) = HeaderValue::from_str(address) {
1249 headers.insert("X-Arete-Account-Address", value);
1250 }
1251 }
1252 if let Some(exists) = exists {
1253 headers.insert(
1254 "X-Arete-Account-Exists",
1255 HeaderValue::from_static(if exists { "true" } else { "false" }),
1256 );
1257 }
1258 response
1259}
1260
1261async fn rpc_call(
1262 client: &Client,
1263 rpc_url: &str,
1264 method: &str,
1265 params: Value,
1266) -> anyhow::Result<Value> {
1267 let response = client
1268 .post(rpc_url)
1269 .json(&json!({
1270 "jsonrpc": "2.0",
1271 "id": "arete-read",
1272 "method": method,
1273 "params": params,
1274 }))
1275 .send()
1276 .await?
1277 .error_for_status()?;
1278 let value = response.json::<Value>().await?;
1279 if let Some(error) = value.get("error") {
1280 return Err(anyhow::anyhow!(error.to_string()));
1281 }
1282 Ok(value.get("result").cloned().unwrap_or(Value::Null))
1283}
1284
1285fn rpc_read_config(encoding: Option<&str>, min_context_slot: Option<u64>) -> Value {
1286 let mut config = json!({ "commitment": "confirmed" });
1287 let object = config
1288 .as_object_mut()
1289 .expect("RPC read config is always an object");
1290 if let Some(encoding) = encoding {
1291 object.insert("encoding".to_string(), json!(encoding));
1292 }
1293 if let Some(min_context_slot) = min_context_slot {
1294 object.insert("minContextSlot".to_string(), json!(min_context_slot));
1295 }
1296 config
1297}
1298
1299async fn rpc_get_native_balance(
1300 client: &Client,
1301 rpc_url: &str,
1302 address: &str,
1303 min_context_slot: Option<u64>,
1304) -> anyhow::Result<Value> {
1305 let result = rpc_call(
1306 client,
1307 rpc_url,
1308 "getBalance",
1309 json!([address, rpc_read_config(None, min_context_slot)]),
1310 )
1311 .await?;
1312 contextual_native_balance_json(&result)
1313}
1314
1315fn contextual_native_balance_json(result: &Value) -> anyhow::Result<Value> {
1316 let lamports = result
1317 .get("value")
1318 .and_then(Value::as_u64)
1319 .ok_or_else(|| anyhow::anyhow!("getBalance response is missing a u64 value"))?;
1320 let context_slot = result
1321 .pointer("/context/slot")
1322 .and_then(Value::as_u64)
1323 .ok_or_else(|| anyhow::anyhow!("getBalance response is missing a u64 context slot"))?;
1324 Ok(json!({
1325 "lamports": lamports.to_string(),
1326 "contextSlot": context_slot.to_string(),
1327 }))
1328}
1329
1330async fn rpc_get_account_info(
1331 client: &Client,
1332 rpc_url: &str,
1333 address: &str,
1334) -> anyhow::Result<Value> {
1335 let result = rpc_call(
1336 client,
1337 rpc_url,
1338 "getAccountInfo",
1339 json!([address, { "encoding": "base64", "commitment": "confirmed" }]),
1340 )
1341 .await?;
1342 Ok(result.get("value").cloned().unwrap_or(Value::Null))
1343}
1344
1345async fn rpc_get_multiple_accounts(
1346 client: &Client,
1347 rpc_url: &str,
1348 addresses: &[String],
1349) -> anyhow::Result<Vec<Value>> {
1350 let result = rpc_call(
1351 client,
1352 rpc_url,
1353 "getMultipleAccounts",
1354 json!([addresses, { "encoding": "base64", "commitment": "confirmed" }]),
1355 )
1356 .await?;
1357 Ok(result
1358 .get("value")
1359 .and_then(Value::as_array)
1360 .cloned()
1361 .unwrap_or_default())
1362}
1363
1364async fn rpc_get_parsed_account_info(
1365 client: &Client,
1366 rpc_url: &str,
1367 address: &str,
1368) -> anyhow::Result<Option<Value>> {
1369 let result = rpc_call(
1370 client,
1371 rpc_url,
1372 "getAccountInfo",
1373 json!([address, { "encoding": "jsonParsed", "commitment": "confirmed" }]),
1374 )
1375 .await?;
1376 Ok(result
1377 .get("value")
1378 .cloned()
1379 .filter(|value| !value.is_null()))
1380}
1381
1382async fn rpc_get_token_balance(
1383 client: &Client,
1384 rpc_url: &str,
1385 owner: &str,
1386 mint: &str,
1387 token_program: Option<&str>,
1388 min_context_slot: Option<u64>,
1389) -> anyhow::Result<Value> {
1390 let filter = token_program
1391 .map(|program_id| json!({ "programId": program_id }))
1392 .unwrap_or_else(|| json!({ "mint": mint }));
1393 let result = rpc_call(
1394 client,
1395 rpc_url,
1396 "getTokenAccountsByOwner",
1397 json!([
1398 owner,
1399 filter,
1400 rpc_read_config(Some("jsonParsed"), min_context_slot)
1401 ]),
1402 )
1403 .await?;
1404
1405 contextual_token_balance_json(&result, owner, mint, token_program)
1406}
1407
1408fn contextual_token_balance_json(
1409 result: &Value,
1410 owner: &str,
1411 mint: &str,
1412 token_program: Option<&str>,
1413) -> anyhow::Result<Value> {
1414 let context_slot = result
1415 .pointer("/context/slot")
1416 .and_then(Value::as_u64)
1417 .ok_or_else(|| {
1418 anyhow::anyhow!("getTokenAccountsByOwner response is missing a u64 context slot")
1419 })?;
1420
1421 let account = result
1422 .get("value")
1423 .and_then(Value::as_array)
1424 .and_then(|items| {
1425 items.iter().find(|item| {
1426 item.pointer("/account/data/parsed/info/mint")
1427 .and_then(Value::as_str)
1428 == Some(mint)
1429 })
1430 })
1431 .cloned();
1432
1433 if let Some(account) = account {
1434 let pubkey = account.get("pubkey").and_then(Value::as_str);
1435 let info = account.pointer("/account/data/parsed/info");
1436 return Ok(json!({
1437 "exists": true,
1438 "address": pubkey,
1439 "owner": owner,
1440 "mint": mint,
1441 "tokenProgram": token_program,
1442 "amount": info.and_then(|value| value.pointer("/tokenAmount/amount")).and_then(Value::as_str).unwrap_or("0"),
1443 "decimals": info.and_then(|value| value.pointer("/tokenAmount/decimals")).and_then(Value::as_u64),
1444 "uiAmountString": info.and_then(|value| value.pointer("/tokenAmount/uiAmountString")).and_then(Value::as_str),
1445 "contextSlot": context_slot.to_string(),
1446 }));
1447 }
1448
1449 Ok(json!({
1450 "exists": false,
1451 "address": Value::Null,
1452 "owner": owner,
1453 "mint": mint,
1454 "tokenProgram": token_program,
1455 "amount": "0",
1456 "decimals": Value::Null,
1457 "uiAmountString": Value::Null,
1458 "contextSlot": context_slot.to_string(),
1459 }))
1460}
1461
1462fn decode_account_bytes(value: &Value) -> Option<Vec<u8>> {
1463 let data = value.get("data")?.as_array()?;
1464 let encoded = data.first()?.as_str()?;
1465 BASE64_STANDARD.decode(encoded).ok()
1466}
1467
1468fn raw_account_json(address: &str, value: &Value) -> Value {
1476 json!({
1477 "address": address,
1478 "ownerProgram": value.get("owner").and_then(Value::as_str).unwrap_or_default(),
1479 "lamports": value
1480 .get("lamports")
1481 .and_then(Value::as_u64)
1482 .unwrap_or(0)
1483 .to_string(),
1484 "executable": value.get("executable").and_then(Value::as_bool).unwrap_or(false),
1485 "data": value
1486 .pointer("/data/0")
1487 .and_then(Value::as_str)
1488 .unwrap_or_default(),
1489 })
1490}
1491
1492fn batch_address_limit(
1500 auth_context: Option<&crate::websocket::auth::AuthContext>,
1501 protocol_max: usize,
1502) -> usize {
1503 auth_context
1504 .and_then(|ctx| ctx.limits.max_http_batch_addresses)
1505 .map(|limit| limit as usize)
1506 .unwrap_or(protocol_max)
1507 .min(protocol_max)
1508}
1509
1510const MAX_CHAIN_BATCH_ADDRESSES: usize = 100;
1512
1513fn batch_accounts_json(addresses: &[String], values: &[Value]) -> Vec<Value> {
1516 addresses
1517 .iter()
1518 .zip(values)
1519 .map(|(address, value)| {
1520 if value.is_null() {
1521 Value::Null
1522 } else {
1523 raw_account_json(address, value)
1524 }
1525 })
1526 .collect()
1527}
1528
1529fn mint_info_json(address: &str, value: &Value) -> Value {
1530 let owner_program = value
1531 .get("owner")
1532 .and_then(Value::as_str)
1533 .unwrap_or_default();
1534 let info = value.pointer("/data/parsed/info");
1535 json!({
1536 "address": address,
1537 "ownerProgram": owner_program,
1538 "decimals": info.and_then(|v| v.get("decimals")).and_then(Value::as_u64),
1539 "supply": info.and_then(|v| v.get("supply")).and_then(Value::as_str),
1540 "mintAuthority": info.and_then(|v| v.get("mintAuthority")).and_then(Value::as_str),
1541 "freezeAuthority": info.and_then(|v| v.get("freezeAuthority")).and_then(Value::as_str),
1542 })
1543}
1544
1545fn token_account_json(address: &str, value: &Value) -> Value {
1546 let owner_program = value
1547 .get("owner")
1548 .and_then(Value::as_str)
1549 .unwrap_or_default();
1550 let info = value.pointer("/data/parsed/info");
1551 json!({
1552 "address": address,
1553 "ownerProgram": owner_program,
1554 "mint": info.and_then(|v| v.get("mint")).and_then(Value::as_str),
1555 "owner": info.and_then(|v| v.get("owner")).and_then(Value::as_str),
1556 "amount": info.and_then(|v| v.pointer("/tokenAmount/amount")).and_then(Value::as_str),
1557 "uiAmountString": info.and_then(|v| v.pointer("/tokenAmount/uiAmountString")).and_then(Value::as_str),
1558 })
1559}
1560
1561#[cfg(test)]
1562mod tests {
1563 use super::*;
1564
1565 #[test]
1566 fn cors_headers_allow_browser_sdk_reads() {
1567 let response = with_cors(
1568 Response::builder()
1569 .status(StatusCode::OK)
1570 .body(Full::new(Bytes::new()))
1571 .unwrap(),
1572 );
1573
1574 assert_eq!(response.headers()[ACCESS_CONTROL_ALLOW_ORIGIN], "*");
1575 assert_eq!(
1576 response.headers()[ACCESS_CONTROL_ALLOW_METHODS],
1577 "GET, POST, OPTIONS"
1578 );
1579 assert_eq!(
1580 response.headers()[ACCESS_CONTROL_ALLOW_HEADERS],
1581 "Authorization, Content-Type"
1582 );
1583 }
1584
1585 #[test]
1586 fn native_balance_serializes_u64_values_as_decimal_strings() {
1587 let value = contextual_native_balance_json(&json!({
1588 "context": { "slot": 9_007_199_254_740_995_u64 },
1589 "value": 9_007_199_254_740_993_u64,
1590 }))
1591 .unwrap();
1592
1593 assert_eq!(value["lamports"], "9007199254740993");
1594 assert_eq!(value["contextSlot"], "9007199254740995");
1595 }
1596
1597 #[test]
1600 fn batch_accounts_keep_position_when_an_account_is_absent() {
1601 let addresses = vec!["a".to_string(), "b".to_string(), "c".to_string()];
1602 let values = vec![
1603 json!({ "owner": "prog", "lamports": 7u64, "executable": false, "data": ["AQI=", "base64"] }),
1604 Value::Null,
1605 json!({ "owner": "prog", "lamports": 9u64, "executable": false, "data": ["AwQ=", "base64"] }),
1606 ];
1607
1608 let items = batch_accounts_json(&addresses, &values);
1609
1610 assert_eq!(items.len(), 3);
1611 assert_eq!(items[0]["address"], "a");
1612 assert_eq!(items[0]["lamports"], "7");
1613 assert_eq!(items[0]["data"], "AQI=");
1614 assert_eq!(items[1], Value::Null, "absent account keeps its slot");
1615 assert_eq!(items[2]["address"], "c");
1616 assert_eq!(items[2]["lamports"], "9");
1617 }
1618
1619 #[test]
1623 fn batch_accounts_serialize_lamports_as_decimal_strings() {
1624 let addresses = vec!["big".to_string()];
1625 let values = vec![json!({
1626 "owner": "prog",
1627 "lamports": 9_007_199_254_740_993_u64,
1628 "executable": false,
1629 "data": ["AQI=", "base64"],
1630 })];
1631
1632 let items = batch_accounts_json(&addresses, &values);
1633
1634 assert_eq!(items[0]["lamports"], "9007199254740993");
1635 }
1636
1637 fn auth_context_allowing(
1638 max_batch_addresses: Option<u32>,
1639 ) -> crate::websocket::auth::AuthContext {
1640 crate::websocket::auth::AuthContext {
1641 subject: "test".to_string(),
1642 issuer: "test-issuer".to_string(),
1643 audience: "test-audience".to_string(),
1644 key_class: arete_auth::KeyClass::Publishable,
1645 metering_key: "meter-test".to_string(),
1646 deployment_id: None,
1647 target_kind: None,
1648 target_id: None,
1649 program_id: None,
1650 program_release_hash: None,
1651 expires_at: u64::MAX,
1652 scope: "read".to_string(),
1653 limits: arete_auth::Limits {
1654 max_http_batch_addresses: max_batch_addresses,
1655 ..Default::default()
1656 },
1657 plan: None,
1658 origin: None,
1659 client_ip: None,
1660 jti: "test-jti".to_string(),
1661 actor_key: None,
1662 account_key: None,
1663 consumer_key: None,
1664 policy_version: None,
1665 account_limits: arete_auth::Limits::default(),
1666 }
1667 }
1668
1669 #[test]
1673 fn a_session_ceiling_bounds_the_batch_below_the_protocol_limit() {
1674 let context = auth_context_allowing(Some(10));
1675
1676 assert_eq!(
1677 batch_address_limit(Some(&context), MAX_CHAIN_BATCH_ADDRESSES),
1678 10
1679 );
1680 }
1681
1682 #[test]
1684 fn a_session_ceiling_cannot_exceed_the_protocol_limit() {
1685 let context = auth_context_allowing(Some(1_000));
1686
1687 assert_eq!(
1688 batch_address_limit(Some(&context), MAX_CHAIN_BATCH_ADDRESSES),
1689 MAX_CHAIN_BATCH_ADDRESSES
1690 );
1691 }
1692
1693 #[test]
1695 fn an_absent_ceiling_falls_back_to_the_protocol_limit() {
1696 let context = auth_context_allowing(None);
1697
1698 assert_eq!(
1699 batch_address_limit(Some(&context), MAX_CHAIN_BATCH_ADDRESSES),
1700 MAX_CHAIN_BATCH_ADDRESSES
1701 );
1702 assert_eq!(
1703 batch_address_limit(None, MAX_PROGRAM_BATCH_ADDRESSES),
1704 MAX_PROGRAM_BATCH_ADDRESSES
1705 );
1706 }
1707
1708 #[test]
1709 fn batch_accounts_json_is_empty_for_no_addresses() {
1710 assert!(batch_accounts_json(&[], &[]).is_empty());
1711 }
1712
1713 #[test]
1716 fn batch_item_matches_the_single_address_shape() {
1717 let value = json!({ "owner": "prog", "lamports": 5u64, "executable": true, "data": ["BQY=", "base64"] });
1718 let addresses = vec!["solo".to_string()];
1719
1720 assert_eq!(
1721 batch_accounts_json(&addresses, std::slice::from_ref(&value))[0],
1722 raw_account_json("solo", &value)
1723 );
1724 }
1725
1726 #[test]
1727 fn rpc_read_config_propagates_min_context_slot() {
1728 let config = rpc_read_config(Some("jsonParsed"), Some(9_007_199_254_740_997));
1729
1730 assert_eq!(config["commitment"], "confirmed");
1731 assert_eq!(config["encoding"], "jsonParsed");
1732 assert_eq!(config["minContextSlot"], 9_007_199_254_740_997_u64);
1733 }
1734
1735 #[test]
1736 fn token_balance_preserves_raw_amount_and_stringifies_context_slot() {
1737 let value = contextual_token_balance_json(
1738 &json!({
1739 "context": { "slot": 9_007_199_254_740_995_u64 },
1740 "value": [{
1741 "pubkey": "token-account",
1742 "account": {
1743 "data": {
1744 "parsed": {
1745 "info": {
1746 "mint": "mint",
1747 "tokenAmount": {
1748 "amount": "18446744073709551615",
1749 "decimals": 9,
1750 "uiAmountString": "18446744073.709551615"
1751 }
1752 }
1753 }
1754 }
1755 }
1756 }]
1757 }),
1758 "owner",
1759 "mint",
1760 None,
1761 )
1762 .unwrap();
1763
1764 assert_eq!(value["amount"], "18446744073709551615");
1765 assert_eq!(value["contextSlot"], "9007199254740995");
1766 }
1767
1768 #[test]
1769 fn balance_bodies_accept_decimal_string_min_context_slots() {
1770 let native: NativeBalanceBody = serde_json::from_value(json!({
1771 "address": "owner",
1772 "minContextSlot": "9007199254740997",
1773 }))
1774 .unwrap();
1775 let token: BalanceBody = serde_json::from_value(json!({
1776 "owner": "owner",
1777 "mint": "mint",
1778 "minContextSlot": "9007199254740999",
1779 }))
1780 .unwrap();
1781
1782 assert_eq!(
1783 parse_min_context_slot(native.min_context_slot.as_deref()).unwrap(),
1784 Some(9_007_199_254_740_997)
1785 );
1786 assert_eq!(
1787 parse_min_context_slot(token.min_context_slot.as_deref()).unwrap(),
1788 Some(9_007_199_254_740_999)
1789 );
1790 }
1791
1792 fn runtime_definition(reader: crate::ProgramAccountReaderFn) -> ProgramRuntimeDefinition {
1793 let program_spec_hash = crate::ProgramSpecHash::from_digest([1; 32]);
1794 let idl_content_hash = crate::IdlContentHash::from_digest([2; 32]);
1795 let normalized_idl_hash = crate::NormalizedIdlHash::from_digest([3; 32]);
1796 let program_release_hash = arete_hash::OssGeneratedProgramReleaseV1::new(
1797 "Program111",
1798 program_spec_hash,
1799 idl_content_hash,
1800 normalized_idl_hash,
1801 )
1802 .hash()
1803 .unwrap();
1804 ProgramRuntimeDefinition {
1805 program_id: "Program111".to_string(),
1806 program_spec_hash,
1807 idl_content_hash,
1808 normalized_idl_hash,
1809 program_release_hash,
1810 account_reader: reader,
1811 }
1812 }
1813
1814 fn program_read_context(
1815 target_id: &str,
1816 program_id: &str,
1817 release_hash: &str,
1818 ) -> crate::websocket::auth::AuthContext {
1819 crate::websocket::auth::AuthContext::from_claims(
1820 arete_auth::SessionClaims::program_read_builder(
1821 "issuer",
1822 "user:1",
1823 target_id,
1824 program_id,
1825 release_hash,
1826 )
1827 .build(),
1828 )
1829 }
1830
1831 #[test]
1832 fn program_read_auth_is_bound_to_target_program_and_release() {
1833 let definition = runtime_definition(Arc::new(|_, _| Ok(Value::Null)));
1834 let release_hash = definition.program_release_hash.to_string();
1835 let valid = program_read_context("binding-1", &definition.program_id, &release_hash);
1836
1837 assert_eq!(
1838 authorize_program_read(Some(&valid), Some("binding-1"), &definition),
1839 Ok(())
1840 );
1841
1842 let wrong_target = program_read_context("binding-2", &definition.program_id, &release_hash);
1843 let wrong_program = program_read_context("binding-1", "Program222", &release_hash);
1844 let wrong_release = program_read_context(
1845 "binding-1",
1846 &definition.program_id,
1847 "arete:h1:program-release:sha256:different",
1848 );
1849
1850 for context in [&wrong_target, &wrong_program, &wrong_release] {
1851 assert_eq!(
1852 authorize_program_read(Some(context), Some("binding-1"), &definition),
1853 Err(ProgramReadError::Unauthorized)
1854 );
1855 }
1856 assert_eq!(
1857 authorize_program_read(Some(&valid), None, &definition),
1858 Err(ProgramReadError::AuthorizationNotConfigured)
1859 );
1860 assert_eq!(authorize_program_read(None, None, &definition), Ok(()));
1861 }
1862
1863 #[test]
1864 fn release_routes_are_exact_and_legacy_program_routes_are_not_accepted() {
1865 let hash = crate::ProgramReleaseHash::from_digest([4; 32]);
1866 let fetch_path = format!("/v1/releases/{hash}/accounts/Vault/address");
1867 let exists_path = format!("{fetch_path}/exists");
1868 let batch_path = format!("/v1/releases/{hash}/accounts/Vault");
1869
1870 assert!(matches!(
1871 ProgramReadRoute::parse(&Method::GET, &fetch_path)
1872 .unwrap()
1873 .operation,
1874 ProgramReadOperation::Fetch { .. }
1875 ));
1876 assert!(matches!(
1877 ProgramReadRoute::parse(&Method::GET, &exists_path)
1878 .unwrap()
1879 .operation,
1880 ProgramReadOperation::Exists { .. }
1881 ));
1882 assert!(matches!(
1883 ProgramReadRoute::parse(&Method::POST, &batch_path)
1884 .unwrap()
1885 .operation,
1886 ProgramReadOperation::Batch
1887 ));
1888 assert_eq!(
1889 ProgramReadRoute::parse(&Method::GET, "/programs/demo/accounts/Vault/address")
1890 .unwrap_err(),
1891 ProgramReadError::NotFound
1892 );
1893 }
1894
1895 #[tokio::test]
1896 async fn owner_mismatch_is_rejected_before_decoder_execution() {
1897 use std::sync::atomic::{AtomicBool, Ordering};
1898
1899 let called = Arc::new(AtomicBool::new(false));
1900 let called_by_reader = called.clone();
1901 let definition = runtime_definition(Arc::new(move |_, _| {
1902 called_by_reader.store(true, Ordering::SeqCst);
1903 Ok(json!({ "decoded": true }))
1904 }));
1905 let value = json!({
1906 "owner": "DifferentProgram",
1907 "data": [BASE64_STANDARD.encode([1, 2, 3]), "base64"]
1908 });
1909
1910 assert!(matches!(
1911 decode_release_account(&definition, "Vault", &value).await,
1912 AccountReadOutcome::Error(ProgramReadError::AccountOwnerMismatch)
1913 ));
1914 assert!(!called.load(Ordering::SeqCst));
1915 }
1916
1917 #[tokio::test]
1918 async fn decode_failures_remain_typed_errors_and_metadata_is_public_only() {
1919 let definition = runtime_definition(Arc::new(|_, _| anyhow::bail!("private diagnostic")));
1920 let value = json!({
1921 "owner": "Program111",
1922 "data": [BASE64_STANDARD.encode([1, 2, 3]), "base64"]
1923 });
1924 assert!(matches!(
1925 decode_release_account(&definition, "Vault", &value).await,
1926 AccountReadOutcome::Error(ProgramReadError::AccountDecodeFailed)
1927 ));
1928
1929 let response = with_release_metadata(
1930 program_read_error_response(ProgramReadError::AccountDecodeFailed),
1931 &definition,
1932 Some("address"),
1933 Some(true),
1934 );
1935 assert_eq!(response.headers()["X-Error-Code"], "ACCOUNT_DECODE_FAILED");
1936 assert_eq!(
1937 response.headers()["X-Arete-Program-Release-Hash"],
1938 definition.program_release_hash.to_string()
1939 );
1940 assert_eq!(
1941 response.headers()["X-Arete-Idl-Content-Hash"],
1942 definition.idl_content_hash.to_string()
1943 );
1944 let body = response.into_body().collect().await.unwrap().to_bytes();
1945 let body = std::str::from_utf8(&body).unwrap();
1946 assert_eq!(body, r#"{"error":{"code":"ACCOUNT_DECODE_FAILED"}}"#);
1947 assert!(!body.contains("private diagnostic"));
1948 assert!(!body.contains("decoder"));
1949 }
1950}