├── .gitignore ├── rust-toolchain.toml ├── .markdownlint.yaml ├── .github └── workflows │ ├── nightly-cargo-audit.yml │ ├── markdown-lint.yml │ ├── scripts │ └── verify_tag.sh │ └── ci.yml ├── src ├── lib.rs ├── list.rs ├── new.rs ├── export.rs ├── import.rs ├── format.rs ├── main.rs ├── balance.rs ├── sign.rs ├── utils.rs └── account.rs ├── Cargo.toml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # IDE 3 | .vscode 4 | 5 | # Output 6 | /target 7 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | profile = "default" # include rustfmt, clippy 3 | channel = "1.90.0" 4 | -------------------------------------------------------------------------------- /.markdownlint.yaml: -------------------------------------------------------------------------------- 1 | "default": true # Default state for all rules 2 | "MD013": false # Disable rule for line length 3 | "MD033": false # Disable rule banning inline HTML 4 | -------------------------------------------------------------------------------- /.github/workflows/nightly-cargo-audit.yml: -------------------------------------------------------------------------------- 1 | name: Nightly Cargo Audit 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | 7 | jobs: 8 | cargo_audit: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v1 12 | - uses: actions-rs/audit-check@v1 13 | with: 14 | token: ${{ secrets.GITHUB_TOKEN }} 15 | -------------------------------------------------------------------------------- /.github/workflows/markdown-lint.yml: -------------------------------------------------------------------------------- 1 | name: Markdown Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | release: 9 | types: [published] 10 | 11 | jobs: 12 | markdown-lint: 13 | name: Markdown Lint 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 18 20 | - run: | 21 | npm install -g markdownlint-cli@0.32.1 22 | markdownlint --config .markdownlint.yaml '**/*.md' 23 | -------------------------------------------------------------------------------- /.github/workflows/scripts/verify_tag.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | err() { 5 | echo -e "\e[31m\e[1merror:\e[0m $@" 1>&2; 6 | } 7 | 8 | status() { 9 | WIDTH=12 10 | printf "\e[32m\e[1m%${WIDTH}s\e[0m %s\n" "$1" "$2" 11 | } 12 | 13 | REF=$1 14 | MANIFEST=$2 15 | 16 | if [ -z "$REF" ]; then 17 | err "Expected ref to be set" 18 | exit 1 19 | fi 20 | 21 | if [ -z "$MANIFEST" ]; then 22 | err "Expected manifest to be set" 23 | exit 1 24 | fi 25 | 26 | # strip preceeding 'v' if it exists on tag 27 | REF=${REF/#v} 28 | TOML_VERSION=$(cat $MANIFEST | dasel -r toml 'package.version') 29 | 30 | if [ "$TOML_VERSION" != "$REF" ]; then 31 | err "Crate version $TOML_VERSION, doesn't match tag version $REF" 32 | exit 1 33 | else 34 | status "Crate version matches tag $TOML_VERSION" 35 | fi 36 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use url::Url; 3 | 4 | pub mod account; 5 | pub mod balance; 6 | pub mod export; 7 | pub mod format; 8 | pub mod import; 9 | pub mod list; 10 | pub mod new; 11 | pub mod sign; 12 | pub mod utils; 13 | 14 | pub const DEFAULT_CACHE_ACCOUNTS: usize = 1; 15 | 16 | /// The default network used in the case that none is specified. 17 | pub mod network { 18 | pub const DEFAULT: &str = MAINNET; 19 | pub const TESTNET: &str = "https://testnet.fuel.network/"; 20 | pub const TESTNET_FAUCET: &str = "https://faucet-testnet.fuel.network/"; 21 | pub const MAINNET: &str = "https://mainnet.fuel.network/"; 22 | } 23 | 24 | /// Contains definitions of URLs to the block explorer for each network. 25 | pub mod explorer { 26 | pub const DEFAULT: &str = MAINNET; 27 | pub const TESTNET: &str = "https://app-testnet.fuel.network"; 28 | pub const MAINNET: &str = "https://app.fuel.network"; 29 | } 30 | 31 | pub struct CliContext { 32 | pub wallet_path: PathBuf, 33 | pub node_url: Url, 34 | } 35 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "forc-wallet" 3 | version = "0.15.2" 4 | edition = "2024" 5 | homepage = "https://fuel.network/" 6 | license = "Apache-2.0" 7 | repository = "https://github.com/FuelLabs/forc-wallet" 8 | description = "A forc plugin for generating or importing wallets using mnemonic phrases." 9 | 10 | [dependencies] 11 | anyhow = "1.0" 12 | clap = { version = "4.2.4", features = ["derive"] } 13 | eth-keystore = { version = "0.5" } 14 | forc-tracing = "0.68" 15 | 16 | # Dependencies from the `fuels-rs` repository: 17 | fuels = "0.75" 18 | 19 | futures = "0.3" 20 | hex = "0.4" 21 | home = "0.5.3" 22 | rand = { version = "0.8", default-features = false } 23 | rpassword = "7.2" 24 | serde_json = "1.0" 25 | termion = "4.0" 26 | tokio = { version = "1.44", features = ["full"] } 27 | url = "2.5" 28 | 29 | [lib] 30 | name = "forc_wallet" 31 | path = "src/lib.rs" 32 | 33 | [[bin]] 34 | name = "forc-wallet" 35 | path = "src/main.rs" 36 | 37 | [dev-dependencies] 38 | fuel-core-client = "0.46.0" 39 | tempfile = "3.19" 40 | wiremock = "0.6" 41 | -------------------------------------------------------------------------------- /src/list.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | account::{UnverifiedOpt, print_balance, print_balance_empty}, 3 | balance::{get_derived_accounts, list_account_balances, print_account_balances}, 4 | }; 5 | use anyhow::Result; 6 | use clap::Args; 7 | use std::collections::BTreeMap; 8 | 9 | #[derive(Debug, Args)] 10 | pub struct List { 11 | /// Contains optional flag for displaying all accounts as hex / bytes values. 12 | /// 13 | /// pass in --as-hex for this alternative display. 14 | #[clap(flatten)] 15 | unverified: UnverifiedOpt, 16 | 17 | /// The minimum amount of derived accounts to display their balances from. 18 | /// If there are not enough accounts in the cache, the wallet will be unlocked (requesting the 19 | /// user's password) and will derive more accounts. 20 | #[clap(short, long)] 21 | target_accounts: Option, 22 | } 23 | 24 | pub async fn list_wallet_cli(ctx: &crate::CliContext, opts: List) -> Result<()> { 25 | let addresses = get_derived_accounts(ctx, opts.unverified.unverified, opts.target_accounts) 26 | .await? 27 | .range(0..opts.target_accounts.unwrap_or(1)) 28 | .map(|(a, b)| (*a, *b)) 29 | .collect::>(); 30 | 31 | let (account_balances, total_balance) = 32 | list_account_balances(&ctx.node_url, &addresses).await?; 33 | print_account_balances(&addresses, &account_balances)?; 34 | println!("\nTotal:"); 35 | if total_balance.is_empty() { 36 | print_balance_empty(&ctx.node_url); 37 | } else { 38 | print_balance(&total_balance); 39 | } 40 | Ok(()) 41 | } 42 | -------------------------------------------------------------------------------- /src/new.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | DEFAULT_CACHE_ACCOUNTS, 3 | account::derive_and_cache_addresses, 4 | utils::{ 5 | display_string_discreetly, ensure_no_wallet_exists, request_new_password, 6 | write_wallet_from_mnemonic_and_password, 7 | }, 8 | }; 9 | use clap::Args; 10 | use fuels::accounts::signers::private_key::generate_mnemonic_phrase; 11 | use std::io::stdin; 12 | 13 | #[derive(Debug, Args)] 14 | pub struct New { 15 | /// Forces wallet creation, removing any existing wallet file 16 | #[clap(short, long)] 17 | pub force: bool, 18 | 19 | /// How many accounts to cache by default (Default 10) 20 | #[clap(short, long)] 21 | pub cache_accounts: Option, 22 | } 23 | 24 | pub async fn new_wallet_cli(ctx: &crate::CliContext, new: New) -> anyhow::Result<()> { 25 | ensure_no_wallet_exists(&ctx.wallet_path, new.force, stdin().lock())?; 26 | let password = request_new_password(); 27 | // Generate a random mnemonic phrase. 28 | let mnemonic = generate_mnemonic_phrase(&mut rand::thread_rng(), 24)?; 29 | write_wallet_from_mnemonic_and_password(&ctx.wallet_path, &mnemonic, &password)?; 30 | 31 | derive_and_cache_addresses( 32 | ctx, 33 | &mnemonic, 34 | 0..new.cache_accounts.unwrap_or(DEFAULT_CACHE_ACCOUNTS), 35 | ) 36 | .await?; 37 | 38 | let mnemonic_string = format!("Wallet mnemonic phrase: {mnemonic}\n"); 39 | display_string_discreetly( 40 | &mnemonic_string, 41 | "### Do not share or lose this mnemonic phrase! Press any key to complete. ###", 42 | )?; 43 | Ok(()) 44 | } 45 | -------------------------------------------------------------------------------- /src/export.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::display_string_discreetly; 2 | use anyhow::{Context, Result, anyhow}; 3 | use rpassword::prompt_password; 4 | use std::path::Path; 5 | 6 | /// Decrypts a wallet using provided password 7 | fn decrypt_mnemonic(wallet_path: &Path, password: &str) -> Result { 8 | let phrase_bytes = eth_keystore::decrypt_key(wallet_path, password) 9 | .map_err(|e| anyhow!("Failed to decrypt keystore: {}", e))?; 10 | 11 | String::from_utf8(phrase_bytes).context("Invalid UTF-8 in mnemonic phrase") 12 | } 13 | 14 | /// Prints the wallet at the given path as mnemonic phrase as a discrete string 15 | pub fn export_wallet_cli(wallet_path: &Path) -> Result<()> { 16 | let prompt = "Please enter your wallet password to export your wallet: "; 17 | let password = prompt_password(prompt)?; 18 | let phrase = decrypt_mnemonic(wallet_path, &password)?; 19 | 20 | // Display phrase in alternate screen 21 | display_string_discreetly( 22 | &phrase, 23 | "### Do not share or lose this mnemonic phrase! Press any key to complete. ###", 24 | )?; 25 | 26 | Ok(()) 27 | } 28 | 29 | #[cfg(test)] 30 | mod tests { 31 | use crate::{ 32 | export::decrypt_mnemonic, 33 | utils::test_utils::{TEST_MNEMONIC, TEST_PASSWORD, with_tmp_dir_and_wallet}, 34 | }; 35 | 36 | #[test] 37 | fn decrypt_wallet() { 38 | with_tmp_dir_and_wallet(|_dir, wallet_path| { 39 | let decrypted_mnemonic = decrypt_mnemonic(wallet_path, TEST_PASSWORD).unwrap(); 40 | assert_eq!(decrypted_mnemonic, TEST_MNEMONIC) 41 | }); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/import.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | DEFAULT_CACHE_ACCOUNTS, 3 | account::derive_and_cache_addresses, 4 | utils::{ 5 | ensure_no_wallet_exists, request_new_password, write_wallet_from_mnemonic_and_password, 6 | }, 7 | }; 8 | use anyhow::{Result, bail}; 9 | use clap::Args; 10 | use fuels::{accounts::signers::derivation::DEFAULT_DERIVATION_PATH, crypto::SecretKey}; 11 | use std::io::stdin; 12 | 13 | #[derive(Debug, Args)] 14 | pub struct Import { 15 | /// Forces wallet creation, removing any existing wallet file 16 | #[clap(short, long)] 17 | pub force: bool, 18 | /// How many accounts to cache by default (Default 10) 19 | #[clap(short, long)] 20 | pub cache_accounts: Option, 21 | } 22 | 23 | /// Check if given mnemonic is valid by trying to create a [SecretKey] from it 24 | fn check_mnemonic(mnemonic: &str) -> Result<()> { 25 | // Check users's phrase by trying to create secret key from it 26 | if SecretKey::new_from_mnemonic_phrase_with_path(mnemonic, DEFAULT_DERIVATION_PATH).is_err() { 27 | bail!("Cannot generate a wallet from provided mnemonics, please check your mnemonic phrase") 28 | } 29 | Ok(()) 30 | } 31 | 32 | pub async fn import_wallet_cli(ctx: &crate::CliContext, import: Import) -> Result<()> { 33 | ensure_no_wallet_exists(&ctx.wallet_path, import.force, stdin().lock())?; 34 | let mnemonic = rpassword::prompt_password("Please enter your mnemonic phrase: ")?; 35 | check_mnemonic(&mnemonic)?; 36 | let password = request_new_password(); 37 | write_wallet_from_mnemonic_and_password(&ctx.wallet_path, &mnemonic, &password)?; 38 | derive_and_cache_addresses( 39 | ctx, 40 | &mnemonic, 41 | 0..import.cache_accounts.unwrap_or(DEFAULT_CACHE_ACCOUNTS), 42 | ) 43 | .await?; 44 | Ok(()) 45 | } 46 | 47 | #[cfg(test)] 48 | mod tests { 49 | use super::*; 50 | use crate::utils::test_utils::TEST_MNEMONIC; 51 | 52 | #[test] 53 | fn check_mnemonic_should_succeed() { 54 | assert!(check_mnemonic(TEST_MNEMONIC).is_ok()) 55 | } 56 | 57 | #[test] 58 | fn check_mnemonic_should_fail() { 59 | let invalid_mnemonic = "this is an invalid mnemonic"; 60 | assert!(check_mnemonic(invalid_mnemonic).is_err()) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # forc-wallet 2 | 3 | A forc plugin for managing Fuel wallets. 4 | 5 | ## Quickstart 6 | 7 | ### Installation through fuelup (recommended) 8 | 9 | `forc-wallet` is packaged alongside the default distributed toolchains when installed using 10 | [fuelup](https://github.com/fuellabs/fuelup). If you have the `latest` toolchain installed, 11 | you should already have `forc-wallet` available: 12 | 13 | ```console 14 | $ fuelup toolchain install latest 15 | $ forc-wallet --version 16 | forc-wallet 0.2.2 17 | ``` 18 | 19 | For usage in [custom toolchains](https://fuellabs.github.io/fuelup/master/concepts/toolchains.html#custom-toolchains): 20 | 21 | ```sh 22 | fuelup component add forc-wallet 23 | ``` 24 | 25 | ### Installation through cargo 26 | 27 | Otherwise, you may use cargo: 28 | 29 | ```sh 30 | cargo install forc-wallet 31 | ``` 32 | 33 | ### Create a wallet 34 | 35 | Before creating accounts and signing transactions with them you need to create a wallet. To do so: 36 | 37 | ```sh 38 | forc-wallet new 39 | ``` 40 | 41 | This will require a password for encrypting the wallet. After the wallet is created you will be shown the mnemonic phrase. 42 | 43 | > Note: You will need your password for signing and account derivation, and you will need your mnemonic phrase if you wish to recover your wallet in the future. 44 | 45 | ### Import a wallet 46 | 47 | To import a wallet from an existing mnemonic phrase, use: 48 | 49 | ```sh 50 | forc-wallet import 51 | ``` 52 | 53 | > Note: `forc-wallet` adheres to the [Web3 Secret Storage Definition](https://ethereum.org/en/developers/docs/data-structures-and-encoding/web3-secret-storage) and accepts paths to wallet files that adhere to this standard. 54 | 55 | ### Create an account 56 | 57 | To create an account for the wallet, you can run: 58 | 59 | ```sh 60 | forc-wallet account new 61 | ``` 62 | 63 | This will require your wallet password (the one that you chose during creation). This command will always derive the next account that has not yet been derived locally. 64 | 65 | To list all accounts derived so far, use the following: 66 | 67 | ```sh 68 | forc-wallet accounts 69 | ``` 70 | 71 | > Note: When we "create" an account, we are really just *revealing* it. All accounts are derived deterministically based on the wallet's mnemonic phrase and derivation path. `forc-wallet` will cache the public addresses of derived accounts within `~/.fuel/wallets/accounts`. 72 | 73 | ### Sign a transaction 74 | 75 | To sign a transaction, you can provide the transaction ID. You can generate a transaction and get its ID using `forc-client`. Signing the transaction once you have the ID is simple: 76 | 77 | ```sh 78 | forc-wallet account sign tx-id 79 | ``` 80 | 81 | ### Sign arbitrary data 82 | 83 | You may sign a string directly: 84 | 85 | ```sh 86 | forc-wallet account sign string "Blah blah blah" 87 | ``` 88 | 89 | Or the contents of a file: 90 | 91 | ```sh 92 | forc-wallet account sign file 93 | ``` 94 | 95 | You may also sign a hex-encoded byte string: 96 | 97 | ```sh 98 | forc-wallet account sign hex 0x0123456789ABCDEF 99 | ``` 100 | 101 | You can also use the `sign` subcommand directly, e.g. the following is the same: 102 | 103 | ```sh 104 | forc-wallet sign --account hex 0x0123456789ABCDEF 105 | ``` 106 | 107 | Using the `sign` subcommand, you can choose to sign directly with a private key (rather than a wallet account): 108 | 109 | ```sh 110 | forc-wallet sign --private-key hex 0x0123456789ABCDEF 111 | ``` 112 | 113 | ## Other useful commands 114 | 115 | ### Get address of an account 116 | 117 | To derive the address of a specific account, you can use: 118 | 119 | ```sh 120 | forc-wallet account 121 | ``` 122 | 123 | ### Get private key of an account 124 | 125 | To retrieve the private key of a specific account, you can use: 126 | 127 | ```sh 128 | forc-wallet account private-key 129 | ``` 130 | 131 | ### Get public key of an account 132 | 133 | To retrieve the public key of a specific account, you can use: 134 | 135 | ```sh 136 | forc-wallet account public-key 137 | ``` 138 | -------------------------------------------------------------------------------- /src/format.rs: -------------------------------------------------------------------------------- 1 | use std::{cmp::max, collections::HashMap, fmt::Display}; 2 | 3 | use anyhow::Result; 4 | 5 | #[derive(PartialEq, Eq)] 6 | enum Value { 7 | Separator, 8 | NewLine, 9 | Entry(String, String), 10 | } 11 | 12 | /// Simple helper to print key-value entries where the keys are all aligned. 13 | /// 14 | /// Here is an example of how it looks: 15 | /// 16 | /// -------------------------------------------------------------------------- 17 | /// account 0: 0x0008faCa0e0280192dbA692156280C5410043f63a48d49C6a1d901de8A29a4aa 18 | /// Asset ID : 0000000000000000000000000000000000000000000000000000000000000000 19 | /// Amount : 499999800 20 | /// 21 | /// Asset ID : 0000000000000000000000000000000000000000000000000000000000000001 22 | /// Amount : 359989610 23 | /// -------------------------------------------------------------------------- 24 | /// account 1: 0x002D7487aeFb0238D2e12c63eaB99B545876e2F2E5cdA90aDFD2dC40BD6B49ff 25 | /// Asset ID : 0000000000000000000000000000000000000000000000000000000000000000 26 | /// Amount : 268983615 27 | #[derive(Default)] 28 | pub struct List(Vec); 29 | 30 | impl List { 31 | pub fn add(&mut self, title: impl ToString, value: impl ToString) { 32 | self.0 33 | .push(Value::Entry(title.to_string(), value.to_string())); 34 | } 35 | 36 | pub fn add_newline(&mut self) { 37 | self.0.push(Value::NewLine); 38 | } 39 | 40 | pub fn add_separator(&mut self) { 41 | if self.0.last() == Some(&Value::Separator) { 42 | return; 43 | } 44 | self.0.push(Value::Separator); 45 | } 46 | 47 | pub fn longest_title(&self) -> usize { 48 | self.0 49 | .iter() 50 | .map(|value| match value { 51 | Value::Separator => 0, 52 | Value::NewLine => 0, 53 | Value::Entry(title, _) => title.len(), 54 | }) 55 | .max() 56 | .unwrap_or(0) 57 | } 58 | } 59 | 60 | impl Display for List { 61 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 62 | let longest_key = self.longest_title(); 63 | let entries = self 64 | .0 65 | .iter() 66 | .map(|entry| match entry { 67 | Value::Separator => None, 68 | Value::NewLine => Some("".to_owned()), 69 | Value::Entry(title, value) => { 70 | let padding = " ".repeat(longest_key - title.len()); 71 | Some(format!("{}{}: {}", title, padding, value)) 72 | } 73 | }) 74 | .collect::>(); 75 | 76 | let longest_entry = entries 77 | .iter() 78 | .map(|entry| entry.as_ref().map(|s| s.len()).unwrap_or(0)) 79 | .max() 80 | .unwrap_or(0); 81 | 82 | let separator = "-".repeat(longest_entry); 83 | 84 | let formatted = entries 85 | .into_iter() 86 | .map(|entry| entry.map(|s| s.to_string()).unwrap_or(separator.clone())) 87 | .collect::>() 88 | .join("\n"); 89 | 90 | write!(f, "{formatted}") 91 | } 92 | } 93 | 94 | #[derive(Default)] 95 | pub struct Table { 96 | headers: Vec, 97 | rows: Vec>, 98 | } 99 | 100 | impl Table { 101 | pub fn add_header(&mut self, header: impl ToString) { 102 | self.headers.push(header.to_string()); 103 | } 104 | 105 | pub fn add_row(&mut self, row: Vec) -> Result<()> { 106 | if self.headers.len() != row.len() { 107 | anyhow::bail!("Row length does not match header length"); 108 | } 109 | self.rows 110 | .push(row.into_iter().map(|x| x.to_string()).collect()); 111 | Ok(()) 112 | } 113 | } 114 | 115 | impl Display for Table { 116 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 117 | let mut longest_columns = self 118 | .headers 119 | .iter() 120 | .enumerate() 121 | .map(|(column_id, x)| (column_id, x.len())) 122 | .collect::>(); 123 | 124 | for row in self.rows.iter() { 125 | for (column_id, value) in row.iter().enumerate() { 126 | longest_columns 127 | .entry(column_id) 128 | .and_modify(|x| *x = max(*x, value.len())); 129 | } 130 | } 131 | let separator = self 132 | .headers 133 | .iter() 134 | .enumerate() 135 | .map(|(column_id, _)| "-".repeat(longest_columns[&column_id])) 136 | .collect::>() 137 | .join("-|-"); 138 | 139 | let mut table = vec![ 140 | self.headers 141 | .iter() 142 | .enumerate() 143 | .map(|(column_id, header)| { 144 | let padding = " ".repeat(longest_columns[&column_id] - header.len()); 145 | format!("{}{}", header, padding) 146 | }) 147 | .collect::>() 148 | .join(" | "), 149 | separator.clone(), 150 | ]; 151 | 152 | for row in &self.rows { 153 | table.push( 154 | row.iter() 155 | .enumerate() 156 | .map(|(column_id, value)| { 157 | let padding = " ".repeat(longest_columns[&column_id] - value.len()); 158 | format!("{}{}", value, padding) 159 | }) 160 | .collect::>() 161 | .join(" | "), 162 | ); 163 | table.push(separator.clone()); 164 | } 165 | 166 | let formatted = table.join("\n"); 167 | 168 | write!(f, "{formatted}") 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::{Parser, Subcommand}; 3 | use forc_tracing::{init_tracing_subscriber, println_error}; 4 | use forc_wallet::{ 5 | CliContext, 6 | account::{self, Account, Accounts}, 7 | balance::{self, Balance}, 8 | export::export_wallet_cli, 9 | import::{Import, import_wallet_cli}, 10 | list::{List, list_wallet_cli}, 11 | network::DEFAULT as DEFAULT_NODE_URL, 12 | new::{New, new_wallet_cli}, 13 | sign::{self, Sign}, 14 | utils::default_wallet_path, 15 | }; 16 | use std::path::PathBuf; 17 | use url::Url; 18 | 19 | #[derive(Debug, Parser)] 20 | #[clap(name = "forc wallet", about = ABOUT, after_long_help = EXAMPLES, version)] 21 | struct App { 22 | /// The path to a wallet. A wallet is a JSON keystore file as described in 23 | /// the Web3 Secret Storage Definition. 24 | /// Read more about the Web3 Secret Storage Definition here: 25 | /// https://ethereum.org/en/developers/docs/data-structures-and-encoding/web3-secret-storage 26 | #[clap(long = "path", default_value_os_t = default_wallet_path() )] 27 | wallet_path: PathBuf, 28 | 29 | /// The URL of the Fuel node to connect to. 30 | #[clap(long, default_value_t = Url::parse(DEFAULT_NODE_URL).unwrap())] 31 | node_url: Url, 32 | 33 | #[clap(subcommand)] 34 | pub cmd: Command, 35 | } 36 | 37 | #[derive(Debug, Subcommand)] 38 | enum Command { 39 | /// Create a new wallet from a random mnemonic phrase. 40 | /// 41 | /// If a `--path` is specified, the wallet will be created at this location. 42 | /// 43 | /// If a '--fore' is specified, will automatically removes the existing wallet at the same 44 | /// path. 45 | New(New), 46 | /// List all wallets in the default wallet directory. 47 | List(List), 48 | /// Import a wallet from the provided mnemonic phrase. 49 | /// 50 | /// If a `--path` is specified, the wallet will be imported to this location. 51 | /// 52 | /// If a '--fore' is specified, will automatically removes the existing wallet at the same 53 | /// path. 54 | Import(Import), 55 | /// Export a wallet as a mnemonic phrase. 56 | /// 57 | /// If a `--path` is specified, the wallet will be read from that location. 58 | Export, 59 | /// Lists all accounts derived for the wallet so far. 60 | /// 61 | /// Note that this only includes accounts that have been previously derived 62 | /// *locally* and still exist within the user's `~/.fuel/wallets/accounts` 63 | /// cache. If this wallet was recently imported, you may need to re-derive 64 | /// your accounts. 65 | /// 66 | /// By default, this requires your password in order to verify and re- 67 | /// derive each of the accounts. Use the `--unverified` flag to bypass 68 | /// this password check and read the public addresses directly from the 69 | /// `~/.fuel/wallets/accounts` cache. 70 | Accounts(Accounts), 71 | /// Derive a new account, sign with an existing account, or display an 72 | /// account's public or private key. See the `EXAMPLES` below. 73 | Account(Account), 74 | Sign(Sign), 75 | /// Present the sum of all account balances under a single wallet balance. 76 | /// 77 | /// Only includes accounts that have been previously derived, i.e. those 78 | /// that show under `forc-wallet accounts`. 79 | Balance(Balance), 80 | } 81 | 82 | const ABOUT: &str = "A forc plugin for generating or importing wallets using BIP39 phrases."; 83 | const EXAMPLES: &str = r#" 84 | EXAMPLES: 85 | # Create a new wallet at the default path `~/.fuel/wallets/.wallet`. 86 | forc wallet new 87 | 88 | # Create a new wallet and automatically replace the existing wallet if it's at the same path. 89 | forc wallet new --force 90 | 91 | # Import a new wallet from a mnemonic phrase. 92 | forc wallet import 93 | 94 | # Export wallet as a mnemonic phrase from default path. 95 | forc wallet import 96 | 97 | # Export wallet as a mnemonic phrase read from the given path. 98 | forc wallet import --path /path/to/wallet 99 | 100 | # Import a new wallet from a mnemonic phrase and automatically replace the existing wallet if it's at the same path. 101 | forc wallet import --force 102 | 103 | # Derive a new account for the default wallet. 104 | forc wallet account new 105 | 106 | # Derive a new account for the wallet at the given path. 107 | forc wallet --path /path/to/wallet account new 108 | 109 | # Derive (or re-derive) the account at index 5. 110 | forc wallet account 5 new 111 | 112 | # Sign a transaction ID with account at index 3. 113 | forc wallet account 3 sign tx-id 0x0bf34feb362608c4171c87115d4a6f63d1cdf4c49b963b464762329488f3ed4f 114 | 115 | # Sign an arbitrary string. 116 | forc wallet account 3 sign string "blah blah blah" 117 | 118 | # Sign the contents of a file. 119 | forc wallet account 3 sign file /path/to/data-to-sign 120 | 121 | # Sign a hex-encoded byte string. 122 | forc wallet account 3 sign hex "0xDEADBEEF" 123 | 124 | # You can also use the `sign` subcommand directly. The following gives the same result. 125 | forc wallet sign --account 3 string "blah blah blah" 126 | 127 | # Sign directly with a private key. 128 | forc wallet sign --private-key string "blah blah blah" 129 | 130 | # Temporarily display the private key of the account at index 0. 131 | forc wallet account 0 private-key 132 | 133 | # Show the public key of the account at index 0. 134 | forc wallet account 0 public-key 135 | 136 | # Transfer 1 token of the base asset id to an address at the gas price of 1. 137 | forc wallet account 0 transfer --to 0x0b8d0f6a7f271919708530d11bdd9398205137e012424b611e9d97118c180bea 138 | --amount 1 --asset-id 0x0000000000000000000000000000000000000000000000000000000000000000 --gas-price 1 139 | "#; 140 | 141 | #[tokio::main] 142 | async fn main() { 143 | init_tracing_subscriber(Default::default()); 144 | if let Err(err) = run().await { 145 | println_error(&format!("{}", err)); 146 | std::process::exit(1); 147 | } 148 | } 149 | 150 | async fn run() -> Result<()> { 151 | let app = App::parse(); 152 | let ctx = CliContext { 153 | wallet_path: app.wallet_path, 154 | node_url: app.node_url, 155 | }; 156 | match app.cmd { 157 | Command::New(new) => new_wallet_cli(&ctx, new).await?, 158 | Command::List(list) => list_wallet_cli(&ctx, list).await?, 159 | Command::Import(import) => import_wallet_cli(&ctx, import).await?, 160 | Command::Export => export_wallet_cli(&ctx.wallet_path)?, 161 | Command::Accounts(accounts) => account::print_accounts_cli(&ctx, accounts).await?, 162 | Command::Account(account) => account::cli(&ctx, account).await?, 163 | Command::Sign(sign) => sign::cli(&ctx, sign)?, 164 | Command::Balance(balance) => balance::cli(&ctx, &balance).await?, 165 | } 166 | Ok(()) 167 | } 168 | -------------------------------------------------------------------------------- /src/balance.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | DEFAULT_CACHE_ACCOUNTS, 3 | account::{ 4 | derive_account_unlocked, derive_and_cache_addresses, print_balance, print_balance_empty, 5 | read_cached_addresses, verify_address_and_update_cache, 6 | }, 7 | format::List, 8 | utils::load_wallet, 9 | }; 10 | use anyhow::{Result, anyhow}; 11 | use clap::Args; 12 | use fuels::{ 13 | accounts::{ViewOnlyAccount, provider::Provider, wallet::Wallet}, 14 | types::{Address, checksum_address::checksum_encode}, 15 | }; 16 | use std::{ 17 | cmp::max, 18 | collections::{BTreeMap, HashMap}, 19 | path::Path, 20 | }; 21 | use url::Url; 22 | 23 | #[derive(Debug, Args)] 24 | #[group(skip)] 25 | pub struct Balance { 26 | // Account-specific args. 27 | #[clap(flatten)] 28 | pub(crate) account: crate::account::Balance, 29 | /// Show the balance for each individual non-empty account before showing 30 | /// the total. 31 | #[clap(long)] 32 | pub(crate) accounts: bool, 33 | } 34 | 35 | /// Whether to verify cached accounts or not. 36 | /// 37 | /// To verify cached accounts we require wallet vault password. 38 | pub enum AccountVerification { 39 | No, 40 | Yes(String), 41 | } 42 | 43 | /// List of accounts and amount of tokens they hold with different ASSET_IDs. 44 | pub type AccountBalances = Vec>; 45 | /// A mapping between account index and the address for that account. 46 | pub type AccountsMap = BTreeMap; 47 | 48 | /// Return a map of accounts after desired verification applied in a map where each key is account 49 | /// index and each value is the `Address` of that account. 50 | pub async fn collect_accounts_with_verification( 51 | wallet_path: &Path, 52 | verification: AccountVerification, 53 | node_url: &Url, 54 | ) -> Result { 55 | let wallet = load_wallet(wallet_path)?; 56 | let mut addresses = read_cached_addresses(&wallet.crypto.ciphertext)?; 57 | if let AccountVerification::Yes(password) = verification { 58 | for (&ix, addr) in addresses.iter_mut() { 59 | let provider = Provider::connect(node_url).await?; 60 | let account = derive_account_unlocked(wallet_path, ix, &password, &provider)?; 61 | if verify_address_and_update_cache(ix, &account, addr, &wallet.crypto.ciphertext)? { 62 | *addr = account.address(); 63 | } 64 | } 65 | } 66 | 67 | Ok(addresses) 68 | } 69 | 70 | /// Returns N derived addresses. If the `unverified` flag is set, it will not verify the addresses 71 | /// and will use the cached ones. 72 | /// 73 | /// This function will override / fix the cached addresses if the user password is requested 74 | pub async fn get_derived_accounts( 75 | ctx: &crate::CliContext, 76 | unverified: bool, 77 | target_accounts: Option, 78 | ) -> Result { 79 | let wallet = load_wallet(&ctx.wallet_path)?; 80 | let addresses = if unverified { 81 | read_cached_addresses(&wallet.crypto.ciphertext)? 82 | } else { 83 | BTreeMap::new() 84 | }; 85 | let target_accounts = target_accounts.unwrap_or(1); 86 | 87 | if !unverified || addresses.len() < target_accounts { 88 | let prompt = "Please enter your wallet password to verify accounts: "; 89 | let password = rpassword::prompt_password(prompt)?; 90 | let phrase_recovered = eth_keystore::decrypt_key(&ctx.wallet_path, password)?; 91 | let phrase = String::from_utf8(phrase_recovered)?; 92 | 93 | let range = 0..max(target_accounts, DEFAULT_CACHE_ACCOUNTS); 94 | derive_and_cache_addresses(ctx, &phrase, range).await 95 | } else { 96 | Ok(addresses) 97 | } 98 | } 99 | 100 | /// Print collected account balances for each asset type. 101 | pub fn print_account_balances( 102 | accounts_map: &AccountsMap, 103 | account_balances: &AccountBalances, 104 | ) -> Result<()> { 105 | let mut list = List::default(); 106 | list.add_newline(); 107 | for (ix, balance) in accounts_map.keys().zip(account_balances) { 108 | let balance: BTreeMap<_, _> = balance.iter().map(|(id, &val)| (id.clone(), val)).collect(); 109 | if balance.is_empty() { 110 | continue; 111 | } 112 | 113 | list.add_separator(); 114 | list.add( 115 | format!("Account {ix}"), 116 | checksum_encode(&format!("0x{}", accounts_map[ix]))?, 117 | ); 118 | list.add_newline(); 119 | 120 | for (asset_id, amount) in balance { 121 | list.add("Asset ID", asset_id); 122 | list.add("Amount", amount.to_string()); 123 | } 124 | list.add_separator(); 125 | } 126 | println!("{}", list); 127 | Ok(()) 128 | } 129 | 130 | pub(crate) async fn list_account_balances( 131 | node_url: &Url, 132 | addresses: &BTreeMap, 133 | ) -> Result<(Vec>, BTreeMap)> { 134 | println!("Connecting to {node_url}"); 135 | let provider = Provider::connect(&node_url).await?; 136 | println!("Fetching and summing balances of the following accounts:"); 137 | for (ix, addr) in addresses { 138 | let addr = format!("0x{}", addr); 139 | let checksum_addr = checksum_encode(&addr)?; 140 | println!(" {ix:>3}: {checksum_addr}"); 141 | } 142 | let accounts: Vec<_> = addresses 143 | .values() 144 | .map(|addr| Wallet::new_locked(*addr, provider.clone())) 145 | .collect(); 146 | let account_balances = 147 | futures::future::try_join_all(accounts.iter().map(|acc| acc.get_balances())).await?; 148 | 149 | let mut total_balance = BTreeMap::default(); 150 | for acc_bal in &account_balances { 151 | for (asset_id, amt) in acc_bal { 152 | let entry = total_balance.entry(asset_id.clone()).or_insert(0u128); 153 | *entry = entry.checked_add(*amt).ok_or_else(|| { 154 | anyhow!("Failed to display balance for asset {asset_id}: Value out of range.") 155 | })?; 156 | } 157 | } 158 | 159 | Ok((account_balances, total_balance)) 160 | } 161 | 162 | pub async fn cli(ctx: &crate::CliContext, balance: &Balance) -> Result<()> { 163 | let verification = if !balance.account.unverified.unverified { 164 | let prompt = "Please enter your wallet password to verify accounts: "; 165 | let password = rpassword::prompt_password(prompt)?; 166 | AccountVerification::Yes(password) 167 | } else { 168 | AccountVerification::No 169 | }; 170 | 171 | let addresses = 172 | collect_accounts_with_verification(&ctx.wallet_path, verification, &ctx.node_url).await?; 173 | let (account_balances, total_balance) = 174 | list_account_balances(&ctx.node_url, &addresses).await?; 175 | 176 | if balance.accounts { 177 | print_account_balances(&addresses, &account_balances)?; 178 | } 179 | 180 | println!("\nTotal:"); 181 | if total_balance.is_empty() { 182 | print_balance_empty(&ctx.node_url); 183 | } else { 184 | print_balance(&total_balance); 185 | } 186 | Ok(()) 187 | } 188 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | release: 9 | types: [published] 10 | 11 | env: 12 | CARGO_TERM_COLOR: always 13 | RUSTFLAGS: -D warnings 14 | REGISTRY: ghcr.io 15 | RUST_VERSION: 1.90.0 16 | NIGHTLY_RUST_VERSION: nightly-2025-09-28 17 | 18 | jobs: 19 | cancel-previous-runs: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Cancel Previous Runs 23 | uses: styfle/cancel-workflow-action@0.9.1 24 | with: 25 | access_token: ${{ github.token }} 26 | 27 | publish-codecov: 28 | name: Check code coverage (branch) 29 | runs-on: ubuntu-latest 30 | permissions: # Write access to push changes to pages 31 | contents: write 32 | pull-requests: write 33 | steps: 34 | - uses: actions/checkout@v4 35 | - name: Install latest Rust 36 | uses: dtolnay/rust-toolchain@master 37 | with: 38 | toolchain: ${{ env.NIGHTLY_RUST_VERSION }} 39 | 40 | - name: Install cargo-llvm-codecov 41 | uses: taiki-e/install-action@cargo-llvm-cov 42 | 43 | - name: Code coverage report 44 | run: cargo +${{ env.NIGHTLY_RUST_VERSION }} llvm-cov --all-features --lcov --branch --output-path lcov.info 45 | 46 | - name: Setup LCOV 47 | uses: hrishikesh-kadam/setup-lcov@v1 48 | 49 | - name: Report code coverage 50 | uses: zgosalvez/github-actions-report-lcov@v4 51 | with: 52 | coverage-files: lcov.info 53 | minimum-coverage: 0 # for now we are not enforcing any minimum coverage. 54 | artifact-name: code-coverage-report 55 | github-token: ${{ secrets.GITHUB_TOKEN }} 56 | update-comment: true 57 | 58 | lint-toml-files: 59 | needs: cancel-previous-runs 60 | runs-on: ubuntu-latest 61 | steps: 62 | - uses: actions/checkout@v2 63 | - uses: actions-rs/toolchain@v1 64 | with: 65 | toolchain: stable 66 | - name: Install Cargo.toml linter 67 | uses: baptiste0928/cargo-install@v1 68 | with: 69 | crate: cargo-toml-lint 70 | version: "0.1" 71 | - name: Run Cargo.toml linter 72 | run: git ls-files | grep Cargo.toml$ | xargs --verbose -n 1 cargo-toml-lint 73 | - name: Notify if Job Fails 74 | uses: ravsamhq/notify-slack-action@v1 75 | if: always() && github.ref == 'refs/heads/master' 76 | with: 77 | status: ${{ job.status }} 78 | token: ${{ secrets.GITHUB_TOKEN }} 79 | notification_title: '{workflow} has {status_message}' 80 | message_format: '{emoji} *{workflow}* {status_message} in <{repo_url}|{repo}> : <{run_url}|View Run Results>' 81 | footer: '' 82 | notify_when: 'failure' 83 | env: 84 | SLACK_WEBHOOK_URL: ${{ secrets.ACTION_MONITORING_SLACK }} 85 | 86 | cargo-verifications: 87 | needs: cancel-previous-runs 88 | runs-on: ubuntu-latest 89 | strategy: 90 | matrix: 91 | include: 92 | - command: fmt 93 | args: --all --verbose -- --check 94 | - command: clippy 95 | args: --all-targets --all-features 96 | - command: check 97 | args: --locked --all-targets --all-features 98 | - command: test 99 | args: --all-targets --all-features 100 | - command: test 101 | args: --all-targets --no-default-features 102 | # disallow any job that takes longer than 30 minutes 103 | timeout-minutes: 30 104 | steps: 105 | - uses: actions/checkout@v2 106 | - uses: actions-rs/toolchain@v1 107 | with: 108 | toolchain: stable 109 | - uses: Swatinem/rust-cache@v1 110 | with: 111 | key: "${{ matrix.command }}${{ matrix.args }}" 112 | - name: ${{ matrix.command }} ${{ matrix.args }} 113 | uses: actions-rs/cargo@v1 114 | with: 115 | command: ${{ matrix.command }} 116 | args: ${{ matrix.args }} 117 | - name: Notify if Job Fails 118 | uses: ravsamhq/notify-slack-action@v1 119 | if: always() && github.ref == 'refs/heads/master' 120 | with: 121 | status: ${{ job.status }} 122 | token: ${{ secrets.GITHUB_TOKEN }} 123 | notification_title: '{workflow} has {status_message}' 124 | message_format: '{emoji} *{workflow}* {status_message} in <{repo_url}|{repo}> : <{run_url}|View Run Results>' 125 | footer: '' 126 | notify_when: 'failure' 127 | env: 128 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_NOTIFY_BUILD }} 129 | RUSTFLAGS: -D warnings 130 | 131 | 132 | publish-crates: 133 | # Only do this job if publishing a release 134 | needs: 135 | - lint-toml-files 136 | - cargo-verifications 137 | if: github.event_name == 'release' && github.event.action == 'published' 138 | runs-on: ubuntu-latest 139 | 140 | steps: 141 | - name: Checkout repository 142 | uses: actions/checkout@v2 143 | 144 | - name: Install toolchain 145 | uses: actions-rs/toolchain@v1 146 | with: 147 | toolchain: stable 148 | override: true 149 | 150 | - name: Verify tag version 151 | run: | 152 | curl -sSLf "https://github.com/TomWright/dasel/releases/download/v1.24.3/dasel_linux_amd64" -L -o dasel && chmod +x dasel 153 | mv ./dasel /usr/local/bin/dasel 154 | ./.github/workflows/scripts/verify_tag.sh ${{ github.ref_name }} Cargo.toml 155 | 156 | - name: Publish crate 157 | uses: FuelLabs/publish-crates@v1 158 | with: 159 | publish-delay: 30000 160 | registry-token: ${{ secrets.CARGO_REGISTRY_TOKEN }} 161 | 162 | - name: Notify if Job Fails 163 | uses: ravsamhq/notify-slack-action@v1 164 | if: always() 165 | with: 166 | status: ${{ job.status }} 167 | token: ${{ secrets.GITHUB_TOKEN }} 168 | notification_title: '{workflow} has {status_message}' 169 | message_format: '{emoji} *{workflow}* {status_message} in <{repo_url}|{repo}> : <{run_url}|View Run Results>' 170 | footer: '' 171 | notify_when: 'failure' 172 | env: 173 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_TOOLING }} 174 | 175 | build-release: 176 | name: build forc wallet release binaries 177 | runs-on: ${{ matrix.job.os }} 178 | if: github.event_name == 'release' && github.event.action == 'published' 179 | needs: cancel-previous-runs 180 | strategy: 181 | matrix: 182 | job: 183 | - os: ubuntu-latest 184 | platform: linux 185 | target: x86_64-unknown-linux-gnu 186 | - os: ubuntu-latest 187 | platform: linux 188 | target: aarch64-unknown-linux-gnu 189 | - os: macos-latest 190 | platform: darwin 191 | target: x86_64-apple-darwin 192 | - os: macos-latest 193 | platform: darwin 194 | target: aarch64-apple-darwin 195 | steps: 196 | - name: Checkout sources 197 | uses: actions/checkout@v2 198 | 199 | - name: Install toolchain 200 | uses: actions-rs/toolchain@v1 201 | with: 202 | profile: minimal 203 | toolchain: stable 204 | target: ${{ matrix.job.target }} 205 | override: true 206 | 207 | - uses: Swatinem/rust-cache@v1 208 | with: 209 | cache-on-failure: true 210 | key: "${{ matrix.job.target }}" 211 | 212 | - name: Use Cross 213 | uses: baptiste0928/cargo-install@v1 214 | with: 215 | crate: cross 216 | cache-key: "${{ matrix.job.target }}" 217 | 218 | - name: Build forc-wallet 219 | run: | 220 | cross build --profile=release --target ${{ matrix.job.target }} -p forc-wallet 221 | - name: Strip release binary x86_64-linux-gnu 222 | if: matrix.job.target == 'x86_64-unknown-linux-gnu' 223 | run: strip "target/${{ matrix.job.target }}/release/forc-wallet" 224 | 225 | - name: Strip release binary aarch64-linux-gnu 226 | if: matrix.job.target == 'aarch64-unknown-linux-gnu' 227 | run: | 228 | docker run --rm -v \ 229 | "$PWD/target:/target:Z" \ 230 | ghcr.io/cross-rs/aarch64-unknown-linux-gnu:main \ 231 | aarch64-linux-gnu-strip \ 232 | /target/aarch64-unknown-linux-gnu/release/forc-wallet 233 | - name: Strip release binary mac 234 | if: matrix.job.os == 'macos-latest' 235 | run: strip -x "target/${{ matrix.job.target }}/release/forc-wallet" 236 | 237 | - name: Prep assets 238 | id: prep_assets 239 | env: 240 | PLATFORM_NAME: ${{ matrix.job.platform }} 241 | TARGET: ${{ matrix.job.target }} 242 | run: | 243 | # Get tag name 244 | # See: https://github.community/t5/GitHub-Actions/How-to-get-just-the-tag-name/m-p/32167/highlight/true#M1027 245 | FORC_WALLET_VERSION="${GITHUB_REF#refs/tags/}" 246 | # trim v from tag prefix 247 | FORC_WALLET_VERSION="${FORC_WALLET_VERSION#v}" 248 | echo "version is: $FORC_WALLET_VERSION" 249 | # setup artifact filename 250 | ARTIFACT="forc-wallet-$FORC_WALLET_VERSION-${{ env.TARGET }}" 251 | ZIP_FILE_NAME="$ARTIFACT.tar.gz" 252 | echo "ZIP_FILE_NAME=$ZIP_FILE_NAME" >> $GITHUB_ENV 253 | # create zip file 254 | mkdir -pv "$ARTIFACT" 255 | cp "target/${{ matrix.job.target }}/release/forc-wallet" "$ARTIFACT" 256 | tar -czvf $ZIP_FILE_NAME "$ARTIFACT" 257 | - name: Upload release archive 258 | uses: softprops/action-gh-release@v2 259 | if: github.ref_type == 'tag' 260 | with: 261 | files: ${{ env.ZIP_FILE_NAME }} 262 | -------------------------------------------------------------------------------- /src/sign.rs: -------------------------------------------------------------------------------- 1 | use crate::account; 2 | use anyhow::{Context, Result, bail}; 3 | use clap::{Args, Subcommand}; 4 | use fuels::crypto::{Message, SecretKey, Signature}; 5 | use fuels::types::Bytes32; 6 | use rpassword::prompt_password; 7 | use std::{ 8 | path::{Path, PathBuf}, 9 | str::FromStr, 10 | }; 11 | 12 | /// Sign some data (e.g. a transaction ID, a file, a string, or a hex-string) 13 | /// using either a wallet account or a private key. 14 | #[derive(Debug, Args)] 15 | pub struct Sign { 16 | /// Sign using the wallet account at the given index. 17 | /// Uses a discrete interactive prompt for password input. 18 | #[clap(long, value_name = "ACCOUNT_INDEX")] 19 | pub account: Option, 20 | /// Sign using a private key. 21 | /// Uses a discrete interactive prompt for collecting the private key. 22 | #[clap(long)] 23 | pub private_key: bool, 24 | /// Sign by passing the private key directly. 25 | /// 26 | /// WARNING: This is primarily provided for non-interactive testing. Using this flag is 27 | /// prone to leaving your private key exposed in your shell command history! 28 | #[clap(long)] 29 | pub private_key_non_interactive: Option, 30 | /// Directly provide the wallet password when signing with an account. 31 | /// 32 | /// WARNING: This is primarily provided for non-interactive testing. Using this flag is 33 | /// prone to leaving your password exposed in your shell command history! 34 | #[clap(long)] 35 | pub password_non_interactive: Option, 36 | #[clap(subcommand)] 37 | pub data: Data, 38 | } 39 | 40 | /// The data that is to be signed. 41 | #[derive(Debug, Subcommand)] 42 | pub enum Data { 43 | /// Sign a transaction ID. 44 | /// 45 | /// The tx ID is signed directly, i.e. it is not re-hashed before signing. 46 | /// 47 | /// Previously `tx`, though renamed in anticipation of support for signing transaction files. 48 | TxId { tx_id: Bytes32 }, 49 | /// Read the file at the given path into bytes and sign the raw data. 50 | File { path: PathBuf }, 51 | /// Sign the given string as a slice of bytes. 52 | String { string: String }, 53 | /// Parse the given hex-encoded byte string and sign the raw bytes. 54 | /// 55 | /// All characters must be within the range '0'..='f'. Each character pair 56 | /// represents a single hex-encoded byte. 57 | /// 58 | /// The string may optionally start with the `0x` prefix which will be 59 | /// discarded before decoding and signing the remainder of the string. 60 | Hex { hex_string: String }, 61 | } 62 | 63 | pub fn cli(ctx: &crate::CliContext, sign: Sign) -> Result<()> { 64 | let Sign { 65 | account, 66 | private_key, 67 | private_key_non_interactive, 68 | password_non_interactive, 69 | data, 70 | } = sign; 71 | match ( 72 | account, 73 | password_non_interactive, 74 | private_key, 75 | private_key_non_interactive, 76 | ) { 77 | // Provided an account index, so we'll request the password. 78 | (Some(acc_ix), None, false, None) => wallet_account_cli(ctx, acc_ix, data)?, 79 | // Provided the password as a flag, so no need for interactive step. 80 | (Some(acc_ix), Some(pw), false, None) => { 81 | let msg = msg_from_data(data)?; 82 | let sig = sign_msg_with_wallet_account(&ctx.wallet_path, acc_ix, &msg, &pw)?; 83 | println!("Signature: {sig}"); 84 | } 85 | // Provided the private key to sign with directly. 86 | (None, None, _, Some(priv_key)) => { 87 | let msg = msg_from_data(data)?; 88 | let sig = Signature::sign(&priv_key, &msg); 89 | println!("Signature: {sig}"); 90 | } 91 | // Sign with a private key interactively. 92 | (None, None, true, None) => private_key_cli(data)?, 93 | // TODO: If the user provides neither account or private flags, ask in interactive mode? 94 | _ => bail!( 95 | "Unexpected set of options passed to `forc wallet sign`.\n \ 96 | To sign with a wallet account, use `forc wallet sign --account `\n \ 97 | To sign with a private key, use `forc wallet sign --private `", 98 | ), 99 | } 100 | Ok(()) 101 | } 102 | 103 | pub(crate) fn wallet_account_cli( 104 | ctx: &crate::CliContext, 105 | account_ix: usize, 106 | data: Data, 107 | ) -> Result<()> { 108 | let msg = msg_from_data(data)?; 109 | sign_msg_with_wallet_account_cli(&ctx.wallet_path, account_ix, &msg) 110 | } 111 | 112 | pub(crate) fn private_key_cli(data: Data) -> Result<()> { 113 | sign_msg_with_private_key_cli(&msg_from_data(data)?) 114 | } 115 | 116 | fn sign_msg_with_private_key_cli(msg: &Message) -> Result<()> { 117 | let secret_key_input = prompt_password("Please enter the private key you wish to sign with: ")?; 118 | let signature = sign_with_private_key_str(msg, &secret_key_input)?; 119 | println!("Signature: {signature}"); 120 | Ok(()) 121 | } 122 | 123 | fn sign_with_private_key_str(msg: &Message, priv_key_input: &str) -> Result { 124 | let secret_key = SecretKey::from_str(priv_key_input)?; 125 | Ok(Signature::sign(&secret_key, msg)) 126 | } 127 | 128 | fn sign_msg_with_wallet_account_cli( 129 | wallet_path: &Path, 130 | account_ix: usize, 131 | msg: &Message, 132 | ) -> Result<()> { 133 | let password = prompt_password("Please enter your wallet password: ")?; 134 | let signature = sign_msg_with_wallet_account(wallet_path, account_ix, msg, &password)?; 135 | println!("Signature: {signature}"); 136 | Ok(()) 137 | } 138 | 139 | fn sign_msg_with_wallet_account( 140 | wallet_path: &Path, 141 | account_ix: usize, 142 | msg: &Message, 143 | pw: &str, 144 | ) -> Result { 145 | let secret_key = account::derive_secret_key(wallet_path, account_ix, pw)?; 146 | Ok(Signature::sign(&secret_key, msg)) 147 | } 148 | 149 | /// Cast the `Bytes32` directly to a message without normalizing it. 150 | /// We don't renormalize as a hash is already a normalized representation. 151 | fn msg_from_hash32(hash: Bytes32) -> Message { 152 | Message::from_bytes(hash.into()) 153 | } 154 | 155 | fn msg_from_file(path: &Path) -> Result { 156 | let bytes = std::fs::read(path).context("failed to read bytes from path")?; 157 | Ok(Message::new(bytes)) 158 | } 159 | 160 | fn msg_from_hex_str(hex_str: &str) -> Result { 161 | let bytes = bytes_from_hex_str(hex_str)?; 162 | Ok(Message::new(bytes)) 163 | } 164 | 165 | fn msg_from_data(data: Data) -> Result { 166 | let msg = match data { 167 | Data::TxId { tx_id } => msg_from_hash32(tx_id), 168 | Data::File { path } => msg_from_file(&path)?, 169 | Data::Hex { hex_string } => msg_from_hex_str(&hex_string)?, 170 | Data::String { string } => Message::new(string), 171 | }; 172 | Ok(msg) 173 | } 174 | 175 | fn bytes_from_hex_str(mut hex_str: &str) -> Result> { 176 | // Check for the prefix. 177 | const PREFIX: &str = "0x"; 178 | if hex_str.starts_with(PREFIX) { 179 | hex_str = &hex_str[PREFIX.len()..]; 180 | } else { 181 | bail!("missing 0x at the beginning of hex string") 182 | } 183 | hex::decode(hex_str).context("failed to decode bytes from hex string") 184 | } 185 | 186 | #[cfg(test)] 187 | mod tests { 188 | use super::*; 189 | use crate::utils::test_utils::{TEST_PASSWORD, with_tmp_dir_and_wallet}; 190 | use fuels::crypto::Message; 191 | 192 | #[test] 193 | fn sign_tx_id() { 194 | with_tmp_dir_and_wallet(|_dir, wallet_path| { 195 | let tx_id = Bytes32::from_str( 196 | "0x6c226b276bd2028c0582229b6396f91801c913973487491b0262c5c7b3cd6e39", 197 | ) 198 | .unwrap(); 199 | let msg = msg_from_hash32(tx_id); 200 | let account_ix = 0; 201 | let sig = 202 | sign_msg_with_wallet_account(wallet_path, account_ix, &msg, TEST_PASSWORD).unwrap(); 203 | assert_eq!( 204 | sig.to_string(), 205 | "bcf4651f072130aaf8925610e1d719b76e25b19b0a86779d3f4294964f1607cc95eb6c58eb37bf0510f618bd284decdf936c48ec6722df5472084e4098d54620" 206 | ); 207 | }); 208 | } 209 | 210 | const TEST_STR: &str = "Blah blah blah"; 211 | const EXPECTED_SIG: &str = "b0b2f29b52d95c1cba47ea7c7edeec6c84a0bd196df489e219f6f388b69d760479b994f4bae2d5f2abef7d5faf7d9f5ee3ea47ada4d15b7a7ee2777dcd7b36bb"; 212 | 213 | #[test] 214 | fn sign_string() { 215 | with_tmp_dir_and_wallet(|_dir, wallet_path| { 216 | let msg = Message::new(TEST_STR); 217 | let account_ix = 0; 218 | let sig = 219 | sign_msg_with_wallet_account(wallet_path, account_ix, &msg, TEST_PASSWORD).unwrap(); 220 | assert_eq!(sig.to_string(), EXPECTED_SIG); 221 | }); 222 | } 223 | 224 | #[test] 225 | fn sign_file() { 226 | with_tmp_dir_and_wallet(|dir, wallet_path| { 227 | let path = dir.join("data"); 228 | std::fs::write(&path, TEST_STR).unwrap(); 229 | let msg = msg_from_file(&path).unwrap(); 230 | let account_ix = 0; 231 | let sig = 232 | sign_msg_with_wallet_account(wallet_path, account_ix, &msg, TEST_PASSWORD).unwrap(); 233 | assert_eq!(sig.to_string(), EXPECTED_SIG); 234 | }); 235 | } 236 | 237 | #[test] 238 | fn sign_hex() { 239 | with_tmp_dir_and_wallet(|_dir, wallet_path| { 240 | let hex_encoded = format!("0x{}", hex::encode(TEST_STR)); 241 | let msg = msg_from_hex_str(&hex_encoded).unwrap(); 242 | let account_ix = 0; 243 | let sig = 244 | sign_msg_with_wallet_account(wallet_path, account_ix, &msg, TEST_PASSWORD).unwrap(); 245 | assert_eq!(sig.to_string(), EXPECTED_SIG); 246 | }); 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Ok, Result, anyhow, bail}; 2 | use eth_keystore::EthKeystore; 3 | use forc_tracing::println_warning; 4 | use home::home_dir; 5 | use std::{ 6 | fs, 7 | io::{BufRead, Read, Write}, 8 | path::{Path, PathBuf}, 9 | }; 10 | 11 | pub const DEFAULT_DERIVATION_PATH_PREFIX: &str = "m/44'/1179993420'"; 12 | 13 | /// The user's fuel directory (stores state related to fuel-core, wallet, etc). 14 | pub fn user_fuel_dir() -> PathBuf { 15 | const USER_FUEL_DIR: &str = ".fuel"; 16 | let home_dir = home_dir().expect("failed to retrieve user home directory"); 17 | home_dir.join(USER_FUEL_DIR) 18 | } 19 | 20 | /// The directory under which `forc wallet` generates wallets. 21 | pub fn user_fuel_wallets_dir() -> PathBuf { 22 | const WALLETS_DIR: &str = "wallets"; 23 | user_fuel_dir().join(WALLETS_DIR) 24 | } 25 | 26 | /// The directory used to cache wallet account addresses. 27 | pub fn user_fuel_wallets_accounts_dir() -> PathBuf { 28 | const ACCOUNTS_DIR: &str = "accounts"; 29 | user_fuel_wallets_dir().join(ACCOUNTS_DIR) 30 | } 31 | 32 | /// Returns default wallet path which is `$HOME/.fuel/wallets/.wallet`. 33 | pub fn default_wallet_path() -> PathBuf { 34 | const DEFAULT_WALLET_FILE_NAME: &str = ".wallet"; 35 | user_fuel_wallets_dir().join(DEFAULT_WALLET_FILE_NAME) 36 | } 37 | 38 | /// Load a wallet from the given path. 39 | pub fn load_wallet(wallet_path: &Path) -> Result { 40 | let file = fs::File::open(wallet_path).map_err(|e| { 41 | anyhow!( 42 | "Failed to load a wallet from {wallet_path:?}: {e}.\n\ 43 | Please be sure to initialize a wallet before creating an account.\n\ 44 | To initialize a wallet, use `forc-wallet new`" 45 | ) 46 | })?; 47 | let reader = std::io::BufReader::new(file); 48 | serde_json::from_reader(reader).map_err(|e| { 49 | anyhow!( 50 | "Failed to deserialize keystore from {wallet_path:?}: {e}.\n\ 51 | Please ensure that {wallet_path:?} is a valid wallet file." 52 | ) 53 | }) 54 | } 55 | 56 | pub(crate) fn wait_for_keypress() { 57 | let mut single_key = [0u8]; 58 | std::io::stdin().read_exact(&mut single_key).unwrap(); 59 | } 60 | 61 | /// Returns the derivation path with account index using the default derivation path from SDK 62 | pub(crate) fn get_derivation_path(account_index: usize) -> String { 63 | format!("{DEFAULT_DERIVATION_PATH_PREFIX}/{account_index}'/0/0") 64 | } 65 | 66 | pub(crate) fn request_new_password() -> String { 67 | let password = 68 | rpassword::prompt_password("Please enter a password to encrypt this private key: ") 69 | .unwrap(); 70 | 71 | let confirmation = rpassword::prompt_password("Please confirm your password: ").unwrap(); 72 | 73 | if password != confirmation { 74 | println_warning("Passwords do not match -- try again!"); 75 | std::process::exit(1); 76 | } 77 | password 78 | } 79 | 80 | /// Print a string to an alternate screen, so the string isn't printed to the terminal. 81 | pub(crate) fn display_string_discreetly( 82 | discreet_string: &str, 83 | continue_message: &str, 84 | ) -> Result<()> { 85 | use termion::screen::IntoAlternateScreen; 86 | let mut screen = std::io::stdout().into_alternate_screen()?; 87 | writeln!(screen, "{discreet_string}")?; 88 | screen.flush()?; 89 | println!("{continue_message}"); 90 | wait_for_keypress(); 91 | Ok(()) 92 | } 93 | 94 | /// Encrypts the given mnemonic with the given password and writes it to a file at the given path. 95 | /// 96 | /// Ensures that the parent dir exists, but that we're not directly overwriting an existing file. 97 | /// 98 | /// The resulting wallet file will be a keystore as per the [Web3 Secret Storage Definition][1]. 99 | /// [1]: https://ethereum.org/en/developers/docs/data-structures-and-encoding/web3-secret-storage. 100 | pub(crate) fn write_wallet_from_mnemonic_and_password( 101 | wallet_path: &Path, 102 | mnemonic: &str, 103 | password: &str, 104 | ) -> Result<()> { 105 | // Ensure we're not overwriting an existing wallet or other file. 106 | // The wallet should have been removed in `ensure_no_wallet_exists`, but we check again to be safe. 107 | if wallet_path.exists() { 108 | bail!( 109 | "File or directory already exists at {wallet_path:?}. \ 110 | Remove the existing file, or provide a different path." 111 | ); 112 | } 113 | 114 | // Ensure the parent directory exists. 115 | let wallet_dir = wallet_path 116 | .parent() 117 | .ok_or_else(|| anyhow!("failed to retrieve parent directory of {wallet_path:?}"))?; 118 | std::fs::create_dir_all(wallet_dir)?; 119 | 120 | // Retrieve the wallet file name. 121 | let wallet_file_name = wallet_path 122 | .file_name() 123 | .and_then(|os_str| os_str.to_str()) 124 | .ok_or_else(|| anyhow!("failed to retrieve file name from {wallet_path:?}"))?; 125 | 126 | // Encrypt and write the wallet file. 127 | eth_keystore::encrypt_key( 128 | wallet_dir, 129 | &mut rand::thread_rng(), 130 | mnemonic, 131 | password, 132 | Some(wallet_file_name), 133 | ) 134 | .with_context(|| format!("failed to create keystore at {wallet_path:?}")) 135 | .map(|_| ()) 136 | } 137 | 138 | /// Ensures there is no wallet at the given [Path], removing an existing wallet if the user has 139 | /// provided the `--force` option or chooses to remove it in the CLI interaction. 140 | /// Returns [Err] if there is an existing wallet and the user chooses not to remove it. 141 | pub(crate) fn ensure_no_wallet_exists( 142 | wallet_path: &Path, 143 | force: bool, 144 | mut reader: impl BufRead, 145 | ) -> Result<()> { 146 | let remove_wallet = || { 147 | if wallet_path.is_dir() { 148 | fs::remove_dir_all(wallet_path).unwrap(); 149 | } else { 150 | fs::remove_file(wallet_path).unwrap(); 151 | } 152 | }; 153 | 154 | if wallet_path.exists() && fs::metadata(wallet_path)?.len() > 0 { 155 | if force { 156 | println_warning(&format!( 157 | "Because the `--force` argument was supplied, the wallet at {} will be removed.", 158 | wallet_path.display(), 159 | )); 160 | remove_wallet(); 161 | } else { 162 | println_warning(&format!( 163 | "There is an existing wallet at {}. \ 164 | Do you wish to replace it with a new wallet? (y/N) ", 165 | wallet_path.display(), 166 | )); 167 | let mut need_replace = String::new(); 168 | reader.read_line(&mut need_replace).unwrap(); 169 | if need_replace.trim() == "y" { 170 | remove_wallet(); 171 | } else { 172 | bail!( 173 | "Failed to create a new wallet at {} \ 174 | because a wallet already exists at that location.", 175 | wallet_path.display(), 176 | ); 177 | } 178 | } 179 | } 180 | Ok(()) 181 | } 182 | 183 | #[cfg(test)] 184 | mod tests { 185 | use super::*; 186 | use crate::utils::test_utils::{TEST_MNEMONIC, TEST_PASSWORD}; 187 | // simulate input 188 | const INPUT_NOP: &[u8; 1] = b"\n"; 189 | const INPUT_YES: &[u8; 2] = b"y\n"; 190 | const INPUT_NO: &[u8; 2] = b"n\n"; 191 | 192 | /// Represents the possible serialized states of a wallet. 193 | /// Used primarily for simulating wallet creation and serialization processes. 194 | enum WalletSerializedState { 195 | Empty, 196 | WithData(String), 197 | } 198 | 199 | /// Simulates the serialization of a wallet to a file, optionally including dummy data. 200 | /// Primarily used to test if checks for wallet file existence are functioning correctly. 201 | fn serialize_wallet_to_file(wallet_path: &Path, state: WalletSerializedState) { 202 | // Create the wallet file if it does not exist. 203 | if !wallet_path.exists() { 204 | fs::File::create(wallet_path).unwrap(); 205 | } 206 | 207 | // Write content to the wallet file based on the specified state. 208 | if let WalletSerializedState::WithData(data) = state { 209 | fs::write(wallet_path, data).unwrap(); 210 | } 211 | } 212 | 213 | fn remove_wallet(wallet_path: &Path) { 214 | if wallet_path.exists() { 215 | fs::remove_file(wallet_path).unwrap(); 216 | } 217 | } 218 | 219 | #[test] 220 | fn handle_absolute_path_argument() { 221 | let tmp_dir = tempfile::TempDir::new().unwrap(); 222 | let tmp_dir_abs = tmp_dir.path().canonicalize().unwrap(); 223 | let wallet_path = tmp_dir_abs.join("wallet.json"); 224 | write_wallet_from_mnemonic_and_password(&wallet_path, TEST_MNEMONIC, TEST_PASSWORD) 225 | .unwrap(); 226 | load_wallet(&wallet_path).unwrap(); 227 | } 228 | 229 | #[test] 230 | fn handle_relative_path_argument() { 231 | let wallet_path = Path::new("test-wallet.json"); 232 | let panic = std::panic::catch_unwind(|| { 233 | write_wallet_from_mnemonic_and_password(wallet_path, TEST_MNEMONIC, TEST_PASSWORD) 234 | .unwrap(); 235 | load_wallet(wallet_path).unwrap(); 236 | }); 237 | let _ = std::fs::remove_file(wallet_path); 238 | if let Err(e) = panic { 239 | std::panic::resume_unwind(e); 240 | } 241 | } 242 | 243 | #[test] 244 | fn derivation_path() { 245 | let derivation_path = get_derivation_path(0); 246 | assert_eq!(derivation_path, "m/44'/1179993420'/0'/0/0"); 247 | } 248 | #[test] 249 | fn encrypt_and_save_phrase() { 250 | let tmp_dir = tempfile::TempDir::new().unwrap(); 251 | let wallet_path = tmp_dir.path().join("wallet.json"); 252 | write_wallet_from_mnemonic_and_password(&wallet_path, TEST_MNEMONIC, TEST_PASSWORD) 253 | .unwrap(); 254 | let phrase_recovered = eth_keystore::decrypt_key(wallet_path, TEST_PASSWORD).unwrap(); 255 | let phrase = String::from_utf8(phrase_recovered).unwrap(); 256 | assert_eq!(phrase, TEST_MNEMONIC) 257 | } 258 | 259 | #[test] 260 | fn write_wallet() { 261 | let tmp_dir = tempfile::TempDir::new().unwrap(); 262 | let wallet_path = tmp_dir.path().join("wallet.json"); 263 | write_wallet_from_mnemonic_and_password(&wallet_path, TEST_MNEMONIC, TEST_PASSWORD) 264 | .unwrap(); 265 | load_wallet(&wallet_path).unwrap(); 266 | } 267 | 268 | #[test] 269 | #[should_panic] 270 | fn write_wallet_to_existing_file_should_fail() { 271 | let tmp_dir = tempfile::TempDir::new().unwrap(); 272 | let wallet_path = tmp_dir.path().join("wallet.json"); 273 | write_wallet_from_mnemonic_and_password(&wallet_path, TEST_MNEMONIC, TEST_PASSWORD) 274 | .unwrap(); 275 | write_wallet_from_mnemonic_and_password(&wallet_path, TEST_MNEMONIC, TEST_PASSWORD) 276 | .unwrap(); 277 | } 278 | 279 | #[test] 280 | fn write_wallet_subdir() { 281 | let tmp_dir = tempfile::TempDir::new().unwrap(); 282 | let wallet_path = tmp_dir.path().join("path").join("to").join("wallet.json"); 283 | write_wallet_from_mnemonic_and_password(&wallet_path, TEST_MNEMONIC, TEST_PASSWORD) 284 | .unwrap(); 285 | load_wallet(&wallet_path).unwrap(); 286 | } 287 | 288 | #[test] 289 | fn test_ensure_no_wallet_exists_no_wallet() { 290 | let tmp_dir = tempfile::TempDir::new().unwrap(); 291 | let wallet_path = tmp_dir.path().join("wallet.json"); 292 | remove_wallet(&wallet_path); 293 | ensure_no_wallet_exists(&wallet_path, false, &INPUT_NOP[..]).unwrap(); 294 | } 295 | 296 | #[test] 297 | fn test_ensure_no_wallet_exists_exists_wallet() { 298 | // case: wallet path exist without --force and input[yes] 299 | let tmp_dir = tempfile::TempDir::new().unwrap(); 300 | let wallet_path = tmp_dir.path().join("wallet.json"); 301 | serialize_wallet_to_file(&wallet_path, WalletSerializedState::Empty); 302 | ensure_no_wallet_exists(&wallet_path, false, &INPUT_YES[..]).unwrap(); 303 | 304 | // case: wallet path exist with --force 305 | let tmp_dir = tempfile::TempDir::new().unwrap(); 306 | let wallet_path = tmp_dir.path().join("empty_wallet.json"); 307 | serialize_wallet_to_file(&wallet_path, WalletSerializedState::Empty); 308 | 309 | // Empty file should not trigger the replacement prompt 310 | ensure_no_wallet_exists(&wallet_path, false, &INPUT_YES[..]).unwrap(); 311 | assert!(wallet_path.exists(), "Empty file should remain untouched"); 312 | } 313 | 314 | #[test] 315 | fn test_ensure_no_wallet_exists_nonempty_file() { 316 | let tmp_dir = tempfile::TempDir::new().unwrap(); 317 | let wallet_path = tmp_dir.path().join("nonempty_wallet.json"); 318 | 319 | // Create non-empty file 320 | serialize_wallet_to_file( 321 | &wallet_path, 322 | WalletSerializedState::WithData("some wallet content".to_string()), 323 | ); 324 | 325 | // Test with --force flag 326 | ensure_no_wallet_exists(&wallet_path, true, &INPUT_NO[..]).unwrap(); 327 | assert!( 328 | !wallet_path.exists(), 329 | "File should be removed with --force flag" 330 | ); 331 | 332 | // Test with user confirmation (yes) 333 | serialize_wallet_to_file( 334 | &wallet_path, 335 | WalletSerializedState::WithData("some wallet content".to_string()), 336 | ); 337 | ensure_no_wallet_exists(&wallet_path, false, &INPUT_YES[..]).unwrap(); 338 | assert!( 339 | !wallet_path.exists(), 340 | "File should be removed after user confirmation" 341 | ); 342 | 343 | // Test with user rejection (no) 344 | serialize_wallet_to_file( 345 | &wallet_path, 346 | WalletSerializedState::WithData("some wallet content".to_string()), 347 | ); 348 | let result = ensure_no_wallet_exists(&wallet_path, false, &INPUT_NO[..]); 349 | assert!( 350 | result.is_err(), 351 | "Should error when user rejects file removal" 352 | ); 353 | assert!( 354 | wallet_path.exists(), 355 | "File should remain when user rejects removal" 356 | ); 357 | } 358 | } 359 | 360 | #[cfg(test)] 361 | pub(crate) mod test_utils { 362 | use fuels::accounts::provider::Provider; 363 | use serde_json::json; 364 | use wiremock::{ 365 | Mock, MockServer, ResponseTemplate, 366 | matchers::{method, path}, 367 | }; 368 | 369 | use super::*; 370 | use std::{panic, path::Path}; 371 | 372 | pub(crate) const TEST_MNEMONIC: &str = "rapid mechanic escape victory bacon switch soda math embrace frozen novel document wait motor thrive ski addict ripple bid magnet horse merge brisk exile"; 373 | pub(crate) const TEST_PASSWORD: &str = "1234"; 374 | 375 | /// Creates temp dir with a temp/test wallet. 376 | pub(crate) fn with_tmp_dir_and_wallet(f: F) 377 | where 378 | F: FnOnce(&Path, &Path) + panic::UnwindSafe, 379 | { 380 | let tmp_dir = tempfile::TempDir::new().unwrap(); 381 | let wallet_path = tmp_dir.path().join("wallet.json"); 382 | write_wallet_from_mnemonic_and_password(&wallet_path, TEST_MNEMONIC, TEST_PASSWORD) 383 | .unwrap(); 384 | f(tmp_dir.path(), &wallet_path); 385 | } 386 | 387 | /// Returns a mock provider with a mock fuel-core server that responds to the nodeInfo graphql query. 388 | /// Note: the raw JSON response will need to be updated if the schema changes. 389 | pub(crate) async fn mock_provider() -> Provider { 390 | let mock_server = MockServer::start().await; 391 | 392 | // Since [fuel_core_client::client::types::NodeInfo] does not implement [serde::Serialize], 393 | // we use raw JSON for the response. 394 | // If you get an error like "Error making HTTP request: error decoding response body", there has 395 | // likely been a change to the schema and the raw JSON response will need to be updated to match 396 | // the new schema. 397 | let node_info_res_body = json!({ 398 | "data": { 399 | "nodeInfo": { 400 | "utxoValidation": true, 401 | "vmBacktrace": false, 402 | "maxTx": "160000", 403 | "maxGas": "30000000000", 404 | "maxSize": "131072000", 405 | "maxDepth": "32", 406 | "nodeVersion": "0.41.9", 407 | "indexation": { 408 | "balances": false, 409 | "coinsToSpend": false, 410 | "assetMetadata": false 411 | }, 412 | "txPoolStats": { 413 | "txCount": "0", 414 | "totalGas": "0", 415 | "totalSize": "0" 416 | } 417 | } 418 | } 419 | }); 420 | 421 | let node_info_response = ResponseTemplate::new(200).set_body_json(node_info_res_body); 422 | 423 | Mock::given(method("POST")) 424 | .and(path("/v1/graphql")) 425 | .respond_with(node_info_response) 426 | .mount(&mock_server) 427 | .await; 428 | 429 | Provider::connect(mock_server.uri()) 430 | .await 431 | .expect("mock provider") 432 | } 433 | } 434 | -------------------------------------------------------------------------------- /src/account.rs: -------------------------------------------------------------------------------- 1 | use crate::format::Table; 2 | use crate::sign; 3 | use crate::utils::{ 4 | display_string_discreetly, get_derivation_path, load_wallet, user_fuel_wallets_accounts_dir, 5 | }; 6 | use anyhow::{Context, Result, anyhow, bail}; 7 | use clap::{Args, Subcommand}; 8 | use eth_keystore::EthKeystore; 9 | use forc_tracing::println_warning; 10 | use fuels::accounts::ViewOnlyAccount; 11 | use fuels::accounts::provider::Provider; 12 | use fuels::accounts::signers::private_key::PrivateKeySigner; 13 | use fuels::accounts::wallet::{Unlocked, Wallet}; 14 | use fuels::crypto::{PublicKey, SecretKey}; 15 | use fuels::types::checksum_address::checksum_encode; 16 | use fuels::types::transaction::TxPolicies; 17 | use fuels::types::{Address, AssetId}; 18 | use std::ops::Range; 19 | use std::{ 20 | collections::BTreeMap, 21 | fs, 22 | path::{Path, PathBuf}, 23 | }; 24 | use url::Url; 25 | 26 | type WalletUnlocked = Wallet>; 27 | 28 | #[derive(Debug, Args)] 29 | pub struct Accounts { 30 | #[clap(flatten)] 31 | unverified: UnverifiedOpt, 32 | } 33 | 34 | #[derive(Debug, Args)] 35 | pub 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, 40 | #[clap(flatten)] 41 | unverified: UnverifiedOpt, 42 | #[clap(subcommand)] 43 | cmd: Option, 44 | } 45 | 46 | #[derive(Debug, Args)] 47 | pub(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)] 56 | pub(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 ` 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)] 84 | pub(crate) struct Balance { 85 | #[clap(flatten)] 86 | pub(crate) unverified: UnverifiedOpt, 87 | } 88 | 89 | #[derive(Debug, Args)] 90 | pub(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, 102 | #[clap(long)] 103 | gas_limit: Option, 104 | #[clap(long)] 105 | maturity: Option, 106 | } 107 | 108 | #[derive(Debug, Args)] 109 | pub(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. 120 | type AccountAddresses = BTreeMap; 121 | 122 | pub 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 | 148 | pub(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. 186 | pub(crate) fn verify_address_and_update_cache( 187 | acc_ix: usize, 188 | account: &Wallet, 189 | expected_addr: &Address, 190 | wallet_ciphertext: &[u8], 191 | ) -> Result { 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 | 204 | pub(crate) fn print_balance_empty(node_url: &Url) { 205 | let testnet_url = crate::network::TESTNET.parse::().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 | 224 | pub(crate) fn print_balance(balance: &BTreeMap) { 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. 238 | pub 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 | 260 | fn 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 | 274 | fn 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 {cmd_str} ...`\n" 286 | ); 287 | print_subcmd_help(); 288 | } 289 | 290 | /// Print the address of the wallet's account at the given index. 291 | pub 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. 318 | pub fn derive_secret_key( 319 | wallet_path: &Path, 320 | account_index: usize, 321 | password: &str, 322 | ) -> Result { 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 | 330 | fn 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. 335 | pub(crate) fn derive_account_unlocked( 336 | wallet_path: &Path, 337 | account_ix: usize, 338 | password: &str, 339 | provider: &Provider, 340 | ) -> Result> { 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 | 346 | pub async fn derive_and_cache_addresses( 347 | ctx: &crate::CliContext, 348 | mnemonic: &str, 349 | range: Range, 350 | ) -> anyhow::Result> { 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::, _>>() 364 | .map(|x| x.into_iter().enumerate().collect()) 365 | } 366 | 367 | fn new_at_index( 368 | keystore: &EthKeystore, 369 | wallet_path: &Path, 370 | account_ix: usize, 371 | provider: &Provider, 372 | ) -> Result { 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 | 383 | pub 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 | 390 | pub(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 | 399 | pub(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. 411 | pub(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 422 | pub(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. 436 | pub(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::().unwrap().host_str() => { 477 | crate::explorer::DEFAULT 478 | } 479 | host if host == crate::network::TESTNET.parse::().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. 495 | fn 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. 504 | fn 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. 509 | fn 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. 514 | pub 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. 532 | pub(crate) fn read_cached_addresses(wallet_ciphertext: &[u8]) -> Result { 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)] 560 | mod 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 | } 613 | --------------------------------------------------------------------------------