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