Skip to main content

cast/cmd/wallet/
mod.rs

1use crate::cmd::{confirm_continue, rpc_provider};
2use alloy_chains::Chain;
3use alloy_dyn_abi::TypedData;
4use alloy_primitives::{Address, B256, Signature, U256, hex};
5use alloy_provider::Provider;
6use alloy_rpc_types::Authorization;
7use alloy_signer::Signer;
8use alloy_signer_local::{
9    MnemonicBuilder, PrivateKeySigner,
10    coins_bip39::{English, Entropy, Mnemonic},
11};
12use clap::Parser;
13use eyre::{Context, Result};
14use foundry_cli::{
15    json::{print_json_success, print_scalar},
16    opts::RpcOpts,
17};
18use foundry_common::{errors::FsPathError, fs, sh_println, shell};
19use foundry_config::Config;
20use foundry_wallets::{BrowserWalletOpts, RawWalletOpts, WalletOpts, WalletSigner};
21use rand_08::thread_rng;
22use serde_json::{Value, json};
23use std::{
24    ffi::OsString,
25    path::{Path, PathBuf},
26};
27use yansi::Paint;
28
29pub mod vanity;
30use vanity::VanityArgs;
31
32pub mod list;
33use list::ListArgs;
34
35mod process_tree;
36
37pub mod session;
38use session::SessionArgs;
39
40mod touch_id;
41use touch_id::TouchIdArgs;
42
43/// CLI arguments for `cast wallet`.
44#[derive(Debug, Parser)]
45pub enum WalletSubcommands {
46    /// Create a new random keypair
47    ///
48    /// Examples:
49    /// - cast wallet new (print a new private key and address)
50    /// - cast wallet new my-wallet (save to the default keystore directory)
51    /// - cast wallet new ~/.foundry/keystores dev (save to an encrypted keystore)
52    #[command(verbatim_doc_comment, visible_alias = "n")]
53    New {
54        /// If provided, then keypair will be written to an encrypted JSON keystore.
55        ///
56        /// A single bare argument that is not an existing directory is treated as ACCOUNT_NAME and
57        /// saved under the default keystore directory (`~/.foundry/keystores`), matching
58        /// `cast wallet import <name>`.
59        path: Option<String>,
60
61        /// Account name for the keystore file. If provided, the keystore file
62        /// will be named using this account name.
63        #[arg(value_name = "ACCOUNT_NAME")]
64        account_name: Option<String>,
65
66        /// Triggers a hidden password prompt for the JSON keystore.
67        ///
68        /// Deprecated: prompting for a hidden password is now the default.
69        #[arg(long, short, conflicts_with = "unsafe_password")]
70        password: bool,
71
72        /// Password for the JSON keystore in cleartext.
73        ///
74        /// This is UNSAFE to use and we recommend using the --password.
75        #[arg(long, env = "CAST_PASSWORD", value_name = "PASSWORD")]
76        unsafe_password: Option<String>,
77
78        /// Number of wallets to generate.
79        #[arg(long, short, default_value = "1")]
80        number: u32,
81
82        /// Overwrite existing keystore files without prompting.
83        #[arg(long)]
84        force: bool,
85
86        /// Enroll the keystore for Touch ID-assisted authentication on macOS.
87        ///
88        /// The macOS login password and explicit keystore passwords remain available.
89        #[arg(long, hide = !cfg!(all(target_os = "macos", feature = "touch-id")))]
90        touch_id: bool,
91    },
92
93    /// Generates a random BIP39 mnemonic phrase
94    #[command(visible_alias = "nm")]
95    NewMnemonic {
96        /// Number of words for the mnemonic
97        #[arg(long, short, default_value = "12")]
98        words: usize,
99
100        /// Number of accounts to display
101        #[arg(long, short, default_value = "1")]
102        accounts: u8,
103
104        /// Entropy to use for the mnemonic
105        #[arg(long, short, conflicts_with = "words")]
106        entropy: Option<String>,
107    },
108
109    /// Generate a vanity address.
110    #[command(visible_alias = "va")]
111    Vanity(VanityArgs),
112
113    /// Convert a private key to an address.
114    #[command(visible_aliases = &["a", "addr"])]
115    Address {
116        /// If provided, the address will be derived from the specified private key.
117        #[arg(value_name = "PRIVATE_KEY")]
118        private_key_override: Option<String>,
119
120        #[command(flatten)]
121        wallet: WalletOpts,
122
123        #[command(flatten)]
124        browser: BrowserWalletOpts,
125    },
126
127    /// Derive accounts from a mnemonic
128    ///
129    /// Examples:
130    /// - cast wallet derive "test test test test test test test test test test test junk"
131    /// - cast wallet derive "$MNEMONIC" --accounts 5
132    #[command(verbatim_doc_comment, visible_alias = "d")]
133    Derive {
134        /// The accounts will be derived from the specified mnemonic phrase.
135        #[arg(value_name = "MNEMONIC")]
136        mnemonic: String,
137
138        /// Number of accounts to display.
139        #[arg(long, short, default_value = "1")]
140        accounts: Option<u8>,
141
142        /// Insecure mode: display private keys in the terminal.
143        #[arg(long, default_value = "false")]
144        insecure: bool,
145    },
146
147    /// Sign a message or typed data
148    ///
149    /// Examples:
150    /// - cast wallet sign "hello" --account dev
151    /// - cast wallet sign "hello" --private-key $PK
152    /// - cast wallet sign --data --from-file typed_data.json --ledger
153    #[command(verbatim_doc_comment, visible_alias = "s")]
154    Sign {
155        /// The message, typed data, or hash to sign.
156        ///
157        /// Messages starting with 0x are expected to be hex encoded, which get decoded before
158        /// being signed.
159        ///
160        /// The message will be prefixed with the Ethereum Signed Message header and hashed before
161        /// signing, unless `--no-hash` is provided.
162        ///
163        /// Typed data can be provided as a json string or a file name.
164        /// Use --data flag to denote the message is a string of typed data.
165        /// Use --data --from-file to denote the message is a file name containing typed data.
166        /// The data will be combined and hashed using the EIP712 specification before signing.
167        /// The data should be formatted as JSON.
168        message: String,
169
170        /// Treat the message as JSON typed data.
171        #[arg(long)]
172        data: bool,
173
174        /// Treat the message as a file containing JSON typed data. Requires `--data`.
175        #[arg(long, requires = "data")]
176        from_file: bool,
177
178        /// Treat the message as a raw 32-byte hash and sign it directly without hashing it again.
179        #[arg(long, conflicts_with = "data")]
180        no_hash: bool,
181
182        #[command(flatten)]
183        wallet: WalletOpts,
184
185        #[command(flatten)]
186        browser: BrowserWalletOpts,
187    },
188
189    /// EIP-7702 sign authorization.
190    #[command(visible_alias = "sa")]
191    SignAuth {
192        /// Address to sign authorization for.
193        address: Address,
194
195        #[command(flatten)]
196        rpc: RpcOpts,
197
198        #[arg(long)]
199        nonce: Option<u64>,
200
201        #[arg(long)]
202        chain: Option<Chain>,
203
204        /// Skip the confirmation prompt for wildcard chain authorizations.
205        #[arg(long)]
206        force: bool,
207
208        /// If set, indicates the authorization will be broadcast by the signing account itself.
209        /// This means the nonce used will be the current nonce + 1 (to account for the
210        /// transaction that will include this authorization).
211        #[arg(long, conflicts_with = "nonce")]
212        self_broadcast: bool,
213
214        #[command(flatten)]
215        wallet: WalletOpts,
216    },
217
218    /// Verify the signature of a message
219    ///
220    /// Examples:
221    /// - cast wallet verify --address $ADDRESS "hello" $SIGNATURE
222    /// - cast wallet verify --address $ADDRESS --no-hash $HASH $SIGNATURE
223    #[command(verbatim_doc_comment, visible_alias = "v")]
224    Verify {
225        /// The original message.
226        ///
227        /// Treats 0x-prefixed strings as hex encoded bytes.
228        /// Non 0x-prefixed strings are treated as raw input message.
229        ///
230        /// The message will be prefixed with the Ethereum Signed Message header and hashed before
231        /// signing, unless `--no-hash` is provided.
232        ///
233        /// Typed data can be provided as a json string or a file name.
234        /// Use --data flag to denote the message is a string of typed data.
235        /// Use --data --from-file to denote the message is a file name containing typed data.
236        /// The data will be combined and hashed using the EIP712 specification before signing.
237        /// The data should be formatted as JSON.
238        message: String,
239
240        /// The signature to verify.
241        signature: Signature,
242
243        /// The address of the message signer.
244        #[arg(long, short)]
245        address: Address,
246
247        /// Treat the message as JSON typed data.
248        #[arg(long)]
249        data: bool,
250
251        /// Treat the message as a file containing JSON typed data. Requires `--data`.
252        #[arg(long, requires = "data")]
253        from_file: bool,
254
255        /// Treat the message as a raw 32-byte hash and sign it directly without hashing it again.
256        #[arg(long, conflicts_with = "data")]
257        no_hash: bool,
258    },
259
260    /// Import a private key into an encrypted keystore
261    ///
262    /// Examples:
263    /// - cast wallet import dev --interactive (prompt for the private key)
264    /// - cast wallet import dev --private-key $PK
265    /// - cast wallet import dev --mnemonic "$MNEMONIC" --mnemonic-index 1
266    #[command(verbatim_doc_comment, visible_alias = "i")]
267    Import {
268        /// The name for the account in the keystore.
269        #[arg(value_name = "ACCOUNT_NAME")]
270        account_name: String,
271        /// If provided, keystore will be saved here instead of the default keystores directory
272        /// (~/.foundry/keystores)
273        #[arg(long, short)]
274        keystore_dir: Option<String>,
275        /// Password for the JSON keystore in cleartext
276        /// This is unsafe, we recommend using the default hidden password prompt
277        #[arg(long, env = "CAST_UNSAFE_PASSWORD", value_name = "PASSWORD")]
278        unsafe_password: Option<String>,
279        /// Enroll the keystore for Touch ID-assisted authentication on macOS.
280        ///
281        /// The macOS login password and explicit keystore passwords remain available.
282        #[arg(long, hide = !cfg!(all(target_os = "macos", feature = "touch-id")))]
283        touch_id: bool,
284        #[command(flatten)]
285        raw_wallet_options: RawWalletOpts,
286    },
287
288    /// List all the accounts in the keystore default directory
289    #[command(visible_alias = "ls")]
290    List(ListArgs),
291
292    /// Manage temporary Tempo wallet sessions.
293    Session(SessionArgs),
294
295    /// Manage Touch ID enrollment for encrypted keystores.
296    TouchId(TouchIdArgs),
297
298    /// Remove a wallet from the keystore.
299    ///
300    /// This command requires the wallet alias and will prompt for a password to ensure that only
301    /// an authorized user can remove the wallet.
302    #[command(visible_aliases = &["rm"], override_usage = "cast wallet remove --name <NAME>")]
303    Remove {
304        /// The alias (or name) of the wallet to remove.
305        #[arg(long, required = true)]
306        name: String,
307        /// Optionally provide the keystore directory if not provided. default directory will be
308        /// used (~/.foundry/keystores).
309        #[arg(long)]
310        dir: Option<String>,
311        /// Password for the JSON keystore in cleartext
312        /// This is unsafe, we recommend using the default hidden password prompt
313        #[arg(long, env = "CAST_UNSAFE_PASSWORD", value_name = "PASSWORD")]
314        unsafe_password: Option<String>,
315    },
316
317    /// Derives private key from mnemonic
318    ///
319    /// Examples:
320    /// - cast wallet private-key "test test test test test test test test test test test junk"
321    /// - cast wallet private-key "$MNEMONIC" 1 (derive the key at index 1)
322    /// - cast wallet private-key "$MNEMONIC" "m/44'/60'/0'/0/1" (use a custom path)
323    #[command(verbatim_doc_comment, name = "private-key", visible_alias = "pk", aliases = &["derive-private-key", "--derive-private-key"])]
324    PrivateKey {
325        /// If provided, the private key will be derived from the specified mnemonic phrase.
326        #[arg(value_name = "MNEMONIC")]
327        mnemonic_override: Option<String>,
328
329        /// If provided, the private key will be derived using the
330        /// specified mnemonic index (if integer) or derivation path.
331        #[arg(value_name = "MNEMONIC_INDEX_OR_DERIVATION_PATH")]
332        mnemonic_index_or_derivation_path_override: Option<String>,
333
334        #[command(flatten)]
335        wallet: WalletOpts,
336    },
337    /// Get the public key for the given private key.
338    #[command(visible_aliases = &["pubkey"])]
339    PublicKey {
340        /// If provided, the public key will be derived from the specified private key.
341        #[arg(long = "raw-private-key", value_name = "PRIVATE_KEY")]
342        private_key_override: Option<String>,
343
344        #[command(flatten)]
345        wallet: WalletOpts,
346    },
347    /// Decrypt a keystore file to get the private key
348    #[command(name = "decrypt-keystore", visible_alias = "dk")]
349    DecryptKeystore {
350        /// The name for the account in the keystore.
351        #[arg(value_name = "ACCOUNT_NAME")]
352        account_name: String,
353        /// If not provided, keystore will try to be located at the default keystores directory
354        /// (~/.foundry/keystores)
355        #[arg(long, short)]
356        keystore_dir: Option<String>,
357        /// Password for the JSON keystore in cleartext
358        /// This is unsafe, we recommend using the default hidden password prompt
359        #[arg(long, env = "CAST_UNSAFE_PASSWORD", value_name = "PASSWORD")]
360        unsafe_password: Option<String>,
361    },
362
363    /// Change the password of a keystore file
364    #[command(name = "change-password", visible_alias = "cp")]
365    ChangePassword {
366        /// The name for the account in the keystore.
367        #[arg(value_name = "ACCOUNT_NAME")]
368        account_name: String,
369        /// If not provided, keystore will try to be located at the default keystores directory
370        /// (~/.foundry/keystores)
371        #[arg(long, short)]
372        keystore_dir: Option<String>,
373        /// Current password for the JSON keystore in cleartext
374        /// This is unsafe, we recommend using the default hidden password prompt
375        #[arg(long, env = "CAST_UNSAFE_PASSWORD", value_name = "PASSWORD")]
376        unsafe_password: Option<String>,
377        /// New password for the JSON keystore in cleartext
378        /// This is unsafe, we recommend using the default hidden password prompt
379        #[arg(long, env = "CAST_UNSAFE_NEW_PASSWORD", value_name = "NEW_PASSWORD")]
380        unsafe_new_password: Option<String>,
381    },
382}
383
384impl WalletSubcommands {
385    pub async fn run(self) -> Result<()> {
386        match self {
387            Self::New {
388                path,
389                mut account_name,
390                unsafe_password,
391                number,
392                password,
393                force,
394                touch_id,
395            } => {
396                ensure_touch_id_available(touch_id)?;
397
398                let path = match path {
399                    Some(path) => Some(resolve_new_dir(path, &mut account_name)?),
400                    None if unsafe_password.is_some() || password || touch_id => {
401                        let path = resolve_keystore_dir(None)?;
402                        fs::create_dir_all(&path)?;
403                        Some(path)
404                    }
405                    None => None,
406                };
407
408                if let Some(name) = &account_name {
409                    ensure_account_name_available(name)?;
410                }
411
412                let json = match path {
413                    Some(path) => new_keystores(
414                        &path,
415                        account_name.as_deref(),
416                        unsafe_password,
417                        number,
418                        force,
419                        touch_id,
420                    )?,
421                    None => new_keypairs(number)?,
422                };
423                if shell::is_json() {
424                    print_json_success(json)?;
425                }
426            }
427            Self::NewMnemonic { words, accounts, entropy } => {
428                let phrase = if let Some(entropy) = entropy {
429                    let entropy = Entropy::from_slice(hex::decode(entropy)?)?;
430                    Mnemonic::<English>::new_from_entropy(entropy).to_phrase()
431                } else {
432                    Mnemonic::<English>::new_with_count(&mut thread_rng(), words)?.to_phrase()
433                };
434
435                let format_json = shell::is_json();
436                if !format_json {
437                    sh_println!("{}", "Generating mnemonic from provided entropy...".yellow())?;
438                }
439
440                let builder = MnemonicBuilder::<English>::default().phrase(phrase.as_str());
441                let wallets = (0..accounts)
442                    .map(|i| -> Result<_> {
443                        Ok(builder
444                            .clone()
445                            .derivation_path(format!("m/44'/60'/0'/0/{i}"))?
446                            .build()?)
447                    })
448                    .collect::<Result<Vec<_>>>()?;
449
450                if !format_json {
451                    sh_println!("{}", "Successfully generated a new mnemonic.".green())?;
452                    sh_println!("Phrase:\n{phrase}")?;
453                    sh_println!("\nAccounts:")?;
454                }
455
456                let mut accounts = Vec::new();
457                for (i, wallet) in wallets.iter().enumerate() {
458                    let public_key = format!("0x{}", hex::encode(wallet.public_key()));
459                    let private_key = format!("0x{}", hex::encode(wallet.credential().to_bytes()));
460                    if format_json {
461                        let mut account = serde_json::Map::new();
462                        account.insert("address".into(), json!(wallet.address().to_string()));
463                        if shell::verbosity() > 0 {
464                            account.insert("public_key".into(), json!(public_key));
465                        }
466                        account.insert("private_key".into(), json!(private_key));
467                        accounts.push(Value::Object(account));
468                    } else {
469                        sh_println!("- Account {i}:")?;
470                        sh_println!("Address:     {}", wallet.address())?;
471                        if shell::verbosity() > 0 {
472                            sh_println!("Public key:  {public_key}")?;
473                        }
474                        sh_println!("Private key: {private_key}\n")?;
475                    }
476                }
477
478                if format_json {
479                    print_json_success(json!({ "mnemonic": phrase, "accounts": accounts }))?;
480                }
481            }
482            Self::Vanity(cmd) => {
483                cmd.run()?;
484            }
485            Self::Address { wallet, browser, private_key_override } => {
486                let addr = if let Some(pk) = private_key_override {
487                    raw_wallet(RawWalletOpts { private_key: Some(pk), ..Default::default() })
488                        .signer()
489                        .await?
490                        .address()
491                } else if let Some(browser) = browser.run::<alloy_network::Ethereum>().await? {
492                    browser.address()
493                } else {
494                    wallet.signer().await?.address()
495                };
496                print_scalar(addr.to_checksum(None))?;
497            }
498            Self::Derive { mnemonic, accounts, insecure } => {
499                let format_json = shell::is_json();
500                let mut accounts_json = Vec::new();
501                for i in 0..accounts.unwrap_or(1) {
502                    let wallet = raw_wallet(RawWalletOpts {
503                        mnemonic: Some(mnemonic.clone()),
504                        mnemonic_index: i as u32,
505                        ..Default::default()
506                    })
507                    .signer()
508                    .await?;
509                    let WalletSigner::Local(wallet) = wallet else {
510                        eyre::bail!("Only local wallets are supported by this command");
511                    };
512
513                    let address = wallet.address().to_checksum(None);
514                    let private_key = format!("0x{}", hex::encode(wallet.credential().to_bytes()));
515                    if format_json {
516                        accounts_json.push(if insecure {
517                            json!({ "address": address, "private_key": private_key })
518                        } else {
519                            json!({ "address": address })
520                        });
521                    } else {
522                        sh_println!("- Account {i}:")?;
523                        if insecure {
524                            sh_println!("Address:     {address}")?;
525                            sh_println!("Private key: {private_key}\n")?;
526                        } else {
527                            sh_println!("Address:     {address}\n")?;
528                        }
529                    }
530                }
531
532                if format_json {
533                    print_json_success(accounts_json)?;
534                }
535            }
536            Self::PublicKey { wallet, private_key_override } => {
537                let wallet = private_key_override
538                    .map(|pk| {
539                        raw_wallet(RawWalletOpts { private_key: Some(pk), ..Default::default() })
540                    })
541                    .unwrap_or(wallet)
542                    .signer()
543                    .await?;
544                let WalletSigner::Local(wallet) = wallet else {
545                    eyre::bail!("Only local wallets are supported by this command");
546                };
547                print_scalar(format!("0x{}", hex::encode(wallet.public_key())))?;
548            }
549            Self::Sign { message, data, from_file, no_hash, wallet, browser } => {
550                if browser.browser && no_hash {
551                    eyre::bail!("Raw hash signing is not supported with a browser wallet");
552                }
553
554                let typed_data = data.then(|| parse_typed_data(&message, from_file)).transpose()?;
555
556                let (sig, address) =
557                    if let Some(browser) = browser.run::<alloy_network::Ethereum>().await? {
558                        let sig = if let Some(typed_data) = &typed_data {
559                            browser.sign_dynamic_typed_data(typed_data).await?
560                        } else {
561                            browser.sign_message(&hex_str_to_bytes(&message)?).await?
562                        };
563                        (sig, browser.address())
564                    } else {
565                        let wallet = wallet.signer().await?;
566                        let sig = if let Some(typed_data) = &typed_data {
567                            wallet.sign_dynamic_typed_data(typed_data).await?
568                        } else if no_hash {
569                            wallet.sign_hash(&hex::decode(&message)?[..].try_into()?).await?
570                        } else {
571                            wallet.sign_message(&hex_str_to_bytes(&message)?).await?
572                        };
573                        (sig, wallet.address())
574                    };
575
576                let signature = hex::encode(sig.as_bytes());
577                if shell::verbosity() == 0 {
578                    print_scalar(format!("0x{signature}"))?;
579                } else if shell::is_json() {
580                    print_json_success(json!({
581                        "message": message,
582                        "address": address,
583                        "signature": signature,
584                    }))?;
585                } else {
586                    sh_status!("Successfully signed!")?;
587                    sh_status!("   Message: {message}")?;
588                    sh_status!("   Address: {address}")?;
589                    sh_println!("0x{signature}")?;
590                }
591            }
592            Self::SignAuth { rpc, nonce, chain, force, wallet, address, self_broadcast } => {
593                let provider = rpc_provider(&rpc)?;
594                let chain_id = match chain {
595                    Some(chain) => chain.id(),
596                    None => provider.get_chain_id().await?,
597                };
598                if chain_id == 0 && !force {
599                    sh_warn!(
600                        "Chain ID 0 creates an EIP-7702 authorization that is valid on every chain."
601                    )?;
602                    if !confirm_continue()? {
603                        return Ok(());
604                    }
605                }
606
607                let wallet = wallet.signer().await?;
608                let nonce = match nonce {
609                    Some(nonce) => nonce,
610                    // When self-broadcasting, the authorization nonce needs to be +1 because the
611                    // transaction itself will consume the current nonce.
612                    None => {
613                        provider.get_transaction_count(wallet.address()).await?
614                            + u64::from(self_broadcast)
615                    }
616                };
617                let auth = Authorization { chain_id: U256::from(chain_id), address, nonce };
618                let signature = wallet.sign_hash(&auth.signature_hash()).await?;
619                let signed = hex::encode_prefixed(alloy_rlp::encode(auth.into_signed(signature)));
620
621                if shell::verbosity() == 0 {
622                    print_scalar(signed)?;
623                } else if shell::is_json() {
624                    print_json_success(json!({
625                        "nonce": nonce,
626                        "chain_id": chain_id,
627                        "address": wallet.address(),
628                        "signature": signed,
629                    }))?;
630                } else {
631                    sh_status!("Successfully signed!")?;
632                    sh_status!("   Nonce: {nonce}")?;
633                    sh_status!("   Chain ID: {chain_id}")?;
634                    sh_status!("   Address: {}", wallet.address())?;
635                    sh_println!("{signed}")?;
636                }
637            }
638            Self::Verify { message, signature, address, data, from_file, no_hash } => {
639                let recovered_address =
640                    recover_signer(&message, &signature, data, from_file, no_hash)?;
641
642                if address != recovered_address {
643                    eyre::bail!("Validation failed. Address {address} did not sign this message.");
644                }
645                if shell::is_json() {
646                    print_json_success(json!({"address": address, "result": true}))?;
647                } else {
648                    sh_println!("Validation succeeded. Address {address} signed this message.")?;
649                }
650            }
651            Self::Import {
652                account_name,
653                keystore_dir,
654                unsafe_password,
655                touch_id,
656                raw_wallet_options,
657            } => {
658                ensure_touch_id_available(touch_id)?;
659                ensure_account_name_available(&account_name)?;
660                let dir = resolve_keystore_dir(keystore_dir)?;
661                fs::create_dir_all(&dir)?;
662
663                let keystore_path = dir.join(&account_name);
664                if keystore_path.exists() {
665                    eyre::bail!("Keystore file already exists at {}", keystore_path.display());
666                }
667                if touch_id {
668                    ensure_touch_id_sidecar_available(&keystore_path)?;
669                }
670
671                let Some(WalletSigner::Local(wallet)) = raw_wallet_options.signer()? else {
672                    eyre::bail!(
673                        "\
674Did you set a private key or mnemonic?
675Run `cast wallet import --help` and use the corresponding CLI
676flag to set your key via:
677--private-key, --mnemonic-path or --interactive."
678                    );
679                };
680
681                let password = password_or_prompt(unsafe_password, "Enter password: ")?;
682                let (wallet, _) = PrivateKeySigner::encrypt_keystore(
683                    dir,
684                    &mut thread_rng(),
685                    wallet.credential().to_bytes(),
686                    &password,
687                    Some(&account_name),
688                )?;
689                let address = wallet.address();
690
691                if touch_id {
692                    let action = format!("keystore was imported at {}", keystore_path.display());
693                    enroll_new_keystore(&keystore_path, &password, &action, 0)?;
694                }
695
696                if shell::is_json() {
697                    let mut result = json!({"account": account_name, "address": address});
698                    if touch_id {
699                        result["touch_id"] = json!(true);
700                    }
701                    print_json_success(result)?;
702                } else {
703                    sh_println!(
704                        "{}",
705                        format!(
706                            "`{account_name}` keystore was saved successfully. Address: {address:?}"
707                        )
708                        .green()
709                    )?;
710                    if touch_id {
711                        sh_status!("{TOUCH_ID_ENROLLED_STATUS}")?;
712                    }
713                }
714            }
715            Self::List(cmd) => {
716                cmd.run().await?;
717            }
718            Self::Session(args) => {
719                args.run().await?;
720            }
721            Self::TouchId(args) => {
722                args.run()?;
723            }
724            Self::Remove { name, dir, unsafe_password } => {
725                let keystore_path = existing_keystore_path(&name, dir)?;
726                let password = password_or_prompt(unsafe_password, "Enter password: ")?;
727                if PrivateKeySigner::decrypt_keystore(&keystore_path, password).is_err() {
728                    eyre::bail!("Invalid password - wallet removal cancelled");
729                }
730
731                remove_touch_id_sidecar(&keystore_path)?;
732                std::fs::remove_file(&keystore_path).wrap_err_with(|| {
733                    format!("Failed to remove keystore file at {}", keystore_path.display())
734                })?;
735
736                if shell::is_json() {
737                    print_json_success(json!({"account": name, "removed": true}))?;
738                } else {
739                    sh_println!(
740                        "{}",
741                        format!("`{name}` keystore was removed successfully.").green()
742                    )?;
743                }
744            }
745            Self::PrivateKey {
746                wallet,
747                mnemonic_override,
748                mnemonic_index_or_derivation_path_override,
749            } => {
750                let (index_override, derivation_path_override) =
751                    match mnemonic_index_or_derivation_path_override {
752                        Some(value) => match value.parse::<u32>() {
753                            Ok(index) => (Some(index), None),
754                            Err(_) => (None, Some(value)),
755                        },
756                        None => (None, None),
757                    };
758                let wallet = WalletOpts {
759                    raw: RawWalletOpts {
760                        mnemonic: mnemonic_override.or(wallet.raw.mnemonic),
761                        mnemonic_index: index_override.unwrap_or(wallet.raw.mnemonic_index),
762                        hd_path: derivation_path_override.or(wallet.raw.hd_path),
763                        ..wallet.raw
764                    },
765                    ..wallet
766                }
767                .signer()
768                .await?;
769                let WalletSigner::Local(wallet) = wallet else {
770                    eyre::bail!("Only local wallets are supported by this command.");
771                };
772
773                let private_key = format!("0x{}", hex::encode(wallet.credential().to_bytes()));
774                if shell::verbosity() == 0 {
775                    print_scalar(private_key)?;
776                } else if shell::is_json() {
777                    print_json_success(json!({
778                        "address": wallet.address(),
779                        "private_key": private_key,
780                    }))?;
781                } else {
782                    sh_println!("Address:     {}", wallet.address())?;
783                    sh_println!("Private key: {private_key}")?;
784                }
785            }
786            Self::DecryptKeystore { account_name, keystore_dir, unsafe_password } => {
787                let keypath = existing_keystore_path(&account_name, keystore_dir)?;
788                let password = password_or_prompt(unsafe_password, "Enter password: ")?;
789                let wallet = PrivateKeySigner::decrypt_keystore(keypath, password)?;
790
791                let private_key = B256::from_slice(&wallet.credential().to_bytes());
792                if shell::is_json() {
793                    print_json_success(
794                        json!({"account": account_name, "private_key": private_key}),
795                    )?;
796                } else {
797                    sh_println!(
798                        "{}",
799                        format!("{account_name}'s private key is: {private_key}").green()
800                    )?;
801                }
802            }
803            Self::ChangePassword {
804                account_name,
805                keystore_dir,
806                unsafe_password,
807                unsafe_new_password,
808            } => {
809                let keypath = existing_keystore_path(&account_name, keystore_dir)?;
810                let sidecar = touch_id_sidecar_path(&keypath);
811
812                let touch_id_enrolled = match touch_id_sidecar_state(&sidecar)? {
813                    TouchIdSidecarState::Missing => false,
814                    TouchIdSidecarState::Recognized => true,
815                    TouchIdSidecarState::Keystore => {
816                        eyre::bail!(
817                            "refusing to change the password because {} is an existing keystore",
818                            sidecar.display()
819                        );
820                    }
821                    TouchIdSidecarState::Unknown => {
822                        // Preserve useful structured errors such as UnsupportedVersion.
823                        #[cfg(all(target_os = "macos", feature = "touch-id"))]
824                        foundry_wallets::touch_id::policy(&keypath)?;
825
826                        // Never continue after an Unknown classification, even if another
827                        // parser happens to accept the file.
828                        eyre::bail!(
829                            "refusing to change the password because {} exists and is not a recognized Touch ID sidecar",
830                            sidecar.display()
831                        );
832                    }
833                };
834
835                #[cfg(all(target_os = "macos", feature = "touch-id"))]
836                let touch_id_policy = touch_id_enrolled
837                    .then(|| foundry_wallets::touch_id::policy(&keypath))
838                    .transpose()?;
839
840                let current_password =
841                    password_or_prompt(unsafe_password, "Enter current password: ")?;
842                // decrypt the keystore to verify the current password and get the private key
843                let wallet = PrivateKeySigner::decrypt_keystore(&keypath, current_password.clone())
844                    .map_err(|_| eyre::eyre!("Invalid password - password change cancelled"))?;
845
846                let new_password = password_or_prompt(unsafe_new_password, "Enter new password: ")?;
847                if current_password == new_password {
848                    eyre::bail!("New password cannot be the same as the current password");
849                }
850
851                let (wallet, _) = PrivateKeySigner::encrypt_keystore(
852                    keypath.parent().unwrap_or(Path::new("")),
853                    &mut thread_rng(),
854                    wallet.credential().to_bytes(),
855                    &new_password,
856                    Some(&account_name),
857                )?;
858
859                #[cfg(all(target_os = "macos", feature = "touch-id"))]
860                if let Some(policy) = touch_id_policy {
861                    foundry_wallets::touch_id::enroll(&keypath, &new_password, policy).map_err(
862                        |error| {
863                            touch_id_enrollment_failure(
864                                &keypath,
865                                &format!(
866                                    "password for keystore `{account_name}` was changed at {}",
867                                    keypath.display()
868                                ),
869                                error,
870                            )
871                        },
872                    )?;
873                }
874
875                #[cfg(not(all(target_os = "macos", feature = "touch-id")))]
876                if touch_id_enrolled {
877                    match remove_touch_id_sidecar(&keypath) {
878                        Ok(true) => {
879                            sh_warn!(
880                                "Removed the stale Touch ID enrollment after changing the password"
881                            )?;
882                        }
883                        Ok(false) => {}
884                        Err(cleanup_error) => {
885                            eyre::bail!(
886                                "password changed, but Touch ID sidecar cleanup failed: {cleanup_error}. The new password is valid; remove {} manually",
887                                sidecar.display()
888                            );
889                        }
890                    }
891                }
892
893                let address = wallet.address();
894                if shell::is_json() {
895                    print_json_success(json!({"account": account_name, "address": address}))?;
896                } else {
897                    sh_println!(
898                        "{}",
899                        format!(
900                            "Password for keystore `{account_name}` was changed successfully. Address: {address:?}"
901                        )
902                        .green()
903                    )?;
904                }
905            }
906        };
907
908        Ok(())
909    }
910}
911
912const TOUCH_ID_ENROLLED_STATUS: &str =
913    "Touch ID-assisted unlock enrolled; password-based unlock remains available.";
914
915/// Creates `number` encrypted keystores in `dir`, returning their JSON records in JSON mode.
916fn new_keystores(
917    dir: &Path,
918    account_name: Option<&str>,
919    unsafe_password: Option<String>,
920    number: u32,
921    force: bool,
922    touch_id: bool,
923) -> Result<Vec<Value>> {
924    let password = password_or_prompt(unsafe_password, "Enter secret: ")?;
925    let names = (0..number)
926        .map(|i| account_name.map(|name| indexed_account_name(name, number, i)))
927        .collect::<Vec<_>>();
928
929    if touch_id {
930        for name in names.iter().flatten() {
931            ensure_touch_id_sidecar_available(&dir.join(name))?;
932        }
933    }
934
935    // Prevent accidental overwriting: check all target files upfront.
936    if !force {
937        let existing =
938            names.iter().flatten().filter(|name| dir.join(name).exists()).collect::<Vec<_>>();
939        if !existing.is_empty() {
940            sh_eprintln!("The following keystore file(s) already exist:")?;
941            for file in &existing {
942                sh_eprintln!("   - {file}")?;
943            }
944            let input: String = foundry_common::prompt!(
945                "\nDo you want to overwrite all {} file(s)? [y/N]: ",
946                existing.len()
947            )?;
948            if !input.trim().eq_ignore_ascii_case("y") {
949                eyre::bail!("Operation cancelled. No keystores were modified.");
950            }
951        }
952    }
953
954    let mut rng = thread_rng();
955    let mut json_values = Vec::new();
956    for (i, name) in names.iter().enumerate() {
957        let (wallet, uuid) =
958            PrivateKeySigner::new_keystore(dir, &mut rng, &password, name.as_deref())?;
959        let keystore_path = dir.join(name.as_deref().unwrap_or(&uuid));
960
961        if touch_id {
962            let action = format!("keystore was created at {}", keystore_path.display());
963            enroll_new_keystore(&keystore_path, &password, &action, i)?;
964        }
965
966        let address = wallet.address().to_checksum(None);
967        if shell::is_json() {
968            let mut result = json!({
969                "address": address,
970                "public_key": format!("0x{}", hex::encode(wallet.public_key())),
971                "path": format!("{}", keystore_path.display()),
972            });
973            if touch_id {
974                result["touch_id"] = json!(true);
975            }
976            json_values.push(result);
977        } else {
978            sh_status!("Created new encrypted keystore file: {}", keystore_path.display())?;
979            if touch_id {
980                sh_status!("{TOUCH_ID_ENROLLED_STATUS}")?;
981            }
982            sh_status!("Address:    {address}")?;
983            if shell::verbosity() > 0 {
984                sh_status!("Public key: 0x{}", hex::encode(wallet.public_key()))?;
985            }
986            // The machine-readable stdout record duplicates the prose above when stdout is an
987            // interactive terminal.
988            if !shell::is_out_tty() {
989                sh_println!("{address}")?;
990            }
991        }
992    }
993    Ok(json_values)
994}
995
996/// Generates `number` random keypairs, returning their JSON records in JSON mode.
997fn new_keypairs(number: u32) -> Result<Vec<Value>> {
998    let mut rng = thread_rng();
999    let mut json_values = Vec::new();
1000    for _ in 0..number {
1001        let wallet = PrivateKeySigner::random_with(&mut rng);
1002        let address = wallet.address().to_checksum(None);
1003        let private_key = format!("0x{}", hex::encode(wallet.credential().to_bytes()));
1004        if shell::is_json() {
1005            json_values.push(json!({
1006                "address": address,
1007                "public_key": format!("0x{}", hex::encode(wallet.public_key())),
1008                "private_key": private_key,
1009            }));
1010        } else {
1011            sh_status!("Successfully created new keypair.")?;
1012            sh_status!("Address:     {address}")?;
1013            if shell::verbosity() > 0 {
1014                sh_status!("Public key:  0x{}", hex::encode(wallet.public_key()))?;
1015            }
1016            sh_status!("Private key: {private_key}")?;
1017            // The machine-readable stdout record duplicates the prose above when stdout is an
1018            // interactive terminal.
1019            if !shell::is_out_tty() {
1020                sh_println!("{address}\t{private_key}")?;
1021            }
1022        }
1023    }
1024    Ok(json_values)
1025}
1026
1027fn raw_wallet(raw: RawWalletOpts) -> WalletOpts {
1028    WalletOpts { raw, ..Default::default() }
1029}
1030
1031/// Parses EIP-712 typed data from a JSON string, or from the file it names when `from_file`.
1032fn parse_typed_data(message: &str, from_file: bool) -> Result<TypedData> {
1033    if from_file {
1034        Ok(fs::read_json_file(Path::new(message))?)
1035    } else {
1036        Ok(serde_json::from_str(message)?)
1037    }
1038}
1039
1040/// Strips the 0x prefix from a hex string and decodes it to bytes.
1041///
1042/// Treats the string as raw bytes if it doesn't start with 0x.
1043fn hex_str_to_bytes(s: &str) -> Result<Vec<u8>> {
1044    Ok(match s.strip_prefix("0x") {
1045        Some(data) => hex::decode(data).wrap_err("Could not decode 0x-prefixed string.")?,
1046        None => s.as_bytes().to_vec(),
1047    })
1048}
1049
1050/// Returns `password` when given, otherwise prompts for it on the terminal.
1051fn password_or_prompt(password: Option<String>, prompt: &str) -> Result<String> {
1052    match password {
1053        Some(password) => Ok(password),
1054        None => Ok(rpassword::prompt_password(prompt)?),
1055    }
1056}
1057
1058/// Resolves the directory for `cast wallet new`.
1059///
1060/// A missing bare name is rewritten into `account_name` and stored in the default keystore
1061/// directory, matching `cast wallet import <name>`. Path-like values and resolution failures
1062/// other than `NotFound` still error.
1063fn resolve_new_dir(path: String, account_name: &mut Option<String>) -> Result<PathBuf> {
1064    match dunce::canonicalize(&path) {
1065        Ok(dir) if dir.is_dir() => Ok(dir),
1066        Ok(dir) => eyre::bail!("`{}` is not a directory", dir.display()),
1067        Err(e)
1068            if e.kind() == std::io::ErrorKind::NotFound
1069                && account_name.is_none()
1070                && is_bare_account_name(&path) =>
1071        {
1072            *account_name = Some(path);
1073            let dir = resolve_keystore_dir(None)?;
1074            fs::create_dir_all(&dir)?;
1075            Ok(dir)
1076        }
1077        Err(e) => eyre::bail!(
1078            "If you specified a directory, please make sure it exists, or create it before running `cast wallet new <DIR>`.\n{path} is not a directory.\nError: {e}"
1079        ),
1080    }
1081}
1082
1083/// Returns true when `value` is a bare keystore account name rather than a filesystem path.
1084///
1085/// Path-like values (`.`, `..`, anything containing a separator or `:`, including Windows
1086/// prefixes such as `C:foo` and ADS names such as `foo:bar`) stay on the existing
1087/// directory-resolution path for `cast wallet new`.
1088fn is_bare_account_name(value: &str) -> bool {
1089    !value.is_empty()
1090        && value != "."
1091        && value != ".."
1092        && !value.contains('/')
1093        && !value.contains('\\')
1094        // `C:foo` is a drive-relative path; `foo:bar` is an alternate data stream on
1095        // Windows. Joining either can write outside the default keystore directory
1096        // or hide the keystore in a stream so listing/loading miss it.
1097        && !value.contains(':')
1098}
1099
1100/// Resolves the keystore directory, defaulting to `~/.foundry/keystores`.
1101fn resolve_keystore_dir(dir: Option<String>) -> Result<PathBuf> {
1102    match dir {
1103        Some(dir) => Ok(PathBuf::from(dir)),
1104        None => Config::foundry_keystores_dir()
1105            .ok_or_else(|| eyre::eyre!("Could not find the default keystore directory.")),
1106    }
1107}
1108
1109/// Validates `account_name` and resolves its existing keystore file in `dir`.
1110fn existing_keystore_path(account_name: &str, dir: Option<String>) -> Result<PathBuf> {
1111    ensure_account_name_available(account_name)?;
1112    let keystore_path = resolve_keystore_dir(dir)?.join(account_name);
1113    if !keystore_path.exists() {
1114        eyre::bail!("Keystore file does not exist at {}", keystore_path.display());
1115    }
1116    Ok(keystore_path)
1117}
1118
1119fn ensure_touch_id_available(touch_id: bool) -> Result<()> {
1120    if !touch_id {
1121        return Ok(());
1122    }
1123
1124    #[cfg(all(target_os = "macos", feature = "touch-id"))]
1125    {
1126        if !foundry_wallets::touch_id::is_available() {
1127            eyre::bail!("Touch ID is unavailable on this Mac");
1128        }
1129        Ok(())
1130    }
1131
1132    #[cfg(not(all(target_os = "macos", feature = "touch-id")))]
1133    eyre::bail!("`--touch-id` requires macOS and a cast build with the `touch-id` feature");
1134}
1135
1136const TOUCH_ID_SIDECAR_SUFFIX: &str = ".touchid";
1137
1138fn ensure_account_name_available(name: &str) -> Result<()> {
1139    let file_name = Path::new(name).file_name().and_then(|s| s.to_str());
1140    if name.is_empty() || name.contains('\\') || file_name != Some(name) {
1141        eyre::bail!("account name must be a single path segment");
1142    }
1143    if name.ends_with(TOUCH_ID_SIDECAR_SUFFIX) {
1144        eyre::bail!("account names ending in `{TOUCH_ID_SIDECAR_SUFFIX}` are reserved");
1145    }
1146    Ok(())
1147}
1148
1149fn touch_id_sidecar_path(keystore_path: &Path) -> PathBuf {
1150    let mut path = OsString::from(keystore_path.as_os_str());
1151    path.push(TOUCH_ID_SIDECAR_SUFFIX);
1152    path.into()
1153}
1154
1155/// Classification of a file at a `.touchid` path.
1156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1157enum TouchIdSidecarState {
1158    /// No filesystem entry exists at the path.
1159    Missing,
1160    /// The file strictly matches the currently supported Touch ID sidecar schema.
1161    Recognized,
1162    /// The file is an Ethereum keystore.
1163    Keystore,
1164    /// Anything else: malformed JSON, empty object, array, unrelated object,
1165    /// unsupported sidecar version, unknown policy, invalid hex, empty payload,
1166    /// truncated sealed payload, invalid X9.63 prefix, or future sidecar format.
1167    Unknown,
1168}
1169
1170/// The only sidecar version this Cast release understands.
1171const TOUCH_ID_SIDECAR_VERSION: u32 = 1;
1172
1173/// Minimum encoded ciphertext payload:
1174/// 65-byte P-256 X9.63 public key + 12-byte ChaChaPoly nonce + 16-byte tag.
1175///
1176/// The encrypted password itself may be empty, so 93 bytes is the true minimum.
1177const TOUCH_ID_SEALED_PASSWORD_MIN_LEN: usize = 65 + 12 + 16;
1178
1179/// X9.63 prefix for an uncompressed P-256 public key.
1180const TOUCH_ID_X963_UNCOMPRESSED_PREFIX: u8 = 0x04;
1181
1182/// Strict deserialization-only representation of the persisted sidecar format.
1183///
1184/// Uses `deny_unknown_fields` so that any unrecognized field (e.g. from a
1185/// future sidecar version) causes a parse failure, which maps to `Unknown`.
1186///
1187/// This duplicates `foundry_wallets::touch_id` on purpose: that module only exists on macOS
1188/// builds with the `touch-id` feature, while sidecar files must be classified everywhere.
1189#[derive(Debug, serde::Deserialize)]
1190#[serde(deny_unknown_fields)]
1191struct TouchIdSidecarWire {
1192    version: u32,
1193    policy: TouchIdPolicyWire,
1194    se_key: String,
1195    sealed_password: String,
1196}
1197
1198impl TouchIdSidecarWire {
1199    /// Whether this is a supported sidecar with plausible payload bytes: `se_key` is non-empty
1200    /// hex and `sealed_password` is hex of at least 93 bytes starting with 0x04.
1201    fn is_recognized(&self) -> bool {
1202        self.version == TOUCH_ID_SIDECAR_VERSION
1203            && hex::decode(&self.se_key).is_ok_and(|se_key| !se_key.is_empty())
1204            && hex::decode(&self.sealed_password).is_ok_and(|sealed| {
1205                sealed.len() >= TOUCH_ID_SEALED_PASSWORD_MIN_LEN
1206                    && sealed.first() == Some(&TOUCH_ID_X963_UNCOMPRESSED_PREFIX)
1207            })
1208    }
1209}
1210
1211/// The policy values currently recognised by this Cast release.
1212#[derive(Clone, Copy, Debug, serde::Deserialize)]
1213#[serde(rename_all = "kebab-case")]
1214enum TouchIdPolicyWire {
1215    UserPresence,
1216    CurrentBiometry,
1217}
1218
1219impl TouchIdPolicyWire {
1220    const fn as_str(self) -> &'static str {
1221        match self {
1222            Self::UserPresence => "user-presence",
1223            Self::CurrentBiometry => "current-biometry",
1224        }
1225    }
1226}
1227
1228/// Returns the state of the file at `path` with respect to the Touch ID sidecar schema.
1229///
1230/// Classification order:
1231/// 1. Not found → `Missing`
1232/// 2. Has both `version` and `crypto`/`Crypto` fields → `Keystore`
1233/// 3. Parses strictly as a v1 Touch ID sidecar + plausible payload → `Recognized`
1234/// 4. Everything else → `Unknown`
1235fn touch_id_sidecar_state(path: &Path) -> Result<TouchIdSidecarState> {
1236    let value = match fs::read_json_file::<Value>(path) {
1237        Ok(v) => v,
1238        Err(FsPathError::Read { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => {
1239            return Ok(TouchIdSidecarState::Missing);
1240        }
1241        Err(e) => return Err(e.into()),
1242    };
1243
1244    if value.get("version").is_some()
1245        && (value.get("crypto").is_some() || value.get("Crypto").is_some())
1246    {
1247        return Ok(TouchIdSidecarState::Keystore);
1248    }
1249
1250    Ok(match serde_json::from_value::<TouchIdSidecarWire>(value) {
1251        Ok(wire) if wire.is_recognized() => TouchIdSidecarState::Recognized,
1252        _ => TouchIdSidecarState::Unknown,
1253    })
1254}
1255
1256fn touch_id_sidecar_policy(path: &Path) -> Result<TouchIdPolicyWire> {
1257    let value = fs::read_json_file::<Value>(path)?;
1258    let wire = serde_json::from_value::<TouchIdSidecarWire>(value)
1259        .wrap_err_with(|| format!("failed to parse Touch ID sidecar at {}", path.display()))?;
1260    if !wire.is_recognized() {
1261        eyre::bail!("{} is not a recognized Touch ID sidecar", path.display());
1262    }
1263    Ok(wire.policy)
1264}
1265
1266fn is_touch_id_sidecar(path: &Path) -> Result<bool> {
1267    let is_sidecar_name = path
1268        .file_name()
1269        .and_then(|name| name.to_str())
1270        .is_some_and(|name| name.ends_with(TOUCH_ID_SIDECAR_SUFFIX));
1271    Ok(is_sidecar_name && touch_id_sidecar_state(path)? == TouchIdSidecarState::Recognized)
1272}
1273
1274fn ensure_touch_id_sidecar_available(keystore_path: &Path) -> Result<()> {
1275    let sidecar = touch_id_sidecar_path(keystore_path);
1276    match touch_id_sidecar_state(&sidecar)? {
1277        TouchIdSidecarState::Missing | TouchIdSidecarState::Recognized => Ok(()),
1278        TouchIdSidecarState::Keystore => {
1279            eyre::bail!(
1280                "refusing Touch ID enrollment because {} is an existing keystore",
1281                sidecar.display()
1282            );
1283        }
1284        TouchIdSidecarState::Unknown => {
1285            eyre::bail!(
1286                "refusing Touch ID enrollment because {} already exists and is not a recognized Touch ID sidecar",
1287                sidecar.display()
1288            );
1289        }
1290    }
1291}
1292
1293/// Recovers the signer of `message`, interpreted as EIP-712 typed data (`data`), a prehashed
1294/// digest (`no_hash`) or a plain message.
1295fn recover_signer(
1296    message: &str,
1297    signature: &Signature,
1298    data: bool,
1299    from_file: bool,
1300    no_hash: bool,
1301) -> Result<Address> {
1302    Ok(if data {
1303        let typed_data = parse_typed_data(message, from_file)?;
1304        signature.recover_address_from_prehash(&typed_data.eip712_signing_hash()?)?
1305    } else if no_hash {
1306        signature.recover_address_from_prehash(&hex::decode(message)?[..].try_into()?)?
1307    } else {
1308        signature.recover_address_from_msg(hex_str_to_bytes(message)?)?
1309    })
1310}
1311
1312fn indexed_account_name(base: &str, number: u32, index: u32) -> String {
1313    if number == 1 { base.to_string() } else { format!("{base}_{}", index + 1) }
1314}
1315
1316fn remove_touch_id_sidecar(keystore_path: &Path) -> Result<bool> {
1317    let sidecar = touch_id_sidecar_path(keystore_path);
1318    match touch_id_sidecar_state(&sidecar)? {
1319        TouchIdSidecarState::Missing => Ok(false),
1320        TouchIdSidecarState::Recognized => match std::fs::remove_file(&sidecar) {
1321            Ok(()) => Ok(true),
1322            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
1323            Err(error) => Err(error).wrap_err_with(|| {
1324                format!("Failed to remove Touch ID sidecar at {}", sidecar.display())
1325            }),
1326        },
1327        TouchIdSidecarState::Keystore => {
1328            eyre::bail!("refusing to remove existing keystore at {}", sidecar.display());
1329        }
1330        TouchIdSidecarState::Unknown => {
1331            eyre::bail!(
1332                "refusing to remove {} because it is not a recognized Touch ID sidecar",
1333                sidecar.display()
1334            );
1335        }
1336    }
1337}
1338
1339/// Enrolls a freshly written keystore for Touch ID with the default policy.
1340///
1341/// `completed_action` describes the keystore write that already succeeded; `index` is the
1342/// keystore's position in a `cast wallet new --number` batch.
1343#[cfg(all(target_os = "macos", feature = "touch-id"))]
1344fn enroll_new_keystore(
1345    keystore_path: &Path,
1346    password: &str,
1347    completed_action: &str,
1348    index: usize,
1349) -> Result<()> {
1350    ensure_touch_id_sidecar_available(keystore_path).map_err(|e| {
1351        eyre::eyre!(
1352            "{completed_action}, but Touch ID enrollment preflight failed: {e}. The sidecar was left untouched and must be resolved manually before password-prompt fallback is reliable"
1353        )
1354    })?;
1355    foundry_wallets::touch_id::enroll(
1356        keystore_path,
1357        password,
1358        foundry_wallets::touch_id::Policy::default(),
1359    )
1360    .map_err(|error| {
1361        let note = if index == 0 { "" } else { " (earlier batch keystores were not rolled back)" };
1362        touch_id_enrollment_failure(keystore_path, &format!("{completed_action}{note}"), error)
1363    })
1364}
1365
1366/// Unreachable in practice: [`ensure_touch_id_available`] rejects `--touch-id` on this platform.
1367#[cfg(not(all(target_os = "macos", feature = "touch-id")))]
1368const fn enroll_new_keystore(_: &Path, _: &str, _: &str, _: usize) -> Result<()> {
1369    Ok(())
1370}
1371
1372#[cfg(all(target_os = "macos", feature = "touch-id"))]
1373fn touch_id_enrollment_failure(
1374    keystore_path: &Path,
1375    completed_action: &str,
1376    enrollment_error: impl std::fmt::Display,
1377) -> eyre::Report {
1378    match remove_touch_id_sidecar(keystore_path) {
1379        Ok(true) => eyre::eyre!(
1380            "{completed_action}, but Touch ID enrollment failed: {enrollment_error}. The stale Touch ID sidecar was removed; password-prompt fallback remains available"
1381        ),
1382        Ok(false) => eyre::eyre!(
1383            "{completed_action}, but Touch ID enrollment failed: {enrollment_error}. No stale Touch ID sidecar remained; password-prompt fallback remains available"
1384        ),
1385        Err(cleanup_error) => eyre::eyre!(
1386            "{completed_action}, but Touch ID enrollment failed: {enrollment_error}. The stale sidecar could not be removed: {cleanup_error}. Remove {} manually before password-prompt fallback is possible",
1387            touch_id_sidecar_path(keystore_path).display()
1388        ),
1389    }
1390}
1391
1392#[cfg(test)]
1393mod tests {
1394    use super::*;
1395    use alloy_primitives::address;
1396    use std::str::FromStr;
1397
1398    fn sidecar_json(version: u32, policy: &str, se_key: &str, sealed_password: &str) -> String {
1399        json!({
1400            "version": version,
1401            "policy": policy,
1402            "se_key": se_key,
1403            "sealed_password": sealed_password,
1404        })
1405        .to_string()
1406    }
1407
1408    fn sealed_password(prefix: &str, len: usize) -> String {
1409        format!("{prefix}{}", "00".repeat(len - 1))
1410    }
1411
1412    #[test]
1413    fn recovers_signer_for_each_message_kind() {
1414        let address = address!("0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf"); // private key = 1
1415
1416        let prehash = alloy_primitives::keccak256("hello");
1417        let signature = Signature::from_str("433ec3d37e4f1253df15e2dea412fed8e915737730f74b3dfb1353268f932ef5557c9158e0b34bce39de28d11797b42e9b1acb2749230885fe075aedc3e491a41b").unwrap();
1418        assert_eq!(
1419            recover_signer(&hex::encode(prehash), &signature, false, false, true).unwrap(),
1420            address
1421        );
1422
1423        let typed_data = r#"{"domain":{"name":"Test","version":"1","chainId":1,"verifyingContract":"0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF"},"message":{"value":123},"primaryType":"Data","types":{"Data":[{"name":"value","type":"uint256"}]}}"#;
1424        let signature = Signature::from_str("0285ff83b93bd01c14e201943af7454fe2bc6c98be707a73888c397d6ae3b0b92f73ca559f81cbb19fe4e0f1dc4105bd7b647c6a84b033057977cf2ec982daf71b").unwrap();
1425        assert_eq!(recover_signer(typed_data, &signature, true, false, false).unwrap(), address);
1426    }
1427
1428    #[test]
1429    fn new_keystores_preflight_every_touch_id_sidecar() {
1430        let dir = tempfile::tempdir().unwrap();
1431        let sidecar = dir.path().join("batch_2.touchid");
1432        std::fs::write(&sidecar, r#"{"version":3,"crypto":{}}"#).unwrap();
1433
1434        let error = new_keystores(dir.path(), Some("batch"), Some("pw".into()), 2, false, true)
1435            .unwrap_err();
1436        assert_eq!(
1437            error.to_string(),
1438            format!(
1439                "refusing Touch ID enrollment because {} is an existing keystore",
1440                sidecar.display()
1441            )
1442        );
1443        assert!(!dir.path().join("batch_1").exists());
1444    }
1445
1446    #[test]
1447    fn classifies_touch_id_sidecars() {
1448        use TouchIdSidecarState::*;
1449
1450        let valid_sealed = sealed_password("04", TOUCH_ID_SEALED_PASSWORD_MIN_LEN);
1451        let cases = [
1452            (None, Missing),
1453            (Some(sidecar_json(1, "user-presence", "aa", &valid_sealed)), Recognized),
1454            (Some(sidecar_json(1, "current-biometry", "aa", &valid_sealed)), Recognized),
1455            (Some(r#"{"version":3,"crypto":{}}"#.to_string()), Keystore),
1456            (Some(r#"{"version":3,"Crypto":{}}"#.to_string()), Keystore),
1457            (Some("{}".to_string()), Unknown),
1458            (Some("[]".to_string()), Unknown),
1459            (Some(r#"{"application":"unrelated"}"#.to_string()), Unknown),
1460            // unsupported version
1461            (Some(sidecar_json(2, "user-presence", "aa", &valid_sealed)), Unknown),
1462            // unknown field is rejected by `deny_unknown_fields`
1463            (
1464                Some(
1465                    json!({
1466                        "version": 1,
1467                        "policy": "user-presence",
1468                        "se_key": "aa",
1469                        "sealed_password": valid_sealed,
1470                        "future_field": true
1471                    })
1472                    .to_string(),
1473                ),
1474                Unknown,
1475            ),
1476            (Some(sidecar_json(1, "future-policy", "aa", &valid_sealed)), Unknown),
1477            // invalid payloads
1478            (Some(sidecar_json(1, "user-presence", "", &valid_sealed)), Unknown),
1479            (Some(sidecar_json(1, "user-presence", "zz", &valid_sealed)), Unknown),
1480            (Some(sidecar_json(1, "user-presence", "aa", "")), Unknown),
1481            (Some(sidecar_json(1, "user-presence", "aa", "zz")), Unknown),
1482            (
1483                Some(sidecar_json(
1484                    1,
1485                    "user-presence",
1486                    "aa",
1487                    &sealed_password("04", TOUCH_ID_SEALED_PASSWORD_MIN_LEN - 1),
1488                )),
1489                Unknown,
1490            ),
1491            (
1492                Some(sidecar_json(
1493                    1,
1494                    "user-presence",
1495                    "aa",
1496                    &sealed_password("03", TOUCH_ID_SEALED_PASSWORD_MIN_LEN),
1497                )),
1498                Unknown,
1499            ),
1500        ];
1501
1502        for (content, expected) in cases {
1503            let dir = tempfile::tempdir().unwrap();
1504            let keystore = dir.path().join("account");
1505            let sidecar = touch_id_sidecar_path(&keystore);
1506            if let Some(content) = &content {
1507                std::fs::write(&sidecar, content).unwrap();
1508            }
1509
1510            assert_eq!(touch_id_sidecar_state(&sidecar).unwrap(), expected, "{content:?}");
1511            assert_eq!(is_touch_id_sidecar(&sidecar).unwrap(), expected == Recognized);
1512
1513            // Enrollment preflight and removal only ever touch recognized sidecars.
1514            let preflight = ensure_touch_id_sidecar_available(&keystore);
1515            let removal = remove_touch_id_sidecar(&keystore);
1516            match expected {
1517                Missing => {
1518                    preflight.unwrap();
1519                    assert!(!removal.unwrap());
1520                }
1521                Recognized => {
1522                    preflight.unwrap();
1523                    assert!(removal.unwrap());
1524                    assert!(!sidecar.exists());
1525                }
1526                Keystore => {
1527                    let err = preflight.unwrap_err().to_string();
1528                    assert!(err.contains("is an existing keystore"), "{err}");
1529                    let err = removal.unwrap_err().to_string();
1530                    assert!(err.contains("refusing to remove existing keystore"), "{err}");
1531                }
1532                Unknown => {
1533                    for err in [preflight.unwrap_err(), removal.unwrap_err()] {
1534                        let err = err.to_string();
1535                        assert!(err.contains("is not a recognized Touch ID sidecar"), "{err}");
1536                    }
1537                }
1538            }
1539            if expected != Recognized {
1540                assert_eq!(
1541                    std::fs::read_to_string(&sidecar).ok(),
1542                    content,
1543                    "file must be untouched"
1544                );
1545            }
1546        }
1547    }
1548
1549    #[test]
1550    fn malformed_json_propagates_error() {
1551        let dir = tempfile::tempdir().unwrap();
1552        let sidecar = dir.path().join("account.touchid");
1553        std::fs::write(&sidecar, "not json").unwrap();
1554        // Malformed JSON is an I/O/parse error, not Unknown.
1555        assert!(is_touch_id_sidecar(&sidecar).is_err());
1556        assert!(touch_id_sidecar_state(&sidecar).is_err());
1557    }
1558
1559    #[test]
1560    fn wallet_sign_auth_nonce_and_self_broadcast_conflict() {
1561        let result = WalletSubcommands::try_parse_from([
1562            "foundry-cli",
1563            "sign-auth",
1564            "0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF",
1565            "--nonce",
1566            "42",
1567            "--self-broadcast",
1568        ]);
1569        assert!(
1570            result.is_err(),
1571            "expected error when both --nonce and --self-broadcast are provided"
1572        );
1573    }
1574
1575    #[test]
1576    fn rejects_path_keystore_account_name() {
1577        assert!(ensure_account_name_available("dev").is_ok());
1578        assert!(ensure_account_name_available("testAccount").is_ok());
1579        for invalid in ["../pwned", "nested/alias", "foo/../bar", "..", ".", "", "foo\\bar"] {
1580            assert!(ensure_account_name_available(invalid).is_err(), "{invalid:?}");
1581        }
1582    }
1583
1584    #[test]
1585    fn can_parse_wallet_new_bare_account_name() {
1586        let args = WalletSubcommands::parse_from(["foundry-cli", "new", "my-wallet"]);
1587        match args {
1588            WalletSubcommands::New { path, account_name, .. } => {
1589                assert_eq!(path.as_deref(), Some("my-wallet"));
1590                assert_eq!(account_name, None);
1591            }
1592            _ => panic!("expected WalletSubcommands::New"),
1593        }
1594    }
1595
1596    #[test]
1597    fn bare_account_name_heuristic() {
1598        assert!(is_bare_account_name("my-wallet"));
1599        assert!(is_bare_account_name("dev"));
1600        assert!(!is_bare_account_name(""));
1601        assert!(!is_bare_account_name("."));
1602        assert!(!is_bare_account_name(".."));
1603        assert!(!is_bare_account_name("./missing-dir"));
1604        assert!(!is_bare_account_name("missing-dir/"));
1605        assert!(!is_bare_account_name("/tmp/keystores"));
1606        assert!(!is_bare_account_name(r"C:\keystores"));
1607        assert!(!is_bare_account_name("C:foo"));
1608        assert!(!is_bare_account_name("foo:bar"));
1609    }
1610}