forc_wallet/
utils.rs

1use anyhow::{Context, Ok, Result, anyhow, bail};
2use eth_keystore::EthKeystore;
3use forc_tracing::println_warning;
4use home::home_dir;
5use std::{
6    fs,
7    io::{BufRead, Read, Write},
8    path::{Path, PathBuf},
9};
10
11pub const DEFAULT_DERIVATION_PATH_PREFIX: &str = "m/44'/1179993420'";
12
13/// The user's fuel directory (stores state related to fuel-core, wallet, etc).
14pub 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.
21pub 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.
27pub 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`.
33pub 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.
39pub fn load_wallet(wallet_path: &Path) -> Result<EthKeystore> {
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
56pub(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
62pub(crate) fn get_derivation_path(account_index: usize) -> String {
63    format!("{DEFAULT_DERIVATION_PATH_PREFIX}/{account_index}'/0/0")
64}
65
66pub(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.
81pub(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.
100pub(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.
141pub(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)]
184mod 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)]
361pub(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: 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}