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#[derive(Debug, Parser)]
47pub enum WalletSubcommands {
48 #[command(verbatim_doc_comment, visible_alias = "n")]
54 New {
55 path: Option<String>,
57
58 #[arg(value_name = "ACCOUNT_NAME")]
61 account_name: Option<String>,
62
63 #[arg(long, short, conflicts_with = "unsafe_password")]
67 password: bool,
68
69 #[arg(long, env = "CAST_PASSWORD", value_name = "PASSWORD")]
73 unsafe_password: Option<String>,
74
75 #[arg(long, short, default_value = "1")]
77 number: u32,
78
79 #[arg(long)]
81 force: bool,
82
83 #[arg(long, hide = !cfg!(all(target_os = "macos", feature = "touch-id")))]
87 touch_id: bool,
88 },
89
90 #[command(visible_alias = "nm")]
92 NewMnemonic {
93 #[arg(long, short, default_value = "12")]
95 words: usize,
96
97 #[arg(long, short, default_value = "1")]
99 accounts: u8,
100
101 #[arg(long, short, conflicts_with = "words")]
103 entropy: Option<String>,
104 },
105
106 #[command(visible_alias = "va")]
108 Vanity(VanityArgs),
109
110 #[command(visible_aliases = &["a", "addr"])]
112 Address {
113 #[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 #[command(verbatim_doc_comment, visible_alias = "d")]
130 Derive {
131 #[arg(value_name = "MNEMONIC")]
133 mnemonic: String,
134
135 #[arg(long, short, default_value = "1")]
137 accounts: Option<u8>,
138
139 #[arg(long, default_value = "false")]
141 insecure: bool,
142 },
143
144 #[command(verbatim_doc_comment, visible_alias = "s")]
151 Sign {
152 message: String,
166
167 #[arg(long)]
169 data: bool,
170
171 #[arg(long, requires = "data")]
173 from_file: bool,
174
175 #[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 #[command(visible_alias = "sa")]
188 SignAuth {
189 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 #[arg(long)]
203 force: bool,
204
205 #[arg(long, conflicts_with = "nonce")]
209 self_broadcast: bool,
210
211 #[command(flatten)]
212 wallet: WalletOpts,
213 },
214
215 #[command(verbatim_doc_comment, visible_alias = "v")]
221 Verify {
222 message: String,
236
237 signature: Signature,
239
240 #[arg(long, short)]
242 address: Address,
243
244 #[arg(long)]
246 data: bool,
247
248 #[arg(long, requires = "data")]
250 from_file: bool,
251
252 #[arg(long, conflicts_with = "data")]
254 no_hash: bool,
255 },
256
257 #[command(verbatim_doc_comment, visible_alias = "i")]
264 Import {
265 #[arg(value_name = "ACCOUNT_NAME")]
267 account_name: String,
268 #[arg(long, short)]
271 keystore_dir: Option<String>,
272 #[arg(long, env = "CAST_UNSAFE_PASSWORD", value_name = "PASSWORD")]
275 unsafe_password: Option<String>,
276 #[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 #[command(visible_alias = "ls")]
287 List(ListArgs),
288
289 Session(SessionArgs),
291
292 TouchId(TouchIdArgs),
294
295 #[command(visible_aliases = &["rm"], override_usage = "cast wallet remove --name <NAME>")]
300 Remove {
301 #[arg(long, required = true)]
303 name: String,
304 #[arg(long)]
307 dir: Option<String>,
308 #[arg(long, env = "CAST_UNSAFE_PASSWORD", value_name = "PASSWORD")]
311 unsafe_password: Option<String>,
312 },
313
314 #[command(verbatim_doc_comment, name = "private-key", visible_alias = "pk", aliases = &["derive-private-key", "--derive-private-key"])]
321 PrivateKey {
322 #[arg(value_name = "MNEMONIC")]
324 mnemonic_override: Option<String>,
325
326 #[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 #[command(visible_aliases = &["pubkey"])]
336 PublicKey {
337 #[arg(long = "raw-private-key", value_name = "PRIVATE_KEY")]
339 private_key_override: Option<String>,
340
341 #[command(flatten)]
342 wallet: WalletOpts,
343 },
344 #[command(name = "decrypt-keystore", visible_alias = "dk")]
346 DecryptKeystore {
347 #[arg(value_name = "ACCOUNT_NAME")]
349 account_name: String,
350 #[arg(long, short)]
353 keystore_dir: Option<String>,
354 #[arg(long, env = "CAST_UNSAFE_PASSWORD", value_name = "PASSWORD")]
357 unsafe_password: Option<String>,
358 },
359
360 #[command(name = "change-password", visible_alias = "cp")]
362 ChangePassword {
363 #[arg(value_name = "ACCOUNT_NAME")]
365 account_name: String,
366 #[arg(long, short)]
369 keystore_dir: Option<String>,
370 #[arg(long, env = "CAST_UNSAFE_PASSWORD", value_name = "PASSWORD")]
373 unsafe_password: Option<String>,
374 #[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 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 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 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 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 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 foundry_common::fs::read_json_file(message.as_ref())?
757 } else {
758 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 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 foundry_common::fs::read_json_file(message.as_ref())?
862 } else {
863 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 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 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 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 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 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 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 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 if let Err(error) = foundry_wallets::touch_id::policy(&keypath) {
1168 return Err(error.into());
1169 }
1170 }
1171
1172 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 rpassword::prompt_password("Enter current password: ")?
1191 };
1192
1193 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 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 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1347enum TouchIdSidecarState {
1348 Missing,
1350 Recognized,
1352 Keystore,
1354 Unknown,
1358}
1359
1360const TOUCH_ID_SIDECAR_VERSION: u32 = 1;
1362
1363const TOUCH_ID_SEALED_PASSWORD_MIN_LEN: usize = 65 + 12 + 16;
1368
1369const TOUCH_ID_X963_UNCOMPRESSED_PREFIX: u8 = 0x04;
1371
1372#[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#[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
1403fn 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
1423fn 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 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 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 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 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 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 #[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 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 assert!(!is_touch_id_sidecar(&p).unwrap());
1814 assert_eq!(touch_id_sidecar_state(&p).unwrap(), TouchIdSidecarState::Missing);
1815 }
1816
1817 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 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 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 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 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 assert_eq!(std::fs::read_to_string(&sidecar).unwrap(), content);
1905 }
1906
1907 #[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 assert_eq!(std::fs::read_to_string(&sidecar).unwrap(), content);
1993 }
1994
1995 #[test]
2003 fn listing_hides_only_recognized_sidecars() {
2004 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 assert!(matches!(is_touch_id_sidecar(&recognized), Ok(true)));
2024 assert!(matches!(is_touch_id_sidecar(&unknown_content), Ok(false)));
2026 assert!(matches!(is_touch_id_sidecar(&unknown_version), Ok(false)));
2028 assert!(matches!(is_touch_id_sidecar(&invalid_payload), Ok(false)));
2030 }
2031
2032 #[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"); 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"); 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}