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 ureq::http::{self, Uri};
47
48/// The format to store a generated transaction as.
49#[derive(Copy, Clone, Debug, ValueEnum)]
50pub enum StoreFormat {
51    String,
52    Bytes,
53}
54
55/// Commands to deploy and execute transactions
56#[derive(Debug, Parser)]
57pub enum DeveloperCommand {
58    /// Decrypt a ciphertext.
59    Decrypt(Decrypt),
60    /// Deploy a program.
61    Deploy(Deploy),
62    /// Execute a program function.
63    Execute(Execute),
64    /// Scan the node for records.
65    Scan(Scan),
66    /// Execute the `credits.aleo/transfer_private` function.
67    TransferPrivate(TransferPrivate),
68}
69
70/// Use the Provable explorer's API by default.
71/// Note, the `v1` here is not the API version, but the explorer's version.
72const DEFAULT_ENDPOINT: &str = "https://api.explorer.provable.com/v1";
73
74#[derive(Debug, Parser)]
75pub struct Developer {
76    /// The specific developer command to run.
77    #[clap(subcommand)]
78    command: DeveloperCommand,
79    /// Specify the network to create an execution for.
80    /// [options: 0 = mainnet, 1 = testnet, 2 = canary]
81    #[clap(long, default_value_t=MainnetV0::ID, long, global=true, value_parser = network_id_parser())]
82    network: u16,
83    /// Sets verbosity of log output. By default, no logs are shown.
84    #[clap(long, global = true)]
85    verbosity: Option<u8>,
86}
87
88/// The serialized REST error sent over the network.
89#[derive(Debug, Deserialize)]
90struct RestError {
91    /// The type of error (corresponding to the HTTP status code).
92    error_type: String,
93    /// The top-level error message.
94    message: String,
95    /// The chain of errors that led to the top-level error.
96    /// Default to an empty vector if no error chain was given.
97    #[serde(default)]
98    chain: Vec<String>,
99}
100
101impl RestError {
102    /// Converts a `RestError` into an `anyhow::Error`.
103    pub fn parse(self) -> anyhow::Error {
104        let mut error: Option<anyhow::Error> = None;
105        for next in self.chain.into_iter() {
106            if let Some(previous) = error {
107                error = Some(previous.context(next));
108            } else {
109                error = Some(anyhow!(next));
110            }
111        }
112
113        let toplevel = format!("{}: {}", self.error_type, self.message);
114        if let Some(error) = error { error.context(toplevel) } else { anyhow!(toplevel) }
115    }
116}
117
118impl Developer {
119    /// Runs the developer subcommand chosen by the user.
120    pub fn parse(self) -> Result<String> {
121        if let Some(verbosity) = self.verbosity {
122            initialize_terminal_logger(verbosity).with_context(|| "Failed to initialize terminal logger")?
123        }
124
125        match self.network {
126            MainnetV0::ID => self.parse_inner::<MainnetV0>(),
127            TestnetV0::ID => self.parse_inner::<TestnetV0>(),
128            CanaryV0::ID => self.parse_inner::<CanaryV0>(),
129            unknown_id => bail!("Unknown network ID ({unknown_id})"),
130        }
131    }
132
133    /// Internal logic of [`Self::parse`] for each of the different networks.
134    fn parse_inner<N: Network>(self) -> Result<String> {
135        use DeveloperCommand::*;
136
137        match self.command {
138            Decrypt(decrypt) => decrypt.parse::<N>(),
139            Deploy(deploy) => deploy.parse::<N>(),
140            Execute(execute) => execute.parse::<N>(),
141            Scan(scan) => scan.parse::<N>(),
142            TransferPrivate(transfer_private) => transfer_private.parse::<N>(),
143        }
144    }
145
146    /// Parse the package from the directory.
147    fn parse_package<N: Network>(program_id: ProgramID<N>, path: &Option<String>) -> Result<Package<N>> {
148        // Instantiate a path to the directory containing the manifest file.
149        let directory = match path {
150            Some(path) => PathBuf::from_str(path)?,
151            None => std::env::current_dir()?,
152        };
153
154        // Load the package.
155        let package = Package::open(&directory)?;
156
157        ensure!(
158            package.program_id() == &program_id,
159            "The program name in the package does not match the specified program name"
160        );
161
162        // Return the package.
163        Ok(package)
164    }
165
166    /// Parses the record string. If the string is a ciphertext, then attempt to decrypt it.
167    fn parse_record<N: Network>(private_key: &PrivateKey<N>, record: &str) -> Result<Record<N, Plaintext<N>>> {
168        match record.starts_with("record1") {
169            true => {
170                // Parse the ciphertext.
171                let ciphertext = Record::<N, Ciphertext<N>>::from_str(record)?;
172                // Derive the view key.
173                let view_key = ViewKey::try_from(private_key)?;
174                // Decrypt the ciphertext.
175                ciphertext.decrypt(&view_key)
176            }
177            false => Record::<N, Plaintext<N>>::from_str(record),
178        }
179    }
180
181    /// Builds the full endpoint Uri from the base and path. Used internally for all REST API calls (copied from `snarkvm_ledger_query::Query`).
182    /// This will add the API version number and network name to the resulting endpoint URL.
183    ///
184    /// # Arguments
185    ///  - `base_url`: the hostname (and path prefix) of the node to query. this must exclude the network name.
186    ///  - `route`: the route to the endpoint (e.g., `stateRoot/latest`). This cannot start with a slash.
187    fn build_endpoint<N: Network>(base_url: &http::Uri, route: &str) -> Result<String> {
188        // This function is only called internally but check for additional sanity.
189        ensure!(!route.starts_with('/'), "path cannot start with a slash");
190
191        // If the route already ends with a version segment (v1 or v2), don't prepend a version.
192        let route_has_version_suffix = {
193            let r = base_url.path().trim_end_matches('/');
194            r.ends_with(API_VERSION_V1) || r.ends_with(API_VERSION_V2)
195        };
196
197        // Work around a bug in the `http` crate where empty paths will be set to '/'
198        // but other paths are not appended with a slash.
199        // See https://github.com/hyperium/http/issues/507
200        let sep = if base_url.path().ends_with('/') { "" } else { "/" };
201
202        // Build "{base}/{maybe_version}/{network}/{route}"
203        let prefix = if route_has_version_suffix {
204            format!("{}/", N::SHORT_NAME)
205        } else {
206            format!("{}/{}/", API_VERSION_V2, N::SHORT_NAME)
207        };
208
209        Ok(format!("{base_url}{sep}{prefix}{route}"))
210    }
211
212    /// Converts the returned JSON error (if any) into an anyhow Error chain.
213    /// If the error was 404, this simply returns `Ok(None)`.
214    fn handle_ureq_result(result: Result<http::Response<ureq::Body>>) -> Result<Option<ureq::Body>> {
215        let response = result?;
216
217        if response.status() == http::StatusCode::OK {
218            Ok(Some(response.into_body()))
219        } else if response.status() == http::StatusCode::NOT_FOUND {
220            Ok(None)
221        } else {
222            let rest_error: RestError =
223                response.into_body().read_json().with_context(|| "Failed to parse error JSON")?;
224
225            Err(rest_error.parse())
226        }
227    }
228
229    /// Helper function to send a POST request with a JSON body to an endpoint and await a JSON response.
230    fn http_post_json<I: Serialize, O: DeserializeOwned>(path: &str, arg: &I) -> Result<Option<O>> {
231        let result =
232            ureq::post(path).config().http_status_as_error(false).build().send_json(arg).map_err(|err| err.into());
233
234        match Self::handle_ureq_result(result).with_context(|| "HTTP POST request failed")? {
235            Some(mut body) => {
236                let json = body.read_json().with_context(|| "Failed to parse JSON response")?;
237                Ok(Some(json))
238            }
239            None => Ok(None),
240        }
241    }
242
243    /// Helper function to send a GET request to an endpoint and await a JSON response.
244    fn http_get_json<N: Network, O: DeserializeOwned>(base_url: &http::Uri, route: &str) -> Result<Option<O>> {
245        let endpoint = Self::build_endpoint::<N>(base_url, route)?;
246        let result = ureq::get(&endpoint).config().http_status_as_error(false).build().call().map_err(|err| err.into());
247
248        match Self::handle_ureq_result(result).with_context(|| "HTTP GET request failed")? {
249            Some(mut body) => {
250                let json = body.read_json().with_context(|| "Failed to parse JSON response")?;
251                Ok(Some(json))
252            }
253            None => Ok(None),
254        }
255    }
256
257    /// Helper function to send a GET request to an endpoint and await the response.
258    fn http_get<N: Network>(base_url: &http::Uri, route: &str) -> Result<Option<ureq::Body>> {
259        let endpoint = Self::build_endpoint::<N>(base_url, route)?;
260        let result = ureq::get(&endpoint).config().http_status_as_error(false).build().call().map_err(|err| err.into());
261
262        Self::handle_ureq_result(result).with_context(|| "HTTP GET request failed")
263    }
264
265    /// Wait for a transaction to be confirmed by the network.
266    fn wait_for_transaction_confirmation<N: Network>(
267        endpoint: &Uri,
268        transaction_id: &N::TransactionID,
269        timeout_seconds: u64,
270    ) -> Result<()> {
271        let start_time = Instant::now();
272        let timeout_duration = Duration::from_secs(timeout_seconds);
273        let poll_interval = Duration::from_secs(1); // Poll every second
274
275        while start_time.elapsed() < timeout_duration {
276            // Check if transaction exists in a confirmed block
277            let result = Self::http_get::<N>(endpoint, &format!("transaction/{transaction_id}"))
278                .with_context(|| "Failed to check transaction status")?;
279
280            match result {
281                Some(_) => return Ok(()),
282                None => {
283                    // Transaction not found yet, continue polling.
284                    thread::sleep(poll_interval);
285                }
286            }
287        }
288
289        // Timeout reached
290        bail!("❌ Transaction {} was not confirmed within {} seconds", transaction_id, timeout_seconds);
291    }
292
293    /// Gets the latest eidtion of an Aleo program.
294    fn get_latest_edition<N: Network>(endpoint: &Uri, program_id: &ProgramID<N>) -> Result<u16> {
295        match Self::http_get_json::<N, _>(endpoint, &format!("program/{program_id}/latest_edition"))? {
296            Some(edition) => Ok(edition),
297            None => bail!("Got unexpected 404 response"),
298        }
299    }
300
301    /// Gets the public account balance of an Aleo Address (in microcredits).
302    fn get_public_balance<N: Network>(endpoint: &Uri, address: &Address<N>) -> Result<u64> {
303        // Initialize the program id and account identifier.
304        let account_mapping = Identifier::<N>::from_str("account")?;
305        let credits = ProgramID::<N>::from_str("credits.aleo")?;
306
307        // Send a request to the query node.
308        let result: Option<Value<N>> =
309            Self::http_get_json::<N, _>(endpoint, &format!("program/{credits}/mapping/{account_mapping}/{address}"))?;
310
311        // Return the balance in microcredits.
312        match result {
313            Some(Value::Plaintext(Plaintext::Literal(Literal::<N>::U64(amount), _))) => Ok(*amount),
314            Some(..) => bail!("Failed to deserialize balance for {address}"),
315            None => Ok(0),
316        }
317    }
318
319    /// Determine if the transaction should be broadcast or displayed to user.
320    ///
321    /// This function expects that exactly one of `dry_run`, `store`, and `broadcast` are `true` (or `Some`).
322    /// `broadcast` can be set to `Some(None)` to broadcast using the default endpoint.
323    /// Alternatively, it can be set to `Some(Some(url))` to providifferent
324    /// endpoint than that used for querying.
325    #[allow(clippy::too_many_arguments)]
326    fn handle_transaction<N: Network>(
327        endpoint: &Uri,
328        broadcast: &Option<Option<Uri>>,
329        dry_run: bool,
330        store: &Option<String>,
331        store_format: StoreFormat,
332        wait: bool,
333        timeout: u64,
334        transaction: Transaction<N>,
335        operation: String,
336    ) -> Result<String> {
337        // Get the transaction id.
338        let transaction_id = transaction.id();
339
340        // Ensure the transaction is not a fee transaction.
341        ensure!(!transaction.is_fee(), "The transaction is a fee transaction and cannot be broadcast");
342
343        // Determine if the transaction should be stored.
344        if let Some(path) = store {
345            match PathBuf::from_str(path) {
346                Ok(file_path) => {
347                    match store_format {
348                        StoreFormat::Bytes => {
349                            let transaction_bytes = transaction.to_bytes_le()?;
350                            std::fs::write(&file_path, transaction_bytes)?;
351                        }
352                        StoreFormat::String => {
353                            let transaction_string = transaction.to_string();
354                            std::fs::write(&file_path, transaction_string)?;
355                        }
356                    }
357
358                    println!(
359                        "Transaction {transaction_id} was stored to {} as {:?}",
360                        file_path.display(),
361                        store_format
362                    );
363                }
364                Err(err) => {
365                    println!("The transaction was unable to be stored due to: {err}");
366                }
367            }
368        };
369
370        // Determine if the transaction should be broadcast to the network.
371        if let Some(broadcast_value) = broadcast {
372            let broadcast_endpoint = if let Some(url) = broadcast_value {
373                url.to_string()
374            } else {
375                Self::build_endpoint::<N>(endpoint, "transaction/broadcast")?
376            };
377
378            let result: Result<String> = match Self::http_post_json(&broadcast_endpoint, &transaction) {
379                Ok(Some(s)) => Ok(s),
380                Ok(None) => Err(anyhow!("Got unexpected 404 error")),
381                Err(err) => Err(err),
382            };
383
384            match result {
385                Ok(response_string) => {
386                    ensure!(
387                        response_string == transaction_id.to_string(),
388                        "The response does not match the transaction id. ({response_string} != {transaction_id})"
389                    );
390
391                    match transaction {
392                        Transaction::Deploy(..) => {
393                            println!(
394                                "⌛ Deployment {transaction_id} ('{}') has been broadcast to {}.",
395                                operation.bold(),
396                                broadcast_endpoint
397                            )
398                        }
399                        Transaction::Execute(..) => {
400                            println!(
401                                "⌛ Execution {transaction_id} ('{}') has been broadcast to {}.",
402                                operation.bold(),
403                                broadcast_endpoint
404                            )
405                        }
406                        _ => unreachable!(),
407                    }
408
409                    // If wait is enabled, wait for transaction confirmation
410                    if wait {
411                        println!("⏳ Waiting for transaction confirmation (timeout: {timeout}s)...");
412                        Self::wait_for_transaction_confirmation::<N>(endpoint, &transaction_id, timeout)?;
413
414                        match transaction {
415                            Transaction::Deploy(..) => {
416                                println!("✅ Deployment {transaction_id} ('{}') confirmed!", operation.bold())
417                            }
418                            Transaction::Execute(..) => {
419                                println!("✅ Execution {transaction_id} ('{}') confirmed!", operation.bold())
420                            }
421                            Transaction::Fee(..) => unreachable!(),
422                        }
423                    }
424                }
425                Err(error) => match transaction {
426                    Transaction::Deploy(..) => {
427                        return Err(error.context(anyhow!(
428                            "Failed to deploy '{op}' to {broadcast_endpoint}",
429                            op = operation.bold()
430                        )));
431                    }
432                    Transaction::Execute(..) => {
433                        return Err(error.context(anyhow!(
434                            "Failed to broadcast execution '{op}' to {broadcast_endpoint}",
435                            op = operation.bold()
436                        )));
437                    }
438                    Transaction::Fee(..) => unreachable!(),
439                },
440            };
441
442            // Output the transaction id.
443            Ok(transaction_id.to_string())
444        } else if dry_run {
445            // Output the transaction string.
446            Ok(transaction.to_string())
447        } else {
448            Ok("".to_string())
449        }
450    }
451}