/ ledger / query / src / query / rest.rs
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  }