Skip to main content

miden_client_cli/
lib.rs

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