cast/cmd/wallet/
mod.rs

1use alloy_chains::Chain;
2use alloy_dyn_abi::TypedData;
3use alloy_primitives::{hex, Address, Signature, B256, U256};
4use alloy_provider::Provider;
5use alloy_rpc_types::Authorization;
6use alloy_signer::{
7    k256::{elliptic_curve::sec1::ToEncodedPoint, SecretKey},
8    Signer,
9};
10use alloy_signer_local::{
11    coins_bip39::{English, Entropy, Mnemonic},
12    MnemonicBuilder, PrivateKeySigner,
13};
14use clap::Parser;
15use eyre::{Context, Result};
16use foundry_cli::{opts::RpcOpts, utils, utils::LoadConfig};
17use foundry_common::{fs, sh_println, shell};
18use foundry_config::Config;
19use foundry_wallets::{RawWalletOpts, WalletOpts, WalletSigner};
20use rand_08::thread_rng;
21use serde_json::json;
22use std::path::Path;
23use yansi::Paint;
24
25pub mod vanity;
26use vanity::VanityArgs;
27
28pub mod list;
29use list::ListArgs;
30
31/// CLI arguments for `cast wallet`.
32#[derive(Debug, Parser)]
33pub enum WalletSubcommands {
34    /// Create a new random keypair.
35    #[command(visible_alias = "n")]
36    New {
37        /// If provided, then keypair will be written to an encrypted JSON keystore.
38        path: Option<String>,
39
40        /// Account name for the keystore file. If provided, the keystore file
41        /// will be named using this account name.
42        #[arg(value_name = "ACCOUNT_NAME")]
43        account_name: Option<String>,
44
45        /// Triggers a hidden password prompt for the JSON keystore.
46        ///
47        /// Deprecated: prompting for a hidden password is now the default.
48        #[arg(long, short, requires = "path", conflicts_with = "unsafe_password")]
49        password: bool,
50
51        /// Password for the JSON keystore in cleartext.
52        ///
53        /// This is UNSAFE to use and we recommend using the --password.
54        #[arg(long, requires = "path", env = "CAST_PASSWORD", value_name = "PASSWORD")]
55        unsafe_password: Option<String>,
56
57        /// Number of wallets to generate.
58        #[arg(long, short, default_value = "1")]
59        number: u32,
60    },
61
62    /// Generates a random BIP39 mnemonic phrase
63    #[command(visible_alias = "nm")]
64    NewMnemonic {
65        /// Number of words for the mnemonic
66        #[arg(long, short, default_value = "12")]
67        words: usize,
68
69        /// Number of accounts to display
70        #[arg(long, short, default_value = "1")]
71        accounts: u8,
72
73        /// Entropy to use for the mnemonic
74        #[arg(long, short, conflicts_with = "words")]
75        entropy: Option<String>,
76    },
77
78    /// Generate a vanity address.
79    #[command(visible_alias = "va")]
80    Vanity(VanityArgs),
81
82    /// Convert a private key to an address.
83    #[command(visible_aliases = &["a", "addr"])]
84    Address {
85        /// If provided, the address will be derived from the specified private key.
86        #[arg(value_name = "PRIVATE_KEY")]
87        private_key_override: Option<String>,
88
89        #[command(flatten)]
90        wallet: WalletOpts,
91    },
92
93    /// Sign a message or typed data.
94    #[command(visible_alias = "s")]
95    Sign {
96        /// The message, typed data, or hash to sign.
97        ///
98        /// Messages starting with 0x are expected to be hex encoded, which get decoded before
99        /// being signed.
100        ///
101        /// The message will be prefixed with the Ethereum Signed Message header and hashed before
102        /// signing, unless `--no-hash` is provided.
103        ///
104        /// Typed data can be provided as a json string or a file name.
105        /// Use --data flag to denote the message is a string of typed data.
106        /// Use --data --from-file to denote the message is a file name containing typed data.
107        /// The data will be combined and hashed using the EIP712 specification before signing.
108        /// The data should be formatted as JSON.
109        message: String,
110
111        /// Treat the message as JSON typed data.
112        #[arg(long)]
113        data: bool,
114
115        /// Treat the message as a file containing JSON typed data. Requires `--data`.
116        #[arg(long, requires = "data")]
117        from_file: bool,
118
119        /// Treat the message as a raw 32-byte hash and sign it directly without hashing it again.
120        #[arg(long, conflicts_with = "data")]
121        no_hash: bool,
122
123        #[command(flatten)]
124        wallet: WalletOpts,
125    },
126
127    /// EIP-7702 sign authorization.
128    #[command(visible_alias = "sa")]
129    SignAuth {
130        /// Address to sign authorization for.
131        address: Address,
132
133        #[command(flatten)]
134        rpc: RpcOpts,
135
136        #[arg(long)]
137        nonce: Option<u64>,
138
139        #[arg(long)]
140        chain: Option<Chain>,
141
142        #[command(flatten)]
143        wallet: WalletOpts,
144    },
145
146    /// Verify the signature of a message.
147    #[command(visible_alias = "v")]
148    Verify {
149        /// The original message.
150        ///
151        /// Treats 0x-prefixed strings as hex encoded bytes.
152        /// Non 0x-prefixed strings are treated as raw input message.
153        message: String,
154
155        /// The signature to verify.
156        signature: Signature,
157
158        /// The address of the message signer.
159        #[arg(long, short)]
160        address: Address,
161    },
162
163    /// Import a private key into an encrypted keystore.
164    #[command(visible_alias = "i")]
165    Import {
166        /// The name for the account in the keystore.
167        #[arg(value_name = "ACCOUNT_NAME")]
168        account_name: String,
169        /// If provided, keystore will be saved here instead of the default keystores directory
170        /// (~/.foundry/keystores)
171        #[arg(long, short)]
172        keystore_dir: Option<String>,
173        /// Password for the JSON keystore in cleartext
174        /// This is unsafe, we recommend using the default hidden password prompt
175        #[arg(long, env = "CAST_UNSAFE_PASSWORD", value_name = "PASSWORD")]
176        unsafe_password: Option<String>,
177        #[command(flatten)]
178        raw_wallet_options: RawWalletOpts,
179    },
180
181    /// List all the accounts in the keystore default directory
182    #[command(visible_alias = "ls")]
183    List(ListArgs),
184
185    /// Remove a wallet from the keystore.
186    ///
187    /// This command requires the wallet alias and will prompt for a password to ensure that only
188    /// an authorized user can remove the wallet.
189    #[command(visible_aliases = &["rm"], override_usage = "cast wallet remove --name <NAME>")]
190    Remove {
191        /// The alias (or name) of the wallet to remove.
192        #[arg(long, required = true)]
193        name: String,
194        /// Optionally provide the keystore directory if not provided. default directory will be
195        /// used (~/.foundry/keystores).
196        #[arg(long)]
197        dir: Option<String>,
198        /// Password for the JSON keystore in cleartext
199        /// This is unsafe, we recommend using the default hidden password prompt
200        #[arg(long, env = "CAST_UNSAFE_PASSWORD", value_name = "PASSWORD")]
201        unsafe_password: Option<String>,
202    },
203
204    /// Derives private key from mnemonic
205    #[command(name = "private-key", visible_alias = "pk", aliases = &["derive-private-key", "--derive-private-key"])]
206    PrivateKey {
207        /// If provided, the private key will be derived from the specified menomonic phrase.
208        #[arg(value_name = "MNEMONIC")]
209        mnemonic_override: Option<String>,
210
211        /// If provided, the private key will be derived using the
212        /// specified mnemonic index (if integer) or derivation path.
213        #[arg(value_name = "MNEMONIC_INDEX_OR_DERIVATION_PATH")]
214        mnemonic_index_or_derivation_path_override: Option<String>,
215
216        #[command(flatten)]
217        wallet: WalletOpts,
218    },
219    /// Get the public key for the given private key.
220    #[command(visible_aliases = &["pubkey"])]
221    PublicKey {
222        /// If provided, the public key will be derived from the specified private key.
223        #[arg(long = "raw-private-key", value_name = "PRIVATE_KEY")]
224        private_key_override: Option<String>,
225
226        #[command(flatten)]
227        wallet: WalletOpts,
228    },
229    /// Decrypt a keystore file to get the private key
230    #[command(name = "decrypt-keystore", visible_alias = "dk")]
231    DecryptKeystore {
232        /// The name for the account in the keystore.
233        #[arg(value_name = "ACCOUNT_NAME")]
234        account_name: String,
235        /// If not provided, keystore will try to be located at the default keystores directory
236        /// (~/.foundry/keystores)
237        #[arg(long, short)]
238        keystore_dir: Option<String>,
239        /// Password for the JSON keystore in cleartext
240        /// This is unsafe, we recommend using the default hidden password prompt
241        #[arg(long, env = "CAST_UNSAFE_PASSWORD", value_name = "PASSWORD")]
242        unsafe_password: Option<String>,
243    },
244
245    /// Change the password of a keystore file
246    #[command(name = "change-password", visible_alias = "cp")]
247    ChangePassword {
248        /// The name for the account in the keystore.
249        #[arg(value_name = "ACCOUNT_NAME")]
250        account_name: String,
251        /// If not provided, keystore will try to be located at the default keystores directory
252        /// (~/.foundry/keystores)
253        #[arg(long, short)]
254        keystore_dir: Option<String>,
255        /// Current password for the JSON keystore in cleartext
256        /// This is unsafe, we recommend using the default hidden password prompt
257        #[arg(long, env = "CAST_UNSAFE_PASSWORD", value_name = "PASSWORD")]
258        unsafe_password: Option<String>,
259        /// New password for the JSON keystore in cleartext
260        /// This is unsafe, we recommend using the default hidden password prompt
261        #[arg(long, env = "CAST_UNSAFE_NEW_PASSWORD", value_name = "NEW_PASSWORD")]
262        unsafe_new_password: Option<String>,
263    },
264}
265
266impl WalletSubcommands {
267    pub async fn run(self) -> Result<()> {
268        match self {
269            Self::New { path, account_name, unsafe_password, number, .. } => {
270                let mut rng = thread_rng();
271
272                let mut json_values = if shell::is_json() { Some(vec![]) } else { None };
273                if let Some(path) = path {
274                    let path = match dunce::canonicalize(path.clone()) {
275                        Ok(path) => path,
276                        // If the path doesn't exist, it will fail to be canonicalized,
277                        // so we attach more context to the error message.
278                        Err(e) => {
279                            eyre::bail!("If you specified a directory, please make sure it exists, or create it before running `cast wallet new <DIR>`.\n{path} is not a directory.\nError: {}", e);
280                        }
281                    };
282                    if !path.is_dir() {
283                        // we require path to be an existing directory
284                        eyre::bail!("`{}` is not a directory", path.display());
285                    }
286
287                    let password = if let Some(password) = unsafe_password {
288                        password
289                    } else {
290                        // if no --unsafe-password was provided read via stdin
291                        rpassword::prompt_password("Enter secret: ")?
292                    };
293
294                    for i in 0..number {
295                        let account_name_ref = account_name.as_deref().map(|name| match number {
296                            1 => name.to_string(),
297                            _ => format!("{}_{}", name, i + 1),
298                        });
299
300                        let (wallet, uuid) = PrivateKeySigner::new_keystore(
301                            &path,
302                            &mut rng,
303                            password.clone(),
304                            account_name_ref.as_deref(),
305                        )?;
306                        let identifier = account_name_ref.as_deref().unwrap_or(&uuid);
307
308                        if let Some(json) = json_values.as_mut() {
309                            json.push(json!({
310                                "address": wallet.address().to_checksum(None),
311                                "path": format!("{}", path.join(identifier).display()),
312                            }));
313                        } else {
314                            sh_println!(
315                                "Created new encrypted keystore file: {}",
316                                path.join(identifier).display()
317                            )?;
318                            sh_println!("Address: {}", wallet.address().to_checksum(None))?;
319                        }
320                    }
321
322                    if let Some(json) = json_values.as_ref() {
323                        sh_println!("{}", serde_json::to_string_pretty(json)?)?;
324                    }
325                } else {
326                    for _ in 0..number {
327                        let wallet = PrivateKeySigner::random_with(&mut rng);
328
329                        if let Some(json) = json_values.as_mut() {
330                            json.push(json!({
331                                "address": wallet.address().to_checksum(None),
332                                "private_key": format!("0x{}", hex::encode(wallet.credential().to_bytes())),
333                            }))
334                        } else {
335                            sh_println!("Successfully created new keypair.")?;
336                            sh_println!("Address:     {}", wallet.address().to_checksum(None))?;
337                            sh_println!(
338                                "Private key: 0x{}",
339                                hex::encode(wallet.credential().to_bytes())
340                            )?;
341                        }
342                    }
343
344                    if let Some(json) = json_values.as_ref() {
345                        sh_println!("{}", serde_json::to_string_pretty(json)?)?;
346                    }
347                }
348            }
349            Self::NewMnemonic { words, accounts, entropy } => {
350                let phrase = if let Some(entropy) = entropy {
351                    let entropy = Entropy::from_slice(hex::decode(entropy)?)?;
352                    Mnemonic::<English>::new_from_entropy(entropy).to_phrase()
353                } else {
354                    let mut rng = thread_rng();
355                    Mnemonic::<English>::new_with_count(&mut rng, words)?.to_phrase()
356                };
357
358                let format_json = shell::is_json();
359
360                if !format_json {
361                    sh_println!("{}", "Generating mnemonic from provided entropy...".yellow())?;
362                }
363
364                let builder = MnemonicBuilder::<English>::default().phrase(phrase.as_str());
365                let derivation_path = "m/44'/60'/0'/0/";
366                let wallets = (0..accounts)
367                    .map(|i| builder.clone().derivation_path(format!("{derivation_path}{i}")))
368                    .collect::<Result<Vec<_>, _>>()?;
369                let wallets =
370                    wallets.into_iter().map(|b| b.build()).collect::<Result<Vec<_>, _>>()?;
371
372                if !format_json {
373                    sh_println!("{}", "Successfully generated a new mnemonic.".green())?;
374                    sh_println!("Phrase:\n{phrase}")?;
375                    sh_println!("\nAccounts:")?;
376                }
377
378                let mut accounts = json!([]);
379                for (i, wallet) in wallets.iter().enumerate() {
380                    let private_key = hex::encode(wallet.credential().to_bytes());
381                    if format_json {
382                        accounts.as_array_mut().unwrap().push(json!({
383                            "address": format!("{}", wallet.address()),
384                            "private_key": format!("0x{}", private_key),
385                        }));
386                    } else {
387                        sh_println!("- Account {i}:")?;
388                        sh_println!("Address:     {}", wallet.address())?;
389                        sh_println!("Private key: 0x{private_key}\n")?;
390                    }
391                }
392
393                if format_json {
394                    let obj = json!({
395                        "mnemonic": phrase,
396                        "accounts": accounts,
397                    });
398                    sh_println!("{}", serde_json::to_string_pretty(&obj)?)?;
399                }
400            }
401            Self::Vanity(cmd) => {
402                cmd.run()?;
403            }
404            Self::Address { wallet, private_key_override } => {
405                let wallet = private_key_override
406                    .map(|pk| WalletOpts {
407                        raw: RawWalletOpts { private_key: Some(pk), ..Default::default() },
408                        ..Default::default()
409                    })
410                    .unwrap_or(wallet)
411                    .signer()
412                    .await?;
413                let addr = wallet.address();
414                sh_println!("{}", addr.to_checksum(None))?;
415            }
416            Self::PublicKey { wallet, private_key_override } => {
417                let wallet = private_key_override
418                    .map(|pk| WalletOpts {
419                        raw: RawWalletOpts { private_key: Some(pk), ..Default::default() },
420                        ..Default::default()
421                    })
422                    .unwrap_or(wallet)
423                    .signer()
424                    .await?;
425
426                let private_key_bytes = match wallet {
427                    WalletSigner::Local(wallet) => wallet.credential().to_bytes(),
428                    _ => eyre::bail!("Only local wallets are supported by this command"),
429                };
430
431                let secret_key = SecretKey::from_slice(&private_key_bytes)
432                    .map_err(|e| eyre::eyre!("Invalid private key: {}", e))?;
433
434                // Get the public key from the private key
435                let public_key = secret_key.public_key();
436
437                // Serialize it as uncompressed (65 bytes: 0x04 || X (32 bytes) || Y (32 bytes))
438                let pubkey_bytes = public_key.to_encoded_point(false);
439                // Strip the 1-byte prefix (0x04) to get 64 bytes for Ethereum use
440                let ethereum_pubkey = &pubkey_bytes.as_bytes()[1..];
441
442                sh_println!("0x{}", hex::encode(ethereum_pubkey))?;
443            }
444            Self::Sign { message, data, from_file, no_hash, wallet } => {
445                let wallet = wallet.signer().await?;
446                let sig = if data {
447                    let typed_data: TypedData = if from_file {
448                        // data is a file name, read json from file
449                        foundry_common::fs::read_json_file(message.as_ref())?
450                    } else {
451                        // data is a json string
452                        serde_json::from_str(&message)?
453                    };
454                    wallet.sign_dynamic_typed_data(&typed_data).await?
455                } else if no_hash {
456                    wallet.sign_hash(&hex::decode(&message)?[..].try_into()?).await?
457                } else {
458                    wallet.sign_message(&Self::hex_str_to_bytes(&message)?).await?
459                };
460
461                if shell::verbosity() > 0 {
462                    if shell::is_json() {
463                        sh_println!(
464                            "{}",
465                            serde_json::to_string_pretty(&json!({
466                                "message": message,
467                                "address": wallet.address(),
468                                "signature": hex::encode(sig.as_bytes()),
469                            }))?
470                        )?;
471                    } else {
472                        sh_println!(
473                            "Successfully signed!\n   Message: {}\n   Address: {}\n   Signature: 0x{}",
474                            message,
475                            wallet.address(),
476                            hex::encode(sig.as_bytes()),
477                        )?;
478                    }
479                } else {
480                    // Pipe friendly output
481                    sh_println!("0x{}", hex::encode(sig.as_bytes()))?;
482                }
483            }
484            Self::SignAuth { rpc, nonce, chain, wallet, address } => {
485                let wallet = wallet.signer().await?;
486                let provider = utils::get_provider(&rpc.load_config()?)?;
487                let nonce = if let Some(nonce) = nonce {
488                    nonce
489                } else {
490                    provider.get_transaction_count(wallet.address()).await?
491                };
492                let chain_id = if let Some(chain) = chain {
493                    chain.id()
494                } else {
495                    provider.get_chain_id().await?
496                };
497                let auth = Authorization { chain_id: U256::from(chain_id), address, nonce };
498                let signature = wallet.sign_hash(&auth.signature_hash()).await?;
499                let auth = auth.into_signed(signature);
500
501                if shell::verbosity() > 0 {
502                    if shell::is_json() {
503                        sh_println!(
504                            "{}",
505                            serde_json::to_string_pretty(&json!({
506                                "nonce": nonce,
507                                "chain_id": chain_id,
508                                "address": wallet.address(),
509                                "signature": hex::encode_prefixed(alloy_rlp::encode(&auth)),
510                            }))?
511                        )?;
512                    } else {
513                        sh_println!(
514                            "Successfully signed!\n   Nonce: {}\n   Chain ID: {}\n   Address: {}\n   Signature: 0x{}",
515                            nonce,
516                            chain_id,
517                            wallet.address(),
518                            hex::encode_prefixed(alloy_rlp::encode(&auth)),
519                        )?;
520                    }
521                } else {
522                    // Pipe friendly output
523                    sh_println!("{}", hex::encode_prefixed(alloy_rlp::encode(&auth)))?;
524                }
525            }
526            Self::Verify { message, signature, address } => {
527                let recovered_address = Self::recover_address_from_message(&message, &signature)?;
528                if address == recovered_address {
529                    sh_println!("Validation succeeded. Address {address} signed this message.")?;
530                } else {
531                    eyre::bail!("Validation failed. Address {address} did not sign this message.");
532                }
533            }
534            Self::Import { account_name, keystore_dir, unsafe_password, raw_wallet_options } => {
535                // Set up keystore directory
536                let dir = if let Some(path) = keystore_dir {
537                    Path::new(&path).to_path_buf()
538                } else {
539                    Config::foundry_keystores_dir().ok_or_else(|| {
540                        eyre::eyre!("Could not find the default keystore directory.")
541                    })?
542                };
543
544                fs::create_dir_all(&dir)?;
545
546                // check if account exists already
547                let keystore_path = Path::new(&dir).join(&account_name);
548                if keystore_path.exists() {
549                    eyre::bail!("Keystore file already exists at {}", keystore_path.display());
550                }
551
552                // get wallet
553                let wallet = raw_wallet_options
554                    .signer()?
555                    .and_then(|s| match s {
556                        WalletSigner::Local(s) => Some(s),
557                        _ => None,
558                    })
559                    .ok_or_else(|| {
560                        eyre::eyre!(
561                            "\
562Did you set a private key or mnemonic?
563Run `cast wallet import --help` and use the corresponding CLI
564flag to set your key via:
565--private-key, --mnemonic-path or --interactive."
566                        )
567                    })?;
568
569                let private_key = wallet.credential().to_bytes();
570                let password = if let Some(password) = unsafe_password {
571                    password
572                } else {
573                    // if no --unsafe-password was provided read via stdin
574                    rpassword::prompt_password("Enter password: ")?
575                };
576
577                let mut rng = thread_rng();
578                let (wallet, _) = PrivateKeySigner::encrypt_keystore(
579                    dir,
580                    &mut rng,
581                    private_key,
582                    password,
583                    Some(&account_name),
584                )?;
585                let address = wallet.address();
586                let success_message = format!(
587                    "`{}` keystore was saved successfully. Address: {:?}",
588                    &account_name, address,
589                );
590                sh_println!("{}", success_message.green())?;
591            }
592            Self::List(cmd) => {
593                cmd.run().await?;
594            }
595            Self::Remove { name, dir, unsafe_password } => {
596                let dir = if let Some(path) = dir {
597                    Path::new(&path).to_path_buf()
598                } else {
599                    Config::foundry_keystores_dir().ok_or_else(|| {
600                        eyre::eyre!("Could not find the default keystore directory.")
601                    })?
602                };
603
604                let keystore_path = Path::new(&dir).join(&name);
605                if !keystore_path.exists() {
606                    eyre::bail!("Keystore file does not exist at {}", keystore_path.display());
607                }
608
609                let password = if let Some(pwd) = unsafe_password {
610                    pwd
611                } else {
612                    rpassword::prompt_password("Enter password: ")?
613                };
614
615                if PrivateKeySigner::decrypt_keystore(&keystore_path, password).is_err() {
616                    eyre::bail!("Invalid password - wallet removal cancelled");
617                }
618
619                std::fs::remove_file(&keystore_path).wrap_err_with(|| {
620                    format!("Failed to remove keystore file at {}", keystore_path.display())
621                })?;
622
623                let success_message = format!("`{}` keystore was removed successfully.", &name);
624                sh_println!("{}", success_message.green())?;
625            }
626            Self::PrivateKey {
627                wallet,
628                mnemonic_override,
629                mnemonic_index_or_derivation_path_override,
630            } => {
631                let (index_override, derivation_path_override) =
632                    match mnemonic_index_or_derivation_path_override {
633                        Some(value) => match value.parse::<u32>() {
634                            Ok(index) => (Some(index), None),
635                            Err(_) => (None, Some(value)),
636                        },
637                        None => (None, None),
638                    };
639                let wallet = WalletOpts {
640                    raw: RawWalletOpts {
641                        mnemonic: mnemonic_override.or(wallet.raw.mnemonic),
642                        mnemonic_index: index_override.unwrap_or(wallet.raw.mnemonic_index),
643                        hd_path: derivation_path_override.or(wallet.raw.hd_path),
644                        ..wallet.raw
645                    },
646                    ..wallet
647                }
648                .signer()
649                .await?;
650                match wallet {
651                    WalletSigner::Local(wallet) => {
652                        if shell::verbosity() > 0 {
653                            sh_println!("Address:     {}", wallet.address())?;
654                            sh_println!(
655                                "Private key: 0x{}",
656                                hex::encode(wallet.credential().to_bytes())
657                            )?;
658                        } else {
659                            sh_println!("0x{}", hex::encode(wallet.credential().to_bytes()))?;
660                        }
661                    }
662                    _ => {
663                        eyre::bail!("Only local wallets are supported by this command.");
664                    }
665                }
666            }
667            Self::DecryptKeystore { account_name, keystore_dir, unsafe_password } => {
668                // Set up keystore directory
669                let dir = if let Some(path) = keystore_dir {
670                    Path::new(&path).to_path_buf()
671                } else {
672                    Config::foundry_keystores_dir().ok_or_else(|| {
673                        eyre::eyre!("Could not find the default keystore directory.")
674                    })?
675                };
676
677                let keypath = dir.join(&account_name);
678
679                if !keypath.exists() {
680                    eyre::bail!("Keystore file does not exist at {}", keypath.display());
681                }
682
683                let password = if let Some(password) = unsafe_password {
684                    password
685                } else {
686                    // if no --unsafe-password was provided read via stdin
687                    rpassword::prompt_password("Enter password: ")?
688                };
689
690                let wallet = PrivateKeySigner::decrypt_keystore(keypath, password)?;
691
692                let private_key = B256::from_slice(&wallet.credential().to_bytes());
693
694                let success_message =
695                    format!("{}'s private key is: {}", &account_name, private_key);
696
697                sh_println!("{}", success_message.green())?;
698            }
699            Self::ChangePassword {
700                account_name,
701                keystore_dir,
702                unsafe_password,
703                unsafe_new_password,
704            } => {
705                // Set up keystore directory
706                let dir = if let Some(path) = keystore_dir {
707                    Path::new(&path).to_path_buf()
708                } else {
709                    Config::foundry_keystores_dir().ok_or_else(|| {
710                        eyre::eyre!("Could not find the default keystore directory.")
711                    })?
712                };
713
714                let keypath = dir.join(&account_name);
715
716                if !keypath.exists() {
717                    eyre::bail!("Keystore file does not exist at {}", keypath.display());
718                }
719
720                let current_password = if let Some(password) = unsafe_password {
721                    password
722                } else {
723                    // if no --unsafe-password was provided read via stdin
724                    rpassword::prompt_password("Enter current password: ")?
725                };
726
727                // decrypt the keystore to verify the current password and get the private key
728                let wallet = PrivateKeySigner::decrypt_keystore(&keypath, current_password.clone())
729                    .map_err(|_| eyre::eyre!("Invalid password - password change cancelled"))?;
730
731                let new_password = if let Some(password) = unsafe_new_password {
732                    password
733                } else {
734                    // if no --unsafe-new-password was provided read via stdin
735                    rpassword::prompt_password("Enter new password: ")?
736                };
737
738                if current_password == new_password {
739                    eyre::bail!("New password cannot be the same as the current password");
740                }
741
742                // Create a new keystore with the new password
743                let private_key = wallet.credential().to_bytes();
744                let mut rng = thread_rng();
745                let (wallet, _) = PrivateKeySigner::encrypt_keystore(
746                    dir,
747                    &mut rng,
748                    private_key,
749                    new_password,
750                    Some(&account_name),
751                )?;
752
753                let success_message = format!(
754                    "Password for keystore `{}` was changed successfully. Address: {:?}",
755                    &account_name,
756                    wallet.address(),
757                );
758                sh_println!("{}", success_message.green())?;
759            }
760        };
761
762        Ok(())
763    }
764
765    /// Recovers an address from the specified message and signature.
766    ///
767    /// Note: This attempts to decode the message as hex if it starts with 0x.
768    fn recover_address_from_message(message: &str, signature: &Signature) -> Result<Address> {
769        let message = Self::hex_str_to_bytes(message)?;
770        Ok(signature.recover_address_from_msg(message)?)
771    }
772
773    /// Strips the 0x prefix from a hex string and decodes it to bytes.
774    ///
775    /// Treats the string as raw bytes if it doesn't start with 0x.
776    fn hex_str_to_bytes(s: &str) -> Result<Vec<u8>> {
777        Ok(match s.strip_prefix("0x") {
778            Some(data) => hex::decode(data).wrap_err("Could not decode 0x-prefixed string.")?,
779            None => s.as_bytes().to_vec(),
780        })
781    }
782}
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787    use alloy_primitives::address;
788    use std::str::FromStr;
789
790    #[test]
791    fn can_parse_wallet_sign_message() {
792        let args = WalletSubcommands::parse_from(["foundry-cli", "sign", "deadbeef"]);
793        match args {
794            WalletSubcommands::Sign { message, data, from_file, .. } => {
795                assert_eq!(message, "deadbeef".to_string());
796                assert!(!data);
797                assert!(!from_file);
798            }
799            _ => panic!("expected WalletSubcommands::Sign"),
800        }
801    }
802
803    #[test]
804    fn can_parse_wallet_sign_hex_message() {
805        let args = WalletSubcommands::parse_from(["foundry-cli", "sign", "0xdeadbeef"]);
806        match args {
807            WalletSubcommands::Sign { message, data, from_file, .. } => {
808                assert_eq!(message, "0xdeadbeef".to_string());
809                assert!(!data);
810                assert!(!from_file);
811            }
812            _ => panic!("expected WalletSubcommands::Sign"),
813        }
814    }
815
816    #[test]
817    fn can_verify_signed_hex_message() {
818        let message = "hello";
819        let signature = Signature::from_str("f2dd00eac33840c04b6fc8a5ec8c4a47eff63575c2bc7312ecb269383de0c668045309c423484c8d097df306e690c653f8e1ec92f7f6f45d1f517027771c3e801c").unwrap();
820        let address = address!("0x28A4F420a619974a2393365BCe5a7b560078Cc13");
821        let recovered_address =
822            WalletSubcommands::recover_address_from_message(message, &signature);
823        assert!(recovered_address.is_ok());
824        assert_eq!(address, recovered_address.unwrap());
825    }
826
827    #[test]
828    fn can_parse_wallet_sign_data() {
829        let args = WalletSubcommands::parse_from(["foundry-cli", "sign", "--data", "{ ... }"]);
830        match args {
831            WalletSubcommands::Sign { message, data, from_file, .. } => {
832                assert_eq!(message, "{ ... }".to_string());
833                assert!(data);
834                assert!(!from_file);
835            }
836            _ => panic!("expected WalletSubcommands::Sign"),
837        }
838    }
839
840    #[test]
841    fn can_parse_wallet_sign_data_file() {
842        let args = WalletSubcommands::parse_from([
843            "foundry-cli",
844            "sign",
845            "--data",
846            "--from-file",
847            "tests/data/typed_data.json",
848        ]);
849        match args {
850            WalletSubcommands::Sign { message, data, from_file, .. } => {
851                assert_eq!(message, "tests/data/typed_data.json".to_string());
852                assert!(data);
853                assert!(from_file);
854            }
855            _ => panic!("expected WalletSubcommands::Sign"),
856        }
857    }
858
859    #[test]
860    fn can_parse_wallet_change_password() {
861        let args = WalletSubcommands::parse_from([
862            "foundry-cli",
863            "change-password",
864            "my_account",
865            "--unsafe-password",
866            "old_password",
867            "--unsafe-new-password",
868            "new_password",
869        ]);
870        match args {
871            WalletSubcommands::ChangePassword {
872                account_name,
873                keystore_dir,
874                unsafe_password,
875                unsafe_new_password,
876            } => {
877                assert_eq!(account_name, "my_account".to_string());
878                assert_eq!(unsafe_password, Some("old_password".to_string()));
879                assert_eq!(unsafe_new_password, Some("new_password".to_string()));
880                assert!(keystore_dir.is_none());
881            }
882            _ => panic!("expected WalletSubcommands::ChangePassword"),
883        }
884    }
885}