Skip to main content

cast/cmd/wallet/
mod.rs

1use alloy_chains::Chain;
2use alloy_dyn_abi::TypedData;
3use alloy_primitives::{Address, B256, Signature, U256, hex};
4use alloy_provider::Provider;
5use alloy_rpc_types::Authorization;
6use alloy_signer::Signer;
7use alloy_signer_local::{
8    MnemonicBuilder, PrivateKeySigner,
9    coins_bip39::{English, Entropy, Mnemonic},
10};
11use clap::Parser;
12use eyre::{Context, Result};
13use foundry_cli::{
14    json::{print_json_success, print_scalar},
15    opts::RpcOpts,
16    utils,
17    utils::LoadConfig,
18};
19use foundry_common::{errors::FsPathError, fs, sh_println, shell};
20use foundry_config::Config;
21use foundry_wallets::{BrowserWalletOpts, RawWalletOpts, WalletOpts, WalletSigner};
22use rand_08::thread_rng;
23use serde_json::json;
24use std::{
25    ffi::OsString,
26    io::Write,
27    path::{Path, PathBuf},
28};
29use yansi::Paint;
30
31pub mod vanity;
32use vanity::VanityArgs;
33
34pub mod list;
35use list::ListArgs;
36
37mod process_tree;
38
39pub mod session;
40use session::SessionArgs;
41
42mod touch_id;
43use touch_id::TouchIdArgs;
44
45/// CLI arguments for `cast wallet`.
46#[derive(Debug, Parser)]
47pub enum WalletSubcommands {
48    /// Create a new random keypair
49    ///
50    /// Examples:
51    /// - cast wallet new (print a new private key and address)
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        path: Option<String>,
57
58        /// Account name for the keystore file. If provided, the keystore file
59        /// will be named using this account name.
60        #[arg(value_name = "ACCOUNT_NAME")]
61        account_name: Option<String>,
62
63        /// Triggers a hidden password prompt for the JSON keystore.
64        ///
65        /// Deprecated: prompting for a hidden password is now the default.
66        #[arg(long, short, conflicts_with = "unsafe_password")]
67        password: bool,
68
69        /// Password for the JSON keystore in cleartext.
70        ///
71        /// This is UNSAFE to use and we recommend using the --password.
72        #[arg(long, env = "CAST_PASSWORD", value_name = "PASSWORD")]
73        unsafe_password: Option<String>,
74
75        /// Number of wallets to generate.
76        #[arg(long, short, default_value = "1")]
77        number: u32,
78
79        /// Overwrite existing keystore files without prompting.
80        #[arg(long)]
81        force: bool,
82
83        /// Enroll the keystore for Touch ID-assisted authentication on macOS.
84        ///
85        /// The macOS login password and explicit keystore passwords remain available.
86        #[arg(long, hide = !cfg!(all(target_os = "macos", feature = "touch-id")))]
87        touch_id: bool,
88    },
89
90    /// Generates a random BIP39 mnemonic phrase
91    #[command(visible_alias = "nm")]
92    NewMnemonic {
93        /// Number of words for the mnemonic
94        #[arg(long, short, default_value = "12")]
95        words: usize,
96
97        /// Number of accounts to display
98        #[arg(long, short, default_value = "1")]
99        accounts: u8,
100
101        /// Entropy to use for the mnemonic
102        #[arg(long, short, conflicts_with = "words")]
103        entropy: Option<String>,
104    },
105
106    /// Generate a vanity address.
107    #[command(visible_alias = "va")]
108    Vanity(VanityArgs),
109
110    /// Convert a private key to an address.
111    #[command(visible_aliases = &["a", "addr"])]
112    Address {
113        /// If provided, the address will be derived from the specified private key.
114        #[arg(value_name = "PRIVATE_KEY")]
115        private_key_override: Option<String>,
116
117        #[command(flatten)]
118        wallet: WalletOpts,
119
120        #[command(flatten)]
121        browser: BrowserWalletOpts,
122    },
123
124    /// Derive accounts from a mnemonic
125    ///
126    /// Examples:
127    /// - cast wallet derive "test test test test test test test test test test test junk"
128    /// - cast wallet derive "$MNEMONIC" --accounts 5
129    #[command(verbatim_doc_comment, visible_alias = "d")]
130    Derive {
131        /// The accounts will be derived from the specified mnemonic phrase.
132        #[arg(value_name = "MNEMONIC")]
133        mnemonic: String,
134
135        /// Number of accounts to display.
136        #[arg(long, short, default_value = "1")]
137        accounts: Option<u8>,
138
139        /// Insecure mode: display private keys in the terminal.
140        #[arg(long, default_value = "false")]
141        insecure: bool,
142    },
143
144    /// Sign a message or typed data
145    ///
146    /// Examples:
147    /// - cast wallet sign "hello" --account dev
148    /// - cast wallet sign "hello" --private-key $PK
149    /// - cast wallet sign --data --from-file typed_data.json --ledger
150    #[command(verbatim_doc_comment, visible_alias = "s")]
151    Sign {
152        /// The message, typed data, or hash to sign.
153        ///
154        /// Messages starting with 0x are expected to be hex encoded, which get decoded before
155        /// being signed.
156        ///
157        /// The message will be prefixed with the Ethereum Signed Message header and hashed before
158        /// signing, unless `--no-hash` is provided.
159        ///
160        /// Typed data can be provided as a json string or a file name.
161        /// Use --data flag to denote the message is a string of typed data.
162        /// Use --data --from-file to denote the message is a file name containing typed data.
163        /// The data will be combined and hashed using the EIP712 specification before signing.
164        /// The data should be formatted as JSON.
165        message: String,
166
167        /// Treat the message as JSON typed data.
168        #[arg(long)]
169        data: bool,
170
171        /// Treat the message as a file containing JSON typed data. Requires `--data`.
172        #[arg(long, requires = "data")]
173        from_file: bool,
174
175        /// Treat the message as a raw 32-byte hash and sign it directly without hashing it again.
176        #[arg(long, conflicts_with = "data")]
177        no_hash: bool,
178
179        #[command(flatten)]
180        wallet: WalletOpts,
181
182        #[command(flatten)]
183        browser: BrowserWalletOpts,
184    },
185
186    /// EIP-7702 sign authorization.
187    #[command(visible_alias = "sa")]
188    SignAuth {
189        /// Address to sign authorization for.
190        address: Address,
191
192        #[command(flatten)]
193        rpc: RpcOpts,
194
195        #[arg(long)]
196        nonce: Option<u64>,
197
198        #[arg(long)]
199        chain: Option<Chain>,
200
201        /// Skip the confirmation prompt for wildcard chain authorizations.
202        #[arg(long)]
203        force: bool,
204
205        /// If set, indicates the authorization will be broadcast by the signing account itself.
206        /// This means the nonce used will be the current nonce + 1 (to account for the
207        /// transaction that will include this authorization).
208        #[arg(long, conflicts_with = "nonce")]
209        self_broadcast: bool,
210
211        #[command(flatten)]
212        wallet: WalletOpts,
213    },
214
215    /// Verify the signature of a message
216    ///
217    /// Examples:
218    /// - cast wallet verify --address $ADDRESS "hello" $SIGNATURE
219    /// - cast wallet verify --address $ADDRESS --no-hash $HASH $SIGNATURE
220    #[command(verbatim_doc_comment, visible_alias = "v")]
221    Verify {
222        /// The original message.
223        ///
224        /// Treats 0x-prefixed strings as hex encoded bytes.
225        /// Non 0x-prefixed strings are treated as raw input message.
226        ///
227        /// The message will be prefixed with the Ethereum Signed Message header and hashed before
228        /// signing, unless `--no-hash` is provided.
229        ///
230        /// Typed data can be provided as a json string or a file name.
231        /// Use --data flag to denote the message is a string of typed data.
232        /// Use --data --from-file to denote the message is a file name containing typed data.
233        /// The data will be combined and hashed using the EIP712 specification before signing.
234        /// The data should be formatted as JSON.
235        message: String,
236
237        /// The signature to verify.
238        signature: Signature,
239
240        /// The address of the message signer.
241        #[arg(long, short)]
242        address: Address,
243
244        /// Treat the message as JSON typed data.
245        #[arg(long)]
246        data: bool,
247
248        /// Treat the message as a file containing JSON typed data. Requires `--data`.
249        #[arg(long, requires = "data")]
250        from_file: bool,
251
252        /// Treat the message as a raw 32-byte hash and sign it directly without hashing it again.
253        #[arg(long, conflicts_with = "data")]
254        no_hash: bool,
255    },
256
257    /// Import a private key into an encrypted keystore
258    ///
259    /// Examples:
260    /// - cast wallet import dev --interactive (prompt for the private key)
261    /// - cast wallet import dev --private-key $PK
262    /// - cast wallet import dev --mnemonic "$MNEMONIC" --mnemonic-index 1
263    #[command(verbatim_doc_comment, visible_alias = "i")]
264    Import {
265        /// The name for the account in the keystore.
266        #[arg(value_name = "ACCOUNT_NAME")]
267        account_name: String,
268        /// If provided, keystore will be saved here instead of the default keystores directory
269        /// (~/.foundry/keystores)
270        #[arg(long, short)]
271        keystore_dir: Option<String>,
272        /// Password for the JSON keystore in cleartext
273        /// This is unsafe, we recommend using the default hidden password prompt
274        #[arg(long, env = "CAST_UNSAFE_PASSWORD", value_name = "PASSWORD")]
275        unsafe_password: Option<String>,
276        /// Enroll the keystore for Touch ID-assisted authentication on macOS.
277        ///
278        /// The macOS login password and explicit keystore passwords remain available.
279        #[arg(long, hide = !cfg!(all(target_os = "macos", feature = "touch-id")))]
280        touch_id: bool,
281        #[command(flatten)]
282        raw_wallet_options: RawWalletOpts,
283    },
284
285    /// List all the accounts in the keystore default directory
286    #[command(visible_alias = "ls")]
287    List(ListArgs),
288
289    /// Manage temporary Tempo wallet sessions.
290    Session(SessionArgs),
291
292    /// Manage Touch ID enrollment for encrypted keystores.
293    TouchId(TouchIdArgs),
294
295    /// Remove a wallet from the keystore.
296    ///
297    /// This command requires the wallet alias and will prompt for a password to ensure that only
298    /// an authorized user can remove the wallet.
299    #[command(visible_aliases = &["rm"], override_usage = "cast wallet remove --name <NAME>")]
300    Remove {
301        /// The alias (or name) of the wallet to remove.
302        #[arg(long, required = true)]
303        name: String,
304        /// Optionally provide the keystore directory if not provided. default directory will be
305        /// used (~/.foundry/keystores).
306        #[arg(long)]
307        dir: Option<String>,
308        /// Password for the JSON keystore in cleartext
309        /// This is unsafe, we recommend using the default hidden password prompt
310        #[arg(long, env = "CAST_UNSAFE_PASSWORD", value_name = "PASSWORD")]
311        unsafe_password: Option<String>,
312    },
313
314    /// Derives private key from mnemonic
315    ///
316    /// Examples:
317    /// - cast wallet private-key "test test test test test test test test test test test junk"
318    /// - cast wallet private-key "$MNEMONIC" 1 (derive the key at index 1)
319    /// - cast wallet private-key "$MNEMONIC" "m/44'/60'/0'/0/1" (use a custom path)
320    #[command(verbatim_doc_comment, name = "private-key", visible_alias = "pk", aliases = &["derive-private-key", "--derive-private-key"])]
321    PrivateKey {
322        /// If provided, the private key will be derived from the specified mnemonic phrase.
323        #[arg(value_name = "MNEMONIC")]
324        mnemonic_override: Option<String>,
325
326        /// If provided, the private key will be derived using the
327        /// specified mnemonic index (if integer) or derivation path.
328        #[arg(value_name = "MNEMONIC_INDEX_OR_DERIVATION_PATH")]
329        mnemonic_index_or_derivation_path_override: Option<String>,
330
331        #[command(flatten)]
332        wallet: WalletOpts,
333    },
334    /// Get the public key for the given private key.
335    #[command(visible_aliases = &["pubkey"])]
336    PublicKey {
337        /// If provided, the public key will be derived from the specified private key.
338        #[arg(long = "raw-private-key", value_name = "PRIVATE_KEY")]
339        private_key_override: Option<String>,
340
341        #[command(flatten)]
342        wallet: WalletOpts,
343    },
344    /// Decrypt a keystore file to get the private key
345    #[command(name = "decrypt-keystore", visible_alias = "dk")]
346    DecryptKeystore {
347        /// The name for the account in the keystore.
348        #[arg(value_name = "ACCOUNT_NAME")]
349        account_name: String,
350        /// If not provided, keystore will try to be located at the default keystores directory
351        /// (~/.foundry/keystores)
352        #[arg(long, short)]
353        keystore_dir: Option<String>,
354        /// Password for the JSON keystore in cleartext
355        /// This is unsafe, we recommend using the default hidden password prompt
356        #[arg(long, env = "CAST_UNSAFE_PASSWORD", value_name = "PASSWORD")]
357        unsafe_password: Option<String>,
358    },
359
360    /// Change the password of a keystore file
361    #[command(name = "change-password", visible_alias = "cp")]
362    ChangePassword {
363        /// The name for the account in the keystore.
364        #[arg(value_name = "ACCOUNT_NAME")]
365        account_name: String,
366        /// If not provided, keystore will try to be located at the default keystores directory
367        /// (~/.foundry/keystores)
368        #[arg(long, short)]
369        keystore_dir: Option<String>,
370        /// Current password for the JSON keystore in cleartext
371        /// This is unsafe, we recommend using the default hidden password prompt
372        #[arg(long, env = "CAST_UNSAFE_PASSWORD", value_name = "PASSWORD")]
373        unsafe_password: Option<String>,
374        /// New 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_NEW_PASSWORD", value_name = "NEW_PASSWORD")]
377        unsafe_new_password: Option<String>,
378    },
379}
380
381impl WalletSubcommands {
382    pub async fn run(self) -> Result<()> {
383        match self {
384            Self::New {
385                path,
386                account_name,
387                unsafe_password,
388                number,
389                password,
390                force,
391                touch_id,
392            } => {
393                ensure_touch_id_available(touch_id)?;
394                if let Some(name) = &account_name {
395                    ensure_account_name_available(name)?;
396                }
397                let mut rng = thread_rng();
398
399                let mut json_values = shell::is_json().then(std::vec::Vec::new);
400
401                let path = if let Some(path) = path {
402                    match dunce::canonicalize(&path) {
403                        Ok(path) => {
404                            if !path.is_dir() {
405                                // we require path to be an existing directory
406                                eyre::bail!("`{}` is not a directory", path.display());
407                            }
408                            Some(path)
409                        }
410                        Err(e) => {
411                            eyre::bail!(
412                                "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: {}",
413                                e
414                            );
415                        }
416                    }
417                } else if unsafe_password.is_some() || password || touch_id {
418                    let path = Config::foundry_keystores_dir().ok_or_else(|| {
419                        eyre::eyre!("Could not find the default keystore directory.")
420                    })?;
421                    fs::create_dir_all(&path)?;
422                    Some(path)
423                } else {
424                    None
425                };
426
427                match path {
428                    Some(path) => {
429                        let password = if let Some(password) = unsafe_password {
430                            password
431                        } else {
432                            // if no --unsafe-password was provided read via stdin
433                            rpassword::prompt_password("Enter secret: ")?
434                        };
435
436                        if touch_id {
437                            ensure_touch_id_sidecars_available(
438                                &path,
439                                account_name.as_deref(),
440                                number,
441                            )?;
442                        }
443
444                        // Prevent accidental overwriting: check all target files upfront
445                        if !force && let Some(ref acc_name) = account_name {
446                            let mut existing_files = Vec::new();
447
448                            for i in 0..number {
449                                let name = indexed_account_name(acc_name, number, i);
450                                let file_path = path.join(&name);
451                                if file_path.exists() {
452                                    existing_files.push(name);
453                                }
454                            }
455
456                            if !existing_files.is_empty() {
457                                sh_eprintln!("The following keystore file(s) already exist:")?;
458                                for file in &existing_files {
459                                    sh_eprintln!("   - {file}")?;
460                                }
461                                sh_eprint!(
462                                    "\nDo you want to overwrite all {} file(s)? [y/N]: ",
463                                    existing_files.len()
464                                )?;
465                                std::io::stderr().flush()?;
466
467                                let mut input = String::new();
468                                std::io::stdin().read_line(&mut input)?;
469
470                                if !input.trim().eq_ignore_ascii_case("y") {
471                                    eyre::bail!("Operation cancelled. No keystores were modified.");
472                                }
473                            }
474                        }
475                        for i in 0..number {
476                            let account_name_ref = account_name
477                                .as_deref()
478                                .map(|name| indexed_account_name(name, number, i));
479
480                            let (wallet, uuid) = PrivateKeySigner::new_keystore(
481                                &path,
482                                &mut rng,
483                                &password,
484                                account_name_ref.as_deref(),
485                            )?;
486                            let identifier = account_name_ref.as_deref().unwrap_or(&uuid);
487                            let keystore_path = path.join(identifier);
488
489                            #[cfg(all(target_os = "macos", feature = "touch-id"))]
490                            if touch_id {
491                                ensure_touch_id_sidecar_available(&keystore_path).map_err(|e| {
492                                    eyre::eyre!(
493                                        "keystore was created at {}, but Touch ID enrollment preflight failed: {e}. The sidecar was left untouched and must be resolved manually before password-prompt fallback is reliable",
494                                        keystore_path.display()
495                                    )
496                                })?;
497                                if let Err(enrollment_error) = foundry_wallets::touch_id::enroll(
498                                    &keystore_path,
499                                    &password,
500                                    foundry_wallets::touch_id::Policy::default(),
501                                ) {
502                                    let completed_action = if i == 0 {
503                                        format!(
504                                            "keystore was created at {}",
505                                            keystore_path.display()
506                                        )
507                                    } else {
508                                        format!(
509                                            "keystore was created at {} (earlier batch keystores were not rolled back)",
510                                            keystore_path.display()
511                                        )
512                                    };
513                                    return Err(touch_id_enrollment_failure(
514                                        &keystore_path,
515                                        &completed_action,
516                                        enrollment_error,
517                                    ));
518                                }
519                            }
520
521                            if let Some(json) = json_values.as_mut() {
522                                let mut result = json!({
523                                    "address": wallet.address().to_checksum(None),
524                                    "public_key": format!("0x{}", hex::encode(wallet.public_key())),
525                                    "path": format!("{}", keystore_path.display()),
526                                });
527                                if touch_id {
528                                    result["touch_id"] = json!(true);
529                                }
530                                json.push(result);
531                            } else {
532                                sh_status!(
533                                    "Created new encrypted keystore file: {}",
534                                    keystore_path.display()
535                                )?;
536                                if touch_id {
537                                    sh_status!(
538                                        "Touch ID-assisted unlock enrolled; password-based unlock remains available."
539                                    )?;
540                                }
541                                sh_status!("Address:    {}", wallet.address().to_checksum(None))?;
542                                if shell::verbosity() > 0 {
543                                    sh_status!(
544                                        "Public key: 0x{}",
545                                        hex::encode(wallet.public_key())
546                                    )?;
547                                }
548                                // The machine-readable stdout record duplicates the prose above
549                                // when stdout is an interactive terminal.
550                                if !shell::is_out_tty() {
551                                    sh_println!("{}", wallet.address().to_checksum(None))?;
552                                }
553                            }
554                        }
555                    }
556                    None => {
557                        for _ in 0..number {
558                            let wallet = PrivateKeySigner::random_with(&mut rng);
559
560                            if let Some(json) = json_values.as_mut() {
561                                json.push(json!({
562                                    "address": wallet.address().to_checksum(None),
563                                    "public_key": format!("0x{}", hex::encode(wallet.public_key())),
564                                    "private_key": format!("0x{}", hex::encode(wallet.credential().to_bytes())),
565                                }));
566                            } else {
567                                sh_status!("Successfully created new keypair.")?;
568                                sh_status!("Address:     {}", wallet.address().to_checksum(None))?;
569                                if shell::verbosity() > 0 {
570                                    sh_status!(
571                                        "Public key:  0x{}",
572                                        hex::encode(wallet.public_key())
573                                    )?;
574                                }
575                                sh_status!(
576                                    "Private key: 0x{}",
577                                    hex::encode(wallet.credential().to_bytes())
578                                )?;
579                                // The machine-readable stdout record duplicates the prose above
580                                // when stdout is an interactive terminal.
581                                if !shell::is_out_tty() {
582                                    sh_println!(
583                                        "{}\t0x{}",
584                                        wallet.address().to_checksum(None),
585                                        hex::encode(wallet.credential().to_bytes())
586                                    )?;
587                                }
588                            }
589                        }
590                    }
591                }
592
593                if let Some(json) = json_values {
594                    print_json_success(json)?;
595                }
596            }
597            Self::NewMnemonic { words, accounts, entropy } => {
598                let phrase = if let Some(entropy) = entropy {
599                    let entropy = Entropy::from_slice(hex::decode(entropy)?)?;
600                    Mnemonic::<English>::new_from_entropy(entropy).to_phrase()
601                } else {
602                    let mut rng = thread_rng();
603                    Mnemonic::<English>::new_with_count(&mut rng, words)?.to_phrase()
604                };
605
606                let format_json = shell::is_json();
607
608                if !format_json {
609                    sh_println!("{}", "Generating mnemonic from provided entropy...".yellow())?;
610                }
611
612                let builder = MnemonicBuilder::<English>::default().phrase(phrase.as_str());
613                let derivation_path = "m/44'/60'/0'/0/";
614                let wallets = (0..accounts)
615                    .map(|i| builder.clone().derivation_path(format!("{derivation_path}{i}")))
616                    .collect::<Result<Vec<_>, _>>()?;
617                let wallets =
618                    wallets.into_iter().map(|b| b.build()).collect::<Result<Vec<_>, _>>()?;
619
620                if !format_json {
621                    sh_println!("{}", "Successfully generated a new mnemonic.".green())?;
622                    sh_println!("Phrase:\n{phrase}")?;
623                    sh_println!("\nAccounts:")?;
624                }
625
626                let mut accounts = json!([]);
627                for (i, wallet) in wallets.iter().enumerate() {
628                    let public_key = hex::encode(wallet.public_key());
629                    let private_key = hex::encode(wallet.credential().to_bytes());
630                    if format_json {
631                        accounts.as_array_mut().unwrap().push(if shell::verbosity() > 0 {
632                            json!({
633                                "address": format!("{}", wallet.address()),
634                                "public_key": format!("0x{}", public_key),
635                                "private_key": format!("0x{}", private_key),
636                            })
637                        } else {
638                            json!({
639                                "address": format!("{}", wallet.address()),
640                                "private_key": format!("0x{}", private_key),
641                            })
642                        });
643                    } else {
644                        sh_println!("- Account {i}:")?;
645                        sh_println!("Address:     {}", wallet.address())?;
646                        if shell::verbosity() > 0 {
647                            sh_println!("Public key:  0x{}", public_key)?;
648                        }
649                        sh_println!("Private key: 0x{}\n", private_key)?;
650                    }
651                }
652
653                if format_json {
654                    print_json_success(json!({
655                        "mnemonic": phrase,
656                        "accounts": accounts,
657                    }))?;
658                }
659            }
660            Self::Vanity(cmd) => {
661                cmd.run()?;
662            }
663            Self::Address { wallet, browser, private_key_override } => {
664                let addr = if let Some(pk) = private_key_override {
665                    WalletOpts {
666                        raw: RawWalletOpts { private_key: Some(pk), ..Default::default() },
667                        ..Default::default()
668                    }
669                    .signer()
670                    .await?
671                    .address()
672                } else if let Some(browser) = browser.run::<alloy_network::Ethereum>().await? {
673                    browser.address()
674                } else {
675                    wallet.signer().await?.address()
676                };
677                print_scalar(addr.to_checksum(None))?;
678            }
679            Self::Derive { mnemonic, accounts, insecure } => {
680                let format_json = shell::is_json();
681                let mut accounts_json = json!([]);
682                for i in 0..accounts.unwrap_or(1) {
683                    let wallet = WalletOpts {
684                        raw: RawWalletOpts {
685                            mnemonic: Some(mnemonic.clone()),
686                            mnemonic_index: i as u32,
687                            ..Default::default()
688                        },
689                        ..Default::default()
690                    }
691                    .signer()
692                    .await?;
693
694                    match wallet {
695                        WalletSigner::Local(local_wallet) => {
696                            let address = local_wallet.address().to_checksum(None);
697                            let private_key = hex::encode(local_wallet.credential().to_bytes());
698                            if format_json {
699                                if insecure {
700                                    accounts_json.as_array_mut().unwrap().push(json!({
701                                        "address": address.clone(),
702                                        "private_key": format!("0x{}", private_key),
703                                    }));
704                                } else {
705                                    accounts_json.as_array_mut().unwrap().push(json!({
706                                        "address": address.clone()
707                                    }));
708                                }
709                            } else {
710                                sh_println!("- Account {i}:")?;
711                                if insecure {
712                                    sh_println!("Address:     {}", address)?;
713                                    sh_println!("Private key: 0x{}\n", private_key)?;
714                                } else {
715                                    sh_println!("Address:     {}\n", address)?;
716                                }
717                            }
718                        }
719                        _ => {
720                            eyre::bail!("Only local wallets are supported by this command");
721                        }
722                    }
723                }
724
725                if format_json {
726                    print_json_success(accounts_json)?;
727                }
728            }
729            Self::PublicKey { wallet, private_key_override } => {
730                let wallet = private_key_override
731                    .map(|pk| WalletOpts {
732                        raw: RawWalletOpts { private_key: Some(pk), ..Default::default() },
733                        ..Default::default()
734                    })
735                    .unwrap_or(wallet)
736                    .signer()
737                    .await?;
738
739                let public_key = match wallet {
740                    WalletSigner::Local(wallet) => wallet.public_key(),
741                    _ => {
742                        eyre::bail!("Only local wallets are supported by this command");
743                    }
744                };
745
746                print_scalar(format!("0x{}", hex::encode(public_key)))?;
747            }
748            Self::Sign { message, data, from_file, no_hash, wallet, browser } => {
749                if browser.browser && no_hash {
750                    eyre::bail!("Raw hash signing is not supported with a browser wallet");
751                }
752
753                let typed_data = if data {
754                    let typed_data: TypedData = if from_file {
755                        // data is a file name, read json from file
756                        foundry_common::fs::read_json_file(message.as_ref())?
757                    } else {
758                        // data is a json string
759                        serde_json::from_str(&message)?
760                    };
761                    Some(typed_data)
762                } else {
763                    None
764                };
765
766                let (sig, address) =
767                    if let Some(browser) = browser.run::<alloy_network::Ethereum>().await? {
768                        let sig = if let Some(typed_data) = &typed_data {
769                            browser.sign_dynamic_typed_data(typed_data).await?
770                        } else {
771                            browser.sign_message(&Self::hex_str_to_bytes(&message)?).await?
772                        };
773                        (sig, browser.address())
774                    } else {
775                        let wallet = wallet.signer().await?;
776                        let sig = if let Some(typed_data) = &typed_data {
777                            wallet.sign_dynamic_typed_data(typed_data).await?
778                        } else if no_hash {
779                            wallet.sign_hash(&hex::decode(&message)?[..].try_into()?).await?
780                        } else {
781                            wallet.sign_message(&Self::hex_str_to_bytes(&message)?).await?
782                        };
783                        (sig, wallet.address())
784                    };
785
786                if shell::verbosity() > 0 {
787                    if shell::is_json() {
788                        print_json_success(json!({
789                            "message": message,
790                            "address": address,
791                            "signature": hex::encode(sig.as_bytes()),
792                        }))?;
793                    } else {
794                        sh_status!("Successfully signed!")?;
795                        sh_status!("   Message: {message}")?;
796                        sh_status!("   Address: {address}")?;
797                        sh_println!("0x{}", hex::encode(sig.as_bytes()))?;
798                    }
799                } else {
800                    print_scalar(format!("0x{}", hex::encode(sig.as_bytes())))?;
801                }
802            }
803            Self::SignAuth { rpc, nonce, chain, force, wallet, address, self_broadcast } => {
804                let provider = utils::get_provider(&rpc.load_config()?)?;
805                let chain_id = if let Some(chain) = chain {
806                    chain.id()
807                } else {
808                    provider.get_chain_id().await?
809                };
810                if chain_id == 0 && !force {
811                    sh_warn!(
812                        "Chain ID 0 creates an EIP-7702 authorization that is valid on every chain."
813                    )?;
814                    let response: String = foundry_common::prompt!("\nContinue anyway? [y/N] ")?;
815                    if !matches!(response.trim(), "y" | "Y") {
816                        sh_status!("Aborted.")?;
817                        return Ok(());
818                    }
819                }
820
821                let wallet = wallet.signer().await?;
822                let nonce = if let Some(nonce) = nonce {
823                    nonce
824                } else {
825                    let current_nonce = provider.get_transaction_count(wallet.address()).await?;
826                    if self_broadcast {
827                        // When self-broadcasting, the authorization nonce needs to be +1
828                        // because the transaction itself will consume the current nonce
829                        current_nonce + 1
830                    } else {
831                        current_nonce
832                    }
833                };
834                let auth = Authorization { chain_id: U256::from(chain_id), address, nonce };
835                let signature = wallet.sign_hash(&auth.signature_hash()).await?;
836                let auth = auth.into_signed(signature);
837
838                if shell::verbosity() > 0 {
839                    if shell::is_json() {
840                        print_json_success(json!({
841                            "nonce": nonce,
842                            "chain_id": chain_id,
843                            "address": wallet.address(),
844                            "signature": hex::encode_prefixed(alloy_rlp::encode(&auth)),
845                        }))?;
846                    } else {
847                        sh_status!("Successfully signed!")?;
848                        sh_status!("   Nonce: {nonce}")?;
849                        sh_status!("   Chain ID: {chain_id}")?;
850                        sh_status!("   Address: {}", wallet.address())?;
851                        sh_println!("{}", hex::encode_prefixed(alloy_rlp::encode(&auth)))?;
852                    }
853                } else {
854                    print_scalar(hex::encode_prefixed(alloy_rlp::encode(&auth)))?;
855                }
856            }
857            Self::Verify { message, signature, address, data, from_file, no_hash } => {
858                let recovered_address = if data {
859                    let typed_data: TypedData = if from_file {
860                        // data is a file name, read json from file
861                        foundry_common::fs::read_json_file(message.as_ref())?
862                    } else {
863                        // data is a json string
864                        serde_json::from_str(&message)?
865                    };
866                    Self::recover_address_from_typed_data(&typed_data, &signature)?
867                } else if no_hash {
868                    Self::recover_address_from_message_no_hash(
869                        &hex::decode(&message)?[..].try_into()?,
870                        &signature,
871                    )?
872                } else {
873                    Self::recover_address_from_message(&message, &signature)?
874                };
875
876                if address == recovered_address {
877                    if shell::is_json() {
878                        print_json_success(json!({"address": address, "result": true}))?;
879                    } else {
880                        sh_println!(
881                            "Validation succeeded. Address {address} signed this message."
882                        )?;
883                    }
884                } else {
885                    eyre::bail!("Validation failed. Address {address} did not sign this message.");
886                }
887            }
888            Self::Import {
889                account_name,
890                keystore_dir,
891                unsafe_password,
892                touch_id,
893                raw_wallet_options,
894            } => {
895                ensure_touch_id_available(touch_id)?;
896                ensure_account_name_available(&account_name)?;
897                // Set up keystore directory
898                let dir = if let Some(path) = keystore_dir {
899                    Path::new(&path).to_path_buf()
900                } else {
901                    Config::foundry_keystores_dir().ok_or_else(|| {
902                        eyre::eyre!("Could not find the default keystore directory.")
903                    })?
904                };
905
906                fs::create_dir_all(&dir)?;
907
908                // check if account exists already
909                let keystore_path = Path::new(&dir).join(&account_name);
910                if keystore_path.exists() {
911                    eyre::bail!("Keystore file already exists at {}", keystore_path.display());
912                }
913                if touch_id {
914                    ensure_touch_id_sidecar_available(&keystore_path)?;
915                }
916
917                // get wallet
918                let wallet = raw_wallet_options
919                    .signer()?
920                    .and_then(|s| match s {
921                        WalletSigner::Local(s) => Some(s),
922                        _ => None,
923                    })
924                    .ok_or_else(|| {
925                        eyre::eyre!(
926                            "\
927Did you set a private key or mnemonic?
928Run `cast wallet import --help` and use the corresponding CLI
929flag to set your key via:
930--private-key, --mnemonic-path or --interactive."
931                        )
932                    })?;
933
934                let private_key = wallet.credential().to_bytes();
935                let password = if let Some(password) = unsafe_password {
936                    password
937                } else {
938                    // if no --unsafe-password was provided read via stdin
939                    rpassword::prompt_password("Enter password: ")?
940                };
941
942                let mut rng = thread_rng();
943                let (wallet, _) = PrivateKeySigner::encrypt_keystore(
944                    dir,
945                    &mut rng,
946                    private_key,
947                    &password,
948                    Some(&account_name),
949                )?;
950                let address = wallet.address();
951
952                #[cfg(all(target_os = "macos", feature = "touch-id"))]
953                if touch_id {
954                    ensure_touch_id_sidecar_available(&keystore_path).map_err(|e| {
955                        eyre::eyre!(
956                            "keystore was imported at {}, but Touch ID enrollment preflight failed: {e}. The sidecar was left untouched and must be resolved manually before password-prompt fallback is reliable",
957                            keystore_path.display()
958                        )
959                    })?;
960                    if let Err(enrollment_error) = foundry_wallets::touch_id::enroll(
961                        &keystore_path,
962                        &password,
963                        foundry_wallets::touch_id::Policy::default(),
964                    ) {
965                        return Err(touch_id_enrollment_failure(
966                            &keystore_path,
967                            &format!("keystore was imported at {}", keystore_path.display()),
968                            enrollment_error,
969                        ));
970                    }
971                }
972
973                if shell::is_json() {
974                    let mut result = json!({"account": account_name, "address": address});
975                    if touch_id {
976                        result["touch_id"] = json!(true);
977                    }
978                    print_json_success(result)?;
979                } else {
980                    sh_println!(
981                        "{}",
982                        format!(
983                            "`{account_name}` keystore was saved successfully. Address: {address:?}"
984                        )
985                        .green()
986                    )?;
987                    if touch_id {
988                        sh_status!(
989                            "Touch ID-assisted unlock enrolled; password-based unlock remains available."
990                        )?;
991                    }
992                }
993            }
994            Self::List(cmd) => {
995                cmd.run().await?;
996            }
997            Self::Session(args) => {
998                args.run().await?;
999            }
1000            Self::TouchId(args) => {
1001                args.run()?;
1002            }
1003            Self::Remove { name, dir, unsafe_password } => {
1004                ensure_account_name_available(&name)?;
1005                let dir = if let Some(path) = dir {
1006                    Path::new(&path).to_path_buf()
1007                } else {
1008                    Config::foundry_keystores_dir().ok_or_else(|| {
1009                        eyre::eyre!("Could not find the default keystore directory.")
1010                    })?
1011                };
1012
1013                let keystore_path = Path::new(&dir).join(&name);
1014                if !keystore_path.exists() {
1015                    eyre::bail!("Keystore file does not exist at {}", keystore_path.display());
1016                }
1017
1018                let password = if let Some(pwd) = unsafe_password {
1019                    pwd
1020                } else {
1021                    rpassword::prompt_password("Enter password: ")?
1022                };
1023
1024                if PrivateKeySigner::decrypt_keystore(&keystore_path, password).is_err() {
1025                    eyre::bail!("Invalid password - wallet removal cancelled");
1026                }
1027
1028                remove_touch_id_sidecar(&keystore_path)?;
1029
1030                std::fs::remove_file(&keystore_path).wrap_err_with(|| {
1031                    format!("Failed to remove keystore file at {}", keystore_path.display())
1032                })?;
1033
1034                if shell::is_json() {
1035                    print_json_success(json!({"account": name, "removed": true}))?;
1036                } else {
1037                    sh_println!(
1038                        "{}",
1039                        format!("`{name}` keystore was removed successfully.").green()
1040                    )?;
1041                }
1042            }
1043            Self::PrivateKey {
1044                wallet,
1045                mnemonic_override,
1046                mnemonic_index_or_derivation_path_override,
1047            } => {
1048                let (index_override, derivation_path_override) =
1049                    match mnemonic_index_or_derivation_path_override {
1050                        Some(value) => match value.parse::<u32>() {
1051                            Ok(index) => (Some(index), None),
1052                            Err(_) => (None, Some(value)),
1053                        },
1054                        None => (None, None),
1055                    };
1056                let wallet = WalletOpts {
1057                    raw: RawWalletOpts {
1058                        mnemonic: mnemonic_override.or(wallet.raw.mnemonic),
1059                        mnemonic_index: index_override.unwrap_or(wallet.raw.mnemonic_index),
1060                        hd_path: derivation_path_override.or(wallet.raw.hd_path),
1061                        ..wallet.raw
1062                    },
1063                    ..wallet
1064                }
1065                .signer()
1066                .await?;
1067                match wallet {
1068                    WalletSigner::Local(wallet) => {
1069                        let private_key =
1070                            format!("0x{}", hex::encode(wallet.credential().to_bytes()));
1071                        if shell::verbosity() > 0 {
1072                            if shell::is_json() {
1073                                print_json_success(json!({
1074                                    "address": wallet.address(),
1075                                    "private_key": private_key,
1076                                }))?;
1077                            } else {
1078                                sh_println!("Address:     {}", wallet.address())?;
1079                                sh_println!("Private key: {private_key}")?;
1080                            }
1081                        } else {
1082                            print_scalar(private_key)?;
1083                        }
1084                    }
1085                    _ => {
1086                        eyre::bail!("Only local wallets are supported by this command.");
1087                    }
1088                }
1089            }
1090            Self::DecryptKeystore { account_name, keystore_dir, unsafe_password } => {
1091                ensure_account_name_available(&account_name)?;
1092                // Set up keystore directory
1093                let dir = if let Some(path) = keystore_dir {
1094                    Path::new(&path).to_path_buf()
1095                } else {
1096                    Config::foundry_keystores_dir().ok_or_else(|| {
1097                        eyre::eyre!("Could not find the default keystore directory.")
1098                    })?
1099                };
1100
1101                let keypath = dir.join(&account_name);
1102
1103                if !keypath.exists() {
1104                    eyre::bail!("Keystore file does not exist at {}", keypath.display());
1105                }
1106
1107                let password = if let Some(password) = unsafe_password {
1108                    password
1109                } else {
1110                    // if no --unsafe-password was provided read via stdin
1111                    rpassword::prompt_password("Enter password: ")?
1112                };
1113
1114                let wallet = PrivateKeySigner::decrypt_keystore(keypath, password)?;
1115
1116                let private_key = B256::from_slice(&wallet.credential().to_bytes());
1117                if shell::is_json() {
1118                    print_json_success(
1119                        json!({"account": account_name, "private_key": private_key}),
1120                    )?;
1121                } else {
1122                    sh_println!(
1123                        "{}",
1124                        format!("{account_name}'s private key is: {private_key}").green()
1125                    )?;
1126                }
1127            }
1128            Self::ChangePassword {
1129                account_name,
1130                keystore_dir,
1131                unsafe_password,
1132                unsafe_new_password,
1133            } => {
1134                ensure_account_name_available(&account_name)?;
1135                // Set up keystore directory
1136                let dir = if let Some(path) = keystore_dir {
1137                    Path::new(&path).to_path_buf()
1138                } else {
1139                    Config::foundry_keystores_dir().ok_or_else(|| {
1140                        eyre::eyre!("Could not find the default keystore directory.")
1141                    })?
1142                };
1143
1144                let keypath = dir.join(&account_name);
1145
1146                if !keypath.exists() {
1147                    eyre::bail!("Keystore file does not exist at {}", keypath.display());
1148                }
1149
1150                let sidecar = touch_id_sidecar_path(&keypath);
1151
1152                let touch_id_enrolled = match touch_id_sidecar_state(&sidecar)? {
1153                    TouchIdSidecarState::Missing => false,
1154                    TouchIdSidecarState::Recognized => true,
1155
1156                    TouchIdSidecarState::Keystore => {
1157                        eyre::bail!(
1158                            "refusing to change the password because {} is an existing keystore",
1159                            sidecar.display()
1160                        );
1161                    }
1162
1163                    TouchIdSidecarState::Unknown => {
1164                        #[cfg(all(target_os = "macos", feature = "touch-id"))]
1165                        {
1166                            // Preserve useful structured errors such as UnsupportedVersion.
1167                            if let Err(error) = foundry_wallets::touch_id::policy(&keypath) {
1168                                return Err(error.into());
1169                            }
1170                        }
1171
1172                        // Never continue after an Unknown classification, even if another
1173                        // parser happens to accept the file.
1174                        eyre::bail!(
1175                            "refusing to change the password because {} exists and is not a recognized Touch ID sidecar",
1176                            sidecar.display()
1177                        );
1178                    }
1179                };
1180
1181                #[cfg(all(target_os = "macos", feature = "touch-id"))]
1182                let touch_id_policy = touch_id_enrolled
1183                    .then(|| foundry_wallets::touch_id::policy(&keypath))
1184                    .transpose()?;
1185
1186                let current_password = if let Some(password) = unsafe_password {
1187                    password
1188                } else {
1189                    // if no --unsafe-password was provided read via stdin
1190                    rpassword::prompt_password("Enter current password: ")?
1191                };
1192
1193                // decrypt the keystore to verify the current password and get the private key
1194                let wallet = PrivateKeySigner::decrypt_keystore(&keypath, current_password.clone())
1195                    .map_err(|_| eyre::eyre!("Invalid password - password change cancelled"))?;
1196
1197                let new_password = if let Some(password) = unsafe_new_password {
1198                    password
1199                } else {
1200                    // if no --unsafe-new-password was provided read via stdin
1201                    rpassword::prompt_password("Enter new password: ")?
1202                };
1203
1204                if current_password == new_password {
1205                    eyre::bail!("New password cannot be the same as the current password");
1206                }
1207
1208                // Create a new keystore with the new password
1209                let private_key = wallet.credential().to_bytes();
1210                let mut rng = thread_rng();
1211                let (wallet, _) = PrivateKeySigner::encrypt_keystore(
1212                    dir,
1213                    &mut rng,
1214                    private_key,
1215                    &new_password,
1216                    Some(&account_name),
1217                )?;
1218
1219                #[cfg(all(target_os = "macos", feature = "touch-id"))]
1220                if let Some(policy) = touch_id_policy
1221                    && let Err(enrollment_error) =
1222                        foundry_wallets::touch_id::enroll(&keypath, &new_password, policy)
1223                {
1224                    return Err(touch_id_enrollment_failure(
1225                        &keypath,
1226                        &format!(
1227                            "password for keystore `{account_name}` was changed at {}",
1228                            keypath.display()
1229                        ),
1230                        enrollment_error,
1231                    ));
1232                }
1233
1234                #[cfg(not(all(target_os = "macos", feature = "touch-id")))]
1235                if touch_id_enrolled {
1236                    match remove_touch_id_sidecar(&keypath) {
1237                        Ok(true) => {
1238                            sh_warn!(
1239                                "Removed the stale Touch ID enrollment after changing the password"
1240                            )?;
1241                        }
1242                        Ok(false) => {}
1243                        Err(cleanup_error) => {
1244                            eyre::bail!(
1245                                "password changed, but Touch ID sidecar cleanup failed: {cleanup_error}. The new password is valid; remove {} manually",
1246                                touch_id_sidecar_path(&keypath).display()
1247                            );
1248                        }
1249                    }
1250                }
1251
1252                let address = wallet.address();
1253                if shell::is_json() {
1254                    print_json_success(json!({"account": account_name, "address": address}))?;
1255                } else {
1256                    sh_println!(
1257                        "{}",
1258                        format!(
1259                            "Password for keystore `{account_name}` was changed successfully. Address: {address:?}"
1260                        )
1261                        .green()
1262                    )?;
1263                }
1264            }
1265        };
1266
1267        Ok(())
1268    }
1269
1270    /// Recovers an address from the specified message and signature.
1271    ///
1272    /// Note: This attempts to decode the message as hex if it starts with 0x.
1273    fn recover_address_from_message(message: &str, signature: &Signature) -> Result<Address> {
1274        let message = Self::hex_str_to_bytes(message)?;
1275        Ok(signature.recover_address_from_msg(message)?)
1276    }
1277
1278    /// Recovers an address from the specified message and signature.
1279    fn recover_address_from_message_no_hash(
1280        prehash: &B256,
1281        signature: &Signature,
1282    ) -> Result<Address> {
1283        Ok(signature.recover_address_from_prehash(prehash)?)
1284    }
1285
1286    /// Recovers an address from the specified EIP-712 typed data and signature.
1287    fn recover_address_from_typed_data(
1288        typed_data: &TypedData,
1289        signature: &Signature,
1290    ) -> Result<Address> {
1291        Ok(signature.recover_address_from_prehash(&typed_data.eip712_signing_hash()?)?)
1292    }
1293
1294    /// Strips the 0x prefix from a hex string and decodes it to bytes.
1295    ///
1296    /// Treats the string as raw bytes if it doesn't start with 0x.
1297    fn hex_str_to_bytes(s: &str) -> Result<Vec<u8>> {
1298        Ok(match s.strip_prefix("0x") {
1299            Some(data) => hex::decode(data).wrap_err("Could not decode 0x-prefixed string.")?,
1300            None => s.as_bytes().to_vec(),
1301        })
1302    }
1303}
1304
1305fn ensure_touch_id_available(touch_id: bool) -> Result<()> {
1306    if !touch_id {
1307        return Ok(());
1308    }
1309
1310    #[cfg(all(target_os = "macos", feature = "touch-id"))]
1311    {
1312        if !foundry_wallets::touch_id::is_available() {
1313            eyre::bail!("Touch ID is unavailable on this Mac");
1314        }
1315        Ok(())
1316    }
1317
1318    #[cfg(not(all(target_os = "macos", feature = "touch-id")))]
1319    eyre::bail!("`--touch-id` requires macOS and a cast build with the `touch-id` feature");
1320}
1321
1322const TOUCH_ID_SIDECAR_SUFFIX: &str = ".touchid";
1323
1324fn ensure_account_name_available(name: &str) -> Result<()> {
1325    let file_name = Path::new(name).file_name().and_then(|s| s.to_str());
1326    if name.is_empty() || name.contains('\\') || file_name != Some(name) {
1327        eyre::bail!("account name must be a single path segment");
1328    }
1329    if name.ends_with(TOUCH_ID_SIDECAR_SUFFIX) {
1330        eyre::bail!("account names ending in `{TOUCH_ID_SIDECAR_SUFFIX}` are reserved");
1331    }
1332    Ok(())
1333}
1334
1335fn touch_id_sidecar_path(keystore_path: &Path) -> PathBuf {
1336    let mut path = OsString::from(keystore_path.as_os_str());
1337    path.push(TOUCH_ID_SIDECAR_SUFFIX);
1338    path.into()
1339}
1340
1341fn is_not_found(error: &FsPathError) -> bool {
1342    matches!(error, FsPathError::Read { source, .. } if source.kind() == std::io::ErrorKind::NotFound)
1343}
1344
1345/// Classification of a file at a `.touchid` path.
1346#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1347enum TouchIdSidecarState {
1348    /// No filesystem entry exists at the path.
1349    Missing,
1350    /// The file strictly matches the currently supported Touch ID sidecar schema.
1351    Recognized,
1352    /// The file is an Ethereum keystore.
1353    Keystore,
1354    /// Anything else: malformed JSON, empty object, array, unrelated object,
1355    /// unsupported sidecar version, unknown policy, invalid hex, empty payload,
1356    /// truncated sealed payload, invalid X9.63 prefix, or future sidecar format.
1357    Unknown,
1358}
1359
1360/// The only sidecar version this Cast release understands.
1361const TOUCH_ID_SIDECAR_VERSION: u32 = 1;
1362
1363/// Minimum encoded ciphertext payload:
1364/// 65-byte P-256 X9.63 public key + 12-byte ChaChaPoly nonce + 16-byte tag.
1365///
1366/// The encrypted password itself may be empty, so 93 bytes is the true minimum.
1367const TOUCH_ID_SEALED_PASSWORD_MIN_LEN: usize = 65 + 12 + 16;
1368
1369/// X9.63 prefix for an uncompressed P-256 public key.
1370const TOUCH_ID_X963_UNCOMPRESSED_PREFIX: u8 = 0x04;
1371
1372/// Strict deserialization-only representation of the persisted sidecar format.
1373///
1374/// Uses `deny_unknown_fields` so that any unrecognized field (e.g. from a
1375/// future sidecar version) causes a parse failure, which maps to `Unknown`.
1376#[derive(Debug, serde::Deserialize)]
1377#[serde(deny_unknown_fields)]
1378#[allow(dead_code)]
1379struct TouchIdSidecarWire {
1380    version: u32,
1381    policy: TouchIdPolicyWire,
1382    se_key: String,
1383    sealed_password: String,
1384}
1385
1386/// The policy values currently recognised by this Cast release.
1387#[derive(Clone, Copy, Debug, serde::Deserialize)]
1388#[serde(rename_all = "kebab-case")]
1389enum TouchIdPolicyWire {
1390    UserPresence,
1391    CurrentBiometry,
1392}
1393
1394impl TouchIdPolicyWire {
1395    const fn as_str(self) -> &'static str {
1396        match self {
1397            Self::UserPresence => "user-presence",
1398            Self::CurrentBiometry => "current-biometry",
1399        }
1400    }
1401}
1402
1403/// Validates that `wire` contains plausible payload bytes:
1404/// - `se_key` is valid hex and non-empty.
1405/// - `sealed_password` is valid hex, at least 93 decoded bytes, and starts with 0x04.
1406fn has_valid_touch_id_payload(wire: &TouchIdSidecarWire) -> bool {
1407    let Ok(se_key) = hex::decode(&wire.se_key) else {
1408        return false;
1409    };
1410
1411    if se_key.is_empty() {
1412        return false;
1413    }
1414
1415    let Ok(sealed_password) = hex::decode(&wire.sealed_password) else {
1416        return false;
1417    };
1418
1419    sealed_password.len() >= TOUCH_ID_SEALED_PASSWORD_MIN_LEN
1420        && sealed_password.first().copied() == Some(TOUCH_ID_X963_UNCOMPRESSED_PREFIX)
1421}
1422
1423/// Returns the state of the file at `path` with respect to the Touch ID sidecar schema.
1424///
1425/// Classification order:
1426/// 1. Not found → `Missing`
1427/// 2. Has both `version` and `crypto`/`Crypto` fields → `Keystore`
1428/// 3. Parses strictly as a v1 Touch ID sidecar + plausible payload → `Recognized`
1429/// 4. Everything else → `Unknown`
1430fn touch_id_sidecar_state(path: &Path) -> Result<TouchIdSidecarState> {
1431    let value = match fs::read_json_file::<serde_json::Value>(path) {
1432        Ok(v) => v,
1433        Err(e) if is_not_found(&e) => return Ok(TouchIdSidecarState::Missing),
1434        Err(e) => return Err(e.into()),
1435    };
1436
1437    // Check for Ethereum keystore before attempting sidecar parse.
1438    // Preserves both lowercase and uppercase `crypto` field variants.
1439    if value.get("version").is_some()
1440        && (value.get("crypto").is_some() || value.get("Crypto").is_some())
1441    {
1442        return Ok(TouchIdSidecarState::Keystore);
1443    }
1444
1445    // Attempt strict sidecar parse. Any missing/extra field, unsupported
1446    // version/policy, or invalid payload maps to `Unknown` rather than `Recognized`.
1447    match serde_json::from_value::<TouchIdSidecarWire>(value) {
1448        Ok(wire)
1449            if wire.version == TOUCH_ID_SIDECAR_VERSION && has_valid_touch_id_payload(&wire) =>
1450        {
1451            Ok(TouchIdSidecarState::Recognized)
1452        }
1453        _ => Ok(TouchIdSidecarState::Unknown),
1454    }
1455}
1456
1457fn touch_id_sidecar_policy(path: &Path) -> Result<TouchIdPolicyWire> {
1458    let value = fs::read_json_file::<serde_json::Value>(path)?;
1459    let wire = serde_json::from_value::<TouchIdSidecarWire>(value)
1460        .wrap_err_with(|| format!("failed to parse Touch ID sidecar at {}", path.display()))?;
1461    if wire.version != TOUCH_ID_SIDECAR_VERSION || !has_valid_touch_id_payload(&wire) {
1462        eyre::bail!("{} is not a recognized Touch ID sidecar", path.display());
1463    }
1464    Ok(wire.policy)
1465}
1466
1467fn is_touch_id_sidecar(path: &Path) -> Result<bool> {
1468    let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
1469        return Ok(false);
1470    };
1471    if !name.ends_with(TOUCH_ID_SIDECAR_SUFFIX) {
1472        return Ok(false);
1473    }
1474    Ok(matches!(touch_id_sidecar_state(path)?, TouchIdSidecarState::Recognized))
1475}
1476
1477fn ensure_touch_id_sidecar_available(keystore_path: &Path) -> Result<()> {
1478    let sidecar = touch_id_sidecar_path(keystore_path);
1479    match touch_id_sidecar_state(&sidecar)? {
1480        TouchIdSidecarState::Missing | TouchIdSidecarState::Recognized => Ok(()),
1481        TouchIdSidecarState::Keystore => {
1482            eyre::bail!(
1483                "refusing Touch ID enrollment because {} is an existing keystore",
1484                sidecar.display()
1485            );
1486        }
1487        TouchIdSidecarState::Unknown => {
1488            eyre::bail!(
1489                "refusing Touch ID enrollment because {} already exists and is not a recognized Touch ID sidecar",
1490                sidecar.display()
1491            );
1492        }
1493    }
1494}
1495
1496fn indexed_account_name(base: &str, number: u32, index: u32) -> String {
1497    if number == 1 { base.to_string() } else { format!("{base}_{}", index + 1) }
1498}
1499
1500fn ensure_touch_id_sidecars_available(
1501    dir: &Path,
1502    account_name: Option<&str>,
1503    number: u32,
1504) -> Result<()> {
1505    let Some(account_name) = account_name else { return Ok(()) };
1506    for index in 0..number {
1507        ensure_touch_id_sidecar_available(&dir.join(indexed_account_name(
1508            account_name,
1509            number,
1510            index,
1511        )))?;
1512    }
1513    Ok(())
1514}
1515
1516fn remove_touch_id_sidecar(keystore_path: &Path) -> Result<bool> {
1517    let sidecar = touch_id_sidecar_path(keystore_path);
1518    match touch_id_sidecar_state(&sidecar)? {
1519        TouchIdSidecarState::Missing => Ok(false),
1520        TouchIdSidecarState::Recognized => match std::fs::remove_file(&sidecar) {
1521            Ok(()) => Ok(true),
1522            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
1523            Err(error) => Err(error).wrap_err_with(|| {
1524                format!("Failed to remove Touch ID sidecar at {}", sidecar.display())
1525            }),
1526        },
1527        TouchIdSidecarState::Keystore => {
1528            eyre::bail!("refusing to remove existing keystore at {}", sidecar.display());
1529        }
1530        TouchIdSidecarState::Unknown => {
1531            eyre::bail!(
1532                "refusing to remove {} because it is not a recognized Touch ID sidecar",
1533                sidecar.display()
1534            );
1535        }
1536    }
1537}
1538
1539#[cfg(all(target_os = "macos", feature = "touch-id"))]
1540fn touch_id_enrollment_failure(
1541    keystore_path: &Path,
1542    completed_action: &str,
1543    enrollment_error: impl std::fmt::Display,
1544) -> eyre::Report {
1545    match remove_touch_id_sidecar(keystore_path) {
1546        Ok(true) => eyre::eyre!(
1547            "{completed_action}, but Touch ID enrollment failed: {enrollment_error}. The stale Touch ID sidecar was removed; password-prompt fallback remains available"
1548        ),
1549        Ok(false) => eyre::eyre!(
1550            "{completed_action}, but Touch ID enrollment failed: {enrollment_error}. No stale Touch ID sidecar remained; password-prompt fallback remains available"
1551        ),
1552        Err(cleanup_error) => eyre::eyre!(
1553            "{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",
1554            touch_id_sidecar_path(keystore_path).display()
1555        ),
1556    }
1557}
1558
1559#[cfg(test)]
1560mod tests {
1561    use super::{session::SessionSubcommands, *};
1562    use alloy_primitives::{address, keccak256};
1563    use std::str::FromStr;
1564
1565    // ── Touch ID sidecar classification ────────────────────────────────────────
1566
1567    fn valid_sealed_password_hex() -> String {
1568        format!("04{}", "00".repeat(TOUCH_ID_SEALED_PASSWORD_MIN_LEN - 1))
1569    }
1570
1571    fn touch_id_sidecar_json(
1572        version: u32,
1573        policy: &str,
1574        se_key: &str,
1575        sealed_password: &str,
1576    ) -> String {
1577        serde_json::json!({
1578            "version": version,
1579            "policy": policy,
1580            "se_key": se_key,
1581            "sealed_password": sealed_password,
1582        })
1583        .to_string()
1584    }
1585
1586    fn valid_touch_id_sidecar_json(policy: &str) -> String {
1587        let sealed_password = valid_sealed_password_hex();
1588        touch_id_sidecar_json(TOUCH_ID_SIDECAR_VERSION, policy, "aa", &sealed_password)
1589    }
1590
1591    /// Returns a temp dir and the path `<dir>/account.touchid`.
1592    fn setup_sidecar_path() -> (tempfile::TempDir, std::path::PathBuf) {
1593        let dir = tempfile::tempdir().unwrap();
1594        let path = dir.path().join("account.touchid");
1595        (dir, path)
1596    }
1597
1598    /// Helper: write content to `path` and return the path.
1599    fn write<'a>(path: &'a std::path::Path, content: &str) -> &'a std::path::Path {
1600        std::fs::write(path, content).unwrap();
1601        path
1602    }
1603
1604    // ── is_touch_id_sidecar ────────────────────────────────────────────────────
1605
1606    #[test]
1607    fn recognized_sidecar_user_presence() {
1608        let (_dir, p) = setup_sidecar_path();
1609        write(&p, &valid_touch_id_sidecar_json("user-presence"));
1610        assert!(is_touch_id_sidecar(&p).unwrap());
1611        assert_eq!(touch_id_sidecar_state(&p).unwrap(), TouchIdSidecarState::Recognized);
1612    }
1613
1614    #[test]
1615    fn recognized_sidecar_current_biometry() {
1616        let (_dir, p) = setup_sidecar_path();
1617        write(&p, &valid_touch_id_sidecar_json("current-biometry"));
1618        assert!(is_touch_id_sidecar(&p).unwrap());
1619        assert_eq!(touch_id_sidecar_state(&p).unwrap(), TouchIdSidecarState::Recognized);
1620    }
1621
1622    #[test]
1623    fn empty_object_is_unknown() {
1624        let (_dir, p) = setup_sidecar_path();
1625        write(&p, "{}");
1626        assert!(!is_touch_id_sidecar(&p).unwrap());
1627        assert_eq!(touch_id_sidecar_state(&p).unwrap(), TouchIdSidecarState::Unknown);
1628    }
1629
1630    #[test]
1631    fn array_is_unknown() {
1632        let (_dir, p) = setup_sidecar_path();
1633        write(&p, "[]");
1634        assert!(!is_touch_id_sidecar(&p).unwrap());
1635        assert_eq!(touch_id_sidecar_state(&p).unwrap(), TouchIdSidecarState::Unknown);
1636    }
1637
1638    #[test]
1639    fn unrelated_object_is_unknown() {
1640        let (_dir, p) = setup_sidecar_path();
1641        write(&p, r#"{"application":"unrelated"}"#);
1642        assert!(!is_touch_id_sidecar(&p).unwrap());
1643        assert_eq!(touch_id_sidecar_state(&p).unwrap(), TouchIdSidecarState::Unknown);
1644    }
1645
1646    #[test]
1647    fn unknown_version_is_unknown() {
1648        let (_dir, p) = setup_sidecar_path();
1649        let content = touch_id_sidecar_json(2, "user-presence", "aa", &valid_sealed_password_hex());
1650        write(&p, &content);
1651        assert!(!is_touch_id_sidecar(&p).unwrap());
1652        assert_eq!(touch_id_sidecar_state(&p).unwrap(), TouchIdSidecarState::Unknown);
1653    }
1654
1655    #[test]
1656    fn unknown_field_is_unknown_due_to_deny_unknown_fields() {
1657        let (_dir, p) = setup_sidecar_path();
1658        let json = serde_json::json!({
1659            "version": 1,
1660            "policy": "user-presence",
1661            "se_key": "aa",
1662            "sealed_password": valid_sealed_password_hex(),
1663            "future_field": true
1664        })
1665        .to_string();
1666        write(&p, &json);
1667        assert!(!is_touch_id_sidecar(&p).unwrap());
1668        assert_eq!(touch_id_sidecar_state(&p).unwrap(), TouchIdSidecarState::Unknown);
1669    }
1670
1671    #[test]
1672    fn unknown_policy_is_unknown() {
1673        let (_dir, p) = setup_sidecar_path();
1674        let content = touch_id_sidecar_json(1, "future-policy", "aa", &valid_sealed_password_hex());
1675        write(&p, &content);
1676        assert!(!is_touch_id_sidecar(&p).unwrap());
1677        assert_eq!(touch_id_sidecar_state(&p).unwrap(), TouchIdSidecarState::Unknown);
1678    }
1679
1680    #[test]
1681    fn empty_se_key_is_unknown() {
1682        let (_dir, p) = setup_sidecar_path();
1683        let content = touch_id_sidecar_json(1, "user-presence", "", &valid_sealed_password_hex());
1684        write(&p, &content);
1685        assert!(!is_touch_id_sidecar(&p).unwrap());
1686        assert_eq!(touch_id_sidecar_state(&p).unwrap(), TouchIdSidecarState::Unknown);
1687    }
1688
1689    #[test]
1690    fn non_hex_se_key_is_unknown() {
1691        let (_dir, p) = setup_sidecar_path();
1692        let content = touch_id_sidecar_json(1, "user-presence", "zz", &valid_sealed_password_hex());
1693        write(&p, &content);
1694        assert!(!is_touch_id_sidecar(&p).unwrap());
1695        assert_eq!(touch_id_sidecar_state(&p).unwrap(), TouchIdSidecarState::Unknown);
1696    }
1697
1698    #[test]
1699    fn empty_sealed_password_is_unknown() {
1700        let (_dir, p) = setup_sidecar_path();
1701        let content = touch_id_sidecar_json(1, "user-presence", "aa", "");
1702        write(&p, &content);
1703        assert!(!is_touch_id_sidecar(&p).unwrap());
1704        assert_eq!(touch_id_sidecar_state(&p).unwrap(), TouchIdSidecarState::Unknown);
1705    }
1706
1707    #[test]
1708    fn non_hex_sealed_password_is_unknown() {
1709        let (_dir, p) = setup_sidecar_path();
1710        let content = touch_id_sidecar_json(1, "user-presence", "aa", "zz");
1711        write(&p, &content);
1712        assert!(!is_touch_id_sidecar(&p).unwrap());
1713        assert_eq!(touch_id_sidecar_state(&p).unwrap(), TouchIdSidecarState::Unknown);
1714    }
1715
1716    #[test]
1717    fn truncated_sealed_password_is_unknown() {
1718        let (_dir, p) = setup_sidecar_path();
1719        let truncated_sealed = format!("04{}", "00".repeat(TOUCH_ID_SEALED_PASSWORD_MIN_LEN - 2));
1720        let content = touch_id_sidecar_json(1, "user-presence", "aa", &truncated_sealed);
1721        write(&p, &content);
1722        assert!(!is_touch_id_sidecar(&p).unwrap());
1723        assert_eq!(touch_id_sidecar_state(&p).unwrap(), TouchIdSidecarState::Unknown);
1724    }
1725
1726    #[test]
1727    fn sealed_password_without_uncompressed_point_prefix_is_unknown() {
1728        let (_dir, p) = setup_sidecar_path();
1729        let invalid_prefix_sealed =
1730            format!("03{}", "00".repeat(TOUCH_ID_SEALED_PASSWORD_MIN_LEN - 1));
1731        let content = touch_id_sidecar_json(1, "user-presence", "aa", &invalid_prefix_sealed);
1732        write(&p, &content);
1733        assert!(!is_touch_id_sidecar(&p).unwrap());
1734        assert_eq!(touch_id_sidecar_state(&p).unwrap(), TouchIdSidecarState::Unknown);
1735    }
1736
1737    #[test]
1738    fn minimum_valid_sealed_password_is_recognized() {
1739        let (_dir, p) = setup_sidecar_path();
1740        let exact_min_sealed = format!("04{}", "00".repeat(TOUCH_ID_SEALED_PASSWORD_MIN_LEN - 1));
1741        let content = touch_id_sidecar_json(1, "user-presence", "aa", &exact_min_sealed);
1742        write(&p, &content);
1743        assert!(is_touch_id_sidecar(&p).unwrap());
1744        assert_eq!(touch_id_sidecar_state(&p).unwrap(), TouchIdSidecarState::Recognized);
1745    }
1746
1747    #[test]
1748    fn invalid_payload_sidecars_are_preserved() {
1749        let valid_sealed = valid_sealed_password_hex();
1750        let truncated_sealed = format!("04{}", "00".repeat(TOUCH_ID_SEALED_PASSWORD_MIN_LEN - 2));
1751        let bad_prefix_sealed = format!("03{}", "00".repeat(TOUCH_ID_SEALED_PASSWORD_MIN_LEN - 1));
1752
1753        let invalid_fixtures = [
1754            touch_id_sidecar_json(1, "user-presence", "", &valid_sealed),
1755            touch_id_sidecar_json(1, "user-presence", "zz", &valid_sealed),
1756            touch_id_sidecar_json(1, "user-presence", "aa", ""),
1757            touch_id_sidecar_json(1, "user-presence", "aa", "zz"),
1758            touch_id_sidecar_json(1, "user-presence", "aa", &truncated_sealed),
1759            touch_id_sidecar_json(1, "user-presence", "aa", &bad_prefix_sealed),
1760        ];
1761
1762        for fixture in invalid_fixtures {
1763            let dir = tempfile::tempdir().unwrap();
1764            let sidecar = dir.path().join("account.touchid");
1765            write(&sidecar, &fixture);
1766            let k_path = keystore_path(dir.path());
1767
1768            let err = ensure_touch_id_sidecar_available(&k_path).unwrap_err();
1769            assert!(
1770                err.to_string().contains("is not a recognized Touch ID sidecar"),
1771                "unexpected error: {err}"
1772            );
1773            assert_eq!(std::fs::read_to_string(&sidecar).unwrap(), fixture);
1774
1775            let err = remove_touch_id_sidecar(&k_path).unwrap_err();
1776            assert!(
1777                err.to_string().contains("is not a recognized Touch ID sidecar"),
1778                "unexpected error: {err}"
1779            );
1780            assert_eq!(std::fs::read_to_string(&sidecar).unwrap(), fixture);
1781        }
1782    }
1783
1784    #[test]
1785    fn malformed_json_propagates_error() {
1786        let (_dir, p) = setup_sidecar_path();
1787        write(&p, "not json");
1788        // Malformed JSON is an I/O/parse error, not Unknown.
1789        assert!(is_touch_id_sidecar(&p).is_err());
1790        assert!(touch_id_sidecar_state(&p).is_err());
1791    }
1792
1793    #[test]
1794    fn keystore_lowercase_crypto_is_keystore() {
1795        let (_dir, p) = setup_sidecar_path();
1796        write(&p, r#"{"version":3,"crypto":{}}"#);
1797        assert!(!is_touch_id_sidecar(&p).unwrap());
1798        assert_eq!(touch_id_sidecar_state(&p).unwrap(), TouchIdSidecarState::Keystore);
1799    }
1800
1801    #[test]
1802    fn keystore_uppercase_crypto_is_keystore() {
1803        let (_dir, p) = setup_sidecar_path();
1804        write(&p, r#"{"version":3,"Crypto":{}}"#);
1805        assert!(!is_touch_id_sidecar(&p).unwrap());
1806        assert_eq!(touch_id_sidecar_state(&p).unwrap(), TouchIdSidecarState::Keystore);
1807    }
1808
1809    #[test]
1810    fn missing_file_is_missing() {
1811        let (_dir, p) = setup_sidecar_path();
1812        // File was never created.
1813        assert!(!is_touch_id_sidecar(&p).unwrap());
1814        assert_eq!(touch_id_sidecar_state(&p).unwrap(), TouchIdSidecarState::Missing);
1815    }
1816
1817    // ── ensure_touch_id_sidecar_available ─────────────────────────────────────
1818
1819    /// Writes a recognized sidecar at `<dir>/account.touchid` and calls
1820    /// `ensure_touch_id_sidecar_available` for `<dir>/account`.
1821    fn keystore_path(dir: &std::path::Path) -> std::path::PathBuf {
1822        dir.join("account")
1823    }
1824
1825    #[test]
1826    fn enrollment_allows_missing_sidecar() {
1827        let dir = tempfile::tempdir().unwrap();
1828        // No sidecar file exists — enrollment must succeed.
1829        ensure_touch_id_sidecar_available(&keystore_path(dir.path())).unwrap();
1830    }
1831
1832    #[test]
1833    fn enrollment_allows_replacing_recognized_sidecar() {
1834        let dir = tempfile::tempdir().unwrap();
1835        let sidecar = dir.path().join("account.touchid");
1836        write(&sidecar, &valid_touch_id_sidecar_json("user-presence"));
1837        // Existing recognized sidecar — re-enrollment must succeed.
1838        ensure_touch_id_sidecar_available(&keystore_path(dir.path())).unwrap();
1839    }
1840
1841    #[test]
1842    fn enrollment_refuses_keystore() {
1843        let dir = tempfile::tempdir().unwrap();
1844        let sidecar = dir.path().join("account.touchid");
1845        write(&sidecar, r#"{"version":3,"crypto":{}}"#);
1846        let err = ensure_touch_id_sidecar_available(&keystore_path(dir.path())).unwrap_err();
1847        assert!(err.to_string().contains("is an existing keystore"), "unexpected error: {err}");
1848        // File must be untouched.
1849        assert_eq!(std::fs::read_to_string(&sidecar).unwrap(), r#"{"version":3,"crypto":{}}"#);
1850    }
1851
1852    #[test]
1853    fn enrollment_refuses_unknown_file() {
1854        let dir = tempfile::tempdir().unwrap();
1855        let sidecar = dir.path().join("account.touchid");
1856        write(&sidecar, r#"{"application":"unrelated"}"#);
1857        let err = ensure_touch_id_sidecar_available(&keystore_path(dir.path())).unwrap_err();
1858        assert!(
1859            err.to_string().contains("is not a recognized Touch ID sidecar"),
1860            "unexpected error: {err}"
1861        );
1862        // File must be untouched.
1863        assert_eq!(std::fs::read_to_string(&sidecar).unwrap(), r#"{"application":"unrelated"}"#);
1864    }
1865
1866    #[test]
1867    fn enrollment_refuses_empty_object() {
1868        let dir = tempfile::tempdir().unwrap();
1869        let sidecar = dir.path().join("account.touchid");
1870        write(&sidecar, "{}");
1871        let err = ensure_touch_id_sidecar_available(&keystore_path(dir.path())).unwrap_err();
1872        assert!(
1873            err.to_string().contains("is not a recognized Touch ID sidecar"),
1874            "unexpected error: {err}"
1875        );
1876        assert_eq!(std::fs::read_to_string(&sidecar).unwrap(), "{}");
1877    }
1878
1879    #[test]
1880    fn enrollment_refuses_array() {
1881        let dir = tempfile::tempdir().unwrap();
1882        let sidecar = dir.path().join("account.touchid");
1883        write(&sidecar, "[]");
1884        let err = ensure_touch_id_sidecar_available(&keystore_path(dir.path())).unwrap_err();
1885        assert!(
1886            err.to_string().contains("is not a recognized Touch ID sidecar"),
1887            "unexpected error: {err}"
1888        );
1889        assert_eq!(std::fs::read_to_string(&sidecar).unwrap(), "[]");
1890    }
1891
1892    #[test]
1893    fn enrollment_refuses_unknown_version() {
1894        let dir = tempfile::tempdir().unwrap();
1895        let sidecar = dir.path().join("account.touchid");
1896        let content = touch_id_sidecar_json(2, "user-presence", "aa", &valid_sealed_password_hex());
1897        write(&sidecar, &content);
1898        let err = ensure_touch_id_sidecar_available(&keystore_path(dir.path())).unwrap_err();
1899        assert!(
1900            err.to_string().contains("is not a recognized Touch ID sidecar"),
1901            "unexpected error: {err}"
1902        );
1903        // Future sidecar format must not be destroyed.
1904        assert_eq!(std::fs::read_to_string(&sidecar).unwrap(), content);
1905    }
1906
1907    // ── remove_touch_id_sidecar ────────────────────────────────────────────────
1908
1909    #[test]
1910    fn removal_returns_false_for_missing_sidecar() {
1911        let dir = tempfile::tempdir().unwrap();
1912        let removed = remove_touch_id_sidecar(&keystore_path(dir.path())).unwrap();
1913        assert!(!removed);
1914    }
1915
1916    #[test]
1917    fn removal_deletes_recognized_sidecar() {
1918        let dir = tempfile::tempdir().unwrap();
1919        let sidecar = dir.path().join("account.touchid");
1920        write(&sidecar, &valid_touch_id_sidecar_json("user-presence"));
1921        let removed = remove_touch_id_sidecar(&keystore_path(dir.path())).unwrap();
1922        assert!(removed);
1923        assert!(!sidecar.exists());
1924    }
1925
1926    #[test]
1927    fn removal_refuses_keystore() {
1928        let dir = tempfile::tempdir().unwrap();
1929        let sidecar = dir.path().join("account.touchid");
1930        let content = r#"{"version":3,"crypto":{}}"#;
1931        write(&sidecar, content);
1932        let err = remove_touch_id_sidecar(&keystore_path(dir.path())).unwrap_err();
1933        assert!(
1934            err.to_string().contains("refusing to remove existing keystore"),
1935            "unexpected error: {err}"
1936        );
1937        assert_eq!(std::fs::read_to_string(&sidecar).unwrap(), content);
1938    }
1939
1940    #[test]
1941    fn removal_refuses_empty_object() {
1942        let dir = tempfile::tempdir().unwrap();
1943        let sidecar = dir.path().join("account.touchid");
1944        write(&sidecar, "{}");
1945        let err = remove_touch_id_sidecar(&keystore_path(dir.path())).unwrap_err();
1946        assert!(
1947            err.to_string().contains("is not a recognized Touch ID sidecar"),
1948            "unexpected error: {err}"
1949        );
1950        assert_eq!(std::fs::read_to_string(&sidecar).unwrap(), "{}");
1951    }
1952
1953    #[test]
1954    fn removal_refuses_array() {
1955        let dir = tempfile::tempdir().unwrap();
1956        let sidecar = dir.path().join("account.touchid");
1957        write(&sidecar, "[]");
1958        let err = remove_touch_id_sidecar(&keystore_path(dir.path())).unwrap_err();
1959        assert!(
1960            err.to_string().contains("is not a recognized Touch ID sidecar"),
1961            "unexpected error: {err}"
1962        );
1963        assert_eq!(std::fs::read_to_string(&sidecar).unwrap(), "[]");
1964    }
1965
1966    #[test]
1967    fn removal_refuses_unrelated_object() {
1968        let dir = tempfile::tempdir().unwrap();
1969        let sidecar = dir.path().join("account.touchid");
1970        let content = r#"{"application":"unrelated"}"#;
1971        write(&sidecar, content);
1972        let err = remove_touch_id_sidecar(&keystore_path(dir.path())).unwrap_err();
1973        assert!(
1974            err.to_string().contains("is not a recognized Touch ID sidecar"),
1975            "unexpected error: {err}"
1976        );
1977        assert_eq!(std::fs::read_to_string(&sidecar).unwrap(), content);
1978    }
1979
1980    #[test]
1981    fn removal_refuses_unknown_version() {
1982        let dir = tempfile::tempdir().unwrap();
1983        let sidecar = dir.path().join("account.touchid");
1984        let content = touch_id_sidecar_json(2, "user-presence", "aa", &valid_sealed_password_hex());
1985        write(&sidecar, &content);
1986        let err = remove_touch_id_sidecar(&keystore_path(dir.path())).unwrap_err();
1987        assert!(
1988            err.to_string().contains("is not a recognized Touch ID sidecar"),
1989            "unexpected error: {err}"
1990        );
1991        // Future sidecar format must not be destroyed.
1992        assert_eq!(std::fs::read_to_string(&sidecar).unwrap(), content);
1993    }
1994
1995    // ── Wallet listing regression ──────────────────────────────────────────────
1996
1997    /// Verify that the listing filter uses `!matches!(is_touch_id_sidecar(&path), Ok(true))`:
1998    /// - recognized v1 sidecars are hidden
1999    /// - unknown-content `.touchid` files are retained
2000    /// - unknown-version `.touchid` files are retained
2001    /// - invalid-payload `.touchid` files are retained
2002    #[test]
2003    fn listing_hides_only_recognized_sidecars() {
2004        // is_touch_id_sidecar returns Ok(true) only for Recognized.
2005        let dir = tempfile::tempdir().unwrap();
2006
2007        let recognized = dir.path().join("recognized.touchid");
2008        write(&recognized, &valid_touch_id_sidecar_json("user-presence"));
2009
2010        let unknown_content = dir.path().join("unknown_content.touchid");
2011        write(&unknown_content, r#"{"application":"unrelated"}"#);
2012
2013        let unknown_version = dir.path().join("unknown_version.touchid");
2014        write(
2015            &unknown_version,
2016            &touch_id_sidecar_json(2, "user-presence", "aa", &valid_sealed_password_hex()),
2017        );
2018
2019        let invalid_payload = dir.path().join("invalid_payload.touchid");
2020        write(&invalid_payload, &touch_id_sidecar_json(1, "user-presence", "aa", "bb"));
2021
2022        // Recognized sidecar → is_touch_id_sidecar returns Ok(true) → hidden.
2023        assert!(matches!(is_touch_id_sidecar(&recognized), Ok(true)));
2024        // Unknown content → Ok(false) → retained by listing.
2025        assert!(matches!(is_touch_id_sidecar(&unknown_content), Ok(false)));
2026        // Unknown version → Ok(false) → retained by listing.
2027        assert!(matches!(is_touch_id_sidecar(&unknown_version), Ok(false)));
2028        // Invalid payload → Ok(false) → retained by listing.
2029        assert!(matches!(is_touch_id_sidecar(&invalid_payload), Ok(false)));
2030    }
2031
2032    // ── preflights_every_named_touch_id_sidecar (preserved from before) ────────
2033
2034    #[test]
2035    fn preflights_every_named_touch_id_sidecar() {
2036        let dir = tempfile::tempdir().unwrap();
2037        let sidecar = dir.path().join("batch_2.touchid");
2038        std::fs::write(&sidecar, r#"{"version":3,"crypto":{}}"#).unwrap();
2039
2040        let error = ensure_touch_id_sidecars_available(dir.path(), Some("batch"), 2).unwrap_err();
2041        assert_eq!(
2042            error.to_string(),
2043            format!(
2044                "refusing Touch ID enrollment because {} is an existing keystore",
2045                sidecar.display()
2046            )
2047        );
2048    }
2049
2050    #[test]
2051    fn can_parse_wallet_sign_message() {
2052        let args = WalletSubcommands::parse_from(["foundry-cli", "sign", "deadbeef"]);
2053        match args {
2054            WalletSubcommands::Sign { message, data, from_file, .. } => {
2055                assert_eq!(message, "deadbeef".to_string());
2056                assert!(!data);
2057                assert!(!from_file);
2058            }
2059            _ => panic!("expected WalletSubcommands::Sign"),
2060        }
2061    }
2062
2063    #[test]
2064    fn can_parse_wallet_new_touch_id() {
2065        let args = WalletSubcommands::parse_from(["foundry-cli", "new", "--touch-id"]);
2066        match args {
2067            WalletSubcommands::New { touch_id, .. } => assert!(touch_id),
2068            _ => panic!("expected WalletSubcommands::New"),
2069        }
2070    }
2071
2072    #[test]
2073    fn can_parse_wallet_import_touch_id() {
2074        let args = WalletSubcommands::parse_from([
2075            "foundry-cli",
2076            "import",
2077            "my_account",
2078            "--touch-id",
2079            "--private-key",
2080            "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
2081        ]);
2082        match args {
2083            WalletSubcommands::Import { touch_id, .. } => assert!(touch_id),
2084            _ => panic!("expected WalletSubcommands::Import"),
2085        }
2086    }
2087
2088    #[test]
2089    fn can_parse_wallet_sign_hex_message() {
2090        let args = WalletSubcommands::parse_from(["foundry-cli", "sign", "0xdeadbeef"]);
2091        match args {
2092            WalletSubcommands::Sign { message, data, from_file, .. } => {
2093                assert_eq!(message, "0xdeadbeef".to_string());
2094                assert!(!data);
2095                assert!(!from_file);
2096            }
2097            _ => panic!("expected WalletSubcommands::Sign"),
2098        }
2099    }
2100
2101    #[test]
2102    fn can_verify_signed_hex_message() {
2103        let message = "hello";
2104        let signature = Signature::from_str("f2dd00eac33840c04b6fc8a5ec8c4a47eff63575c2bc7312ecb269383de0c668045309c423484c8d097df306e690c653f8e1ec92f7f6f45d1f517027771c3e801c").unwrap();
2105        let address = address!("0x28A4F420a619974a2393365BCe5a7b560078Cc13");
2106        let recovered_address =
2107            WalletSubcommands::recover_address_from_message(message, &signature);
2108        assert!(recovered_address.is_ok());
2109        assert_eq!(address, recovered_address.unwrap());
2110    }
2111
2112    #[test]
2113    fn can_verify_signed_hex_message_no_hash() {
2114        let prehash = keccak256("hello");
2115        let signature = Signature::from_str("433ec3d37e4f1253df15e2dea412fed8e915737730f74b3dfb1353268f932ef5557c9158e0b34bce39de28d11797b42e9b1acb2749230885fe075aedc3e491a41b").unwrap();
2116        let address = address!("0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf"); // private key = 1
2117        let recovered_address =
2118            WalletSubcommands::recover_address_from_message_no_hash(&prehash, &signature);
2119        assert!(recovered_address.is_ok());
2120        assert_eq!(address, recovered_address.unwrap());
2121    }
2122
2123    #[test]
2124    fn can_verify_signed_typed_data() {
2125        let typed_data: TypedData = serde_json::from_str(r#"{"domain":{"name":"Test","version":"1","chainId":1,"verifyingContract":"0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF"},"message":{"value":123},"primaryType":"Data","types":{"Data":[{"name":"value","type":"uint256"}]}}"#).unwrap();
2126        let signature = Signature::from_str("0285ff83b93bd01c14e201943af7454fe2bc6c98be707a73888c397d6ae3b0b92f73ca559f81cbb19fe4e0f1dc4105bd7b647c6a84b033057977cf2ec982daf71b").unwrap();
2127        let address = address!("0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf"); // private key = 1
2128        let recovered_address =
2129            WalletSubcommands::recover_address_from_typed_data(&typed_data, &signature);
2130        assert!(recovered_address.is_ok());
2131        assert_eq!(address, recovered_address.unwrap());
2132    }
2133
2134    #[test]
2135    fn can_parse_wallet_sign_data() {
2136        let args = WalletSubcommands::parse_from(["foundry-cli", "sign", "--data", "{ ... }"]);
2137        match args {
2138            WalletSubcommands::Sign { message, data, from_file, .. } => {
2139                assert_eq!(message, "{ ... }".to_string());
2140                assert!(data);
2141                assert!(!from_file);
2142            }
2143            _ => panic!("expected WalletSubcommands::Sign"),
2144        }
2145    }
2146
2147    #[test]
2148    fn can_parse_wallet_sign_data_file() {
2149        let args = WalletSubcommands::parse_from([
2150            "foundry-cli",
2151            "sign",
2152            "--data",
2153            "--from-file",
2154            "tests/data/typed_data.json",
2155        ]);
2156        match args {
2157            WalletSubcommands::Sign { message, data, from_file, .. } => {
2158                assert_eq!(message, "tests/data/typed_data.json".to_string());
2159                assert!(data);
2160                assert!(from_file);
2161            }
2162            _ => panic!("expected WalletSubcommands::Sign"),
2163        }
2164    }
2165
2166    #[test]
2167    fn can_parse_wallet_change_password() {
2168        let args = WalletSubcommands::parse_from([
2169            "foundry-cli",
2170            "change-password",
2171            "my_account",
2172            "--unsafe-password",
2173            "old_password",
2174            "--unsafe-new-password",
2175            "new_password",
2176        ]);
2177        match args {
2178            WalletSubcommands::ChangePassword {
2179                account_name,
2180                keystore_dir,
2181                unsafe_password,
2182                unsafe_new_password,
2183            } => {
2184                assert_eq!(account_name, "my_account".to_string());
2185                assert_eq!(unsafe_password, Some("old_password".to_string()));
2186                assert_eq!(unsafe_new_password, Some("new_password".to_string()));
2187                assert!(keystore_dir.is_none());
2188            }
2189            _ => panic!("expected WalletSubcommands::ChangePassword"),
2190        }
2191    }
2192
2193    #[test]
2194    fn can_parse_wallet_session_create() {
2195        let args = WalletSubcommands::parse_from([
2196            "foundry-cli",
2197            "session",
2198            "create",
2199            "--root",
2200            "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf",
2201            "--chain-id",
2202            "4217",
2203            "--expires",
2204            "10m",
2205            "--scope",
2206            "0x20c0000000000000000000000000000000000001:transfer",
2207            "--spend-limit",
2208            "PathUSD=0",
2209            "--private-key",
2210            "0x59c6995e998f97a5a004497e5da3b5d2b2b66a87f064d39c44da0b6d6e4f8ff0",
2211        ]);
2212
2213        match args {
2214            WalletSubcommands::Session(args) => match args.command {
2215                Some(SessionSubcommands::Create {
2216                    root_account,
2217                    chain_id,
2218                    expires,
2219                    scope,
2220                    spend_limits,
2221                    wallet,
2222                }) => {
2223                    assert_eq!(
2224                        root_account,
2225                        address!("0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf")
2226                    );
2227                    assert_eq!(chain_id, 4217);
2228                    assert_eq!(expires, 600);
2229                    assert_eq!(scope.len(), 1);
2230                    assert_eq!(spend_limits.len(), 1);
2231                    assert_eq!(
2232                        wallet.raw.private_key.as_deref(),
2233                        Some("0x59c6995e998f97a5a004497e5da3b5d2b2b66a87f064d39c44da0b6d6e4f8ff0")
2234                    );
2235                }
2236                _ => panic!("expected WalletSubcommands::Session::Create"),
2237            },
2238            _ => panic!("expected WalletSubcommands::Session"),
2239        }
2240    }
2241
2242    #[test]
2243    fn can_parse_wallet_session_revoke() {
2244        for (extra_args, expected_local) in [([].as_slice(), false), (["--local"].as_slice(), true)]
2245        {
2246            let args = WalletSubcommands::parse_from(
2247                [
2248                    "foundry-cli",
2249                    "session",
2250                    "revoke",
2251                    "0x1111111111111111111111111111111111111111111111111111111111111111",
2252                ]
2253                .into_iter()
2254                .chain(extra_args.iter().copied()),
2255            );
2256
2257            match args {
2258                WalletSubcommands::Session(args) => match args.command {
2259                    Some(SessionSubcommands::Revoke { session_id, local, .. }) => {
2260                        assert_eq!(session_id, B256::from([0x11; 32]));
2261                        assert_eq!(local, expected_local);
2262                    }
2263                    _ => panic!("expected WalletSubcommands::Session::Revoke"),
2264                },
2265                _ => panic!("expected WalletSubcommands::Session"),
2266            }
2267        }
2268    }
2269
2270    #[test]
2271    fn can_parse_wallet_session_run_for_command() {
2272        let args = WalletSubcommands::parse_from([
2273            "foundry-cli",
2274            "session",
2275            "--root",
2276            "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf",
2277            "--chain-id",
2278            "4217",
2279            "--expires",
2280            "10m",
2281            "--target",
2282            "0x20c0000000000000000000000000000000000001",
2283            "--selector",
2284            "transfer(address,uint256)",
2285            "--spend-limit",
2286            "PathUSD=0",
2287            "--for",
2288            "forge script Deploy --broadcast",
2289            "--private-key",
2290            "0x59c6995e998f97a5a004497e5da3b5d2b2b66a87f064d39c44da0b6d6e4f8ff0",
2291        ]);
2292
2293        match args {
2294            WalletSubcommands::Session(args) => {
2295                assert!(args.command.is_none());
2296                assert_eq!(
2297                    args.root_account,
2298                    Some(address!("0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf"))
2299                );
2300                assert_eq!(args.send_tx.eth.etherscan.chain.map(|chain| chain.id()), Some(4217));
2301                assert_eq!(args.expires, Some(600));
2302                assert_eq!(
2303                    args.target,
2304                    Some(address!("0x20c0000000000000000000000000000000000001"))
2305                );
2306                assert_eq!(args.selectors.len(), 1);
2307                assert_eq!(args.spend_limits.len(), 1);
2308                assert_eq!(args.for_command.as_deref(), Some("forge script Deploy --broadcast"));
2309                assert_eq!(
2310                    args.send_tx.eth.wallet.raw.private_key.as_deref(),
2311                    Some("0x59c6995e998f97a5a004497e5da3b5d2b2b66a87f064d39c44da0b6d6e4f8ff0")
2312                );
2313            }
2314            _ => panic!("expected WalletSubcommands::Session"),
2315        }
2316    }
2317
2318    #[test]
2319    fn wallet_sign_auth_nonce_and_self_broadcast_conflict() {
2320        let result = WalletSubcommands::try_parse_from([
2321            "foundry-cli",
2322            "sign-auth",
2323            "0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF",
2324            "--nonce",
2325            "42",
2326            "--self-broadcast",
2327        ]);
2328        assert!(
2329            result.is_err(),
2330            "expected error when both --nonce and --self-broadcast are provided"
2331        );
2332    }
2333
2334    #[test]
2335    fn rejects_path_keystore_account_name() {
2336        assert!(ensure_account_name_available("dev").is_ok());
2337        assert!(ensure_account_name_available("testAccount").is_ok());
2338        assert!(ensure_account_name_available("../pwned").is_err());
2339        assert!(ensure_account_name_available("nested/alias").is_err());
2340        assert!(ensure_account_name_available("foo/../bar").is_err());
2341        assert!(ensure_account_name_available("..").is_err());
2342        assert!(ensure_account_name_available(".").is_err());
2343        assert!(ensure_account_name_available("").is_err());
2344        assert!(ensure_account_name_available("foo\\bar").is_err());
2345    }
2346}