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