rest.rs
1 // Copyright (c) 2025-2026 ACDC Network 2 // This file is part of the alphavm library. 3 // 4 // Alpha Chain | Delta Chain Protocol 5 // International Monetary Graphite. 6 // 7 // Derived from Aleo (https://aleo.org) and ProvableHQ (https://provable.com). 8 // They built world-class ZK infrastructure. We installed the EASY button. 9 // Their cryptography: elegant. Our modifications: bureaucracy-compatible. 10 // Original brilliance: theirs. Robert's Rules: ours. Bugs: definitely ours. 11 // 12 // Original Aleo/ProvableHQ code subject to Apache 2.0 https://www.apache.org/licenses/LICENSE-2.0 13 // All modifications and new work: CC0 1.0 Universal Public Domain Dedication. 14 // No rights reserved. No permission required. No warranty. No refunds. 15 // 16 // https://creativecommons.org/publicdomain/zero/1.0/ 17 // SPDX-License-Identifier: CC0-1.0 18 19 use crate::QueryTrait; 20 21 use alphavm_console::{ 22 network::Network, 23 program::{ProgramID, StatePath}, 24 types::Field, 25 }; 26 use alphavm_ledger_block::Transaction; 27 use alphavm_synthesizer_program::Program; 28 29 use anyhow::{anyhow, bail, ensure, Context, Result}; 30 use serde::{de::DeserializeOwned, Deserialize}; 31 use ureq::http::{self, uri}; 32 33 use std::str::FromStr; 34 35 /// Queries that use a node's REST API as their source of information. 36 #[derive(Clone)] 37 pub struct RestQuery<N: Network> { 38 base_url: http::Uri, 39 _marker: std::marker::PhantomData<N>, 40 } 41 42 impl<N: Network> From<http::Uri> for RestQuery<N> { 43 fn from(base_url: http::Uri) -> Self { 44 Self { base_url, _marker: Default::default() } 45 } 46 } 47 48 /// The serialized REST error sent over the network. 49 #[derive(Debug, Deserialize)] 50 pub struct RestError { 51 /// The type of error (corresponding to the HTTP status code). 52 error_type: String, 53 /// The top-level error message. 54 message: String, 55 /// The chain of errors that led to the top-level error. 56 #[serde(skip_serializing_if = "Vec::is_empty")] 57 chain: Vec<String>, 58 } 59 60 impl RestError { 61 /// Converts a `RestError` into an `anyhow::Error`. 62 pub fn parse(self) -> anyhow::Error { 63 let mut error: Option<anyhow::Error> = None; 64 for next in self.chain.into_iter() { 65 if let Some(previous) = error { 66 error = Some(previous.context(next)); 67 } else { 68 error = Some(anyhow!(next)); 69 } 70 } 71 72 let toplevel = format!("{}: {}", self.error_type, self.message); 73 if let Some(error) = error { 74 error.context(toplevel) 75 } else { 76 anyhow!(toplevel) 77 } 78 } 79 } 80 81 /// Initialize the `Query` object from an endpoint URL (passed as a string). The URI should point to a snarkOS node's REST API. 82 impl<N: Network> FromStr for RestQuery<N> { 83 type Err = anyhow::Error; 84 85 fn from_str(str_representation: &str) -> Result<Self> { 86 let base_url = str_representation.parse::<http::Uri>().with_context(|| "Failed to parse URL")?; 87 88 // Perform checks. 89 if let Some(scheme) = base_url.scheme() { 90 if *scheme != uri::Scheme::HTTP && *scheme != uri::Scheme::HTTPS { 91 bail!("Invalid scheme in URL: {scheme}"); 92 } 93 } 94 95 if let Some(s) = base_url.host() { 96 if s.is_empty() { 97 bail!("Invalid URL for REST endpoint. Empty hostname given."); 98 } 99 } else { 100 bail!("Invalid URL for REST endpoint. No hostname given."); 101 } 102 103 if base_url.query().is_some() { 104 bail!("Base URL for REST endpoints cannot contain a query"); 105 } 106 107 Ok(Self::from(base_url)) 108 } 109 } 110 111 #[cfg_attr(feature = "async", async_trait::async_trait(?Send))] 112 impl<N: Network> QueryTrait<N> for RestQuery<N> { 113 /// Returns the current state root. 114 fn current_state_root(&self) -> Result<N::StateRoot> { 115 self.get_request("stateRoot/latest") 116 } 117 118 /// Returns the current state root. 119 #[cfg(feature = "async")] 120 async fn current_state_root_async(&self) -> Result<N::StateRoot> { 121 self.get_request_async("stateRoot/latest").await 122 } 123 124 /// Returns a state path for the given `commitment`. 125 fn get_state_path_for_commitment(&self, commitment: &Field<N>) -> Result<StatePath<N>> { 126 self.get_request(&format!("statePath/{commitment}")) 127 } 128 129 /// Returns a state path for the given `commitment`. 130 #[cfg(feature = "async")] 131 async fn get_state_path_for_commitment_async(&self, commitment: &Field<N>) -> Result<StatePath<N>> { 132 self.get_request_async(&format!("statePath/{commitment}")).await 133 } 134 135 /// Returns a list of state paths for the given list of `commitment`s. 136 fn get_state_paths_for_commitments(&self, commitments: &[Field<N>]) -> Result<Vec<StatePath<N>>> { 137 // Construct the comma separated string of commitments. 138 let commitments_string = commitments.iter().map(|cm| cm.to_string()).collect::<Vec<_>>().join(","); 139 self.get_request(&format!("statePaths?commitments={commitments_string}")) 140 } 141 142 /// Returns a list of state paths for the given list of `commitment`s. 143 #[cfg(feature = "async")] 144 async fn get_state_paths_for_commitments_async(&self, commitments: &[Field<N>]) -> Result<Vec<StatePath<N>>> { 145 // Construct the comma separated string of commitments. 146 let commitments_string = commitments.iter().map(|cm| cm.to_string()).collect::<Vec<_>>().join(","); 147 self.get_request_async(&format!("statePaths?commitments={commitments_string}")).await 148 } 149 150 /// Returns a state path for the given `commitment`. 151 fn current_block_height(&self) -> Result<u32> { 152 self.get_request("block/height/latest") 153 } 154 155 /// Returns a state path for the given `commitment`. 156 #[cfg(feature = "async")] 157 async fn current_block_height_async(&self) -> Result<u32> { 158 self.get_request_async("block/height/latest").await 159 } 160 } 161 162 impl<N: Network> RestQuery<N> { 163 /// Returns the transaction for the given transaction ID. 164 pub fn get_transaction(&self, transaction_id: &N::TransactionID) -> Result<Transaction<N>> { 165 self.get_request(&format!("transaction/{transaction_id}")) 166 } 167 168 /// Returns the transaction for the given transaction ID. 169 #[cfg(feature = "async")] 170 pub async fn get_transaction_async(&self, transaction_id: &N::TransactionID) -> Result<Transaction<N>> { 171 self.get_request_async(&format!("transaction/{transaction_id}")).await 172 } 173 174 /// Returns the program for the given program ID. 175 pub fn get_program(&self, program_id: &ProgramID<N>) -> Result<Program<N>> { 176 self.get_request(&format!("program/{program_id}")) 177 } 178 179 /// Returns the program for the given program ID. 180 #[cfg(feature = "async")] 181 pub async fn get_program_async(&self, program_id: &ProgramID<N>) -> Result<Program<N>> { 182 self.get_request_async(&format!("program/{program_id}")).await 183 } 184 185 /// Builds the full endpoint Uri from the base and path. Used internally 186 /// for all REST API calls. 187 /// 188 /// # Arguments 189 /// - `route`: the route to the endpoint (e.g., `stateRoot/latest`). This cannot start with a slash. 190 fn build_endpoint(&self, route: &str) -> Result<String> { 191 // This function is only called internally but check for additional sanity. 192 ensure!(!route.starts_with('/'), "path cannot start with a slash"); 193 194 // Work around a bug in the `http` crate where empty paths will be set to '/' but other paths are not appended with a slash. 195 // See [this issue](https://github.com/hyperium/http/issues/507). 196 let path = if self.base_url.path().ends_with('/') { 197 format!("{base_url}{network}/{route}", base_url = self.base_url, network = N::SHORT_NAME) 198 } else { 199 format!("{base_url}/{network}/{route}", base_url = self.base_url, network = N::SHORT_NAME) 200 }; 201 202 Ok(path) 203 } 204 205 /// Performs a GET request to the given URL and deserializes the returned JSON. 206 /// 207 /// # Arguments 208 /// - `route`: the specific API route to use, e.g., `stateRoot/latest` 209 fn get_request<T: DeserializeOwned>(&self, route: &str) -> Result<T> { 210 let endpoint = self.build_endpoint(route)?; 211 let mut response = ureq::get(&endpoint) 212 .config() 213 .http_status_as_error(false) 214 .build() 215 .call() 216 // This handles I/O errors. 217 .with_context(|| format!("Failed to fetch from {endpoint}"))?; 218 219 if response.status().is_success() { 220 response.body_mut().read_json().with_context(|| format!("Failed to parse JSON response from {endpoint}")) 221 } else { 222 // v2 will return the error in JSON format. 223 let is_json = response 224 .headers() 225 .get(http::header::CONTENT_TYPE) 226 .and_then(|ct| ct.to_str().ok()) 227 .map(|ct| ct.contains("json")) 228 .unwrap_or(false); 229 230 // Convert returned error into an `anyhow::Error`. 231 // Depending on the API version, the error is either encoded as a string or as a JSON. 232 if is_json { 233 let error: RestError = response 234 .body_mut() 235 .read_json() 236 .with_context(|| format!("Failed to parse JSON error response from {endpoint}"))?; 237 Err(error.parse().context(format!("Failed to fetch from {endpoint}"))) 238 } else { 239 let error = response 240 .body_mut() 241 .read_to_string() 242 .with_context(|| format!("Failed to read error message {endpoint}"))?; 243 Err(anyhow!(error).context(format!("Failed to fetch from {endpoint}"))) 244 } 245 } 246 } 247 248 /// Async version of [`Self::get_request`]. Performs a GET request to the given URL and deserializes the returned JSON. 249 /// 250 /// # Arguments 251 /// - `route`: the specific API route to use, e.g., `stateRoot/latest` 252 #[cfg(feature = "async")] 253 async fn get_request_async<T: DeserializeOwned>(&self, route: &str) -> Result<T> { 254 let endpoint = self.build_endpoint(route)?; 255 let response = reqwest::get(&endpoint).await.with_context(|| format!("Failed to fetch from {endpoint}"))?; 256 257 if response.status().is_success() { 258 response.json().await.with_context(|| format!("Failed to parse JSON response from {endpoint}")) 259 } else { 260 // v2 will return the error in JSON format. 261 let is_json = response 262 .headers() 263 .get(http::header::CONTENT_TYPE) 264 .and_then(|ct| ct.to_str().ok()) 265 .map(|ct| ct.contains("json")) 266 .unwrap_or(false); 267 268 if is_json { 269 // Convert returned error into an `anyhow::Error`. 270 let error: RestError = response 271 .json() 272 .await 273 .with_context(|| format!("Failed to parse JSON error response from {endpoint}"))?; 274 Err(error.parse().context(format!("Failed to fetch from {endpoint}"))) 275 } else { 276 let error = 277 response.text().await.with_context(|| format!("Failed to read error message {endpoint}"))?; 278 Err(anyhow!(error).context(format!("Failed to fetch from {endpoint}"))) 279 } 280 } 281 } 282 } 283 284 #[cfg(test)] 285 mod tests { 286 use crate::Query; 287 288 use alphavm_console::network::TestnetV0; 289 use alphavm_ledger_store::helpers::memory::BlockMemory; 290 291 use anyhow::Result; 292 293 type CurrentNetwork = TestnetV0; 294 type CurrentQuery = Query<CurrentNetwork, BlockMemory<CurrentNetwork>>; 295 296 /// Tests HTTP's behavior of printing an empty path `/` 297 /// 298 /// `generate_endpoint` can handle base_urls with and without a trailing slash. 299 /// However, this test is still useful to see if the behavior changes in the future and a second slash is not 300 /// appended to a URL with an existing trailing slash. 301 #[test] 302 fn test_rest_url_parse() -> Result<()> { 303 let noslash = "http://localhost:3030"; 304 let withslash = format!("{noslash}/"); 305 let route = "some/route"; 306 307 let query = noslash.parse::<CurrentQuery>().unwrap(); 308 let Query::REST(rest) = query else { panic!() }; 309 assert_eq!(rest.base_url.path_and_query().unwrap().to_string(), "/"); 310 assert_eq!(rest.base_url.to_string(), withslash); 311 assert_eq!(rest.build_endpoint(route)?, format!("{noslash}/testnet/{route}")); 312 313 let query = withslash.parse::<CurrentQuery>().unwrap(); 314 let Query::REST(rest) = query else { panic!() }; 315 assert_eq!(rest.base_url.path_and_query().unwrap().to_string(), "/"); 316 assert_eq!(rest.base_url.to_string(), withslash); 317 assert_eq!(rest.build_endpoint(route)?, format!("{noslash}/testnet/{route}")); 318 319 Ok(()) 320 } 321 322 #[test] 323 fn test_rest_url_with_colon_parse() { 324 let str = "http://myendpoint.addr/:var/foo/bar"; 325 let query = str.parse::<CurrentQuery>().unwrap(); 326 327 let Query::REST(rest) = query else { panic!() }; 328 assert_eq!(rest.base_url.to_string(), format!("{str}")); 329 assert_eq!(rest.base_url.path_and_query().unwrap().to_string(), "/:var/foo/bar"); 330 } 331 332 #[test] 333 fn test_rest_url_parse_with_suffix() -> Result<()> { 334 let base = "http://localhost:3030/a/prefix/v2"; 335 let route = "a/route"; 336 337 // Test without trailing slash. 338 let query = base.parse::<CurrentQuery>().unwrap(); 339 let Query::REST(rest) = query else { panic!() }; 340 assert_eq!(rest.build_endpoint(route)?, format!("{base}/testnet/{route}")); 341 342 // Set again with trailing slash. 343 let query = format!("{base}/").parse::<CurrentQuery>().unwrap(); 344 let Query::REST(rest) = query else { panic!() }; 345 assert_eq!(rest.build_endpoint(route)?, format!("{base}/testnet/{route}")); 346 347 Ok(()) 348 } 349 }