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