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