Skip to main content

aleo_rust_sdk/
network.rs

1//! # Aleo Network — RPC client for interacting with Aleo blockchain nodes.
2//!
3//! Uses [Provable's v2 REST API](https://api.explorer.provable.com/v2/testnet) for GET
4//! endpoints (block height, state root, programs) and JSON-RPC (`testnetbeta.aleorpc.com`)
5//! for mapping/record queries.
6//!
7//! ## Endpoints
8//!
9//! | Endpoint | Protocol | Used For |
10//! |----------|----------|----------|
11//! | `api.explorer.provable.com/v2/testnet` | REST | Block height, state root, programs, broadcast |
12//! | `testnetbeta.aleorpc.com` | JSON-RPC | Mapping values, records, `getMappingValue` |
13//!
14//! ## Usage
15//!
16//! ```no_run
17//! use aleo_rust_sdk::AleoHttpClient;
18//!
19//! # async fn run() -> anyhow::Result<()> {
20//! let client = AleoHttpClient::new("https://api.explorer.provable.com/v2/testnet")?;
21//!
22//! let height = client.fetch_block_height().await?;
23//! println!("Block height: {height}");
24//! # Ok(())
25//! # }
26//! ```
27
28use anyhow::{Context, Result};
29use async_trait::async_trait;
30use serde_json::Value;
31use snarkvm::ledger::query::QueryTrait;
32use snarkvm::prelude::{Field, Network, Program, StatePath, TestnetV0};
33use std::io::Write;
34use std::str::FromStr;
35
36/// Browser-like User-Agent to bypass Cloudflare WAF.
37const UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
38
39/// JSON-RPC endpoint for Aleo testnet (used for mapping/records queries).
40const JSON_RPC_URL: &str = "https://testnetbeta.aleorpc.com";
41
42/// HTTP client with browser-like headers for Aleo network interaction.
43#[derive(Clone, Debug)]
44pub struct AleoHttpClient {
45    /// Base URL for REST endpoints (e.g. `https://api.explorer.provable.com/v2/testnet`)
46    pub base_url: String,
47    /// JSON-RPC endpoint (e.g. `https://testnetbeta.aleorpc.com`)
48    rpc_url: String,
49    inner: reqwest::Client,
50}
51
52impl AleoHttpClient {
53    /// Create a new client pointing at an Aleo node (default RPC endpoint).
54    pub fn new(base_url: &str) -> Result<Self> {
55        Self::new_with_rpc(base_url, JSON_RPC_URL)
56    }
57
58    /// Create a client with a custom RPC URL (useful for testing with wiremock).
59    pub fn new_with_rpc(base_url: &str, rpc_url: &str) -> Result<Self> {
60        let mut builder = reqwest::Client::builder()
61            .http1_only()
62            .danger_accept_invalid_certs(true)
63            .timeout(std::time::Duration::from_secs(120));
64
65        // Respect HTTP_PROXY / HTTPS_PROXY env vars (e.g. Clash at 127.0.0.1:7890).
66        // No manual proxy detection needed — reqwest 0.12 respects these by default
67        // when Proxy::system() is used. We set custom() to control exactly.
68        if let Ok(proxy_url) = std::env::var("HTTP_PROXY")
69            .or_else(|_| std::env::var("http_proxy"))
70            .or_else(|_| std::env::var("HTTPS_PROXY"))
71            .or_else(|_| std::env::var("https_proxy"))
72        {
73            if let Ok(proxy) =
74                reqwest::Proxy::http(&proxy_url).or_else(|_| reqwest::Proxy::all(&proxy_url))
75            {
76                builder = builder.proxy(proxy);
77            }
78        }
79
80        let inner = builder.build().context("Failed to build HTTP client")?;
81        Ok(Self {
82            base_url: base_url.trim_end_matches('/').to_string(),
83            rpc_url: rpc_url.trim_end_matches('/').to_string(),
84            inner,
85        })
86    }
87
88    fn headers() -> reqwest::header::HeaderMap {
89        let mut h = reqwest::header::HeaderMap::new();
90        h.insert("User-Agent", UA.parse().unwrap());
91        h.insert(
92            "Accept",
93            "application/json, text/plain, */*".parse().unwrap(),
94        );
95        h
96    }
97
98    /// Helper: JSON-RPC POST call.
99    async fn json_rpc(&self, method: &str, params: Vec<Value>) -> Result<Value> {
100        let body = serde_json::json!({
101            "jsonrpc": "2.0",
102            "id": 1,
103            "method": method,
104            "params": params,
105        });
106        let mut headers = Self::headers();
107        headers.insert("Content-Type", "application/json".parse().unwrap());
108
109        let resp = self.inner.post(&self.rpc_url).headers(headers).json(&body).send().await?;
110        let text = resp.text().await?;
111        let v: Value = serde_json::from_str(&text).context("Failed to parse JSON-RPC response")?;
112
113        if let Some(err) = v.get("error") {
114            anyhow::bail!("JSON-RPC error ({method}): {err}");
115        }
116        v.get("result").cloned().context("JSON-RPC response missing result")
117    }
118
119    /// Fetch a program from the network (REST GET).
120    pub async fn fetch_program(&self, program_id: &str) -> Result<Program<TestnetV0>> {
121        let url = format!("{}/program/{program_id}", self.base_url);
122        tracing::info!("GET {url}");
123        let text = self.inner.get(&url).headers(Self::headers()).send().await?.text().await?;
124
125        let clean = text.trim_matches('"').replace("\\n", "\n");
126        Program::<TestnetV0>::from_str(&clean).context("Failed to parse program")
127    }
128
129    /// Fetch latest state root + block height (REST GET).
130    pub async fn fetch_state_root(&self) -> Result<(<TestnetV0 as Network>::StateRoot, u32)> {
131        let root_url = format!("{}/stateRoot/latest", self.base_url);
132        let text = self.inner.get(&root_url).headers(Self::headers()).send().await?.text().await?;
133        let root_str = text.trim_matches('"');
134        let state_root = <TestnetV0 as Network>::StateRoot::from_str(root_str)
135            .context("Failed to parse state root")?;
136
137        let height = self.fetch_block_height().await?;
138        Ok((state_root, height))
139    }
140
141    /// Broadcast a JSON-serialized transaction (REST POST).
142    pub async fn broadcast_transaction(&self, tx_json: String) -> Result<String> {
143        let url = format!(
144            "{}/transaction/broadcast?check_transaction=true",
145            self.base_url
146        );
147        let mut headers = Self::headers();
148        headers.insert("Content-Type", "application/json".parse().unwrap());
149        headers.insert("Origin", "https://explorer.provable.com".parse().unwrap());
150        headers.insert("Referer", "https://explorer.provable.com/".parse().unwrap());
151
152        let resp = self.inner.post(&url).headers(headers).body(tx_json).send().await?;
153        let status = resp.status();
154        let body = resp.text().await.unwrap_or_default();
155
156        if !status.is_success() {
157            anyhow::bail!("Broadcast rejected ({status}): {body}");
158        }
159        Ok(body)
160    }
161
162    /// Fetch a transaction by its ID from the explorer API.
163    /// Returns the raw JSON response as a String.
164    pub async fn fetch_transaction(&self, tx_id: &str) -> Result<String> {
165        let url = format!("{}/transaction/{tx_id}", self.base_url);
166        let res = self.inner.get(&url).headers(Self::headers()).send().await?;
167        let body = res.text().await?;
168        Ok(body)
169    }
170
171    /// Poll for confirmation (up to 30 attempts, 5s apart).
172    pub async fn wait_for_confirmation(&self, tx_id: &str) -> Result<()> {
173        let check_url = format!("{}/transaction/{tx_id}", self.base_url);
174        tracing::info!("Waiting for confirmation... (polling every 5s)");
175
176        for _ in 1..=30 {
177            tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
178
179            match self.inner.get(&check_url).headers(Self::headers()).send().await {
180                Ok(res) if res.status().is_success() => {
181                    tracing::info!("Confirmed on chain!");
182                    tracing::info!("🔗 https://testnet.explorer.provable.com/transaction/{tx_id}");
183                    return Ok(());
184                }
185                _ => {
186                    print!(".");
187                    std::io::stdout().flush().ok();
188                }
189            }
190        }
191        anyhow::bail!("Timed out waiting for confirmation of {tx_id}")
192    }
193
194    // ── On-chain state queries ──────────────────────────────────────────
195
196    /// Query a mapping value via REST API (GET /program/{id}/mapping/{name}/{key}).
197    ///
198    /// Returns `None` if the key does not exist (404).
199    pub async fn fetch_mapping_value_rest(
200        &self,
201        program_id: &str,
202        mapping_name: &str,
203        key: &str,
204    ) -> Result<Option<u64>> {
205        let url = format!(
206            "{}/program/{program_id}/mapping/{mapping_name}/{key}",
207            self.base_url
208        );
209        let resp = self.inner.get(&url).headers(Self::headers()).send().await?;
210        if resp.status().is_client_error() {
211            return Ok(None); // 404 = no entry
212        }
213        let text = resp.text().await?;
214        let cleaned = text.trim().trim_matches('"').replace("u64", "");
215        match cleaned.parse::<u64>() {
216            Ok(v) => Ok(Some(v)),
217            Err(e) => {
218                tracing::warn!("Failed to parse REST mapping value '{text}': {e}");
219                Ok(None)
220            }
221        }
222    }
223
224    /// Query a mapping value via JSON-RPC `getMappingValue`.
225    ///
226    /// Returns `None` if the key does not exist in the mapping.
227    pub async fn fetch_mapping_value(
228        &self,
229        program_id: &str,
230        mapping_name: &str,
231        key: &str,
232    ) -> Result<Option<String>> {
233        let result = self
234            .json_rpc(
235                "getMappingValue",
236                vec![
237                    Value::String(program_id.to_string()),
238                    Value::String(mapping_name.to_string()),
239                    Value::String(key.to_string()),
240                ],
241            )
242            .await;
243
244        match result {
245            Ok(Value::String(s)) => Ok(Some(s)),
246            Ok(v) => Ok(Some(v.to_string())),
247            Err(e) => {
248                tracing::warn!("Mapping query note (key may not exist): {e}");
249                Ok(None)
250            }
251        }
252    }
253
254    /// Fetch the current block height (REST GET).
255    pub async fn fetch_block_height(&self) -> Result<u32> {
256        let url = format!("{}/block/height/latest", self.base_url);
257        let text = self.inner.get(&url).headers(Self::headers()).send().await?.text().await?;
258        Ok(text.trim().parse()?)
259    }
260
261    /// Fetch latest state root only (REST GET).
262    pub async fn fetch_state_root_only(&self) -> Result<<TestnetV0 as Network>::StateRoot> {
263        let url = format!("{}/stateRoot/latest", self.base_url);
264        let text = self.inner.get(&url).headers(Self::headers()).send().await?.text().await?;
265        <TestnetV0 as Network>::StateRoot::from_str(text.trim_matches('"'))
266            .context("Failed to parse state root")
267    }
268
269    /// Fetch unspent records by view key via JSON-RPC.
270    pub async fn fetch_records(&self, view_key: &str) -> Result<String> {
271        let height = self.fetch_block_height().await?;
272        let start = height.saturating_sub(1000);
273
274        let result = self
275            .json_rpc(
276                "records/isOwner",
277                vec![
278                    Value::String(view_key.to_string()),
279                    Value::Number(serde_json::Number::from(start)),
280                    Value::Number(serde_json::Number::from(height)),
281                ],
282            )
283            .await?;
284
285        Ok(serde_json::to_string_pretty(&result)?)
286    }
287
288    /// Fetch all records within a block range via JSON-RPC `records/all`.
289    ///
290    /// Returns raw record ciphertexts that must be decrypted with a view key.
291    pub async fn fetch_all_records(
292        &self,
293        start: u32,
294        end: u32,
295        page: u32,
296        per_page: u32,
297    ) -> Result<Value> {
298        let params = serde_json::json!({
299            "start": start,
300            "end": end,
301            "page": page,
302            "recordsPerRequest": per_page,
303        });
304        let body = serde_json::json!({
305            "jsonrpc": "2.0",
306            "id": 1,
307            "method": "records/all",
308            "params": params,
309        });
310        let mut headers = Self::headers();
311        headers.insert("Content-Type", "application/json".parse().unwrap());
312
313        let resp = self.inner.post(&self.rpc_url).headers(headers).json(&body).send().await?;
314        let text = resp.text().await?;
315        let v: Value =
316            serde_json::from_str(&text).context("Failed to parse records/all response")?;
317        if let Some(err) = v.get("error") {
318            anyhow::bail!("records/all error: {err}");
319        }
320        v.get("result").cloned().context("records/all response missing result")
321    }
322
323    /// Find unspent `credits.aleo` record ciphertexts owned by the given view key.
324    /// Scans recent blocks via `records/all`, decrypts each record, and returns
325    /// those containing `credits.aleo` with the owner matching the view key's address.
326    pub async fn find_private_credits_records(&self, view_key: &str) -> Result<Vec<(String, u64)>> {
327        use snarkvm::console::program::Record;
328        use snarkvm::prelude::Ciphertext;
329        use std::str::FromStr;
330
331        let vk = snarkvm::prelude::ViewKey::<TestnetV0>::from_str(view_key)?;
332        let owner_addr = vk.to_address();
333
334        let height = self.fetch_block_height().await?;
335        let start = height.saturating_sub(100_000); // scan last ~100K blocks
336        let mut results = Vec::new();
337
338        tracing::info!("Scanning blocks {start}..{height} for private records...");
339        let records = self.fetch_all_records(start, height, 0, 500).await?;
340
341        if let Some(arr) = records.as_array() {
342            for record_entry in arr {
343                let program_id = record_entry["program_id"].as_str().unwrap_or("");
344                if program_id != "credits.aleo" {
345                    continue;
346                }
347                let ciphertext_str = record_entry["record_ciphertext"].as_str().unwrap_or("");
348                if ciphertext_str.is_empty() {
349                    continue;
350                }
351                // Try to parse and decrypt
352                if let Ok(record) =
353                    Record::<TestnetV0, Ciphertext<TestnetV0>>::from_str(ciphertext_str)
354                {
355                    if let Ok(decrypted) = record.decrypt(&vk) {
356                        // Check owner matches
357                        if *decrypted.owner() == snarkvm::prelude::Owner::Public(owner_addr)
358                            || *decrypted.owner()
359                                == snarkvm::prelude::Owner::Private(
360                                    snarkvm::prelude::Plaintext::from(
361                                        snarkvm::prelude::Literal::Address(owner_addr),
362                                    ),
363                                )
364                        {
365                            // Extract microcredits from data
366                            for (id, entry) in decrypted.data().iter() {
367                                if id.to_string() == "microcredits" {
368                                    let amount_str =
369                                        entry.to_string().replace("u64", "").trim().to_string();
370                                    if let Ok(amount) = amount_str.parse::<u64>() {
371                                        results.push((ciphertext_str.to_string(), amount));
372                                    }
373                                }
374                            }
375                        }
376                    }
377                }
378            }
379        }
380
381        // Sort by amount descending
382        results.sort_by_key(|a| std::cmp::Reverse(a.1));
383        Ok(results)
384    }
385}
386
387/// Custom query returning a fixed state root (bypasses ureq/WAF issues).
388#[derive(Clone, Debug)]
389pub struct FixedStateRootQuery<N: Network> {
390    pub state_root: N::StateRoot,
391    pub block_height: u32,
392}
393
394#[async_trait(?Send)]
395impl<N: Network> QueryTrait<N> for FixedStateRootQuery<N> {
396    fn current_state_root(&self) -> Result<N::StateRoot> {
397        Ok(self.state_root)
398    }
399    fn current_block_height(&self) -> Result<u32> {
400        Ok(self.block_height)
401    }
402    fn get_state_path_for_commitment(&self, _commitment: &Field<N>) -> Result<StatePath<N>> {
403        StatePath::from_str("").or_else(|_| anyhow::bail!("State path not available"))
404    }
405    fn get_state_paths_for_commitments(
406        &self,
407        _commitments: &[Field<N>],
408    ) -> Result<Vec<StatePath<N>>> {
409        Ok(Vec::new())
410    }
411    async fn current_state_root_async(&self) -> Result<N::StateRoot> {
412        Ok(self.state_root)
413    }
414    async fn current_block_height_async(&self) -> Result<u32> {
415        Ok(self.block_height)
416    }
417    async fn get_state_path_for_commitment_async(
418        &self,
419        _commitment: &Field<N>,
420    ) -> Result<StatePath<N>> {
421        StatePath::from_str("").or_else(|_| anyhow::bail!("State path not available"))
422    }
423    async fn get_state_paths_for_commitments_async(
424        &self,
425        _commitments: &[Field<N>],
426    ) -> Result<Vec<StatePath<N>>> {
427        Ok(Vec::new())
428    }
429}
430
431/// A custom query that fetches Merkle state paths from the Provable API v2
432/// `statePath/{commitment}` endpoint during `trace.prepare()`.
433///
434/// The state root returned by [`current_state_root`] is cached from the most
435/// recently fetched state path — ensuring the Merkle proof and the root it
436/// verifies against are consistent.
437#[derive(Debug)]
438pub struct ProvableQuery {
439    pub state_root: <TestnetV0 as Network>::StateRoot,
440    pub block_height: u32,
441    /// Provable API v2 base URL (e.g. `https://api.provable.com/v2/testnet`)
442    pub api_base_url: String,
443    /// Cached state root extracted from the most recently fetched state path.
444    /// [`current_state_root`] returns this cached value so the Merkle proof
445    /// verifies against the same root.
446    last_state_root: std::sync::Mutex<Option<<TestnetV0 as Network>::StateRoot>>,
447}
448
449impl Clone for ProvableQuery {
450    fn clone(&self) -> Self {
451        Self {
452            state_root: self.state_root,
453            block_height: self.block_height,
454            api_base_url: self.api_base_url.clone(),
455            // Each clone gets a fresh empty cache — it will be populated lazily
456            // on the first get_state_path_for_commitment call.
457            last_state_root: std::sync::Mutex::new(None),
458        }
459    }
460}
461
462impl ProvableQuery {
463    pub fn new(
464        state_root: <TestnetV0 as Network>::StateRoot,
465        block_height: u32,
466        api_base_url: &str,
467    ) -> Self {
468        Self {
469            state_root,
470            block_height,
471            api_base_url: api_base_url.trim_end_matches('/').to_string(),
472            last_state_root: std::sync::Mutex::new(None),
473        }
474    }
475}
476
477#[async_trait(?Send)]
478impl QueryTrait<TestnetV0> for ProvableQuery {
479    fn current_state_root(&self) -> Result<<TestnetV0 as Network>::StateRoot> {
480        // Return the root cached from the most recently fetched state path.
481        // This ensures the Merkle proof returned by get_state_path_for_commitment
482        // verifies against the same root.
483        let guard = self.last_state_root.lock().unwrap();
484        match *guard {
485            Some(root) => Ok(root),
486            None => Ok(self.state_root),
487        }
488    }
489    fn current_block_height(&self) -> Result<u32> {
490        Ok(self.block_height)
491    }
492    fn get_state_path_for_commitment(&self, commitment: &Field<TestnetV0>) -> Result<StatePath<TestnetV0>> {
493        let url = format!("{}/statePath/{commitment}", self.api_base_url);
494        let response = ureq::get(&url)
495            .set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
496            .set("Accept", "application/json")
497            .call()
498            .map_err(|e| anyhow::anyhow!("Provable statePath GET {url} failed: {e}"))?;
499        let body = response.into_string()
500            .map_err(|e| anyhow::anyhow!("Failed to read statePath response from {url}: {e}"))?;
501        // Provable returns state path as a quoted string.
502        let trimmed = body.trim().trim_matches('"');
503        let path = StatePath::<TestnetV0>::from_str(trimmed)
504            .map_err(|e| anyhow::anyhow!("Failed to parse state path from '{trimmed}': {e}"))?;
505        // Cache the global state root embedded in the path so current_state_root
506        // returns the same root the Merkle proof was built against.
507        let path_root = path.global_state_root();
508        let mut guard = self.last_state_root.lock().unwrap();
509        *guard = Some(path_root);
510        Ok(path)
511    }
512    fn get_state_paths_for_commitments(
513        &self,
514        commitments: &[Field<TestnetV0>],
515    ) -> Result<Vec<StatePath<TestnetV0>>> {
516        if commitments.is_empty() {
517            return Ok(Vec::new());
518        }
519        let cm_strings: Vec<String> = commitments.iter().map(|c| c.to_string()).collect();
520        let url = format!("{}/statePaths?commitments={}", self.api_base_url, cm_strings.join(","));
521        let response = ureq::get(&url)
522            .set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
523            .set("Accept", "application/json")
524            .call()
525            .map_err(|e| anyhow::anyhow!("Provable statePaths GET {url} failed: {e}"))?;
526        let body = response.into_string()
527            .map_err(|e| anyhow::anyhow!("Failed to read statePaths response from {url}: {e}"))?;
528        serde_json::from_str(&body)
529            .map_err(|e| anyhow::anyhow!("Failed to parse state paths from '{body}': {e}"))
530    }
531    async fn current_state_root_async(&self) -> Result<<TestnetV0 as Network>::StateRoot> {
532        Ok(self.state_root.clone())
533    }
534    async fn current_block_height_async(&self) -> Result<u32> {
535        Ok(self.block_height)
536    }
537    async fn get_state_path_for_commitment_async(
538        &self,
539        commitment: &Field<TestnetV0>,
540    ) -> Result<StatePath<TestnetV0>> {
541        self.get_state_path_for_commitment(commitment)
542    }
543    async fn get_state_paths_for_commitments_async(
544        &self,
545        commitments: &[Field<TestnetV0>],
546    ) -> Result<Vec<StatePath<TestnetV0>>> {
547        self.get_state_paths_for_commitments(commitments)
548    }
549}