1use crate::format::Table;
2use crate::sign;
3use crate::utils::{
4 display_string_discreetly, get_derivation_path, load_wallet, user_fuel_wallets_accounts_dir,
5};
6use anyhow::{Context, Result, anyhow, bail};
7use clap::{Args, Subcommand};
8use eth_keystore::EthKeystore;
9use forc_tracing::println_warning;
10use fuels::accounts::ViewOnlyAccount;
11use fuels::accounts::provider::Provider;
12use fuels::accounts::signers::private_key::PrivateKeySigner;
13use fuels::accounts::wallet::{Unlocked, Wallet};
14use fuels::crypto::{PublicKey, SecretKey};
15use fuels::types::checksum_address::checksum_encode;
16use fuels::types::transaction::TxPolicies;
17use fuels::types::{Address, AssetId};
18use std::ops::Range;
19use std::{
20 collections::BTreeMap,
21 fs,
22 path::{Path, PathBuf},
23};
24use url::Url;
25
26type WalletUnlocked<S> = Wallet<Unlocked<S>>;
27
28#[derive(Debug, Args)]
29pub struct Accounts {
30 #[clap(flatten)]
31 unverified: UnverifiedOpt,
32}
33
34#[derive(Debug, Args)]
35pub struct Account {
36 index: Option<usize>,
40 #[clap(flatten)]
41 unverified: UnverifiedOpt,
42 #[clap(subcommand)]
43 cmd: Option<Command>,
44}
45
46#[derive(Debug, Args)]
47pub(crate) struct Fmt {
48 #[clap(long)]
52 as_hex: bool,
53}
54
55#[derive(Debug, Subcommand)]
56pub(crate) enum Command {
57 New,
66 #[clap(subcommand)]
68 Sign(sign::Data),
69 PrivateKey,
74 PublicKey(Fmt),
77 Balance(Balance),
79 Transfer(Transfer),
81}
82
83#[derive(Debug, Args)]
84pub(crate) struct Balance {
85 #[clap(flatten)]
86 pub(crate) unverified: UnverifiedOpt,
87}
88
89#[derive(Debug, Args)]
90pub(crate) struct Transfer {
91 #[clap(long)]
93 to: Address,
94 #[clap(long)]
96 amount: u64,
97 #[clap(long)]
99 asset_id: AssetId,
100 #[clap(long)]
101 gas_price: Option<u64>,
102 #[clap(long)]
103 gas_limit: Option<u64>,
104 #[clap(long)]
105 maturity: Option<u64>,
106}
107
108#[derive(Debug, Args)]
109pub(crate) struct UnverifiedOpt {
110 #[clap(long = "unverified")]
116 pub(crate) unverified: bool,
117}
118
119type AccountAddresses = BTreeMap<usize, Address>;
121
122pub async fn cli(ctx: &crate::CliContext, account: Account) -> Result<()> {
123 match (account.index, account.cmd) {
124 (None, Some(Command::New)) => new_cli(ctx).await?,
125 (Some(acc_ix), Some(Command::New)) => new_at_index_cli(ctx, acc_ix).await?,
126 (Some(acc_ix), None) => print_address(ctx, acc_ix, account.unverified.unverified).await?,
127 (Some(acc_ix), Some(Command::Sign(sign_cmd))) => {
128 sign::wallet_account_cli(ctx, acc_ix, sign_cmd)?
129 }
130 (Some(acc_ix), Some(Command::PrivateKey)) => private_key_cli(ctx, acc_ix)?,
131 (Some(acc_ix), Some(Command::PublicKey(format))) => match format.as_hex {
132 true => hex_address_cli(ctx, acc_ix)?,
133 false => public_key_cli(ctx, acc_ix)?,
134 },
135
136 (Some(acc_ix), Some(Command::Balance(balance))) => {
137 account_balance_cli(ctx, acc_ix, &balance).await?
138 }
139 (Some(acc_ix), Some(Command::Transfer(transfer))) => {
140 transfer_cli(ctx, acc_ix, transfer).await?
141 }
142 (None, Some(cmd)) => print_subcmd_index_warning(&cmd),
143 (None, None) => print_subcmd_help(),
144 }
145 Ok(())
146}
147
148pub(crate) async fn account_balance_cli(
149 ctx: &crate::CliContext,
150 acc_ix: usize,
151 balance: &Balance,
152) -> Result<()> {
153 let wallet = load_wallet(&ctx.wallet_path)?;
154 let provider = Provider::connect(&ctx.node_url).await?;
155 let mut cached_addrs = read_cached_addresses(&wallet.crypto.ciphertext)?;
156 let cached_addr = cached_addrs
157 .remove(&acc_ix)
158 .ok_or_else(|| anyhow!("No cached address for account {acc_ix}"))?;
159
160 let account = if balance.unverified.unverified {
161 Wallet::new_locked(cached_addr, provider)
162 } else {
163 let prompt = format!("Please enter your wallet password to verify account {acc_ix}: ");
164 let password = rpassword::prompt_password(prompt)?;
165 let account = derive_account_unlocked(&ctx.wallet_path, acc_ix, &password, &provider)?;
166 verify_address_and_update_cache(acc_ix, &account, &cached_addr, &wallet.crypto.ciphertext)?;
167 account.lock()
168 };
169 println!("Connecting to {}", &ctx.node_url);
170 println!("Fetching the balance of the following account:",);
171 let account_adr = checksum_encode(&format!("0x{}", account.address()))?;
172 println!(" {acc_ix:>3}: {}", account_adr);
173 let account_balance: BTreeMap<_, _> = account.get_balances().await?.into_iter().collect();
174 println!("\nAccount {acc_ix}:");
175 if account_balance.is_empty() {
176 print_balance_empty(&ctx.node_url);
177 } else {
178 print_balance(&account_balance);
179 }
180 Ok(())
181}
182
183pub(crate) fn verify_address_and_update_cache(
187 acc_ix: usize,
188 account: &Wallet,
189 expected_addr: &Address,
190 wallet_ciphertext: &[u8],
191) -> Result<bool> {
192 let addr = account.address();
193 println_warning(&format!(
194 "Cached address for account {} differs from derived address.\n\
195{:>2}Cached: {}
196{:>2}Derived: {}
197{:>2}Updating cache with newly derived address.",
198 acc_ix, "", expected_addr, "", addr, "",
199 ));
200 cache_address(wallet_ciphertext, acc_ix, &addr)?;
201 Ok(false)
202}
203
204pub(crate) fn print_balance_empty(node_url: &Url) {
205 let testnet_url = crate::network::TESTNET.parse::<Url>().unwrap();
206
207 let faucet_url = match node_url.host_str() {
208 host if host == testnet_url.host_str() => crate::network::TESTNET_FAUCET,
209 _ => return println!(" Account empty."),
210 };
211 if node_url
212 .host_str()
213 .is_some_and(|a| a == crate::network::MAINNET)
214 {
215 println!(" Account empty.");
216 } else {
217 println!(
218 " Account empty. Visit the faucet to acquire some test funds: {}",
219 faucet_url
220 );
221 }
222}
223
224pub(crate) fn print_balance(balance: &BTreeMap<String, u128>) {
225 let mut table = Table::default();
226 table.add_header("Asset ID");
227 table.add_header("Amount");
228
229 for (asset_id, amount) in balance {
230 table
231 .add_row(vec![asset_id.to_owned(), amount.to_string()])
232 .expect("add_row");
233 }
234 println!("{}", table);
235}
236
237pub async fn print_accounts_cli(ctx: &crate::CliContext, accounts: Accounts) -> Result<()> {
239 let wallet = load_wallet(&ctx.wallet_path)?;
240 let addresses = read_cached_addresses(&wallet.crypto.ciphertext)?;
241 if accounts.unverified.unverified {
242 println!("Account addresses (unverified, printed from cache):");
243 addresses
244 .iter()
245 .for_each(|(ix, addr)| println!("[{ix}] {addr}"));
246 } else {
247 let prompt = "Please enter your wallet password to verify cached accounts: ";
248 let password = rpassword::prompt_password(prompt)?;
249 let provider = Provider::connect(&ctx.node_url).await?;
250 for &ix in addresses.keys() {
251 let account = derive_account_unlocked(&ctx.wallet_path, ix, &password, &provider)?;
252 let account_addr = account.address();
253 println!("[{ix}] {account_addr}");
254 cache_address(&wallet.crypto.ciphertext, ix, &account_addr)?;
255 }
256 }
257 Ok(())
258}
259
260fn print_subcmd_help() {
261 std::process::Command::new("forc-wallet")
267 .args(["account", "--help"])
268 .stdout(std::process::Stdio::inherit())
269 .stderr(std::process::Stdio::inherit())
270 .output()
271 .expect("failed to invoke `forc wallet account --help` command");
272}
273
274fn print_subcmd_index_warning(cmd: &Command) {
275 let cmd_str = match cmd {
276 Command::Sign(_) => "sign",
277 Command::PrivateKey => "private-key",
278 Command::PublicKey(_) => "public-key",
279 Command::Transfer(_) => "transfer",
280 Command::Balance(_) => "balance",
281 Command::New => unreachable!("new is valid without an index"),
282 };
283 eprintln!(
284 "Error: The command `{cmd_str}` requires an account index. \
285 For example: `forc wallet account <INDEX> {cmd_str} ...`\n"
286 );
287 print_subcmd_help();
288}
289
290pub async fn print_address(
292 ctx: &crate::CliContext,
293 account_ix: usize,
294 unverified: bool,
295) -> Result<()> {
296 let wallet = load_wallet(&ctx.wallet_path)?;
297 if unverified {
298 let addresses = read_cached_addresses(&wallet.crypto.ciphertext)?;
299 match addresses.get(&account_ix) {
300 Some(address) => println!("Account {account_ix} address (unverified): {address}"),
301 None => eprintln!("Account {account_ix} is not derived yet!"),
302 }
303 } else {
304 let prompt = format!("Please enter your wallet password to verify account {account_ix}: ");
305 let password = rpassword::prompt_password(prompt)?;
306 let provider = Provider::connect(&ctx.node_url).await?;
307 let account = derive_account_unlocked(&ctx.wallet_path, account_ix, &password, &provider)?;
308 let account_addr = account.address();
309 let checksum_addr = checksum_encode(&format!("0x{}", account_addr))?;
310 println!("Account {account_ix} address: {checksum_addr}");
311 cache_address(&wallet.crypto.ciphertext, account_ix, &account_addr)?;
312 }
313 Ok(())
314}
315
316pub fn derive_secret_key(
319 wallet_path: &Path,
320 account_index: usize,
321 password: &str,
322) -> Result<SecretKey> {
323 let phrase_recovered = eth_keystore::decrypt_key(wallet_path, password)?;
324 let phrase = String::from_utf8(phrase_recovered)?;
325 let derive_path = get_derivation_path(account_index);
326 let secret_key = SecretKey::new_from_mnemonic_phrase_with_path(&phrase, &derive_path)?;
327 Ok(secret_key)
328}
329
330fn next_derivation_index(addrs: &AccountAddresses) -> usize {
331 addrs.last_key_value().map(|(&ix, _)| ix + 1).unwrap_or(0)
332}
333
334pub(crate) fn derive_account_unlocked(
336 wallet_path: &Path,
337 account_ix: usize,
338 password: &str,
339 provider: &Provider,
340) -> Result<WalletUnlocked<PrivateKeySigner>> {
341 let secret_key = derive_secret_key(wallet_path, account_ix, password)?;
342 let wallet = WalletUnlocked::new(PrivateKeySigner::new(secret_key), provider.clone());
343 Ok(wallet)
344}
345
346pub async fn derive_and_cache_addresses(
347 ctx: &crate::CliContext,
348 mnemonic: &str,
349 range: Range<usize>,
350) -> anyhow::Result<BTreeMap<usize, Address>> {
351 let wallet = load_wallet(&ctx.wallet_path)?;
352 let provider = Provider::connect(&ctx.node_url).await?;
353 range
354 .into_iter()
355 .map(|acc_ix| {
356 let derive_path = get_derivation_path(acc_ix);
357 let secret_key = SecretKey::new_from_mnemonic_phrase_with_path(mnemonic, &derive_path)?;
358 let account = WalletUnlocked::new(PrivateKeySigner::new(secret_key), provider.clone());
359 cache_address(&wallet.crypto.ciphertext, acc_ix, &account.address())?;
360
361 Ok(account.address().to_owned())
362 })
363 .collect::<Result<Vec<_>, _>>()
364 .map(|x| x.into_iter().enumerate().collect())
365}
366
367fn new_at_index(
368 keystore: &EthKeystore,
369 wallet_path: &Path,
370 account_ix: usize,
371 provider: &Provider,
372) -> Result<String> {
373 let prompt = format!("Please enter your wallet password to derive account {account_ix}: ");
374 let password = rpassword::prompt_password(prompt)?;
375 let account = derive_account_unlocked(wallet_path, account_ix, &password, provider)?;
376 let account_addr = account.address();
377 cache_address(&keystore.crypto.ciphertext, account_ix, &account_addr)?;
378 let checksum_addr = checksum_encode(&account_addr.to_string())?;
379 println!("Wallet address: {checksum_addr}");
380 Ok(checksum_addr)
381}
382
383pub async fn new_at_index_cli(ctx: &crate::CliContext, account_ix: usize) -> Result<()> {
384 let keystore = load_wallet(&ctx.wallet_path)?;
385 let provider = Provider::connect(&ctx.node_url).await?;
386 new_at_index(&keystore, &ctx.wallet_path, account_ix, &provider)?;
387 Ok(())
388}
389
390pub(crate) async fn new_cli(ctx: &crate::CliContext) -> Result<()> {
391 let keystore = load_wallet(&ctx.wallet_path)?;
392 let addresses = read_cached_addresses(&keystore.crypto.ciphertext)?;
393 let account_ix = next_derivation_index(&addresses);
394 let provider = Provider::connect(&ctx.node_url).await?;
395 new_at_index(&keystore, &ctx.wallet_path, account_ix, &provider)?;
396 Ok(())
397}
398
399pub(crate) fn private_key_cli(ctx: &crate::CliContext, account_ix: usize) -> Result<()> {
400 let prompt = format!(
401 "Please enter your wallet password to display account {account_ix}'s private key: "
402 );
403 let password = rpassword::prompt_password(prompt)?;
404 let secret_key = derive_secret_key(&ctx.wallet_path, account_ix, &password)?;
405 let secret_key_string = format!("Secret key for account {account_ix}: {secret_key}\n");
406 display_string_discreetly(&secret_key_string, "### Press any key to complete. ###")?;
407 Ok(())
408}
409
410pub(crate) fn public_key_cli(ctx: &crate::CliContext, account_ix: usize) -> Result<()> {
412 let prompt =
413 format!("Please enter your wallet password to display account {account_ix}'s public key: ");
414 let password = rpassword::prompt_password(prompt)?;
415 let secret_key = derive_secret_key(&ctx.wallet_path, account_ix, &password)?;
416 let public_key = PublicKey::from(&secret_key);
417 println!("Public key for account {account_ix}: {public_key}");
418 Ok(())
419}
420
421pub(crate) fn hex_address_cli(ctx: &crate::CliContext, account_ix: usize) -> Result<()> {
423 let prompt = format!(
424 "Please enter your wallet password to display account {account_ix}'s plain address: "
425 );
426 let password = rpassword::prompt_password(prompt)?;
427 let secret_key = derive_secret_key(&ctx.wallet_path, account_ix, &password)?;
428 let public_key = PublicKey::from(&secret_key);
429 let hashed = public_key.hash();
430 let plain_address: Address = (*hashed).into();
431 println!("Plain address for {}: {}", account_ix, plain_address);
432 Ok(())
433}
434
435pub(crate) async fn transfer_cli(
437 ctx: &crate::CliContext,
438 acc_ix: usize,
439 transfer: Transfer,
440) -> Result<()> {
441 use fuels::accounts::Account;
442
443 println!(
444 "Preparing to transfer:\n Amount: {}\n Asset ID: 0x{}\n To: {}\n",
445 transfer.amount, transfer.asset_id, transfer.to
446 );
447 let provider = Provider::connect(&ctx.node_url).await?;
448
449 let to = transfer.to;
450
451 let prompt = format!(
452 "Please enter your wallet password to unlock account {acc_ix} and to initiate transfer: "
453 );
454 let password = rpassword::prompt_password(prompt)?;
455 let mut account = derive_account_unlocked(&ctx.wallet_path, acc_ix, &password, &provider)?;
456 account.set_provider(provider);
457 println!("Transferring...");
458
459 let tx_response = account
460 .transfer(
461 to,
462 transfer.amount,
463 transfer.asset_id,
464 TxPolicies::new(
465 transfer.gas_price,
466 None,
467 transfer.maturity,
468 None,
469 None,
470 transfer.gas_limit,
471 ),
472 )
473 .await?;
474
475 let block_explorer_url = match ctx.node_url.host_str() {
476 host if host == crate::network::MAINNET.parse::<Url>().unwrap().host_str() => {
477 crate::explorer::DEFAULT
478 }
479 host if host == crate::network::TESTNET.parse::<Url>().unwrap().host_str() => {
480 crate::explorer::TESTNET
481 }
482 _ => "",
483 };
484
485 let tx_explorer_url = format!("{block_explorer_url}/tx/0x{}", tx_response.tx_id);
486 println!(
487 "\nTransfer complete!\nSummary:\n Transaction ID: 0x{}\n Receipts: {:#?}\n Explorer: {}\n",
488 tx_response.tx_id, tx_response.tx_status.receipts, tx_explorer_url
489 );
490
491 Ok(())
492}
493
494fn address_cache_dir_name(wallet_ciphertext: &[u8]) -> String {
496 use std::hash::{Hash, Hasher};
497 let hasher = &mut std::collections::hash_map::DefaultHasher::default();
498 wallet_ciphertext.iter().for_each(|byte| byte.hash(hasher));
499 let hash = hasher.finish();
500 format!("{hash:x}")
501}
502
503fn address_cache_dir(wallet_ciphertext: &[u8]) -> PathBuf {
505 user_fuel_wallets_accounts_dir().join(address_cache_dir_name(wallet_ciphertext))
506}
507
508fn address_path(wallet_ciphertext: &[u8], account_ix: usize) -> PathBuf {
510 address_cache_dir(wallet_ciphertext).join(format!("{account_ix}"))
511}
512
513pub fn cache_address(
515 wallet_ciphertext: &[u8],
516 account_ix: usize,
517 account_addr: &Address,
518) -> Result<()> {
519 let path = address_path(wallet_ciphertext, account_ix);
520 if path.exists() && !path.is_file() {
521 bail!("attempting to cache account address to {path:?}, but the path is a directory");
522 }
523 let parent = path
524 .parent()
525 .expect("account address path contained no parent directory");
526 fs::create_dir_all(parent).context("failed to create account address cache directory")?;
527 fs::write(path, account_addr.to_string()).context("failed to cache account address to file")?;
528 Ok(())
529}
530
531pub(crate) fn read_cached_addresses(wallet_ciphertext: &[u8]) -> Result<AccountAddresses> {
533 let wallet_accounts_dir = address_cache_dir(wallet_ciphertext);
534 if !wallet_accounts_dir.exists() {
535 return Ok(Default::default());
536 }
537 fs::read_dir(&wallet_accounts_dir)
538 .context("failed to read account address cache")?
539 .map(|res| {
540 let entry = res.context("failed to read account address cache")?;
541 let path = entry.path();
542 let file_name = path
543 .file_name()
544 .and_then(|os_str| os_str.to_str())
545 .ok_or_else(|| anyhow!("failed to read utf8 file name from {path:?}"))?;
546 let account_ix: usize = file_name
547 .parse()
548 .context("failed to parse account index from file name")?;
549 let account_addr_str = std::fs::read_to_string(&path)
550 .context("failed to read account address from cache")?;
551 let account_addr: Address = account_addr_str
552 .parse()
553 .map_err(|e| anyhow!("failed to parse cached account address: {e}"))?;
554 Ok((account_ix, account_addr))
555 })
556 .collect()
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562 use crate::utils::test_utils::{
563 TEST_MNEMONIC, TEST_PASSWORD, mock_provider, with_tmp_dir_and_wallet,
564 };
565 use crate::utils::write_wallet_from_mnemonic_and_password;
566 use fuels::types::Address;
567 use std::str::FromStr;
568
569 #[tokio::test]
570 async fn create_new_account() {
571 let mock_provider = mock_provider().await;
572
573 let tmp_dir = tempfile::TempDir::new().unwrap();
574 let wallet_path = tmp_dir.path().join("wallet.json");
575 write_wallet_from_mnemonic_and_password(&wallet_path, TEST_MNEMONIC, TEST_PASSWORD)
576 .unwrap();
577
578 let wallet = derive_account_unlocked(&wallet_path, 0, TEST_PASSWORD, &mock_provider)
579 .expect("wallet unlocked");
580 let wallet_addr = wallet.address();
581 let wallet_addr_str = wallet_addr.to_string();
582 assert_eq!(
583 wallet_addr_str,
584 "914504548bad3ad1e2c489be3af683ede849e286bee0a349644edebc91267bde"
585 );
586 assert_eq!(
588 format!("{:x}", wallet_addr),
589 "914504548bad3ad1e2c489be3af683ede849e286bee0a349644edebc91267bde"
590 );
591 }
592
593 #[test]
594 fn derive_account_by_index() {
595 with_tmp_dir_and_wallet(|_dir, wallet_path| {
596 let account_ix = 0;
598 let private_key = derive_secret_key(wallet_path, account_ix, TEST_PASSWORD).unwrap();
599 assert_eq!(
600 private_key.to_string(),
601 "961bf9754dd036dd13b1d543b3c0f74062bc4ac668ea89d38ce8d712c591f5cf"
602 )
603 });
604 }
605 #[test]
606 fn derive_address() {
607 let address_hex = "978f983cf8210549fa92e23bff07d42e3108aa395cc961066d832e2e6a252900";
608 let plain_address =
609 Address::from_str(address_hex).expect("failed to create Address from hex string");
610 assert_eq!(format!("{:x}", plain_address), address_hex)
611 }
612}