Skip to main content

miden_client_cli/
lib.rs

1use std::env;
2use std::ffi::OsString;
3use std::ops::{Deref, DerefMut};
4use std::sync::Arc;
5
6use clap::{Parser, Subcommand};
7use comfy_table::{Attribute, Cell, ContentArrangement, Table, presets};
8use errors::CliError;
9use miden_client::account::AccountHeader;
10use miden_client::builder::ClientBuilder;
11use miden_client::keystore::{FilesystemKeyStore, Keystore};
12use miden_client::note_transport::grpc::GrpcNoteTransportClient;
13use miden_client::rpc::GrpcClient;
14use miden_client::store::{NoteFilter as ClientNoteFilter, OutputNoteRecord};
15use miden_client_sqlite_store::ClientBuilderSqliteExt;
16
17mod commands;
18use commands::account::AccountCmd;
19use commands::call::CallCmd;
20use commands::clear_config::ClearConfigCmd;
21use commands::exec::ExecCmd;
22use commands::export::ExportCmd;
23use commands::import::ImportCmd;
24use commands::info::InfoCmd;
25use commands::init::InitCmd;
26use commands::network_note_status::NetworkNoteStatusCmd;
27use commands::new_account::{NewAccountCmd, NewWalletCmd};
28use commands::new_transactions::{ConsumeNotesCmd, MintCmd, PswapCmd, SendCmd, SwapCmd};
29use commands::notes::NotesCmd;
30use commands::sync::SyncCmd;
31use commands::tags::TagsCmd;
32use commands::transactions::TransactionCmd;
33
34use self::utils::config_file_exists;
35use crate::commands::address::AddressCmd;
36
37pub type CliKeyStore = FilesystemKeyStore;
38
39/// A Client configured using the CLI's system user configuration.
40///
41/// This is a wrapper around `Client<CliKeyStore>` that provides convenient
42/// initialization methods while maintaining full compatibility with the
43/// underlying Client API through `Deref`.
44///
45/// # Examples
46///
47/// ```no_run
48/// use miden_client_cli::transaction::TransactionRequestBuilder;
49/// use miden_client_cli::{CliClient, DebugMode};
50///
51/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
52/// // Create a CLI-configured client
53/// let mut client = CliClient::new(DebugMode::Disabled).await?;
54///
55/// // All Client methods work automatically via Deref
56/// client.sync_state().await?;
57///
58/// // Build and submit transactions
59/// let req = TransactionRequestBuilder::new()
60///     // ... configure transaction
61///     .build()?;
62///
63/// // client.submit_new_transaction(req, target_account_id)?;
64/// # Ok(())
65/// # }
66/// ```
67pub struct CliClient(miden_client::Client<CliKeyStore>);
68
69impl CliClient {
70    /// Creates a new `CliClient` instance from an existing `CliConfig`.
71    ///
72    ///
73    /// **⚠️ WARNING: This method bypasses the standard CLI configuration discovery logic and should
74    /// only be used in specific scenarios such as testing or when you have explicit control
75    /// requirements.**
76    ///
77    /// ## When NOT to use this method
78    ///
79    /// - **DO NOT** use this method if you want your application to behave like the CLI tool
80    /// - **DO NOT** use this for general-purpose client initialization
81    /// - **DO NOT** use this if you expect automatic local/global config resolution
82    ///
83    /// ## When to use this method
84    ///
85    /// - **Testing**: When you need to test with a specific configuration
86    /// - **Explicit Control**: When you must load config from a non-standard location
87    /// - **Programmatic Config**: When you're constructing configuration programmatically
88    ///
89    /// ## Recommended Alternative
90    ///
91    /// For standard client initialization that matches CLI behavior, use:
92    /// ```ignore
93    /// CliClient::new(debug_mode).await?
94    /// ```
95    ///
96    /// This method **does not** follow the CLI's configuration priority logic (local → global).
97    /// Instead, it uses exactly the configuration provided, which may not be what you expect.
98    ///
99    /// # Arguments
100    ///
101    /// * `config` - The CLI configuration to use (bypasses standard config discovery)
102    /// * `debug_mode` - The debug mode setting ([`DebugMode::Enabled`] or [`DebugMode::Disabled`])
103    ///
104    /// # Returns
105    ///
106    /// A configured [`CliClient`] instance.
107    ///
108    /// # Errors
109    ///
110    /// Returns a [`CliError`] if:
111    /// - Keystore initialization fails
112    /// - Client builder fails to construct the client
113    /// - Note transport connection fails (if configured)
114    ///
115    /// # Examples
116    ///
117    /// ```no_run
118    /// use std::path::PathBuf;
119    ///
120    /// use miden_client_cli::{CliClient, CliConfig, DebugMode};
121    ///
122    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
123    /// // BEWARE: This bypasses standard config discovery!
124    /// // Only use if you know what you're doing.
125    /// let config = CliConfig::from_dir(&PathBuf::from("/path/to/.miden"))?;
126    /// let client = CliClient::from_config(config, DebugMode::Disabled).await?;
127    ///
128    /// // Prefer this for standard CLI-like behavior:
129    /// let client = CliClient::new(DebugMode::Disabled).await?;
130    /// # Ok(())
131    /// # }
132    /// ```
133    pub async fn from_config(
134        config: CliConfig,
135        debug_mode: miden_client::DebugMode,
136    ) -> Result<Self, CliError> {
137        // Create keystore
138        let keystore =
139            CliKeyStore::new(config.secret_keys_directory.clone()).map_err(CliError::KeyStore)?;
140
141        // Build client with the provided configuration
142        let rpc_client = Arc::new(
143            GrpcClient::new(&config.rpc.endpoint.clone().into(), config.rpc.timeout_ms)
144                .with_max_decoding_message_size(CLI_MAX_RESPONSE_SIZE_BYTES),
145        );
146
147        let mut builder = ClientBuilder::new()
148            .sqlite_store(config.store_filepath.clone())
149            .rpc(rpc_client)
150            .authenticator(Arc::new(keystore))
151            .in_debug_mode(debug_mode)
152            .tx_discard_delta(Some(TX_DISCARD_DELTA));
153
154        // Add optional max_block_number_delta
155        if let Some(delta) = config.max_block_number_delta {
156            builder = builder.max_block_number_delta(delta);
157        }
158
159        // Add optional note transport client
160        if let Some(tl_config) = config.note_transport {
161            let note_transport_client =
162                GrpcNoteTransportClient::new(tl_config.endpoint.clone(), tl_config.timeout_ms);
163            builder = builder.note_transport(Arc::new(note_transport_client));
164        }
165
166        // Build and return the wrapped client
167        let client = builder.build().await.map_err(CliError::from)?;
168        Ok(CliClient(client))
169    }
170
171    /// Creates a new `CliClient` instance configured using the system user configuration.
172    ///
173    /// # ✅ Recommended Constructor
174    ///
175    /// **This is the recommended way to create a `CliClient` instance.**
176    ///
177    /// This method implements the configuration logic used by the CLI tool, allowing external
178    /// projects to create a Client instance with the same configuration. It searches for
179    /// configuration files in the following order:
180    ///
181    /// 1. Local `.miden/miden-client.toml` in the current working directory
182    /// 2. Global `.miden/miden-client.toml` in the home directory
183    ///
184    /// If no configuration file is found, it silently initializes a default configuration.
185    ///
186    /// The client is initialized with:
187    /// - `SQLite` store from the configured path
188    /// - `gRPC` client connection to the configured RPC endpoint
189    /// - Filesystem-based keystore authenticator
190    /// - Optional note transport client (if configured)
191    /// - Transaction graceful blocks delta
192    /// - Optional max block number delta
193    ///
194    /// # Arguments
195    ///
196    /// * `debug_mode` - The debug mode setting ([`DebugMode::Enabled`] or [`DebugMode::Disabled`]).
197    ///
198    /// # Returns
199    ///
200    /// A configured [`CliClient`] instance.
201    ///
202    /// # Errors
203    ///
204    /// Returns a [`CliError`] if:
205    /// - No configuration file is found (local or global)
206    /// - Configuration file parsing fails
207    /// - Keystore initialization fails
208    /// - Client builder fails to construct the client
209    /// - Note transport connection fails (if configured)
210    ///
211    /// # Examples
212    ///
213    /// ```no_run
214    /// use miden_client_cli::transaction::TransactionRequestBuilder;
215    /// use miden_client_cli::{CliClient, DebugMode};
216    ///
217    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
218    /// // Create a client with default settings (debug disabled)
219    /// let mut client = CliClient::new(DebugMode::Disabled).await?;
220    ///
221    /// // Or with debug mode enabled
222    /// let mut client = CliClient::new(DebugMode::Enabled).await?;
223    ///
224    /// // Use it like a regular Client
225    /// client.sync_state().await?;
226    ///
227    /// // Build and submit transactions
228    /// let req = TransactionRequestBuilder::new()
229    ///     // ... configure transaction
230    ///     .build()?;
231    ///
232    /// // client.submit_new_transaction(req, target_account_id)?;
233    /// # Ok(())
234    /// # }
235    /// ```
236    pub async fn new(debug_mode: miden_client::DebugMode) -> Result<Self, CliError> {
237        // Check if client is not yet initialized => silently initialize the client
238        if !config_file_exists()? {
239            let init_cmd = InitCmd::default();
240            init_cmd.execute()?;
241        }
242
243        // Load configuration from system
244        let config = CliConfig::load()?;
245
246        // Create client using the loaded configuration
247        Self::from_config(config, debug_mode).await
248    }
249
250    /// Unwraps the `CliClient` to get the inner `Client<CliKeyStore>`.
251    ///
252    /// This consumes the `CliClient` and returns the underlying client.
253    ///
254    /// # Examples
255    ///
256    /// ```no_run
257    /// use miden_client_cli::{CliClient, DebugMode};
258    ///
259    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
260    /// let cli_client = CliClient::new(DebugMode::Disabled).await?;
261    /// let inner_client = cli_client.into_inner();
262    /// # Ok(())
263    /// # }
264    /// ```
265    pub fn into_inner(self) -> miden_client::Client<CliKeyStore> {
266        self.0
267    }
268}
269
270/// Allows using `CliClient` like `Client<CliKeyStore>` through deref coercion.
271///
272/// This enables calling all `Client` methods on `CliClient` directly.
273impl Deref for CliClient {
274    type Target = miden_client::Client<CliKeyStore>;
275
276    fn deref(&self) -> &Self::Target {
277        &self.0
278    }
279}
280
281/// Allows mutable access to `Client<CliKeyStore>` methods.
282impl DerefMut for CliClient {
283    fn deref_mut(&mut self) -> &mut Self::Target {
284        &mut self.0
285    }
286}
287
288mod advice_inputs;
289pub mod config;
290// These modules intentionally shadow the miden_client re-exports - CLI has its own errors/utils
291#[allow(hidden_glob_reexports)]
292mod errors;
293mod info;
294#[allow(hidden_glob_reexports)]
295mod utils;
296
297/// Re-export `MIDEN_DIR` for use in tests
298pub use config::MIDEN_DIR;
299/// Re-export common types for external projects
300pub use config::{CLIENT_CONFIG_FILE_NAME, CliConfig};
301pub use errors::CliError as Error;
302/// Re-export the entire `miden_client` crate so external projects can use a single dependency.
303pub use miden_client::*;
304
305/// Client binary name.
306///
307/// If, for whatever reason, we fail to obtain the client's executable name,
308/// then we simply display the standard "miden-client".
309pub fn client_binary_name() -> OsString {
310    std::env::current_exe()
311        .inspect_err(|e| {
312            eprintln!(
313                "WARNING: Couldn't obtain the path of the current executable because of {e}.\
314             Defaulting to miden-client."
315            );
316        })
317        .and_then(|executable_path| {
318            executable_path.file_name().map(std::ffi::OsStr::to_os_string).ok_or(
319                std::io::Error::other("Couldn't obtain the file name of the current executable"),
320            )
321        })
322        .unwrap_or(OsString::from("miden-client"))
323}
324
325/// Number of blocks that must elapse after a transaction’s reference block before it is marked
326/// stale and discarded.
327const TX_DISCARD_DELTA: u32 = 20;
328
329/// Maximum size (in bytes) of any decoded gRPC response the CLI accepts. Sized to fit large
330/// `SyncTransactions` responses.
331const CLI_MAX_RESPONSE_SIZE_BYTES: usize = 6 * 1024 * 1024;
332
333/// Root CLI struct.
334#[derive(Parser, Debug)]
335#[command(
336    name = "miden-client",
337    about = "The Miden client",
338    version,
339    propagate_version = true,
340    rename_all = "kebab-case"
341)]
342#[command(multicall(true))]
343pub struct MidenClientCli {
344    #[command(subcommand)]
345    behavior: Behavior,
346}
347
348impl From<MidenClientCli> for Cli {
349    fn from(value: MidenClientCli) -> Self {
350        match value.behavior {
351            Behavior::MidenClient { cli } => cli,
352            Behavior::External(args) => Cli::parse_from(args).set_external(),
353        }
354    }
355}
356
357#[derive(Debug, Subcommand)]
358#[command(rename_all = "kebab-case")]
359enum Behavior {
360    /// The Miden Client CLI.
361    MidenClient {
362        #[command(flatten)]
363        cli: Cli,
364    },
365
366    /// Used when the Miden Client CLI is called under a different name, like
367    /// when it is called from [Midenup](https://github.com/0xMiden/midenup).
368    /// Vec<OsString> holds the "raw" arguments passed to the command line,
369    /// analogous to `argv`.
370    #[command(external_subcommand)]
371    External(Vec<OsString>),
372}
373
374#[derive(Parser, Debug)]
375#[command(name = "miden-client")]
376pub struct Cli {
377    /// Activates the executor's debug mode, which enables debug output for scripts
378    /// that were compiled and executed with this mode.
379    #[arg(short, long, default_value_t = false)]
380    debug: bool,
381
382    #[command(subcommand)]
383    action: Command,
384
385    /// Indicates whether the client's CLI is being called directly, or
386    /// externally under an alias (like in the case of
387    /// [Midenup](https://github.com/0xMiden/midenup).
388    #[arg(skip)]
389    #[allow(unused)]
390    external: bool,
391}
392
393/// CLI actions.
394#[derive(Debug, Parser)]
395pub enum Command {
396    Account(AccountCmd),
397    NewAccount(NewAccountCmd),
398    NewWallet(NewWalletCmd),
399    Import(ImportCmd),
400    Export(ExportCmd),
401    Init(InitCmd),
402    ClearConfig(ClearConfigCmd),
403    Notes(NotesCmd),
404    Sync(SyncCmd),
405    /// View a summary of the current client state.
406    Info(InfoCmd),
407    Tags(TagsCmd),
408    Address(AddressCmd),
409    #[command(name = "tx")]
410    Transaction(TransactionCmd),
411    Mint(MintCmd),
412    Send(SendCmd),
413    Pswap(PswapCmd),
414    Swap(SwapCmd),
415    ConsumeNotes(ConsumeNotesCmd),
416    Exec(ExecCmd),
417    NetworkNoteStatus(NetworkNoteStatusCmd),
418    Call(CallCmd),
419}
420
421/// CLI entry point.
422impl Cli {
423    pub async fn execute(&self) -> Result<(), CliError> {
424        // Handle commands that don't require client initialization
425        match &self.action {
426            Command::Init(init_cmd) => {
427                init_cmd.execute()?;
428                return Ok(());
429            },
430            Command::ClearConfig(clear_config_cmd) => {
431                clear_config_cmd.execute()?;
432                return Ok(());
433            },
434            Command::NetworkNoteStatus(cmd) => {
435                return cmd.execute().await;
436            },
437            _ => {},
438        }
439
440        // Check if Client is not yet initialized => silently initialize the client
441        if !config_file_exists()? {
442            let init_cmd = InitCmd::default();
443            init_cmd.execute()?;
444        }
445
446        // Define whether we want to use the executor's debug mode based on the env var and
447        // the flag override
448        let in_debug_mode = match env::var("MIDEN_DEBUG") {
449            Ok(value) if value.to_lowercase() == "true" => miden_client::DebugMode::Enabled,
450            _ => miden_client::DebugMode::Disabled,
451        };
452
453        // Load configuration
454        let cli_config = CliConfig::load()?;
455
456        // Create keystore for commands that need it
457        let keystore = CliKeyStore::new(cli_config.secret_keys_directory.clone())
458            .map_err(CliError::KeyStore)?;
459
460        // Create the client
461        let cli_client = CliClient::from_config(cli_config, in_debug_mode).await?;
462
463        // Extract the inner client for command execution
464        let client = cli_client.into_inner();
465
466        // Execute CLI command
467        match &self.action {
468            Command::Account(account) => account.execute(client).await,
469            Command::NewWallet(new_wallet) => Box::pin(new_wallet.execute(client, keystore)).await,
470            Command::NewAccount(new_account) => {
471                Box::pin(new_account.execute(client, keystore)).await
472            },
473            Command::Import(import) => import.execute(client, keystore).await,
474            Command::Init(_) | Command::ClearConfig(_) | Command::NetworkNoteStatus(_) => Ok(()), /* Already handled earlier */
475            Command::Info(info_cmd) => info::print_client_info(&client, info_cmd.rpc_status).await,
476            Command::Notes(notes) => Box::pin(notes.execute(client)).await,
477            Command::Sync(sync) => sync.execute(client).await,
478            Command::Tags(tags) => tags.execute(client).await,
479            Command::Address(addresses) => addresses.execute(client).await,
480            Command::Transaction(transaction) => transaction.execute(client).await,
481            Command::Exec(execute_program) => Box::pin(execute_program.execute(client)).await,
482            Command::Call(call) => Box::pin(call.execute(client)).await,
483            Command::Export(cmd) => cmd.execute(client, keystore).await,
484            Command::Mint(mint) => Box::pin(mint.execute(client)).await,
485            Command::Send(send) => Box::pin(send.execute(client)).await,
486            Command::Pswap(pswap) => Box::pin(pswap.execute(client)).await,
487            Command::Swap(swap) => Box::pin(swap.execute(client)).await,
488            Command::ConsumeNotes(consume_notes) => Box::pin(consume_notes.execute(client)).await,
489        }
490    }
491
492    fn set_external(mut self) -> Self {
493        self.external = true;
494        self
495    }
496}
497
498pub fn create_dynamic_table(headers: &[&str]) -> Table {
499    let header_cells = headers
500        .iter()
501        .map(|header| Cell::new(header).add_attribute(Attribute::Bold))
502        .collect::<Vec<_>>();
503
504    let mut table = Table::new();
505    table
506        .load_preset(presets::UTF8_FULL)
507        .set_content_arrangement(ContentArrangement::DynamicFullWidth)
508        .set_header(header_cells);
509
510    table
511}
512
513/// Returns the client output note whose ID starts with `note_id_prefix`.
514///
515/// # Errors
516///
517/// - Returns [`IdPrefixFetchError::NoMatch`](miden_client::IdPrefixFetchError::NoMatch) if we were
518///   unable to find any note where `note_id_prefix` is a prefix of its ID.
519/// - Returns [`IdPrefixFetchError::MultipleMatches`](miden_client::IdPrefixFetchError::MultipleMatches)
520///   if there were more than one note found where `note_id_prefix` is a prefix of its ID.
521pub(crate) async fn get_output_note_with_id_prefix<AUTH: Keystore + Sync>(
522    client: &miden_client::Client<AUTH>,
523    note_id_prefix: &str,
524) -> Result<OutputNoteRecord, miden_client::IdPrefixFetchError> {
525    let mut output_note_records = client
526        .get_output_notes(ClientNoteFilter::All)
527        .await
528        .map_err(|err| {
529            tracing::error!("Error when fetching all notes from the store: {err}");
530            miden_client::IdPrefixFetchError::NoMatch(
531                format!("note ID prefix {note_id_prefix}").to_string(),
532            )
533        })?
534        .into_iter()
535        .filter(|note_record| note_record.id().to_hex().starts_with(note_id_prefix))
536        .collect::<Vec<_>>();
537
538    if output_note_records.is_empty() {
539        return Err(miden_client::IdPrefixFetchError::NoMatch(
540            format!("note ID prefix {note_id_prefix}").to_string(),
541        ));
542    }
543    if output_note_records.len() > 1 {
544        let output_note_record_ids =
545            output_note_records.iter().map(OutputNoteRecord::id).collect::<Vec<_>>();
546        tracing::error!(
547            "Multiple notes found for the prefix {}: {:?}",
548            note_id_prefix,
549            output_note_record_ids
550        );
551        return Err(miden_client::IdPrefixFetchError::MultipleMatches(
552            format!("note ID prefix {note_id_prefix}").to_string(),
553        ));
554    }
555
556    Ok(output_note_records
557        .pop()
558        .expect("input_note_records should always have one element"))
559}
560
561/// Returns the client account whose ID starts with `account_id_prefix`.
562///
563/// # Errors
564///
565/// - Returns [`IdPrefixFetchError::NoMatch`](miden_client::IdPrefixFetchError::NoMatch) if we were
566///   unable to find any account where `account_id_prefix` is a prefix of its ID.
567/// - Returns [`IdPrefixFetchError::MultipleMatches`](miden_client::IdPrefixFetchError::MultipleMatches)
568///   if there were more than one account found where `account_id_prefix` is a prefix of its ID.
569async fn get_account_with_id_prefix<AUTH>(
570    client: &miden_client::Client<AUTH>,
571    account_id_prefix: &str,
572) -> Result<AccountHeader, miden_client::IdPrefixFetchError> {
573    let mut accounts = client
574        .get_account_headers()
575        .await
576        .map_err(|err| {
577            tracing::error!("Error when fetching all accounts from the store: {err}");
578            miden_client::IdPrefixFetchError::NoMatch(
579                format!("account ID prefix {account_id_prefix}").to_string(),
580            )
581        })?
582        .into_iter()
583        .filter(|(account_header, _)| account_header.id().to_hex().starts_with(account_id_prefix))
584        .map(|(acc, _)| acc)
585        .collect::<Vec<_>>();
586
587    if accounts.is_empty() {
588        return Err(miden_client::IdPrefixFetchError::NoMatch(
589            format!("account ID prefix {account_id_prefix}").to_string(),
590        ));
591    }
592    if accounts.len() > 1 {
593        let account_ids = accounts.iter().map(AccountHeader::id).collect::<Vec<_>>();
594        tracing::error!(
595            "Multiple accounts found for the prefix {}: {:?}",
596            account_id_prefix,
597            account_ids
598        );
599        return Err(miden_client::IdPrefixFetchError::MultipleMatches(
600            format!("account ID prefix {account_id_prefix}").to_string(),
601        ));
602    }
603
604    Ok(accounts.pop().expect("account_ids should always have one element"))
605}