From 05563df3b0cdee0489de6f14ca9090df4bda606d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20D=2E=20Rodas?= Date: Mon, 1 Apr 2024 19:17:03 -0400 Subject: [PATCH] WIP: Working on `forc wallet list` (#167) Fixes #149 `list` command will request the password to unlock the wallet to derive the requested number of addresses. The derived addresses will be stored in cache. If `--unverified` is passed and the cache has enough addresses, the cache will be used, otherwise the user will be requested to unlock their wallet. --- src/account.rs | 35 +++++++++-- src/balance.rs | 103 +++++++++++++++++++++++------- src/format.rs | 165 +++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 4 ++ src/list.rs | 49 +++++++++++++++ src/main.rs | 41 ++++++------ src/new.rs | 21 ++++++- 7 files changed, 364 insertions(+), 54 deletions(-) create mode 100644 src/format.rs create mode 100644 src/list.rs diff --git a/src/account.rs b/src/account.rs index e678033..a3c99bc 100644 --- a/src/account.rs +++ b/src/account.rs @@ -1,3 +1,4 @@ +use crate::format::Table; use crate::sign; use crate::utils::{ display_string_discreetly, get_derivation_path, load_wallet, user_fuel_wallets_accounts_dir, @@ -13,6 +14,7 @@ use fuels::{ prelude::*, types::bech32::FUEL_BECH32_HRP, }; +use std::ops::Range; use std::{ collections::BTreeMap, fmt, fs, @@ -261,12 +263,16 @@ pub(crate) fn print_balance_empty(node_url: &Url) { } pub(crate) fn print_balance(balance: &BTreeMap) { - let asset_id_header = "Asset ID"; - let amount_header = "Amount"; - println!(" {asset_id_header:66} {amount_header}"); + let mut table = Table::default(); + table.add_header("Asset ID"); + table.add_header("Amount"); + for (asset_id, amount) in balance { - println!(" {asset_id} {amount}"); + table + .add_row(vec![asset_id.to_owned(), amount.to_string()]) + .expect("add_row"); } + println!("{}", table.to_string()); } /// Prints a list of all known (cached) accounts for the wallet at the given path. @@ -383,6 +389,25 @@ fn derive_account_unlocked( Ok(wallet) } +pub fn derive_and_cache_addresses( + wallet: &EthKeystore, + mnemonic: &str, + range: Range, +) -> anyhow::Result> { + range + .into_iter() + .map(|acc_ix| { + let derive_path = get_derivation_path(acc_ix); + let secret_key = SecretKey::new_from_mnemonic_phrase_with_path(mnemonic, &derive_path)?; + let account = WalletUnlocked::new_from_private_key(secret_key, None); + cache_address(&wallet.crypto.ciphertext, acc_ix, account.address())?; + + Ok(account.address().to_owned()) + }) + .collect::, _>>() + .map(|x| x.into_iter().enumerate().collect()) +} + pub(crate) fn derive_account( wallet_path: &Path, account_ix: usize, @@ -543,7 +568,7 @@ fn address_path(wallet_ciphertext: &[u8], account_ix: usize) -> PathBuf { } /// Cache a single wallet account address to a file as a simple utf8 string. -fn cache_address( +pub fn cache_address( wallet_ciphertext: &[u8], account_ix: usize, account_addr: &Bech32Address, diff --git a/src/balance.rs b/src/balance.rs index c149d60..1a9b298 100644 --- a/src/balance.rs +++ b/src/balance.rs @@ -5,16 +5,20 @@ use fuels::{ prelude::*, }; use std::{ + cmp::max, collections::{BTreeMap, HashMap}, path::Path, }; +use url::Url; use crate::{ account::{ - derive_account, print_balance, print_balance_empty, read_cached_addresses, - verify_address_and_update_cache, + derive_account, derive_and_cache_addresses, print_balance, print_balance_empty, + read_cached_addresses, verify_address_and_update_cache, }, + format::List, utils::load_wallet, + DEFAULT_CACHE_ACCOUNTS, }; #[derive(Debug, Args)] @@ -26,7 +30,7 @@ pub struct Balance { /// Show the balance for each individual non-empty account before showing /// the total. #[clap(long)] - accounts: bool, + pub(crate) accounts: bool, } /// Whether to verify cached accounts or not. @@ -62,8 +66,40 @@ pub fn collect_accounts_with_verification( Ok(addresses) } +/// Returns N derived addresses. If the `unverified` flag is set, it will not verify the addresses +/// and will use the cached ones. +/// +/// This function will override / fix the cached addresses if the user password is requested +pub fn get_derived_accounts( + wallet_path: &Path, + unverified: bool, + target_accounts: Option, +) -> Result { + let wallet = load_wallet(wallet_path)?; + let addresses = if unverified { + read_cached_addresses(&wallet.crypto.ciphertext)? + } else { + BTreeMap::new() + }; + let target_accounts = target_accounts.unwrap_or(1); + + if !unverified || addresses.len() < target_accounts { + let prompt = "Please enter your wallet password to verify accounts: "; + let password = rpassword::prompt_password(prompt)?; + let phrase_recovered = eth_keystore::decrypt_key(wallet_path, password)?; + let phrase = String::from_utf8(phrase_recovered)?; + + let range = 0..max(target_accounts, DEFAULT_CACHE_ACCOUNTS); + derive_and_cache_addresses(&wallet, &phrase, range) + } else { + Ok(addresses) + } +} + /// Print collected account balances for each asset type. pub fn print_account_balances(accounts_map: &AccountsMap, account_balances: &AccountBalances) { + let mut list = List::default(); + list.add_newline(); for (ix, balance) in accounts_map.keys().zip(account_balances) { let balance: BTreeMap<_, _> = balance .iter() @@ -72,25 +108,28 @@ pub fn print_account_balances(accounts_map: &AccountsMap, account_balances: &Acc if balance.is_empty() { continue; } - println!("\nAccount {ix} -- {}:", accounts_map[ix]); - print_balance(&balance); + + list.add_seperator(); + list.add(format!("Account {ix}"), accounts_map[ix].to_string()); + list.add_newline(); + + for (asset_id, amount) in balance { + list.add("Asset ID", asset_id); + list.add("Amount", amount.to_string()); + } + list.add_seperator(); } + println!("{}", list.to_string()); } -pub async fn cli(wallet_path: &Path, balance: &Balance) -> Result<()> { - let verification = if !balance.account.unverified.unverified { - let prompt = "Please enter your wallet password to verify accounts: "; - let password = rpassword::prompt_password(prompt)?; - AccountVerification::Yes(password) - } else { - AccountVerification::No - }; - let addresses = collect_accounts_with_verification(wallet_path, verification)?; - let node_url = &balance.account.node_url; +pub(crate) async fn list_account_balances( + node_url: &Url, + addresses: &BTreeMap, +) -> Result<(Vec>, BTreeMap)> { println!("Connecting to {node_url}"); - let provider = Provider::connect(node_url).await?; + let provider = Provider::connect(&node_url).await?; println!("Fetching and summing balances of the following accounts:"); - for (ix, addr) in &addresses { + for (ix, addr) in addresses { println!(" {ix:>3}: {addr}"); } let accounts: Vec<_> = addresses @@ -100,20 +139,36 @@ pub async fn cli(wallet_path: &Path, balance: &Balance) -> Result<()> { let account_balances = futures::future::try_join_all(accounts.iter().map(|acc| acc.get_balances())).await?; - if balance.accounts { - print_account_balances(&addresses, &account_balances); - } - let mut total_balance = BTreeMap::default(); - println!("\nTotal:"); - for acc_bal in account_balances { + for acc_bal in &account_balances { for (asset_id, amt) in acc_bal { let entry = total_balance.entry(asset_id.clone()).or_insert(0u128); - *entry = entry.checked_add(u128::from(amt)).ok_or_else(|| { + *entry = entry.checked_add(u128::from(*amt)).ok_or_else(|| { anyhow!("Failed to display balance for asset {asset_id}: Value out of range.") })?; } } + + Ok((account_balances, total_balance)) +} + +pub async fn cli(wallet_path: &Path, balance: &Balance) -> Result<()> { + let verification = if !balance.account.unverified.unverified { + let prompt = "Please enter your wallet password to verify accounts: "; + let password = rpassword::prompt_password(prompt)?; + AccountVerification::Yes(password) + } else { + AccountVerification::No + }; + let addresses = collect_accounts_with_verification(wallet_path, verification)?; + let node_url = &balance.account.node_url; + let (account_balances, total_balance) = list_account_balances(node_url, &addresses).await?; + + if balance.accounts { + print_account_balances(&addresses, &account_balances); + } + + println!("\nTotal:"); if total_balance.is_empty() { print_balance_empty(&balance.account.node_url); } else { diff --git a/src/format.rs b/src/format.rs new file mode 100644 index 0000000..c1669d6 --- /dev/null +++ b/src/format.rs @@ -0,0 +1,165 @@ +use std::{cmp::max, collections::HashMap}; + +use anyhow::Result; + +#[derive(PartialEq, Eq)] +enum Value { + Seperator, + NewLine, + Entry(String, String), +} + +/// Simple helper to print key-value entries where the keys are all alligned. +/// +/// Here is an example of how it looks: +/// +/// -------------------------------------------------------------------------- +/// account 0: fuel12sdaglkadsgmoaeigm49309k403ydxtqmzuaqtzdjlsww5t2jmg9skutn8n +/// Asset ID : 0000000000000000000000000000000000000000000000000000000000000000 +/// Amount : 499999800 +/// +/// Asset ID : 0000000000000000000000000000000000000000000000000000000000000001 +/// Amount : 359989610 +/// -------------------------------------------------------------------------- +/// account 1: fuel29asdfjoiajg934344iw9e8jfasoiaeigjaergokjaeoigjaeg9ij39ijg34 +/// Asset ID : 0000000000000000000000000000000000000000000000000000000000000000 +/// Amount : 268983615 +#[derive(Default)] +pub struct List(Vec); + +impl List { + pub fn add(&mut self, title: impl ToString, value: impl ToString) { + self.0 + .push(Value::Entry(title.to_string(), value.to_string())); + } + + pub fn add_newline(&mut self) { + self.0.push(Value::NewLine); + } + + pub fn add_seperator(&mut self) { + if self.0.last() == Some(&Value::Seperator) { + return; + } + self.0.push(Value::Seperator); + } + + pub fn longest_title(&self) -> usize { + self.0 + .iter() + .map(|value| match value { + Value::Seperator => 0, + Value::NewLine => 0, + Value::Entry(title, _) => title.len(), + }) + .max() + .unwrap_or(0) + } +} + +impl ToString for List { + fn to_string(&self) -> String { + let longest_key = self.longest_title(); + let entries = self + .0 + .iter() + .map(|entry| match entry { + Value::Seperator => None, + Value::NewLine => Some("".to_owned()), + Value::Entry(title, value) => { + let padding = " ".repeat(longest_key - title.len()); + Some(format!("{}{}: {}", title, padding, value)) + } + }) + .collect::>(); + + let longest_entry = entries + .iter() + .map(|entry| entry.as_ref().map(|s| s.len()).unwrap_or(0)) + .max() + .unwrap_or(0); + + let seperator = "-".repeat(longest_entry); + + entries + .into_iter() + .map(|entry| entry.map(|s| s.to_string()).unwrap_or(seperator.clone())) + .collect::>() + .join("\n") + } +} + +#[derive(Default)] +pub struct Table { + headers: Vec, + rows: Vec>, +} + +impl Table { + pub fn add_header(&mut self, header: impl ToString) { + self.headers.push(header.to_string()); + } + + pub fn add_row(&mut self, row: Vec) -> Result<()> { + if self.headers.len() != row.len() { + anyhow::bail!("Row length does not match header length"); + } + self.rows + .push(row.into_iter().map(|x| x.to_string()).collect()); + Ok(()) + } +} +impl ToString for Table { + fn to_string(&self) -> String { + let mut longest_columns = self + .headers + .iter() + .enumerate() + .map(|(column_id, x)| (column_id, x.len())) + .collect::>(); + + for row in self.rows.iter() { + for (column_id, value) in row.iter().enumerate() { + longest_columns + .entry(column_id) + .and_modify(|x| *x = max(*x, value.len())); + } + } + let separator = self + .headers + .iter() + .enumerate() + .map(|(column_id, _)| "-".repeat(longest_columns[&column_id])) + .collect::>() + .join("-|-"); + + let mut table = vec![ + self.headers + .iter() + .enumerate() + .map(|(column_id, header)| { + let padding = " ".repeat(longest_columns[&column_id] - header.len()); + format!("{}{}", header, padding) + }) + .collect::>() + .join(" | "), + separator.clone(), + ]; + + for row in &self.rows { + table.push( + row.iter() + .enumerate() + .map(|(column_id, value)| { + let padding = " ".repeat(longest_columns[&column_id] - value.len()); + format!("{}{}", value, padding) + }) + .collect::>() + .join(" | "), + ); + table.push(separator.clone()); + } + + table.join("\n") + } +} diff --git a/src/lib.rs b/src/lib.rs index 83c4a3e..c3a6306 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,14 @@ pub mod account; pub mod balance; +pub mod format; pub mod import; +pub mod list; pub mod new; pub mod sign; pub mod utils; +pub const DEFAULT_CACHE_ACCOUNTS: usize = 1; + /// The default network used in the case that none is specified. pub mod network { pub const DEFAULT: &str = BETA_5; diff --git a/src/list.rs b/src/list.rs new file mode 100644 index 0000000..2acf2ec --- /dev/null +++ b/src/list.rs @@ -0,0 +1,49 @@ +use crate::{ + account::{print_balance, print_balance_empty, Unverified}, + balance::{get_derived_accounts, list_account_balances, print_account_balances}, +}; +use anyhow::Result; +use clap::Args; +use std::{collections::BTreeMap, path::Path}; +use url::Url; + +#[derive(Debug, Args)] +pub struct List { + /// The URL of the node to connect to to requests balances. + #[clap(long, default_value_t = crate::network::DEFAULT.parse().unwrap())] + pub(crate) node_url: Url, + + /// Contains optional flag for displaying all accounts as hex / bytes values. + /// + /// pass in --as-hex for this alternative display. + #[clap(flatten)] + unverified: Unverified, + + /// The minimum amount of derived accounts to display their balances from. + /// If there are not enough accounts in the cache, the wallet will be unlocked (requesting the + /// user's password) and will derive more accounts. + #[clap(short, long)] + target_accounts: Option, +} + +pub async fn list_wallet_cli(wallet_path: &Path, opts: List) -> Result<()> { + let addresses = get_derived_accounts( + wallet_path, + opts.unverified.unverified, + opts.target_accounts, + )? + .range(0..opts.target_accounts.unwrap_or(1)) + .map(|(a, b)| (*a, b.clone())) + .collect::>(); + + let (account_balances, total_balance) = + list_account_balances(&opts.node_url, &addresses).await?; + print_account_balances(&addresses, &account_balances); + println!("\nTotal:"); + if total_balance.is_empty() { + print_balance_empty(&opts.node_url); + } else { + print_balance(&total_balance); + } + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index d06fb7c..241eba1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,23 +1,15 @@ -mod account; -mod balance; -mod import; -mod new; -mod sign; -mod utils; - -use balance::Balance; -pub use forc_wallet::explorer; -pub use forc_wallet::network; - -use crate::{ - account::{Account, Accounts}, - import::{import_wallet_cli, Import}, - new::{new_wallet_cli, New}, - sign::Sign, -}; use anyhow::Result; use clap::{Parser, Subcommand}; use forc_tracing::{init_tracing_subscriber, println_error}; +use forc_wallet::{ + account::{self, Account, Accounts}, + balance::{self, Balance}, + import::{import_wallet_cli, Import}, + list::{list_wallet_cli, List}, + new::{new_wallet_cli, New}, + sign::{self, Sign}, + utils, +}; use std::path::PathBuf; #[derive(Debug, Parser)] @@ -40,13 +32,17 @@ enum Command { /// /// If a `--path` is specified, the wallet will be created at this location. /// - /// If a '--fore' is specified, will automatically removes the existing wallet at the same path. + /// If a '--fore' is specified, will automatically removes the existing wallet at the same + /// path. New(New), + /// TODO: List all wallets in the default wallet directory. + List(List), /// Import a wallet from the provided mnemonic phrase. /// /// If a `--path` is specified, the wallet will be imported to this location. /// - /// If a '--fore' is specified, will automatically removes the existing wallet at the same path. + /// If a '--fore' is specified, will automatically removes the existing wallet at the same + /// path. Import(Import), /// Lists all accounts derived for the wallet so far. /// @@ -119,12 +115,12 @@ EXAMPLES: # Show the public key of the account at index 0. forc wallet account 0 public-key - # Transfer 1 token of the base asset id to a bech32 address at the gas price of 1. + # Transfer 1 token of the base asset id to a bech32 address at the gas price of 1. forc wallet account 0 transfer --to fuel1dq2vgftet24u4nkpzmtfus9k689ap5avkm8kdjna8j3d6765yfdsjt6586 --amount 1 --asset-id 0x0000000000000000000000000000000000000000000000000000000000000000 --gas-price 1 - # Transfer 1 token of the base asset id to a hex address at the gas price of 1. - forc wallet account 0 transfer --to 0x0b8d0f6a7f271919708530d11bdd9398205137e012424b611e9d97118c180bea + # Transfer 1 token of the base asset id to a hex address at the gas price of 1. + forc wallet account 0 transfer --to 0x0b8d0f6a7f271919708530d11bdd9398205137e012424b611e9d97118c180bea --amount 1 --asset-id 0x0000000000000000000000000000000000000000000000000000000000000000 --gas-price 1 "#; @@ -142,6 +138,7 @@ async fn run() -> Result<()> { let wallet_path = app.wallet_path.unwrap_or_else(utils::default_wallet_path); match app.cmd { Command::New(new) => new_wallet_cli(&wallet_path, new)?, + Command::List(list) => list_wallet_cli(&wallet_path, list).await?, Command::Import(import) => import_wallet_cli(&wallet_path, import)?, Command::Accounts(accounts) => account::print_accounts_cli(&wallet_path, accounts)?, Command::Account(account) => account::cli(&wallet_path, account).await?, diff --git a/src/new.rs b/src/new.rs index db54911..5822804 100644 --- a/src/new.rs +++ b/src/new.rs @@ -1,6 +1,10 @@ -use crate::utils::{ - display_string_discreetly, ensure_no_wallet_exists, request_new_password, - write_wallet_from_mnemonic_and_password, +use crate::{ + account::derive_and_cache_addresses, + utils::{ + display_string_discreetly, ensure_no_wallet_exists, load_wallet, request_new_password, + write_wallet_from_mnemonic_and_password, + }, + DEFAULT_CACHE_ACCOUNTS, }; use clap::Args; use fuels::prelude::*; @@ -11,6 +15,10 @@ pub struct New { /// Forces wallet creation, removing any existing wallet file #[clap(short, long)] pub force: bool, + + /// How many accounts to cache by default (Default 10) + #[clap(short, long)] + pub cache_accounts: Option, } pub fn new_wallet_cli(wallet_path: &Path, new: New) -> anyhow::Result<()> { @@ -19,6 +27,13 @@ pub fn new_wallet_cli(wallet_path: &Path, new: New) -> anyhow::Result<()> { // Generate a random mnemonic phrase. let mnemonic = generate_mnemonic_phrase(&mut rand::thread_rng(), 24)?; write_wallet_from_mnemonic_and_password(wallet_path, &mnemonic, &password)?; + + derive_and_cache_addresses( + &load_wallet(wallet_path)?, + &mnemonic, + 0..new.cache_accounts.unwrap_or(DEFAULT_CACHE_ACCOUNTS), + )?; + let mnemonic_string = format!("Wallet mnemonic phrase: {mnemonic}\n"); display_string_discreetly( &mnemonic_string,