miden_client_cli/
lib.rs

1use std::env;
2use std::ffi::OsString;
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::auth::TransactionAuthenticator;
10use miden_client::builder::ClientBuilder;
11use miden_client::keystore::FilesystemKeyStore;
12use miden_client::note_transport::grpc::GrpcNoteTransportClient;
13use miden_client::store::{NoteFilter as ClientNoteFilter, OutputNoteRecord};
14use miden_client::{Client, ClientError, DebugMode, IdPrefixFetchError};
15use miden_client_sqlite_store::ClientBuilderSqliteExt;
16use rand::rngs::StdRng;
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, load_config_file};
32use crate::commands::address::AddressCmd;
33
34pub type CliKeyStore = FilesystemKeyStore<StdRng>;
35
36mod config;
37mod errors;
38mod faucet_details_map;
39mod info;
40mod utils;
41
42/// Re-export `MIDEN_DIR` for use in tests
43pub use config::MIDEN_DIR;
44
45/// Config file name.
46const CLIENT_CONFIG_FILE_NAME: &str = "miden-client.toml";
47
48/// Client binary name.
49///
50/// If, for whatever reason, we fail to obtain the client's executable name,
51/// then we simply display the standard "miden-client".
52pub fn client_binary_name() -> OsString {
53    std::env::current_exe()
54        .inspect_err(|e| {
55            eprintln!(
56                "WARNING: Couldn't obtain the path of the current executable because of {e}.\
57             Defaulting to miden-client."
58            );
59        })
60        .and_then(|executable_path| {
61            executable_path.file_name().map(std::ffi::OsStr::to_os_string).ok_or(
62                std::io::Error::other("Couldn't obtain the file name of the current executable"),
63            )
64        })
65        .unwrap_or(OsString::from("miden-client"))
66}
67
68/// Number of blocks that must elapse after a transaction’s reference block before it is marked
69/// stale and discarded.
70const TX_GRACEFUL_BLOCK_DELTA: u32 = 20;
71
72/// Root CLI struct.
73#[derive(Parser, Debug)]
74#[command(
75    name = "miden-client",
76    about = "The Miden client",
77    version,
78    propagate_version = true,
79    rename_all = "kebab-case"
80)]
81#[command(multicall(true))]
82pub struct MidenClientCli {
83    #[command(subcommand)]
84    behavior: Behavior,
85}
86
87impl From<MidenClientCli> for Cli {
88    fn from(value: MidenClientCli) -> Self {
89        match value.behavior {
90            Behavior::MidenClient { cli } => cli,
91            Behavior::External(args) => Cli::parse_from(args).set_external(),
92        }
93    }
94}
95
96#[derive(Debug, Subcommand)]
97#[command(rename_all = "kebab-case")]
98enum Behavior {
99    /// The Miden Client CLI.
100    MidenClient {
101        #[command(flatten)]
102        cli: Cli,
103    },
104
105    /// Used when the Miden Client CLI is called under a different name, like
106    /// when it is called from [Midenup](https://github.com/0xMiden/midenup).
107    /// Vec<OsString> holds the "raw" arguments passed to the command line,
108    /// analogous to `argv`.
109    #[command(external_subcommand)]
110    External(Vec<OsString>),
111}
112
113#[derive(Parser, Debug)]
114#[command(name = "miden-client")]
115pub struct Cli {
116    /// Activates the executor's debug mode, which enables debug output for scripts
117    /// that were compiled and executed with this mode.
118    #[arg(short, long, default_value_t = false)]
119    debug: bool,
120
121    #[command(subcommand)]
122    action: Command,
123
124    /// Indicates whether the client's CLI is being called directly, or
125    /// externally under an alias (like in the case of
126    /// [Midenup](https://github.com/0xMiden/midenup).
127    #[arg(skip)]
128    #[allow(unused)]
129    external: bool,
130}
131
132/// CLI actions.
133#[derive(Debug, Parser)]
134pub enum Command {
135    Account(AccountCmd),
136    NewAccount(NewAccountCmd),
137    NewWallet(NewWalletCmd),
138    Import(ImportCmd),
139    Export(ExportCmd),
140    Init(InitCmd),
141    ClearConfig(ClearConfigCmd),
142    Notes(NotesCmd),
143    Sync(SyncCmd),
144    /// View a summary of the current client state.
145    Info,
146    Tags(TagsCmd),
147    Address(AddressCmd),
148    #[command(name = "tx")]
149    Transaction(TransactionCmd),
150    Mint(MintCmd),
151    Send(SendCmd),
152    Swap(SwapCmd),
153    ConsumeNotes(ConsumeNotesCmd),
154    Exec(ExecCmd),
155}
156
157/// CLI entry point.
158impl Cli {
159    pub async fn execute(&self) -> Result<(), CliError> {
160        // Handle commands that don't require client initialization
161        match &self.action {
162            Command::Init(init_cmd) => {
163                init_cmd.execute()?;
164                return Ok(());
165            },
166            Command::ClearConfig(clear_config_cmd) => {
167                clear_config_cmd.execute()?;
168                return Ok(());
169            },
170            _ => {},
171        }
172
173        // Check if Client is not yet initialized => silently initialize the client
174        if !config_file_exists()? {
175            let init_cmd = InitCmd::default();
176            init_cmd.execute()?;
177        }
178
179        // Define whether we want to use the executor's debug mode based on the env var and
180        // the flag override
181        let in_debug_mode = match env::var("MIDEN_DEBUG") {
182            Ok(value) if value.to_lowercase() == "true" => DebugMode::Enabled,
183            _ => DebugMode::Disabled,
184        };
185
186        // Create the client
187        let (cli_config, _config_path) = load_config_file()?;
188
189        let keystore = CliKeyStore::new(cli_config.secret_keys_directory.clone())
190            .map_err(CliError::KeyStore)?;
191
192        let mut builder = ClientBuilder::new()
193            .sqlite_store(cli_config.store_filepath.clone())
194            .grpc_client(&cli_config.rpc.endpoint.clone().into(), Some(cli_config.rpc.timeout_ms))
195            .authenticator(Arc::new(keystore.clone()))
196            .in_debug_mode(in_debug_mode)
197            .tx_graceful_blocks(Some(TX_GRACEFUL_BLOCK_DELTA));
198
199        if let Some(delta) = cli_config.max_block_number_delta {
200            builder = builder.max_block_number_delta(delta);
201        }
202
203        if let Some(tl_config) = cli_config.note_transport {
204            let client =
205                GrpcNoteTransportClient::connect(tl_config.endpoint.clone(), tl_config.timeout_ms)
206                    .await
207                    .map_err(|e| CliError::from(ClientError::from(e)))?;
208            builder = builder.note_transport(Arc::new(client));
209        }
210
211        let client = builder.build().await?;
212
213        // Execute CLI command
214        match &self.action {
215            Command::Account(account) => account.execute(client).await,
216            Command::NewWallet(new_wallet) => Box::pin(new_wallet.execute(client, keystore)).await,
217            Command::NewAccount(new_account) => {
218                Box::pin(new_account.execute(client, keystore)).await
219            },
220            Command::Import(import) => import.execute(client, keystore).await,
221            Command::Init(_) | Command::ClearConfig(_) => Ok(()), // Already handled earlier
222            Command::Info => info::print_client_info(&client).await,
223            Command::Notes(notes) => Box::pin(notes.execute(client)).await,
224            Command::Sync(sync) => sync.execute(client).await,
225            Command::Tags(tags) => tags.execute(client).await,
226            Command::Address(addresses) => addresses.execute(client).await,
227            Command::Transaction(transaction) => transaction.execute(client).await,
228            Command::Exec(execute_program) => Box::pin(execute_program.execute(client)).await,
229            Command::Export(cmd) => cmd.execute(client, keystore).await,
230            Command::Mint(mint) => Box::pin(mint.execute(client)).await,
231            Command::Send(send) => Box::pin(send.execute(client)).await,
232            Command::Swap(swap) => Box::pin(swap.execute(client)).await,
233            Command::ConsumeNotes(consume_notes) => Box::pin(consume_notes.execute(client)).await,
234        }
235    }
236
237    fn set_external(mut self) -> Self {
238        self.external = true;
239        self
240    }
241}
242
243pub fn create_dynamic_table(headers: &[&str]) -> Table {
244    let header_cells = headers
245        .iter()
246        .map(|header| Cell::new(header).add_attribute(Attribute::Bold))
247        .collect::<Vec<_>>();
248
249    let mut table = Table::new();
250    table
251        .load_preset(presets::UTF8_FULL)
252        .set_content_arrangement(ContentArrangement::DynamicFullWidth)
253        .set_header(header_cells);
254
255    table
256}
257
258/// Returns the client output note whose ID starts with `note_id_prefix`.
259///
260/// # Errors
261///
262/// - Returns [`IdPrefixFetchError::NoMatch`] if we were unable to find any note where
263///   `note_id_prefix` is a prefix of its ID.
264/// - Returns [`IdPrefixFetchError::MultipleMatches`] if there were more than one note found where
265///   `note_id_prefix` is a prefix of its ID.
266pub(crate) async fn get_output_note_with_id_prefix<AUTH: TransactionAuthenticator + Sync>(
267    client: &Client<AUTH>,
268    note_id_prefix: &str,
269) -> Result<OutputNoteRecord, IdPrefixFetchError> {
270    let mut output_note_records = client
271        .get_output_notes(ClientNoteFilter::All)
272        .await
273        .map_err(|err| {
274            tracing::error!("Error when fetching all notes from the store: {err}");
275            IdPrefixFetchError::NoMatch(format!("note ID prefix {note_id_prefix}").to_string())
276        })?
277        .into_iter()
278        .filter(|note_record| note_record.id().to_hex().starts_with(note_id_prefix))
279        .collect::<Vec<_>>();
280
281    if output_note_records.is_empty() {
282        return Err(IdPrefixFetchError::NoMatch(
283            format!("note ID prefix {note_id_prefix}").to_string(),
284        ));
285    }
286    if output_note_records.len() > 1 {
287        let output_note_record_ids =
288            output_note_records.iter().map(OutputNoteRecord::id).collect::<Vec<_>>();
289        tracing::error!(
290            "Multiple notes found for the prefix {}: {:?}",
291            note_id_prefix,
292            output_note_record_ids
293        );
294        return Err(IdPrefixFetchError::MultipleMatches(
295            format!("note ID prefix {note_id_prefix}").to_string(),
296        ));
297    }
298
299    Ok(output_note_records
300        .pop()
301        .expect("input_note_records should always have one element"))
302}
303
304/// Returns the client account whose ID starts with `account_id_prefix`.
305///
306/// # Errors
307///
308/// - Returns [`IdPrefixFetchError::NoMatch`] if we were unable to find any account where
309///   `account_id_prefix` is a prefix of its ID.
310/// - Returns [`IdPrefixFetchError::MultipleMatches`] if there were more than one account found
311///   where `account_id_prefix` is a prefix of its ID.
312async fn get_account_with_id_prefix<AUTH>(
313    client: &Client<AUTH>,
314    account_id_prefix: &str,
315) -> Result<AccountHeader, IdPrefixFetchError> {
316    let mut accounts = client
317        .get_account_headers()
318        .await
319        .map_err(|err| {
320            tracing::error!("Error when fetching all accounts from the store: {err}");
321            IdPrefixFetchError::NoMatch(
322                format!("account ID prefix {account_id_prefix}").to_string(),
323            )
324        })?
325        .into_iter()
326        .filter(|(account_header, _)| account_header.id().to_hex().starts_with(account_id_prefix))
327        .map(|(acc, _)| acc)
328        .collect::<Vec<_>>();
329
330    if accounts.is_empty() {
331        return Err(IdPrefixFetchError::NoMatch(
332            format!("account ID prefix {account_id_prefix}").to_string(),
333        ));
334    }
335    if accounts.len() > 1 {
336        let account_ids = accounts.iter().map(AccountHeader::id).collect::<Vec<_>>();
337        tracing::error!(
338            "Multiple accounts found for the prefix {}: {:?}",
339            account_id_prefix,
340            account_ids
341        );
342        return Err(IdPrefixFetchError::MultipleMatches(
343            format!("account ID prefix {account_id_prefix}").to_string(),
344        ));
345    }
346
347    Ok(accounts.pop().expect("account_ids should always have one element"))
348}