1use alloy_consensus::BlockHeader;
2use alloy_ens::NameOrAddress;
3use foundry_wallets::BrowserWalletOpts;
4use std::time::Duration;
5
6use alloy_network::{EthereumWallet, TransactionBuilder};
7use alloy_primitives::{Address, B256, Bytes, U256, hex};
8use alloy_provider::{Provider, ProviderBuilder as AlloyProviderBuilder};
9use alloy_rlp::Encodable;
10use alloy_rpc_types::BlockId;
11use alloy_signer::Signer;
12use alloy_sol_types::SolCall;
13use alloy_transport::TransportError;
14use chrono::DateTime;
15use clap::Parser;
16use eyre::Result;
17use foundry_cli::{
18 json::print_json_object,
19 opts::{RpcOpts, TempoOpts, TransactionOpts},
20 utils::{LoadConfig, maybe_print_resolved_lane, parse_fee_token_address, resolve_lane},
21};
22use foundry_common::{
23 FoundryTransactionBuilder,
24 provider::ProviderBuilder,
25 sh_warn, shell,
26 tempo::{
27 self, KeyType, KeysFile, TEMPO_BROWSER_GAS_BUFFER, WalletType, maybe_print_fee_token,
28 read_tempo_keys_file, resolve_and_set_fee_token, tempo_keys_path,
29 },
30};
31use foundry_evm::hardfork::TempoHardfork;
32use foundry_wallets::{WalletOpts, WalletSigner, wallet_browser::signer::BrowserSigner};
33use serde::Deserialize;
34use tempo_alloy::{TempoNetwork, provider::TempoProviderExt};
35use tempo_contracts::precompiles::{
36 ACCOUNT_KEYCHAIN_ADDRESS, DEFAULT_FEE_TOKEN,
37 IAccountKeychain::{
38 self, CallScope, KeyInfo, KeyRestrictions, LegacyTokenLimit, SelectorRule, SignatureType,
39 TokenLimit,
40 },
41 ISignatureVerifier, ITIP20, PATH_USD_ADDRESS, SIGNATURE_VERIFIER_ADDRESS,
42 account_keychain::{
43 authorizeAdminKeyCall, authorizeKeyCall, authorizeKeyWithWitnessCall,
44 legacyAuthorizeKeyCall,
45 },
46};
47use tempo_primitives::transaction::{
48 CallScope as AuthCallScope, KeyAuthorization, PrimitiveSignature,
49 SelectorRule as AuthSelectorRule, SignatureType as AuthSignatureType, SignedKeyAuthorization,
50 TokenLimit as AuthTokenLimit,
51};
52use yansi::Paint;
53
54use crate::cmd::tempo_policy_args::{
55 SelectorArg, parse_period, parse_scope, parse_selector_arg, parse_selector_bytes,
56};
57
58use crate::{
59 cmd::send::cast_send,
60 tx::{CastTxBuilder, CastTxSender, SendTxOpts},
61};
62
63#[derive(Debug, Parser)]
68pub enum KeychainSubcommand {
69 #[command(visible_alias = "ls")]
71 List,
72
73 Show {
75 wallet_address: Address,
77 },
78
79 #[command(visible_alias = "info")]
81 Check {
82 wallet_address: Address,
84
85 key_address: Address,
87
88 #[command(flatten)]
89 rpc: RpcOpts,
90 },
91
92 Inspect {
94 key_address: Address,
96
97 #[arg(long, visible_alias = "wallet-address", value_name = "ADDRESS")]
99 root_account: Option<Address>,
100
101 #[command(flatten)]
102 rpc: RpcOpts,
103 },
104
105 Doctor {
110 #[arg(required_unless_present = "root_account")]
112 key_address: Option<Address>,
113
114 #[arg(long, visible_alias = "wallet-address", value_name = "ADDRESS")]
117 root_account: Option<Address>,
118
119 #[arg(long, value_name = "ADDRESS")]
121 to: Option<Address>,
122
123 #[arg(long, value_parser = parse_selector_arg, requires = "to")]
126 selector: Option<SelectorArg>,
127
128 #[arg(long, value_name = "ADDRESS", requires = "selector")]
130 recipient: Option<Address>,
131
132 #[arg(
134 id = "doctor_fee_token",
135 long = "fee-token",
136 value_name = "TOKEN",
137 value_parser = parse_fee_token_address
138 )]
139 fee_token: Option<Address>,
140
141 #[command(flatten)]
142 tempo: TempoOpts,
143
144 #[command(flatten)]
145 rpc: RpcOpts,
146 },
147
148 #[command(visible_alias = "auth")]
150 Authorize {
151 key_address: Address,
153
154 #[arg(default_value = "secp256k1", value_parser = parse_signature_type)]
156 key_type: SignatureType,
157
158 #[arg(default_value_t = u64::MAX)]
160 expiry: u64,
161
162 #[arg(long)]
164 enforce_limits: bool,
165
166 #[arg(long = "limit", value_parser = parse_limit)]
168 limits: Vec<TokenLimit>,
169
170 #[arg(long = "scope", value_parser = parse_scope)]
174 scope: Vec<CallScope>,
175
176 #[arg(long = "scopes", value_parser = parse_scopes_json_wrapped, conflicts_with = "scope")]
180 scopes_json: Option<ScopesJson>,
181
182 #[arg(long)]
189 witness: Option<B256>,
190
191 #[arg(long)]
196 admin: bool,
197
198 #[command(flatten)]
199 tx: TransactionOpts,
200
201 #[command(flatten)]
202 send_tx: SendTxOpts,
203 },
204
205 #[command(visible_alias = "rev")]
207 Revoke {
208 key_address: Address,
210
211 #[command(flatten)]
212 tx: TransactionOpts,
213
214 #[command(flatten)]
215 send_tx: SendTxOpts,
216 },
217
218 #[command(name = "burn-witness")]
220 BurnWitness {
221 witness: B256,
223
224 #[command(flatten)]
225 tx: TransactionOpts,
226
227 #[command(flatten)]
228 send_tx: SendTxOpts,
229 },
230
231 #[command(name = "is-witness-burned")]
233 IsWitnessBurned {
234 account: Address,
236
237 witness: B256,
239
240 #[command(flatten)]
241 rpc: RpcOpts,
242 },
243
244 #[command(name = "is-admin")]
246 IsAdmin {
247 account: Address,
249
250 key_address: Address,
252
253 #[command(flatten)]
254 rpc: RpcOpts,
255 },
256
257 Verify {
263 account: Address,
265
266 hash: B256,
268
269 signature: Bytes,
271
272 #[command(flatten)]
273 rpc: RpcOpts,
274 },
275
276 #[command(name = "verify-admin")]
282 VerifyAdmin {
283 account: Address,
285
286 hash: B256,
288
289 signature: Bytes,
291
292 #[command(flatten)]
293 rpc: RpcOpts,
294 },
295
296 #[command(name = "rl", visible_alias = "remaining-limit")]
298 RemainingLimit {
299 wallet_address: Address,
301
302 key_address: Address,
304
305 token: Address,
307
308 #[command(flatten)]
309 rpc: RpcOpts,
310 },
311
312 #[command(name = "ul", visible_alias = "update-limit")]
314 UpdateLimit {
315 key_address: Address,
317
318 token: Address,
320
321 new_limit: U256,
323
324 #[command(flatten)]
325 tx: TransactionOpts,
326
327 #[command(flatten)]
328 send_tx: SendTxOpts,
329 },
330
331 #[command(name = "ss", visible_alias = "set-scope")]
333 SetScope {
334 key_address: Address,
336
337 #[arg(long = "scope", required = true, value_parser = parse_scope)]
339 scope: Vec<CallScope>,
340
341 #[command(flatten)]
342 tx: TransactionOpts,
343
344 #[command(flatten)]
345 send_tx: SendTxOpts,
346 },
347
348 #[command(name = "rs", visible_alias = "remove-scope")]
350 RemoveScope {
351 key_address: Address,
353
354 target: Address,
356
357 #[command(flatten)]
358 tx: TransactionOpts,
359
360 #[command(flatten)]
361 send_tx: SendTxOpts,
362 },
363
364 Policy {
366 #[command(subcommand)]
367 command: KeychainPolicySubcommand,
368 },
369}
370
371#[derive(Debug, Parser)]
373pub enum KeyAuthorizationSubcommand {
374 Encode {
376 #[command(flatten)]
377 authorization: KeyAuthorizationArgs,
378
379 #[arg(long, value_name = "ADDRESS")]
384 account: Option<Address>,
385 },
386
387 Sign {
393 #[command(flatten)]
394 authorization: KeyAuthorizationArgs,
395
396 #[arg(long = "bind-account", value_name = "ADDRESS")]
400 account: Option<Address>,
401
402 #[command(flatten)]
403 wallet: Box<WalletOpts>,
404
405 #[command(flatten)]
406 browser: BrowserWalletOpts,
407 },
408
409 Inspect {
414 authorization: String,
416
417 #[arg(long, value_name = "ADDRESS")]
419 account: Option<Address>,
420 },
421}
422
423#[derive(Debug, Parser)]
425pub struct KeyAuthorizationArgs {
426 #[arg(long)]
428 chain_id: u64,
429
430 key_address: Address,
432
433 #[arg(long, default_value = "secp256k1", value_parser = parse_auth_signature_type)]
436 key_type: AuthSignatureType,
437
438 #[arg(long)]
440 expiry: Option<u64>,
441
442 #[arg(long)]
444 enforce_limits: bool,
445
446 #[arg(long = "limit", value_parser = parse_auth_limit)]
448 limits: Vec<AuthTokenLimit>,
449
450 #[arg(long = "scope", value_parser = parse_auth_scope)]
453 scope: Vec<AuthCallScope>,
454
455 #[arg(long = "scopes", value_parser = parse_auth_scopes_json_wrapped, conflicts_with = "scope")]
457 scopes_json: Option<AuthScopesJson>,
458
459 #[arg(long)]
463 witness: Option<B256>,
464
465 #[arg(long)]
471 admin: bool,
472}
473
474#[derive(Debug, Parser)]
476pub enum KeychainPolicySubcommand {
477 AddCall {
479 key_address: Address,
481
482 #[arg(long, visible_alias = "wallet-address", value_name = "ADDRESS")]
484 root_account: Option<Address>,
485
486 #[arg(long)]
488 target: Address,
489
490 #[arg(long, value_parser = parse_selector_arg)]
492 selector: SelectorArg,
493
494 #[arg(long, value_delimiter = ',')]
496 recipients: Vec<Address>,
497
498 #[command(flatten)]
499 tx: TransactionOpts,
500
501 #[command(flatten)]
502 send_tx: SendTxOpts,
503 },
504
505 SetLimit {
507 key_address: Address,
509
510 #[arg(long, value_parser = parse_fee_token_address)]
512 token: Address,
513
514 #[arg(long)]
516 amount: U256,
517
518 #[arg(long, value_parser = parse_period)]
523 period: Option<u64>,
524
525 #[command(flatten)]
526 tx: TransactionOpts,
527
528 #[command(flatten)]
529 send_tx: SendTxOpts,
530 },
531
532 RemoveTarget {
534 key_address: Address,
536
537 #[arg(long)]
539 target: Address,
540
541 #[command(flatten)]
542 tx: TransactionOpts,
543
544 #[command(flatten)]
545 send_tx: SendTxOpts,
546 },
547}
548
549fn parse_signature_type(s: &str) -> Result<SignatureType, String> {
550 match s.to_lowercase().as_str() {
551 "secp256k1" => Ok(SignatureType::Secp256k1),
552 "p256" => Ok(SignatureType::P256),
553 "webauthn" => Ok(SignatureType::WebAuthn),
554 _ => Err(format!("unknown signature type: {s} (expected secp256k1, p256, or webauthn)")),
555 }
556}
557
558fn parse_auth_signature_type(s: &str) -> Result<AuthSignatureType, String> {
559 match s.to_lowercase().as_str() {
560 "secp256k1" => Ok(AuthSignatureType::Secp256k1),
561 "p256" => Ok(AuthSignatureType::P256),
562 "webauthn" => Ok(AuthSignatureType::WebAuthn),
563 _ => Err(format!("unknown signature type: {s} (expected secp256k1, p256, or webauthn)")),
564 }
565}
566
567const fn signature_type_name(t: &SignatureType) -> &'static str {
568 match t {
569 SignatureType::Secp256k1 => "secp256k1",
570 SignatureType::P256 => "p256",
571 SignatureType::WebAuthn => "webauthn",
572 _ => "unknown",
573 }
574}
575
576const fn signature_type_label(t: &SignatureType) -> &'static str {
577 match t {
578 SignatureType::Secp256k1 => "Secp256k1",
579 SignatureType::P256 => "P256",
580 SignatureType::WebAuthn => "WebAuthn",
581 _ => "unknown",
582 }
583}
584
585const fn key_type_name(t: &KeyType) -> &'static str {
586 match t {
587 KeyType::Secp256k1 => "secp256k1",
588 KeyType::P256 => "p256",
589 KeyType::WebAuthn => "webauthn",
590 }
591}
592
593const fn key_type_label(t: &KeyType) -> &'static str {
594 match t {
595 KeyType::Secp256k1 => "Secp256k1",
596 KeyType::P256 => "P256",
597 KeyType::WebAuthn => "WebAuthn",
598 }
599}
600
601const fn wallet_type_name(t: &WalletType) -> &'static str {
602 match t {
603 WalletType::Local => "local",
604 WalletType::Passkey => "passkey",
605 }
606}
607
608fn parse_limit(s: &str) -> Result<TokenLimit, String> {
610 let mut parts = s.splitn(3, ':');
611 let token_str = parts.next().unwrap();
612 let amount_str = parts
613 .next()
614 .ok_or_else(|| format!("invalid limit format: {s} (expected TOKEN:AMOUNT[:PERIOD])"))?;
615 let period_str = parts.next();
616 let token: Address =
617 token_str.parse().map_err(|e| format!("invalid token address '{token_str}': {e}"))?;
618 let amount: U256 =
619 amount_str.parse().map_err(|e| format!("invalid amount '{amount_str}': {e}"))?;
620 let period = match period_str {
621 Some(p) => parse_period(p)?,
622 None => 0,
623 };
624 Ok(TokenLimit { token, amount, period })
625}
626
627fn parse_auth_limit(s: &str) -> Result<AuthTokenLimit, String> {
629 let parts: Vec<_> = s.split(':').collect();
630 let (token_str, amount_str, period_str) = match parts.as_slice() {
631 [token_str, amount_str] => (*token_str, *amount_str, None),
632 [token_str, amount_str, period_str] => (*token_str, *amount_str, Some(*period_str)),
633 _ => return Err(format!("invalid limit format: {s} (expected TOKEN:AMOUNT[:PERIOD])")),
634 };
635 let token: Address =
636 token_str.parse().map_err(|e| format!("invalid token address '{token_str}': {e}"))?;
637 let limit: U256 =
638 amount_str.parse().map_err(|e| format!("invalid amount '{amount_str}': {e}"))?;
639 let period = if let Some(period_str) = period_str { parse_period(period_str)? } else { 0 };
640 Ok(AuthTokenLimit { token, limit, period })
641}
642
643fn parse_auth_scope(s: &str) -> Result<AuthCallScope, String> {
644 parse_scope(s).map(abi_scope_to_auth_scope)
645}
646
647fn abi_scope_to_auth_scope(scope: CallScope) -> AuthCallScope {
648 AuthCallScope {
649 target: scope.target,
650 selector_rules: scope
651 .selectorRules
652 .into_iter()
653 .map(|rule| {
654 let mut selector = [0u8; 4];
655 selector.copy_from_slice(rule.selector.as_slice());
656 AuthSelectorRule { selector, recipients: rule.recipients }
657 })
658 .collect(),
659 }
660}
661#[derive(serde::Deserialize)]
663struct JsonCallScope {
664 target: Address,
665 #[serde(default)]
666 selectors: Option<Vec<JsonSelectorEntry>>,
667}
668
669#[derive(serde::Deserialize)]
671#[serde(untagged)]
672enum JsonSelectorEntry {
673 Name(String),
674 WithRecipients(JsonSelectorWithRecipients),
675}
676
677#[derive(serde::Deserialize)]
678#[serde(deny_unknown_fields)]
679struct JsonSelectorWithRecipients {
680 selector: String,
681 #[serde(default)]
682 recipients: Vec<Address>,
683}
684
685fn parse_scopes_json(s: &str) -> Result<Vec<CallScope>, String> {
687 let entries: Vec<JsonCallScope> =
688 serde_json::from_str(s).map_err(|e| format!("invalid --scopes JSON: {e}"))?;
689
690 let mut scopes = Vec::new();
691 for entry in entries {
692 let selector_rules = match entry.selectors {
693 None => vec![],
694 Some(sels) => {
695 let mut rules = Vec::new();
696 for sel_entry in sels {
697 let (selector_str, recipients) = match sel_entry {
698 JsonSelectorEntry::Name(name) => (name, vec![]),
699 JsonSelectorEntry::WithRecipients(r) => (r.selector, r.recipients),
700 };
701 let selector = parse_selector_bytes(&selector_str)
702 .map_err(|e| format!("in --scopes JSON: {e}"))?;
703 rules.push(SelectorRule { selector: selector.into(), recipients });
704 }
705 rules
706 }
707 };
708 scopes.push(CallScope { target: entry.target, selectorRules: selector_rules });
709 }
710
711 Ok(scopes)
712}
713
714#[derive(Debug, Clone)]
716pub struct ScopesJson(Vec<CallScope>);
717
718fn parse_scopes_json_wrapped(s: &str) -> Result<ScopesJson, String> {
720 parse_scopes_json(s).map(ScopesJson)
721}
722
723#[derive(Debug, Clone)]
725pub struct AuthScopesJson(Vec<AuthCallScope>);
726
727fn parse_auth_scopes_json_wrapped(s: &str) -> Result<AuthScopesJson, String> {
728 parse_scopes_json(s)
729 .map(|scopes| AuthScopesJson(scopes.into_iter().map(abi_scope_to_auth_scope).collect()))
730}
731
732impl KeychainSubcommand {
733 #[allow(clippy::large_stack_frames)]
734 pub async fn run(self) -> Result<()> {
735 match self {
736 Self::List => run_list(),
737 Self::Show { wallet_address } => run_show(wallet_address),
738 Self::Check { wallet_address, key_address, rpc } => {
739 run_check(wallet_address, key_address, rpc).await
740 }
741 Self::Inspect { key_address, root_account, rpc } => {
742 run_inspect(key_address, root_account, rpc).await
743 }
744 Self::Doctor {
745 key_address,
746 root_account,
747 to,
748 selector,
749 recipient,
750 fee_token,
751 tempo,
752 rpc,
753 } => {
754 run_doctor(
755 key_address,
756 root_account,
757 to,
758 selector.map(SelectorArg::into_bytes),
759 recipient,
760 fee_token,
761 tempo,
762 rpc,
763 )
764 .await
765 }
766 Self::Authorize {
767 key_address,
768 key_type,
769 expiry,
770 enforce_limits,
771 limits,
772 scope,
773 scopes_json,
774 witness,
775 admin,
776 tx,
777 send_tx,
778 } => {
779 let scopes_present = scopes_json.is_some() || !scope.is_empty();
780 let all_scopes = if let Some(ScopesJson(json_scopes)) = scopes_json {
781 json_scopes
782 } else {
783 scope
784 };
785 run_authorize(
786 key_address,
787 key_type,
788 expiry,
789 enforce_limits,
790 limits,
791 all_scopes,
792 scopes_present,
793 witness,
794 admin,
795 tx,
796 send_tx,
797 )
798 .await
799 }
800 Self::Revoke { key_address, tx, send_tx } => run_revoke(key_address, tx, send_tx).await,
801 Self::BurnWitness { witness, tx, send_tx } => {
802 run_burn_witness(witness, tx, send_tx).await
803 }
804 Self::IsWitnessBurned { account, witness, rpc } => {
805 run_is_witness_burned(account, witness, rpc).await
806 }
807 Self::IsAdmin { account, key_address, rpc } => {
808 run_is_admin(account, key_address, rpc).await
809 }
810 Self::Verify { account, hash, signature, rpc } => {
811 run_verify_keychain(account, hash, signature, rpc, false).await
812 }
813 Self::VerifyAdmin { account, hash, signature, rpc } => {
814 run_verify_keychain(account, hash, signature, rpc, true).await
815 }
816 Self::RemainingLimit { wallet_address, key_address, token, rpc } => {
817 run_remaining_limit(wallet_address, key_address, token, rpc).await
818 }
819 Self::UpdateLimit { key_address, token, new_limit, tx, send_tx } => {
820 run_update_limit(key_address, token, new_limit, tx, send_tx).await
821 }
822 Self::SetScope { key_address, scope, tx, send_tx } => {
823 run_set_scope(key_address, scope, tx, send_tx).await
824 }
825 Self::RemoveScope { key_address, target, tx, send_tx } => {
826 run_remove_scope(key_address, target, tx, send_tx).await
827 }
828 Self::Policy { command } => command.run().await,
829 }
830 }
831}
832
833impl KeyAuthorizationSubcommand {
834 pub async fn run(self) -> Result<()> {
835 match self {
836 Self::Encode { authorization, account } => run_key_auth_encode(authorization, account),
837 Self::Sign { authorization, account, wallet, browser } => {
838 run_key_auth_sign(authorization, account, *wallet, browser).await
839 }
840 Self::Inspect { authorization, account } => {
841 run_key_auth_inspect(&authorization, account)
842 }
843 }
844 }
845}
846
847impl KeychainPolicySubcommand {
848 pub async fn run(self) -> Result<()> {
849 match self {
850 Self::AddCall {
851 key_address,
852 root_account,
853 target,
854 selector,
855 recipients,
856 tx,
857 send_tx,
858 } => {
859 run_policy_add_call(
860 key_address,
861 root_account,
862 target,
863 selector.into_bytes(),
864 recipients,
865 tx,
866 send_tx,
867 )
868 .await
869 }
870 Self::SetLimit { key_address, token, amount, period, tx, send_tx } => {
871 run_policy_set_limit(key_address, token, amount, period, tx, send_tx).await
872 }
873 Self::RemoveTarget { key_address, target, tx, send_tx } => {
874 run_remove_scope(key_address, target, tx, send_tx).await
875 }
876 }
877 }
878}
879
880fn run_list() -> Result<()> {
882 let keys_file = load_keys_file()?;
883
884 if shell::is_json() {
885 let entries: Vec<_> = keys_file.keys.iter().map(key_entry_to_json).collect();
886 print_json_object(entries)?;
887 return Ok(());
888 }
889
890 if keys_file.keys.is_empty() {
891 sh_println!("No keys found in keys.toml.")?;
892 return Ok(());
893 }
894
895 for (i, entry) in keys_file.keys.iter().enumerate() {
896 if i > 0 {
897 sh_println!()?;
898 }
899 print_key_entry(entry)?;
900 }
901
902 Ok(())
903}
904
905fn run_show(wallet_address: Address) -> Result<()> {
907 let keys_file = load_keys_file()?;
908
909 let entries: Vec<_> =
910 keys_file.keys.iter().filter(|e| e.wallet_address == wallet_address).collect();
911
912 if shell::is_json() {
913 let entries_json: Vec<_> = entries.iter().map(|e| key_entry_to_json(e)).collect();
914 print_json_object(entries_json)?;
915 return Ok(());
916 }
917
918 if entries.is_empty() {
919 sh_println!("No keys found for wallet {wallet_address}.")?;
920 return Ok(());
921 }
922
923 for (i, entry) in entries.iter().enumerate() {
924 if i > 0 {
925 sh_println!()?;
926 }
927 print_key_entry(entry)?;
928 }
929
930 Ok(())
931}
932
933#[derive(Debug, Clone)]
934struct LocalLimitMetadata {
935 token: Address,
936 amount: String,
937}
938
939#[derive(Debug, Clone)]
940struct KeyMetadata {
941 root_account: Address,
942 key_type: Option<KeyType>,
943 limits: Vec<LocalLimitMetadata>,
944}
945
946#[derive(Debug, Clone)]
947struct InspectedLimit {
948 token: Address,
949 configured_amount: Option<String>,
950 remaining: U256,
951 period_end: Option<u64>,
952}
953
954#[derive(Debug, Clone)]
955enum AllowedCallsView {
956 Unsupported,
957 Unrestricted,
958 Scoped(Vec<CallScope>),
959}
960
961async fn run_inspect(
963 key_address: Address,
964 root_account: Option<Address>,
965 rpc: RpcOpts,
966) -> Result<()> {
967 let metadata = resolve_key_metadata(key_address, root_account)?;
968 let config = rpc.load_config()?;
969 let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
970
971 let info: KeyInfo = provider.get_keychain_key(metadata.root_account, key_address).await?;
972 let provisioned = info.keyId != Address::ZERO;
973 let is_t3 = is_tempo_hardfork_active(&provider, TempoHardfork::T3).await?;
974 let is_t6 = is_tempo_hardfork_active(&provider, TempoHardfork::T6).await?;
975
976 let is_admin = if is_t6 {
978 provider.account_keychain().isAdminKey(metadata.root_account, key_address).call().await?
979 } else {
980 false
981 };
982 let role = if key_address == metadata.root_account {
983 "root"
984 } else if is_admin {
985 "admin"
986 } else {
987 "limited"
988 };
989
990 let mut limits = Vec::new();
991 if info.enforceLimits {
992 for local_limit in &metadata.limits {
993 let (remaining, period_end) = if is_t3 {
994 let limit = provider
995 .get_keychain_remaining_limit_with_period(
996 metadata.root_account,
997 key_address,
998 local_limit.token,
999 )
1000 .await?;
1001 (limit.remaining, Some(limit.periodEnd))
1002 } else {
1003 let remaining = provider
1004 .account_keychain()
1005 .getRemainingLimit(metadata.root_account, key_address, local_limit.token)
1006 .call()
1007 .await?;
1008 (remaining, None)
1009 };
1010
1011 limits.push(InspectedLimit {
1012 token: local_limit.token,
1013 configured_amount: Some(local_limit.amount.clone()),
1014 remaining,
1015 period_end,
1016 });
1017 }
1018 }
1019
1020 let allowed_calls = if is_t3 {
1021 let allowed = provider
1022 .account_keychain()
1023 .getAllowedCalls(metadata.root_account, key_address)
1024 .call()
1025 .await?;
1026 if allowed.isScoped {
1027 AllowedCallsView::Scoped(allowed.scopes)
1028 } else {
1029 AllowedCallsView::Unrestricted
1030 }
1031 } else {
1032 AllowedCallsView::Unsupported
1033 };
1034
1035 if shell::is_json() {
1036 let key_type = if provisioned {
1037 signature_type_name(&info.signatureType).to_string()
1038 } else {
1039 metadata
1040 .key_type
1041 .map(|key_type| key_type_name(&key_type).to_string())
1042 .unwrap_or_else(|| "unknown".to_string())
1043 };
1044 let json = serde_json::json!({
1045 "root_account": metadata.root_account.to_string(),
1046 "key_id": key_address.to_string(),
1047 "provisioned": provisioned,
1048 "type": key_type,
1049 "role": role,
1050 "is_admin": is_admin,
1051 "expiry": provisioned.then_some(info.expiry),
1052 "expiry_human": provisioned.then(|| format_expiry_for_inspect(info.expiry)),
1053 "enforce_limits": info.enforceLimits,
1054 "is_revoked": info.isRevoked,
1055 "limits": limits.iter().map(inspected_limit_to_json).collect::<Vec<_>>(),
1056 "allowed_calls": allowed_calls_to_json(&allowed_calls),
1057 });
1058 print_json_object(json)?;
1059 return Ok(());
1060 }
1061
1062 let key_type = if provisioned {
1063 signature_type_label(&info.signatureType)
1064 } else {
1065 metadata.key_type.map(|key_type| key_type_label(&key_type)).unwrap_or("unknown")
1066 };
1067
1068 sh_println!("Root account: {}", metadata.root_account)?;
1069 sh_println!("Key id: {key_address}")?;
1070 sh_println!("Type: {key_type}")?;
1071 sh_println!("Role: {role}")?;
1072
1073 if info.isRevoked {
1074 sh_println!("Status: revoked")?;
1075 } else if !provisioned {
1076 sh_println!("Status: not provisioned")?;
1077 } else {
1078 sh_println!("Status: active")?;
1079 sh_println!("Expiry: {}", format_expiry_for_inspect(info.expiry))?;
1080 }
1081
1082 print_inspected_limits(info.enforceLimits, &limits)?;
1083 print_allowed_calls(&allowed_calls)?;
1084
1085 Ok(())
1086}
1087
1088async fn run_check(wallet_address: Address, key_address: Address, rpc: RpcOpts) -> Result<()> {
1090 let config = rpc.load_config()?;
1091 let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
1092
1093 let info: KeyInfo = provider.get_keychain_key(wallet_address, key_address).await?;
1094
1095 let provisioned = info.keyId != Address::ZERO;
1096
1097 if shell::is_json() {
1098 let json = serde_json::json!({
1099 "wallet_address": wallet_address.to_string(),
1100 "key_address": key_address.to_string(),
1101 "provisioned": provisioned,
1102 "signatureType": signature_type_name(&info.signatureType),
1103 "key_id": info.keyId.to_string(),
1104 "expiry": info.expiry,
1105 "expiry_human": format_expiry(info.expiry),
1106 "enforce_limits": info.enforceLimits,
1107 "is_revoked": info.isRevoked,
1108 });
1109 print_json_object(json)?;
1110 return Ok(());
1111 }
1112
1113 sh_println!("Wallet: {wallet_address}")?;
1114 sh_println!("Key: {key_address}")?;
1115
1116 if info.isRevoked {
1117 sh_println!("Status: {} revoked", "✗".red())?;
1118 return Ok(());
1119 }
1120
1121 if !provisioned {
1122 sh_println!("Status: {} not provisioned", "✗".red())?;
1123 return Ok(());
1124 }
1125
1126 sh_println!("Status: {} active", "✓".green())?;
1128
1129 sh_println!("Signature Type: {}", signature_type_name(&info.signatureType))?;
1130 sh_println!("Key ID: {}", info.keyId)?;
1131
1132 let expiry_str = format_expiry(info.expiry);
1134 if info.expiry == u64::MAX {
1135 sh_println!("Expiry: {}", expiry_str)?;
1136 } else {
1137 let now = std::time::SystemTime::now()
1138 .duration_since(std::time::UNIX_EPOCH)
1139 .unwrap_or_default()
1140 .as_secs();
1141 if info.expiry <= now {
1142 sh_println!("Expiry: {} ({})", expiry_str, "expired".red())?;
1143 } else {
1144 sh_println!("Expiry: {}", expiry_str)?;
1145 }
1146 }
1147
1148 sh_println!("Spending Limits: {}", if info.enforceLimits { "enforced" } else { "none" })?;
1149
1150 Ok(())
1151}
1152
1153#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
1168#[serde(rename_all = "lowercase")]
1169enum DoctorStatus {
1170 Pass,
1171 Warn,
1172 Fail,
1173}
1174
1175#[derive(Debug, Clone, serde::Serialize)]
1176struct DoctorStep {
1177 name: &'static str,
1178 label: &'static str,
1179 status: DoctorStatus,
1180 detail: String,
1181 #[serde(skip_serializing_if = "Option::is_none")]
1182 hint: Option<String>,
1183}
1184
1185impl DoctorStep {
1186 fn pass(name: &'static str, label: &'static str, detail: impl Into<String>) -> Self {
1187 Self { name, label, status: DoctorStatus::Pass, detail: detail.into(), hint: None }
1188 }
1189
1190 fn warn(
1191 name: &'static str,
1192 label: &'static str,
1193 detail: impl Into<String>,
1194 hint: impl Into<String>,
1195 ) -> Self {
1196 Self {
1197 name,
1198 label,
1199 status: DoctorStatus::Warn,
1200 detail: detail.into(),
1201 hint: Some(hint.into()),
1202 }
1203 }
1204
1205 fn fail(
1206 name: &'static str,
1207 label: &'static str,
1208 detail: impl Into<String>,
1209 hint: impl Into<String>,
1210 ) -> Self {
1211 Self {
1212 name,
1213 label,
1214 status: DoctorStatus::Fail,
1215 detail: detail.into(),
1216 hint: Some(hint.into()),
1217 }
1218 }
1219}
1220
1221#[derive(Debug, Clone, Copy, Default, serde::Serialize)]
1222struct DoctorContext {
1223 #[serde(skip_serializing_if = "Option::is_none")]
1224 root_account: Option<Address>,
1225 #[serde(skip_serializing_if = "Option::is_none")]
1226 key_address: Option<Address>,
1227 #[serde(skip_serializing_if = "Option::is_none")]
1228 chain_id: Option<u64>,
1229 fee_token: Address,
1230}
1231
1232#[derive(Debug)]
1234struct DoctorSubject {
1235 root_account: Address,
1236 key_address: Address,
1237 entry: Option<tempo::KeyEntry>,
1238 explicit: bool,
1239}
1240
1241#[derive(Debug)]
1243struct DoctorCandidate {
1244 root_account: Address,
1245 key_address: Address,
1246 chain_id: Option<u64>,
1247 entry: Option<tempo::KeyEntry>,
1248 explicit: bool,
1249}
1250
1251impl DoctorCandidate {
1252 fn from_entry(entry: tempo::KeyEntry) -> Self {
1253 Self {
1254 root_account: entry.wallet_address,
1255 key_address: key_entry_effective_key(&entry),
1256 chain_id: Some(entry.chain_id),
1257 entry: Some(entry),
1258 explicit: false,
1259 }
1260 }
1261
1262 const fn explicit(root_account: Address, key_address: Address) -> Self {
1263 Self { root_account, key_address, chain_id: None, entry: None, explicit: true }
1264 }
1265
1266 fn has_inline_key(&self) -> bool {
1267 self.entry.as_ref().is_some_and(|entry| entry.has_inline_key())
1268 }
1269
1270 fn is_passkey_with_inline_key(&self) -> bool {
1271 self.entry
1272 .as_ref()
1273 .is_some_and(|entry| entry.wallet_type == WalletType::Passkey && entry.has_inline_key())
1274 }
1275}
1276
1277#[derive(Debug)]
1278struct LocalCandidateResolution {
1279 step: DoctorStep,
1280 candidates: Vec<DoctorCandidate>,
1281}
1282
1283struct ValidKeyAuthorization {
1284 signed: SignedKeyAuthorization,
1285 detail: String,
1286}
1287
1288enum KeyRegistrationState {
1289 OnChain(KeyInfo),
1290 PendingAuthorization(Box<SignedKeyAuthorization>),
1291}
1292
1293struct SponsorshipDiagnosis {
1294 step: DoctorStep,
1295 fee_payer: Option<Address>,
1296}
1297
1298#[derive(Debug, Clone)]
1299enum ChainTimestamp {
1300 Known(u64),
1301 Unknown { detail: String, hint: &'static str },
1302}
1303
1304impl ChainTimestamp {
1305 const fn timestamp(&self) -> Option<u64> {
1306 match self {
1307 Self::Known(timestamp) => Some(*timestamp),
1308 Self::Unknown { .. } => None,
1309 }
1310 }
1311
1312 fn unavailable_step(
1313 &self,
1314 name: &'static str,
1315 label: &'static str,
1316 detail: impl Into<String>,
1317 ) -> DoctorStep {
1318 match self {
1319 Self::Known(_) => unreachable!("chain timestamp is available"),
1320 Self::Unknown { detail: reason, hint } => {
1321 DoctorStep::warn(name, label, format!("{}: {reason}", detail.into()), *hint)
1322 }
1323 }
1324 }
1325}
1326
1327enum AllowedCallMatch {
1329 Allowed(String),
1331 Denied(String),
1333 RecipientRestricted(Vec<Address>),
1335}
1336
1337#[allow(clippy::too_many_arguments)]
1339async fn run_doctor(
1340 key_address: Option<Address>,
1341 root_account: Option<Address>,
1342 to: Option<Address>,
1343 selector: Option<[u8; 4]>,
1344 recipient: Option<Address>,
1345 fee_token: Option<Address>,
1346 mut tempo: TempoOpts,
1347 rpc: RpcOpts,
1348) -> Result<()> {
1349 let mut steps: Vec<DoctorStep> = Vec::new();
1350 let requested_fee_token = fee_token.or(tempo.fee_token);
1351 let mut context = DoctorContext {
1352 root_account,
1353 key_address,
1354 chain_id: None,
1355 fee_token: requested_fee_token.unwrap_or(DEFAULT_FEE_TOKEN),
1356 };
1357
1358 let candidates = match collect_local_candidates(key_address, root_account) {
1360 Ok(resolution) => {
1361 steps.push(resolution.step);
1362 resolution.candidates
1363 }
1364 Err(step) => {
1365 steps.push(step);
1366 return finalize_doctor(steps, context);
1367 }
1368 };
1369
1370 let config = match rpc.load_config() {
1372 Ok(c) => c,
1373 Err(err) => {
1374 steps.push(DoctorStep::fail(
1375 "rpc_reachability",
1376 "RPC reachable",
1377 format!("could not load RPC config: {err}"),
1378 "check --rpc-url and your foundry.toml",
1379 ));
1380 return finalize_doctor(steps, context);
1381 }
1382 };
1383 let provider = match ProviderBuilder::<TempoNetwork>::from_config(&config)
1384 .and_then(|builder| builder.build())
1385 {
1386 Ok(p) => p,
1387 Err(err) => {
1388 steps.push(DoctorStep::fail(
1389 "rpc_reachability",
1390 "RPC reachable",
1391 format!("could not build provider: {err}"),
1392 "verify --rpc-url is set and reachable",
1393 ));
1394 return finalize_doctor(steps, context);
1395 }
1396 };
1397
1398 let rpc_chain_id = match provider.get_chain_id().await {
1399 Ok(id) => {
1400 context.chain_id = Some(id);
1401 steps.push(DoctorStep::pass(
1402 "rpc_reachability",
1403 "RPC reachable",
1404 format!("chain id {id}"),
1405 ));
1406 id
1407 }
1408 Err(err) => {
1409 steps.push(DoctorStep::fail(
1410 "rpc_reachability",
1411 "RPC reachable",
1412 format!("eth_chainId failed: {err}"),
1413 "confirm the node is reachable and not rate-limited",
1414 ));
1415 return finalize_doctor(steps, context);
1416 }
1417 };
1418 let chain_timestamp = fetch_chain_timestamp(&provider).await;
1419
1420 let subject = match select_subject_for_chain(candidates, rpc_chain_id, root_account) {
1422 Ok(s) => {
1423 let detail = if s.entry.is_some() {
1424 format!(
1425 "local entry on chain {} matches RPC (root {}, key {})",
1426 rpc_chain_id, s.root_account, s.key_address
1427 )
1428 } else {
1429 format!(
1430 "using explicit root {} and key {} on RPC chain {}",
1431 s.root_account, s.key_address, rpc_chain_id
1432 )
1433 };
1434 steps.push(DoctorStep::pass("chain_id_match", "Chain ID match", detail));
1435 context.root_account = Some(s.root_account);
1436 context.key_address = Some(s.key_address);
1437 s
1438 }
1439 Err(detail) => {
1440 steps.push(DoctorStep::fail(
1441 "chain_id_match",
1442 "Chain ID match",
1443 detail,
1444 "use the RPC for the chain the local entry was created on, or pass --root-account",
1445 ));
1446 return finalize_doctor(steps, context);
1447 }
1448 };
1449
1450 let local_signing = check_local_signing_readiness(&subject);
1452 let local_signing_failed = local_signing.status == DoctorStatus::Fail;
1453 steps.push(local_signing);
1454 if local_signing_failed {
1455 return finalize_doctor(steps, context);
1456 }
1457
1458 let registration = match provider
1460 .get_keychain_key(subject.root_account, subject.key_address)
1461 .await
1462 {
1463 Ok(info) if info.keyId != Address::ZERO => {
1464 steps.push(DoctorStep::pass(
1465 "key_registration",
1466 "Key registration",
1467 format!("provisioned, type {}", signature_type_label(&info.signatureType)),
1468 ));
1469 KeyRegistrationState::OnChain(info)
1470 }
1471 Ok(_) => match validate_pending_key_authorization(&subject, rpc_chain_id, &chain_timestamp)
1472 {
1473 Ok(valid) => {
1474 steps.push(DoctorStep::pass("key_registration", "Key registration", valid.detail));
1475 KeyRegistrationState::PendingAuthorization(Box::new(valid.signed))
1476 }
1477 Err(step) => {
1478 steps.push(step);
1479 return finalize_doctor(steps, context);
1480 }
1481 },
1482 Err(err) => {
1483 steps.push(DoctorStep::fail(
1484 "key_registration",
1485 "Key registration",
1486 format!("AccountKeychain.getKey failed: {err}"),
1487 "verify the RPC supports the AccountKeychain precompile",
1488 ));
1489 return finalize_doctor(steps, context);
1490 }
1491 };
1492
1493 match registration {
1494 KeyRegistrationState::OnChain(info) => {
1495 if info.isRevoked {
1497 steps.push(DoctorStep::fail(
1498 "revocation",
1499 "Revocation",
1500 "key is revoked on-chain".to_string(),
1501 "authorize a new key or re-authorize this one",
1502 ));
1503 return finalize_doctor(steps, context);
1504 }
1505 steps.push(DoctorStep::pass("revocation", "Revocation", "active"));
1506
1507 let expiry = check_key_expiry(info.expiry, &chain_timestamp);
1509 let expiry_failed = expiry.status == DoctorStatus::Fail;
1510 steps.push(expiry);
1511 if expiry_failed {
1512 return finalize_doctor(steps, context);
1513 }
1514
1515 let (step, is_t3) = check_hardfork(&provider).await;
1517 steps.push(step);
1518
1519 steps.push(
1521 check_spending_limits(&provider, &subject, &info, context.fee_token, is_t3).await,
1522 );
1523
1524 steps.push(
1526 check_allowed_calls(&provider, &subject, is_t3, to, selector, recipient).await,
1527 );
1528 }
1529 KeyRegistrationState::PendingAuthorization(signed) => {
1530 steps.push(DoctorStep::pass(
1531 "revocation",
1532 "Revocation",
1533 "not on-chain yet; key_authorization will provision a fresh key",
1534 ));
1535
1536 let expiry = check_authorization_expiry(&signed, &chain_timestamp);
1537 let expiry_failed = expiry.status == DoctorStatus::Fail;
1538 steps.push(expiry);
1539 if expiry_failed {
1540 return finalize_doctor(steps, context);
1541 }
1542
1543 let (step, is_t3) = check_hardfork(&provider).await;
1544 steps.push(step);
1545 steps.push(check_authorization_spending_limits(&signed, context.fee_token, is_t3));
1546 steps.push(check_authorization_allowed_calls(&signed, is_t3, to, selector, recipient));
1547 }
1548 }
1549
1550 let resolved_expires_at = tempo.resolve_expires();
1552 steps.push(check_expiring_nonce(&tempo, resolved_expires_at, &chain_timestamp));
1553
1554 let sponsorship = check_sponsorship(&tempo, subject.root_account).await;
1555 let sponsor_failed = sponsorship.step.status == DoctorStatus::Fail;
1556 let fee_payer = sponsorship.fee_payer;
1557 steps.push(sponsorship.step);
1558
1559 if sponsor_failed && tempo.has_sponsor_submission() {
1560 steps.push(DoctorStep::warn(
1561 "fee_token_balance",
1562 "Fee-token balance",
1563 "skipped; sponsorship config is invalid",
1564 "fix the sponsorship configuration before checking the fee payer balance",
1565 ));
1566 } else {
1567 let balance_account = fee_payer.unwrap_or(subject.root_account);
1568 let balance_owner = if fee_payer.is_some() { "sponsor" } else { "root account" };
1569 steps.push(
1570 check_fee_token_balance(&provider, balance_account, context.fee_token, balance_owner)
1571 .await,
1572 );
1573 }
1574
1575 finalize_doctor(steps, context)
1576}
1577
1578fn collect_local_candidates(
1580 key_address: Option<Address>,
1581 root_account: Option<Address>,
1582) -> Result<LocalCandidateResolution, DoctorStep> {
1583 let explicit_candidate = || {
1584 key_address
1585 .zip(root_account)
1586 .map(|(key_address, root_account)| DoctorCandidate::explicit(root_account, key_address))
1587 };
1588
1589 let Some(keys_file) = read_tempo_keys_file() else {
1590 if let Some(candidate) = explicit_candidate() {
1591 return Ok(LocalCandidateResolution {
1592 step: DoctorStep::pass(
1593 "local_registry",
1594 "Local registry",
1595 format!(
1596 "could not read {}; using explicit root/key",
1597 tempo_keys_path_display()
1598 ),
1599 ),
1600 candidates: vec![candidate],
1601 });
1602 }
1603
1604 return Err(DoctorStep::fail(
1605 "local_registry",
1606 "Local registry",
1607 format!("could not read local keys file at {}", tempo_keys_path_display()),
1608 "run `cast tempo login` or pass both KEY_ADDRESS and --root-account",
1609 ));
1610 };
1611
1612 let matches: Vec<tempo::KeyEntry> = keys_file
1613 .keys
1614 .into_iter()
1615 .filter(|entry| match (key_address, root_account) {
1616 (Some(k), Some(r)) => key_entry_effective_key(entry) == k && entry.wallet_address == r,
1617 (Some(k), None) => key_entry_effective_key(entry) == k,
1618 (None, Some(r)) => entry.wallet_address == r,
1619 (None, None) => false,
1620 })
1621 .collect();
1622
1623 if matches.is_empty() {
1624 if let Some(candidate) = explicit_candidate() {
1625 return Ok(LocalCandidateResolution {
1626 step: DoctorStep::pass(
1627 "local_registry",
1628 "Local registry",
1629 format!(
1630 "no local entry for key {} and root {}; using explicit root/key",
1631 candidate.key_address, candidate.root_account
1632 ),
1633 ),
1634 candidates: vec![candidate],
1635 });
1636 }
1637
1638 let descriptor = match (key_address, root_account) {
1639 (Some(k), Some(r)) => format!("key {k} for root {r}"),
1640 (Some(k), None) => format!("key {k}"),
1641 (None, Some(r)) => format!("root account {r}"),
1642 (None, None) => "the requested key".to_string(),
1643 };
1644 let hint = match (key_address, root_account) {
1645 (Some(_), None) => "pass --root-account to diagnose an explicit key/root pair",
1646 (None, Some(_)) => "pass KEY_ADDRESS to diagnose a key without a local registry entry",
1647 _ => "run `cast tempo login` or add the key to ~/.tempo/wallet/keys.toml",
1648 };
1649 return Err(DoctorStep::fail(
1650 "local_registry",
1651 "Local registry",
1652 format!("no entry for {descriptor} in {}", tempo_keys_path_display()),
1653 hint,
1654 ));
1655 }
1656
1657 let count = matches.len();
1658 let mut candidates: Vec<DoctorCandidate> =
1659 matches.into_iter().map(DoctorCandidate::from_entry).collect();
1660 if let Some(candidate) = explicit_candidate() {
1661 candidates.push(candidate);
1662 }
1663
1664 Ok(LocalCandidateResolution {
1665 step: DoctorStep::pass(
1666 "local_registry",
1667 "Local registry",
1668 format!("{count} candidate(s) in {}", tempo_keys_path_display()),
1669 ),
1670 candidates,
1671 })
1672}
1673
1674fn select_subject_for_chain(
1676 candidates: Vec<DoctorCandidate>,
1677 rpc_chain_id: u64,
1678 explicit_root: Option<Address>,
1679) -> Result<DoctorSubject, String> {
1680 let local_chain_ids: Vec<u64> = candidates.iter().filter_map(|e| e.chain_id).collect();
1681
1682 let chain_matched: Vec<DoctorCandidate> = candidates
1683 .into_iter()
1684 .filter(|entry| entry.chain_id.is_none_or(|chain_id| chain_id == rpc_chain_id))
1685 .collect();
1686
1687 if chain_matched.is_empty() {
1688 return Err(format!(
1689 "no local entry matches RPC chain id {rpc_chain_id} (local entries on {local_chain_ids:?})"
1690 ));
1691 }
1692
1693 if explicit_root.is_none()
1695 && chain_matched.iter().any(|entry| entry.root_account != chain_matched[0].root_account)
1696 {
1697 return Err(
1698 "multiple local entries match this chain across different root accounts; pass --root-account"
1699 .to_string(),
1700 );
1701 }
1702
1703 let has_explicit = chain_matched.iter().any(|entry| entry.explicit);
1704
1705 let preferred_idx = chain_matched
1708 .iter()
1709 .position(DoctorCandidate::is_passkey_with_inline_key)
1710 .or_else(|| chain_matched.iter().position(DoctorCandidate::has_inline_key))
1711 .unwrap_or(0);
1712 let entry = chain_matched.into_iter().nth(preferred_idx).expect("non-empty");
1713
1714 Ok(DoctorSubject {
1715 root_account: entry.root_account,
1716 key_address: entry.key_address,
1717 entry: entry.entry,
1718 explicit: has_explicit,
1719 })
1720}
1721
1722fn check_local_signing_readiness(subject: &DoctorSubject) -> DoctorStep {
1724 let Some(entry) = subject.entry.as_ref() else {
1725 return DoctorStep::warn(
1726 "local_signing",
1727 "Local signing",
1728 "not verified; using explicit root/key without a local registry entry",
1729 "pass --tempo.access-key in the send command or add this key to ~/.tempo/wallet/keys.toml",
1730 );
1731 };
1732
1733 if entry.has_inline_key() {
1734 return DoctorStep::pass(
1735 "local_signing",
1736 "Local signing",
1737 format!("inline {} key available", key_type_name(&entry.key_type)),
1738 );
1739 }
1740
1741 if subject.explicit {
1742 return DoctorStep::warn(
1743 "local_signing",
1744 "Local signing",
1745 "local entry has no inline access-key private key; explicit root/key can still use --tempo.access-key",
1746 "pass --tempo.access-key in the send command or refresh the local key material",
1747 );
1748 }
1749
1750 DoctorStep::fail(
1751 "local_signing",
1752 "Local signing",
1753 "local entry has no inline access-key private key",
1754 "run `cast tempo login` again, restore the key material, or pass --tempo.access-key when sending",
1755 )
1756}
1757
1758fn validate_pending_key_authorization(
1759 subject: &DoctorSubject,
1760 rpc_chain_id: u64,
1761 chain_timestamp: &ChainTimestamp,
1762) -> Result<ValidKeyAuthorization, DoctorStep> {
1763 let Some(entry) = subject.entry.as_ref() else {
1764 return Err(DoctorStep::fail(
1765 "key_registration",
1766 "Key registration",
1767 format!(
1768 "key {} is not registered for root account {}",
1769 subject.key_address, subject.root_account
1770 ),
1771 "authorize the key with `cast keychain authorize <KEY>` or add a local key_authorization",
1772 ));
1773 };
1774
1775 let Some(raw) = entry.key_authorization.as_deref().filter(|raw| !raw.trim().is_empty()) else {
1776 return Err(DoctorStep::fail(
1777 "key_registration",
1778 "Key registration",
1779 format!(
1780 "key {} is not registered for root account {}",
1781 subject.key_address, subject.root_account
1782 ),
1783 "authorize the key with `cast keychain authorize <KEY>` or refresh the local key_authorization",
1784 ));
1785 };
1786
1787 let signed: SignedKeyAuthorization = tempo::decode_key_authorization(raw).map_err(|err| {
1788 DoctorStep::fail(
1789 "key_registration",
1790 "Key registration",
1791 format!("local key_authorization could not be decoded: {err}"),
1792 "refresh the access key with `cast tempo login`",
1793 )
1794 })?;
1795 let auth = &signed.authorization;
1796
1797 if auth.key_id != subject.key_address {
1798 return Err(DoctorStep::fail(
1799 "key_registration",
1800 "Key registration",
1801 format!(
1802 "local key_authorization is for key {}, expected {}",
1803 auth.key_id, subject.key_address
1804 ),
1805 "refresh the access key for this root/key pair",
1806 ));
1807 }
1808
1809 if auth.chain_id != rpc_chain_id {
1810 return Err(DoctorStep::fail(
1811 "key_registration",
1812 "Key registration",
1813 format!(
1814 "local key_authorization is for chain {}, RPC is chain {}",
1815 auth.chain_id, rpc_chain_id
1816 ),
1817 "use the RPC for the chain the authorization was created on",
1818 ));
1819 }
1820
1821 if !key_type_matches_authorization(&entry.key_type, &auth.key_type) {
1822 return Err(DoctorStep::fail(
1823 "key_registration",
1824 "Key registration",
1825 format!(
1826 "local key type {} does not match key_authorization type {}",
1827 key_type_label(&entry.key_type),
1828 auth_signature_type_label(&auth.key_type)
1829 ),
1830 "refresh the local key entry so its key material and authorization agree",
1831 ));
1832 }
1833
1834 if let Some(expiry) = auth.expiry
1835 && let Some(chain_timestamp) = chain_timestamp.timestamp()
1836 && expiry.get() <= chain_timestamp
1837 {
1838 return Err(DoctorStep::fail(
1839 "key_registration",
1840 "Key registration",
1841 format!(
1842 "local key_authorization expired {}",
1843 format_relative_timestamp_from(expiry.get(), chain_timestamp)
1844 ),
1845 "refresh the access key to get a later key_authorization expiry",
1846 ));
1847 }
1848
1849 match signed.recover_signer() {
1850 Ok(recovered) if recovered == subject.root_account => {}
1851 Ok(recovered) => {
1852 return Err(DoctorStep::fail(
1853 "key_registration",
1854 "Key registration",
1855 format!(
1856 "local key_authorization recovers signer {recovered}, expected root {}",
1857 subject.root_account
1858 ),
1859 "refresh the authorization with the correct root account",
1860 ));
1861 }
1862 Err(err) => {
1863 return Err(DoctorStep::fail(
1864 "key_registration",
1865 "Key registration",
1866 format!("local key_authorization signature could not be verified: {err}"),
1867 "refresh the access key with `cast tempo login`",
1868 ));
1869 }
1870 }
1871
1872 let expiry = auth
1873 .expiry
1874 .map(|expiry| {
1875 let relative = chain_timestamp
1876 .timestamp()
1877 .map(|timestamp| format_relative_timestamp_from(expiry.get(), timestamp))
1878 .unwrap_or_else(|| format_relative_timestamp(expiry.get()));
1879 format!("{} ({})", relative, format_timestamp_iso(expiry.get()))
1880 })
1881 .unwrap_or_else(|| "never expires".to_string());
1882 let witness = auth.witness().map(|witness| format!(", witness {witness}")).unwrap_or_default();
1883 let detail = format!(
1884 "not on-chain; local key_authorization can provision atomically, type {}, expiry {}{}",
1885 auth_signature_type_label(&auth.key_type),
1886 expiry,
1887 witness
1888 );
1889
1890 Ok(ValidKeyAuthorization { signed, detail })
1891}
1892
1893async fn fetch_chain_timestamp<P>(provider: &P) -> ChainTimestamp
1894where
1895 P: Provider<TempoNetwork>,
1896{
1897 match provider.get_block(BlockId::latest()).await {
1898 Ok(Some(block)) => ChainTimestamp::Known(block.header.timestamp()),
1899 Ok(None) => ChainTimestamp::Unknown {
1900 detail: "latest block not found; chain timestamp unavailable".to_string(),
1901 hint: "verify the RPC can serve latest block data",
1902 },
1903 Err(err) => ChainTimestamp::Unknown {
1904 detail: format!("latest block query failed: {err}"),
1905 hint: "validity windows and expiries could not be checked against chain time",
1906 },
1907 }
1908}
1909
1910fn check_key_expiry(expiry: u64, chain_timestamp: &ChainTimestamp) -> DoctorStep {
1911 if expiry == u64::MAX {
1912 return DoctorStep::pass("expiry", "Expiry", "never expires");
1913 }
1914
1915 let Some(chain_timestamp) = chain_timestamp.timestamp() else {
1916 return chain_timestamp.unavailable_step("expiry", "Expiry", "key expiry not checked");
1917 };
1918
1919 if expiry <= chain_timestamp {
1920 DoctorStep::fail(
1921 "expiry",
1922 "Expiry",
1923 format!("expired {}", format_relative_timestamp_from(expiry, chain_timestamp)),
1924 "authorize a new key with a later expiry",
1925 )
1926 } else {
1927 DoctorStep::pass(
1928 "expiry",
1929 "Expiry",
1930 format!(
1931 "{} ({})",
1932 format_relative_timestamp_from(expiry, chain_timestamp),
1933 format_timestamp_iso(expiry)
1934 ),
1935 )
1936 }
1937}
1938
1939fn check_authorization_expiry(
1940 signed: &SignedKeyAuthorization,
1941 chain_timestamp: &ChainTimestamp,
1942) -> DoctorStep {
1943 let Some(expiry) = signed.authorization.expiry else {
1944 return DoctorStep::pass("expiry", "Expiry", "key_authorization never expires");
1945 };
1946
1947 let Some(chain_timestamp) = chain_timestamp.timestamp() else {
1948 return chain_timestamp.unavailable_step(
1949 "expiry",
1950 "Expiry",
1951 "key_authorization expiry not checked",
1952 );
1953 };
1954
1955 let expiry = expiry.get();
1956 if expiry <= chain_timestamp {
1957 DoctorStep::fail(
1958 "expiry",
1959 "Expiry",
1960 format!(
1961 "key_authorization expired {}",
1962 format_relative_timestamp_from(expiry, chain_timestamp)
1963 ),
1964 "refresh the access key to get a later key_authorization expiry",
1965 )
1966 } else {
1967 DoctorStep::pass(
1968 "expiry",
1969 "Expiry",
1970 format!(
1971 "key_authorization {} ({})",
1972 format_relative_timestamp_from(expiry, chain_timestamp),
1973 format_timestamp_iso(expiry)
1974 ),
1975 )
1976 }
1977}
1978
1979async fn check_hardfork<P>(provider: &P) -> (DoctorStep, Option<bool>)
1980where
1981 P: Provider<TempoNetwork>,
1982{
1983 match is_tempo_hardfork_active(provider, TempoHardfork::T3).await {
1984 Ok(true) => (DoctorStep::pass("hardfork", "Hardfork", "Tempo T3 active"), Some(true)),
1985 Ok(false) => (
1986 DoctorStep::pass("hardfork", "Hardfork", "pre-T3; TIP-1011 scopes not enforced"),
1987 Some(false),
1988 ),
1989 Err(err) => (
1990 DoctorStep::warn(
1991 "hardfork",
1992 "Hardfork",
1993 format!("could not determine Tempo T3 activation: {err}"),
1994 "TIP-1011 allowed-call and T3 spending-period checks will be skipped",
1995 ),
1996 None,
1997 ),
1998 }
1999}
2000
2001async fn check_spending_limits<P>(
2003 provider: &P,
2004 subject: &DoctorSubject,
2005 info: &KeyInfo,
2006 fee_token: Address,
2007 is_t3: Option<bool>,
2008) -> DoctorStep
2009where
2010 P: Provider<TempoNetwork>,
2011{
2012 let Some(is_t3) = is_t3 else {
2013 return DoctorStep::warn(
2014 "spending_limits",
2015 "Spending limits",
2016 "skipped; hardfork unknown",
2017 "retry against an RPC that reports Tempo hardfork activation",
2018 );
2019 };
2020
2021 if !info.enforceLimits {
2022 return DoctorStep::pass(
2023 "spending_limits",
2024 "Spending limits",
2025 "limits not enforced for this key",
2026 );
2027 }
2028
2029 let local_limits = subject.entry.as_ref().map(|entry| entry.limits.as_slice()).unwrap_or(&[]);
2030
2031 let mut tokens: Vec<Address> = local_limits.iter().map(|l| l.currency).collect();
2033 if !tokens.contains(&fee_token) {
2034 tokens.push(fee_token);
2035 }
2036
2037 let mut lines: Vec<String> = Vec::new();
2038 let mut any_zero = false;
2039
2040 for token in tokens {
2041 let configured = local_limits.iter().find(|l| l.currency == token).map(|l| l.limit.clone());
2042
2043 let (remaining, period_end) = if is_t3 {
2044 match provider
2045 .get_keychain_remaining_limit_with_period(
2046 subject.root_account,
2047 subject.key_address,
2048 token,
2049 )
2050 .await
2051 {
2052 Ok(r) => (r.remaining, Some(r.periodEnd)),
2053 Err(err) => {
2054 return DoctorStep::warn(
2055 "spending_limits",
2056 "Spending limits",
2057 format!("{} query failed: {err}", address_label(token)),
2058 "verify the AccountKeychain precompile is reachable",
2059 );
2060 }
2061 }
2062 } else {
2063 match provider
2064 .account_keychain()
2065 .getRemainingLimit(subject.root_account, subject.key_address, token)
2066 .call()
2067 .await
2068 {
2069 Ok(r) => (r, None),
2070 Err(err) => {
2071 return DoctorStep::warn(
2072 "spending_limits",
2073 "Spending limits",
2074 format!("{} query failed: {err}", address_label(token)),
2075 "verify the AccountKeychain precompile is reachable",
2076 );
2077 }
2078 }
2079 };
2080
2081 if remaining.is_zero() {
2082 any_zero = true;
2083 }
2084
2085 let configured_str = configured.as_deref().unwrap_or("?");
2086 let period_str = period_end
2087 .and_then(|pe| (pe != 0).then(|| format!(" ({})", format_period_end(pe))))
2088 .unwrap_or_default();
2089 lines.push(format!(
2090 "{} remaining {} / {}{}",
2091 address_label(token),
2092 remaining,
2093 configured_str,
2094 period_str
2095 ));
2096 }
2097
2098 let detail = lines.join("; ");
2099 if any_zero {
2100 DoctorStep::warn(
2101 "spending_limits",
2102 "Spending limits",
2103 detail,
2104 "raise the limit (e.g. `cast keychain ul ...`) or wait for the window reset",
2105 )
2106 } else {
2107 DoctorStep::pass("spending_limits", "Spending limits", detail)
2108 }
2109}
2110
2111fn check_authorization_spending_limits(
2112 signed: &SignedKeyAuthorization,
2113 fee_token: Address,
2114 is_t3: Option<bool>,
2115) -> DoctorStep {
2116 let auth = &signed.authorization;
2117
2118 if is_t3.is_none() && auth.has_periodic_limits() {
2119 return DoctorStep::warn(
2120 "spending_limits",
2121 "Spending limits",
2122 "skipped; hardfork unknown and key_authorization uses periodic limits",
2123 "retry against an RPC that reports Tempo hardfork activation",
2124 );
2125 }
2126
2127 if matches!(is_t3, Some(false)) && !auth.is_legacy_compatible() {
2128 return DoctorStep::fail(
2129 "spending_limits",
2130 "Spending limits",
2131 "key_authorization uses T3-only limits or call scopes on a pre-T3 chain",
2132 "use a T3 RPC or refresh the authorization with legacy-compatible restrictions",
2133 );
2134 }
2135
2136 match auth.limits.as_deref() {
2137 None => DoctorStep::pass(
2138 "spending_limits",
2139 "Spending limits",
2140 "limits not enforced by key_authorization",
2141 ),
2142 Some([]) => DoctorStep::warn(
2143 "spending_limits",
2144 "Spending limits",
2145 "key_authorization allows no token spending",
2146 "refresh the access key with spending limits if the transaction spends TIP-20 tokens",
2147 ),
2148 Some(limits) => {
2149 let detail = format_authorization_limits(limits, fee_token);
2150 if !limits.iter().any(|limit| limit.token == fee_token) {
2151 DoctorStep::warn(
2152 "spending_limits",
2153 "Spending limits",
2154 detail,
2155 "refresh the access key with a limit for the selected fee token",
2156 )
2157 } else if limits.iter().any(|limit| limit.token == fee_token && limit.limit.is_zero()) {
2158 DoctorStep::warn(
2159 "spending_limits",
2160 "Spending limits",
2161 detail,
2162 "raise the fee-token limit before sending with this authorization",
2163 )
2164 } else {
2165 DoctorStep::pass("spending_limits", "Spending limits", detail)
2166 }
2167 }
2168 }
2169}
2170
2171async fn check_allowed_calls<P>(
2173 provider: &P,
2174 subject: &DoctorSubject,
2175 is_t3: Option<bool>,
2176 to: Option<Address>,
2177 selector: Option<[u8; 4]>,
2178 recipient: Option<Address>,
2179) -> DoctorStep
2180where
2181 P: Provider<TempoNetwork>,
2182{
2183 let Some(is_t3) = is_t3 else {
2184 return DoctorStep::warn(
2185 "allowed_calls",
2186 "Allowed calls",
2187 "skipped; hardfork unknown",
2188 "retry against an RPC that reports Tempo hardfork activation",
2189 );
2190 };
2191
2192 if !is_t3 {
2193 return DoctorStep::pass(
2194 "allowed_calls",
2195 "Allowed calls",
2196 "TIP-1011 not enforced before T3",
2197 );
2198 }
2199
2200 let allowed = match provider
2201 .account_keychain()
2202 .getAllowedCalls(subject.root_account, subject.key_address)
2203 .call()
2204 .await
2205 {
2206 Ok(a) => a,
2207 Err(err) => {
2208 return DoctorStep::warn(
2209 "allowed_calls",
2210 "Allowed calls",
2211 format!("getAllowedCalls failed: {err}"),
2212 "verify the AccountKeychain precompile is reachable",
2213 );
2214 }
2215 };
2216
2217 if !allowed.isScoped {
2218 return DoctorStep::pass("allowed_calls", "Allowed calls", "any call permitted");
2219 }
2220
2221 diagnose_allowed_scopes(&allowed.scopes, to, selector, recipient)
2222}
2223
2224fn diagnose_allowed_scopes(
2225 scopes: &[CallScope],
2226 to: Option<Address>,
2227 selector: Option<[u8; 4]>,
2228 recipient: Option<Address>,
2229) -> DoctorStep {
2230 if scopes.is_empty() {
2231 let detail = "scoped, but no targets permitted";
2232 return if to.is_some() && selector.is_some() {
2233 DoctorStep::fail(
2234 "allowed_calls",
2235 "Allowed calls",
2236 detail,
2237 "widen the policy with `cast keychain policy add-call ...`",
2238 )
2239 } else {
2240 DoctorStep::warn(
2241 "allowed_calls",
2242 "Allowed calls",
2243 detail,
2244 "widen the policy with `cast keychain policy add-call ...`",
2245 )
2246 };
2247 }
2248
2249 let Some(to) = to else {
2250 return DoctorStep::pass(
2251 "allowed_calls",
2252 "Allowed calls",
2253 format!(
2254 "scoped to {} target(s); pass --to/--selector to test a specific call",
2255 scopes.len()
2256 ),
2257 );
2258 };
2259
2260 let Some(selector) = selector else {
2261 return if scopes.iter().any(|s| s.target == to) {
2263 DoctorStep::pass(
2264 "allowed_calls",
2265 "Allowed calls",
2266 format!("target {to} is in scope; pass --selector to test the function"),
2267 )
2268 } else {
2269 DoctorStep::warn(
2270 "allowed_calls",
2271 "Allowed calls",
2272 format!("target {to} not in any allowed scope"),
2273 "widen the policy with `cast keychain policy add-call ...`",
2274 )
2275 };
2276 };
2277
2278 match match_allowed_call(scopes, to, selector, recipient) {
2279 AllowedCallMatch::Allowed(detail) => {
2280 DoctorStep::pass("allowed_calls", "Allowed calls", detail)
2281 }
2282 AllowedCallMatch::Denied(reason) => DoctorStep::fail(
2283 "allowed_calls",
2284 "Allowed calls",
2285 reason,
2286 "widen the policy with `cast keychain policy add-call ...`",
2287 ),
2288 AllowedCallMatch::RecipientRestricted(recipients) => DoctorStep::pass(
2289 "allowed_calls",
2290 "Allowed calls",
2291 format!(
2292 "selector {} on {} allowed only for {}; pass --recipient to verify exact match",
2293 format_selector(&selector),
2294 address_label_with_address(to),
2295 format_recipients(&recipients)
2296 ),
2297 ),
2298 }
2299}
2300
2301fn check_authorization_allowed_calls(
2302 signed: &SignedKeyAuthorization,
2303 is_t3: Option<bool>,
2304 to: Option<Address>,
2305 selector: Option<[u8; 4]>,
2306 recipient: Option<Address>,
2307) -> DoctorStep {
2308 let auth = &signed.authorization;
2309
2310 let Some(is_t3) = is_t3 else {
2311 return DoctorStep::warn(
2312 "allowed_calls",
2313 "Allowed calls",
2314 "skipped; hardfork unknown",
2315 "retry against an RPC that reports Tempo hardfork activation",
2316 );
2317 };
2318
2319 if !is_t3 {
2320 return DoctorStep::pass(
2321 "allowed_calls",
2322 "Allowed calls",
2323 "TIP-1011 not enforced before T3",
2324 );
2325 }
2326
2327 let Some(scopes) = auth.allowed_calls.as_deref() else {
2328 return DoctorStep::pass(
2329 "allowed_calls",
2330 "Allowed calls",
2331 "any call permitted by key_authorization",
2332 );
2333 };
2334
2335 let scopes: Vec<CallScope> = scopes.iter().cloned().map(Into::into).collect();
2336 diagnose_allowed_scopes(&scopes, to, selector, recipient)
2337}
2338
2339fn match_allowed_call(
2341 scopes: &[CallScope],
2342 to: Address,
2343 selector: [u8; 4],
2344 recipient: Option<Address>,
2345) -> AllowedCallMatch {
2346 let matching_scopes: Vec<_> = scopes.iter().filter(|scope| scope.target == to).collect();
2347 if matching_scopes.is_empty() {
2348 return AllowedCallMatch::Denied(format!("target {to} not in any allowed scope"));
2349 }
2350
2351 if matching_scopes.iter().any(|scope| scope.selectorRules.is_empty()) {
2352 return AllowedCallMatch::Allowed(format!(
2353 "any selector on {} permitted",
2354 address_label_with_address(to)
2355 ));
2356 }
2357
2358 let matching_rules: Vec<_> = matching_scopes
2359 .iter()
2360 .flat_map(|scope| scope.selectorRules.iter())
2361 .filter(|rule| rule.selector.0 == selector)
2362 .collect();
2363
2364 if matching_rules.is_empty() {
2365 return AllowedCallMatch::Denied(format!(
2366 "selector {} on {} not in allowed list",
2367 format_selector(&selector),
2368 address_label_with_address(to)
2369 ));
2370 }
2371
2372 if matching_rules.iter().any(|rule| rule.recipients.is_empty()) {
2373 return AllowedCallMatch::Allowed(format!(
2374 "{} on {} permitted (any recipient)",
2375 format_selector(&selector),
2376 address_label_with_address(to)
2377 ));
2378 }
2379
2380 match recipient {
2381 Some(r) if matching_rules.iter().any(|rule| rule.recipients.contains(&r)) => {
2382 AllowedCallMatch::Allowed(format!(
2383 "{} on {} to recipient {} permitted",
2384 format_selector(&selector),
2385 address_label_with_address(to),
2386 r
2387 ))
2388 }
2389 Some(r) => AllowedCallMatch::Denied(format!(
2390 "recipient {r} not in allowed list for {} on {}",
2391 format_selector(&selector),
2392 address_label_with_address(to)
2393 )),
2394 None => {
2395 let mut recipients = Vec::new();
2396 for recipient in matching_rules.iter().flat_map(|rule| rule.recipients.iter().copied())
2397 {
2398 if !recipients.contains(&recipient) {
2399 recipients.push(recipient);
2400 }
2401 }
2402 AllowedCallMatch::RecipientRestricted(recipients)
2403 }
2404 }
2405}
2406
2407async fn check_fee_token_balance<P>(
2409 provider: &P,
2410 account: Address,
2411 fee_token: Address,
2412 owner_label: &'static str,
2413) -> DoctorStep
2414where
2415 P: Provider<TempoNetwork>,
2416{
2417 match ITIP20::new(fee_token, provider).balanceOf(account).call().await {
2418 Ok(balance) if balance.is_zero() => DoctorStep::warn(
2419 "fee_token_balance",
2420 "Fee-token balance",
2421 format!("0 {} on {owner_label} {}", address_label(fee_token), account),
2422 format!("fund {owner_label} {} with {}", account, address_label(fee_token)),
2423 ),
2424 Ok(balance) => DoctorStep::pass(
2425 "fee_token_balance",
2426 "Fee-token balance",
2427 format!("{} {} on {owner_label} {}", balance, address_label(fee_token), account),
2428 ),
2429 Err(err) => DoctorStep::warn(
2430 "fee_token_balance",
2431 "Fee-token balance",
2432 format!("balanceOf failed: {err}"),
2433 "verify --fee-token points to a TIP-20 token",
2434 ),
2435 }
2436}
2437
2438fn check_expiring_nonce(
2440 tempo: &TempoOpts,
2441 resolved_expires_at: Option<u64>,
2442 chain_timestamp: &ChainTimestamp,
2443) -> DoctorStep {
2444 if !tempo.expiring_nonce && tempo.valid_before.is_none() && tempo.valid_after.is_none() {
2445 return DoctorStep::pass("expiring_nonce", "Expiring nonce", "not requested");
2446 }
2447
2448 let Some(chain_timestamp) = chain_timestamp.timestamp() else {
2449 return chain_timestamp.unavailable_step(
2450 "expiring_nonce",
2451 "Expiring nonce",
2452 "validity window not checked",
2453 );
2454 };
2455
2456 check_expiring_nonce_window(tempo, resolved_expires_at, chain_timestamp)
2457}
2458
2459fn check_expiring_nonce_window(
2460 tempo: &TempoOpts,
2461 resolved_expires_at: Option<u64>,
2462 chain_timestamp: u64,
2463) -> DoctorStep {
2464 let valid_before = tempo.valid_before;
2465 let valid_after = tempo.valid_after;
2466 let missing_expiring_nonce =
2467 (valid_before.is_some() || valid_after.is_some()) && !tempo.expiring_nonce;
2468
2469 if let (Some(after), Some(before)) = (valid_after, valid_before)
2470 && after >= before
2471 {
2472 return DoctorStep::fail(
2473 "expiring_nonce",
2474 "Expiring nonce",
2475 format!("valid-after {after} is not before valid-before {before}"),
2476 "choose a valid window where valid-after < valid-before",
2477 );
2478 }
2479
2480 if let Some(before) = valid_before {
2481 if before <= chain_timestamp {
2482 return DoctorStep::fail(
2483 "expiring_nonce",
2484 "Expiring nonce",
2485 format!(
2486 "valid-before {} is expired at chain timestamp {}",
2487 format_timestamp_iso(before),
2488 chain_timestamp
2489 ),
2490 "use a later --tempo.valid-before or rerun with --tempo.expires",
2491 );
2492 }
2493
2494 let ttl = before - chain_timestamp;
2495 if ttl <= 3 {
2496 return DoctorStep::fail(
2497 "expiring_nonce",
2498 "Expiring nonce",
2499 format!(
2500 "valid-before must be more than 3s after chain timestamp {chain_timestamp}; current ttl is {ttl}s"
2501 ),
2502 "use a later --tempo.valid-before or rerun with --tempo.expires",
2503 );
2504 }
2505 if ttl <= 5 {
2506 return DoctorStep::warn(
2507 "expiring_nonce",
2508 "Expiring nonce",
2509 format!("valid for only {ttl}s at chain timestamp {chain_timestamp}"),
2510 "use a larger validity window before signing",
2511 );
2512 }
2513 if ttl > 30 {
2514 if resolved_expires_at.is_some() {
2515 return DoctorStep::warn(
2516 "expiring_nonce",
2517 "Expiring nonce",
2518 format!(
2519 "--tempo.expires resolved to a deadline {ttl}s ahead of chain timestamp {chain_timestamp}"
2520 ),
2521 "check local clock/RPC timestamp skew before relying on this deadline",
2522 );
2523 }
2524
2525 return DoctorStep::warn(
2526 "expiring_nonce",
2527 "Expiring nonce",
2528 format!(
2529 "valid-before is {ttl}s ahead of chain timestamp {chain_timestamp}; --tempo.expires caps this at 30s"
2530 ),
2531 "prefer --tempo.expires for bounded retry-safe sends",
2532 );
2533 }
2534 }
2535
2536 if let Some(after) = valid_after
2537 && after > chain_timestamp
2538 {
2539 return DoctorStep::warn(
2540 "expiring_nonce",
2541 "Expiring nonce",
2542 format!("transaction is not valid until {}", format_timestamp_iso(after)),
2543 "wait until valid-after or choose an earlier lower bound",
2544 );
2545 }
2546
2547 if missing_expiring_nonce {
2548 return DoctorStep::warn(
2549 "expiring_nonce",
2550 "Expiring nonce",
2551 "validity window set without --tempo.expiring-nonce",
2552 "use --tempo.expiring-nonce or --tempo.expires so nonce_key is set to the expiring lane",
2553 );
2554 }
2555
2556 let mut detail = format!("enabled at chain timestamp {chain_timestamp}");
2557 if let Some(before) = valid_before {
2558 detail.push_str(&format!(", valid-before {}", format_timestamp_iso(before)));
2559 }
2560 if let Some(after) = valid_after {
2561 detail.push_str(&format!(", valid-after {}", format_timestamp_iso(after)));
2562 }
2563 if let Some(expires_at) = resolved_expires_at {
2564 detail.push_str(&format!(
2565 ", --tempo.expires resolved to {}",
2566 format_timestamp_iso(expires_at)
2567 ));
2568 }
2569
2570 DoctorStep::pass("expiring_nonce", "Expiring nonce", detail)
2571}
2572
2573async fn check_sponsorship(tempo: &TempoOpts, sender: Address) -> SponsorshipDiagnosis {
2575 if tempo.print_sponsor_hash {
2576 return SponsorshipDiagnosis {
2577 step: DoctorStep::pass(
2578 "sponsorship",
2579 "Sponsorship",
2580 "--tempo.print-sponsor-hash requested, but doctor has no concrete tx payload",
2581 ),
2582 fee_payer: None,
2583 };
2584 }
2585
2586 if !tempo.has_sponsor_submission() {
2587 return SponsorshipDiagnosis {
2588 step: DoctorStep::pass("sponsorship", "Sponsorship", "not requested"),
2589 fee_payer: None,
2590 };
2591 }
2592
2593 let sponsor = match tempo.sponsor_config().await {
2594 Ok(Some(sponsor)) => sponsor,
2595 Ok(None) => {
2596 return SponsorshipDiagnosis {
2597 step: DoctorStep::pass("sponsorship", "Sponsorship", "not requested"),
2598 fee_payer: None,
2599 };
2600 }
2601 Err(err) => {
2602 return SponsorshipDiagnosis {
2603 step: DoctorStep::fail(
2604 "sponsorship",
2605 "Sponsorship",
2606 format!(
2607 "invalid sponsor config: {}",
2608 sanitize_sponsor_config_error(&err.to_string(), tempo)
2609 ),
2610 "pass --tempo.sponsor with either --tempo.sponsor-signer or --tempo.sponsor-sig",
2611 ),
2612 fee_payer: None,
2613 };
2614 }
2615 };
2616
2617 if sponsor.sponsor() == sender {
2618 return SponsorshipDiagnosis {
2619 step: DoctorStep::fail(
2620 "sponsorship",
2621 "Sponsorship",
2622 format!("sponsor {} equals transaction sender {sender}", sponsor.sponsor()),
2623 "use a different fee payer for sponsored transactions",
2624 ),
2625 fee_payer: Some(sponsor.sponsor()),
2626 };
2627 }
2628
2629 if tempo.sponsor_sig.is_some() {
2630 return SponsorshipDiagnosis {
2631 step: DoctorStep::warn(
2632 "sponsorship",
2633 "Sponsorship",
2634 format!("signature syntax parsed for sponsor {}", sponsor.sponsor()),
2635 "doctor cannot recover fee_payer_signature without the exact transaction digest",
2636 ),
2637 fee_payer: Some(sponsor.sponsor()),
2638 };
2639 }
2640
2641 SponsorshipDiagnosis {
2642 step: DoctorStep::pass(
2643 "sponsorship",
2644 "Sponsorship",
2645 format!("sponsor signer configured for {}", sponsor.sponsor()),
2646 ),
2647 fee_payer: Some(sponsor.sponsor()),
2648 }
2649}
2650
2651fn unix_timestamp_now() -> u64 {
2652 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()
2653}
2654
2655const fn key_type_matches_authorization(key_type: &KeyType, auth_type: &AuthSignatureType) -> bool {
2656 matches!(
2657 (key_type, auth_type),
2658 (KeyType::Secp256k1, AuthSignatureType::Secp256k1)
2659 | (KeyType::P256, AuthSignatureType::P256)
2660 | (KeyType::WebAuthn, AuthSignatureType::WebAuthn)
2661 )
2662}
2663
2664const fn auth_signature_type_label(t: &AuthSignatureType) -> &'static str {
2665 match t {
2666 AuthSignatureType::Secp256k1 => "Secp256k1",
2667 AuthSignatureType::P256 => "P256",
2668 AuthSignatureType::WebAuthn => "WebAuthn",
2669 }
2670}
2671
2672const fn auth_signature_type_name(t: &AuthSignatureType) -> &'static str {
2673 match t {
2674 AuthSignatureType::Secp256k1 => "secp256k1",
2675 AuthSignatureType::P256 => "p256",
2676 AuthSignatureType::WebAuthn => "webauthn",
2677 }
2678}
2679
2680fn format_authorization_limits(limits: &[AuthTokenLimit], fee_token: Address) -> String {
2681 let mut lines: Vec<String> = limits
2682 .iter()
2683 .map(|limit| {
2684 let period =
2685 if limit.period == 0 { String::new() } else { format!(" per {}s", limit.period) };
2686 format!("{} limit {}{}", address_label(limit.token), limit.limit, period)
2687 })
2688 .collect();
2689
2690 if !limits.iter().any(|limit| limit.token == fee_token) {
2691 lines.push(format!("{} not listed in key_authorization limits", address_label(fee_token)));
2692 }
2693
2694 lines.join("; ")
2695}
2696
2697fn sanitize_sponsor_config_error(message: &str, tempo: &TempoOpts) -> String {
2698 let mut sanitized = message.to_string();
2699 if let Some(spec) = tempo.sponsor_signer.as_deref()
2700 && spec.starts_with("private-key://")
2701 {
2702 sanitized = sanitized.replace(spec, "private-key://<redacted>");
2703 }
2704 redact_private_key_uri_tokens(&sanitized)
2705}
2706
2707fn redact_private_key_uri_tokens(message: &str) -> String {
2708 const PREFIX: &str = "private-key://";
2709 let mut redacted = String::with_capacity(message.len());
2710 let mut rest = message;
2711
2712 while let Some(idx) = rest.find(PREFIX) {
2713 redacted.push_str(&rest[..idx + PREFIX.len()]);
2714 redacted.push_str("<redacted>");
2715 let after_prefix = &rest[idx + PREFIX.len()..];
2716 let end = after_prefix
2717 .find(|c: char| c.is_whitespace() || matches!(c, '`' | '\'' | '"' | ',' | ';' | ')'))
2718 .unwrap_or(after_prefix.len());
2719 rest = &after_prefix[end..];
2720 }
2721
2722 redacted.push_str(rest);
2723 redacted
2724}
2725
2726fn finalize_doctor(steps: Vec<DoctorStep>, context: DoctorContext) -> Result<()> {
2728 let failure_count = steps.iter().filter(|s| s.status == DoctorStatus::Fail).count();
2729 let warning_count = steps.iter().filter(|s| s.status == DoctorStatus::Warn).count();
2730 let no_failures = failure_count == 0;
2731 let healthy = no_failures && warning_count == 0;
2732 let status = if failure_count > 0 {
2733 "fail"
2734 } else if warning_count > 0 {
2735 "warn"
2736 } else {
2737 "pass"
2738 };
2739
2740 if shell::is_json() {
2741 foundry_cli::json::print_json_success(serde_json::json!({
2742 "context": context,
2743 "steps": steps,
2744 "status": status,
2745 "no_failures": no_failures,
2746 "healthy": healthy,
2747 "warning_count": warning_count,
2748 "failure_count": failure_count,
2749 }))?;
2750 } else {
2751 for step in &steps {
2752 print_doctor_step(step)?;
2753 }
2754 sh_println!()?;
2755 if healthy {
2756 sh_println!("{} access-key signing path looks healthy", "✓".green())?;
2757 } else if no_failures {
2758 sh_println!("{} access-key signing path has warnings (see above)", "!".yellow())?;
2759 } else {
2760 sh_println!("{} access-key signing path has issues (see above)", "✗".red())?;
2761 }
2762 }
2763
2764 Ok(())
2765}
2766
2767fn print_doctor_step(step: &DoctorStep) -> Result<()> {
2768 let marker = match step.status {
2769 DoctorStatus::Pass => "✓".green().to_string(),
2770 DoctorStatus::Warn => "!".yellow().to_string(),
2771 DoctorStatus::Fail => "✗".red().to_string(),
2772 };
2773
2774 let label = format!("{:<22}", step.label);
2775 sh_println!("{marker} {label} {}", step.detail)?;
2776 if let Some(hint) = step.hint.as_deref() {
2777 sh_println!(" {} {}", "hint:".dim(), hint)?;
2778 }
2779 Ok(())
2780}
2781
2782#[allow(clippy::too_many_arguments)]
2784async fn run_authorize(
2785 key_address: Address,
2786 key_type: SignatureType,
2787 expiry: u64,
2788 enforce_limits: bool,
2789 limits: Vec<TokenLimit>,
2790 allowed_calls: Vec<CallScope>,
2791 scopes_present: bool,
2792 witness: Option<B256>,
2793 admin: bool,
2794 tx_opts: TransactionOpts,
2795 send_tx: SendTxOpts,
2796) -> Result<()> {
2797 let enforce = enforce_limits || !limits.is_empty();
2798
2799 let config = send_tx.eth.load_config()?;
2800 let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
2801
2802 if admin {
2804 if !is_tempo_hardfork_active(&provider, TempoHardfork::T6).await? {
2805 eyre::bail!("--admin requires a Tempo T6-capable AccountKeychain RPC");
2806 }
2807 eyre::ensure!(expiry == u64::MAX, "--admin cannot be combined with an explicit --expiry");
2809 eyre::ensure!(
2810 !enforce,
2811 "--admin cannot be combined with spending limits (--enforce-limits / --limit)"
2812 );
2813 eyre::ensure!(
2814 !scopes_present,
2815 "--admin cannot be combined with call scopes (--scope / --scopes)"
2816 );
2817
2818 let calldata = authorizeAdminKeyCall {
2820 keyId: key_address,
2821 signatureType: key_type,
2822 witness: witness.unwrap_or(B256::ZERO),
2823 }
2824 .abi_encode();
2825 send_keychain_tx(calldata, tx_opts, &send_tx, None).await?;
2826 return Ok(());
2827 }
2828
2829 let is_t3 = is_tempo_hardfork_active(&provider, TempoHardfork::T3).await?;
2830 if witness.is_some() && !is_tempo_hardfork_active(&provider, TempoHardfork::T5).await? {
2831 eyre::bail!("--witness requires a Tempo T5-capable AccountKeychain RPC");
2832 }
2833
2834 let calldata = if is_t3 {
2835 let restrictions = KeyRestrictions {
2837 expiry,
2838 enforceLimits: enforce,
2839 limits,
2840 allowAnyCalls: allowed_calls.is_empty(),
2841 allowedCalls: allowed_calls,
2842 };
2843 if let Some(witness) = witness {
2844 authorizeKeyWithWitnessCall {
2845 keyId: key_address,
2846 signatureType: key_type,
2847 config: restrictions,
2848 witness,
2849 }
2850 .abi_encode()
2851 } else {
2852 authorizeKeyCall { keyId: key_address, signatureType: key_type, config: restrictions }
2853 .abi_encode()
2854 }
2855 } else {
2856 if let Some(limit) = limits.iter().find(|limit| limit.period != 0) {
2858 eyre::bail!(
2859 "legacy AccountKeychain authorization does not support periodic limits; remove \
2860 the period from --limit {}:{}:{} or use a Tempo T3-capable chain",
2861 limit.token,
2862 limit.amount,
2863 limit.period
2864 );
2865 }
2866
2867 let legacy_limits: Vec<LegacyTokenLimit> = limits
2868 .into_iter()
2869 .map(|l| LegacyTokenLimit { token: l.token, amount: l.amount })
2870 .collect();
2871 legacyAuthorizeKeyCall {
2872 keyId: key_address,
2873 signatureType: key_type,
2874 expiry,
2875 enforceLimits: enforce,
2876 limits: legacy_limits,
2877 }
2878 .abi_encode()
2879 };
2880
2881 send_keychain_tx(calldata, tx_opts, &send_tx, None).await?;
2882 Ok(())
2883}
2884
2885fn run_key_auth_encode(args: KeyAuthorizationArgs, account: Option<Address>) -> Result<()> {
2886 let authorization = args.into_authorization(account)?;
2887 let encoded = encode_key_authorization(&authorization);
2888
2889 if shell::is_json() {
2890 let json = serde_json::json!({
2891 "key_authorization": hex::encode_prefixed(&encoded),
2892 "signature_hash": authorization.signature_hash().to_string(),
2893 "rlp_length": encoded.len(),
2894 "is_admin": authorization.is_admin(),
2895 "account": authorization.account.map(|account| account.to_string()),
2896 "witness": authorization.witness().map(|witness| witness.to_string()),
2897 });
2898 sh_println!("{}", serde_json::to_string_pretty(&json)?)?;
2899 } else {
2900 sh_println!("{}", hex::encode_prefixed(&encoded))?;
2901 }
2902
2903 Ok(())
2904}
2905
2906async fn run_key_auth_sign(
2907 args: KeyAuthorizationArgs,
2908 account: Option<Address>,
2909 wallet: WalletOpts,
2910 browser: BrowserWalletOpts,
2911) -> Result<()> {
2912 let is_admin = args.admin;
2913
2914 if browser.browser && (args.witness.is_some() || is_admin || account.is_some()) {
2917 eyre::bail!(
2918 "browser key authorization signing does not support T5/T6 fields yet: witness, admin, account"
2919 );
2920 }
2921
2922 if let Some(browser) = browser.run::<TempoNetwork>().await? {
2923 let signer_address = browser.address();
2924 ensure_key_authorization_root_sender(signer_address, wallet.from)?;
2925 let authorization = args.into_authorization(None)?;
2927 let authorized_key_type = auth_signature_type_name(&authorization.key_type);
2928 let signature_hash = authorization.signature_hash();
2929 let signed = browser.sign_key_authorization(authorization).await?;
2930 return print_signed_key_authorization(
2931 &signed,
2932 signature_hash,
2933 signer_address,
2934 authorized_key_type,
2935 );
2936 }
2937
2938 let (signer, tempo_access_key) = wallet.maybe_signer().await?;
2939 let signer = signer.ok_or_else(|| {
2940 eyre::eyre!(
2941 "a signer is required to sign key authorizations; pass a signer with \
2942 --browser, --private-key, --keystore, Ledger, Trezor, AWS, GCP, or Turnkey"
2943 )
2944 })?;
2945 let signer_address = signer.address();
2946
2947 let bound_account = if let Some(access_key) = tempo_access_key.as_ref() {
2949 if let Some(explicit) = account {
2951 eyre::ensure!(
2952 explicit == access_key.wallet_address,
2953 "--bind-account {explicit} does not match the selected Tempo access key's root account {}",
2954 access_key.wallet_address,
2955 );
2956 }
2957 Some(access_key.wallet_address)
2958 } else {
2959 ensure_key_authorization_root_sender(signer_address, wallet.from)?;
2960 match account {
2961 Some(explicit) => Some(explicit),
2962 None if is_admin => Some(signer_address),
2963 None => None,
2964 }
2965 };
2966
2967 let authorization = args.into_authorization(bound_account)?;
2968 let authorized_key_type = auth_signature_type_name(&authorization.key_type);
2969 let signature_hash = authorization.signature_hash();
2970 let signature = signer.sign_hash(&signature_hash).await?;
2971 let signed = authorization.into_signed(PrimitiveSignature::Secp256k1(signature));
2972 print_signed_key_authorization(&signed, signature_hash, signer_address, authorized_key_type)
2973}
2974
2975fn print_signed_key_authorization(
2976 signed: &SignedKeyAuthorization,
2977 signature_hash: B256,
2978 signer_address: Address,
2979 authorized_key_type: &'static str,
2980) -> Result<()> {
2981 let encoded = encode_key_authorization(signed);
2982
2983 if shell::is_json() {
2984 let signature_type = auth_signature_type_name(&signed.signature.signature_type());
2985 let json = serde_json::json!({
2986 "signed_key_authorization": hex::encode_prefixed(&encoded),
2987 "signature_hash": signature_hash.to_string(),
2988 "rlp_length": encoded.len(),
2989 "signer": signer_address.to_string(),
2990 "authorized_key_type": authorized_key_type,
2991 "signature_type": signature_type,
2992 "witness": signed.authorization.witness().map(|witness| witness.to_string()),
2993 "is_admin": signed.authorization.is_admin(),
2994 "account": signed.authorization.account.map(|account| account.to_string()),
2995 });
2996 sh_println!("{}", serde_json::to_string_pretty(&json)?)?;
2997 } else {
2998 sh_println!("{}", hex::encode_prefixed(&encoded))?;
2999 }
3000
3001 Ok(())
3002}
3003
3004fn encode_key_authorization<T: Encodable>(authorization: &T) -> Vec<u8> {
3005 let mut out = Vec::new();
3006 authorization.encode(&mut out);
3007 out
3008}
3009
3010fn decode_and_validate_key_authorization(
3016 authorization: &str,
3017 expected_account: Option<Address>,
3018) -> Result<(KeyAuthorization, bool, Option<Address>)> {
3019 let raw = authorization.trim();
3020
3021 let (auth, signed, signer) =
3022 match tempo::decode_key_authorization::<SignedKeyAuthorization>(raw) {
3023 Ok(signed) => {
3025 let signer = signed.recover_signer().ok();
3026 (signed.authorization, true, signer)
3027 }
3028 Err(signed_err) => match tempo::decode_key_authorization::<KeyAuthorization>(raw) {
3029 Ok(unsigned) => (unsigned, false, None),
3030 Err(unsigned_err) => {
3031 eyre::bail!(
3032 "could not decode key authorization as signed ({signed_err}) or unsigned \
3033 ({unsigned_err})"
3034 );
3035 }
3036 },
3037 };
3038
3039 if let Some(account) = auth.account {
3041 eyre::ensure!(
3042 account != Address::ZERO,
3043 "key authorization account cannot be the zero address"
3044 );
3045 }
3046 if auth.is_admin() {
3047 eyre::ensure!(auth.expiry.is_none(), "admin key authorization cannot carry an expiry");
3051 eyre::ensure!(
3052 auth.limits.is_none(),
3053 "admin key authorization cannot carry spending limits"
3054 );
3055 eyre::ensure!(
3056 auth.allowed_calls.is_none(),
3057 "admin key authorization cannot carry call scopes"
3058 );
3059 }
3060
3061 if let Some(expected) = expected_account {
3063 match auth.account {
3064 Some(account) if account == expected => {}
3065 Some(account) => {
3066 eyre::bail!(
3067 "key authorization is bound to account {account} but {expected} was expected"
3068 );
3069 }
3070 None => {
3071 eyre::bail!(
3072 "expected key authorization bound to account {expected} but it has no account field"
3073 );
3074 }
3075 }
3076 }
3077
3078 Ok((auth, signed, signer))
3079}
3080
3081fn run_key_auth_inspect(authorization: &str, expected_account: Option<Address>) -> Result<()> {
3084 let (auth, signed, signer) =
3085 decode_and_validate_key_authorization(authorization, expected_account)?;
3086
3087 if shell::is_json() {
3088 let json = serde_json::json!({
3089 "signed": signed,
3090 "signer": signer.map(|signer| signer.to_string()),
3091 "chain_id": auth.chain_id,
3092 "key_address": auth.key_id.to_string(),
3093 "key_type": auth_signature_type_name(&auth.key_type),
3094 "is_admin": auth.is_admin(),
3095 "account": auth.account.map(|account| account.to_string()),
3096 "expiry": auth.expiry.map(|expiry| expiry.get()),
3097 "witness": auth.witness().map(|witness| witness.to_string()),
3098 "enforce_limits": auth.limits.is_some(),
3099 "scoped_calls": auth.allowed_calls.is_some(),
3100 });
3101 sh_println!("{}", serde_json::to_string_pretty(&json)?)?;
3102 } else {
3103 sh_println!("Signed: {signed}")?;
3104 if let Some(signer) = signer {
3105 sh_println!("Signer: {signer}")?;
3106 }
3107 sh_println!("Chain ID: {}", auth.chain_id)?;
3108 sh_println!("Key Address: {}", auth.key_id)?;
3109 sh_println!("Key Type: {}", auth_signature_type_name(&auth.key_type))?;
3110 sh_println!("Admin: {}", auth.is_admin())?;
3111 if let Some(account) = auth.account {
3112 sh_println!("Account: {account}")?;
3113 }
3114 match auth.expiry {
3115 Some(expiry) => sh_println!("Expiry: {}", expiry.get())?,
3116 None => sh_println!("Expiry: none")?,
3117 }
3118 match auth.witness() {
3119 Some(witness) => sh_println!("Witness: {witness}")?,
3120 None => sh_println!("Witness: none")?,
3121 }
3122 sh_println!("Enforce Lim: {}", auth.limits.is_some())?;
3123 sh_println!("Scoped Calls: {}", auth.allowed_calls.is_some())?;
3124 }
3125
3126 Ok(())
3127}
3128
3129impl KeyAuthorizationArgs {
3130 fn into_authorization(self, account: Option<Address>) -> Result<KeyAuthorization> {
3135 let (scopes, explicit_scopes_json) =
3136 if let Some(AuthScopesJson(json_scopes)) = self.scopes_json {
3137 (json_scopes, true)
3138 } else {
3139 (self.scope, false)
3140 };
3141
3142 let scopes_present = explicit_scopes_json || !scopes.is_empty();
3143 validate_admin_key_authorization(
3144 self.admin,
3145 account,
3146 self.expiry.is_some(),
3147 self.enforce_limits || !self.limits.is_empty(),
3148 scopes_present,
3149 )?;
3150
3151 let mut authorization =
3152 KeyAuthorization::unrestricted(self.chain_id, self.key_type, self.key_address);
3153
3154 if let Some(expiry) = self.expiry {
3155 if expiry == 0 {
3156 eyre::bail!("--expiry must be greater than zero");
3157 }
3158 authorization = authorization.with_expiry(expiry);
3159 }
3160
3161 if self.enforce_limits || !self.limits.is_empty() {
3162 authorization = authorization.with_limits(self.limits);
3163 }
3164
3165 if scopes_present {
3166 authorization = authorization.with_allowed_calls(scopes);
3167 }
3168
3169 if let Some(witness) = self.witness {
3170 authorization = authorization.with_witness(witness);
3171 }
3172
3173 if self.admin {
3175 authorization = authorization.into_admin(account.expect("admin requires account"));
3177 } else if let Some(account) = account {
3178 authorization = authorization.with_account(account);
3179 }
3180
3181 Ok(authorization)
3182 }
3183}
3184
3185fn validate_admin_key_authorization(
3191 admin: bool,
3192 account: Option<Address>,
3193 has_expiry: bool,
3194 has_limits: bool,
3195 has_scopes: bool,
3196) -> Result<()> {
3197 if let Some(account) = account {
3198 eyre::ensure!(account != Address::ZERO, "--account cannot be the zero address");
3199 }
3200
3201 if admin {
3202 eyre::ensure!(account.is_some(), "--admin requires --account");
3203 eyre::ensure!(!has_expiry, "--admin cannot be combined with --expiry");
3204 eyre::ensure!(
3205 !has_limits,
3206 "--admin cannot be combined with spending limits (--enforce-limits / --limit)"
3207 );
3208 eyre::ensure!(
3209 !has_scopes,
3210 "--admin cannot be combined with call scopes (--scope / --scopes)"
3211 );
3212 }
3213
3214 Ok(())
3215}
3216
3217async fn run_revoke(
3219 key_address: Address,
3220 tx_opts: TransactionOpts,
3221 send_tx: SendTxOpts,
3222) -> Result<()> {
3223 let calldata = IAccountKeychain::revokeKeyCall { keyId: key_address }.abi_encode();
3224 send_keychain_tx(calldata, tx_opts, &send_tx, None).await?;
3225 Ok(())
3226}
3227
3228async fn run_burn_witness(
3230 witness: B256,
3231 tx_opts: TransactionOpts,
3232 send_tx: SendTxOpts,
3233) -> Result<()> {
3234 let config = send_tx.eth.load_config()?;
3235 let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
3236 if !is_tempo_hardfork_active(&provider, TempoHardfork::T5).await? {
3237 eyre::bail!("burn-witness requires a Tempo T5-capable AccountKeychain RPC");
3238 }
3239
3240 let calldata = IAccountKeychain::burnKeyAuthorizationWitnessCall { witness }.abi_encode();
3241 send_keychain_tx(calldata, tx_opts, &send_tx, None).await?;
3242 Ok(())
3243}
3244
3245async fn run_is_witness_burned(account: Address, witness: B256, rpc: RpcOpts) -> Result<()> {
3247 let config = rpc.load_config()?;
3248 let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
3249 if !is_tempo_hardfork_active(&provider, TempoHardfork::T5).await? {
3250 eyre::bail!("is-witness-burned requires a Tempo T5-capable AccountKeychain RPC");
3251 }
3252
3253 let burned = provider
3254 .account_keychain()
3255 .isKeyAuthorizationWitnessBurned(account, witness)
3256 .call()
3257 .await?;
3258
3259 if shell::is_json() {
3260 let json = serde_json::json!({
3261 "account": account.to_string(),
3262 "witness": witness.to_string(),
3263 "burned": burned,
3264 });
3265 sh_println!("{}", serde_json::to_string_pretty(&json)?)?;
3266 } else {
3267 sh_println!("{burned}")?;
3268 }
3269
3270 Ok(())
3271}
3272
3273async fn run_is_admin(account: Address, key_address: Address, rpc: RpcOpts) -> Result<()> {
3275 let config = rpc.load_config()?;
3276 let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
3277 if !is_tempo_hardfork_active(&provider, TempoHardfork::T6).await? {
3278 eyre::bail!("is-admin requires a Tempo T6-capable AccountKeychain RPC");
3279 }
3280
3281 let is_admin = provider.account_keychain().isAdminKey(account, key_address).call().await?;
3282
3283 if shell::is_json() {
3284 let json = serde_json::json!({
3285 "account": account.to_string(),
3286 "key_address": key_address.to_string(),
3287 "is_admin": is_admin,
3288 });
3289 sh_println!("{}", serde_json::to_string_pretty(&json)?)?;
3290 } else {
3291 sh_println!("{is_admin}")?;
3292 }
3293
3294 Ok(())
3295}
3296
3297async fn run_verify_keychain(
3299 account: Address,
3300 hash: B256,
3301 signature: Bytes,
3302 rpc: RpcOpts,
3303 admin: bool,
3304) -> Result<()> {
3305 let config = rpc.load_config()?;
3306 let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
3307 let command = if admin { "verify-admin" } else { "verify" };
3308 if !is_tempo_hardfork_active(&provider, TempoHardfork::T6).await? {
3309 eyre::bail!("{command} requires a Tempo T6-capable SignatureVerifier RPC");
3310 }
3311
3312 let verifier = ISignatureVerifier::new(SIGNATURE_VERIFIER_ADDRESS, &provider);
3313 let valid = if admin {
3314 verifier.verifyKeychainAdmin(account, hash, signature.clone()).call().await?
3315 } else {
3316 verifier.verifyKeychain(account, hash, signature.clone()).call().await?
3317 };
3318
3319 if shell::is_json() {
3320 let json = serde_json::json!({
3321 "account": account.to_string(),
3322 "hash": hash.to_string(),
3323 "signature": signature.to_string(),
3324 "admin": admin,
3325 "valid": valid,
3326 });
3327 sh_println!("{}", serde_json::to_string_pretty(&json)?)?;
3328 } else {
3329 sh_println!("{valid}")?;
3330 }
3331
3332 Ok(())
3333}
3334
3335async fn run_remaining_limit(
3337 wallet_address: Address,
3338 key_address: Address,
3339 token: Address,
3340 rpc: RpcOpts,
3341) -> Result<()> {
3342 let config = rpc.load_config()?;
3343 let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
3344
3345 let remaining: U256 = if is_tempo_hardfork_active(&provider, TempoHardfork::T3).await? {
3346 provider.get_keychain_remaining_limit(wallet_address, key_address, token).await?
3347 } else {
3348 provider
3350 .account_keychain()
3351 .getRemainingLimit(wallet_address, key_address, token)
3352 .call()
3353 .await?
3354 };
3355
3356 if shell::is_json() {
3357 sh_println!("{}", serde_json::json!({ "remaining": remaining.to_string() }))?;
3358 } else {
3359 sh_println!("{remaining}")?;
3360 }
3361
3362 Ok(())
3363}
3364
3365async fn run_update_limit(
3367 key_address: Address,
3368 token: Address,
3369 new_limit: U256,
3370 tx_opts: TransactionOpts,
3371 send_tx: SendTxOpts,
3372) -> Result<()> {
3373 let calldata = IAccountKeychain::updateSpendingLimitCall {
3374 keyId: key_address,
3375 token,
3376 newLimit: new_limit,
3377 }
3378 .abi_encode();
3379 send_keychain_tx(calldata, tx_opts, &send_tx, None).await?;
3380 Ok(())
3381}
3382
3383async fn run_set_scope(
3385 key_address: Address,
3386 scopes: Vec<CallScope>,
3387 tx_opts: TransactionOpts,
3388 send_tx: SendTxOpts,
3389) -> Result<()> {
3390 let calldata =
3391 IAccountKeychain::setAllowedCallsCall { keyId: key_address, scopes }.abi_encode();
3392 send_keychain_tx(calldata, tx_opts, &send_tx, None).await?;
3393 Ok(())
3394}
3395
3396async fn run_remove_scope(
3398 key_address: Address,
3399 target: Address,
3400 tx_opts: TransactionOpts,
3401 send_tx: SendTxOpts,
3402) -> Result<()> {
3403 let calldata =
3404 IAccountKeychain::removeAllowedCallsCall { keyId: key_address, target }.abi_encode();
3405 send_keychain_tx(calldata, tx_opts, &send_tx, None).await?;
3406 Ok(())
3407}
3408
3409async fn run_policy_add_call(
3411 key_address: Address,
3412 root_account: Option<Address>,
3413 target: Address,
3414 selector: [u8; 4],
3415 recipients: Vec<Address>,
3416 tx_opts: TransactionOpts,
3417 send_tx: SendTxOpts,
3418) -> Result<()> {
3419 let metadata = resolve_key_metadata(key_address, root_account)?;
3420 let config = send_tx.eth.load_config()?;
3421 let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
3422
3423 if !is_tempo_hardfork_active(&provider, TempoHardfork::T3).await? {
3424 eyre::bail!("allowed-call policy editing requires the Tempo T3 hardfork");
3425 }
3426
3427 let allowed = provider
3428 .account_keychain()
3429 .getAllowedCalls(metadata.root_account, key_address)
3430 .call()
3431 .await?;
3432
3433 let new_rule = SelectorRule { selector: selector.into(), recipients };
3434 let existing_target = allowed
3435 .isScoped
3436 .then(|| allowed.scopes.into_iter().find(|scope| scope.target == target))
3437 .flatten();
3438
3439 let (target_scope, changed) = match existing_target {
3440 Some(mut scope) => {
3441 if scope.selectorRules.is_empty() {
3442 sh_warn!(
3443 "Allowed calls for {} already allow any selector; leaving wildcard scope unchanged",
3444 address_label_with_address(target)
3445 )?;
3446 }
3447 let changed = add_selector_rule_to_scope(&mut scope, new_rule);
3448 (scope, changed)
3449 }
3450 None => (CallScope { target, selectorRules: vec![new_rule] }, true),
3451 };
3452
3453 if !changed {
3454 if shell::is_json() {
3455 sh_println!(
3456 "{}",
3457 serde_json::json!({ "status": "already_present", "target": target.to_string() })
3458 )?;
3459 } else {
3460 sh_status!("Allowed call already present for {}", address_label_with_address(target))?;
3461 }
3462 return Ok(());
3463 }
3464
3465 let calldata =
3466 IAccountKeychain::setAllowedCallsCall { keyId: key_address, scopes: vec![target_scope] }
3467 .abi_encode();
3468 send_keychain_tx(calldata, tx_opts, &send_tx, None).await?;
3469 Ok(())
3470}
3471
3472async fn run_policy_set_limit(
3474 key_address: Address,
3475 token: Address,
3476 amount: U256,
3477 period: Option<u64>,
3478 tx_opts: TransactionOpts,
3479 send_tx: SendTxOpts,
3480) -> Result<()> {
3481 if period.is_some_and(|period| period != 0) {
3482 eyre::bail!(
3483 "--period is not supported by the current AccountKeychain updateSpendingLimit \
3484 precompile; periods can only be set when authorizing a key"
3485 );
3486 }
3487
3488 run_update_limit(key_address, token, amount, tx_opts, send_tx).await
3490}
3491
3492#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3493pub(crate) enum KeychainTxOutcome {
3494 Submitted,
3495 PrintedSponsorHash,
3496}
3497
3498pub(crate) enum KeychainRootSigner {
3499 Browser(BrowserSigner<TempoNetwork>),
3500 Wallet(Box<WalletSigner>),
3501}
3502
3503impl KeychainRootSigner {
3504 fn address(&self) -> Address {
3505 match self {
3506 Self::Browser(browser) => browser.address(),
3507 Self::Wallet(signer) => signer.address(),
3508 }
3509 }
3510}
3511
3512pub(crate) async fn resolve_keychain_root_signer(
3514 send_tx: &SendTxOpts,
3515 expected_from: Option<Address>,
3516 print_sponsor_hash: bool,
3517) -> Result<KeychainRootSigner> {
3518 let (signer, tempo_access_key) = send_tx.eth.wallet.maybe_signer().await?;
3519 if let Some(browser) = send_tx.browser.run::<TempoNetwork>().await? {
3520 ensure_root_sender(browser.address(), expected_from)?;
3521 return Ok(KeychainRootSigner::Browser(browser));
3522 }
3523
3524 if tempo_access_key.is_some() {
3530 eyre::bail!(
3531 "submitting AccountKeychain admin mutators (authorize / revoke / policy) signed by a \
3532 Tempo access key currently reverts on-chain with UnauthorizedCaller() on the pinned \
3533 Tempo build, even for an active admin key. Use a root account signer (--browser for \
3534 passkey roots, or --private-key / --keystore / Ledger / Trezor / AWS / GCP / Turnkey) \
3535 for direct mutations."
3536 );
3537 }
3538
3539 let signer = match signer {
3540 Some(s) => s,
3541 None if print_sponsor_hash => {
3542 eyre::bail!(
3543 "--tempo.print-sponsor-hash requires a root account signer, such as \
3544 --browser, --private-key, or --keystore"
3545 );
3546 }
3547 None => send_tx.eth.wallet.signer().await?,
3548 };
3549 ensure_root_sender(signer.address(), expected_from)?;
3550 Ok(KeychainRootSigner::Wallet(Box::new(signer)))
3551}
3552
3553pub(crate) async fn send_keychain_tx(
3555 calldata: Vec<u8>,
3556 tx_opts: TransactionOpts,
3557 send_tx: &SendTxOpts,
3558 expected_from: Option<Address>,
3559) -> Result<KeychainTxOutcome> {
3560 let root_signer =
3561 resolve_keychain_root_signer(send_tx, expected_from, tx_opts.tempo.print_sponsor_hash)
3562 .await?;
3563 send_keychain_tx_with_root_signer(calldata, tx_opts, send_tx, root_signer, || Ok(())).await
3564}
3565
3566pub(crate) async fn send_keychain_tx_with_root_signer(
3568 calldata: Vec<u8>,
3569 mut tx_opts: TransactionOpts,
3570 send_tx: &SendTxOpts,
3571 root_signer: KeychainRootSigner,
3572 before_submit: impl FnOnce() -> Result<()>,
3573) -> Result<KeychainTxOutcome> {
3574 let print_sponsor_hash = tx_opts.tempo.print_sponsor_hash;
3575 let sponsor_fee_payer = tx_opts.tempo.sponsor;
3576 let expires_at = tx_opts.tempo.resolve_expires();
3577 let tempo_sponsor =
3578 if print_sponsor_hash { None } else { tx_opts.tempo.sponsor_config().await? };
3579
3580 let config = send_tx.eth.load_config()?;
3581 let timeout = send_tx.timeout.unwrap_or(config.transaction_timeout);
3582 let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
3583
3584 if let Some(interval) = send_tx.poll_interval {
3585 provider.client().set_poll_interval(Duration::from_secs(interval));
3586 }
3587
3588 let resolved_lane = resolve_lane(&mut tx_opts.tempo, &config.root)?;
3591
3592 let builder = CastTxBuilder::new(&provider, tx_opts, &config)
3593 .await?
3594 .with_to(Some(NameOrAddress::Address(ACCOUNT_KEYCHAIN_ADDRESS)))
3595 .await?
3596 .with_code_sig_and_args(None, Some(hex::encode_prefixed(&calldata)), vec![])
3597 .await?;
3598
3599 if print_sponsor_hash {
3600 let from = root_signer.address();
3601 let chain = builder.chain();
3602 let (mut tx, _) = builder.build(from).await?;
3603 if let Some(fee_payer) = sponsor_fee_payer {
3604 resolve_and_set_fee_token(
3605 (!config.eth_rpc_curl).then_some(&provider),
3606 Some(chain),
3607 &mut tx,
3608 Some(fee_payer),
3609 )
3610 .await?;
3611 }
3612 let hash = tx
3613 .compute_sponsor_hash(from)
3614 .ok_or_else(|| eyre::eyre!("This network does not support sponsored transactions"))?;
3615 if shell::is_json() {
3616 sh_println!("{}", serde_json::json!({ "sponsor_hash": format!("{hash:?}") }))?;
3617 } else {
3618 sh_println!("{hash:?}")?;
3619 }
3620 return Ok(KeychainTxOutcome::PrintedSponsorHash);
3621 }
3622
3623 crate::tempo::print_expires(expires_at)?;
3624
3625 match root_signer {
3626 KeychainRootSigner::Browser(browser) => {
3627 let chain = builder.chain();
3628 let (mut tx, _) = builder.with_browser_wallet().build(browser.address()).await?;
3629 if chain.is_tempo()
3630 && let Some(gas) = tx.gas_limit()
3631 {
3632 tx.set_gas_limit(gas + TEMPO_BROWSER_GAS_BUFFER);
3633 }
3634 if let Some(sponsor) = &tempo_sponsor {
3635 sponsor
3636 .resolve_and_set_fee_token(
3637 (!config.eth_rpc_curl).then_some(&provider),
3638 Some(chain),
3639 &mut tx,
3640 )
3641 .await?;
3642 sponsor.attach_and_print::<TempoNetwork>(&mut tx, browser.address()).await?;
3643 } else {
3644 let fee_token = resolve_and_set_fee_token(
3645 (!config.eth_rpc_curl).then_some(&provider),
3646 Some(chain),
3647 &mut tx,
3648 Some(browser.address()),
3649 )
3650 .await?;
3651 maybe_print_fee_token((!config.eth_rpc_curl).then_some(&provider), fee_token)
3652 .await?;
3653 }
3654
3655 before_submit()?;
3656 let tx_hash = browser.send_transaction_via_browser(tx).await?;
3657 CastTxSender::new(&provider)
3658 .print_tx_result(tx_hash, send_tx.cast_async, send_tx.confirmations, timeout)
3659 .await?;
3660 }
3661 KeychainRootSigner::Wallet(signer) => {
3662 let from = signer.address();
3663 let chain = builder.chain();
3664 let (mut tx, _) = builder.build(from).await?;
3665 maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?;
3666 if let Some(sponsor) = &tempo_sponsor {
3667 sponsor
3668 .resolve_and_set_fee_token(
3669 (!config.eth_rpc_curl).then_some(&provider),
3670 Some(chain),
3671 &mut tx,
3672 )
3673 .await?;
3674 sponsor.attach_and_print::<TempoNetwork>(&mut tx, from).await?;
3675 } else {
3676 let fee_token = resolve_and_set_fee_token(
3677 (!config.eth_rpc_curl).then_some(&provider),
3678 Some(chain),
3679 &mut tx,
3680 Some(from),
3681 )
3682 .await?;
3683 maybe_print_fee_token((!config.eth_rpc_curl).then_some(&provider), fee_token)
3684 .await?;
3685 }
3686
3687 before_submit()?;
3688 let wallet = EthereumWallet::from(*signer);
3689 let provider = AlloyProviderBuilder::<_, _, TempoNetwork>::default()
3690 .wallet(wallet)
3691 .connect_provider(&provider);
3692
3693 cast_send(
3694 provider,
3695 tx,
3696 tempo_sponsor.is_none().then_some(chain),
3697 None,
3698 send_tx.cast_async,
3699 send_tx.sync,
3700 send_tx.confirmations,
3701 timeout,
3702 tempo_sponsor.is_none() && !config.eth_rpc_curl,
3703 )
3704 .await?;
3705 }
3706 }
3707
3708 Ok(KeychainTxOutcome::Submitted)
3709}
3710
3711fn ensure_root_sender(actual: Address, expected: Option<Address>) -> Result<()> {
3713 if let Some(expected) = expected
3714 && actual != expected
3715 {
3716 eyre::bail!(
3717 "AccountKeychain transaction must be signed by root account {expected}; resolved signer is {actual}"
3718 );
3719 }
3720 Ok(())
3721}
3722
3723fn ensure_key_authorization_root_sender(actual: Address, expected: Option<Address>) -> Result<()> {
3725 if let Some(expected) = expected
3726 && actual != expected
3727 {
3728 eyre::bail!(
3729 "key authorization must be signed by root account {expected}; resolved signer is {actual}"
3730 );
3731 }
3732 Ok(())
3733}
3734
3735#[derive(Debug, Deserialize)]
3736#[serde(rename_all = "camelCase")]
3737struct AnvilNodeInfo {
3738 hard_fork: Option<String>,
3739 network: Option<String>,
3740}
3741
3742pub(crate) async fn is_tempo_hardfork_active<P>(
3743 provider: &P,
3744 hardfork: TempoHardfork,
3745) -> Result<bool>
3746where
3747 P: Provider<TempoNetwork>,
3748{
3749 match provider.is_hardfork_active(hardfork).await {
3750 Ok(active) => Ok(active),
3751 Err(err) if is_rpc_method_not_found(&err) => {
3752 match anvil_tempo_hardfork_active(provider, hardfork).await {
3753 Ok(Some(active)) => Ok(active),
3754 _ => Err(err.into()),
3755 }
3756 }
3757 Err(err) => Err(err.into()),
3758 }
3759}
3760
3761pub(crate) async fn ensure_tempo_precompile_active<P>(
3765 provider: &P,
3766 hardfork: TempoHardfork,
3767 precompile: Address,
3768 requirement: &str,
3769) -> Result<()>
3770where
3771 P: Provider<TempoNetwork>,
3772{
3773 let active = match is_tempo_hardfork_active(provider, hardfork).await {
3774 Ok(active) => active,
3775 Err(_) => !provider.get_code_at(precompile).await?.is_empty(),
3776 };
3777 if !active {
3778 eyre::bail!("{requirement}");
3779 }
3780 Ok(())
3781}
3782
3783async fn anvil_tempo_hardfork_active<P>(
3784 provider: &P,
3785 hardfork: TempoHardfork,
3786) -> Result<Option<bool>, TransportError>
3787where
3788 P: Provider<TempoNetwork>,
3789{
3790 let info = provider.raw_request::<_, AnvilNodeInfo>("anvil_nodeInfo".into(), ()).await?;
3791 Ok(active_from_anvil_node_info(&info, hardfork))
3792}
3793
3794fn active_from_anvil_node_info(info: &AnvilNodeInfo, hardfork: TempoHardfork) -> Option<bool> {
3795 (info.network.as_deref() == Some("tempo")).then(|| {
3796 info.hard_fork
3797 .as_deref()
3798 .and_then(|active_hardfork| active_hardfork.parse::<TempoHardfork>().ok())
3799 .is_some_and(|active_hardfork| active_hardfork >= hardfork)
3800 })
3801}
3802
3803fn is_rpc_method_not_found(err: &TransportError) -> bool {
3804 err.as_error_resp().is_some_and(|payload| payload.code == -32601)
3805}
3806
3807fn resolve_key_metadata(
3808 key_address: Address,
3809 root_account: Option<Address>,
3810) -> Result<KeyMetadata> {
3811 let keys_file = read_tempo_keys_file();
3812
3813 if let Some(root_account) = root_account {
3814 if let Some(keys_file) = keys_file.as_ref()
3815 && let Some(entry) = keys_file.keys.iter().find(|entry| {
3816 entry.wallet_address == root_account
3817 && key_entry_effective_key(entry) == key_address
3818 })
3819 {
3820 return Ok(key_metadata_from_entry(entry));
3821 }
3822
3823 return Ok(KeyMetadata { root_account, key_type: None, limits: Vec::new() });
3824 }
3825
3826 let Some(keys_file) = keys_file.as_ref() else {
3827 eyre::bail!(
3828 "key {key_address} was not found because the local keys file could not be read at {}; pass --root-account",
3829 tempo_keys_path_display()
3830 );
3831 };
3832
3833 let matches: Vec<_> = keys_file
3834 .keys
3835 .iter()
3836 .filter(|entry| key_entry_effective_key(entry) == key_address)
3837 .collect();
3838
3839 if matches.is_empty() {
3840 eyre::bail!(
3841 "key {key_address} was not found in {}; pass --root-account",
3842 tempo_keys_path_display()
3843 );
3844 }
3845
3846 let root_account = matches[0].wallet_address;
3847 if matches.iter().any(|entry| entry.wallet_address != root_account) {
3848 eyre::bail!(
3849 "key {key_address} matches multiple root accounts in {}; pass --root-account",
3850 tempo_keys_path_display()
3851 );
3852 }
3853
3854 let entry =
3855 matches.iter().copied().find(|entry| !entry.limits.is_empty()).unwrap_or(matches[0]);
3856 Ok(key_metadata_from_entry(entry))
3857}
3858
3859fn key_entry_effective_key(entry: &tempo::KeyEntry) -> Address {
3860 entry.key_address.unwrap_or(entry.wallet_address)
3861}
3862
3863fn key_metadata_from_entry(entry: &tempo::KeyEntry) -> KeyMetadata {
3864 KeyMetadata {
3865 root_account: entry.wallet_address,
3866 key_type: Some(entry.key_type),
3867 limits: entry
3868 .limits
3869 .iter()
3870 .map(|limit| LocalLimitMetadata { token: limit.currency, amount: limit.limit.clone() })
3871 .collect(),
3872 }
3873}
3874
3875fn tempo_keys_path_display() -> String {
3876 let Some(path) = tempo_keys_path() else {
3877 return "(unknown)".to_string();
3878 };
3879
3880 if let Some(home) =
3881 std::env::var_os("HOME").filter(|home| !home.is_empty()).map(std::path::PathBuf::from)
3882 && let Ok(relative) = path.strip_prefix(&home)
3883 && relative == std::path::Path::new(".tempo/wallet/keys.toml")
3884 {
3885 return "~/.tempo/wallet/keys.toml".to_string();
3886 }
3887
3888 path.display().to_string()
3889}
3890
3891fn add_selector_rule_to_scope(scope: &mut CallScope, rule: SelectorRule) -> bool {
3892 if scope.selectorRules.is_empty() {
3893 return false;
3894 }
3895
3896 let Some(existing_rule) =
3897 scope.selectorRules.iter_mut().find(|existing| existing.selector == rule.selector)
3898 else {
3899 scope.selectorRules.push(rule);
3900 return true;
3901 };
3902
3903 if existing_rule.recipients.is_empty() {
3904 return false;
3905 }
3906
3907 if rule.recipients.is_empty() {
3908 existing_rule.recipients = Vec::new();
3909 return true;
3910 }
3911
3912 let mut changed = false;
3913 for recipient in rule.recipients {
3914 if !existing_rule.recipients.contains(&recipient) {
3915 existing_rule.recipients.push(recipient);
3916 changed = true;
3917 }
3918 }
3919 changed
3920}
3921
3922fn inspected_limit_to_json(limit: &InspectedLimit) -> serde_json::Value {
3923 serde_json::json!({
3924 "token": limit.token.to_string(),
3925 "token_label": address_label(limit.token),
3926 "configured_amount": limit.configured_amount.as_deref(),
3927 "remaining": limit.remaining.to_string(),
3928 "period_end": limit.period_end,
3929 "period_end_human": limit.period_end.and_then(|period_end| {
3930 (period_end != 0).then(|| format_period_end(period_end))
3931 }),
3932 })
3933}
3934
3935fn allowed_calls_to_json(allowed_calls: &AllowedCallsView) -> serde_json::Value {
3936 match allowed_calls {
3937 AllowedCallsView::Unsupported => serde_json::json!({
3938 "mode": "unsupported",
3939 "scopes": [],
3940 }),
3941 AllowedCallsView::Unrestricted => serde_json::json!({
3942 "mode": "any",
3943 "scopes": [],
3944 }),
3945 AllowedCallsView::Scoped(scopes) => serde_json::json!({
3946 "mode": if scopes.is_empty() { "none" } else { "scoped" },
3947 "scopes": scopes.iter().map(call_scope_to_json).collect::<Vec<_>>(),
3948 }),
3949 }
3950}
3951
3952fn call_scope_to_json(scope: &CallScope) -> serde_json::Value {
3953 serde_json::json!({
3954 "target": scope.target.to_string(),
3955 "target_label": address_label(scope.target),
3956 "selectors": scope.selectorRules.iter().map(selector_rule_to_json).collect::<Vec<_>>(),
3957 })
3958}
3959
3960fn selector_rule_to_json(rule: &SelectorRule) -> serde_json::Value {
3961 serde_json::json!({
3962 "selector": selector_hex(&rule.selector.0),
3963 "signature": selector_signature(&rule.selector.0),
3964 "recipients": rule.recipients.iter().map(ToString::to_string).collect::<Vec<_>>(),
3965 })
3966}
3967
3968fn print_inspected_limits(enforce_limits: bool, limits: &[InspectedLimit]) -> Result<()> {
3969 if !enforce_limits {
3970 sh_println!("Limits: none")?;
3971 return Ok(());
3972 }
3973
3974 sh_println!("Limits:")?;
3975 if limits.is_empty() {
3976 sh_println!(" enforced, but no local limit metadata was found")?;
3977 return Ok(());
3978 }
3979
3980 for limit in limits {
3981 let configured = limit.configured_amount.as_deref().unwrap_or("unknown");
3982 let period = limit
3983 .period_end
3984 .and_then(|period_end| {
3985 (period_end != 0).then(|| format!(" ({})", format_period_end(period_end)))
3986 })
3987 .unwrap_or_default();
3988 sh_println!(
3989 " {}: {} / {} remaining{}",
3990 address_label(limit.token),
3991 limit.remaining,
3992 configured,
3993 period
3994 )?;
3995 }
3996
3997 Ok(())
3998}
3999
4000fn print_allowed_calls(allowed_calls: &AllowedCallsView) -> Result<()> {
4001 match allowed_calls {
4002 AllowedCallsView::Unsupported => sh_println!("Allowed calls: unsupported before T3")?,
4003 AllowedCallsView::Unrestricted => sh_println!("Allowed calls: any")?,
4004 AllowedCallsView::Scoped(scopes) if scopes.is_empty() => {
4005 sh_println!("Allowed calls: none")?;
4006 }
4007 AllowedCallsView::Scoped(scopes) => {
4008 sh_println!("Allowed calls:")?;
4009 for scope in scopes {
4010 sh_println!(" {}:", address_label_with_address(scope.target))?;
4011 if scope.selectorRules.is_empty() {
4012 sh_println!(" any selector")?;
4013 continue;
4014 }
4015
4016 for rule in &scope.selectorRules {
4017 sh_println!(
4018 " {} -> {}",
4019 format_selector(&rule.selector.0),
4020 format_recipients(&rule.recipients)
4021 )?;
4022 }
4023 }
4024 }
4025 }
4026
4027 Ok(())
4028}
4029
4030fn address_label(address: Address) -> String {
4031 if address == PATH_USD_ADDRESS { "PathUSD".to_string() } else { address.to_string() }
4032}
4033
4034fn address_label_with_address(address: Address) -> String {
4035 if address == PATH_USD_ADDRESS { format!("PathUSD ({address})") } else { address.to_string() }
4036}
4037
4038fn format_selector(selector: &[u8; 4]) -> String {
4039 selector_signature(selector).map(str::to_string).unwrap_or_else(|| selector_hex(selector))
4040}
4041
4042fn selector_signature(selector: &[u8; 4]) -> Option<&'static str> {
4043 if selector == &ITIP20::transferCall::SELECTOR {
4044 Some("transfer(address,uint256)")
4045 } else if selector == &ITIP20::approveCall::SELECTOR {
4046 Some("approve(address,uint256)")
4047 } else if selector == &ITIP20::transferFromCall::SELECTOR {
4048 Some("transferFrom(address,address,uint256)")
4049 } else if selector == &ITIP20::transferWithMemoCall::SELECTOR {
4050 Some("transferWithMemo(address,uint256,bytes32)")
4051 } else if selector == &ITIP20::transferFromWithMemoCall::SELECTOR {
4052 Some("transferFromWithMemo(address,address,uint256,bytes32)")
4053 } else if selector == &ITIP20::mintCall::SELECTOR {
4054 Some("mint(address,uint256)")
4055 } else if selector == &ITIP20::burnCall::SELECTOR {
4056 Some("burn(uint256)")
4057 } else {
4058 None
4059 }
4060}
4061
4062fn selector_hex(selector: &[u8; 4]) -> String {
4063 hex::encode_prefixed(selector)
4064}
4065
4066fn format_recipients(recipients: &[Address]) -> String {
4067 if recipients.is_empty() {
4068 return "any recipient".to_string();
4069 }
4070
4071 let recipients = recipients.iter().map(ToString::to_string).collect::<Vec<_>>().join(", ");
4072 format!("recipients [{recipients}]")
4073}
4074
4075fn format_expiry_for_inspect(expiry: u64) -> String {
4076 if expiry == u64::MAX {
4077 return "never".to_string();
4078 }
4079
4080 format!("{} ({})", format_timestamp_iso(expiry), format_relative_timestamp(expiry))
4081}
4082
4083fn format_period_end(period_end: u64) -> String {
4084 format!("period resets {}", format_relative_timestamp(period_end))
4085}
4086
4087fn format_timestamp_iso(timestamp: u64) -> String {
4088 DateTime::from_timestamp(timestamp as i64, 0)
4089 .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
4090 .unwrap_or_else(|| timestamp.to_string())
4091}
4092
4093fn format_relative_timestamp(timestamp: u64) -> String {
4094 format_relative_timestamp_from(timestamp, unix_timestamp_now())
4095}
4096
4097fn format_relative_timestamp_from(timestamp: u64, now: u64) -> String {
4098 if timestamp == now {
4099 "now".to_string()
4100 } else if timestamp > now {
4101 format!("in {}", format_duration_words(timestamp - now))
4102 } else {
4103 format!("{} ago", format_duration_words(now - timestamp))
4104 }
4105}
4106
4107fn format_duration_words(seconds: u64) -> String {
4108 const MINUTE: u64 = 60;
4109 const HOUR: u64 = 60 * MINUTE;
4110 const DAY: u64 = 24 * HOUR;
4111
4112 if seconds >= DAY {
4113 let days = seconds / DAY;
4114 if days == 1 { "1 day".to_string() } else { format!("{days} days") }
4115 } else if seconds >= HOUR {
4116 format!("{}h", seconds / HOUR)
4117 } else if seconds >= MINUTE {
4118 format!("{}m", seconds / MINUTE)
4119 } else {
4120 format!("{seconds}s")
4121 }
4122}
4123
4124fn format_expiry(expiry: u64) -> String {
4125 if expiry == u64::MAX {
4126 return "never".to_string();
4127 }
4128 DateTime::from_timestamp(expiry as i64, 0)
4129 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
4130 .unwrap_or_else(|| expiry.to_string())
4131}
4132
4133fn load_keys_file() -> Result<KeysFile> {
4134 match read_tempo_keys_file() {
4135 Some(f) => Ok(f),
4136 None => {
4137 let path = tempo_keys_path()
4138 .map(|p| p.display().to_string())
4139 .unwrap_or_else(|| "(unknown)".to_string());
4140 eyre::bail!("could not read keys file at {path}");
4141 }
4142 }
4143}
4144
4145fn print_key_entry(entry: &tempo::KeyEntry) -> Result<()> {
4146 sh_println!("Wallet: {}", entry.wallet_address)?;
4147 sh_println!("Wallet Type: {}", wallet_type_name(&entry.wallet_type))?;
4148 sh_println!("Chain ID: {}", entry.chain_id)?;
4149 sh_println!("Key Type: {}", key_type_name(&entry.key_type))?;
4150
4151 if let Some(key_address) = entry.key_address {
4152 sh_println!("Key Address: {key_address}")?;
4153
4154 if key_address == entry.wallet_address {
4155 sh_println!("Mode: direct (EOA)")?;
4156 } else {
4157 sh_println!("Mode: keychain (access key)")?;
4158 }
4159 } else {
4160 sh_println!("Key Address: (not set)")?;
4161 sh_println!("Mode: direct (EOA)")?;
4162 }
4163
4164 if let Some(expiry) = entry.expiry {
4165 sh_println!("Expiry: {}", format_expiry(expiry))?;
4166 }
4167
4168 let decoded = decoded_entry_key_authorization(entry);
4169 let is_admin = decoded.as_ref().is_some_and(|signed| signed.authorization.is_admin());
4170 sh_println!("Role: {}", local_key_role(entry, is_admin))?;
4171
4172 sh_println!("Has Key: {}", entry.has_inline_key())?;
4173 sh_println!("Has Auth: {}", entry.key_authorization.is_some())?;
4174 if let Some(signed) = &decoded {
4175 let witness = signed
4176 .authorization
4177 .witness()
4178 .map(|witness| witness.to_string())
4179 .unwrap_or_else(|| "(none)".to_string());
4180 sh_println!("Auth Witness: {witness}")?;
4181 sh_println!("Auth Admin: {}", signed.authorization.is_admin())?;
4182 if let Some(account) = signed.authorization.account {
4183 sh_println!("Auth Account: {account}")?;
4184 }
4185 }
4186
4187 if !entry.limits.is_empty() {
4188 sh_println!("Limits:")?;
4189 for limit in &entry.limits {
4190 sh_println!(" {} → {}", limit.currency, limit.limit)?;
4191 }
4192 }
4193
4194 Ok(())
4195}
4196
4197fn key_entry_to_json(entry: &tempo::KeyEntry) -> serde_json::Value {
4198 let is_direct = entry.key_address.is_none_or(|key_address| key_address == entry.wallet_address);
4199 let decoded = decoded_entry_key_authorization(entry);
4200 let authorization_witness =
4201 decoded.as_ref().and_then(|signed| signed.authorization.witness()).map(|w| w.to_string());
4202 let authorization_is_admin =
4203 decoded.as_ref().is_some_and(|signed| signed.authorization.is_admin());
4204 let authorization_account = decoded
4205 .as_ref()
4206 .and_then(|signed| signed.authorization.account)
4207 .map(|account| account.to_string());
4208 let role = local_key_role(entry, authorization_is_admin);
4209
4210 let limits: Vec<_> = entry
4211 .limits
4212 .iter()
4213 .map(|l| {
4214 serde_json::json!({
4215 "currency": l.currency.to_string(),
4216 "limit": l.limit,
4217 })
4218 })
4219 .collect();
4220
4221 serde_json::json!({
4222 "wallet_address": entry.wallet_address.to_string(),
4223 "wallet_type": wallet_type_name(&entry.wallet_type),
4224 "chain_id": entry.chain_id,
4225 "key_type": key_type_name(&entry.key_type),
4226 "key_address": entry.key_address.map(|a: Address| a.to_string()),
4227 "mode": if is_direct { "direct" } else { "keychain" },
4228 "expiry": entry.expiry,
4229 "expiry_human": entry.expiry.map(format_expiry),
4230 "has_key": entry.has_inline_key(),
4231 "has_authorization": entry.key_authorization.is_some(),
4232 "role": role,
4233 "authorization_witness": authorization_witness,
4234 "authorization_is_admin": authorization_is_admin,
4235 "authorization_account": authorization_account,
4236 "limits": limits,
4237 })
4238}
4239
4240fn local_key_role(entry: &tempo::KeyEntry, is_admin: bool) -> &'static str {
4246 let is_direct = entry.key_address.is_none_or(|key_address| key_address == entry.wallet_address);
4247 if is_direct {
4248 "root"
4249 } else if is_admin {
4250 "admin"
4251 } else {
4252 "limited"
4253 }
4254}
4255
4256fn decoded_entry_key_authorization(entry: &tempo::KeyEntry) -> Option<SignedKeyAuthorization> {
4257 let raw = entry.key_authorization.as_deref()?.trim();
4258 if raw.is_empty() {
4259 return None;
4260 }
4261 tempo::decode_key_authorization(raw).ok()
4262}
4263
4264#[cfg(test)]
4265mod tests {
4266 use super::*;
4267 use alloy_json_rpc::ErrorPayload;
4268 use std::str::FromStr;
4269
4270 #[test]
4271 fn test_parse_scopes_json_plain() {
4272 let json = r#"[{"target":"0x20c0000000000000000000000000000000000001","selectors":["transfer","approve"]},{"target":"0x86A2EE8FAf9A840F7a2c64CA3d51209F9A02081D"}]"#;
4273 let result = parse_scopes_json(json).unwrap();
4274 assert_eq!(result.len(), 2);
4275 assert_eq!(result[0].selectorRules.len(), 2);
4276 assert!(result[1].selectorRules.is_empty());
4277 }
4278
4279 #[test]
4280 fn test_parse_scopes_json_with_recipients() {
4281 let json = r#"[{"target":"0x20c0000000000000000000000000000000000001","selectors":[{"selector":"transfer","recipients":["0x1111111111111111111111111111111111111111"]}]}]"#;
4282 let result = parse_scopes_json(json).unwrap();
4283 assert_eq!(result.len(), 1);
4284 assert_eq!(result[0].selectorRules.len(), 1);
4285 assert_eq!(result[0].selectorRules[0].recipients.len(), 1);
4286 }
4287
4288 #[test]
4289 fn test_parse_scopes_json_deny_unknown_fields() {
4290 let json = r#"[{"target":"0x20c0000000000000000000000000000000000001","selectors":[{"selector":"transfer","recipients":[],"bogus":true}]}]"#;
4291 assert!(parse_scopes_json(json).is_err());
4292 }
4293
4294 #[test]
4295 fn test_add_selector_rule_merges_recipients() {
4296 let first = Address::from_str("0x1111111111111111111111111111111111111111").unwrap();
4297 let second = Address::from_str("0x2222222222222222222222222222222222222222").unwrap();
4298 let mut scope = CallScope {
4299 target: PATH_USD_ADDRESS,
4300 selectorRules: vec![SelectorRule {
4301 selector: parse_selector_bytes("transfer").unwrap().into(),
4302 recipients: vec![first],
4303 }],
4304 };
4305
4306 let changed = add_selector_rule_to_scope(
4307 &mut scope,
4308 SelectorRule {
4309 selector: parse_selector_bytes("transfer").unwrap().into(),
4310 recipients: vec![second],
4311 },
4312 );
4313
4314 assert!(changed);
4315 assert_eq!(scope.selectorRules.len(), 1);
4316 assert_eq!(scope.selectorRules[0].recipients, vec![first, second]);
4317 }
4318
4319 #[test]
4320 fn test_add_selector_rule_empty_recipients_widens_to_any() {
4321 let first = Address::from_str("0x1111111111111111111111111111111111111111").unwrap();
4322 let mut scope = CallScope {
4323 target: PATH_USD_ADDRESS,
4324 selectorRules: vec![SelectorRule {
4325 selector: parse_selector_bytes("approve").unwrap().into(),
4326 recipients: vec![first],
4327 }],
4328 };
4329
4330 let changed = add_selector_rule_to_scope(
4331 &mut scope,
4332 SelectorRule {
4333 selector: parse_selector_bytes("approve").unwrap().into(),
4334 recipients: vec![],
4335 },
4336 );
4337
4338 assert!(changed);
4339 assert!(scope.selectorRules[0].recipients.is_empty());
4340 }
4341
4342 #[test]
4343 fn test_add_selector_rule_target_wildcard_is_unchanged() {
4344 let mut scope = CallScope { target: PATH_USD_ADDRESS, selectorRules: vec![] };
4345
4346 let changed = add_selector_rule_to_scope(
4347 &mut scope,
4348 SelectorRule {
4349 selector: parse_selector_bytes("transfer").unwrap().into(),
4350 recipients: vec![],
4351 },
4352 );
4353
4354 assert!(!changed);
4355 assert!(scope.selectorRules.is_empty());
4356 }
4357
4358 #[test]
4359 fn test_policy_set_limit_parses() {
4360 let key = "0x1111111111111111111111111111111111111111";
4361
4362 let command = KeychainSubcommand::try_parse_from([
4363 "keychain",
4364 "policy",
4365 "set-limit",
4366 key,
4367 "--token",
4368 "PathUSD",
4369 "--amount",
4370 "123",
4371 ])
4372 .unwrap();
4373
4374 match command {
4375 KeychainSubcommand::Policy {
4376 command:
4377 KeychainPolicySubcommand::SetLimit { key_address, token, amount, period, .. },
4378 } => {
4379 assert_eq!(key_address, Address::from_str(key).unwrap());
4380 assert_eq!(token, PATH_USD_ADDRESS);
4381 assert_eq!(amount, U256::from(123));
4382 assert_eq!(period, None);
4383 }
4384 other => panic!("unexpected command: {other:?}"),
4385 }
4386 }
4387
4388 #[test]
4389 fn test_key_authorization_encode_parses() {
4390 let key = "0x1111111111111111111111111111111111111111";
4391 let token = "0x20c0000000000000000000000000000000000000";
4392
4393 let command = KeyAuthorizationSubcommand::try_parse_from([
4394 "key-authorization",
4395 "encode",
4396 key,
4397 "--chain-id",
4398 "4217",
4399 "--key-type",
4400 "secp256k1",
4401 "--expiry",
4402 "1782647677",
4403 "--witness",
4404 "0x5353535353535353535353535353535353535353535353535353535353535353",
4405 "--limit",
4406 "0x20c0000000000000000000000000000000000000:10000000",
4407 ])
4408 .unwrap();
4409
4410 match command {
4411 KeyAuthorizationSubcommand::Encode {
4412 authorization:
4413 KeyAuthorizationArgs {
4414 chain_id,
4415 key_address,
4416 key_type,
4417 expiry,
4418 limits,
4419 witness,
4420 ..
4421 },
4422 ..
4423 } => {
4424 assert_eq!(chain_id, 4217);
4425 assert_eq!(key_address, Address::from_str(key).unwrap());
4426 assert_eq!(key_type, AuthSignatureType::Secp256k1);
4427 assert_eq!(expiry, Some(1_782_647_677));
4428 assert_eq!(witness, Some(B256::repeat_byte(0x53)));
4429 assert_eq!(limits.len(), 1);
4430 assert_eq!(limits[0].token, Address::from_str(token).unwrap());
4431 assert_eq!(limits[0].limit, U256::from(10_000_000));
4432 assert_eq!(limits[0].period, 0);
4433 }
4434 other => panic!("unexpected command: {other:?}"),
4435 }
4436 }
4437
4438 #[test]
4439 fn test_active_from_anvil_node_info_requires_tempo_network() {
4440 let tempo_t3 =
4441 AnvilNodeInfo { network: Some("tempo".to_string()), hard_fork: Some("T3".to_string()) };
4442 assert_eq!(active_from_anvil_node_info(&tempo_t3, TempoHardfork::T2), Some(true));
4443 assert_eq!(active_from_anvil_node_info(&tempo_t3, TempoHardfork::T3), Some(true));
4444 assert_eq!(active_from_anvil_node_info(&tempo_t3, TempoHardfork::T4), Some(false));
4445
4446 let ethereum_t3 = AnvilNodeInfo {
4447 network: Some("ethereum".to_string()),
4448 hard_fork: Some("T3".to_string()),
4449 };
4450 assert_eq!(active_from_anvil_node_info(ðereum_t3, TempoHardfork::T3), None);
4451 }
4452
4453 fn rule(selector: [u8; 4], recipients: Vec<Address>) -> SelectorRule {
4454 SelectorRule { selector: selector.into(), recipients }
4455 }
4456
4457 fn target_addr(byte: u8) -> Address {
4458 Address::from([byte; 20])
4459 }
4460
4461 fn signed_authorization_with_limits(
4462 limits: Option<Vec<AuthTokenLimit>>,
4463 ) -> SignedKeyAuthorization {
4464 let mut authorization =
4465 KeyAuthorization::unrestricted(31337, AuthSignatureType::Secp256k1, target_addr(0x42));
4466 authorization.limits = limits;
4467 authorization.into_signed(PrimitiveSignature::default())
4468 }
4469
4470 fn key_auth_args() -> KeyAuthorizationArgs {
4471 KeyAuthorizationArgs {
4472 chain_id: 31337,
4473 key_address: target_addr(0x42),
4474 key_type: AuthSignatureType::Secp256k1,
4475 expiry: None,
4476 enforce_limits: false,
4477 limits: vec![],
4478 scope: vec![],
4479 scopes_json: None,
4480 witness: None,
4481 admin: false,
4482 }
4483 }
4484
4485 #[test]
4486 fn test_signed_key_authorization_witness_roundtrip_and_json_exposure() {
4487 use alloy_rlp::Decodable;
4488
4489 let witness = B256::repeat_byte(0x53);
4490 let authorization =
4491 KeyAuthorization::unrestricted(31337, AuthSignatureType::Secp256k1, target_addr(0x42))
4492 .with_witness(witness);
4493 let signed = authorization.into_signed(PrimitiveSignature::from_bytes(&[0u8; 65]).unwrap());
4494 let encoded = encode_key_authorization(&signed);
4495 let hex = hex::encode_prefixed(&encoded);
4496
4497 let decoded = SignedKeyAuthorization::decode(&mut encoded.as_slice()).unwrap();
4498 assert_eq!(decoded.authorization.witness(), Some(witness));
4499
4500 let entry = tempo::KeyEntry { key_authorization: Some(hex), ..Default::default() };
4501 let json = key_entry_to_json(&entry);
4502 assert_eq!(json["authorization_witness"], witness.to_string());
4503 }
4504
4505 #[test]
4506 fn test_key_auth_encode_preserves_zero_witness_presence() {
4507 let absent = key_auth_args().into_authorization(None).unwrap();
4508 let mut args = key_auth_args();
4509 args.witness = Some(B256::ZERO);
4510 let zero_witness = args.into_authorization(None).unwrap();
4511
4512 assert_eq!(absent.witness(), None);
4513 assert_eq!(zero_witness.witness(), Some(B256::ZERO));
4514 assert_ne!(absent.signature_hash(), zero_witness.signature_hash());
4515 assert_ne!(encode_key_authorization(&absent), encode_key_authorization(&zero_witness));
4516 }
4517
4518 #[test]
4519 fn test_admin_key_auth_roundtrip_preserves_admin_and_account() {
4520 use alloy_rlp::Decodable;
4521
4522 let account = target_addr(0xAB);
4523 let mut args = key_auth_args();
4524 args.admin = true;
4525 let authorization = args.into_authorization(Some(account)).unwrap();
4526 assert!(authorization.is_admin());
4527 assert_eq!(authorization.account, Some(account));
4528
4529 let signed = authorization.into_signed(PrimitiveSignature::from_bytes(&[0u8; 65]).unwrap());
4530 let encoded = encode_key_authorization(&signed);
4531 let decoded = SignedKeyAuthorization::decode(&mut encoded.as_slice()).unwrap();
4532 assert!(decoded.authorization.is_admin());
4533 assert_eq!(decoded.authorization.account, Some(account));
4534 }
4535
4536 #[test]
4537 fn test_account_bound_non_admin_roundtrip() {
4538 use alloy_rlp::Decodable;
4539
4540 let account = target_addr(0xCD);
4541 let authorization = key_auth_args().into_authorization(Some(account)).unwrap();
4542 assert!(!authorization.is_admin());
4543 assert_eq!(authorization.account, Some(account));
4544
4545 let signed = authorization.into_signed(PrimitiveSignature::from_bytes(&[0u8; 65]).unwrap());
4546 let encoded = encode_key_authorization(&signed);
4547 let decoded = SignedKeyAuthorization::decode(&mut encoded.as_slice()).unwrap();
4548 assert!(!decoded.authorization.is_admin());
4549 assert_eq!(decoded.authorization.account, Some(account));
4550 }
4551
4552 #[test]
4553 fn test_non_admin_authorization_omits_t6_fields() {
4554 let authorization = key_auth_args().into_authorization(None).unwrap();
4556 assert!(!authorization.is_admin());
4557 assert_eq!(authorization.account, None);
4558 assert!(authorization.is_legacy_compatible());
4559 }
4560
4561 #[test]
4562 fn test_admin_account_binding_changes_signature_hash() {
4563 let mut admin_a = key_auth_args();
4564 admin_a.admin = true;
4565 let auth_a = admin_a.into_authorization(Some(target_addr(0x01))).unwrap();
4566
4567 let mut admin_b = key_auth_args();
4568 admin_b.admin = true;
4569 let auth_b = admin_b.into_authorization(Some(target_addr(0x02))).unwrap();
4570
4571 assert_ne!(auth_a.signature_hash(), auth_b.signature_hash());
4573 }
4574
4575 #[test]
4576 fn test_admin_requires_account() {
4577 let mut args = key_auth_args();
4578 args.admin = true;
4579 let err = args.into_authorization(None).unwrap_err().to_string();
4580 assert!(err.contains("--admin requires --account"), "got: {err}");
4581 }
4582
4583 #[test]
4584 fn test_admin_rejects_expiry_limits_and_scopes() {
4585 let account = target_addr(0xAB);
4586
4587 let mut expiry = key_auth_args();
4588 expiry.admin = true;
4589 expiry.expiry = Some(1_782_647_677);
4590 assert!(
4591 expiry.into_authorization(Some(account)).unwrap_err().to_string().contains("--expiry"),
4592 "expiry must be rejected for admin keys"
4593 );
4594
4595 let mut limits = key_auth_args();
4596 limits.admin = true;
4597 limits.enforce_limits = true;
4598 assert!(
4599 limits
4600 .into_authorization(Some(account))
4601 .unwrap_err()
4602 .to_string()
4603 .contains("spending limits"),
4604 "spending limits must be rejected for admin keys"
4605 );
4606
4607 let mut scopes = key_auth_args();
4608 scopes.admin = true;
4609 scopes.scopes_json = Some(AuthScopesJson(vec![]));
4610 assert!(
4611 scopes
4612 .into_authorization(Some(account))
4613 .unwrap_err()
4614 .to_string()
4615 .contains("call scopes"),
4616 "call scopes must be rejected for admin keys"
4617 );
4618 }
4619
4620 #[test]
4621 fn test_account_zero_is_rejected() {
4622 let err = key_auth_args().into_authorization(Some(Address::ZERO)).unwrap_err().to_string();
4623 assert!(err.contains("--account cannot be the zero address"), "got: {err}");
4624 }
4625
4626 fn signed_admin_auth_hex(account: Address) -> String {
4628 let mut args = key_auth_args();
4629 args.admin = true;
4630 let authorization = args.into_authorization(Some(account)).unwrap();
4631 let signed = authorization.into_signed(PrimitiveSignature::from_bytes(&[0u8; 65]).unwrap());
4632 hex::encode_prefixed(encode_key_authorization(&signed))
4633 }
4634
4635 #[test]
4636 fn test_inspect_decodes_signed_and_unsigned_shapes() {
4637 let account = target_addr(0xAB);
4639 let (auth, signed, _signer) =
4640 decode_and_validate_key_authorization(&signed_admin_auth_hex(account), None).unwrap();
4641 assert!(signed, "signed input must be reported as signed");
4642 assert!(auth.is_admin());
4643 assert_eq!(auth.account, Some(account));
4644
4645 let unsigned = key_auth_args().into_authorization(None).unwrap();
4647 let hex = hex::encode_prefixed(encode_key_authorization(&unsigned));
4648 let (auth, signed, signer) = decode_and_validate_key_authorization(&hex, None).unwrap();
4649 assert!(!signed, "unsigned input must be reported as unsigned");
4650 assert!(!auth.is_admin());
4651 assert_eq!(auth.account, None);
4652 assert!(signer.is_none(), "unsigned input must not recover a signer");
4653 }
4654
4655 #[test]
4656 fn test_inspect_account_mismatch_is_rejected() {
4657 let account = target_addr(0xAB);
4658 let hex = signed_admin_auth_hex(account);
4659 let err = decode_and_validate_key_authorization(&hex, Some(target_addr(0xCD)))
4660 .unwrap_err()
4661 .to_string();
4662 assert!(err.contains("is bound to account") && err.contains("was expected"), "got: {err}");
4663 }
4664
4665 #[test]
4666 fn test_inspect_rejects_admin_auth_carrying_restrictions() {
4667 let account = target_addr(0xAB);
4670 let authorization =
4671 KeyAuthorization::unrestricted(31337, AuthSignatureType::Secp256k1, target_addr(0x42))
4672 .with_expiry(1_782_647_677)
4673 .into_admin(account);
4674 let hex = hex::encode_prefixed(encode_key_authorization(&authorization));
4675
4676 let err = decode_and_validate_key_authorization(&hex, None).unwrap_err().to_string();
4677 assert!(err.contains("cannot carry an expiry"), "got: {err}");
4678 }
4679
4680 #[test]
4681 fn test_inspect_accepts_root_signed_admin_auth_without_account() {
4682 let mut authorization =
4685 KeyAuthorization::unrestricted(31337, AuthSignatureType::Secp256k1, target_addr(0x42));
4686 authorization.is_admin = true;
4687 let hex = hex::encode_prefixed(encode_key_authorization(&authorization));
4688
4689 let (auth, _signed, _signer) =
4690 decode_and_validate_key_authorization(&hex, None).expect("admin auth may omit account");
4691 assert!(auth.is_admin());
4692 assert_eq!(auth.account, None);
4693 }
4694
4695 #[test]
4696 fn test_inspect_admin_auth_without_account_rejected_when_account_expected() {
4697 let mut authorization =
4700 KeyAuthorization::unrestricted(31337, AuthSignatureType::Secp256k1, target_addr(0x42));
4701 authorization.is_admin = true;
4702 let hex = hex::encode_prefixed(encode_key_authorization(&authorization));
4703
4704 let err = decode_and_validate_key_authorization(&hex, Some(target_addr(0xAB)))
4705 .unwrap_err()
4706 .to_string();
4707 assert!(err.contains("no account field"), "got: {err}");
4708 }
4709
4710 #[test]
4711 fn test_local_key_role_classification() {
4712 let wallet = target_addr(0x01);
4713 let key = target_addr(0x02);
4714
4715 let root = tempo::KeyEntry {
4716 wallet_address: wallet,
4717 key_address: Some(wallet),
4718 ..Default::default()
4719 };
4720 assert_eq!(local_key_role(&root, false), "root");
4721
4722 let access = tempo::KeyEntry {
4723 wallet_address: wallet,
4724 key_address: Some(key),
4725 ..Default::default()
4726 };
4727 assert_eq!(local_key_role(&access, false), "limited");
4728 assert_eq!(local_key_role(&access, true), "admin");
4729 }
4730
4731 #[test]
4732 fn test_key_auth_encode_preserves_explicit_empty_scopes_json() {
4733 let absent = key_auth_args().into_authorization(None).unwrap();
4734 let mut args = key_auth_args();
4735 args.scopes_json = Some(AuthScopesJson(vec![]));
4736 let deny_all = args.into_authorization(None).unwrap();
4737
4738 assert_eq!(absent.allowed_calls, None);
4739 assert_eq!(deny_all.allowed_calls, Some(vec![]));
4740 assert_ne!(absent.signature_hash(), deny_all.signature_hash());
4741 assert_ne!(encode_key_authorization(&absent), encode_key_authorization(&deny_all));
4742 }
4743
4744 #[test]
4745 fn test_key_auth_encode_rejects_zero_expiry() {
4746 let mut args = key_auth_args();
4747 args.expiry = Some(0);
4748 let err = args.into_authorization(None).unwrap_err();
4749 assert!(
4750 err.to_string().contains("--expiry must be greater than zero"),
4751 "unexpected error: {err}"
4752 );
4753 }
4754
4755 #[test]
4756 fn test_key_auth_root_sender_mismatch_message_is_artifact_specific() {
4757 let expected = target_addr(0x11);
4758 let actual = target_addr(0x22);
4759 let err = ensure_key_authorization_root_sender(actual, Some(expected)).unwrap_err();
4760 assert_eq!(
4761 err.to_string(),
4762 format!(
4763 "key authorization must be signed by root account {expected}; resolved signer is {actual}"
4764 )
4765 );
4766 }
4767
4768 #[test]
4769 fn test_match_allowed_call_target_wildcard_any_selector() {
4770 let scopes = vec![CallScope { target: target_addr(0xAA), selectorRules: vec![] }];
4771 let result =
4772 match_allowed_call(&scopes, target_addr(0xAA), ITIP20::transferCall::SELECTOR, None);
4773 assert!(matches!(result, AllowedCallMatch::Allowed(_)));
4774 }
4775
4776 #[test]
4777 fn test_match_allowed_call_empty_recipients_any_recipient() {
4778 let scopes = vec![CallScope {
4779 target: target_addr(0xAA),
4780 selectorRules: vec![rule(ITIP20::transferCall::SELECTOR, vec![])],
4781 }];
4782 let result = match_allowed_call(
4783 &scopes,
4784 target_addr(0xAA),
4785 ITIP20::transferCall::SELECTOR,
4786 Some(target_addr(0xBB)),
4787 );
4788 assert!(matches!(result, AllowedCallMatch::Allowed(_)));
4789 }
4790
4791 #[test]
4792 fn test_match_allowed_call_missing_target_denied() {
4793 let scopes = vec![CallScope { target: target_addr(0xAA), selectorRules: vec![] }];
4794 let result =
4795 match_allowed_call(&scopes, target_addr(0xCC), ITIP20::transferCall::SELECTOR, None);
4796 assert!(matches!(result, AllowedCallMatch::Denied(_)));
4797 }
4798
4799 #[test]
4800 fn test_match_allowed_call_recipient_restricted_no_recipient_arg() {
4801 let recipients = vec![target_addr(0xBB)];
4802 let scopes = vec![CallScope {
4803 target: target_addr(0xAA),
4804 selectorRules: vec![rule(ITIP20::transferCall::SELECTOR, recipients.clone())],
4805 }];
4806 let result =
4807 match_allowed_call(&scopes, target_addr(0xAA), ITIP20::transferCall::SELECTOR, None);
4808 match result {
4809 AllowedCallMatch::RecipientRestricted(rs) => assert_eq!(rs, recipients),
4810 other => panic!(
4811 "expected RecipientRestricted, got {:?}",
4812 match other {
4813 AllowedCallMatch::Allowed(s) => format!("Allowed({s})"),
4814 AllowedCallMatch::Denied(s) => format!("Denied({s})"),
4815 AllowedCallMatch::RecipientRestricted(_) => unreachable!(),
4816 }
4817 ),
4818 }
4819 }
4820
4821 #[test]
4822 fn test_match_allowed_call_recipient_match_allowed() {
4823 let recipients = vec![target_addr(0xBB), target_addr(0xCC)];
4824 let scopes = vec![CallScope {
4825 target: target_addr(0xAA),
4826 selectorRules: vec![rule(ITIP20::transferCall::SELECTOR, recipients)],
4827 }];
4828 let result = match_allowed_call(
4829 &scopes,
4830 target_addr(0xAA),
4831 ITIP20::transferCall::SELECTOR,
4832 Some(target_addr(0xCC)),
4833 );
4834 assert!(matches!(result, AllowedCallMatch::Allowed(_)));
4835 }
4836
4837 #[test]
4838 fn test_match_allowed_call_recipient_not_in_list_denied() {
4839 let recipients = vec![target_addr(0xBB)];
4840 let scopes = vec![CallScope {
4841 target: target_addr(0xAA),
4842 selectorRules: vec![rule(ITIP20::transferCall::SELECTOR, recipients)],
4843 }];
4844 let result = match_allowed_call(
4845 &scopes,
4846 target_addr(0xAA),
4847 ITIP20::transferCall::SELECTOR,
4848 Some(target_addr(0xDD)),
4849 );
4850 assert!(matches!(result, AllowedCallMatch::Denied(_)));
4851 }
4852
4853 #[test]
4854 fn test_match_allowed_call_selector_not_in_list_denied() {
4855 let scopes = vec![CallScope {
4856 target: target_addr(0xAA),
4857 selectorRules: vec![rule(ITIP20::transferCall::SELECTOR, vec![])],
4858 }];
4859 let result =
4860 match_allowed_call(&scopes, target_addr(0xAA), ITIP20::approveCall::SELECTOR, None);
4861 assert!(matches!(result, AllowedCallMatch::Denied(_)));
4862 }
4863
4864 #[test]
4865 fn test_match_allowed_call_checks_duplicate_target_scopes() {
4866 let scopes = vec![
4867 CallScope {
4868 target: target_addr(0xAA),
4869 selectorRules: vec![rule(ITIP20::approveCall::SELECTOR, vec![])],
4870 },
4871 CallScope {
4872 target: target_addr(0xAA),
4873 selectorRules: vec![rule(ITIP20::transferCall::SELECTOR, vec![])],
4874 },
4875 ];
4876
4877 let result =
4878 match_allowed_call(&scopes, target_addr(0xAA), ITIP20::transferCall::SELECTOR, None);
4879 assert!(matches!(result, AllowedCallMatch::Allowed(_)));
4880 }
4881
4882 #[test]
4883 fn test_match_allowed_call_aggregates_duplicate_target_recipients() {
4884 let first = target_addr(0xBB);
4885 let second = target_addr(0xCC);
4886 let scopes = vec![
4887 CallScope {
4888 target: target_addr(0xAA),
4889 selectorRules: vec![rule(ITIP20::transferCall::SELECTOR, vec![first])],
4890 },
4891 CallScope {
4892 target: target_addr(0xAA),
4893 selectorRules: vec![rule(ITIP20::transferCall::SELECTOR, vec![second])],
4894 },
4895 ];
4896
4897 let result = match_allowed_call(
4898 &scopes,
4899 target_addr(0xAA),
4900 ITIP20::transferCall::SELECTOR,
4901 Some(second),
4902 );
4903 assert!(matches!(result, AllowedCallMatch::Allowed(_)));
4904
4905 let result =
4906 match_allowed_call(&scopes, target_addr(0xAA), ITIP20::transferCall::SELECTOR, None);
4907 match result {
4908 AllowedCallMatch::RecipientRestricted(recipients) => {
4909 assert_eq!(recipients, vec![first, second]);
4910 }
4911 _ => panic!("expected recipient restriction"),
4912 }
4913 }
4914
4915 #[test]
4916 fn test_doctor_command_parses_with_only_root_account() {
4917 let cmd = KeychainSubcommand::try_parse_from([
4918 "keychain",
4919 "doctor",
4920 "--root-account",
4921 "0x1111111111111111111111111111111111111111",
4922 ])
4923 .unwrap();
4924 match cmd {
4925 KeychainSubcommand::Doctor { key_address, root_account, .. } => {
4926 assert!(key_address.is_none());
4927 assert!(root_account.is_some());
4928 }
4929 other => panic!("unexpected: {other:?}"),
4930 }
4931 }
4932
4933 #[test]
4934 fn test_doctor_selector_requires_to() {
4935 let res = KeychainSubcommand::try_parse_from([
4936 "keychain",
4937 "doctor",
4938 "0x1111111111111111111111111111111111111111",
4939 "--selector",
4940 "transfer",
4941 ]);
4942 assert!(res.is_err(), "--selector without --to should error");
4943 }
4944
4945 #[test]
4946 fn test_doctor_parses_tempo_expiring_nonce_options() {
4947 let cmd = KeychainSubcommand::try_parse_from([
4948 "keychain",
4949 "doctor",
4950 "0x1111111111111111111111111111111111111111",
4951 "--root-account",
4952 "0x2222222222222222222222222222222222222222",
4953 "--tempo.expiring-nonce",
4954 "--tempo.valid-before",
4955 "9999999999",
4956 "--tempo.fee-token",
4957 "0x20C0000000000000000000000000000000000002",
4958 ])
4959 .unwrap();
4960 match cmd {
4961 KeychainSubcommand::Doctor { tempo, .. } => {
4962 assert!(tempo.expiring_nonce);
4963 assert_eq!(tempo.valid_before, Some(9_999_999_999));
4964 assert_eq!(
4965 tempo.fee_token,
4966 Some(Address::from_str("0x20C0000000000000000000000000000000000002").unwrap())
4967 );
4968 }
4969 other => panic!("unexpected: {other:?}"),
4970 }
4971 }
4972
4973 #[test]
4974 fn test_doctor_parses_fee_token_option() {
4975 let cmd = KeychainSubcommand::try_parse_from([
4976 "keychain",
4977 "doctor",
4978 "0x1111111111111111111111111111111111111111",
4979 "--root-account",
4980 "0x2222222222222222222222222222222222222222",
4981 "--fee-token",
4982 "PathUSD",
4983 ])
4984 .unwrap();
4985 match cmd {
4986 KeychainSubcommand::Doctor { fee_token, .. } => {
4987 assert_eq!(fee_token, Some(PATH_USD_ADDRESS));
4988 }
4989 other => panic!("unexpected: {other:?}"),
4990 }
4991 }
4992
4993 #[test]
4994 fn test_select_subject_accepts_explicit_root_key_without_local_entry() {
4995 let root = target_addr(0x11);
4996 let key = target_addr(0x22);
4997 let subject =
4998 select_subject_for_chain(vec![DoctorCandidate::explicit(root, key)], 31337, Some(root))
4999 .unwrap();
5000
5001 assert_eq!(subject.root_account, root);
5002 assert_eq!(subject.key_address, key);
5003 assert!(subject.entry.is_none());
5004
5005 let signing = check_local_signing_readiness(&subject);
5006 assert_eq!(signing.status, DoctorStatus::Warn);
5007 }
5008
5009 #[test]
5010 fn test_select_subject_uses_explicit_root_key_when_local_entry_is_wrong_chain() {
5011 let root = target_addr(0x11);
5012 let key = target_addr(0x22);
5013 let local = tempo::KeyEntry {
5014 wallet_address: root,
5015 chain_id: 1,
5016 key_address: Some(key),
5017 key: Some("0xdeadbeef".to_string()),
5018 ..Default::default()
5019 };
5020
5021 let subject = select_subject_for_chain(
5022 vec![DoctorCandidate::from_entry(local), DoctorCandidate::explicit(root, key)],
5023 31337,
5024 Some(root),
5025 )
5026 .unwrap();
5027
5028 assert_eq!(subject.root_account, root);
5029 assert_eq!(subject.key_address, key);
5030 assert!(subject.entry.is_none());
5031 }
5032
5033 #[test]
5034 fn test_select_subject_mirrors_mpp_passkey_inline_priority() {
5035 let root = target_addr(0x11);
5036 let local_key = target_addr(0x22);
5037 let passkey_key = target_addr(0x33);
5038 let local = tempo::KeyEntry {
5039 wallet_address: root,
5040 chain_id: 31337,
5041 key_address: Some(local_key),
5042 key: Some("0xlocal".to_string()),
5043 wallet_type: WalletType::Local,
5044 ..Default::default()
5045 };
5046 let passkey = tempo::KeyEntry {
5047 wallet_address: root,
5048 chain_id: 31337,
5049 key_address: Some(passkey_key),
5050 key: Some("0xpasskey".to_string()),
5051 wallet_type: WalletType::Passkey,
5052 ..Default::default()
5053 };
5054
5055 let subject = select_subject_for_chain(
5056 vec![DoctorCandidate::from_entry(local), DoctorCandidate::from_entry(passkey)],
5057 31337,
5058 Some(root),
5059 )
5060 .unwrap();
5061
5062 assert_eq!(subject.key_address, passkey_key);
5063 }
5064
5065 #[test]
5066 fn test_select_subject_keeps_explicit_stale_entry_for_authorization_metadata() {
5067 let root = target_addr(0x11);
5068 let key = target_addr(0x22);
5069 let local = tempo::KeyEntry {
5070 wallet_address: root,
5071 chain_id: 31337,
5072 key_address: Some(key),
5073 key_authorization: Some("0xdeadbeef".to_string()),
5074 ..Default::default()
5075 };
5076
5077 let subject = select_subject_for_chain(
5078 vec![DoctorCandidate::from_entry(local), DoctorCandidate::explicit(root, key)],
5079 31337,
5080 Some(root),
5081 )
5082 .unwrap();
5083
5084 assert_eq!(subject.root_account, root);
5085 assert_eq!(subject.key_address, key);
5086 assert!(subject.explicit);
5087 assert!(subject.entry.as_ref().is_some_and(|entry| entry.key_authorization.is_some()));
5088
5089 let signing = check_local_signing_readiness(&subject);
5090 assert_eq!(signing.status, DoctorStatus::Warn);
5091 }
5092
5093 #[test]
5094 fn test_local_signing_readiness_fails_without_inline_key() {
5095 let root = target_addr(0x11);
5096 let key = target_addr(0x22);
5097 let subject = DoctorSubject {
5098 root_account: root,
5099 key_address: key,
5100 explicit: false,
5101 entry: Some(tempo::KeyEntry {
5102 wallet_address: root,
5103 chain_id: 31337,
5104 key_address: Some(key),
5105 ..Default::default()
5106 }),
5107 };
5108
5109 let signing = check_local_signing_readiness(&subject);
5110 assert_eq!(signing.status, DoctorStatus::Fail);
5111 }
5112
5113 #[test]
5114 fn test_local_signing_readiness_passes_with_inline_key() {
5115 let root = target_addr(0x11);
5116 let key = target_addr(0x22);
5117 let subject = DoctorSubject {
5118 root_account: root,
5119 key_address: key,
5120 explicit: false,
5121 entry: Some(tempo::KeyEntry {
5122 wallet_address: root,
5123 chain_id: 31337,
5124 key_address: Some(key),
5125 key: Some("0xdeadbeef".to_string()),
5126 ..Default::default()
5127 }),
5128 };
5129
5130 let signing = check_local_signing_readiness(&subject);
5131 assert_eq!(signing.status, DoctorStatus::Pass);
5132 }
5133
5134 #[test]
5135 fn test_check_authorization_spending_limits_warns_when_fee_token_missing() {
5136 let fee_token = target_addr(0xAA);
5137 let signed = signed_authorization_with_limits(Some(vec![AuthTokenLimit {
5138 token: target_addr(0xBB),
5139 limit: U256::from(1),
5140 period: 0,
5141 }]));
5142
5143 let step = check_authorization_spending_limits(&signed, fee_token, Some(true));
5144 assert_eq!(step.status, DoctorStatus::Warn);
5145 assert!(step.detail.contains("not listed"));
5146 }
5147
5148 #[test]
5149 fn test_check_authorization_spending_limits_warns_when_fee_token_zero() {
5150 let fee_token = target_addr(0xAA);
5151 let signed = signed_authorization_with_limits(Some(vec![AuthTokenLimit {
5152 token: fee_token,
5153 limit: U256::ZERO,
5154 period: 0,
5155 }]));
5156
5157 let step = check_authorization_spending_limits(&signed, fee_token, Some(true));
5158 assert_eq!(step.status, DoctorStatus::Warn);
5159 }
5160
5161 #[test]
5162 fn test_check_authorization_spending_limits_warns_when_periodic_hardfork_unknown() {
5163 let fee_token = target_addr(0xAA);
5164 let signed = signed_authorization_with_limits(Some(vec![AuthTokenLimit {
5165 token: fee_token,
5166 limit: U256::from(1),
5167 period: 60,
5168 }]));
5169
5170 let step = check_authorization_spending_limits(&signed, fee_token, None);
5171 assert_eq!(step.status, DoctorStatus::Warn);
5172 }
5173
5174 #[test]
5175 fn test_check_authorization_allowed_calls_warns_when_hardfork_unknown() {
5176 let signed = signed_authorization_with_limits(None);
5177 let step = check_authorization_allowed_calls(&signed, None, None, None, None);
5178 assert_eq!(step.status, DoctorStatus::Warn);
5179 }
5180
5181 #[test]
5182 fn test_check_key_expiry_uses_chain_timestamp() {
5183 let step = check_key_expiry(100, &ChainTimestamp::Known(100));
5184 assert_eq!(step.status, DoctorStatus::Fail);
5185
5186 let step = check_key_expiry(101, &ChainTimestamp::Known(100));
5187 assert_eq!(step.status, DoctorStatus::Pass);
5188 }
5189
5190 #[test]
5191 fn test_check_key_expiry_warns_when_chain_timestamp_unknown() {
5192 let step = check_key_expiry(
5193 100,
5194 &ChainTimestamp::Unknown {
5195 detail: "latest block not found".to_string(),
5196 hint: "test hint",
5197 },
5198 );
5199
5200 assert_eq!(step.status, DoctorStatus::Warn);
5201 }
5202
5203 #[test]
5204 fn test_check_expiring_nonce_window_validates_without_expiring_nonce_flag() {
5205 let tempo =
5206 TempoOpts { valid_after: Some(20), valid_before: Some(20), ..Default::default() };
5207 let step = check_expiring_nonce_window(&tempo, None, 10);
5208 assert_eq!(step.status, DoctorStatus::Fail);
5209
5210 let tempo = TempoOpts { valid_before: Some(10), ..Default::default() };
5211 let step = check_expiring_nonce_window(&tempo, None, 10);
5212 assert_eq!(step.status, DoctorStatus::Fail);
5213 }
5214
5215 #[test]
5216 fn test_check_expiring_nonce_window_thresholds() {
5217 let tempo =
5218 TempoOpts { expiring_nonce: true, valid_before: Some(103), ..Default::default() };
5219 assert_eq!(check_expiring_nonce_window(&tempo, None, 100).status, DoctorStatus::Fail);
5220
5221 let tempo =
5222 TempoOpts { expiring_nonce: true, valid_before: Some(104), ..Default::default() };
5223 assert_eq!(check_expiring_nonce_window(&tempo, None, 100).status, DoctorStatus::Warn);
5224
5225 let tempo =
5226 TempoOpts { expiring_nonce: true, valid_before: Some(105), ..Default::default() };
5227 assert_eq!(check_expiring_nonce_window(&tempo, None, 100).status, DoctorStatus::Warn);
5228
5229 let tempo =
5230 TempoOpts { expiring_nonce: true, valid_before: Some(131), ..Default::default() };
5231 assert_eq!(check_expiring_nonce_window(&tempo, None, 100).status, DoctorStatus::Warn);
5232 }
5233
5234 #[test]
5235 fn test_diagnose_allowed_scopes_exact_denial_fails() {
5236 let step = diagnose_allowed_scopes(
5237 &[],
5238 Some(target_addr(0x11)),
5239 Some([0xaa, 0xbb, 0xcc, 0xdd]),
5240 None,
5241 );
5242 assert_eq!(step.status, DoctorStatus::Fail);
5243 }
5244
5245 #[test]
5246 fn test_diagnose_allowed_scopes_target_only_denial_warns() {
5247 let scope = CallScope {
5248 target: target_addr(0x11),
5249 selectorRules: vec![SelectorRule {
5250 selector: [0xaa, 0xbb, 0xcc, 0xdd].into(),
5251 recipients: Vec::new(),
5252 }],
5253 };
5254
5255 let step = diagnose_allowed_scopes(&[scope], Some(target_addr(0x22)), None, None);
5256 assert_eq!(step.status, DoctorStatus::Warn);
5257 }
5258
5259 #[test]
5260 fn test_sponsor_config_error_redacts_private_key_uri() {
5261 let tempo = TempoOpts {
5262 sponsor_signer: Some("private-key://super-secret".to_string()),
5263 ..Default::default()
5264 };
5265
5266 let sanitized = sanitize_sponsor_config_error(
5267 "unsupported Tempo sponsor signer `private-key://super-secret`",
5268 &tempo,
5269 );
5270
5271 assert!(sanitized.contains("private-key://<redacted>"));
5272 assert!(!sanitized.contains("super-secret"));
5273 }
5274
5275 #[test]
5276 fn test_rpc_method_not_found_detection() {
5277 let method_missing: TransportError =
5278 TransportError::ErrorResp(ErrorPayload::method_not_found());
5279 assert!(is_rpc_method_not_found(&method_missing));
5280
5281 let internal_error: TransportError =
5282 TransportError::ErrorResp(ErrorPayload::internal_error());
5283 assert!(!is_rpc_method_not_found(&internal_error));
5284
5285 let transport_error = alloy_transport::TransportErrorKind::backend_gone();
5286 assert!(!is_rpc_method_not_found(&transport_error));
5287 }
5288}