cast/cmd/wallet/
mod.rs

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