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