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