forc_wallet/
account.rs

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    /// The index of the account.
37    ///
38    /// This index is used directly within the path used to derive the account.
39    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    /// Option for public key to be displayed as hex / bytes.
49    ///
50    /// pass in --as-hex for this alternative display.
51    #[clap(long)]
52    as_hex: bool,
53}
54
55#[derive(Debug, Subcommand)]
56pub(crate) enum Command {
57    /// Derive and reveal a new account for the wallet.
58    ///
59    /// Note that upon derivation of the new account, the account's public
60    /// address will be cached in plain text for convenient retrieval via the
61    /// `accounts` and `account <ix>` commands.
62    ///
63    /// The index of the newly derived account will be that which succeeds the
64    /// greatest known account index currently within the cache.
65    New,
66    /// Sign a transaction with the specified account.
67    #[clap(subcommand)]
68    Sign(sign::Data),
69    /// Temporarily display the private key of an account from its index.
70    ///
71    /// WARNING: This prints your account's private key to an alternative,
72    /// temporary, terminal window!
73    PrivateKey,
74    /// Reveal the public key for the specified account.
75    /// Takes an optional bool flag --as-hex that displays the PublicKey in hex format.
76    PublicKey(Fmt),
77    /// Print each asset balance associated with the specified account.
78    Balance(Balance),
79    /// Transfer assets from this account to another.
80    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    /// The address of the account to transfer assets to.
92    #[clap(long)]
93    to: Address,
94    /// Amount (in u64) of assets to transfer.
95    #[clap(long)]
96    amount: u64,
97    /// Asset ID of the asset to transfer.
98    #[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    /// When enabled, shows account addresses stored in the cache without re-deriving them.
111    ///
112    /// The cache can be found at `~/.fuel/wallets/addresses`.
113    ///
114    /// Useful for non-interactive scripts on trusted systems or integration tests.
115    #[clap(long = "unverified")]
116    pub(crate) unverified: bool,
117}
118
119/// A map from an account's index to its address.
120type 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
183/// Display a warning to the user if the expected address differs from the account address.
184/// Returns `Ok(true)` if the address matched, `Ok(false)` if it did not, `Err` if we failed to
185/// update the cache.
186pub(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
237/// Prints a list of all known (cached) accounts for the wallet at the given path.
238pub 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    // The user must provide either the account index or a `New`
262    // command - otherwise we print the help output for the
263    // `account` subcommand. There doesn't seem to be a nice way
264    // of doing this with clap's derive API, so we do-so with a
265    // child process.
266    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
290/// Print the address of the wallet's account at the given index.
291pub 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
316/// Given a path to a wallet, an account index and the wallet's password,
317/// derive the account address for the account at the given index.
318pub 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
334/// Derive an account at the first index succeeding the greatest known existing index.
335pub(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
410/// Prints the public key of given account index.
411pub(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
421/// Prints the plain address for the given account index
422pub(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
435/// Transfers assets from account at a given account index to a target address.
436pub(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
494/// A unique 64-bit hash is created from the wallet's ciphertext to use as a unique directory name.
495fn 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
503/// The path in which a wallet's account addresses will be cached.
504fn address_cache_dir(wallet_ciphertext: &[u8]) -> PathBuf {
505    user_fuel_wallets_accounts_dir().join(address_cache_dir_name(wallet_ciphertext))
506}
507
508/// The cache path for a wallet account address.
509fn address_path(wallet_ciphertext: &[u8], account_ix: usize) -> PathBuf {
510    address_cache_dir(wallet_ciphertext).join(format!("{account_ix}"))
511}
512
513/// Cache a single wallet account address to a file as a simple utf8 string.
514pub 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
531/// Read all cached account addresses for the wallet with the given ciphertext.
532pub(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        // Test wallet address in hex format
587        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            // derive account with account index 0
597            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}