1use alloy_consensus::BlockHeader;
2use alloy_ens::NameOrAddress;
3use std::time::Duration;
4
5use alloy_network::{EthereumWallet, TransactionBuilder};
6use alloy_primitives::{Address, B256, U256, hex};
7use alloy_provider::{Provider, ProviderBuilder as AlloyProviderBuilder};
8use alloy_rlp::Encodable;
9use alloy_rpc_types::BlockId;
10use alloy_signer::Signer;
11use alloy_sol_types::SolCall;
12use alloy_transport::TransportError;
13use chrono::DateTime;
14use clap::Parser;
15use eyre::Result;
16use foundry_cli::{
17 json::print_json_object,
18 opts::{RpcOpts, TempoOpts, TransactionOpts},
19 utils::{LoadConfig, maybe_print_resolved_lane, resolve_lane},
20};
21use foundry_common::{
22 FoundryTransactionBuilder,
23 provider::ProviderBuilder,
24 sh_warn, shell,
25 tempo::{
26 self, KeyType, KeysFile, TEMPO_BROWSER_GAS_BUFFER, WalletType,
27 print_resolved_fee_token_selection, read_tempo_keys_file, tempo_keys_path,
28 },
29};
30use foundry_evm::hardfork::TempoHardfork;
31use foundry_wallets::{WalletOpts, WalletSigner, wallet_browser::signer::BrowserSigner};
32use serde::Deserialize;
33use tempo_alloy::{TempoNetwork, provider::TempoProviderExt};
34use tempo_contracts::precompiles::{
35 ACCOUNT_KEYCHAIN_ADDRESS, DEFAULT_FEE_TOKEN,
36 IAccountKeychain::{
37 self, CallScope, KeyInfo, KeyRestrictions, LegacyTokenLimit, SelectorRule, SignatureType,
38 TokenLimit,
39 },
40 ITIP20, PATH_USD_ADDRESS,
41 account_keychain::{authorizeKeyCall, authorizeKeyWithWitnessCall, legacyAuthorizeKeyCall},
42};
43use tempo_primitives::transaction::{
44 CallScope as AuthCallScope, KeyAuthorization, PrimitiveSignature,
45 SelectorRule as AuthSelectorRule, SignatureType as AuthSignatureType, SignedKeyAuthorization,
46 TokenLimit as AuthTokenLimit,
47};
48use yansi::Paint;
49
50use crate::cmd::tempo_policy_args::{
51 SelectorArg, parse_period, parse_policy_token, parse_scope, parse_selector_arg,
52 parse_selector_bytes,
53};
54
55use crate::{
56 cmd::send::cast_send,
57 tx::{CastTxBuilder, CastTxSender, SendTxOpts},
58};
59
60#[derive(Debug, Parser)]
65pub enum KeychainSubcommand {
66 #[command(visible_alias = "ls")]
68 List,
69
70 Show {
72 wallet_address: Address,
74 },
75
76 #[command(visible_alias = "info")]
78 Check {
79 wallet_address: Address,
81
82 key_address: Address,
84
85 #[command(flatten)]
86 rpc: RpcOpts,
87 },
88
89 Inspect {
91 key_address: Address,
93
94 #[arg(long, visible_alias = "wallet-address", value_name = "ADDRESS")]
96 root_account: Option<Address>,
97
98 #[command(flatten)]
99 rpc: RpcOpts,
100 },
101
102 Doctor {
107 #[arg(required_unless_present = "root_account")]
109 key_address: Option<Address>,
110
111 #[arg(long, visible_alias = "wallet-address", value_name = "ADDRESS")]
114 root_account: Option<Address>,
115
116 #[arg(long, value_name = "ADDRESS")]
118 to: Option<Address>,
119
120 #[arg(long, value_parser = parse_selector_arg, requires = "to")]
123 selector: Option<SelectorArg>,
124
125 #[arg(long, value_name = "ADDRESS", requires = "selector")]
127 recipient: Option<Address>,
128
129 #[arg(
131 id = "doctor_fee_token",
132 long = "fee-token",
133 value_name = "TOKEN",
134 value_parser = parse_policy_token
135 )]
136 fee_token: Option<Address>,
137
138 #[command(flatten)]
139 tempo: TempoOpts,
140
141 #[command(flatten)]
142 rpc: RpcOpts,
143 },
144
145 #[command(visible_alias = "auth")]
147 Authorize {
148 key_address: Address,
150
151 #[arg(default_value = "secp256k1", value_parser = parse_signature_type)]
153 key_type: SignatureType,
154
155 #[arg(default_value_t = u64::MAX)]
157 expiry: u64,
158
159 #[arg(long)]
161 enforce_limits: bool,
162
163 #[arg(long = "limit", value_parser = parse_limit)]
165 limits: Vec<TokenLimit>,
166
167 #[arg(long = "scope", value_parser = parse_scope)]
171 scope: Vec<CallScope>,
172
173 #[arg(long = "scopes", value_parser = parse_scopes_json_wrapped, conflicts_with = "scope")]
177 scopes_json: Option<ScopesJson>,
178
179 #[arg(long)]
183 witness: Option<B256>,
184
185 #[command(flatten)]
186 tx: TransactionOpts,
187
188 #[command(flatten)]
189 send_tx: SendTxOpts,
190 },
191
192 #[command(visible_alias = "rev")]
194 Revoke {
195 key_address: Address,
197
198 #[command(flatten)]
199 tx: TransactionOpts,
200
201 #[command(flatten)]
202 send_tx: SendTxOpts,
203 },
204
205 #[command(name = "burn-witness")]
207 BurnWitness {
208 witness: B256,
210
211 #[command(flatten)]
212 tx: TransactionOpts,
213
214 #[command(flatten)]
215 send_tx: SendTxOpts,
216 },
217
218 #[command(name = "is-witness-burned")]
220 IsWitnessBurned {
221 account: Address,
223
224 witness: B256,
226
227 #[command(flatten)]
228 rpc: RpcOpts,
229 },
230
231 #[command(name = "rl", visible_alias = "remaining-limit")]
233 RemainingLimit {
234 wallet_address: Address,
236
237 key_address: Address,
239
240 token: Address,
242
243 #[command(flatten)]
244 rpc: RpcOpts,
245 },
246
247 #[command(name = "ul", visible_alias = "update-limit")]
249 UpdateLimit {
250 key_address: Address,
252
253 token: Address,
255
256 new_limit: U256,
258
259 #[command(flatten)]
260 tx: TransactionOpts,
261
262 #[command(flatten)]
263 send_tx: SendTxOpts,
264 },
265
266 #[command(name = "ss", visible_alias = "set-scope")]
268 SetScope {
269 key_address: Address,
271
272 #[arg(long = "scope", required = true, value_parser = parse_scope)]
274 scope: Vec<CallScope>,
275
276 #[command(flatten)]
277 tx: TransactionOpts,
278
279 #[command(flatten)]
280 send_tx: SendTxOpts,
281 },
282
283 #[command(name = "rs", visible_alias = "remove-scope")]
285 RemoveScope {
286 key_address: Address,
288
289 target: Address,
291
292 #[command(flatten)]
293 tx: TransactionOpts,
294
295 #[command(flatten)]
296 send_tx: SendTxOpts,
297 },
298
299 Policy {
301 #[command(subcommand)]
302 command: KeychainPolicySubcommand,
303 },
304}
305
306#[derive(Debug, Parser)]
308pub enum KeyAuthSubcommand {
309 Encode {
311 #[command(flatten)]
312 authorization: KeyAuthArgs,
313 },
314
315 Sign {
317 #[command(flatten)]
318 authorization: KeyAuthArgs,
319
320 #[command(flatten)]
321 wallet: Box<WalletOpts>,
322 },
323}
324
325#[derive(Debug, Parser)]
327pub struct KeyAuthArgs {
328 #[arg(long)]
330 chain_id: u64,
331
332 key_address: Address,
334
335 #[arg(long, default_value = "secp256k1", value_parser = parse_auth_signature_type)]
338 key_type: AuthSignatureType,
339
340 #[arg(long)]
342 expiry: Option<u64>,
343
344 #[arg(long)]
346 enforce_limits: bool,
347
348 #[arg(long = "limit", value_parser = parse_auth_limit)]
350 limits: Vec<AuthTokenLimit>,
351
352 #[arg(long = "scope", value_parser = parse_auth_scope)]
355 scope: Vec<AuthCallScope>,
356
357 #[arg(long = "scopes", value_parser = parse_auth_scopes_json_wrapped, conflicts_with = "scope")]
359 scopes_json: Option<AuthScopesJson>,
360
361 #[arg(long)]
365 witness: Option<B256>,
366}
367
368#[derive(Debug, Parser)]
370pub enum KeychainPolicySubcommand {
371 AddCall {
373 key_address: Address,
375
376 #[arg(long, visible_alias = "wallet-address", value_name = "ADDRESS")]
378 root_account: Option<Address>,
379
380 #[arg(long)]
382 target: Address,
383
384 #[arg(long, value_parser = parse_selector_arg)]
386 selector: SelectorArg,
387
388 #[arg(long, value_delimiter = ',')]
390 recipients: Vec<Address>,
391
392 #[command(flatten)]
393 tx: TransactionOpts,
394
395 #[command(flatten)]
396 send_tx: SendTxOpts,
397 },
398
399 SetLimit {
401 key_address: Address,
403
404 #[arg(long, value_parser = parse_policy_token)]
406 token: Address,
407
408 #[arg(long)]
410 amount: U256,
411
412 #[arg(long, value_parser = parse_period)]
417 period: Option<u64>,
418
419 #[command(flatten)]
420 tx: TransactionOpts,
421
422 #[command(flatten)]
423 send_tx: SendTxOpts,
424 },
425
426 RemoveTarget {
428 key_address: Address,
430
431 #[arg(long)]
433 target: Address,
434
435 #[command(flatten)]
436 tx: TransactionOpts,
437
438 #[command(flatten)]
439 send_tx: SendTxOpts,
440 },
441}
442
443fn parse_signature_type(s: &str) -> Result<SignatureType, String> {
444 match s.to_lowercase().as_str() {
445 "secp256k1" => Ok(SignatureType::Secp256k1),
446 "p256" => Ok(SignatureType::P256),
447 "webauthn" => Ok(SignatureType::WebAuthn),
448 _ => Err(format!("unknown signature type: {s} (expected secp256k1, p256, or webauthn)")),
449 }
450}
451
452fn parse_auth_signature_type(s: &str) -> Result<AuthSignatureType, String> {
453 match s.to_lowercase().as_str() {
454 "secp256k1" => Ok(AuthSignatureType::Secp256k1),
455 "p256" => Ok(AuthSignatureType::P256),
456 "webauthn" => Ok(AuthSignatureType::WebAuthn),
457 _ => Err(format!("unknown signature type: {s} (expected secp256k1, p256, or webauthn)")),
458 }
459}
460
461const fn signature_type_name(t: &SignatureType) -> &'static str {
462 match t {
463 SignatureType::Secp256k1 => "secp256k1",
464 SignatureType::P256 => "p256",
465 SignatureType::WebAuthn => "webauthn",
466 _ => "unknown",
467 }
468}
469
470const fn signature_type_label(t: &SignatureType) -> &'static str {
471 match t {
472 SignatureType::Secp256k1 => "Secp256k1",
473 SignatureType::P256 => "P256",
474 SignatureType::WebAuthn => "WebAuthn",
475 _ => "unknown",
476 }
477}
478
479const fn key_type_name(t: &KeyType) -> &'static str {
480 match t {
481 KeyType::Secp256k1 => "secp256k1",
482 KeyType::P256 => "p256",
483 KeyType::WebAuthn => "webauthn",
484 }
485}
486
487const fn key_type_label(t: &KeyType) -> &'static str {
488 match t {
489 KeyType::Secp256k1 => "Secp256k1",
490 KeyType::P256 => "P256",
491 KeyType::WebAuthn => "WebAuthn",
492 }
493}
494
495const fn wallet_type_name(t: &WalletType) -> &'static str {
496 match t {
497 WalletType::Local => "local",
498 WalletType::Passkey => "passkey",
499 }
500}
501
502fn parse_limit(s: &str) -> Result<TokenLimit, String> {
504 let (token_str, amount_str) = s
505 .split_once(':')
506 .ok_or_else(|| format!("invalid limit format: {s} (expected TOKEN:AMOUNT)"))?;
507 let token: Address =
508 token_str.parse().map_err(|e| format!("invalid token address '{token_str}': {e}"))?;
509 let amount: U256 =
510 amount_str.parse().map_err(|e| format!("invalid amount '{amount_str}': {e}"))?;
511 Ok(TokenLimit { token, amount, period: 0 })
512}
513
514fn parse_auth_limit(s: &str) -> Result<AuthTokenLimit, String> {
516 let parts: Vec<_> = s.split(':').collect();
517 let (token_str, amount_str, period_str) = match parts.as_slice() {
518 [token_str, amount_str] => (*token_str, *amount_str, None),
519 [token_str, amount_str, period_str] => (*token_str, *amount_str, Some(*period_str)),
520 _ => return Err(format!("invalid limit format: {s} (expected TOKEN:AMOUNT[:PERIOD])")),
521 };
522 let token: Address =
523 token_str.parse().map_err(|e| format!("invalid token address '{token_str}': {e}"))?;
524 let limit: U256 =
525 amount_str.parse().map_err(|e| format!("invalid amount '{amount_str}': {e}"))?;
526 let period = if let Some(period_str) = period_str { parse_period(period_str)? } else { 0 };
527 Ok(AuthTokenLimit { token, limit, period })
528}
529
530fn parse_auth_scope(s: &str) -> Result<AuthCallScope, String> {
531 parse_scope(s).map(abi_scope_to_auth_scope)
532}
533
534fn abi_scope_to_auth_scope(scope: CallScope) -> AuthCallScope {
535 AuthCallScope {
536 target: scope.target,
537 selector_rules: scope
538 .selectorRules
539 .into_iter()
540 .map(|rule| {
541 let mut selector = [0u8; 4];
542 selector.copy_from_slice(rule.selector.as_slice());
543 AuthSelectorRule { selector, recipients: rule.recipients }
544 })
545 .collect(),
546 }
547}
548#[derive(serde::Deserialize)]
550struct JsonCallScope {
551 target: Address,
552 #[serde(default)]
553 selectors: Option<Vec<JsonSelectorEntry>>,
554}
555
556#[derive(serde::Deserialize)]
558#[serde(untagged)]
559enum JsonSelectorEntry {
560 Name(String),
561 WithRecipients(JsonSelectorWithRecipients),
562}
563
564#[derive(serde::Deserialize)]
565#[serde(deny_unknown_fields)]
566struct JsonSelectorWithRecipients {
567 selector: String,
568 #[serde(default)]
569 recipients: Vec<Address>,
570}
571
572fn parse_scopes_json(s: &str) -> Result<Vec<CallScope>, String> {
574 let entries: Vec<JsonCallScope> =
575 serde_json::from_str(s).map_err(|e| format!("invalid --scopes JSON: {e}"))?;
576
577 let mut scopes = Vec::new();
578 for entry in entries {
579 let selector_rules = match entry.selectors {
580 None => vec![],
581 Some(sels) => {
582 let mut rules = Vec::new();
583 for sel_entry in sels {
584 let (selector_str, recipients) = match sel_entry {
585 JsonSelectorEntry::Name(name) => (name, vec![]),
586 JsonSelectorEntry::WithRecipients(r) => (r.selector, r.recipients),
587 };
588 let selector = parse_selector_bytes(&selector_str)
589 .map_err(|e| format!("in --scopes JSON: {e}"))?;
590 rules.push(SelectorRule { selector: selector.into(), recipients });
591 }
592 rules
593 }
594 };
595 scopes.push(CallScope { target: entry.target, selectorRules: selector_rules });
596 }
597
598 Ok(scopes)
599}
600
601#[derive(Debug, Clone)]
603pub struct ScopesJson(Vec<CallScope>);
604
605fn parse_scopes_json_wrapped(s: &str) -> Result<ScopesJson, String> {
607 parse_scopes_json(s).map(ScopesJson)
608}
609
610#[derive(Debug, Clone)]
612pub struct AuthScopesJson(Vec<AuthCallScope>);
613
614fn parse_auth_scopes_json_wrapped(s: &str) -> Result<AuthScopesJson, String> {
615 parse_scopes_json(s)
616 .map(|scopes| AuthScopesJson(scopes.into_iter().map(abi_scope_to_auth_scope).collect()))
617}
618
619impl KeychainSubcommand {
620 #[allow(clippy::large_stack_frames)]
621 pub async fn run(self) -> Result<()> {
622 match self {
623 Self::List => run_list(),
624 Self::Show { wallet_address } => run_show(wallet_address),
625 Self::Check { wallet_address, key_address, rpc } => {
626 run_check(wallet_address, key_address, rpc).await
627 }
628 Self::Inspect { key_address, root_account, rpc } => {
629 run_inspect(key_address, root_account, rpc).await
630 }
631 Self::Doctor {
632 key_address,
633 root_account,
634 to,
635 selector,
636 recipient,
637 fee_token,
638 tempo,
639 rpc,
640 } => {
641 run_doctor(
642 key_address,
643 root_account,
644 to,
645 selector.map(SelectorArg::into_bytes),
646 recipient,
647 fee_token,
648 tempo,
649 rpc,
650 )
651 .await
652 }
653 Self::Authorize {
654 key_address,
655 key_type,
656 expiry,
657 enforce_limits,
658 limits,
659 scope,
660 scopes_json,
661 witness,
662 tx,
663 send_tx,
664 } => {
665 let all_scopes = if let Some(ScopesJson(json_scopes)) = scopes_json {
666 json_scopes
667 } else {
668 scope
669 };
670 run_authorize(
671 key_address,
672 key_type,
673 expiry,
674 enforce_limits,
675 limits,
676 all_scopes,
677 witness,
678 tx,
679 send_tx,
680 )
681 .await
682 }
683 Self::Revoke { key_address, tx, send_tx } => run_revoke(key_address, tx, send_tx).await,
684 Self::BurnWitness { witness, tx, send_tx } => {
685 run_burn_witness(witness, tx, send_tx).await
686 }
687 Self::IsWitnessBurned { account, witness, rpc } => {
688 run_is_witness_burned(account, witness, rpc).await
689 }
690 Self::RemainingLimit { wallet_address, key_address, token, rpc } => {
691 run_remaining_limit(wallet_address, key_address, token, rpc).await
692 }
693 Self::UpdateLimit { key_address, token, new_limit, tx, send_tx } => {
694 run_update_limit(key_address, token, new_limit, tx, send_tx).await
695 }
696 Self::SetScope { key_address, scope, tx, send_tx } => {
697 run_set_scope(key_address, scope, tx, send_tx).await
698 }
699 Self::RemoveScope { key_address, target, tx, send_tx } => {
700 run_remove_scope(key_address, target, tx, send_tx).await
701 }
702 Self::Policy { command } => command.run().await,
703 }
704 }
705}
706
707impl KeyAuthSubcommand {
708 pub async fn run(self) -> Result<()> {
709 match self {
710 Self::Encode { authorization } => run_key_auth_encode(authorization),
711 Self::Sign { authorization, wallet } => run_key_auth_sign(authorization, *wallet).await,
712 }
713 }
714}
715
716impl KeychainPolicySubcommand {
717 pub async fn run(self) -> Result<()> {
718 match self {
719 Self::AddCall {
720 key_address,
721 root_account,
722 target,
723 selector,
724 recipients,
725 tx,
726 send_tx,
727 } => {
728 run_policy_add_call(
729 key_address,
730 root_account,
731 target,
732 selector.into_bytes(),
733 recipients,
734 tx,
735 send_tx,
736 )
737 .await
738 }
739 Self::SetLimit { key_address, token, amount, period, tx, send_tx } => {
740 run_policy_set_limit(key_address, token, amount, period, tx, send_tx).await
741 }
742 Self::RemoveTarget { key_address, target, tx, send_tx } => {
743 run_remove_scope(key_address, target, tx, send_tx).await
744 }
745 }
746 }
747}
748
749fn run_list() -> Result<()> {
751 let keys_file = load_keys_file()?;
752
753 if shell::is_json() {
754 let entries: Vec<_> = keys_file.keys.iter().map(key_entry_to_json).collect();
755 print_json_object(entries)?;
756 return Ok(());
757 }
758
759 if keys_file.keys.is_empty() {
760 sh_println!("No keys found in keys.toml.")?;
761 return Ok(());
762 }
763
764 for (i, entry) in keys_file.keys.iter().enumerate() {
765 if i > 0 {
766 sh_println!()?;
767 }
768 print_key_entry(entry)?;
769 }
770
771 Ok(())
772}
773
774fn run_show(wallet_address: Address) -> Result<()> {
776 let keys_file = load_keys_file()?;
777
778 let entries: Vec<_> =
779 keys_file.keys.iter().filter(|e| e.wallet_address == wallet_address).collect();
780
781 if shell::is_json() {
782 let entries_json: Vec<_> = entries.iter().map(|e| key_entry_to_json(e)).collect();
783 print_json_object(entries_json)?;
784 return Ok(());
785 }
786
787 if entries.is_empty() {
788 sh_println!("No keys found for wallet {wallet_address}.")?;
789 return Ok(());
790 }
791
792 for (i, entry) in entries.iter().enumerate() {
793 if i > 0 {
794 sh_println!()?;
795 }
796 print_key_entry(entry)?;
797 }
798
799 Ok(())
800}
801
802#[derive(Debug, Clone)]
803struct LocalLimitMetadata {
804 token: Address,
805 amount: String,
806}
807
808#[derive(Debug, Clone)]
809struct KeyMetadata {
810 root_account: Address,
811 key_type: Option<KeyType>,
812 limits: Vec<LocalLimitMetadata>,
813}
814
815#[derive(Debug, Clone)]
816struct InspectedLimit {
817 token: Address,
818 configured_amount: Option<String>,
819 remaining: U256,
820 period_end: Option<u64>,
821}
822
823#[derive(Debug, Clone)]
824enum AllowedCallsView {
825 Unsupported,
826 Unrestricted,
827 Scoped(Vec<CallScope>),
828}
829
830async fn run_inspect(
832 key_address: Address,
833 root_account: Option<Address>,
834 rpc: RpcOpts,
835) -> Result<()> {
836 let metadata = resolve_key_metadata(key_address, root_account)?;
837 let config = rpc.load_config()?;
838 let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
839
840 let info: KeyInfo = provider.get_keychain_key(metadata.root_account, key_address).await?;
841 let provisioned = info.keyId != Address::ZERO;
842 let is_t3 = is_tempo_hardfork_active(&provider, TempoHardfork::T3).await?;
843
844 let mut limits = Vec::new();
845 if info.enforceLimits {
846 for local_limit in &metadata.limits {
847 let (remaining, period_end) = if is_t3 {
848 let limit = provider
849 .get_keychain_remaining_limit_with_period(
850 metadata.root_account,
851 key_address,
852 local_limit.token,
853 )
854 .await?;
855 (limit.remaining, Some(limit.periodEnd))
856 } else {
857 let remaining = provider
858 .account_keychain()
859 .getRemainingLimit(metadata.root_account, key_address, local_limit.token)
860 .call()
861 .await?;
862 (remaining, None)
863 };
864
865 limits.push(InspectedLimit {
866 token: local_limit.token,
867 configured_amount: Some(local_limit.amount.clone()),
868 remaining,
869 period_end,
870 });
871 }
872 }
873
874 let allowed_calls = if is_t3 {
875 let allowed = provider
876 .account_keychain()
877 .getAllowedCalls(metadata.root_account, key_address)
878 .call()
879 .await?;
880 if allowed.isScoped {
881 AllowedCallsView::Scoped(allowed.scopes)
882 } else {
883 AllowedCallsView::Unrestricted
884 }
885 } else {
886 AllowedCallsView::Unsupported
887 };
888
889 if shell::is_json() {
890 let key_type = if provisioned {
891 signature_type_name(&info.signatureType).to_string()
892 } else {
893 metadata
894 .key_type
895 .map(|key_type| key_type_name(&key_type).to_string())
896 .unwrap_or_else(|| "unknown".to_string())
897 };
898 let json = serde_json::json!({
899 "root_account": metadata.root_account.to_string(),
900 "key_id": key_address.to_string(),
901 "provisioned": provisioned,
902 "type": key_type,
903 "expiry": provisioned.then_some(info.expiry),
904 "expiry_human": provisioned.then(|| format_expiry_for_inspect(info.expiry)),
905 "enforce_limits": info.enforceLimits,
906 "is_revoked": info.isRevoked,
907 "limits": limits.iter().map(inspected_limit_to_json).collect::<Vec<_>>(),
908 "allowed_calls": allowed_calls_to_json(&allowed_calls),
909 });
910 print_json_object(json)?;
911 return Ok(());
912 }
913
914 let key_type = if provisioned {
915 signature_type_label(&info.signatureType)
916 } else {
917 metadata.key_type.map(|key_type| key_type_label(&key_type)).unwrap_or("unknown")
918 };
919
920 sh_println!("Root account: {}", metadata.root_account)?;
921 sh_println!("Key id: {key_address}")?;
922 sh_println!("Type: {key_type}")?;
923
924 if info.isRevoked {
925 sh_println!("Status: revoked")?;
926 } else if !provisioned {
927 sh_println!("Status: not provisioned")?;
928 } else {
929 sh_println!("Status: active")?;
930 sh_println!("Expiry: {}", format_expiry_for_inspect(info.expiry))?;
931 }
932
933 print_inspected_limits(info.enforceLimits, &limits)?;
934 print_allowed_calls(&allowed_calls)?;
935
936 Ok(())
937}
938
939async fn run_check(wallet_address: Address, key_address: Address, rpc: RpcOpts) -> Result<()> {
941 let config = rpc.load_config()?;
942 let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
943
944 let info: KeyInfo = provider.get_keychain_key(wallet_address, key_address).await?;
945
946 let provisioned = info.keyId != Address::ZERO;
947
948 if shell::is_json() {
949 let json = serde_json::json!({
950 "wallet_address": wallet_address.to_string(),
951 "key_address": key_address.to_string(),
952 "provisioned": provisioned,
953 "signatureType": signature_type_name(&info.signatureType),
954 "key_id": info.keyId.to_string(),
955 "expiry": info.expiry,
956 "expiry_human": format_expiry(info.expiry),
957 "enforce_limits": info.enforceLimits,
958 "is_revoked": info.isRevoked,
959 });
960 print_json_object(json)?;
961 return Ok(());
962 }
963
964 sh_println!("Wallet: {wallet_address}")?;
965 sh_println!("Key: {key_address}")?;
966
967 if info.isRevoked {
968 sh_println!("Status: {} revoked", "✗".red())?;
969 return Ok(());
970 }
971
972 if !provisioned {
973 sh_println!("Status: {} not provisioned", "✗".red())?;
974 return Ok(());
975 }
976
977 sh_println!("Status: {} active", "✓".green())?;
979
980 sh_println!("Signature Type: {}", signature_type_name(&info.signatureType))?;
981 sh_println!("Key ID: {}", info.keyId)?;
982
983 let expiry_str = format_expiry(info.expiry);
985 if info.expiry == u64::MAX {
986 sh_println!("Expiry: {}", expiry_str)?;
987 } else {
988 let now = std::time::SystemTime::now()
989 .duration_since(std::time::UNIX_EPOCH)
990 .unwrap_or_default()
991 .as_secs();
992 if info.expiry <= now {
993 sh_println!("Expiry: {} ({})", expiry_str, "expired".red())?;
994 } else {
995 sh_println!("Expiry: {}", expiry_str)?;
996 }
997 }
998
999 sh_println!("Spending Limits: {}", if info.enforceLimits { "enforced" } else { "none" })?;
1000
1001 Ok(())
1002}
1003
1004#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
1019#[serde(rename_all = "lowercase")]
1020enum DoctorStatus {
1021 Pass,
1022 Warn,
1023 Fail,
1024}
1025
1026#[derive(Debug, Clone, serde::Serialize)]
1027struct DoctorStep {
1028 name: &'static str,
1029 label: &'static str,
1030 status: DoctorStatus,
1031 detail: String,
1032 #[serde(skip_serializing_if = "Option::is_none")]
1033 hint: Option<String>,
1034}
1035
1036impl DoctorStep {
1037 fn pass(name: &'static str, label: &'static str, detail: impl Into<String>) -> Self {
1038 Self { name, label, status: DoctorStatus::Pass, detail: detail.into(), hint: None }
1039 }
1040
1041 fn warn(
1042 name: &'static str,
1043 label: &'static str,
1044 detail: impl Into<String>,
1045 hint: impl Into<String>,
1046 ) -> Self {
1047 Self {
1048 name,
1049 label,
1050 status: DoctorStatus::Warn,
1051 detail: detail.into(),
1052 hint: Some(hint.into()),
1053 }
1054 }
1055
1056 fn fail(
1057 name: &'static str,
1058 label: &'static str,
1059 detail: impl Into<String>,
1060 hint: impl Into<String>,
1061 ) -> Self {
1062 Self {
1063 name,
1064 label,
1065 status: DoctorStatus::Fail,
1066 detail: detail.into(),
1067 hint: Some(hint.into()),
1068 }
1069 }
1070}
1071
1072#[derive(Debug, Clone, Copy, Default, serde::Serialize)]
1073struct DoctorContext {
1074 #[serde(skip_serializing_if = "Option::is_none")]
1075 root_account: Option<Address>,
1076 #[serde(skip_serializing_if = "Option::is_none")]
1077 key_address: Option<Address>,
1078 #[serde(skip_serializing_if = "Option::is_none")]
1079 chain_id: Option<u64>,
1080 fee_token: Address,
1081}
1082
1083#[derive(Debug)]
1085struct DoctorSubject {
1086 root_account: Address,
1087 key_address: Address,
1088 entry: Option<tempo::KeyEntry>,
1089 explicit: bool,
1090}
1091
1092#[derive(Debug)]
1094struct DoctorCandidate {
1095 root_account: Address,
1096 key_address: Address,
1097 chain_id: Option<u64>,
1098 entry: Option<tempo::KeyEntry>,
1099 explicit: bool,
1100}
1101
1102impl DoctorCandidate {
1103 fn from_entry(entry: tempo::KeyEntry) -> Self {
1104 Self {
1105 root_account: entry.wallet_address,
1106 key_address: key_entry_effective_key(&entry),
1107 chain_id: Some(entry.chain_id),
1108 entry: Some(entry),
1109 explicit: false,
1110 }
1111 }
1112
1113 const fn explicit(root_account: Address, key_address: Address) -> Self {
1114 Self { root_account, key_address, chain_id: None, entry: None, explicit: true }
1115 }
1116
1117 fn has_inline_key(&self) -> bool {
1118 self.entry.as_ref().is_some_and(|entry| entry.has_inline_key())
1119 }
1120
1121 fn is_passkey_with_inline_key(&self) -> bool {
1122 self.entry
1123 .as_ref()
1124 .is_some_and(|entry| entry.wallet_type == WalletType::Passkey && entry.has_inline_key())
1125 }
1126}
1127
1128#[derive(Debug)]
1129struct LocalCandidateResolution {
1130 step: DoctorStep,
1131 candidates: Vec<DoctorCandidate>,
1132}
1133
1134struct ValidKeyAuthorization {
1135 signed: SignedKeyAuthorization,
1136 detail: String,
1137}
1138
1139enum KeyRegistrationState {
1140 OnChain(KeyInfo),
1141 PendingAuthorization(Box<SignedKeyAuthorization>),
1142}
1143
1144struct SponsorshipDiagnosis {
1145 step: DoctorStep,
1146 fee_payer: Option<Address>,
1147}
1148
1149#[derive(Debug, Clone)]
1150enum ChainTimestamp {
1151 Known(u64),
1152 Unknown { detail: String, hint: &'static str },
1153}
1154
1155impl ChainTimestamp {
1156 const fn timestamp(&self) -> Option<u64> {
1157 match self {
1158 Self::Known(timestamp) => Some(*timestamp),
1159 Self::Unknown { .. } => None,
1160 }
1161 }
1162
1163 fn unavailable_step(
1164 &self,
1165 name: &'static str,
1166 label: &'static str,
1167 detail: impl Into<String>,
1168 ) -> DoctorStep {
1169 match self {
1170 Self::Known(_) => unreachable!("chain timestamp is available"),
1171 Self::Unknown { detail: reason, hint } => {
1172 DoctorStep::warn(name, label, format!("{}: {reason}", detail.into()), *hint)
1173 }
1174 }
1175 }
1176}
1177
1178enum AllowedCallMatch {
1180 Allowed(String),
1182 Denied(String),
1184 RecipientRestricted(Vec<Address>),
1186}
1187
1188#[allow(clippy::too_many_arguments)]
1190async fn run_doctor(
1191 key_address: Option<Address>,
1192 root_account: Option<Address>,
1193 to: Option<Address>,
1194 selector: Option<[u8; 4]>,
1195 recipient: Option<Address>,
1196 fee_token: Option<Address>,
1197 mut tempo: TempoOpts,
1198 rpc: RpcOpts,
1199) -> Result<()> {
1200 let mut steps: Vec<DoctorStep> = Vec::new();
1201 let requested_fee_token = fee_token.or(tempo.fee_token);
1202 let mut context = DoctorContext {
1203 root_account,
1204 key_address,
1205 chain_id: None,
1206 fee_token: requested_fee_token.unwrap_or(DEFAULT_FEE_TOKEN),
1207 };
1208
1209 let candidates = match collect_local_candidates(key_address, root_account) {
1211 Ok(resolution) => {
1212 steps.push(resolution.step);
1213 resolution.candidates
1214 }
1215 Err(step) => {
1216 steps.push(step);
1217 return finalize_doctor(steps, context);
1218 }
1219 };
1220
1221 let config = match rpc.load_config() {
1223 Ok(c) => c,
1224 Err(err) => {
1225 steps.push(DoctorStep::fail(
1226 "rpc_reachability",
1227 "RPC reachable",
1228 format!("could not load RPC config: {err}"),
1229 "check --rpc-url and your foundry.toml",
1230 ));
1231 return finalize_doctor(steps, context);
1232 }
1233 };
1234 let provider = match ProviderBuilder::<TempoNetwork>::from_config(&config)
1235 .and_then(|builder| builder.build())
1236 {
1237 Ok(p) => p,
1238 Err(err) => {
1239 steps.push(DoctorStep::fail(
1240 "rpc_reachability",
1241 "RPC reachable",
1242 format!("could not build provider: {err}"),
1243 "verify --rpc-url is set and reachable",
1244 ));
1245 return finalize_doctor(steps, context);
1246 }
1247 };
1248
1249 let rpc_chain_id = match provider.get_chain_id().await {
1250 Ok(id) => {
1251 context.chain_id = Some(id);
1252 steps.push(DoctorStep::pass(
1253 "rpc_reachability",
1254 "RPC reachable",
1255 format!("chain id {id}"),
1256 ));
1257 id
1258 }
1259 Err(err) => {
1260 steps.push(DoctorStep::fail(
1261 "rpc_reachability",
1262 "RPC reachable",
1263 format!("eth_chainId failed: {err}"),
1264 "confirm the node is reachable and not rate-limited",
1265 ));
1266 return finalize_doctor(steps, context);
1267 }
1268 };
1269 let chain_timestamp = fetch_chain_timestamp(&provider).await;
1270
1271 let subject = match select_subject_for_chain(candidates, rpc_chain_id, root_account) {
1273 Ok(s) => {
1274 let detail = if s.entry.is_some() {
1275 format!(
1276 "local entry on chain {} matches RPC (root {}, key {})",
1277 rpc_chain_id, s.root_account, s.key_address
1278 )
1279 } else {
1280 format!(
1281 "using explicit root {} and key {} on RPC chain {}",
1282 s.root_account, s.key_address, rpc_chain_id
1283 )
1284 };
1285 steps.push(DoctorStep::pass("chain_id_match", "Chain ID match", detail));
1286 context.root_account = Some(s.root_account);
1287 context.key_address = Some(s.key_address);
1288 s
1289 }
1290 Err(detail) => {
1291 steps.push(DoctorStep::fail(
1292 "chain_id_match",
1293 "Chain ID match",
1294 detail,
1295 "use the RPC for the chain the local entry was created on, or pass --root-account",
1296 ));
1297 return finalize_doctor(steps, context);
1298 }
1299 };
1300
1301 let local_signing = check_local_signing_readiness(&subject);
1303 let local_signing_failed = local_signing.status == DoctorStatus::Fail;
1304 steps.push(local_signing);
1305 if local_signing_failed {
1306 return finalize_doctor(steps, context);
1307 }
1308
1309 let registration = match provider
1311 .get_keychain_key(subject.root_account, subject.key_address)
1312 .await
1313 {
1314 Ok(info) if info.keyId != Address::ZERO => {
1315 steps.push(DoctorStep::pass(
1316 "key_registration",
1317 "Key registration",
1318 format!("provisioned, type {}", signature_type_label(&info.signatureType)),
1319 ));
1320 KeyRegistrationState::OnChain(info)
1321 }
1322 Ok(_) => match validate_pending_key_authorization(&subject, rpc_chain_id, &chain_timestamp)
1323 {
1324 Ok(valid) => {
1325 steps.push(DoctorStep::pass("key_registration", "Key registration", valid.detail));
1326 KeyRegistrationState::PendingAuthorization(Box::new(valid.signed))
1327 }
1328 Err(step) => {
1329 steps.push(step);
1330 return finalize_doctor(steps, context);
1331 }
1332 },
1333 Err(err) => {
1334 steps.push(DoctorStep::fail(
1335 "key_registration",
1336 "Key registration",
1337 format!("AccountKeychain.getKey failed: {err}"),
1338 "verify the RPC supports the AccountKeychain precompile",
1339 ));
1340 return finalize_doctor(steps, context);
1341 }
1342 };
1343
1344 match registration {
1345 KeyRegistrationState::OnChain(info) => {
1346 if info.isRevoked {
1348 steps.push(DoctorStep::fail(
1349 "revocation",
1350 "Revocation",
1351 "key is revoked on-chain".to_string(),
1352 "authorize a new key or re-authorize this one",
1353 ));
1354 return finalize_doctor(steps, context);
1355 }
1356 steps.push(DoctorStep::pass("revocation", "Revocation", "active"));
1357
1358 let expiry = check_key_expiry(info.expiry, &chain_timestamp);
1360 let expiry_failed = expiry.status == DoctorStatus::Fail;
1361 steps.push(expiry);
1362 if expiry_failed {
1363 return finalize_doctor(steps, context);
1364 }
1365
1366 let (step, is_t3) = check_hardfork(&provider).await;
1368 steps.push(step);
1369
1370 steps.push(
1372 check_spending_limits(&provider, &subject, &info, context.fee_token, is_t3).await,
1373 );
1374
1375 steps.push(
1377 check_allowed_calls(&provider, &subject, is_t3, to, selector, recipient).await,
1378 );
1379 }
1380 KeyRegistrationState::PendingAuthorization(signed) => {
1381 steps.push(DoctorStep::pass(
1382 "revocation",
1383 "Revocation",
1384 "not on-chain yet; key_authorization will provision a fresh key",
1385 ));
1386
1387 let expiry = check_authorization_expiry(&signed, &chain_timestamp);
1388 let expiry_failed = expiry.status == DoctorStatus::Fail;
1389 steps.push(expiry);
1390 if expiry_failed {
1391 return finalize_doctor(steps, context);
1392 }
1393
1394 let (step, is_t3) = check_hardfork(&provider).await;
1395 steps.push(step);
1396 steps.push(check_authorization_spending_limits(&signed, context.fee_token, is_t3));
1397 steps.push(check_authorization_allowed_calls(&signed, is_t3, to, selector, recipient));
1398 }
1399 }
1400
1401 let resolved_expires_at = tempo.resolve_expires();
1403 steps.push(check_expiring_nonce(&tempo, resolved_expires_at, &chain_timestamp));
1404
1405 let sponsorship = check_sponsorship(&tempo, subject.root_account).await;
1406 let sponsor_failed = sponsorship.step.status == DoctorStatus::Fail;
1407 let fee_payer = sponsorship.fee_payer;
1408 steps.push(sponsorship.step);
1409
1410 if sponsor_failed && tempo.has_sponsor_submission() {
1411 steps.push(DoctorStep::warn(
1412 "fee_token_balance",
1413 "Fee-token balance",
1414 "skipped; sponsorship config is invalid",
1415 "fix the sponsorship configuration before checking the fee payer balance",
1416 ));
1417 } else {
1418 let balance_account = fee_payer.unwrap_or(subject.root_account);
1419 let balance_owner = if fee_payer.is_some() { "sponsor" } else { "root account" };
1420 steps.push(
1421 check_fee_token_balance(&provider, balance_account, context.fee_token, balance_owner)
1422 .await,
1423 );
1424 }
1425
1426 finalize_doctor(steps, context)
1427}
1428
1429fn collect_local_candidates(
1431 key_address: Option<Address>,
1432 root_account: Option<Address>,
1433) -> Result<LocalCandidateResolution, DoctorStep> {
1434 let explicit_candidate = || {
1435 key_address
1436 .zip(root_account)
1437 .map(|(key_address, root_account)| DoctorCandidate::explicit(root_account, key_address))
1438 };
1439
1440 let Some(keys_file) = read_tempo_keys_file() else {
1441 if let Some(candidate) = explicit_candidate() {
1442 return Ok(LocalCandidateResolution {
1443 step: DoctorStep::pass(
1444 "local_registry",
1445 "Local registry",
1446 format!(
1447 "could not read {}; using explicit root/key",
1448 tempo_keys_path_display()
1449 ),
1450 ),
1451 candidates: vec![candidate],
1452 });
1453 }
1454
1455 return Err(DoctorStep::fail(
1456 "local_registry",
1457 "Local registry",
1458 format!("could not read local keys file at {}", tempo_keys_path_display()),
1459 "run `cast tempo login` or pass both KEY_ADDRESS and --root-account",
1460 ));
1461 };
1462
1463 let matches: Vec<tempo::KeyEntry> = keys_file
1464 .keys
1465 .into_iter()
1466 .filter(|entry| match (key_address, root_account) {
1467 (Some(k), Some(r)) => key_entry_effective_key(entry) == k && entry.wallet_address == r,
1468 (Some(k), None) => key_entry_effective_key(entry) == k,
1469 (None, Some(r)) => entry.wallet_address == r,
1470 (None, None) => false,
1471 })
1472 .collect();
1473
1474 if matches.is_empty() {
1475 if let Some(candidate) = explicit_candidate() {
1476 return Ok(LocalCandidateResolution {
1477 step: DoctorStep::pass(
1478 "local_registry",
1479 "Local registry",
1480 format!(
1481 "no local entry for key {} and root {}; using explicit root/key",
1482 candidate.key_address, candidate.root_account
1483 ),
1484 ),
1485 candidates: vec![candidate],
1486 });
1487 }
1488
1489 let descriptor = match (key_address, root_account) {
1490 (Some(k), Some(r)) => format!("key {k} for root {r}"),
1491 (Some(k), None) => format!("key {k}"),
1492 (None, Some(r)) => format!("root account {r}"),
1493 (None, None) => "the requested key".to_string(),
1494 };
1495 let hint = match (key_address, root_account) {
1496 (Some(_), None) => "pass --root-account to diagnose an explicit key/root pair",
1497 (None, Some(_)) => "pass KEY_ADDRESS to diagnose a key without a local registry entry",
1498 _ => "run `cast tempo login` or add the key to ~/.tempo/wallet/keys.toml",
1499 };
1500 return Err(DoctorStep::fail(
1501 "local_registry",
1502 "Local registry",
1503 format!("no entry for {descriptor} in {}", tempo_keys_path_display()),
1504 hint,
1505 ));
1506 }
1507
1508 let count = matches.len();
1509 let mut candidates: Vec<DoctorCandidate> =
1510 matches.into_iter().map(DoctorCandidate::from_entry).collect();
1511 if let Some(candidate) = explicit_candidate() {
1512 candidates.push(candidate);
1513 }
1514
1515 Ok(LocalCandidateResolution {
1516 step: DoctorStep::pass(
1517 "local_registry",
1518 "Local registry",
1519 format!("{count} candidate(s) in {}", tempo_keys_path_display()),
1520 ),
1521 candidates,
1522 })
1523}
1524
1525fn select_subject_for_chain(
1527 candidates: Vec<DoctorCandidate>,
1528 rpc_chain_id: u64,
1529 explicit_root: Option<Address>,
1530) -> Result<DoctorSubject, String> {
1531 let local_chain_ids: Vec<u64> = candidates.iter().filter_map(|e| e.chain_id).collect();
1532
1533 let chain_matched: Vec<DoctorCandidate> = candidates
1534 .into_iter()
1535 .filter(|entry| entry.chain_id.is_none_or(|chain_id| chain_id == rpc_chain_id))
1536 .collect();
1537
1538 if chain_matched.is_empty() {
1539 return Err(format!(
1540 "no local entry matches RPC chain id {rpc_chain_id} (local entries on {local_chain_ids:?})"
1541 ));
1542 }
1543
1544 if explicit_root.is_none()
1546 && chain_matched.iter().any(|entry| entry.root_account != chain_matched[0].root_account)
1547 {
1548 return Err(
1549 "multiple local entries match this chain across different root accounts; pass --root-account"
1550 .to_string(),
1551 );
1552 }
1553
1554 let has_explicit = chain_matched.iter().any(|entry| entry.explicit);
1555
1556 let preferred_idx = chain_matched
1559 .iter()
1560 .position(DoctorCandidate::is_passkey_with_inline_key)
1561 .or_else(|| chain_matched.iter().position(DoctorCandidate::has_inline_key))
1562 .unwrap_or(0);
1563 let entry = chain_matched.into_iter().nth(preferred_idx).expect("non-empty");
1564
1565 Ok(DoctorSubject {
1566 root_account: entry.root_account,
1567 key_address: entry.key_address,
1568 entry: entry.entry,
1569 explicit: has_explicit,
1570 })
1571}
1572
1573fn check_local_signing_readiness(subject: &DoctorSubject) -> DoctorStep {
1575 let Some(entry) = subject.entry.as_ref() else {
1576 return DoctorStep::warn(
1577 "local_signing",
1578 "Local signing",
1579 "not verified; using explicit root/key without a local registry entry",
1580 "pass --tempo.access-key in the send command or add this key to ~/.tempo/wallet/keys.toml",
1581 );
1582 };
1583
1584 if entry.has_inline_key() {
1585 return DoctorStep::pass(
1586 "local_signing",
1587 "Local signing",
1588 format!("inline {} key available", key_type_name(&entry.key_type)),
1589 );
1590 }
1591
1592 if subject.explicit {
1593 return DoctorStep::warn(
1594 "local_signing",
1595 "Local signing",
1596 "local entry has no inline access-key private key; explicit root/key can still use --tempo.access-key",
1597 "pass --tempo.access-key in the send command or refresh the local key material",
1598 );
1599 }
1600
1601 DoctorStep::fail(
1602 "local_signing",
1603 "Local signing",
1604 "local entry has no inline access-key private key",
1605 "run `cast tempo login` again, restore the key material, or pass --tempo.access-key when sending",
1606 )
1607}
1608
1609fn validate_pending_key_authorization(
1610 subject: &DoctorSubject,
1611 rpc_chain_id: u64,
1612 chain_timestamp: &ChainTimestamp,
1613) -> Result<ValidKeyAuthorization, DoctorStep> {
1614 let Some(entry) = subject.entry.as_ref() else {
1615 return Err(DoctorStep::fail(
1616 "key_registration",
1617 "Key registration",
1618 format!(
1619 "key {} is not registered for root account {}",
1620 subject.key_address, subject.root_account
1621 ),
1622 "authorize the key with `cast keychain authorize <KEY>` or add a local key_authorization",
1623 ));
1624 };
1625
1626 let Some(raw) = entry.key_authorization.as_deref().filter(|raw| !raw.trim().is_empty()) else {
1627 return Err(DoctorStep::fail(
1628 "key_registration",
1629 "Key registration",
1630 format!(
1631 "key {} is not registered for root account {}",
1632 subject.key_address, subject.root_account
1633 ),
1634 "authorize the key with `cast keychain authorize <KEY>` or refresh the local key_authorization",
1635 ));
1636 };
1637
1638 let signed: SignedKeyAuthorization = tempo::decode_key_authorization(raw).map_err(|err| {
1639 DoctorStep::fail(
1640 "key_registration",
1641 "Key registration",
1642 format!("local key_authorization could not be decoded: {err}"),
1643 "refresh the access key with `cast tempo login`",
1644 )
1645 })?;
1646 let auth = &signed.authorization;
1647
1648 if auth.key_id != subject.key_address {
1649 return Err(DoctorStep::fail(
1650 "key_registration",
1651 "Key registration",
1652 format!(
1653 "local key_authorization is for key {}, expected {}",
1654 auth.key_id, subject.key_address
1655 ),
1656 "refresh the access key for this root/key pair",
1657 ));
1658 }
1659
1660 if auth.chain_id != rpc_chain_id {
1661 return Err(DoctorStep::fail(
1662 "key_registration",
1663 "Key registration",
1664 format!(
1665 "local key_authorization is for chain {}, RPC is chain {}",
1666 auth.chain_id, rpc_chain_id
1667 ),
1668 "use the RPC for the chain the authorization was created on",
1669 ));
1670 }
1671
1672 if !key_type_matches_authorization(&entry.key_type, &auth.key_type) {
1673 return Err(DoctorStep::fail(
1674 "key_registration",
1675 "Key registration",
1676 format!(
1677 "local key type {} does not match key_authorization type {}",
1678 key_type_label(&entry.key_type),
1679 auth_signature_type_label(&auth.key_type)
1680 ),
1681 "refresh the local key entry so its key material and authorization agree",
1682 ));
1683 }
1684
1685 if let Some(expiry) = auth.expiry
1686 && let Some(chain_timestamp) = chain_timestamp.timestamp()
1687 && expiry.get() <= chain_timestamp
1688 {
1689 return Err(DoctorStep::fail(
1690 "key_registration",
1691 "Key registration",
1692 format!(
1693 "local key_authorization expired {}",
1694 format_relative_timestamp_from(expiry.get(), chain_timestamp)
1695 ),
1696 "refresh the access key to get a later key_authorization expiry",
1697 ));
1698 }
1699
1700 match signed.recover_signer() {
1701 Ok(recovered) if recovered == subject.root_account => {}
1702 Ok(recovered) => {
1703 return Err(DoctorStep::fail(
1704 "key_registration",
1705 "Key registration",
1706 format!(
1707 "local key_authorization recovers signer {recovered}, expected root {}",
1708 subject.root_account
1709 ),
1710 "refresh the authorization with the correct root account",
1711 ));
1712 }
1713 Err(err) => {
1714 return Err(DoctorStep::fail(
1715 "key_registration",
1716 "Key registration",
1717 format!("local key_authorization signature could not be verified: {err}"),
1718 "refresh the access key with `cast tempo login`",
1719 ));
1720 }
1721 }
1722
1723 let expiry = auth
1724 .expiry
1725 .map(|expiry| {
1726 let relative = chain_timestamp
1727 .timestamp()
1728 .map(|timestamp| format_relative_timestamp_from(expiry.get(), timestamp))
1729 .unwrap_or_else(|| format_relative_timestamp(expiry.get()));
1730 format!("{} ({})", relative, format_timestamp_iso(expiry.get()))
1731 })
1732 .unwrap_or_else(|| "never expires".to_string());
1733 let witness = auth.witness().map(|witness| format!(", witness {witness}")).unwrap_or_default();
1734 let detail = format!(
1735 "not on-chain; local key_authorization can provision atomically, type {}, expiry {}{}",
1736 auth_signature_type_label(&auth.key_type),
1737 expiry,
1738 witness
1739 );
1740
1741 Ok(ValidKeyAuthorization { signed, detail })
1742}
1743
1744async fn fetch_chain_timestamp<P>(provider: &P) -> ChainTimestamp
1745where
1746 P: Provider<TempoNetwork>,
1747{
1748 match provider.get_block(BlockId::latest()).await {
1749 Ok(Some(block)) => ChainTimestamp::Known(block.header.timestamp()),
1750 Ok(None) => ChainTimestamp::Unknown {
1751 detail: "latest block not found; chain timestamp unavailable".to_string(),
1752 hint: "verify the RPC can serve latest block data",
1753 },
1754 Err(err) => ChainTimestamp::Unknown {
1755 detail: format!("latest block query failed: {err}"),
1756 hint: "validity windows and expiries could not be checked against chain time",
1757 },
1758 }
1759}
1760
1761fn check_key_expiry(expiry: u64, chain_timestamp: &ChainTimestamp) -> DoctorStep {
1762 if expiry == u64::MAX {
1763 return DoctorStep::pass("expiry", "Expiry", "never expires");
1764 }
1765
1766 let Some(chain_timestamp) = chain_timestamp.timestamp() else {
1767 return chain_timestamp.unavailable_step("expiry", "Expiry", "key expiry not checked");
1768 };
1769
1770 if expiry <= chain_timestamp {
1771 DoctorStep::fail(
1772 "expiry",
1773 "Expiry",
1774 format!("expired {}", format_relative_timestamp_from(expiry, chain_timestamp)),
1775 "authorize a new key with a later expiry",
1776 )
1777 } else {
1778 DoctorStep::pass(
1779 "expiry",
1780 "Expiry",
1781 format!(
1782 "{} ({})",
1783 format_relative_timestamp_from(expiry, chain_timestamp),
1784 format_timestamp_iso(expiry)
1785 ),
1786 )
1787 }
1788}
1789
1790fn check_authorization_expiry(
1791 signed: &SignedKeyAuthorization,
1792 chain_timestamp: &ChainTimestamp,
1793) -> DoctorStep {
1794 let Some(expiry) = signed.authorization.expiry else {
1795 return DoctorStep::pass("expiry", "Expiry", "key_authorization never expires");
1796 };
1797
1798 let Some(chain_timestamp) = chain_timestamp.timestamp() else {
1799 return chain_timestamp.unavailable_step(
1800 "expiry",
1801 "Expiry",
1802 "key_authorization expiry not checked",
1803 );
1804 };
1805
1806 let expiry = expiry.get();
1807 if expiry <= chain_timestamp {
1808 DoctorStep::fail(
1809 "expiry",
1810 "Expiry",
1811 format!(
1812 "key_authorization expired {}",
1813 format_relative_timestamp_from(expiry, chain_timestamp)
1814 ),
1815 "refresh the access key to get a later key_authorization expiry",
1816 )
1817 } else {
1818 DoctorStep::pass(
1819 "expiry",
1820 "Expiry",
1821 format!(
1822 "key_authorization {} ({})",
1823 format_relative_timestamp_from(expiry, chain_timestamp),
1824 format_timestamp_iso(expiry)
1825 ),
1826 )
1827 }
1828}
1829
1830async fn check_hardfork<P>(provider: &P) -> (DoctorStep, Option<bool>)
1831where
1832 P: Provider<TempoNetwork>,
1833{
1834 match is_tempo_hardfork_active(provider, TempoHardfork::T3).await {
1835 Ok(true) => (DoctorStep::pass("hardfork", "Hardfork", "Tempo T3 active"), Some(true)),
1836 Ok(false) => (
1837 DoctorStep::pass("hardfork", "Hardfork", "pre-T3; TIP-1011 scopes not enforced"),
1838 Some(false),
1839 ),
1840 Err(err) => (
1841 DoctorStep::warn(
1842 "hardfork",
1843 "Hardfork",
1844 format!("could not determine Tempo T3 activation: {err}"),
1845 "TIP-1011 allowed-call and T3 spending-period checks will be skipped",
1846 ),
1847 None,
1848 ),
1849 }
1850}
1851
1852async fn check_spending_limits<P>(
1854 provider: &P,
1855 subject: &DoctorSubject,
1856 info: &KeyInfo,
1857 fee_token: Address,
1858 is_t3: Option<bool>,
1859) -> DoctorStep
1860where
1861 P: Provider<TempoNetwork>,
1862{
1863 let Some(is_t3) = is_t3 else {
1864 return DoctorStep::warn(
1865 "spending_limits",
1866 "Spending limits",
1867 "skipped; hardfork unknown",
1868 "retry against an RPC that reports Tempo hardfork activation",
1869 );
1870 };
1871
1872 if !info.enforceLimits {
1873 return DoctorStep::pass(
1874 "spending_limits",
1875 "Spending limits",
1876 "limits not enforced for this key",
1877 );
1878 }
1879
1880 let local_limits = subject.entry.as_ref().map(|entry| entry.limits.as_slice()).unwrap_or(&[]);
1881
1882 let mut tokens: Vec<Address> = local_limits.iter().map(|l| l.currency).collect();
1884 if !tokens.contains(&fee_token) {
1885 tokens.push(fee_token);
1886 }
1887
1888 let mut lines: Vec<String> = Vec::new();
1889 let mut any_zero = false;
1890
1891 for token in tokens {
1892 let configured = local_limits.iter().find(|l| l.currency == token).map(|l| l.limit.clone());
1893
1894 let (remaining, period_end) = if is_t3 {
1895 match provider
1896 .get_keychain_remaining_limit_with_period(
1897 subject.root_account,
1898 subject.key_address,
1899 token,
1900 )
1901 .await
1902 {
1903 Ok(r) => (r.remaining, Some(r.periodEnd)),
1904 Err(err) => {
1905 return DoctorStep::warn(
1906 "spending_limits",
1907 "Spending limits",
1908 format!("{} query failed: {err}", address_label(token)),
1909 "verify the AccountKeychain precompile is reachable",
1910 );
1911 }
1912 }
1913 } else {
1914 match provider
1915 .account_keychain()
1916 .getRemainingLimit(subject.root_account, subject.key_address, token)
1917 .call()
1918 .await
1919 {
1920 Ok(r) => (r, None),
1921 Err(err) => {
1922 return DoctorStep::warn(
1923 "spending_limits",
1924 "Spending limits",
1925 format!("{} query failed: {err}", address_label(token)),
1926 "verify the AccountKeychain precompile is reachable",
1927 );
1928 }
1929 }
1930 };
1931
1932 if remaining.is_zero() {
1933 any_zero = true;
1934 }
1935
1936 let configured_str = configured.as_deref().unwrap_or("?");
1937 let period_str = period_end
1938 .and_then(|pe| (pe != 0).then(|| format!(" ({})", format_period_end(pe))))
1939 .unwrap_or_default();
1940 lines.push(format!(
1941 "{} remaining {} / {}{}",
1942 address_label(token),
1943 remaining,
1944 configured_str,
1945 period_str
1946 ));
1947 }
1948
1949 let detail = lines.join("; ");
1950 if any_zero {
1951 DoctorStep::warn(
1952 "spending_limits",
1953 "Spending limits",
1954 detail,
1955 "raise the limit (e.g. `cast keychain ul ...`) or wait for the window reset",
1956 )
1957 } else {
1958 DoctorStep::pass("spending_limits", "Spending limits", detail)
1959 }
1960}
1961
1962fn check_authorization_spending_limits(
1963 signed: &SignedKeyAuthorization,
1964 fee_token: Address,
1965 is_t3: Option<bool>,
1966) -> DoctorStep {
1967 let auth = &signed.authorization;
1968
1969 if is_t3.is_none() && auth.has_periodic_limits() {
1970 return DoctorStep::warn(
1971 "spending_limits",
1972 "Spending limits",
1973 "skipped; hardfork unknown and key_authorization uses periodic limits",
1974 "retry against an RPC that reports Tempo hardfork activation",
1975 );
1976 }
1977
1978 if matches!(is_t3, Some(false)) && !auth.is_legacy_compatible() {
1979 return DoctorStep::fail(
1980 "spending_limits",
1981 "Spending limits",
1982 "key_authorization uses T3-only limits or call scopes on a pre-T3 chain",
1983 "use a T3 RPC or refresh the authorization with legacy-compatible restrictions",
1984 );
1985 }
1986
1987 match auth.limits.as_deref() {
1988 None => DoctorStep::pass(
1989 "spending_limits",
1990 "Spending limits",
1991 "limits not enforced by key_authorization",
1992 ),
1993 Some([]) => DoctorStep::warn(
1994 "spending_limits",
1995 "Spending limits",
1996 "key_authorization allows no token spending",
1997 "refresh the access key with spending limits if the transaction spends TIP-20 tokens",
1998 ),
1999 Some(limits) => {
2000 let detail = format_authorization_limits(limits, fee_token);
2001 if !limits.iter().any(|limit| limit.token == fee_token) {
2002 DoctorStep::warn(
2003 "spending_limits",
2004 "Spending limits",
2005 detail,
2006 "refresh the access key with a limit for the selected fee token",
2007 )
2008 } else if limits.iter().any(|limit| limit.token == fee_token && limit.limit.is_zero()) {
2009 DoctorStep::warn(
2010 "spending_limits",
2011 "Spending limits",
2012 detail,
2013 "raise the fee-token limit before sending with this authorization",
2014 )
2015 } else {
2016 DoctorStep::pass("spending_limits", "Spending limits", detail)
2017 }
2018 }
2019 }
2020}
2021
2022async fn check_allowed_calls<P>(
2024 provider: &P,
2025 subject: &DoctorSubject,
2026 is_t3: Option<bool>,
2027 to: Option<Address>,
2028 selector: Option<[u8; 4]>,
2029 recipient: Option<Address>,
2030) -> DoctorStep
2031where
2032 P: Provider<TempoNetwork>,
2033{
2034 let Some(is_t3) = is_t3 else {
2035 return DoctorStep::warn(
2036 "allowed_calls",
2037 "Allowed calls",
2038 "skipped; hardfork unknown",
2039 "retry against an RPC that reports Tempo hardfork activation",
2040 );
2041 };
2042
2043 if !is_t3 {
2044 return DoctorStep::pass(
2045 "allowed_calls",
2046 "Allowed calls",
2047 "TIP-1011 not enforced before T3",
2048 );
2049 }
2050
2051 let allowed = match provider
2052 .account_keychain()
2053 .getAllowedCalls(subject.root_account, subject.key_address)
2054 .call()
2055 .await
2056 {
2057 Ok(a) => a,
2058 Err(err) => {
2059 return DoctorStep::warn(
2060 "allowed_calls",
2061 "Allowed calls",
2062 format!("getAllowedCalls failed: {err}"),
2063 "verify the AccountKeychain precompile is reachable",
2064 );
2065 }
2066 };
2067
2068 if !allowed.isScoped {
2069 return DoctorStep::pass("allowed_calls", "Allowed calls", "any call permitted");
2070 }
2071
2072 diagnose_allowed_scopes(&allowed.scopes, to, selector, recipient)
2073}
2074
2075fn diagnose_allowed_scopes(
2076 scopes: &[CallScope],
2077 to: Option<Address>,
2078 selector: Option<[u8; 4]>,
2079 recipient: Option<Address>,
2080) -> DoctorStep {
2081 if scopes.is_empty() {
2082 let detail = "scoped, but no targets permitted";
2083 return if to.is_some() && selector.is_some() {
2084 DoctorStep::fail(
2085 "allowed_calls",
2086 "Allowed calls",
2087 detail,
2088 "widen the policy with `cast keychain policy add-call ...`",
2089 )
2090 } else {
2091 DoctorStep::warn(
2092 "allowed_calls",
2093 "Allowed calls",
2094 detail,
2095 "widen the policy with `cast keychain policy add-call ...`",
2096 )
2097 };
2098 }
2099
2100 let Some(to) = to else {
2101 return DoctorStep::pass(
2102 "allowed_calls",
2103 "Allowed calls",
2104 format!(
2105 "scoped to {} target(s); pass --to/--selector to test a specific call",
2106 scopes.len()
2107 ),
2108 );
2109 };
2110
2111 let Some(selector) = selector else {
2112 return if scopes.iter().any(|s| s.target == to) {
2114 DoctorStep::pass(
2115 "allowed_calls",
2116 "Allowed calls",
2117 format!("target {to} is in scope; pass --selector to test the function"),
2118 )
2119 } else {
2120 DoctorStep::warn(
2121 "allowed_calls",
2122 "Allowed calls",
2123 format!("target {to} not in any allowed scope"),
2124 "widen the policy with `cast keychain policy add-call ...`",
2125 )
2126 };
2127 };
2128
2129 match match_allowed_call(scopes, to, selector, recipient) {
2130 AllowedCallMatch::Allowed(detail) => {
2131 DoctorStep::pass("allowed_calls", "Allowed calls", detail)
2132 }
2133 AllowedCallMatch::Denied(reason) => DoctorStep::fail(
2134 "allowed_calls",
2135 "Allowed calls",
2136 reason,
2137 "widen the policy with `cast keychain policy add-call ...`",
2138 ),
2139 AllowedCallMatch::RecipientRestricted(recipients) => DoctorStep::pass(
2140 "allowed_calls",
2141 "Allowed calls",
2142 format!(
2143 "selector {} on {} allowed only for {}; pass --recipient to verify exact match",
2144 format_selector(&selector),
2145 address_label_with_address(to),
2146 format_recipients(&recipients)
2147 ),
2148 ),
2149 }
2150}
2151
2152fn check_authorization_allowed_calls(
2153 signed: &SignedKeyAuthorization,
2154 is_t3: Option<bool>,
2155 to: Option<Address>,
2156 selector: Option<[u8; 4]>,
2157 recipient: Option<Address>,
2158) -> DoctorStep {
2159 let auth = &signed.authorization;
2160
2161 let Some(is_t3) = is_t3 else {
2162 return DoctorStep::warn(
2163 "allowed_calls",
2164 "Allowed calls",
2165 "skipped; hardfork unknown",
2166 "retry against an RPC that reports Tempo hardfork activation",
2167 );
2168 };
2169
2170 if !is_t3 {
2171 return DoctorStep::pass(
2172 "allowed_calls",
2173 "Allowed calls",
2174 "TIP-1011 not enforced before T3",
2175 );
2176 }
2177
2178 let Some(scopes) = auth.allowed_calls.as_deref() else {
2179 return DoctorStep::pass(
2180 "allowed_calls",
2181 "Allowed calls",
2182 "any call permitted by key_authorization",
2183 );
2184 };
2185
2186 let scopes: Vec<CallScope> = scopes.iter().cloned().map(Into::into).collect();
2187 diagnose_allowed_scopes(&scopes, to, selector, recipient)
2188}
2189
2190fn match_allowed_call(
2192 scopes: &[CallScope],
2193 to: Address,
2194 selector: [u8; 4],
2195 recipient: Option<Address>,
2196) -> AllowedCallMatch {
2197 let matching_scopes: Vec<_> = scopes.iter().filter(|scope| scope.target == to).collect();
2198 if matching_scopes.is_empty() {
2199 return AllowedCallMatch::Denied(format!("target {to} not in any allowed scope"));
2200 }
2201
2202 if matching_scopes.iter().any(|scope| scope.selectorRules.is_empty()) {
2203 return AllowedCallMatch::Allowed(format!(
2204 "any selector on {} permitted",
2205 address_label_with_address(to)
2206 ));
2207 }
2208
2209 let matching_rules: Vec<_> = matching_scopes
2210 .iter()
2211 .flat_map(|scope| scope.selectorRules.iter())
2212 .filter(|rule| rule.selector.0 == selector)
2213 .collect();
2214
2215 if matching_rules.is_empty() {
2216 return AllowedCallMatch::Denied(format!(
2217 "selector {} on {} not in allowed list",
2218 format_selector(&selector),
2219 address_label_with_address(to)
2220 ));
2221 }
2222
2223 if matching_rules.iter().any(|rule| rule.recipients.is_empty()) {
2224 return AllowedCallMatch::Allowed(format!(
2225 "{} on {} permitted (any recipient)",
2226 format_selector(&selector),
2227 address_label_with_address(to)
2228 ));
2229 }
2230
2231 match recipient {
2232 Some(r) if matching_rules.iter().any(|rule| rule.recipients.contains(&r)) => {
2233 AllowedCallMatch::Allowed(format!(
2234 "{} on {} to recipient {} permitted",
2235 format_selector(&selector),
2236 address_label_with_address(to),
2237 r
2238 ))
2239 }
2240 Some(r) => AllowedCallMatch::Denied(format!(
2241 "recipient {r} not in allowed list for {} on {}",
2242 format_selector(&selector),
2243 address_label_with_address(to)
2244 )),
2245 None => {
2246 let mut recipients = Vec::new();
2247 for recipient in matching_rules.iter().flat_map(|rule| rule.recipients.iter().copied())
2248 {
2249 if !recipients.contains(&recipient) {
2250 recipients.push(recipient);
2251 }
2252 }
2253 AllowedCallMatch::RecipientRestricted(recipients)
2254 }
2255 }
2256}
2257
2258async fn check_fee_token_balance<P>(
2260 provider: &P,
2261 account: Address,
2262 fee_token: Address,
2263 owner_label: &'static str,
2264) -> DoctorStep
2265where
2266 P: Provider<TempoNetwork>,
2267{
2268 match ITIP20::new(fee_token, provider).balanceOf(account).call().await {
2269 Ok(balance) if balance.is_zero() => DoctorStep::warn(
2270 "fee_token_balance",
2271 "Fee-token balance",
2272 format!("0 {} on {owner_label} {}", address_label(fee_token), account),
2273 format!("fund {owner_label} {} with {}", account, address_label(fee_token)),
2274 ),
2275 Ok(balance) => DoctorStep::pass(
2276 "fee_token_balance",
2277 "Fee-token balance",
2278 format!("{} {} on {owner_label} {}", balance, address_label(fee_token), account),
2279 ),
2280 Err(err) => DoctorStep::warn(
2281 "fee_token_balance",
2282 "Fee-token balance",
2283 format!("balanceOf failed: {err}"),
2284 "verify --fee-token points to a TIP-20 token",
2285 ),
2286 }
2287}
2288
2289fn check_expiring_nonce(
2291 tempo: &TempoOpts,
2292 resolved_expires_at: Option<u64>,
2293 chain_timestamp: &ChainTimestamp,
2294) -> DoctorStep {
2295 if !tempo.expiring_nonce && tempo.valid_before.is_none() && tempo.valid_after.is_none() {
2296 return DoctorStep::pass("expiring_nonce", "Expiring nonce", "not requested");
2297 }
2298
2299 let Some(chain_timestamp) = chain_timestamp.timestamp() else {
2300 return chain_timestamp.unavailable_step(
2301 "expiring_nonce",
2302 "Expiring nonce",
2303 "validity window not checked",
2304 );
2305 };
2306
2307 check_expiring_nonce_window(tempo, resolved_expires_at, chain_timestamp)
2308}
2309
2310fn check_expiring_nonce_window(
2311 tempo: &TempoOpts,
2312 resolved_expires_at: Option<u64>,
2313 chain_timestamp: u64,
2314) -> DoctorStep {
2315 let valid_before = tempo.valid_before;
2316 let valid_after = tempo.valid_after;
2317 let missing_expiring_nonce =
2318 (valid_before.is_some() || valid_after.is_some()) && !tempo.expiring_nonce;
2319
2320 if let (Some(after), Some(before)) = (valid_after, valid_before)
2321 && after >= before
2322 {
2323 return DoctorStep::fail(
2324 "expiring_nonce",
2325 "Expiring nonce",
2326 format!("valid-after {after} is not before valid-before {before}"),
2327 "choose a valid window where valid-after < valid-before",
2328 );
2329 }
2330
2331 if let Some(before) = valid_before {
2332 if before <= chain_timestamp {
2333 return DoctorStep::fail(
2334 "expiring_nonce",
2335 "Expiring nonce",
2336 format!(
2337 "valid-before {} is expired at chain timestamp {}",
2338 format_timestamp_iso(before),
2339 chain_timestamp
2340 ),
2341 "use a later --tempo.valid-before or rerun with --tempo.expires",
2342 );
2343 }
2344
2345 let ttl = before - chain_timestamp;
2346 if ttl <= 3 {
2347 return DoctorStep::fail(
2348 "expiring_nonce",
2349 "Expiring nonce",
2350 format!(
2351 "valid-before must be more than 3s after chain timestamp {chain_timestamp}; current ttl is {ttl}s"
2352 ),
2353 "use a later --tempo.valid-before or rerun with --tempo.expires",
2354 );
2355 }
2356 if ttl <= 5 {
2357 return DoctorStep::warn(
2358 "expiring_nonce",
2359 "Expiring nonce",
2360 format!("valid for only {ttl}s at chain timestamp {chain_timestamp}"),
2361 "use a larger validity window before signing",
2362 );
2363 }
2364 if ttl > 30 {
2365 if resolved_expires_at.is_some() {
2366 return DoctorStep::warn(
2367 "expiring_nonce",
2368 "Expiring nonce",
2369 format!(
2370 "--tempo.expires resolved to a deadline {ttl}s ahead of chain timestamp {chain_timestamp}"
2371 ),
2372 "check local clock/RPC timestamp skew before relying on this deadline",
2373 );
2374 }
2375
2376 return DoctorStep::warn(
2377 "expiring_nonce",
2378 "Expiring nonce",
2379 format!(
2380 "valid-before is {ttl}s ahead of chain timestamp {chain_timestamp}; --tempo.expires caps this at 30s"
2381 ),
2382 "prefer --tempo.expires for bounded retry-safe sends",
2383 );
2384 }
2385 }
2386
2387 if let Some(after) = valid_after
2388 && after > chain_timestamp
2389 {
2390 return DoctorStep::warn(
2391 "expiring_nonce",
2392 "Expiring nonce",
2393 format!("transaction is not valid until {}", format_timestamp_iso(after)),
2394 "wait until valid-after or choose an earlier lower bound",
2395 );
2396 }
2397
2398 if missing_expiring_nonce {
2399 return DoctorStep::warn(
2400 "expiring_nonce",
2401 "Expiring nonce",
2402 "validity window set without --tempo.expiring-nonce",
2403 "use --tempo.expiring-nonce or --tempo.expires so nonce_key is set to the expiring lane",
2404 );
2405 }
2406
2407 let mut detail = format!("enabled at chain timestamp {chain_timestamp}");
2408 if let Some(before) = valid_before {
2409 detail.push_str(&format!(", valid-before {}", format_timestamp_iso(before)));
2410 }
2411 if let Some(after) = valid_after {
2412 detail.push_str(&format!(", valid-after {}", format_timestamp_iso(after)));
2413 }
2414 if let Some(expires_at) = resolved_expires_at {
2415 detail.push_str(&format!(
2416 ", --tempo.expires resolved to {}",
2417 format_timestamp_iso(expires_at)
2418 ));
2419 }
2420
2421 DoctorStep::pass("expiring_nonce", "Expiring nonce", detail)
2422}
2423
2424async fn check_sponsorship(tempo: &TempoOpts, sender: Address) -> SponsorshipDiagnosis {
2426 if tempo.print_sponsor_hash {
2427 return SponsorshipDiagnosis {
2428 step: DoctorStep::pass(
2429 "sponsorship",
2430 "Sponsorship",
2431 "--tempo.print-sponsor-hash requested, but doctor has no concrete tx payload",
2432 ),
2433 fee_payer: None,
2434 };
2435 }
2436
2437 if !tempo.has_sponsor_submission() {
2438 return SponsorshipDiagnosis {
2439 step: DoctorStep::pass("sponsorship", "Sponsorship", "not requested"),
2440 fee_payer: None,
2441 };
2442 }
2443
2444 let sponsor = match tempo.sponsor_config().await {
2445 Ok(Some(sponsor)) => sponsor,
2446 Ok(None) => {
2447 return SponsorshipDiagnosis {
2448 step: DoctorStep::pass("sponsorship", "Sponsorship", "not requested"),
2449 fee_payer: None,
2450 };
2451 }
2452 Err(err) => {
2453 return SponsorshipDiagnosis {
2454 step: DoctorStep::fail(
2455 "sponsorship",
2456 "Sponsorship",
2457 format!(
2458 "invalid sponsor config: {}",
2459 sanitize_sponsor_config_error(&err.to_string(), tempo)
2460 ),
2461 "pass --tempo.sponsor with either --tempo.sponsor-signer or --tempo.sponsor-sig",
2462 ),
2463 fee_payer: None,
2464 };
2465 }
2466 };
2467
2468 if sponsor.sponsor() == sender {
2469 return SponsorshipDiagnosis {
2470 step: DoctorStep::fail(
2471 "sponsorship",
2472 "Sponsorship",
2473 format!("sponsor {} equals transaction sender {sender}", sponsor.sponsor()),
2474 "use a different fee payer for sponsored transactions",
2475 ),
2476 fee_payer: Some(sponsor.sponsor()),
2477 };
2478 }
2479
2480 if tempo.sponsor_sig.is_some() {
2481 return SponsorshipDiagnosis {
2482 step: DoctorStep::warn(
2483 "sponsorship",
2484 "Sponsorship",
2485 format!("signature syntax parsed for sponsor {}", sponsor.sponsor()),
2486 "doctor cannot recover fee_payer_signature without the exact transaction digest",
2487 ),
2488 fee_payer: Some(sponsor.sponsor()),
2489 };
2490 }
2491
2492 SponsorshipDiagnosis {
2493 step: DoctorStep::pass(
2494 "sponsorship",
2495 "Sponsorship",
2496 format!("sponsor signer configured for {}", sponsor.sponsor()),
2497 ),
2498 fee_payer: Some(sponsor.sponsor()),
2499 }
2500}
2501
2502fn unix_timestamp_now() -> u64 {
2503 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()
2504}
2505
2506const fn key_type_matches_authorization(key_type: &KeyType, auth_type: &AuthSignatureType) -> bool {
2507 matches!(
2508 (key_type, auth_type),
2509 (KeyType::Secp256k1, AuthSignatureType::Secp256k1)
2510 | (KeyType::P256, AuthSignatureType::P256)
2511 | (KeyType::WebAuthn, AuthSignatureType::WebAuthn)
2512 )
2513}
2514
2515const fn auth_signature_type_label(t: &AuthSignatureType) -> &'static str {
2516 match t {
2517 AuthSignatureType::Secp256k1 => "Secp256k1",
2518 AuthSignatureType::P256 => "P256",
2519 AuthSignatureType::WebAuthn => "WebAuthn",
2520 }
2521}
2522
2523const fn auth_signature_type_name(t: &AuthSignatureType) -> &'static str {
2524 match t {
2525 AuthSignatureType::Secp256k1 => "secp256k1",
2526 AuthSignatureType::P256 => "p256",
2527 AuthSignatureType::WebAuthn => "webauthn",
2528 }
2529}
2530
2531fn format_authorization_limits(limits: &[AuthTokenLimit], fee_token: Address) -> String {
2532 let mut lines: Vec<String> = limits
2533 .iter()
2534 .map(|limit| {
2535 let period =
2536 if limit.period == 0 { String::new() } else { format!(" per {}s", limit.period) };
2537 format!("{} limit {}{}", address_label(limit.token), limit.limit, period)
2538 })
2539 .collect();
2540
2541 if !limits.iter().any(|limit| limit.token == fee_token) {
2542 lines.push(format!("{} not listed in key_authorization limits", address_label(fee_token)));
2543 }
2544
2545 lines.join("; ")
2546}
2547
2548fn sanitize_sponsor_config_error(message: &str, tempo: &TempoOpts) -> String {
2549 let mut sanitized = message.to_string();
2550 if let Some(spec) = tempo.sponsor_signer.as_deref()
2551 && spec.starts_with("private-key://")
2552 {
2553 sanitized = sanitized.replace(spec, "private-key://<redacted>");
2554 }
2555 redact_private_key_uri_tokens(&sanitized)
2556}
2557
2558fn redact_private_key_uri_tokens(message: &str) -> String {
2559 const PREFIX: &str = "private-key://";
2560 let mut redacted = String::with_capacity(message.len());
2561 let mut rest = message;
2562
2563 while let Some(idx) = rest.find(PREFIX) {
2564 redacted.push_str(&rest[..idx + PREFIX.len()]);
2565 redacted.push_str("<redacted>");
2566 let after_prefix = &rest[idx + PREFIX.len()..];
2567 let end = after_prefix
2568 .find(|c: char| c.is_whitespace() || matches!(c, '`' | '\'' | '"' | ',' | ';' | ')'))
2569 .unwrap_or(after_prefix.len());
2570 rest = &after_prefix[end..];
2571 }
2572
2573 redacted.push_str(rest);
2574 redacted
2575}
2576
2577fn finalize_doctor(steps: Vec<DoctorStep>, context: DoctorContext) -> Result<()> {
2579 let failure_count = steps.iter().filter(|s| s.status == DoctorStatus::Fail).count();
2580 let warning_count = steps.iter().filter(|s| s.status == DoctorStatus::Warn).count();
2581 let no_failures = failure_count == 0;
2582 let healthy = no_failures && warning_count == 0;
2583 let status = if failure_count > 0 {
2584 "fail"
2585 } else if warning_count > 0 {
2586 "warn"
2587 } else {
2588 "pass"
2589 };
2590
2591 if shell::is_json() {
2592 foundry_cli::json::print_json_success(serde_json::json!({
2593 "context": context,
2594 "steps": steps,
2595 "status": status,
2596 "no_failures": no_failures,
2597 "healthy": healthy,
2598 "warning_count": warning_count,
2599 "failure_count": failure_count,
2600 }))?;
2601 } else {
2602 for step in &steps {
2603 print_doctor_step(step)?;
2604 }
2605 sh_println!()?;
2606 if healthy {
2607 sh_println!("{} access-key signing path looks healthy", "✓".green())?;
2608 } else if no_failures {
2609 sh_println!("{} access-key signing path has warnings (see above)", "!".yellow())?;
2610 } else {
2611 sh_println!("{} access-key signing path has issues (see above)", "✗".red())?;
2612 }
2613 }
2614
2615 Ok(())
2616}
2617
2618fn print_doctor_step(step: &DoctorStep) -> Result<()> {
2619 let marker = match step.status {
2620 DoctorStatus::Pass => "✓".green().to_string(),
2621 DoctorStatus::Warn => "!".yellow().to_string(),
2622 DoctorStatus::Fail => "✗".red().to_string(),
2623 };
2624
2625 let label = format!("{:<22}", step.label);
2626 sh_println!("{marker} {label} {}", step.detail)?;
2627 if let Some(hint) = step.hint.as_deref() {
2628 sh_println!(" {} {}", "hint:".dim(), hint)?;
2629 }
2630 Ok(())
2631}
2632
2633#[allow(clippy::too_many_arguments)]
2635async fn run_authorize(
2636 key_address: Address,
2637 key_type: SignatureType,
2638 expiry: u64,
2639 enforce_limits: bool,
2640 limits: Vec<TokenLimit>,
2641 allowed_calls: Vec<CallScope>,
2642 witness: Option<B256>,
2643 tx_opts: TransactionOpts,
2644 send_tx: SendTxOpts,
2645) -> Result<()> {
2646 let enforce = enforce_limits || !limits.is_empty();
2647
2648 let config = send_tx.eth.load_config()?;
2649 let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
2650
2651 let is_t3 = is_tempo_hardfork_active(&provider, TempoHardfork::T3).await?;
2652 if witness.is_some() && !is_tempo_hardfork_active(&provider, TempoHardfork::T5).await? {
2653 eyre::bail!("--witness requires a Tempo T5-capable AccountKeychain RPC");
2654 }
2655
2656 let calldata = if is_t3 {
2657 let restrictions = KeyRestrictions {
2659 expiry,
2660 enforceLimits: enforce,
2661 limits,
2662 allowAnyCalls: allowed_calls.is_empty(),
2663 allowedCalls: allowed_calls,
2664 };
2665 if let Some(witness) = witness {
2666 authorizeKeyWithWitnessCall {
2667 keyId: key_address,
2668 signatureType: key_type,
2669 config: restrictions,
2670 witness,
2671 }
2672 .abi_encode()
2673 } else {
2674 authorizeKeyCall { keyId: key_address, signatureType: key_type, config: restrictions }
2675 .abi_encode()
2676 }
2677 } else {
2678 let legacy_limits: Vec<LegacyTokenLimit> = limits
2680 .into_iter()
2681 .map(|l| LegacyTokenLimit { token: l.token, amount: l.amount })
2682 .collect();
2683 legacyAuthorizeKeyCall {
2684 keyId: key_address,
2685 signatureType: key_type,
2686 expiry,
2687 enforceLimits: enforce,
2688 limits: legacy_limits,
2689 }
2690 .abi_encode()
2691 };
2692
2693 send_keychain_tx(calldata, tx_opts, &send_tx, None).await?;
2694 Ok(())
2695}
2696
2697fn run_key_auth_encode(args: KeyAuthArgs) -> Result<()> {
2698 let authorization = args.into_authorization()?;
2699 let encoded = encode_key_authorization(&authorization);
2700
2701 if shell::is_json() {
2702 let json = serde_json::json!({
2703 "key_authorization": hex::encode_prefixed(&encoded),
2704 "signature_hash": authorization.signature_hash().to_string(),
2705 "rlp_length": encoded.len(),
2706 "witness": authorization.witness().map(|witness| witness.to_string()),
2707 });
2708 sh_println!("{}", serde_json::to_string_pretty(&json)?)?;
2709 } else {
2710 sh_println!("{}", hex::encode_prefixed(&encoded))?;
2711 }
2712
2713 Ok(())
2714}
2715
2716async fn run_key_auth_sign(args: KeyAuthArgs, wallet: WalletOpts) -> Result<()> {
2717 let authorization = args.into_authorization()?;
2718 let authorized_key_type = auth_signature_type_name(&authorization.key_type);
2719 let signature_hash = authorization.signature_hash();
2720 let (signer, tempo_access_key) = wallet.maybe_signer().await?;
2721 if tempo_access_key.is_some() {
2722 eyre::bail!(
2723 "Tempo access keys cannot sign key authorizations; use a persistent root signer"
2724 );
2725 }
2726 let signer = signer.ok_or_else(|| {
2727 eyre::eyre!(
2728 "a persistent root signer is required to sign key authorizations; pass a signer with \
2729 --private-key, --keystore, Ledger, Trezor, AWS, GCP, or Turnkey"
2730 )
2731 })?;
2732 let signer_address = signer.address();
2733 let signature = signer.sign_hash(&signature_hash).await?;
2734 let signed = authorization.into_signed(PrimitiveSignature::Secp256k1(signature));
2735 let encoded = encode_key_authorization(&signed);
2736
2737 if shell::is_json() {
2738 let json = serde_json::json!({
2739 "signed_key_authorization": hex::encode_prefixed(&encoded),
2740 "signature_hash": signature_hash.to_string(),
2741 "rlp_length": encoded.len(),
2742 "signer": signer_address.to_string(),
2743 "authorized_key_type": authorized_key_type,
2744 "signature_type": "secp256k1",
2745 "witness": signed.authorization.witness().map(|witness| witness.to_string()),
2746 });
2747 sh_println!("{}", serde_json::to_string_pretty(&json)?)?;
2748 } else {
2749 sh_println!("{}", hex::encode_prefixed(&encoded))?;
2750 }
2751
2752 Ok(())
2753}
2754
2755fn encode_key_authorization<T: Encodable>(authorization: &T) -> Vec<u8> {
2756 let mut out = Vec::new();
2757 authorization.encode(&mut out);
2758 out
2759}
2760
2761impl KeyAuthArgs {
2762 fn into_authorization(self) -> Result<KeyAuthorization> {
2763 let (scopes, explicit_scopes_json) =
2764 if let Some(AuthScopesJson(json_scopes)) = self.scopes_json {
2765 (json_scopes, true)
2766 } else {
2767 (self.scope, false)
2768 };
2769
2770 let mut authorization =
2771 KeyAuthorization::unrestricted(self.chain_id, self.key_type, self.key_address);
2772
2773 if let Some(expiry) = self.expiry {
2774 if expiry == 0 {
2775 eyre::bail!("--expiry must be greater than zero");
2776 }
2777 authorization = authorization.with_expiry(expiry);
2778 }
2779
2780 if self.enforce_limits || !self.limits.is_empty() {
2781 authorization = authorization.with_limits(self.limits);
2782 }
2783
2784 if explicit_scopes_json || !scopes.is_empty() {
2785 authorization = authorization.with_allowed_calls(scopes);
2786 }
2787
2788 if let Some(witness) = self.witness {
2789 authorization = authorization.with_witness(witness);
2790 }
2791
2792 Ok(authorization)
2793 }
2794}
2795
2796async fn run_revoke(
2798 key_address: Address,
2799 tx_opts: TransactionOpts,
2800 send_tx: SendTxOpts,
2801) -> Result<()> {
2802 let calldata = IAccountKeychain::revokeKeyCall { keyId: key_address }.abi_encode();
2803 send_keychain_tx(calldata, tx_opts, &send_tx, None).await?;
2804 Ok(())
2805}
2806
2807async fn run_burn_witness(
2809 witness: B256,
2810 tx_opts: TransactionOpts,
2811 send_tx: SendTxOpts,
2812) -> Result<()> {
2813 let config = send_tx.eth.load_config()?;
2814 let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
2815 if !is_tempo_hardfork_active(&provider, TempoHardfork::T5).await? {
2816 eyre::bail!("burn-witness requires a Tempo T5-capable AccountKeychain RPC");
2817 }
2818
2819 let calldata = IAccountKeychain::burnKeyAuthorizationWitnessCall { witness }.abi_encode();
2820 send_keychain_tx(calldata, tx_opts, &send_tx, None).await?;
2821 Ok(())
2822}
2823
2824async fn run_is_witness_burned(account: Address, witness: B256, rpc: RpcOpts) -> Result<()> {
2826 let config = rpc.load_config()?;
2827 let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
2828 if !is_tempo_hardfork_active(&provider, TempoHardfork::T5).await? {
2829 eyre::bail!("is-witness-burned requires a Tempo T5-capable AccountKeychain RPC");
2830 }
2831
2832 let burned = provider
2833 .account_keychain()
2834 .isKeyAuthorizationWitnessBurned(account, witness)
2835 .call()
2836 .await?;
2837
2838 if shell::is_json() {
2839 let json = serde_json::json!({
2840 "account": account.to_string(),
2841 "witness": witness.to_string(),
2842 "burned": burned,
2843 });
2844 sh_println!("{}", serde_json::to_string_pretty(&json)?)?;
2845 } else {
2846 sh_println!("{burned}")?;
2847 }
2848
2849 Ok(())
2850}
2851
2852async fn run_remaining_limit(
2854 wallet_address: Address,
2855 key_address: Address,
2856 token: Address,
2857 rpc: RpcOpts,
2858) -> Result<()> {
2859 let config = rpc.load_config()?;
2860 let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
2861
2862 let remaining: U256 = if is_tempo_hardfork_active(&provider, TempoHardfork::T3).await? {
2863 provider.get_keychain_remaining_limit(wallet_address, key_address, token).await?
2864 } else {
2865 provider
2867 .account_keychain()
2868 .getRemainingLimit(wallet_address, key_address, token)
2869 .call()
2870 .await?
2871 };
2872
2873 if shell::is_json() {
2874 sh_println!("{}", serde_json::json!({ "remaining": remaining.to_string() }))?;
2875 } else {
2876 sh_println!("{remaining}")?;
2877 }
2878
2879 Ok(())
2880}
2881
2882async fn run_update_limit(
2884 key_address: Address,
2885 token: Address,
2886 new_limit: U256,
2887 tx_opts: TransactionOpts,
2888 send_tx: SendTxOpts,
2889) -> Result<()> {
2890 let calldata = IAccountKeychain::updateSpendingLimitCall {
2891 keyId: key_address,
2892 token,
2893 newLimit: new_limit,
2894 }
2895 .abi_encode();
2896 send_keychain_tx(calldata, tx_opts, &send_tx, None).await?;
2897 Ok(())
2898}
2899
2900async fn run_set_scope(
2902 key_address: Address,
2903 scopes: Vec<CallScope>,
2904 tx_opts: TransactionOpts,
2905 send_tx: SendTxOpts,
2906) -> Result<()> {
2907 let calldata =
2908 IAccountKeychain::setAllowedCallsCall { keyId: key_address, scopes }.abi_encode();
2909 send_keychain_tx(calldata, tx_opts, &send_tx, None).await?;
2910 Ok(())
2911}
2912
2913async fn run_remove_scope(
2915 key_address: Address,
2916 target: Address,
2917 tx_opts: TransactionOpts,
2918 send_tx: SendTxOpts,
2919) -> Result<()> {
2920 let calldata =
2921 IAccountKeychain::removeAllowedCallsCall { keyId: key_address, target }.abi_encode();
2922 send_keychain_tx(calldata, tx_opts, &send_tx, None).await?;
2923 Ok(())
2924}
2925
2926async fn run_policy_add_call(
2928 key_address: Address,
2929 root_account: Option<Address>,
2930 target: Address,
2931 selector: [u8; 4],
2932 recipients: Vec<Address>,
2933 tx_opts: TransactionOpts,
2934 send_tx: SendTxOpts,
2935) -> Result<()> {
2936 let metadata = resolve_key_metadata(key_address, root_account)?;
2937 let config = send_tx.eth.load_config()?;
2938 let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
2939
2940 if !is_tempo_hardfork_active(&provider, TempoHardfork::T3).await? {
2941 eyre::bail!("allowed-call policy editing requires the Tempo T3 hardfork");
2942 }
2943
2944 let allowed = provider
2945 .account_keychain()
2946 .getAllowedCalls(metadata.root_account, key_address)
2947 .call()
2948 .await?;
2949
2950 let new_rule = SelectorRule { selector: selector.into(), recipients };
2951 let existing_target = allowed
2952 .isScoped
2953 .then(|| allowed.scopes.into_iter().find(|scope| scope.target == target))
2954 .flatten();
2955
2956 let (target_scope, changed) = match existing_target {
2957 Some(mut scope) => {
2958 if scope.selectorRules.is_empty() {
2959 sh_warn!(
2960 "Allowed calls for {} already allow any selector; leaving wildcard scope unchanged",
2961 address_label_with_address(target)
2962 )?;
2963 }
2964 let changed = add_selector_rule_to_scope(&mut scope, new_rule);
2965 (scope, changed)
2966 }
2967 None => (CallScope { target, selectorRules: vec![new_rule] }, true),
2968 };
2969
2970 if !changed {
2971 if shell::is_json() {
2972 sh_println!(
2973 "{}",
2974 serde_json::json!({ "status": "already_present", "target": target.to_string() })
2975 )?;
2976 } else {
2977 sh_status!("Allowed call already present for {}", address_label_with_address(target))?;
2978 }
2979 return Ok(());
2980 }
2981
2982 let calldata =
2983 IAccountKeychain::setAllowedCallsCall { keyId: key_address, scopes: vec![target_scope] }
2984 .abi_encode();
2985 send_keychain_tx(calldata, tx_opts, &send_tx, None).await?;
2986 Ok(())
2987}
2988
2989async fn run_policy_set_limit(
2991 key_address: Address,
2992 token: Address,
2993 amount: U256,
2994 period: Option<u64>,
2995 tx_opts: TransactionOpts,
2996 send_tx: SendTxOpts,
2997) -> Result<()> {
2998 if period.is_some_and(|period| period != 0) {
2999 eyre::bail!(
3000 "--period is not supported by the current AccountKeychain updateSpendingLimit \
3001 precompile; periods can only be set when authorizing a key"
3002 );
3003 }
3004
3005 run_update_limit(key_address, token, amount, tx_opts, send_tx).await
3007}
3008
3009#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3010pub(crate) enum KeychainTxOutcome {
3011 Submitted,
3012 PrintedSponsorHash,
3013}
3014
3015pub(crate) enum KeychainRootSigner {
3016 Browser(BrowserSigner<TempoNetwork>),
3017 Wallet(Box<WalletSigner>),
3018}
3019
3020impl KeychainRootSigner {
3021 fn address(&self) -> Address {
3022 match self {
3023 Self::Browser(browser) => browser.address(),
3024 Self::Wallet(signer) => signer.address(),
3025 }
3026 }
3027}
3028
3029pub(crate) async fn resolve_keychain_root_signer(
3031 send_tx: &SendTxOpts,
3032 expected_from: Option<Address>,
3033 print_sponsor_hash: bool,
3034) -> Result<KeychainRootSigner> {
3035 let (signer, tempo_access_key) = send_tx.eth.wallet.maybe_signer().await?;
3036 if let Some(browser) = send_tx.browser.run::<TempoNetwork>().await? {
3037 ensure_root_sender(browser.address(), expected_from)?;
3038 return Ok(KeychainRootSigner::Browser(browser));
3039 }
3040
3041 if tempo_access_key.is_some() {
3042 eyre::bail!(
3043 "keychain policy changes must be signed by the root account; the selected `--from` \
3044 resolved to a Tempo access key. Use `--browser` for passkey roots, or pass a root \
3045 account signer with `--private-key`, `--keystore`, Ledger, Trezor, AWS, GCP, or Turnkey."
3046 );
3047 }
3048
3049 let signer = match signer {
3050 Some(s) => s,
3051 None if print_sponsor_hash => {
3052 eyre::bail!(
3053 "--tempo.print-sponsor-hash requires a root account signer, such as \
3054 --browser, --private-key, or --keystore"
3055 );
3056 }
3057 None => send_tx.eth.wallet.signer().await?,
3058 };
3059 ensure_root_sender(signer.address(), expected_from)?;
3060 Ok(KeychainRootSigner::Wallet(Box::new(signer)))
3061}
3062
3063pub(crate) async fn send_keychain_tx(
3065 calldata: Vec<u8>,
3066 tx_opts: TransactionOpts,
3067 send_tx: &SendTxOpts,
3068 expected_from: Option<Address>,
3069) -> Result<KeychainTxOutcome> {
3070 let root_signer =
3071 resolve_keychain_root_signer(send_tx, expected_from, tx_opts.tempo.print_sponsor_hash)
3072 .await?;
3073 send_keychain_tx_with_root_signer(calldata, tx_opts, send_tx, root_signer, || Ok(())).await
3074}
3075
3076pub(crate) async fn send_keychain_tx_with_root_signer(
3078 calldata: Vec<u8>,
3079 mut tx_opts: TransactionOpts,
3080 send_tx: &SendTxOpts,
3081 root_signer: KeychainRootSigner,
3082 before_submit: impl FnOnce() -> Result<()>,
3083) -> Result<KeychainTxOutcome> {
3084 let print_sponsor_hash = tx_opts.tempo.print_sponsor_hash;
3085 let expires_at = tx_opts.tempo.resolve_expires();
3086 let tempo_sponsor =
3087 if print_sponsor_hash { None } else { tx_opts.tempo.sponsor_config().await? };
3088
3089 let config = send_tx.eth.load_config()?;
3090 let timeout = send_tx.timeout.unwrap_or(config.transaction_timeout);
3091 let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
3092
3093 if let Some(interval) = send_tx.poll_interval {
3094 provider.client().set_poll_interval(Duration::from_secs(interval));
3095 }
3096
3097 let resolved_lane = resolve_lane(&mut tx_opts.tempo, &config.root)?;
3100
3101 let builder = CastTxBuilder::new(&provider, tx_opts, &config)
3102 .await?
3103 .with_to(Some(NameOrAddress::Address(ACCOUNT_KEYCHAIN_ADDRESS)))
3104 .await?
3105 .with_code_sig_and_args(None, Some(hex::encode_prefixed(&calldata)), vec![])
3106 .await?;
3107
3108 if print_sponsor_hash {
3109 let from = root_signer.address();
3110 let (tx, _) = builder.build(from).await?;
3111 let hash = tx
3112 .compute_sponsor_hash(from)
3113 .ok_or_else(|| eyre::eyre!("This network does not support sponsored transactions"))?;
3114 if shell::is_json() {
3115 sh_println!("{}", serde_json::json!({ "sponsor_hash": format!("{hash:?}") }))?;
3116 } else {
3117 sh_println!("{hash:?}")?;
3118 }
3119 return Ok(KeychainTxOutcome::PrintedSponsorHash);
3120 }
3121
3122 crate::tempo::print_expires(expires_at)?;
3123
3124 match root_signer {
3125 KeychainRootSigner::Browser(browser) => {
3126 let chain = builder.chain();
3127 let (mut tx, _) = builder.with_browser_wallet().build(browser.address()).await?;
3128 if chain.is_tempo()
3129 && let Some(gas) = tx.gas_limit()
3130 {
3131 tx.set_gas_limit(gas + TEMPO_BROWSER_GAS_BUFFER);
3132 }
3133 if let Some(sponsor) = &tempo_sponsor {
3134 sponsor.attach_and_print::<TempoNetwork>(&mut tx, browser.address()).await?;
3135 }
3136 print_resolved_fee_token_selection(Some(chain), tx.fee_token())?;
3137
3138 before_submit()?;
3139 let tx_hash = browser.send_transaction_via_browser(tx).await?;
3140 CastTxSender::new(&provider)
3141 .print_tx_result(tx_hash, send_tx.cast_async, send_tx.confirmations, timeout)
3142 .await?;
3143 }
3144 KeychainRootSigner::Wallet(signer) => {
3145 let from = signer.address();
3146 let chain = builder.chain();
3147 let (mut tx, _) = builder.build(from).await?;
3148 maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?;
3149 if let Some(sponsor) = &tempo_sponsor {
3150 sponsor.attach_and_print::<TempoNetwork>(&mut tx, from).await?;
3151 }
3152
3153 before_submit()?;
3154 let wallet = EthereumWallet::from(*signer);
3155 let provider = AlloyProviderBuilder::<_, _, TempoNetwork>::default()
3156 .wallet(wallet)
3157 .connect_provider(&provider);
3158
3159 cast_send(
3160 provider,
3161 tx,
3162 Some(chain),
3163 send_tx.cast_async,
3164 send_tx.sync,
3165 send_tx.confirmations,
3166 timeout,
3167 )
3168 .await?;
3169 }
3170 }
3171
3172 Ok(KeychainTxOutcome::Submitted)
3173}
3174
3175fn ensure_root_sender(actual: Address, expected: Option<Address>) -> Result<()> {
3177 if let Some(expected) = expected
3178 && actual != expected
3179 {
3180 eyre::bail!(
3181 "AccountKeychain transaction must be signed by root account {expected}; resolved signer is {actual}"
3182 );
3183 }
3184 Ok(())
3185}
3186
3187#[derive(Debug, Deserialize)]
3188#[serde(rename_all = "camelCase")]
3189struct AnvilNodeInfo {
3190 hard_fork: Option<String>,
3191 network: Option<String>,
3192}
3193
3194async fn is_tempo_hardfork_active<P>(provider: &P, hardfork: TempoHardfork) -> Result<bool>
3195where
3196 P: Provider<TempoNetwork>,
3197{
3198 match provider.is_hardfork_active(hardfork).await {
3199 Ok(active) => Ok(active),
3200 Err(err) if is_rpc_method_not_found(&err) => {
3201 match anvil_tempo_hardfork_active(provider, hardfork).await {
3202 Ok(Some(active)) => Ok(active),
3203 _ => Err(err.into()),
3204 }
3205 }
3206 Err(err) => Err(err.into()),
3207 }
3208}
3209
3210async fn anvil_tempo_hardfork_active<P>(
3211 provider: &P,
3212 hardfork: TempoHardfork,
3213) -> Result<Option<bool>, TransportError>
3214where
3215 P: Provider<TempoNetwork>,
3216{
3217 let info = provider.raw_request::<_, AnvilNodeInfo>("anvil_nodeInfo".into(), ()).await?;
3218 Ok(active_from_anvil_node_info(&info, hardfork))
3219}
3220
3221fn active_from_anvil_node_info(info: &AnvilNodeInfo, hardfork: TempoHardfork) -> Option<bool> {
3222 (info.network.as_deref() == Some("tempo")).then(|| {
3223 info.hard_fork
3224 .as_deref()
3225 .and_then(|active_hardfork| active_hardfork.parse::<TempoHardfork>().ok())
3226 .is_some_and(|active_hardfork| active_hardfork >= hardfork)
3227 })
3228}
3229
3230fn is_rpc_method_not_found(err: &TransportError) -> bool {
3231 err.as_error_resp().is_some_and(|payload| payload.code == -32601)
3232}
3233
3234fn resolve_key_metadata(
3235 key_address: Address,
3236 root_account: Option<Address>,
3237) -> Result<KeyMetadata> {
3238 let keys_file = read_tempo_keys_file();
3239
3240 if let Some(root_account) = root_account {
3241 if let Some(keys_file) = keys_file.as_ref()
3242 && let Some(entry) = keys_file.keys.iter().find(|entry| {
3243 entry.wallet_address == root_account
3244 && key_entry_effective_key(entry) == key_address
3245 })
3246 {
3247 return Ok(key_metadata_from_entry(entry));
3248 }
3249
3250 return Ok(KeyMetadata { root_account, key_type: None, limits: Vec::new() });
3251 }
3252
3253 let Some(keys_file) = keys_file.as_ref() else {
3254 eyre::bail!(
3255 "key {key_address} was not found because the local keys file could not be read at {}; pass --root-account",
3256 tempo_keys_path_display()
3257 );
3258 };
3259
3260 let matches: Vec<_> = keys_file
3261 .keys
3262 .iter()
3263 .filter(|entry| key_entry_effective_key(entry) == key_address)
3264 .collect();
3265
3266 if matches.is_empty() {
3267 eyre::bail!(
3268 "key {key_address} was not found in {}; pass --root-account",
3269 tempo_keys_path_display()
3270 );
3271 }
3272
3273 let root_account = matches[0].wallet_address;
3274 if matches.iter().any(|entry| entry.wallet_address != root_account) {
3275 eyre::bail!(
3276 "key {key_address} matches multiple root accounts in {}; pass --root-account",
3277 tempo_keys_path_display()
3278 );
3279 }
3280
3281 let entry =
3282 matches.iter().copied().find(|entry| !entry.limits.is_empty()).unwrap_or(matches[0]);
3283 Ok(key_metadata_from_entry(entry))
3284}
3285
3286fn key_entry_effective_key(entry: &tempo::KeyEntry) -> Address {
3287 entry.key_address.unwrap_or(entry.wallet_address)
3288}
3289
3290fn key_metadata_from_entry(entry: &tempo::KeyEntry) -> KeyMetadata {
3291 KeyMetadata {
3292 root_account: entry.wallet_address,
3293 key_type: Some(entry.key_type),
3294 limits: entry
3295 .limits
3296 .iter()
3297 .map(|limit| LocalLimitMetadata { token: limit.currency, amount: limit.limit.clone() })
3298 .collect(),
3299 }
3300}
3301
3302fn tempo_keys_path_display() -> String {
3303 let Some(path) = tempo_keys_path() else {
3304 return "(unknown)".to_string();
3305 };
3306
3307 if let Some(home) =
3308 std::env::var_os("HOME").filter(|home| !home.is_empty()).map(std::path::PathBuf::from)
3309 && let Ok(relative) = path.strip_prefix(&home)
3310 && relative == std::path::Path::new(".tempo/wallet/keys.toml")
3311 {
3312 return "~/.tempo/wallet/keys.toml".to_string();
3313 }
3314
3315 path.display().to_string()
3316}
3317
3318fn add_selector_rule_to_scope(scope: &mut CallScope, rule: SelectorRule) -> bool {
3319 if scope.selectorRules.is_empty() {
3320 return false;
3321 }
3322
3323 let Some(existing_rule) =
3324 scope.selectorRules.iter_mut().find(|existing| existing.selector == rule.selector)
3325 else {
3326 scope.selectorRules.push(rule);
3327 return true;
3328 };
3329
3330 if existing_rule.recipients.is_empty() {
3331 return false;
3332 }
3333
3334 if rule.recipients.is_empty() {
3335 existing_rule.recipients = Vec::new();
3336 return true;
3337 }
3338
3339 let mut changed = false;
3340 for recipient in rule.recipients {
3341 if !existing_rule.recipients.contains(&recipient) {
3342 existing_rule.recipients.push(recipient);
3343 changed = true;
3344 }
3345 }
3346 changed
3347}
3348
3349fn inspected_limit_to_json(limit: &InspectedLimit) -> serde_json::Value {
3350 serde_json::json!({
3351 "token": limit.token.to_string(),
3352 "token_label": address_label(limit.token),
3353 "configured_amount": limit.configured_amount.as_deref(),
3354 "remaining": limit.remaining.to_string(),
3355 "period_end": limit.period_end,
3356 "period_end_human": limit.period_end.and_then(|period_end| {
3357 (period_end != 0).then(|| format_period_end(period_end))
3358 }),
3359 })
3360}
3361
3362fn allowed_calls_to_json(allowed_calls: &AllowedCallsView) -> serde_json::Value {
3363 match allowed_calls {
3364 AllowedCallsView::Unsupported => serde_json::json!({
3365 "mode": "unsupported",
3366 "scopes": [],
3367 }),
3368 AllowedCallsView::Unrestricted => serde_json::json!({
3369 "mode": "any",
3370 "scopes": [],
3371 }),
3372 AllowedCallsView::Scoped(scopes) => serde_json::json!({
3373 "mode": if scopes.is_empty() { "none" } else { "scoped" },
3374 "scopes": scopes.iter().map(call_scope_to_json).collect::<Vec<_>>(),
3375 }),
3376 }
3377}
3378
3379fn call_scope_to_json(scope: &CallScope) -> serde_json::Value {
3380 serde_json::json!({
3381 "target": scope.target.to_string(),
3382 "target_label": address_label(scope.target),
3383 "selectors": scope.selectorRules.iter().map(selector_rule_to_json).collect::<Vec<_>>(),
3384 })
3385}
3386
3387fn selector_rule_to_json(rule: &SelectorRule) -> serde_json::Value {
3388 serde_json::json!({
3389 "selector": selector_hex(&rule.selector.0),
3390 "signature": selector_signature(&rule.selector.0),
3391 "recipients": rule.recipients.iter().map(ToString::to_string).collect::<Vec<_>>(),
3392 })
3393}
3394
3395fn print_inspected_limits(enforce_limits: bool, limits: &[InspectedLimit]) -> Result<()> {
3396 if !enforce_limits {
3397 sh_println!("Limits: none")?;
3398 return Ok(());
3399 }
3400
3401 sh_println!("Limits:")?;
3402 if limits.is_empty() {
3403 sh_println!(" enforced, but no local limit metadata was found")?;
3404 return Ok(());
3405 }
3406
3407 for limit in limits {
3408 let configured = limit.configured_amount.as_deref().unwrap_or("unknown");
3409 let period = limit
3410 .period_end
3411 .and_then(|period_end| {
3412 (period_end != 0).then(|| format!(" ({})", format_period_end(period_end)))
3413 })
3414 .unwrap_or_default();
3415 sh_println!(
3416 " {}: {} / {} remaining{}",
3417 address_label(limit.token),
3418 limit.remaining,
3419 configured,
3420 period
3421 )?;
3422 }
3423
3424 Ok(())
3425}
3426
3427fn print_allowed_calls(allowed_calls: &AllowedCallsView) -> Result<()> {
3428 match allowed_calls {
3429 AllowedCallsView::Unsupported => sh_println!("Allowed calls: unsupported before T3")?,
3430 AllowedCallsView::Unrestricted => sh_println!("Allowed calls: any")?,
3431 AllowedCallsView::Scoped(scopes) if scopes.is_empty() => {
3432 sh_println!("Allowed calls: none")?;
3433 }
3434 AllowedCallsView::Scoped(scopes) => {
3435 sh_println!("Allowed calls:")?;
3436 for scope in scopes {
3437 sh_println!(" {}:", address_label_with_address(scope.target))?;
3438 if scope.selectorRules.is_empty() {
3439 sh_println!(" any selector")?;
3440 continue;
3441 }
3442
3443 for rule in &scope.selectorRules {
3444 sh_println!(
3445 " {} -> {}",
3446 format_selector(&rule.selector.0),
3447 format_recipients(&rule.recipients)
3448 )?;
3449 }
3450 }
3451 }
3452 }
3453
3454 Ok(())
3455}
3456
3457fn address_label(address: Address) -> String {
3458 if address == PATH_USD_ADDRESS { "PathUSD".to_string() } else { address.to_string() }
3459}
3460
3461fn address_label_with_address(address: Address) -> String {
3462 if address == PATH_USD_ADDRESS { format!("PathUSD ({address})") } else { address.to_string() }
3463}
3464
3465fn format_selector(selector: &[u8; 4]) -> String {
3466 selector_signature(selector).map(str::to_string).unwrap_or_else(|| selector_hex(selector))
3467}
3468
3469fn selector_signature(selector: &[u8; 4]) -> Option<&'static str> {
3470 if selector == &ITIP20::transferCall::SELECTOR {
3471 Some("transfer(address,uint256)")
3472 } else if selector == &ITIP20::approveCall::SELECTOR {
3473 Some("approve(address,uint256)")
3474 } else if selector == &ITIP20::transferFromCall::SELECTOR {
3475 Some("transferFrom(address,address,uint256)")
3476 } else if selector == &ITIP20::transferWithMemoCall::SELECTOR {
3477 Some("transferWithMemo(address,uint256,bytes32)")
3478 } else if selector == &ITIP20::transferFromWithMemoCall::SELECTOR {
3479 Some("transferFromWithMemo(address,address,uint256,bytes32)")
3480 } else if selector == &ITIP20::mintCall::SELECTOR {
3481 Some("mint(address,uint256)")
3482 } else if selector == &ITIP20::burnCall::SELECTOR {
3483 Some("burn(uint256)")
3484 } else {
3485 None
3486 }
3487}
3488
3489fn selector_hex(selector: &[u8; 4]) -> String {
3490 hex::encode_prefixed(selector)
3491}
3492
3493fn format_recipients(recipients: &[Address]) -> String {
3494 if recipients.is_empty() {
3495 return "any recipient".to_string();
3496 }
3497
3498 let recipients = recipients.iter().map(ToString::to_string).collect::<Vec<_>>().join(", ");
3499 format!("recipients [{recipients}]")
3500}
3501
3502fn format_expiry_for_inspect(expiry: u64) -> String {
3503 if expiry == u64::MAX {
3504 return "never".to_string();
3505 }
3506
3507 format!("{} ({})", format_timestamp_iso(expiry), format_relative_timestamp(expiry))
3508}
3509
3510fn format_period_end(period_end: u64) -> String {
3511 format!("period resets {}", format_relative_timestamp(period_end))
3512}
3513
3514fn format_timestamp_iso(timestamp: u64) -> String {
3515 DateTime::from_timestamp(timestamp as i64, 0)
3516 .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
3517 .unwrap_or_else(|| timestamp.to_string())
3518}
3519
3520fn format_relative_timestamp(timestamp: u64) -> String {
3521 format_relative_timestamp_from(timestamp, unix_timestamp_now())
3522}
3523
3524fn format_relative_timestamp_from(timestamp: u64, now: u64) -> String {
3525 if timestamp == now {
3526 "now".to_string()
3527 } else if timestamp > now {
3528 format!("in {}", format_duration_words(timestamp - now))
3529 } else {
3530 format!("{} ago", format_duration_words(now - timestamp))
3531 }
3532}
3533
3534fn format_duration_words(seconds: u64) -> String {
3535 const MINUTE: u64 = 60;
3536 const HOUR: u64 = 60 * MINUTE;
3537 const DAY: u64 = 24 * HOUR;
3538
3539 if seconds >= DAY {
3540 let days = seconds / DAY;
3541 if days == 1 { "1 day".to_string() } else { format!("{days} days") }
3542 } else if seconds >= HOUR {
3543 format!("{}h", seconds / HOUR)
3544 } else if seconds >= MINUTE {
3545 format!("{}m", seconds / MINUTE)
3546 } else {
3547 format!("{seconds}s")
3548 }
3549}
3550
3551fn format_expiry(expiry: u64) -> String {
3552 if expiry == u64::MAX {
3553 return "never".to_string();
3554 }
3555 DateTime::from_timestamp(expiry as i64, 0)
3556 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
3557 .unwrap_or_else(|| expiry.to_string())
3558}
3559
3560fn load_keys_file() -> Result<KeysFile> {
3561 match read_tempo_keys_file() {
3562 Some(f) => Ok(f),
3563 None => {
3564 let path = tempo_keys_path()
3565 .map(|p| p.display().to_string())
3566 .unwrap_or_else(|| "(unknown)".to_string());
3567 eyre::bail!("could not read keys file at {path}")
3568 }
3569 }
3570}
3571
3572fn print_key_entry(entry: &tempo::KeyEntry) -> Result<()> {
3573 sh_println!("Wallet: {}", entry.wallet_address)?;
3574 sh_println!("Wallet Type: {}", wallet_type_name(&entry.wallet_type))?;
3575 sh_println!("Chain ID: {}", entry.chain_id)?;
3576 sh_println!("Key Type: {}", key_type_name(&entry.key_type))?;
3577
3578 if let Some(key_address) = entry.key_address {
3579 sh_println!("Key Address: {key_address}")?;
3580
3581 if key_address == entry.wallet_address {
3582 sh_println!("Mode: direct (EOA)")?;
3583 } else {
3584 sh_println!("Mode: keychain (access key)")?;
3585 }
3586 } else {
3587 sh_println!("Key Address: (not set)")?;
3588 sh_println!("Mode: direct (EOA)")?;
3589 }
3590
3591 if let Some(expiry) = entry.expiry {
3592 sh_println!("Expiry: {}", format_expiry(expiry))?;
3593 }
3594
3595 sh_println!("Has Key: {}", entry.has_inline_key())?;
3596 sh_println!("Has Auth: {}", entry.key_authorization.is_some())?;
3597 if let Some(signed) = decoded_entry_key_authorization(entry) {
3598 let witness = signed
3599 .authorization
3600 .witness()
3601 .map(|witness| witness.to_string())
3602 .unwrap_or_else(|| "(none)".to_string());
3603 sh_println!("Auth Witness: {witness}")?;
3604 }
3605
3606 if !entry.limits.is_empty() {
3607 sh_println!("Limits:")?;
3608 for limit in &entry.limits {
3609 sh_println!(" {} → {}", limit.currency, limit.limit)?;
3610 }
3611 }
3612
3613 Ok(())
3614}
3615
3616fn key_entry_to_json(entry: &tempo::KeyEntry) -> serde_json::Value {
3617 let is_direct = entry.key_address.is_none_or(|key_address| key_address == entry.wallet_address);
3618 let authorization_witness = decoded_entry_key_authorization(entry)
3619 .and_then(|signed| signed.authorization.witness())
3620 .map(|witness| witness.to_string());
3621
3622 let limits: Vec<_> = entry
3623 .limits
3624 .iter()
3625 .map(|l| {
3626 serde_json::json!({
3627 "currency": l.currency.to_string(),
3628 "limit": l.limit,
3629 })
3630 })
3631 .collect();
3632
3633 serde_json::json!({
3634 "wallet_address": entry.wallet_address.to_string(),
3635 "wallet_type": wallet_type_name(&entry.wallet_type),
3636 "chain_id": entry.chain_id,
3637 "key_type": key_type_name(&entry.key_type),
3638 "key_address": entry.key_address.map(|a: Address| a.to_string()),
3639 "mode": if is_direct { "direct" } else { "keychain" },
3640 "expiry": entry.expiry,
3641 "expiry_human": entry.expiry.map(format_expiry),
3642 "has_key": entry.has_inline_key(),
3643 "has_authorization": entry.key_authorization.is_some(),
3644 "authorization_witness": authorization_witness,
3645 "limits": limits,
3646 })
3647}
3648
3649fn decoded_entry_key_authorization(entry: &tempo::KeyEntry) -> Option<SignedKeyAuthorization> {
3650 let raw = entry.key_authorization.as_deref()?.trim();
3651 if raw.is_empty() {
3652 return None;
3653 }
3654 tempo::decode_key_authorization(raw).ok()
3655}
3656
3657#[cfg(test)]
3658mod tests {
3659 use super::*;
3660 use alloy_json_rpc::ErrorPayload;
3661 use std::str::FromStr;
3662
3663 #[test]
3664 fn test_parse_scopes_json_plain() {
3665 let json = r#"[{"target":"0x20c0000000000000000000000000000000000001","selectors":["transfer","approve"]},{"target":"0x86A2EE8FAf9A840F7a2c64CA3d51209F9A02081D"}]"#;
3666 let result = parse_scopes_json(json).unwrap();
3667 assert_eq!(result.len(), 2);
3668 assert_eq!(result[0].selectorRules.len(), 2);
3669 assert!(result[1].selectorRules.is_empty());
3670 }
3671
3672 #[test]
3673 fn test_parse_scopes_json_with_recipients() {
3674 let json = r#"[{"target":"0x20c0000000000000000000000000000000000001","selectors":[{"selector":"transfer","recipients":["0x1111111111111111111111111111111111111111"]}]}]"#;
3675 let result = parse_scopes_json(json).unwrap();
3676 assert_eq!(result.len(), 1);
3677 assert_eq!(result[0].selectorRules.len(), 1);
3678 assert_eq!(result[0].selectorRules[0].recipients.len(), 1);
3679 }
3680
3681 #[test]
3682 fn test_parse_scopes_json_deny_unknown_fields() {
3683 let json = r#"[{"target":"0x20c0000000000000000000000000000000000001","selectors":[{"selector":"transfer","recipients":[],"bogus":true}]}]"#;
3684 assert!(parse_scopes_json(json).is_err());
3685 }
3686
3687 #[test]
3688 fn test_add_selector_rule_merges_recipients() {
3689 let first = Address::from_str("0x1111111111111111111111111111111111111111").unwrap();
3690 let second = Address::from_str("0x2222222222222222222222222222222222222222").unwrap();
3691 let mut scope = CallScope {
3692 target: PATH_USD_ADDRESS,
3693 selectorRules: vec![SelectorRule {
3694 selector: parse_selector_bytes("transfer").unwrap().into(),
3695 recipients: vec![first],
3696 }],
3697 };
3698
3699 let changed = add_selector_rule_to_scope(
3700 &mut scope,
3701 SelectorRule {
3702 selector: parse_selector_bytes("transfer").unwrap().into(),
3703 recipients: vec![second],
3704 },
3705 );
3706
3707 assert!(changed);
3708 assert_eq!(scope.selectorRules.len(), 1);
3709 assert_eq!(scope.selectorRules[0].recipients, vec![first, second]);
3710 }
3711
3712 #[test]
3713 fn test_add_selector_rule_empty_recipients_widens_to_any() {
3714 let first = Address::from_str("0x1111111111111111111111111111111111111111").unwrap();
3715 let mut scope = CallScope {
3716 target: PATH_USD_ADDRESS,
3717 selectorRules: vec![SelectorRule {
3718 selector: parse_selector_bytes("approve").unwrap().into(),
3719 recipients: vec![first],
3720 }],
3721 };
3722
3723 let changed = add_selector_rule_to_scope(
3724 &mut scope,
3725 SelectorRule {
3726 selector: parse_selector_bytes("approve").unwrap().into(),
3727 recipients: vec![],
3728 },
3729 );
3730
3731 assert!(changed);
3732 assert!(scope.selectorRules[0].recipients.is_empty());
3733 }
3734
3735 #[test]
3736 fn test_add_selector_rule_target_wildcard_is_unchanged() {
3737 let mut scope = CallScope { target: PATH_USD_ADDRESS, selectorRules: vec![] };
3738
3739 let changed = add_selector_rule_to_scope(
3740 &mut scope,
3741 SelectorRule {
3742 selector: parse_selector_bytes("transfer").unwrap().into(),
3743 recipients: vec![],
3744 },
3745 );
3746
3747 assert!(!changed);
3748 assert!(scope.selectorRules.is_empty());
3749 }
3750
3751 #[test]
3752 fn test_policy_set_limit_parses() {
3753 let key = "0x1111111111111111111111111111111111111111";
3754
3755 let command = KeychainSubcommand::try_parse_from([
3756 "keychain",
3757 "policy",
3758 "set-limit",
3759 key,
3760 "--token",
3761 "PathUSD",
3762 "--amount",
3763 "123",
3764 ])
3765 .unwrap();
3766
3767 match command {
3768 KeychainSubcommand::Policy {
3769 command:
3770 KeychainPolicySubcommand::SetLimit { key_address, token, amount, period, .. },
3771 } => {
3772 assert_eq!(key_address, Address::from_str(key).unwrap());
3773 assert_eq!(token, PATH_USD_ADDRESS);
3774 assert_eq!(amount, U256::from(123));
3775 assert_eq!(period, None);
3776 }
3777 other => panic!("unexpected command: {other:?}"),
3778 }
3779 }
3780
3781 #[test]
3782 fn test_active_from_anvil_node_info_requires_tempo_network() {
3783 let tempo_t3 =
3784 AnvilNodeInfo { network: Some("tempo".to_string()), hard_fork: Some("T3".to_string()) };
3785 assert_eq!(active_from_anvil_node_info(&tempo_t3, TempoHardfork::T2), Some(true));
3786 assert_eq!(active_from_anvil_node_info(&tempo_t3, TempoHardfork::T3), Some(true));
3787 assert_eq!(active_from_anvil_node_info(&tempo_t3, TempoHardfork::T4), Some(false));
3788
3789 let ethereum_t3 = AnvilNodeInfo {
3790 network: Some("ethereum".to_string()),
3791 hard_fork: Some("T3".to_string()),
3792 };
3793 assert_eq!(active_from_anvil_node_info(ðereum_t3, TempoHardfork::T3), None);
3794 }
3795
3796 fn rule(selector: [u8; 4], recipients: Vec<Address>) -> SelectorRule {
3797 SelectorRule { selector: selector.into(), recipients }
3798 }
3799
3800 fn target_addr(byte: u8) -> Address {
3801 Address::from([byte; 20])
3802 }
3803
3804 fn signed_authorization_with_limits(
3805 limits: Option<Vec<AuthTokenLimit>>,
3806 ) -> SignedKeyAuthorization {
3807 let mut authorization =
3808 KeyAuthorization::unrestricted(31337, AuthSignatureType::Secp256k1, target_addr(0x42));
3809 authorization.limits = limits;
3810 authorization.into_signed(PrimitiveSignature::default())
3811 }
3812
3813 fn key_auth_args(witness: Option<B256>) -> KeyAuthArgs {
3814 KeyAuthArgs {
3815 chain_id: 31337,
3816 key_address: target_addr(0x42),
3817 key_type: AuthSignatureType::Secp256k1,
3818 expiry: None,
3819 enforce_limits: false,
3820 limits: vec![],
3821 scope: vec![],
3822 scopes_json: None,
3823 witness,
3824 }
3825 }
3826
3827 #[test]
3828 fn test_key_auth_encode_distinguishes_absent_and_zero_witness() {
3829 use alloy_rlp::Decodable;
3830
3831 let absent = key_auth_args(None).into_authorization().unwrap();
3832 let zero = key_auth_args(Some(B256::ZERO)).into_authorization().unwrap();
3833
3834 assert_eq!(absent.witness(), None);
3835 assert_eq!(zero.witness(), Some(B256::ZERO));
3836 assert_ne!(absent.signature_hash(), zero.signature_hash());
3837
3838 let absent_encoded = encode_key_authorization(&absent);
3839 let zero_encoded = encode_key_authorization(&zero);
3840 assert_ne!(absent_encoded, zero_encoded);
3841
3842 let decoded_absent = KeyAuthorization::decode(&mut absent_encoded.as_slice()).unwrap();
3843 let decoded_zero = KeyAuthorization::decode(&mut zero_encoded.as_slice()).unwrap();
3844
3845 assert_eq!(decoded_absent.witness(), None);
3846 assert_eq!(decoded_zero.witness(), Some(B256::ZERO));
3847 }
3848
3849 #[test]
3850 fn test_signed_key_authorization_witness_roundtrip_and_json_exposure() {
3851 use alloy_rlp::Decodable;
3852
3853 let witness = B256::repeat_byte(0x53);
3854 let signed = key_auth_args(Some(witness))
3855 .into_authorization()
3856 .unwrap()
3857 .into_signed(PrimitiveSignature::from_bytes(&[0u8; 65]).unwrap());
3858 let encoded = encode_key_authorization(&signed);
3859 let hex = hex::encode_prefixed(&encoded);
3860
3861 let decoded = SignedKeyAuthorization::decode(&mut encoded.as_slice()).unwrap();
3862 assert_eq!(decoded.authorization.witness(), Some(witness));
3863
3864 let entry = tempo::KeyEntry { key_authorization: Some(hex), ..Default::default() };
3865 let json = key_entry_to_json(&entry);
3866 assert_eq!(json["authorization_witness"], witness.to_string());
3867 }
3868
3869 #[test]
3870 fn test_key_auth_encode_preserves_explicit_empty_scopes_json() {
3871 let absent = key_auth_args(None).into_authorization().unwrap();
3872 let mut args = key_auth_args(None);
3873 args.scopes_json = Some(AuthScopesJson(vec![]));
3874 let deny_all = args.into_authorization().unwrap();
3875
3876 assert_eq!(absent.allowed_calls, None);
3877 assert_eq!(deny_all.allowed_calls, Some(vec![]));
3878 assert_ne!(absent.signature_hash(), deny_all.signature_hash());
3879 assert_ne!(encode_key_authorization(&absent), encode_key_authorization(&deny_all));
3880 }
3881
3882 #[test]
3883 fn test_key_auth_encode_rejects_zero_expiry() {
3884 let mut args = key_auth_args(None);
3885 args.expiry = Some(0);
3886 let err = args.into_authorization().unwrap_err();
3887 assert!(
3888 err.to_string().contains("--expiry must be greater than zero"),
3889 "unexpected error: {err}"
3890 );
3891 }
3892
3893 #[test]
3894 fn test_match_allowed_call_target_wildcard_any_selector() {
3895 let scopes = vec![CallScope { target: target_addr(0xAA), selectorRules: vec![] }];
3896 let result =
3897 match_allowed_call(&scopes, target_addr(0xAA), ITIP20::transferCall::SELECTOR, None);
3898 assert!(matches!(result, AllowedCallMatch::Allowed(_)));
3899 }
3900
3901 #[test]
3902 fn test_match_allowed_call_empty_recipients_any_recipient() {
3903 let scopes = vec![CallScope {
3904 target: target_addr(0xAA),
3905 selectorRules: vec![rule(ITIP20::transferCall::SELECTOR, vec![])],
3906 }];
3907 let result = match_allowed_call(
3908 &scopes,
3909 target_addr(0xAA),
3910 ITIP20::transferCall::SELECTOR,
3911 Some(target_addr(0xBB)),
3912 );
3913 assert!(matches!(result, AllowedCallMatch::Allowed(_)));
3914 }
3915
3916 #[test]
3917 fn test_match_allowed_call_missing_target_denied() {
3918 let scopes = vec![CallScope { target: target_addr(0xAA), selectorRules: vec![] }];
3919 let result =
3920 match_allowed_call(&scopes, target_addr(0xCC), ITIP20::transferCall::SELECTOR, None);
3921 assert!(matches!(result, AllowedCallMatch::Denied(_)));
3922 }
3923
3924 #[test]
3925 fn test_match_allowed_call_recipient_restricted_no_recipient_arg() {
3926 let recipients = vec![target_addr(0xBB)];
3927 let scopes = vec![CallScope {
3928 target: target_addr(0xAA),
3929 selectorRules: vec![rule(ITIP20::transferCall::SELECTOR, recipients.clone())],
3930 }];
3931 let result =
3932 match_allowed_call(&scopes, target_addr(0xAA), ITIP20::transferCall::SELECTOR, None);
3933 match result {
3934 AllowedCallMatch::RecipientRestricted(rs) => assert_eq!(rs, recipients),
3935 other => panic!(
3936 "expected RecipientRestricted, got {:?}",
3937 match other {
3938 AllowedCallMatch::Allowed(s) => format!("Allowed({s})"),
3939 AllowedCallMatch::Denied(s) => format!("Denied({s})"),
3940 AllowedCallMatch::RecipientRestricted(_) => unreachable!(),
3941 }
3942 ),
3943 }
3944 }
3945
3946 #[test]
3947 fn test_match_allowed_call_recipient_match_allowed() {
3948 let recipients = vec![target_addr(0xBB), target_addr(0xCC)];
3949 let scopes = vec![CallScope {
3950 target: target_addr(0xAA),
3951 selectorRules: vec![rule(ITIP20::transferCall::SELECTOR, recipients)],
3952 }];
3953 let result = match_allowed_call(
3954 &scopes,
3955 target_addr(0xAA),
3956 ITIP20::transferCall::SELECTOR,
3957 Some(target_addr(0xCC)),
3958 );
3959 assert!(matches!(result, AllowedCallMatch::Allowed(_)));
3960 }
3961
3962 #[test]
3963 fn test_match_allowed_call_recipient_not_in_list_denied() {
3964 let recipients = vec![target_addr(0xBB)];
3965 let scopes = vec![CallScope {
3966 target: target_addr(0xAA),
3967 selectorRules: vec![rule(ITIP20::transferCall::SELECTOR, recipients)],
3968 }];
3969 let result = match_allowed_call(
3970 &scopes,
3971 target_addr(0xAA),
3972 ITIP20::transferCall::SELECTOR,
3973 Some(target_addr(0xDD)),
3974 );
3975 assert!(matches!(result, AllowedCallMatch::Denied(_)));
3976 }
3977
3978 #[test]
3979 fn test_match_allowed_call_selector_not_in_list_denied() {
3980 let scopes = vec![CallScope {
3981 target: target_addr(0xAA),
3982 selectorRules: vec![rule(ITIP20::transferCall::SELECTOR, vec![])],
3983 }];
3984 let result =
3985 match_allowed_call(&scopes, target_addr(0xAA), ITIP20::approveCall::SELECTOR, None);
3986 assert!(matches!(result, AllowedCallMatch::Denied(_)));
3987 }
3988
3989 #[test]
3990 fn test_match_allowed_call_checks_duplicate_target_scopes() {
3991 let scopes = vec![
3992 CallScope {
3993 target: target_addr(0xAA),
3994 selectorRules: vec![rule(ITIP20::approveCall::SELECTOR, vec![])],
3995 },
3996 CallScope {
3997 target: target_addr(0xAA),
3998 selectorRules: vec![rule(ITIP20::transferCall::SELECTOR, vec![])],
3999 },
4000 ];
4001
4002 let result =
4003 match_allowed_call(&scopes, target_addr(0xAA), ITIP20::transferCall::SELECTOR, None);
4004 assert!(matches!(result, AllowedCallMatch::Allowed(_)));
4005 }
4006
4007 #[test]
4008 fn test_match_allowed_call_aggregates_duplicate_target_recipients() {
4009 let first = target_addr(0xBB);
4010 let second = target_addr(0xCC);
4011 let scopes = vec![
4012 CallScope {
4013 target: target_addr(0xAA),
4014 selectorRules: vec![rule(ITIP20::transferCall::SELECTOR, vec![first])],
4015 },
4016 CallScope {
4017 target: target_addr(0xAA),
4018 selectorRules: vec![rule(ITIP20::transferCall::SELECTOR, vec![second])],
4019 },
4020 ];
4021
4022 let result = match_allowed_call(
4023 &scopes,
4024 target_addr(0xAA),
4025 ITIP20::transferCall::SELECTOR,
4026 Some(second),
4027 );
4028 assert!(matches!(result, AllowedCallMatch::Allowed(_)));
4029
4030 let result =
4031 match_allowed_call(&scopes, target_addr(0xAA), ITIP20::transferCall::SELECTOR, None);
4032 match result {
4033 AllowedCallMatch::RecipientRestricted(recipients) => {
4034 assert_eq!(recipients, vec![first, second]);
4035 }
4036 _ => panic!("expected recipient restriction"),
4037 }
4038 }
4039
4040 #[test]
4041 fn test_doctor_command_parses_with_only_root_account() {
4042 let cmd = KeychainSubcommand::try_parse_from([
4043 "keychain",
4044 "doctor",
4045 "--root-account",
4046 "0x1111111111111111111111111111111111111111",
4047 ])
4048 .unwrap();
4049 match cmd {
4050 KeychainSubcommand::Doctor { key_address, root_account, .. } => {
4051 assert!(key_address.is_none());
4052 assert!(root_account.is_some());
4053 }
4054 other => panic!("unexpected: {other:?}"),
4055 }
4056 }
4057
4058 #[test]
4059 fn test_doctor_selector_requires_to() {
4060 let res = KeychainSubcommand::try_parse_from([
4061 "keychain",
4062 "doctor",
4063 "0x1111111111111111111111111111111111111111",
4064 "--selector",
4065 "transfer",
4066 ]);
4067 assert!(res.is_err(), "--selector without --to should error");
4068 }
4069
4070 #[test]
4071 fn test_doctor_parses_tempo_expiring_nonce_options() {
4072 let cmd = KeychainSubcommand::try_parse_from([
4073 "keychain",
4074 "doctor",
4075 "0x1111111111111111111111111111111111111111",
4076 "--root-account",
4077 "0x2222222222222222222222222222222222222222",
4078 "--tempo.expiring-nonce",
4079 "--tempo.valid-before",
4080 "9999999999",
4081 "--tempo.fee-token",
4082 "0x20C0000000000000000000000000000000000002",
4083 ])
4084 .unwrap();
4085 match cmd {
4086 KeychainSubcommand::Doctor { tempo, .. } => {
4087 assert!(tempo.expiring_nonce);
4088 assert_eq!(tempo.valid_before, Some(9_999_999_999));
4089 assert_eq!(
4090 tempo.fee_token,
4091 Some(Address::from_str("0x20C0000000000000000000000000000000000002").unwrap())
4092 );
4093 }
4094 other => panic!("unexpected: {other:?}"),
4095 }
4096 }
4097
4098 #[test]
4099 fn test_doctor_parses_fee_token_option() {
4100 let cmd = KeychainSubcommand::try_parse_from([
4101 "keychain",
4102 "doctor",
4103 "0x1111111111111111111111111111111111111111",
4104 "--root-account",
4105 "0x2222222222222222222222222222222222222222",
4106 "--fee-token",
4107 "PathUSD",
4108 ])
4109 .unwrap();
4110 match cmd {
4111 KeychainSubcommand::Doctor { fee_token, .. } => {
4112 assert_eq!(fee_token, Some(PATH_USD_ADDRESS));
4113 }
4114 other => panic!("unexpected: {other:?}"),
4115 }
4116 }
4117
4118 #[test]
4119 fn test_select_subject_accepts_explicit_root_key_without_local_entry() {
4120 let root = target_addr(0x11);
4121 let key = target_addr(0x22);
4122 let subject =
4123 select_subject_for_chain(vec![DoctorCandidate::explicit(root, key)], 31337, Some(root))
4124 .unwrap();
4125
4126 assert_eq!(subject.root_account, root);
4127 assert_eq!(subject.key_address, key);
4128 assert!(subject.entry.is_none());
4129
4130 let signing = check_local_signing_readiness(&subject);
4131 assert_eq!(signing.status, DoctorStatus::Warn);
4132 }
4133
4134 #[test]
4135 fn test_select_subject_uses_explicit_root_key_when_local_entry_is_wrong_chain() {
4136 let root = target_addr(0x11);
4137 let key = target_addr(0x22);
4138 let local = tempo::KeyEntry {
4139 wallet_address: root,
4140 chain_id: 1,
4141 key_address: Some(key),
4142 key: Some("0xdeadbeef".to_string()),
4143 ..Default::default()
4144 };
4145
4146 let subject = select_subject_for_chain(
4147 vec![DoctorCandidate::from_entry(local), DoctorCandidate::explicit(root, key)],
4148 31337,
4149 Some(root),
4150 )
4151 .unwrap();
4152
4153 assert_eq!(subject.root_account, root);
4154 assert_eq!(subject.key_address, key);
4155 assert!(subject.entry.is_none());
4156 }
4157
4158 #[test]
4159 fn test_select_subject_mirrors_mpp_passkey_inline_priority() {
4160 let root = target_addr(0x11);
4161 let local_key = target_addr(0x22);
4162 let passkey_key = target_addr(0x33);
4163 let local = tempo::KeyEntry {
4164 wallet_address: root,
4165 chain_id: 31337,
4166 key_address: Some(local_key),
4167 key: Some("0xlocal".to_string()),
4168 wallet_type: WalletType::Local,
4169 ..Default::default()
4170 };
4171 let passkey = tempo::KeyEntry {
4172 wallet_address: root,
4173 chain_id: 31337,
4174 key_address: Some(passkey_key),
4175 key: Some("0xpasskey".to_string()),
4176 wallet_type: WalletType::Passkey,
4177 ..Default::default()
4178 };
4179
4180 let subject = select_subject_for_chain(
4181 vec![DoctorCandidate::from_entry(local), DoctorCandidate::from_entry(passkey)],
4182 31337,
4183 Some(root),
4184 )
4185 .unwrap();
4186
4187 assert_eq!(subject.key_address, passkey_key);
4188 }
4189
4190 #[test]
4191 fn test_select_subject_keeps_explicit_stale_entry_for_authorization_metadata() {
4192 let root = target_addr(0x11);
4193 let key = target_addr(0x22);
4194 let local = tempo::KeyEntry {
4195 wallet_address: root,
4196 chain_id: 31337,
4197 key_address: Some(key),
4198 key_authorization: Some("0xdeadbeef".to_string()),
4199 ..Default::default()
4200 };
4201
4202 let subject = select_subject_for_chain(
4203 vec![DoctorCandidate::from_entry(local), DoctorCandidate::explicit(root, key)],
4204 31337,
4205 Some(root),
4206 )
4207 .unwrap();
4208
4209 assert_eq!(subject.root_account, root);
4210 assert_eq!(subject.key_address, key);
4211 assert!(subject.explicit);
4212 assert!(subject.entry.as_ref().is_some_and(|entry| entry.key_authorization.is_some()));
4213
4214 let signing = check_local_signing_readiness(&subject);
4215 assert_eq!(signing.status, DoctorStatus::Warn);
4216 }
4217
4218 #[test]
4219 fn test_local_signing_readiness_fails_without_inline_key() {
4220 let root = target_addr(0x11);
4221 let key = target_addr(0x22);
4222 let subject = DoctorSubject {
4223 root_account: root,
4224 key_address: key,
4225 explicit: false,
4226 entry: Some(tempo::KeyEntry {
4227 wallet_address: root,
4228 chain_id: 31337,
4229 key_address: Some(key),
4230 ..Default::default()
4231 }),
4232 };
4233
4234 let signing = check_local_signing_readiness(&subject);
4235 assert_eq!(signing.status, DoctorStatus::Fail);
4236 }
4237
4238 #[test]
4239 fn test_local_signing_readiness_passes_with_inline_key() {
4240 let root = target_addr(0x11);
4241 let key = target_addr(0x22);
4242 let subject = DoctorSubject {
4243 root_account: root,
4244 key_address: key,
4245 explicit: false,
4246 entry: Some(tempo::KeyEntry {
4247 wallet_address: root,
4248 chain_id: 31337,
4249 key_address: Some(key),
4250 key: Some("0xdeadbeef".to_string()),
4251 ..Default::default()
4252 }),
4253 };
4254
4255 let signing = check_local_signing_readiness(&subject);
4256 assert_eq!(signing.status, DoctorStatus::Pass);
4257 }
4258
4259 #[test]
4260 fn test_check_authorization_spending_limits_warns_when_fee_token_missing() {
4261 let fee_token = target_addr(0xAA);
4262 let signed = signed_authorization_with_limits(Some(vec![AuthTokenLimit {
4263 token: target_addr(0xBB),
4264 limit: U256::from(1),
4265 period: 0,
4266 }]));
4267
4268 let step = check_authorization_spending_limits(&signed, fee_token, Some(true));
4269 assert_eq!(step.status, DoctorStatus::Warn);
4270 assert!(step.detail.contains("not listed"));
4271 }
4272
4273 #[test]
4274 fn test_check_authorization_spending_limits_warns_when_fee_token_zero() {
4275 let fee_token = target_addr(0xAA);
4276 let signed = signed_authorization_with_limits(Some(vec![AuthTokenLimit {
4277 token: fee_token,
4278 limit: U256::ZERO,
4279 period: 0,
4280 }]));
4281
4282 let step = check_authorization_spending_limits(&signed, fee_token, Some(true));
4283 assert_eq!(step.status, DoctorStatus::Warn);
4284 }
4285
4286 #[test]
4287 fn test_check_authorization_spending_limits_warns_when_periodic_hardfork_unknown() {
4288 let fee_token = target_addr(0xAA);
4289 let signed = signed_authorization_with_limits(Some(vec![AuthTokenLimit {
4290 token: fee_token,
4291 limit: U256::from(1),
4292 period: 60,
4293 }]));
4294
4295 let step = check_authorization_spending_limits(&signed, fee_token, None);
4296 assert_eq!(step.status, DoctorStatus::Warn);
4297 }
4298
4299 #[test]
4300 fn test_check_authorization_allowed_calls_warns_when_hardfork_unknown() {
4301 let signed = signed_authorization_with_limits(None);
4302 let step = check_authorization_allowed_calls(&signed, None, None, None, None);
4303 assert_eq!(step.status, DoctorStatus::Warn);
4304 }
4305
4306 #[test]
4307 fn test_check_key_expiry_uses_chain_timestamp() {
4308 let step = check_key_expiry(100, &ChainTimestamp::Known(100));
4309 assert_eq!(step.status, DoctorStatus::Fail);
4310
4311 let step = check_key_expiry(101, &ChainTimestamp::Known(100));
4312 assert_eq!(step.status, DoctorStatus::Pass);
4313 }
4314
4315 #[test]
4316 fn test_check_key_expiry_warns_when_chain_timestamp_unknown() {
4317 let step = check_key_expiry(
4318 100,
4319 &ChainTimestamp::Unknown {
4320 detail: "latest block not found".to_string(),
4321 hint: "test hint",
4322 },
4323 );
4324
4325 assert_eq!(step.status, DoctorStatus::Warn);
4326 }
4327
4328 #[test]
4329 fn test_check_expiring_nonce_window_validates_without_expiring_nonce_flag() {
4330 let tempo =
4331 TempoOpts { valid_after: Some(20), valid_before: Some(20), ..Default::default() };
4332 let step = check_expiring_nonce_window(&tempo, None, 10);
4333 assert_eq!(step.status, DoctorStatus::Fail);
4334
4335 let tempo = TempoOpts { valid_before: Some(10), ..Default::default() };
4336 let step = check_expiring_nonce_window(&tempo, None, 10);
4337 assert_eq!(step.status, DoctorStatus::Fail);
4338 }
4339
4340 #[test]
4341 fn test_check_expiring_nonce_window_thresholds() {
4342 let tempo =
4343 TempoOpts { expiring_nonce: true, valid_before: Some(103), ..Default::default() };
4344 assert_eq!(check_expiring_nonce_window(&tempo, None, 100).status, DoctorStatus::Fail);
4345
4346 let tempo =
4347 TempoOpts { expiring_nonce: true, valid_before: Some(104), ..Default::default() };
4348 assert_eq!(check_expiring_nonce_window(&tempo, None, 100).status, DoctorStatus::Warn);
4349
4350 let tempo =
4351 TempoOpts { expiring_nonce: true, valid_before: Some(105), ..Default::default() };
4352 assert_eq!(check_expiring_nonce_window(&tempo, None, 100).status, DoctorStatus::Warn);
4353
4354 let tempo =
4355 TempoOpts { expiring_nonce: true, valid_before: Some(131), ..Default::default() };
4356 assert_eq!(check_expiring_nonce_window(&tempo, None, 100).status, DoctorStatus::Warn);
4357 }
4358
4359 #[test]
4360 fn test_diagnose_allowed_scopes_exact_denial_fails() {
4361 let step = diagnose_allowed_scopes(
4362 &[],
4363 Some(target_addr(0x11)),
4364 Some([0xaa, 0xbb, 0xcc, 0xdd]),
4365 None,
4366 );
4367 assert_eq!(step.status, DoctorStatus::Fail);
4368 }
4369
4370 #[test]
4371 fn test_diagnose_allowed_scopes_target_only_denial_warns() {
4372 let scope = CallScope {
4373 target: target_addr(0x11),
4374 selectorRules: vec![SelectorRule {
4375 selector: [0xaa, 0xbb, 0xcc, 0xdd].into(),
4376 recipients: Vec::new(),
4377 }],
4378 };
4379
4380 let step = diagnose_allowed_scopes(&[scope], Some(target_addr(0x22)), None, None);
4381 assert_eq!(step.status, DoctorStatus::Warn);
4382 }
4383
4384 #[test]
4385 fn test_sponsor_config_error_redacts_private_key_uri() {
4386 let tempo = TempoOpts {
4387 sponsor_signer: Some("private-key://super-secret".to_string()),
4388 ..Default::default()
4389 };
4390
4391 let sanitized = sanitize_sponsor_config_error(
4392 "unsupported Tempo sponsor signer `private-key://super-secret`",
4393 &tempo,
4394 );
4395
4396 assert!(sanitized.contains("private-key://<redacted>"));
4397 assert!(!sanitized.contains("super-secret"));
4398 }
4399
4400 #[test]
4401 fn test_rpc_method_not_found_detection() {
4402 let method_missing: TransportError =
4403 TransportError::ErrorResp(ErrorPayload::method_not_found());
4404 assert!(is_rpc_method_not_found(&method_missing));
4405
4406 let internal_error: TransportError =
4407 TransportError::ErrorResp(ErrorPayload::internal_error());
4408 assert!(!is_rpc_method_not_found(&internal_error));
4409
4410 let transport_error = alloy_transport::TransportErrorKind::backend_gone();
4411 assert!(!is_rpc_method_not_found(&transport_error));
4412 }
4413}