Skip to content

Commit

Permalink
WIP: Working on forc wallet list (#167)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
crodas committed Apr 1, 2024
1 parent f1fa930 commit 05563df
Show file tree
Hide file tree
Showing 7 changed files with 364 additions and 54 deletions.
35 changes: 30 additions & 5 deletions src/account.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -13,6 +14,7 @@ use fuels::{
prelude::*,
types::bech32::FUEL_BECH32_HRP,
};
use std::ops::Range;
use std::{
collections::BTreeMap,
fmt, fs,
Expand Down Expand Up @@ -261,12 +263,16 @@ pub(crate) fn print_balance_empty(node_url: &Url) {
}

pub(crate) fn print_balance(balance: &BTreeMap<String, u128>) {
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.
Expand Down Expand Up @@ -383,6 +389,25 @@ fn derive_account_unlocked(
Ok(wallet)
}

pub fn derive_and_cache_addresses(
wallet: &EthKeystore,
mnemonic: &str,
range: Range<usize>,
) -> anyhow::Result<BTreeMap<usize, Bech32Address>> {
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::<Result<Vec<_>, _>>()
.map(|x| x.into_iter().enumerate().collect())
}

pub(crate) fn derive_account(
wallet_path: &Path,
account_ix: usize,
Expand Down Expand Up @@ -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,
Expand Down
103 changes: 79 additions & 24 deletions src/balance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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.
Expand Down Expand Up @@ -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<usize>,
) -> Result<AccountsMap> {
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()
Expand All @@ -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<usize, Bech32Address>,
) -> Result<(Vec<HashMap<String, u64>>, BTreeMap<String, u128>)> {
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
Expand All @@ -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 {
Expand Down
165 changes: 165 additions & 0 deletions src/format.rs
Original file line number Diff line number Diff line change
@@ -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<Value>);

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::<Vec<_>>();

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::<Vec<_>>()
.join("\n")
}
}

#[derive(Default)]
pub struct Table {
headers: Vec<String>,
rows: Vec<Vec<String>>,
}

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<impl ToString>) -> 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::<HashMap<_, _>>();

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::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>()
.join(" | "),
);
table.push(separator.clone());
}

table.join("\n")
}
}
Loading

0 comments on commit 05563df

Please sign in to comment.