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