Skip to main content

snarkos_cli/commands/developer/
mod.rs

1// Copyright (c) 2019-2025 Provable Inc.
2// This file is part of the snarkOS library.
3
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at:
7
8// http://www.apache.org/licenses/LICENSE-2.0
9
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16mod decrypt;
17pub use decrypt::*;
18
19mod deploy;
20pub use deploy::*;
21
22mod execute;
23pub use execute::*;
24
25mod scan;
26pub use scan::*;
27
28mod transfer_private;
29pub use transfer_private::*;
30
31use crate::helpers::{args::network_id_parser, logger::initialize_terminal_logger};
32
33use snarkos_node_rest::{API_VERSION_V1, API_VERSION_V2};
34use snarkvm::{package::Package, prelude::*};
35
36use anyhow::{Context, Result, anyhow, bail, ensure};
37use clap::{Parser, ValueEnum};
38use colored::Colorize;
39use serde::{Serialize, de::DeserializeOwned};
40use std::{
41    path::PathBuf,
42    str::FromStr,
43    thread,
44    time::{Duration, Instant},
45};
46use tracing::debug;
47use ureq::http::{self, Uri};
48
49/// The format to store a generated transaction as.
50#[derive(Copy, Clone, Debug, ValueEnum)]
51pub enum StoreFormat {
52    String,
53    Bytes,
54}
55
56/// The API version used by an endpoint
57#[derive(Copy, Clone, Debug, PartialEq, Eq)]
58pub enum ApiVersion {
59    V1,
60    V2,
61}
62
63/// Commands to deploy and execute transactions
64#[derive(Debug, Parser)]
65pub enum DeveloperCommand {
66    /// Decrypt a ciphertext.
67    Decrypt(Decrypt),
68    /// Deploy a program.
69    Deploy(Deploy),
70    /// Execute a program function.
71    Execute(Execute),
72    /// Scan the node for records.
73    Scan(Scan),
74    /// Execute the `credits.aleo/transfer_private` function.
75    TransferPrivate(TransferPrivate),
76}
77
78/// Use the Provable explorer's API by default.
79/// Note, `v2` here refers to the explorer version, not the snarkOS API version.
80const DEFAULT_ENDPOINT: &str = "https://api.explorer.provable.com/v2";
81
82#[derive(Debug, Parser)]
83pub struct Developer {
84    /// The specific developer command to run.
85    #[clap(subcommand)]
86    command: DeveloperCommand,
87    /// Specify the network to create an execution for.
88    /// [options: 0 = mainnet, 1 = testnet, 2 = canary]
89    #[clap(long, default_value_t=MainnetV0::ID, long, global=true, value_parser = network_id_parser())]
90    network: u16,
91    /// Sets verbosity of log output. By default, no logs are shown.
92    #[clap(long, global = true)]
93    verbosity: Option<u8>,
94}
95
96/// The serialized REST error sent over the network.
97#[derive(Debug, Deserialize)]
98struct RestError {
99    /// The type of error (corresponding to the HTTP status code).
100    error_type: String,
101    /// The top-level error message.
102    message: String,
103    /// The chain of errors that led to the top-level error.
104    /// Default to an empty vector if no error chain was given.
105    #[serde(default)]
106    chain: Vec<String>,
107}
108
109impl RestError {
110    /// Converts a `RestError` into an `anyhow::Error`.
111    pub fn parse(self) -> anyhow::Error {
112        let mut error: Option<anyhow::Error> = None;
113        for next in self.chain.into_iter() {
114            if let Some(previous) = error {
115                error = Some(previous.context(next));
116            } else {
117                error = Some(anyhow!(next));
118            }
119        }
120
121        let toplevel = format!("{}: {}", self.error_type, self.message);
122        if let Some(error) = error { error.context(toplevel) } else { anyhow!(toplevel) }
123    }
124}
125
126impl Developer {
127    /// Runs the developer subcommand chosen by the user.
128    pub fn parse(self) -> Result<String> {
129        if let Some(verbosity) = self.verbosity {
130            initialize_terminal_logger(verbosity).with_context(|| "Failed to initialize terminal logger")?
131        }
132
133        match self.network {
134            MainnetV0::ID => self.parse_inner::<MainnetV0>(),
135            TestnetV0::ID => self.parse_inner::<TestnetV0>(),
136            CanaryV0::ID => self.parse_inner::<CanaryV0>(),
137            unknown_id => bail!("Unknown network ID ({unknown_id})"),
138        }
139    }
140
141    /// Internal logic of [`Self::parse`] for each of the different networks.
142    fn parse_inner<N: Network>(self) -> Result<String> {
143        use DeveloperCommand::*;
144
145        match self.command {
146            Decrypt(decrypt) => decrypt.parse::<N>(),
147            Deploy(deploy) => deploy.parse::<N>(),
148            Execute(execute) => execute.parse::<N>(),
149            Scan(scan) => scan.parse::<N>(),
150            TransferPrivate(transfer_private) => transfer_private.parse::<N>(),
151        }
152    }
153
154    /// Parse the package from the directory.
155    fn parse_package<N: Network>(program_id: ProgramID<N>, path: &Option<String>) -> Result<Package<N>> {
156        // Instantiate a path to the directory containing the manifest file.
157        let directory = match path {
158            Some(path) => PathBuf::from_str(path)?,
159            None => std::env::current_dir()?,
160        };
161
162        // Load the package.
163        let package = Package::open(&directory)?;
164
165        ensure!(
166            package.program_id() == &program_id,
167            "The program name in the package does not match the specified program name"
168        );
169
170        // Return the package.
171        Ok(package)
172    }
173
174    /// Parses the record string. If the string is a ciphertext, then attempt to decrypt it.
175    fn parse_record<N: Network>(private_key: &PrivateKey<N>, record: &str) -> Result<Record<N, Plaintext<N>>> {
176        match record.starts_with("record1") {
177            true => {
178                // Parse the ciphertext.
179                let ciphertext = Record::<N, Ciphertext<N>>::from_str(record)?;
180                // Derive the view key.
181                let view_key = ViewKey::try_from(private_key)?;
182                // Decrypt the ciphertext.
183                ciphertext.decrypt(&view_key)
184            }
185            false => Record::<N, Plaintext<N>>::from_str(record),
186        }
187    }
188
189    /// Builds the full endpoint Uri from the base and path. Used internally for all REST API calls (copied from `snarkvm_ledger_query::Query`).
190    /// This will add the API version number and network name to the resulting endpoint URL.
191    ///
192    /// # Arguments
193    ///  - `base_url`: the hostname (and path prefix) of the node to query. this must exclude the network name.
194    ///  - `route`: the route to the endpoint (e.g., `stateRoot/latest`). This cannot start with a slash.
195    ///
196    /// # Returns
197    /// The full endpoint Uri and the API version used by it.
198    fn build_endpoint<N: Network>(base_url: &http::Uri, route: &str) -> Result<(String, ApiVersion)> {
199        // This function is only called internally but check for additional sanity.
200        ensure!(!route.starts_with('/'), "path cannot start with a slash");
201
202        // Determine the API version we are interacting with.
203        let api_version = {
204            let r = base_url.path().trim_end_matches('/');
205
206            if r.ends_with(API_VERSION_V1) {
207                ApiVersion::V1
208            } else if r.ends_with(API_VERSION_V2) {
209                ApiVersion::V2
210            } else {
211                // Default to v1.
212                // Note: If the snarkos-node-rest switches default to v2, this needs to be updated.
213                ApiVersion::V1
214            }
215        };
216
217        // Work around a bug in the `http` crate where empty paths will be set to '/'
218        // but other paths are not appended with a slash.
219        // See https://github.com/hyperium/http/issues/507
220        let sep = if base_url.path().ends_with('/') { "" } else { "/" };
221
222        // Build "{base}/{maybe_version}/{network}/{route}"
223        let full_uri = format!("{base_url}{sep}{network}/{route}", network = N::SHORT_NAME);
224        Ok((full_uri, api_version))
225    }
226
227    /// Converts the returned JSON error (if any) into an anyhow Error chain.
228    /// If the error was 404, this simply returns `Ok(None)`.
229    fn handle_ureq_result(result: Result<http::Response<ureq::Body>>) -> Result<Option<ureq::Body>> {
230        let response = result?;
231
232        if response.status().is_success() {
233            Ok(Some(response.into_body()))
234        } else if response.status() == http::StatusCode::NOT_FOUND {
235            Ok(None)
236        } else {
237            // V2 returns the error in JSON format.
238            let is_json = response
239                .headers()
240                .get(http::header::CONTENT_TYPE)
241                .and_then(|h| h.to_str().ok())
242                .map(|ct| ct.contains("json"))
243                .unwrap_or(false);
244
245            if is_json {
246                let rest_error: RestError =
247                    response.into_body().read_json().with_context(|| "Failed to parse error JSON")?;
248
249                Err(rest_error.parse())
250            } else {
251                // V1 returns the error message a string.
252                let err_msg = response.into_body().read_to_string()?;
253                Err(anyhow!(err_msg))
254            }
255        }
256    }
257
258    /// Extracts the API version from a custom endpoint.
259    fn parse_custom_endpoint<N: Network>(url: &Uri) -> (String, ApiVersion) {
260        // Determine API version for custom endpoint.
261        if let Some(pq) = url.path_and_query()
262            && pq.path().ends_with(&format!("{API_VERSION_V2}/{}/transaction/broadcast", N::SHORT_NAME))
263        {
264            (url.to_string(), ApiVersion::V2)
265        } else {
266            (url.to_string(), ApiVersion::V1)
267        }
268    }
269
270    /// Helper function to send a POST request with a JSON body to an endpoint and await a JSON response.
271    fn http_post_json<I: Serialize, O: DeserializeOwned>(path: &str, arg: &I) -> Result<Option<O>> {
272        debug!("Issuing POST request to \"{path}\"");
273
274        let result =
275            ureq::post(path).config().http_status_as_error(false).build().send_json(arg).map_err(|err| err.into());
276
277        match Self::handle_ureq_result(result).with_context(|| "HTTP POST request failed")? {
278            Some(mut body) => {
279                let json = body.read_json().with_context(|| "Failed to parse JSON response")?;
280                Ok(Some(json))
281            }
282            None => Ok(None),
283        }
284    }
285
286    /// Helper function to send a GET request to an endpoint and await a JSON response.
287    fn http_get_json<N: Network, O: DeserializeOwned>(base_url: &http::Uri, route: &str) -> Result<Option<O>> {
288        let (endpoint, _api_version) = Self::build_endpoint::<N>(base_url, route)?;
289        debug!("Issuing GET request to \"{endpoint}\"");
290
291        let result = ureq::get(&endpoint).config().http_status_as_error(false).build().call().map_err(|err| err.into());
292
293        match Self::handle_ureq_result(result).with_context(|| "HTTP GET request failed")? {
294            Some(mut body) => {
295                let json = body.read_json().with_context(|| "Failed to parse JSON response")?;
296                Ok(Some(json))
297            }
298            None => Ok(None),
299        }
300    }
301
302    /// Helper function to send a GET request to an endpoint and await the response.
303    fn http_get<N: Network>(base_url: &http::Uri, route: &str) -> Result<Option<ureq::Body>> {
304        let (endpoint, _api_version) = Self::build_endpoint::<N>(base_url, route)?;
305        debug!("Issuing GET request to \"{endpoint}\"");
306
307        let result = ureq::get(&endpoint).config().http_status_as_error(false).build().call().map_err(|err| err.into());
308
309        Self::handle_ureq_result(result).with_context(|| "HTTP GET request failed")
310    }
311
312    /// Wait for a transaction to be confirmed by the network.
313    fn wait_for_transaction_confirmation<N: Network>(
314        endpoint: &Uri,
315        transaction_id: &N::TransactionID,
316        timeout_seconds: u64,
317        api_version: ApiVersion,
318    ) -> Result<()> {
319        let start_time = Instant::now();
320        let timeout_duration = Duration::from_secs(timeout_seconds);
321        let poll_interval = Duration::from_secs(1); // Poll every second
322
323        while start_time.elapsed() < timeout_duration {
324            // Check if transaction exists in a confirmed block
325            let result = Self::http_get::<N>(endpoint, &format!("transaction/{transaction_id}"));
326
327            match api_version {
328                ApiVersion::V1 => match result {
329                    Ok(Some(_)) => return Ok(()),
330                    Ok(None) => {
331                        // Transaction not found yet, continue polling.
332                    }
333                    Err(err) => {
334                        // The V1 API returns 500 on missing transactions. Retroy on any error.
335                        eprintln!("Got error when fetching transaction ({err}). Will retry...");
336                    }
337                },
338                ApiVersion::V2 => {
339                    // With the V2 API, we can differentiate between benign errors and fatal errors.
340                    match result.with_context(|| "Failed to check transaction status")? {
341                        Some(_) => return Ok(()),
342                        None => {
343                            // Transaction not found yet, continue polling.
344                        }
345                    }
346                }
347            }
348
349            thread::sleep(poll_interval);
350        }
351
352        // Timeout reached
353        bail!("❌ Transaction {transaction_id} was not confirmed within {timeout_seconds} seconds");
354    }
355
356    /// Gets the latest eidtion of an Aleo program.
357    fn get_latest_edition<N: Network>(endpoint: &Uri, program_id: &ProgramID<N>) -> Result<u16> {
358        match Self::http_get_json::<N, _>(endpoint, &format!("program/{program_id}/latest_edition"))? {
359            Some(edition) => Ok(edition),
360            None => bail!("Got unexpected 404 response"),
361        }
362    }
363
364    /// Gets the public account balance of an Aleo Address (in microcredits).
365    fn get_public_balance<N: Network>(endpoint: &Uri, address: &Address<N>) -> Result<Option<u64>> {
366        // Initialize the program id and account identifier.
367        let account_mapping = Identifier::<N>::from_str("account")?;
368        let credits = ProgramID::<N>::from_str("credits.aleo")?;
369
370        // Request the balance from the endpoint.
371        // If no such balance/account exists, the node returns status code 200 with `null` as the response body.
372        // Nodes should never return 404 for this endpoint.
373        let result: Option<Value<N>> =
374            Self::http_get_json::<N, _>(endpoint, &format!("program/{credits}/mapping/{account_mapping}/{address}"))?
375                .ok_or_else(|| anyhow!("Got unexpected 404 error when fetching public balance"))?;
376
377        // Return the balance in microcredits.
378        match result {
379            Some(Value::Plaintext(Plaintext::Literal(Literal::<N>::U64(amount), _))) => Ok(Some(*amount)),
380            Some(..) => bail!("Failed to deserialize balance for {address}"),
381            None => Ok(None),
382        }
383    }
384
385    /// Determine if the transaction should be broadcast or displayed to user.
386    ///
387    /// This function expects that exactly one of `dry_run`, `store`, and `broadcast` are `true` (or `Some`).
388    /// `broadcast` can be set to `Some(None)` to broadcast using the default endpoint.
389    /// Alternatively, it can be set to `Some(Some(url))` to providifferent
390    /// endpoint than that used for querying.
391    #[allow(clippy::too_many_arguments)]
392    fn handle_transaction<N: Network>(
393        endpoint: &Uri,
394        broadcast: &Option<Option<Uri>>,
395        dry_run: bool,
396        store: &Option<String>,
397        store_format: StoreFormat,
398        wait: bool,
399        timeout: u64,
400        transaction: Transaction<N>,
401        operation: String,
402    ) -> Result<String> {
403        // Get the transaction id.
404        let transaction_id = transaction.id();
405
406        // Ensure the transaction is not a fee transaction.
407        ensure!(!transaction.is_fee(), "The transaction is a fee transaction and cannot be broadcast");
408
409        // Determine if the transaction should be stored.
410        if let Some(path) = store {
411            match PathBuf::from_str(path) {
412                Ok(file_path) => {
413                    match store_format {
414                        StoreFormat::Bytes => {
415                            let transaction_bytes = transaction.to_bytes_le()?;
416                            std::fs::write(&file_path, transaction_bytes)?;
417                        }
418                        StoreFormat::String => {
419                            let transaction_string = transaction.to_string();
420                            std::fs::write(&file_path, transaction_string)?;
421                        }
422                    }
423
424                    println!(
425                        "Transaction {transaction_id} was stored to {} as {:?}",
426                        file_path.display(),
427                        store_format
428                    );
429                }
430                Err(err) => {
431                    println!("The transaction was unable to be stored due to: {err}");
432                }
433            }
434        };
435
436        // Determine if the transaction should be broadcast to the network.
437        if let Some(broadcast_value) = broadcast {
438            let (broadcast_endpoint, api_version) = if let Some(url) = broadcast_value {
439                debug!("Using custom endpoint for broadcasting: {url}");
440                Self::parse_custom_endpoint::<N>(url)
441            } else {
442                Self::build_endpoint::<N>(endpoint, "transaction/broadcast")?
443            };
444
445            let result: Result<String> = match Self::http_post_json(&broadcast_endpoint, &transaction) {
446                Ok(Some(s)) => Ok(s),
447                Ok(None) => Err(anyhow!("Got unexpected 404 error")),
448                Err(err) => Err(err),
449            };
450
451            match result {
452                Ok(response_string) => {
453                    ensure!(
454                        response_string == transaction_id.to_string(),
455                        "The response does not match the transaction id. ({response_string} != {transaction_id})"
456                    );
457
458                    match transaction {
459                        Transaction::Deploy(..) => {
460                            println!(
461                                "⌛ Deployment {transaction_id} ('{}') has been broadcast to {}.",
462                                operation.bold(),
463                                broadcast_endpoint
464                            )
465                        }
466                        Transaction::Execute(..) => {
467                            println!(
468                                "⌛ Execution {transaction_id} ('{}') has been broadcast to {}.",
469                                operation.bold(),
470                                broadcast_endpoint
471                            )
472                        }
473                        _ => unreachable!(),
474                    }
475
476                    // If wait is enabled, wait for transaction confirmation
477                    if wait {
478                        println!("⏳ Waiting for transaction confirmation (timeout: {timeout}s)...");
479                        Self::wait_for_transaction_confirmation::<N>(endpoint, &transaction_id, timeout, api_version)?;
480
481                        match transaction {
482                            Transaction::Deploy(..) => {
483                                println!("✅ Deployment {transaction_id} ('{}') confirmed!", operation.bold())
484                            }
485                            Transaction::Execute(..) => {
486                                println!("✅ Execution {transaction_id} ('{}') confirmed!", operation.bold())
487                            }
488                            Transaction::Fee(..) => unreachable!(),
489                        }
490                    }
491                }
492                Err(error) => match transaction {
493                    Transaction::Deploy(..) => {
494                        return Err(error.context(anyhow!(
495                            "Failed to deploy '{op}' to {broadcast_endpoint}",
496                            op = operation.bold()
497                        )));
498                    }
499                    Transaction::Execute(..) => {
500                        return Err(error.context(anyhow!(
501                            "Failed to broadcast execution '{op}' to {broadcast_endpoint}",
502                            op = operation.bold()
503                        )));
504                    }
505                    Transaction::Fee(..) => unreachable!(),
506                },
507            };
508
509            // Output the transaction id.
510            Ok(transaction_id.to_string())
511        } else if dry_run {
512            // Output the transaction string.
513            Ok(transaction.to_string())
514        } else {
515            Ok("".to_string())
516        }
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    use snarkvm::ledger::test_helpers::CurrentNetwork;
525
526    /// Test that the default endpoints (V1) work as expected.
527    ///
528    /// Note, if the default endpoint ever changes, this test needs to be updated.
529    #[test]
530    fn test_build_endpoint_default_v1() {
531        let base_uri_str = "http://localhost:3030";
532        let base_uri = Uri::try_from(base_uri_str).unwrap();
533        let (endpoint, api_version) =
534            Developer::build_endpoint::<CurrentNetwork>(&base_uri, "transaction/broadcast").unwrap();
535
536        assert_eq!(endpoint, format!("{base_uri_str}/{}/transaction/broadcast", CurrentNetwork::SHORT_NAME));
537        assert_eq!(api_version, ApiVersion::V1);
538    }
539
540    /// Ensure that the V1 endpoints work as expected.
541    #[test]
542    fn test_build_endpoint_v1() {
543        let base_uri_str = "http://localhost:3030/v1";
544        let base_uri = Uri::try_from(base_uri_str).unwrap();
545        let (endpoint, api_version) =
546            Developer::build_endpoint::<CurrentNetwork>(&base_uri, "transaction/broadcast").unwrap();
547
548        assert_eq!(endpoint, format!("{base_uri_str}/{}/transaction/broadcast", CurrentNetwork::SHORT_NAME));
549        assert_eq!(api_version, ApiVersion::V1);
550    }
551
552    /// Ensure that the V2 endpoints work as expected.
553    #[test]
554    fn test_build_endpoint_v2() {
555        let base_uri_str = "http://localhost:3030/v2";
556        let base_uri = Uri::try_from(base_uri_str).unwrap();
557        let (endpoint, api_version) =
558            Developer::build_endpoint::<CurrentNetwork>(&base_uri, "transaction/broadcast").unwrap();
559
560        assert_eq!(endpoint, format!("{base_uri_str}/{}/transaction/broadcast", CurrentNetwork::SHORT_NAME));
561        assert_eq!(api_version, ApiVersion::V2);
562    }
563
564    #[test]
565    fn test_custom_endpoint_v1() {
566        let endpoint_str = "http://localhost:3030/v1/mainnet/transaction/broadcast";
567        let endpoint = Uri::try_from(endpoint_str).unwrap();
568
569        let (parsed, api_version) = Developer::parse_custom_endpoint::<CurrentNetwork>(&endpoint);
570
571        assert_eq!(parsed, endpoint_str);
572        assert_eq!(api_version, ApiVersion::V1);
573    }
574
575    #[test]
576    fn test_custom_endpoint_v2() {
577        let endpoint_str = "http://localhost:3030/v2/mainnet/transaction/broadcast";
578        let endpoint = Uri::try_from(endpoint_str).unwrap();
579
580        let (parsed, api_version) = Developer::parse_custom_endpoint::<CurrentNetwork>(&endpoint);
581
582        assert_eq!(parsed, endpoint_str);
583        assert_eq!(api_version, ApiVersion::V2);
584    }
585}