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, AccountId};
9use miden_client::asset::AssetId;
10use miden_client::builder::ClientBuilder;
11use miden_client::keystore::{FilesystemKeyStore, Keystore};
12use miden_client::note_transport::grpc::GrpcNoteTransportClient;
13use miden_client::protocol_config::ProtocolConfig;
14use miden_client::rpc::{GrpcClient, VerifyingRpcClient};
15use miden_client::store::{NoteFilter as ClientNoteFilter, OutputNoteRecord};
16use miden_client_sqlite_store::ClientBuilderSqliteExt;
17
18mod commands;
19use commands::account::AccountCmd;
20use commands::call::CallCmd;
21use commands::clear_config::ClearConfigCmd;
22use commands::exec::ExecCmd;
23use commands::export::ExportCmd;
24use commands::import::ImportCmd;
25use commands::info::InfoCmd;
26use commands::init::InitCmd;
27use commands::keys::KeysCmd;
28use commands::network_note_status::NetworkNoteStatusCmd;
29use commands::new_account::{NewAccountCmd, NewWalletCmd};
30use commands::new_transactions::{ConsumeNotesCmd, MintCmd, PswapCmd, SwapCmd, TransferCmd};
31use commands::notes::NotesCmd;
32use commands::sync::SyncCmd;
33use commands::tags::TagsCmd;
34use commands::transactions::TransactionCmd;
35
36use self::utils::config_file_exists;
37use crate::commands::address::AddressCmd;
38
39pub type CliKeyStore = FilesystemKeyStore;
40
41/// A Client configured using the CLI's system user configuration.
42///
43/// This is a wrapper around `Client<CliKeyStore>` that provides convenient initialization methods
44/// while maintaining full compatibility with the underlying Client API through `Deref`.
45///
46/// # Examples
47///
48/// ```no_run
49/// use miden_client_cli::CliClient;
50/// use miden_client_cli::transaction::TransactionRequestBuilder;
51///
52/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
53/// // Create a CLI-configured client
54/// let mut client = CliClient::new().await?;
55///
56/// // All Client methods work automatically via Deref
57/// client.sync_state().await?;
58///
59/// // Build and submit transactions
60/// let req = TransactionRequestBuilder::new()
61///     // ... configure transaction
62///     .build()?;
63///
64/// // client.submit_new_transaction(req, target_account_id)?;
65/// # Ok(())
66/// # }
67/// ```
68pub struct CliClient(miden_client::Client<CliKeyStore>);
69
70impl CliClient {
71    /// Creates a new `CliClient` instance from an existing `CliConfig`.
72    ///
73    ///
74    /// **⚠️ WARNING: This method bypasses the standard CLI configuration discovery logic and should
75    /// only be used in specific scenarios such as testing or when you have explicit control
76    /// requirements.**
77    ///
78    /// ## When NOT to use this method
79    ///
80    /// - **DO NOT** use this method if you want your application to behave like the CLI tool
81    /// - **DO NOT** use this for general-purpose client initialization
82    /// - **DO NOT** use this if you expect automatic local/global config resolution
83    ///
84    /// ## When to use this method
85    ///
86    /// - **Testing**: When you need to test with a specific configuration
87    /// - **Explicit Control**: When you must load config from a non-standard location
88    /// - **Programmatic Config**: When you're constructing configuration programmatically
89    ///
90    /// ## Recommended Alternative
91    ///
92    /// For standard client initialization that matches CLI behavior, use:
93    /// ```ignore
94    /// CliClient::new().await?
95    /// ```
96    ///
97    /// This method **does not** follow the CLI's configuration priority logic (local → global).
98    /// Instead, it uses exactly the configuration provided, which may not be what you expect.
99    ///
100    /// # Arguments
101    ///
102    /// * `config` - The CLI configuration to use (bypasses standard config discovery)
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};
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).await?;
127    ///
128    /// // Prefer this for standard CLI-like behavior:
129    /// let client = CliClient::new().await?;
130    /// # Ok(())
131    /// # }
132    /// ```
133    pub async fn from_config(config: CliConfig) -> Result<Self, CliError> {
134        let keystore =
135            CliKeyStore::new(config.secret_keys_directory.clone()).map_err(CliError::KeyStore)?;
136
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        if let Some(faucet) = config.fee_faucet_id.as_deref() {
149            let faucet_id = AccountId::from_hex(faucet).map_err(|err| {
150                CliError::Config(Box::new(err), "invalid `fee_faucet_id`".to_string())
151            })?;
152            let protocol_config = ProtocolConfig::current(AssetId::new_fungible(faucet_id))
153                .map_err(|err| {
154                    CliError::Config(
155                        Box::new(err),
156                        "failed to derive the protocol configuration from `fee_faucet_id`"
157                            .to_string(),
158                    )
159                })?;
160            builder = builder.protocol_config(protocol_config);
161        }
162
163        if let Some(delta) = config.max_block_number_delta {
164            builder = builder.max_block_number_delta(delta);
165        }
166
167        if let Some(tl_config) = config.note_transport {
168            let note_transport_client =
169                GrpcNoteTransportClient::new(tl_config.endpoint.clone(), tl_config.timeout_ms);
170            builder = builder.note_transport(Arc::new(note_transport_client));
171        }
172
173        let client = builder.build().await.map_err(CliError::from)?;
174        if let Some(path) = std::env::var_os("MIDEN_PROTOCOL_CONFIG") {
175            let path = std::path::PathBuf::from(path);
176            let bytes = std::fs::read(&path).map_err(|err| {
177                CliError::Config(Box::new(err), format!("failed to read {}", path.display()))
178            })?;
179            let protocol_config = ProtocolConfig::read_from_bytes(&bytes).map_err(|err| {
180                CliError::Config(Box::new(err), format!("failed to decode {}", path.display()))
181            })?;
182            client.add_protocol_config(protocol_config).await.map_err(CliError::from)?;
183        }
184        Ok(CliClient(client))
185    }
186
187    /// Creates a new `CliClient` instance configured using the system user configuration.
188    ///
189    /// # ✅ Recommended Constructor
190    ///
191    /// **This is the recommended way to create a `CliClient` instance.**
192    ///
193    /// This method implements the configuration logic used by the CLI tool, allowing external
194    /// projects to create a Client instance with the same configuration. It searches for
195    /// configuration files in the following order:
196    ///
197    /// 1. Local `.miden/miden-client.toml` in the current working directory
198    /// 2. Global `.miden/miden-client.toml` in the home directory
199    ///
200    /// If no configuration file is found, it silently initializes a default configuration.
201    ///
202    /// The client is initialized with:
203    /// - `SQLite` store from the configured path
204    /// - `gRPC` client connection to the configured RPC endpoint
205    /// - Filesystem-based keystore authenticator
206    /// - Optional note transport client (if configured)
207    /// - Transaction graceful blocks delta
208    /// - Optional max block number delta
209    ///
210    /// # Returns
211    ///
212    /// A configured [`CliClient`] instance.
213    ///
214    /// # Errors
215    ///
216    /// Returns a [`CliError`] if:
217    /// - No configuration file is found (local or global)
218    /// - Configuration file parsing fails
219    /// - Keystore initialization fails
220    /// - Client builder fails to construct the client
221    /// - Note transport connection fails (if configured)
222    ///
223    /// # Examples
224    ///
225    /// ```no_run
226    /// use miden_client_cli::CliClient;
227    /// use miden_client_cli::transaction::TransactionRequestBuilder;
228    ///
229    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
230    /// // Create a client with default settings (debug disabled)
231    /// let mut client = CliClient::new().await?;
232    ///
233    /// // Or with debug mode enabled
234    /// let mut client = CliClient::new().await?;
235    ///
236    /// // Use it like a regular Client
237    /// client.sync_state().await?;
238    ///
239    /// // Build and submit transactions
240    /// let req = TransactionRequestBuilder::new()
241    ///     // ... configure transaction
242    ///     .build()?;
243    ///
244    /// // client.submit_new_transaction(req, target_account_id)?;
245    /// # Ok(())
246    /// # }
247    /// ```
248    pub async fn new() -> Result<Self, CliError> {
249        // Check if client is not yet initialized => silently initialize the client
250        if !config_file_exists()? {
251            let init_cmd = InitCmd::default();
252            init_cmd.execute()?;
253        }
254
255        let config = CliConfig::load()?;
256
257        Self::from_config(config).await
258    }
259
260    /// Unwraps the `CliClient` to get the inner `Client<CliKeyStore>`.
261    ///
262    /// This consumes the `CliClient` and returns the underlying client.
263    ///
264    /// # Examples
265    ///
266    /// ```no_run
267    /// use miden_client_cli::CliClient;
268    ///
269    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
270    /// let cli_client = CliClient::new().await?;
271    /// let inner_client = cli_client.into_inner();
272    /// # Ok(())
273    /// # }
274    /// ```
275    pub fn into_inner(self) -> miden_client::Client<CliKeyStore> {
276        self.0
277    }
278}
279
280/// Allows using `CliClient` like `Client<CliKeyStore>` through deref coercion.
281///
282/// This enables calling all `Client` methods on `CliClient` directly.
283impl Deref for CliClient {
284    type Target = miden_client::Client<CliKeyStore>;
285
286    fn deref(&self) -> &Self::Target {
287        &self.0
288    }
289}
290
291/// Allows mutable access to `Client<CliKeyStore>` methods.
292impl DerefMut for CliClient {
293    fn deref_mut(&mut self) -> &mut Self::Target {
294        &mut self.0
295    }
296}
297
298mod advice_inputs;
299mod codecs;
300pub mod config;
301// These modules intentionally shadow the miden_client re-exports - CLI has its own errors/utils
302#[allow(hidden_glob_reexports)]
303mod errors;
304mod info;
305#[allow(hidden_glob_reexports)]
306mod utils;
307
308/// Re-export `MIDEN_DIR` for use in tests
309pub use config::MIDEN_DIR;
310/// Re-export common types for external projects
311pub use config::{CLIENT_CONFIG_FILE_NAME, CliConfig};
312pub use errors::CliError as Error;
313/// Re-export the entire `miden_client` crate so external projects can use a single dependency.
314pub use miden_client::*;
315
316/// Client binary name.
317///
318/// If, for whatever reason, we fail to obtain the client's executable name, then we simply display
319/// the standard "miden-client".
320pub fn client_binary_name() -> OsString {
321    std::env::current_exe()
322        .inspect_err(|e| {
323            eprintln!(
324                "WARNING: Couldn't obtain the path of the current executable because of {e}.\
325             Defaulting to miden-client."
326            );
327        })
328        .and_then(|executable_path| {
329            executable_path.file_name().map(std::ffi::OsStr::to_os_string).ok_or(
330                std::io::Error::other("Couldn't obtain the file name of the current executable"),
331            )
332        })
333        .unwrap_or(OsString::from("miden-client"))
334}
335
336/// Number of blocks that must elapse after a transaction’s reference block before it is marked
337/// stale and discarded.
338const TX_DISCARD_DELTA: u32 = 20;
339
340/// Maximum size (in bytes) of any decoded gRPC response the CLI accepts. Sized to fit large
341/// `SyncTransactions` responses.
342const CLI_MAX_RESPONSE_SIZE_BYTES: usize = 6 * 1024 * 1024;
343
344/// Root CLI struct.
345#[derive(Parser, Debug)]
346#[command(
347    name = "miden-client",
348    about = "The Miden client",
349    version,
350    propagate_version = true,
351    rename_all = "kebab-case"
352)]
353#[command(multicall(true))]
354pub struct MidenClientCli {
355    #[command(subcommand)]
356    behavior: Behavior,
357}
358
359impl From<MidenClientCli> for Cli {
360    fn from(value: MidenClientCli) -> Self {
361        match value.behavior {
362            Behavior::MidenClient { cli } => cli,
363            Behavior::External(args) => Cli::parse_from(args).set_external(),
364        }
365    }
366}
367
368#[derive(Debug, Subcommand)]
369#[command(rename_all = "kebab-case")]
370enum Behavior {
371    /// The Miden Client CLI.
372    MidenClient {
373        #[command(flatten)]
374        cli: Cli,
375    },
376
377    /// Used when the Miden Client CLI is called under a different name, like when it is called from
378    /// [Midenup](https://github.com/0xMiden/midenup). Vec<OsString> holds the "raw" arguments
379    /// passed to the command line, analogous to `argv`.
380    #[command(external_subcommand)]
381    External(Vec<OsString>),
382}
383
384#[derive(Parser, Debug)]
385#[command(name = "miden-client", version)]
386pub struct Cli {
387    #[command(subcommand)]
388    action: Command,
389
390    /// Indicates whether the client's CLI is being called directly, or externally under an alias
391    /// (like in the case of [Midenup](https://github.com/0xMiden/midenup).
392    #[arg(skip)]
393    #[allow(unused)]
394    external: bool,
395}
396
397/// CLI actions.
398#[derive(Debug, Parser)]
399pub enum Command {
400    Account(AccountCmd),
401    NewAccount(NewAccountCmd),
402    NewWallet(NewWalletCmd),
403    Import(ImportCmd),
404    Export(ExportCmd),
405    Keys(KeysCmd),
406    Init(InitCmd),
407    ClearConfig(ClearConfigCmd),
408    Notes(NotesCmd),
409    Sync(SyncCmd),
410    /// View a summary of the current client state.
411    Info(InfoCmd),
412    Tags(TagsCmd),
413    Address(AddressCmd),
414    #[command(name = "tx")]
415    Transaction(TransactionCmd),
416    Mint(MintCmd),
417    Transfer(TransferCmd),
418    Pswap(PswapCmd),
419    Swap(SwapCmd),
420    ConsumeNotes(ConsumeNotesCmd),
421    Exec(ExecCmd),
422    NetworkNoteStatus(NetworkNoteStatusCmd),
423    Call(CallCmd),
424}
425
426/// CLI entry point.
427impl Cli {
428    pub async fn execute(&self) -> Result<(), CliError> {
429        // Handle commands that don't require client initialization
430        match &self.action {
431            Command::Init(init_cmd) => {
432                init_cmd.execute()?;
433                return Ok(());
434            },
435            Command::ClearConfig(clear_config_cmd) => {
436                clear_config_cmd.execute()?;
437                return Ok(());
438            },
439            Command::NetworkNoteStatus(cmd) => {
440                return cmd.execute().await;
441            },
442            _ => {},
443        }
444
445        // Initialize the client silently if it has no configuration file yet.
446        if !config_file_exists()? {
447            let init_cmd = InitCmd::default();
448            init_cmd.execute()?;
449        }
450
451        let cli_config = CliConfig::load()?;
452
453        let keystore = CliKeyStore::new(cli_config.secret_keys_directory.clone())
454            .map_err(CliError::KeyStore)?;
455
456        if let Command::Keys(keys) = &self.action {
457            return keys.execute(&keystore);
458        }
459
460        let cli_client = CliClient::from_config(cli_config).await?;
461
462        let client = cli_client.into_inner();
463
464        match &self.action {
465            Command::Account(account) => account.execute(client).await,
466            Command::NewWallet(new_wallet) => Box::pin(new_wallet.execute(client, keystore)).await,
467            Command::NewAccount(new_account) => {
468                Box::pin(new_account.execute(client, keystore)).await
469            },
470            Command::Import(import) => import.execute(client, keystore).await,
471            Command::Init(_)
472            | Command::ClearConfig(_)
473            | Command::NetworkNoteStatus(_)
474            | Command::Keys(_) => 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::Transfer(transfer) => Box::pin(transfer.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}