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