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