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::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, SendCmd, SwapCmd};
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::transaction::TransactionRequestBuilder;
48/// use miden_client_cli::{CliClient, DebugMode};
49///
50/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
51/// // Create a CLI-configured client
52/// let mut client = CliClient::new(DebugMode::Disabled).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(debug_mode).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 /// * `debug_mode` - The debug mode setting ([`DebugMode::Enabled`] or [`DebugMode::Disabled`])
102 ///
103 /// # Returns
104 ///
105 /// A configured [`CliClient`] instance.
106 ///
107 /// # Errors
108 ///
109 /// Returns a [`CliError`] if:
110 /// - Keystore initialization fails
111 /// - Client builder fails to construct the client
112 /// - Note transport connection fails (if configured)
113 ///
114 /// # Examples
115 ///
116 /// ```no_run
117 /// use std::path::PathBuf;
118 ///
119 /// use miden_client_cli::{CliClient, CliConfig, DebugMode};
120 ///
121 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
122 /// // BEWARE: This bypasses standard config discovery!
123 /// // Only use if you know what you're doing.
124 /// let config = CliConfig::from_dir(&PathBuf::from("/path/to/.miden"))?;
125 /// let client = CliClient::from_config(config, DebugMode::Disabled).await?;
126 ///
127 /// // Prefer this for standard CLI-like behavior:
128 /// let client = CliClient::new(DebugMode::Disabled).await?;
129 /// # Ok(())
130 /// # }
131 /// ```
132 pub async fn from_config(
133 config: CliConfig,
134 debug_mode: miden_client::DebugMode,
135 ) -> Result<Self, CliError> {
136 // Create keystore
137 let keystore =
138 CliKeyStore::new(config.secret_keys_directory.clone()).map_err(CliError::KeyStore)?;
139
140 // Build client with the provided configuration
141 let mut builder = ClientBuilder::new()
142 .sqlite_store(config.store_filepath.clone())
143 .grpc_client(&config.rpc.endpoint.clone().into(), Some(config.rpc.timeout_ms))
144 .authenticator(Arc::new(keystore))
145 .in_debug_mode(debug_mode)
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 /// # Arguments
189 ///
190 /// * `debug_mode` - The debug mode setting ([`DebugMode::Enabled`] or [`DebugMode::Disabled`]).
191 ///
192 /// # Returns
193 ///
194 /// A configured [`CliClient`] instance.
195 ///
196 /// # Errors
197 ///
198 /// Returns a [`CliError`] if:
199 /// - No configuration file is found (local or global)
200 /// - Configuration file parsing fails
201 /// - Keystore initialization fails
202 /// - Client builder fails to construct the client
203 /// - Note transport connection fails (if configured)
204 ///
205 /// # Examples
206 ///
207 /// ```no_run
208 /// use miden_client_cli::transaction::TransactionRequestBuilder;
209 /// use miden_client_cli::{CliClient, DebugMode};
210 ///
211 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
212 /// // Create a client with default settings (debug disabled)
213 /// let mut client = CliClient::new(DebugMode::Disabled).await?;
214 ///
215 /// // Or with debug mode enabled
216 /// let mut client = CliClient::new(DebugMode::Enabled).await?;
217 ///
218 /// // Use it like a regular Client
219 /// client.sync_state().await?;
220 ///
221 /// // Build and submit transactions
222 /// let req = TransactionRequestBuilder::new()
223 /// // ... configure transaction
224 /// .build()?;
225 ///
226 /// // client.submit_new_transaction(req, target_account_id)?;
227 /// # Ok(())
228 /// # }
229 /// ```
230 pub async fn new(debug_mode: miden_client::DebugMode) -> Result<Self, CliError> {
231 // Check if client is not yet initialized => silently initialize the client
232 if !config_file_exists()? {
233 let init_cmd = InitCmd::default();
234 init_cmd.execute()?;
235 }
236
237 // Load configuration from system
238 let config = CliConfig::load()?;
239
240 // Create client using the loaded configuration
241 Self::from_config(config, debug_mode).await
242 }
243
244 /// Unwraps the `CliClient` to get the inner `Client<CliKeyStore>`.
245 ///
246 /// This consumes the `CliClient` and returns the underlying client.
247 ///
248 /// # Examples
249 ///
250 /// ```no_run
251 /// use miden_client_cli::{CliClient, DebugMode};
252 ///
253 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
254 /// let cli_client = CliClient::new(DebugMode::Disabled).await?;
255 /// let inner_client = cli_client.into_inner();
256 /// # Ok(())
257 /// # }
258 /// ```
259 pub fn into_inner(self) -> miden_client::Client<CliKeyStore> {
260 self.0
261 }
262}
263
264/// Allows using `CliClient` like `Client<CliKeyStore>` through deref coercion.
265///
266/// This enables calling all `Client` methods on `CliClient` directly.
267impl Deref for CliClient {
268 type Target = miden_client::Client<CliKeyStore>;
269
270 fn deref(&self) -> &Self::Target {
271 &self.0
272 }
273}
274
275/// Allows mutable access to `Client<CliKeyStore>` methods.
276impl DerefMut for CliClient {
277 fn deref_mut(&mut self) -> &mut Self::Target {
278 &mut self.0
279 }
280}
281
282pub mod config;
283// These modules intentionally shadow the miden_client re-exports - CLI has its own errors/utils
284#[allow(hidden_glob_reexports)]
285mod errors;
286mod info;
287#[allow(hidden_glob_reexports)]
288mod utils;
289
290/// Re-export `MIDEN_DIR` for use in tests
291pub use config::MIDEN_DIR;
292/// Re-export common types for external projects
293pub use config::{CLIENT_CONFIG_FILE_NAME, CliConfig};
294pub use errors::CliError as Error;
295/// Re-export the entire `miden_client` crate so external projects can use a single dependency.
296pub use miden_client::*;
297
298/// Client binary name.
299///
300/// If, for whatever reason, we fail to obtain the client's executable name,
301/// then we simply display the standard "miden-client".
302pub fn client_binary_name() -> OsString {
303 std::env::current_exe()
304 .inspect_err(|e| {
305 eprintln!(
306 "WARNING: Couldn't obtain the path of the current executable because of {e}.\
307 Defaulting to miden-client."
308 );
309 })
310 .and_then(|executable_path| {
311 executable_path.file_name().map(std::ffi::OsStr::to_os_string).ok_or(
312 std::io::Error::other("Couldn't obtain the file name of the current executable"),
313 )
314 })
315 .unwrap_or(OsString::from("miden-client"))
316}
317
318/// Number of blocks that must elapse after a transaction’s reference block before it is marked
319/// stale and discarded.
320const TX_DISCARD_DELTA: u32 = 20;
321
322/// Root CLI struct.
323#[derive(Parser, Debug)]
324#[command(
325 name = "miden-client",
326 about = "The Miden client",
327 version,
328 propagate_version = true,
329 rename_all = "kebab-case"
330)]
331#[command(multicall(true))]
332pub struct MidenClientCli {
333 #[command(subcommand)]
334 behavior: Behavior,
335}
336
337impl From<MidenClientCli> for Cli {
338 fn from(value: MidenClientCli) -> Self {
339 match value.behavior {
340 Behavior::MidenClient { cli } => cli,
341 Behavior::External(args) => Cli::parse_from(args).set_external(),
342 }
343 }
344}
345
346#[derive(Debug, Subcommand)]
347#[command(rename_all = "kebab-case")]
348enum Behavior {
349 /// The Miden Client CLI.
350 MidenClient {
351 #[command(flatten)]
352 cli: Cli,
353 },
354
355 /// Used when the Miden Client CLI is called under a different name, like
356 /// when it is called from [Midenup](https://github.com/0xMiden/midenup).
357 /// Vec<OsString> holds the "raw" arguments passed to the command line,
358 /// analogous to `argv`.
359 #[command(external_subcommand)]
360 External(Vec<OsString>),
361}
362
363#[derive(Parser, Debug)]
364#[command(name = "miden-client")]
365pub struct Cli {
366 /// Activates the executor's debug mode, which enables debug output for scripts
367 /// that were compiled and executed with this mode.
368 #[arg(short, long, default_value_t = false)]
369 debug: bool,
370
371 #[command(subcommand)]
372 action: Command,
373
374 /// Indicates whether the client's CLI is being called directly, or
375 /// externally under an alias (like in the case of
376 /// [Midenup](https://github.com/0xMiden/midenup).
377 #[arg(skip)]
378 #[allow(unused)]
379 external: bool,
380}
381
382/// CLI actions.
383#[derive(Debug, Parser)]
384pub enum Command {
385 Account(AccountCmd),
386 NewAccount(NewAccountCmd),
387 NewWallet(NewWalletCmd),
388 Import(ImportCmd),
389 Export(ExportCmd),
390 Init(InitCmd),
391 ClearConfig(ClearConfigCmd),
392 Notes(NotesCmd),
393 Sync(SyncCmd),
394 /// View a summary of the current client state.
395 Info(InfoCmd),
396 Tags(TagsCmd),
397 Address(AddressCmd),
398 #[command(name = "tx")]
399 Transaction(TransactionCmd),
400 Mint(MintCmd),
401 Send(SendCmd),
402 Pswap(PswapCmd),
403 Swap(SwapCmd),
404 ConsumeNotes(ConsumeNotesCmd),
405 Exec(ExecCmd),
406 NetworkNoteStatus(NetworkNoteStatusCmd),
407 Call(CallCmd),
408}
409
410/// CLI entry point.
411impl Cli {
412 pub async fn execute(&self) -> Result<(), CliError> {
413 // Handle commands that don't require client initialization
414 match &self.action {
415 Command::Init(init_cmd) => {
416 init_cmd.execute()?;
417 return Ok(());
418 },
419 Command::ClearConfig(clear_config_cmd) => {
420 clear_config_cmd.execute()?;
421 return Ok(());
422 },
423 Command::NetworkNoteStatus(cmd) => {
424 return cmd.execute().await;
425 },
426 _ => {},
427 }
428
429 // Check if Client is not yet initialized => silently initialize the client
430 if !config_file_exists()? {
431 let init_cmd = InitCmd::default();
432 init_cmd.execute()?;
433 }
434
435 // Define whether we want to use the executor's debug mode based on the env var and
436 // the flag override
437 let in_debug_mode = match env::var("MIDEN_DEBUG") {
438 Ok(value) if value.to_lowercase() == "true" => miden_client::DebugMode::Enabled,
439 _ => miden_client::DebugMode::Disabled,
440 };
441
442 // Load configuration
443 let cli_config = CliConfig::load()?;
444
445 // Create keystore for commands that need it
446 let keystore = CliKeyStore::new(cli_config.secret_keys_directory.clone())
447 .map_err(CliError::KeyStore)?;
448
449 // Create the client
450 let cli_client = CliClient::from_config(cli_config, in_debug_mode).await?;
451
452 // Extract the inner client for command execution
453 let client = cli_client.into_inner();
454
455 // Execute CLI command
456 match &self.action {
457 Command::Account(account) => account.execute(client).await,
458 Command::NewWallet(new_wallet) => Box::pin(new_wallet.execute(client, keystore)).await,
459 Command::NewAccount(new_account) => {
460 Box::pin(new_account.execute(client, keystore)).await
461 },
462 Command::Import(import) => import.execute(client, keystore).await,
463 Command::Init(_) | Command::ClearConfig(_) | Command::NetworkNoteStatus(_) => Ok(()), /* Already handled earlier */
464 Command::Info(info_cmd) => info::print_client_info(&client, info_cmd.rpc_status).await,
465 Command::Notes(notes) => Box::pin(notes.execute(client)).await,
466 Command::Sync(sync) => sync.execute(client).await,
467 Command::Tags(tags) => tags.execute(client).await,
468 Command::Address(addresses) => addresses.execute(client).await,
469 Command::Transaction(transaction) => transaction.execute(client).await,
470 Command::Exec(execute_program) => Box::pin(execute_program.execute(client)).await,
471 Command::Call(call) => Box::pin(call.execute(client)).await,
472 Command::Export(cmd) => cmd.execute(client, keystore).await,
473 Command::Mint(mint) => Box::pin(mint.execute(client)).await,
474 Command::Send(send) => Box::pin(send.execute(client)).await,
475 Command::Pswap(pswap) => Box::pin(pswap.execute(client)).await,
476 Command::Swap(swap) => Box::pin(swap.execute(client)).await,
477 Command::ConsumeNotes(consume_notes) => Box::pin(consume_notes.execute(client)).await,
478 }
479 }
480
481 fn set_external(mut self) -> Self {
482 self.external = true;
483 self
484 }
485}
486
487pub fn create_dynamic_table(headers: &[&str]) -> Table {
488 let header_cells = headers
489 .iter()
490 .map(|header| Cell::new(header).add_attribute(Attribute::Bold))
491 .collect::<Vec<_>>();
492
493 let mut table = Table::new();
494 table
495 .load_preset(presets::UTF8_FULL)
496 .set_content_arrangement(ContentArrangement::DynamicFullWidth)
497 .set_header(header_cells);
498
499 table
500}
501
502/// Returns the client output note whose ID starts with `note_id_prefix`.
503///
504/// # Errors
505///
506/// - Returns [`IdPrefixFetchError::NoMatch`](miden_client::IdPrefixFetchError::NoMatch) if we were
507/// unable to find any note where `note_id_prefix` is a prefix of its ID.
508/// - Returns [`IdPrefixFetchError::MultipleMatches`](miden_client::IdPrefixFetchError::MultipleMatches)
509/// if there were more than one note found where `note_id_prefix` is a prefix of its ID.
510pub(crate) async fn get_output_note_with_id_prefix<AUTH: Keystore + Sync>(
511 client: &miden_client::Client<AUTH>,
512 note_id_prefix: &str,
513) -> Result<OutputNoteRecord, miden_client::IdPrefixFetchError> {
514 let mut output_note_records = client
515 .get_output_notes(ClientNoteFilter::All)
516 .await
517 .map_err(|err| {
518 tracing::error!("Error when fetching all notes from the store: {err}");
519 miden_client::IdPrefixFetchError::NoMatch(
520 format!("note ID prefix {note_id_prefix}").to_string(),
521 )
522 })?
523 .into_iter()
524 .filter(|note_record| note_record.id().to_hex().starts_with(note_id_prefix))
525 .collect::<Vec<_>>();
526
527 if output_note_records.is_empty() {
528 return Err(miden_client::IdPrefixFetchError::NoMatch(
529 format!("note ID prefix {note_id_prefix}").to_string(),
530 ));
531 }
532 if output_note_records.len() > 1 {
533 let output_note_record_ids =
534 output_note_records.iter().map(OutputNoteRecord::id).collect::<Vec<_>>();
535 tracing::error!(
536 "Multiple notes found for the prefix {}: {:?}",
537 note_id_prefix,
538 output_note_record_ids
539 );
540 return Err(miden_client::IdPrefixFetchError::MultipleMatches(
541 format!("note ID prefix {note_id_prefix}").to_string(),
542 ));
543 }
544
545 Ok(output_note_records
546 .pop()
547 .expect("input_note_records should always have one element"))
548}
549
550/// Returns the client account whose ID starts with `account_id_prefix`.
551///
552/// # Errors
553///
554/// - Returns [`IdPrefixFetchError::NoMatch`](miden_client::IdPrefixFetchError::NoMatch) if we were
555/// unable to find any account where `account_id_prefix` is a prefix of its ID.
556/// - Returns [`IdPrefixFetchError::MultipleMatches`](miden_client::IdPrefixFetchError::MultipleMatches)
557/// if there were more than one account found where `account_id_prefix` is a prefix of its ID.
558async fn get_account_with_id_prefix<AUTH>(
559 client: &miden_client::Client<AUTH>,
560 account_id_prefix: &str,
561) -> Result<AccountHeader, miden_client::IdPrefixFetchError> {
562 let mut accounts = client
563 .get_account_headers()
564 .await
565 .map_err(|err| {
566 tracing::error!("Error when fetching all accounts from the store: {err}");
567 miden_client::IdPrefixFetchError::NoMatch(
568 format!("account ID prefix {account_id_prefix}").to_string(),
569 )
570 })?
571 .into_iter()
572 .filter(|(account_header, _)| account_header.id().to_hex().starts_with(account_id_prefix))
573 .map(|(acc, _)| acc)
574 .collect::<Vec<_>>();
575
576 if accounts.is_empty() {
577 return Err(miden_client::IdPrefixFetchError::NoMatch(
578 format!("account ID prefix {account_id_prefix}").to_string(),
579 ));
580 }
581 if accounts.len() > 1 {
582 let account_ids = accounts.iter().map(AccountHeader::id).collect::<Vec<_>>();
583 tracing::error!(
584 "Multiple accounts found for the prefix {}: {:?}",
585 account_id_prefix,
586 account_ids
587 );
588 return Err(miden_client::IdPrefixFetchError::MultipleMatches(
589 format!("account ID prefix {account_id_prefix}").to_string(),
590 ));
591 }
592
593 Ok(accounts.pop().expect("account_ids should always have one element"))
594}