From fe407ffe1c52dd4f1eb0229ee0f5946ebfa81b9f Mon Sep 17 00:00:00 2001 From: /alex/ Date: Thu, 21 Mar 2024 13:50:51 +0100 Subject: [PATCH] CLI: improve wallet creation (#2183) * allow to set alias as init parameter * remove bip path default * refactor a bit * better doc Co-authored-by: DaughterOfMars * fixes * fixes 2 * thanks :beers: Co-authored-by: DaughterOfMars * fmt * bip path options * clean up * more clean up * bail * specify default decision --------- Co-authored-by: DaughterOfMars --- cli/src/cli.rs | 137 +++++++++++++++++++------------------- cli/src/helper.rs | 135 +++++++++++++++++++++++++++++-------- cli/src/wallet_cli/mod.rs | 4 +- 3 files changed, 177 insertions(+), 99 deletions(-) diff --git a/cli/src/cli.rs b/cli/src/cli.rs index 3cdf6c6eac..209c19424a 100644 --- a/cli/src/cli.rs +++ b/cli/src/cli.rs @@ -20,8 +20,9 @@ use log::LevelFilter; use crate::{ helper::{ - check_file_exists, enter_or_generate_mnemonic, generate_mnemonic, get_alias, get_decision, get_password, - import_mnemonic, select_secret_manager, SecretManagerChoice, + check_file_exists, enter_address, enter_alias, enter_decision, enter_or_generate_mnemonic, enter_password, + generate_mnemonic, import_mnemonic, parse_bip_path, select_or_enter_bip_path, select_secret_manager, + SecretManagerChoice, }, println_log_error, println_log_info, }; @@ -67,12 +68,16 @@ pub struct InitParameters { /// Set the node to connect to with this wallet. #[arg(short, long, value_name = "URL", env = "NODE_URL", default_value = DEFAULT_NODE_URL)] pub node_url: String, - /// Set the BIP path, `4219/0/0/0` if not provided. - #[arg(short, long, value_parser = parse_bip_path, default_value = "4219/0/0/0")] + /// Set the BIP path. If not provided a bip path has to be provided interactively on first launch. + /// The expected format is: `///`. + #[arg(short, long, value_parser = parse_bip_path)] pub bip_path: Option, /// Set the Bech32-encoded wallet address. #[arg(short, long)] pub address: Option, + /// Set the wallet alias name. + #[arg(short = 'l', long)] + pub alias: Option, } impl Default for InitParameters { @@ -82,37 +87,13 @@ impl Default for InitParameters { stronghold_snapshot_path: DEFAULT_STRONGHOLD_SNAPSHOT_PATH.to_string(), mnemonic_file_path: None, node_url: DEFAULT_NODE_URL.to_string(), - bip_path: Some(Bip44::new(SHIMMER_COIN_TYPE)), + bip_path: None, address: None, + alias: None, } } } -fn parse_bip_path(arg: &str) -> Result { - let mut bip_path_enc = Vec::with_capacity(4); - for p in arg.split_terminator('/').map(|p| p.trim()) { - match p.parse::() { - Ok(value) => bip_path_enc.push(value), - Err(_) => { - return Err(format!("cannot parse BIP path: {p}")); - } - } - } - - if bip_path_enc.len() != 4 { - return Err( - "invalid BIP path format. Expected: `coin_type/account_index/change_address/address_index`".to_string(), - ); - } - - let bip_path = Bip44::new(bip_path_enc[0]) - .with_account(bip_path_enc[1]) - .with_change(bip_path_enc[2]) - .with_address_index(bip_path_enc[3]); - - Ok(bip_path) -} - #[derive(Debug, Clone, Subcommand)] pub enum CliCommand { /// Create a backup file. Currently only Stronghold backup is supported. @@ -201,7 +182,7 @@ pub async fn new_wallet(cli: Cli) -> Result, Error> { LinkedSecretManager::Stronghold { snapshot_exists: true, .. } => { - let password = get_password("Stronghold password", false)?; + let password = enter_password("Stronghold password", false)?; backup_to_stronghold_snapshot_command(&wallet, &password, Path::new(&backup_path)).await?; return Ok(None); } @@ -223,7 +204,7 @@ pub async fn new_wallet(cli: Cli) -> Result, Error> { LinkedSecretManager::Stronghold { snapshot_exists: true, .. } => { - let current_password = get_password("Stronghold password", false)?; + let current_password = enter_password("Stronghold password", false)?; change_password_command(&wallet, current_password).await?; Some(wallet) } @@ -246,12 +227,7 @@ pub async fn new_wallet(cli: Cli) -> Result, Error> { storage_path.display() ); } - let secret_manager = create_secret_manager(&init_parameters).await?; - let secret_manager_variant = secret_manager.to_string(); - let wallet = init_command(storage_path, secret_manager, init_parameters).await?; - println_log_info!("Created new wallet with '{}' secret manager.", secret_manager_variant); - - Some(wallet) + Some(init_command(storage_path, init_parameters).await?) } CliCommand::MigrateStrongholdSnapshotV2ToV3 { path } => { migrate_stronghold_snapshot_v2_to_v3_command(path).await?; @@ -348,12 +324,8 @@ pub async fn new_wallet(cli: Cli) -> Result, Error> { let snapshot_path = Path::new(&init_params.stronghold_snapshot_path); if !snapshot_path.exists() { - if get_decision("Create a new wallet with default parameters?")? { - let secret_manager = create_secret_manager(&init_params).await?; - let secret_manager_variant = secret_manager.to_string(); - let wallet = init_command(storage_path, secret_manager, init_params).await?; - println_log_info!("Created new wallet with '{}' secret manager.", secret_manager_variant); - Some(wallet) + if enter_decision("Create a new wallet with default parameters?", "yes")? { + Some(init_command(storage_path, init_params).await?) } else { Cli::print_help()?; None @@ -370,6 +342,17 @@ pub async fn new_wallet(cli: Cli) -> Result, Error> { }) } +async fn print_new_wallet_summary(wallet: &Wallet, secret_manager_variant: &str) -> Result<(), Error> { + println_log_info!("Created new wallet with the following parameters:",); + println_log_info!(" Secret manager: {secret_manager_variant}"); + println_log_info!(" Wallet address: {}", wallet.address().await); + println_log_info!(" Wallet bip path: {:?}", wallet.bip_path().await); + println_log_info!(" Wallet alias: {:?}", wallet.alias().await); + println_log_info!(" Network name: {}", wallet.client().get_network_name().await?); + println_log_info!(" Node url: {}", wallet.client().get_node_info().await?.url); + Ok(()) +} + pub async fn backup_to_stronghold_snapshot_command( wallet: &Wallet, password: &Password, @@ -385,7 +368,7 @@ pub async fn backup_to_stronghold_snapshot_command( } pub async fn change_password_command(wallet: &Wallet, current_password: Password) -> Result<(), Error> { - let new_password = get_password("New Stronghold password", true)?; + let new_password = enter_password("New Stronghold password", true)?; wallet .change_stronghold_password(current_password, new_password) .await?; @@ -395,38 +378,54 @@ pub async fn change_password_command(wallet: &Wallet, current_password: Password Ok(()) } -pub async fn init_command( - storage_path: &Path, - secret_manager: SecretManager, - init_params: InitParameters, -) -> Result { - let alias = if get_decision("Do you want to assign an alias to your wallet?")? { - Some(get_alias("New wallet alias").await?) - } else { - None - }; +pub async fn init_command(storage_path: &Path, init_params: InitParameters) -> Result { + let secret_manager = create_secret_manager(&init_params).await?; + let secret_manager_variant = secret_manager.to_string(); + + let mut address = init_params.address.map(|s| Bech32Address::from_str(&s)).transpose()?; + let mut forced = false; + if address.is_none() { + if enter_decision("Do you want to set the address of the new wallet?", "no")? { + address.replace(enter_address()?); + } else { + forced = true; + } + } + + let mut bip_path = init_params.bip_path; + if bip_path.is_none() { + if forced || enter_decision("Do you want to set the bip path of the new wallet?", "yes")? { + bip_path.replace(select_or_enter_bip_path()?); + } + } + + let mut alias = init_params.alias; + if alias.is_none() { + if enter_decision("Do you want to set an alias for the new wallet?", "yes")? { + alias.replace(enter_alias()?); + } + } - Ok(Wallet::builder() + let wallet = Wallet::builder() .with_secret_manager(secret_manager) .with_client_options(ClientOptions::new().with_node(init_params.node_url.as_str())?) .with_storage_path(storage_path.to_str().expect("invalid unicode")) - .with_address( - init_params - .address - .map(|addr| Bech32Address::from_str(&addr)) - .transpose()?, - ) - .with_bip_path(init_params.bip_path) + .with_address(address) + .with_bip_path(bip_path) .with_alias(alias) .finish() - .await?) + .await?; + + print_new_wallet_summary(&wallet, &secret_manager_variant).await?; + + Ok(wallet) } pub async fn migrate_stronghold_snapshot_v2_to_v3_command(path: Option) -> Result<(), Error> { let snapshot_path = path.as_deref().unwrap_or(DEFAULT_STRONGHOLD_SNAPSHOT_PATH); check_file_exists(snapshot_path.as_ref()).await?; - let password = get_password("Stronghold password", false)?; + let password = enter_password("Stronghold password", false)?; StrongholdAdapter::migrate_snapshot_v2_to_v3(snapshot_path, password, "wallet.rs", 100, None, None)?; println_log_info!("Stronghold snapshot successfully migrated from v2 to v3."); @@ -449,7 +448,7 @@ pub async fn restore_from_stronghold_snapshot_command( let mut builder = Wallet::builder(); let password = if snapshot_path.exists() { - Some(get_password("Stronghold password", false)?) + Some(enter_password("Stronghold password", false)?) } else { None }; @@ -480,7 +479,7 @@ pub async fn restore_from_stronghold_snapshot_command( .finish() .await?; - let password = get_password("Stronghold backup password", false)?; + let password = enter_password("Stronghold backup password", false)?; if let Err(e) = wallet .restore_from_stronghold_snapshot(backup_path.into(), password, None, None) .await @@ -519,7 +518,7 @@ async fn create_secret_manager(init_params: &InitParameters) -> Result Result import_mnemonic(path).await?, None => enter_or_generate_mnemonic().await?, diff --git a/cli/src/helper.rs b/cli/src/helper.rs index 75f179f46f..f31123ae49 100644 --- a/cli/src/helper.rs +++ b/cli/src/helper.rs @@ -8,8 +8,13 @@ use chrono::{DateTime, NaiveDateTime, Utc}; use dialoguer::{console::Term, theme::ColorfulTheme, Input, Select}; use eyre::{bail, eyre, Error}; use iota_sdk::{ - client::{utils::Password, verify_mnemonic}, - crypto::keys::bip39::Mnemonic, + client::{ + constants::{IOTA_COIN_TYPE, SHIMMER_COIN_TYPE}, + utils::Password, + verify_mnemonic, + }, + crypto::keys::{bip39::Mnemonic, bip44::Bip44}, + types::block::address::Bech32Address, }; use tokio::{ fs::{self, OpenOptions}, @@ -21,7 +26,7 @@ use crate::{println_log_error, println_log_info}; const DEFAULT_MNEMONIC_FILE_PATH: &str = "./mnemonic.txt"; -pub fn get_password(prompt: &str, confirmation: bool) -> Result { +pub fn enter_password(prompt: &str, confirmation: bool) -> Result { let mut password = dialoguer::Password::new().with_prompt(prompt); if confirmation { @@ -33,11 +38,11 @@ pub fn get_password(prompt: &str, confirmation: bool) -> Result Ok(password.interact()?.into()) } -pub fn get_decision(prompt: &str) -> Result { +pub fn enter_decision(prompt: &str, default: &str) -> Result { loop { let input = Input::::new() .with_prompt(prompt) - .default("yes".into()) + .default(default.to_string()) .interact_text()?; match input.to_lowercase().as_str() { @@ -50,13 +55,70 @@ pub fn get_decision(prompt: &str) -> Result { } } -pub async fn get_alias(prompt: &str) -> Result { +pub fn enter_address() -> Result { loop { - let input = Input::::new().with_prompt(prompt).interact_text()?; - if input.is_empty() || !input.is_ascii() { - println_log_error!("Invalid input, please choose a non-empty alias consisting of ASCII characters."); - } else { + let input = Input::::new() + .with_prompt("Enter a Bech32 wallet address") + .interact_text()?; + match Bech32Address::from_str(&input) { + Ok(address) => { + return Ok(address); + } + Err(err) => { + println_log_error!("Invalid input, please enter a valid Bech32 address: {err}"); + } + } + } +} + +pub fn parse_bip_path(input: &str) -> Result { + if input.is_empty() || !input.is_ascii() { + bail!("invalid BIP path format. Expected: `///`"); + } + + let mut segments = Vec::with_capacity(4); + for (i, segment) in input.split_terminator('/').map(|p| p.trim()).enumerate() { + match segment.parse::() { + Ok(s) => segments.push(s), + Err(err) => { + bail!("invalid BIP path segment. {i}/`{segment}`: {err}"); + } + } + } + + if segments.len() != 4 { + bail!("invalid BIP path format. Expected: `///`"); + } + + let bip_path = Bip44::new(segments[0]) + .with_account(segments[1]) + .with_change(segments[2]) + .with_address_index(segments[3]); + + Ok(bip_path) +} + +pub fn enter_alias() -> Result { + loop { + let input = Input::::new() + .with_prompt("Enter a wallet alias") + .interact_text()?; + if !input.is_empty() && input.is_ascii() { return Ok(input); + } else { + println_log_error!("Invalid input, please enter a valid alias (non-empty, ASCII)."); + } + } +} + +pub fn enter_mnemonic() -> Result { + loop { + let mnemonic = Mnemonic::from(Input::::new().with_prompt("Enter a mnemonic").interact_text()?); + match verify_mnemonic(&*mnemonic) { + Ok(_) => return Ok(mnemonic), + Err(err) => { + println_log_error!("Invalid mnemonic. Please enter a bip-39 conform mnemonic: {err}"); + } } } } @@ -82,7 +144,7 @@ pub async fn enter_or_generate_mnemonic() -> Result { let mnemonic = match selected_choice { 0 => generate_mnemonic(None, None).await?, 1 => enter_mnemonic()?, - _ => panic!("invalid choice index"), + _ => panic!("invalid choice"), }; Ok(mnemonic) @@ -138,21 +200,6 @@ pub async fn generate_mnemonic( Ok(mnemonic) } -pub fn enter_mnemonic() -> Result { - loop { - let input = Mnemonic::from( - Input::::new() - .with_prompt("Enter your mnemonic") - .interact_text()?, - ); - if verify_mnemonic(&*input).is_err() { - println_log_error!("Invalid mnemonic. Please enter a bip-39 conform mnemonic."); - } else { - return Ok(input); - } - } -} - pub async fn import_mnemonic(path: &str) -> Result { let mut mnemonics = read_mnemonics_from_file(path).await?; if mnemonics.is_empty() { @@ -324,7 +371,7 @@ impl From for SecretManagerChoice { 0 => Self::Stronghold, 1 => Self::LedgerNano, 2 => Self::LedgerNanoSimulator, - _ => panic!("invalid secret manager choice index"), + _ => panic!("invalid secret manager choice"), } } } @@ -342,7 +389,7 @@ impl FromStr for SecretManagerChoice { } } -pub async fn select_secret_manager() -> Result { +pub fn select_secret_manager() -> Result { let choices = ["Stronghold", "Ledger Nano", "Ledger Nano Simulator"]; Ok(Select::with_theme(&ColorfulTheme::default()) @@ -352,3 +399,35 @@ pub async fn select_secret_manager() -> Result { .interact_on(&Term::stderr())? .into()) } + +pub fn select_or_enter_bip_path() -> Result { + let choices = ["IOTA [4218/0/0/0]", "Shimmer [4219/0/0/0]", "Custom"]; + + Ok( + match Select::with_theme(&ColorfulTheme::default()) + .with_prompt("Select bip path") + .items(&choices) + .default(0) + .interact_on(&Term::stderr())? + .into() + { + 0 => Bip44::new(IOTA_COIN_TYPE), + 1 => Bip44::new(SHIMMER_COIN_TYPE), + 2 => enter_bip_path()?, + _ => panic!("invalid choice"), + }, + ) +} + +fn enter_bip_path() -> Result { + loop { + let input = Input::::new().with_prompt("Enter a bip path").interact_text()?; + match parse_bip_path(&input) { + Ok(bip_path) => return Ok(bip_path), + Err(err) => { + let s = err.to_string(); + println_log_error!("{s}"); + } + } + } +} diff --git a/cli/src/wallet_cli/mod.rs b/cli/src/wallet_cli/mod.rs index 8ee6d3c69e..9cff41dc0f 100644 --- a/cli/src/wallet_cli/mod.rs +++ b/cli/src/wallet_cli/mod.rs @@ -35,7 +35,7 @@ use rustyline::{error::ReadlineError, history::MemHistory, Config, Editor}; use self::completer::WalletCommandHelper; use crate::{ - helper::{bytes_from_hex_or_file, get_password, to_utc_date_time}, + helper::{bytes_from_hex_or_file, enter_password, to_utc_date_time}, println_log_error, println_log_info, }; @@ -1408,7 +1408,7 @@ async fn ensure_password(wallet: &Wallet) -> Result<(), Error> { if matches!(*wallet.secret_manager().read().await, SecretManager::Stronghold(_)) && !wallet.is_stronghold_password_available().await? { - let password = get_password("Stronghold password", false)?; + let password = enter_password("Stronghold password", false)?; wallet.set_stronghold_password(password).await?; }