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