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