1use crate::{
2 cmd::{
3 auth::confirm_and_build,
4 print_json_or,
5 send::{SendOptions, cast_send},
6 tempo_policy_args::{parse_period, parse_scope, parse_selector_bytes},
7 },
8 tempo::{
9 active_tempo_hardfork, apply_fee_payment, is_tempo_hardfork_active, print_expires,
10 require_hardfork, sponsor_hash, tempo_provider,
11 },
12 tx::{CastTxBuilder, SendTxOpts, SenderKind, apply_poll_interval},
13};
14use alloy_consensus::BlockHeader;
15use alloy_ens::NameOrAddress;
16use alloy_network::{EthereumWallet, NetworkTransactionBuilder};
17use alloy_primitives::{Address, B256, Bytes, U256, hex};
18use alloy_provider::{Provider, ProviderBuilder as AlloyProviderBuilder};
19use alloy_rpc_types::BlockId;
20use alloy_signer::Signer;
21use alloy_sol_types::SolCall;
22use chrono::DateTime;
23use clap::Parser;
24use eyre::Result;
25use foundry_cli::{
26 json::{print_json_object, print_json_success},
27 opts::{RpcOpts, TempoOpts, TransactionOpts},
28 utils::{LoadConfig, now, parse_fee_token_address, parse_json, resolve_lane},
29};
30use foundry_common::{
31 provider::ProviderBuilder,
32 sh_warn, shell,
33 tempo::{
34 self, AccountsStoreView, KeyType, read_tempo_accounts_store, tempo_accounts_store_path,
35 },
36};
37use foundry_evm::hardfork::TempoHardfork;
38use foundry_wallets::{
39 BrowserWalletOpts, WalletOpts, WalletSigner, wallet_browser::signer::BrowserSigner,
40};
41use serde::Deserialize;
42use serde_json::{Value, json};
43use std::fmt::Display;
44use tempo_alloy::{TempoNetwork, provider::TempoProviderExt};
45use tempo_contracts::precompiles::{
46 ACCOUNT_KEYCHAIN_ADDRESS, DEFAULT_FEE_TOKEN,
47 IAccountKeychain::{
48 self, CallScope, KeyInfo, KeyRestrictions, LegacyTokenLimit, SelectorRule, SignatureType,
49 TokenLimit,
50 },
51 ISignatureVerifier, ITIP20, PATH_USD_ADDRESS, SIGNATURE_VERIFIER_ADDRESS,
52 account_keychain::{
53 authorizeAdminKeyCall, authorizeKeyCall, authorizeKeyWithWitnessCall,
54 legacyAuthorizeKeyCall,
55 },
56};
57use tempo_primitives::transaction::{
58 CallScope as AuthCallScope, KeyAuthorization, PrimitiveSignature,
59 SignatureType as AuthSignatureType, SignedKeyAuthorization, TokenLimit as AuthTokenLimit,
60};
61use yansi::Paint;
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_bytes, requires = "to")]
126 selector: Option<[u8; 4]>,
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")]
181 scopes_json: Option<ScopesJson>,
182
183 #[arg(long)]
190 witness: Option<B256>,
191
192 #[arg(long)]
197 admin: bool,
198
199 #[arg(long)]
201 force: bool,
202
203 #[command(flatten)]
204 tx: TransactionOpts,
205
206 #[command(flatten)]
207 send_tx: SendTxOpts,
208 },
209
210 #[command(visible_alias = "rev")]
212 Revoke {
213 key_address: Address,
215
216 #[arg(long)]
218 force: bool,
219
220 #[command(flatten)]
221 tx: TransactionOpts,
222
223 #[command(flatten)]
224 send_tx: SendTxOpts,
225 },
226
227 #[command(name = "burn-witness")]
229 BurnWitness {
230 witness: B256,
232
233 #[arg(long)]
235 force: bool,
236
237 #[command(flatten)]
238 tx: TransactionOpts,
239
240 #[command(flatten)]
241 send_tx: SendTxOpts,
242 },
243
244 #[command(name = "is-witness-burned")]
246 IsWitnessBurned {
247 account: Address,
249
250 witness: B256,
252
253 #[command(flatten)]
254 rpc: RpcOpts,
255 },
256
257 #[command(name = "is-admin")]
259 IsAdmin {
260 account: Address,
262
263 key_address: Address,
265
266 #[command(flatten)]
267 rpc: RpcOpts,
268 },
269
270 Verify {
276 account: Address,
278
279 hash: B256,
281
282 signature: Bytes,
284
285 #[command(flatten)]
286 rpc: RpcOpts,
287 },
288
289 #[command(name = "verify-admin")]
295 VerifyAdmin {
296 account: Address,
298
299 hash: B256,
301
302 signature: Bytes,
304
305 #[command(flatten)]
306 rpc: RpcOpts,
307 },
308
309 #[command(name = "rl", visible_alias = "remaining-limit")]
311 RemainingLimit {
312 wallet_address: Address,
314
315 key_address: Address,
317
318 token: Address,
320
321 #[command(flatten)]
322 rpc: RpcOpts,
323 },
324
325 #[command(name = "ul", visible_alias = "update-limit")]
327 UpdateLimit {
328 key_address: Address,
330
331 token: Address,
333
334 new_limit: U256,
336
337 #[arg(long)]
339 force: bool,
340
341 #[command(flatten)]
342 tx: TransactionOpts,
343
344 #[command(flatten)]
345 send_tx: SendTxOpts,
346 },
347
348 #[command(name = "ss", visible_alias = "set-scope")]
350 SetScope {
351 key_address: Address,
353
354 #[arg(long = "scope", required = true, value_parser = parse_scope)]
356 scope: Vec<CallScope>,
357
358 #[arg(long)]
360 force: bool,
361
362 #[command(flatten)]
363 tx: TransactionOpts,
364
365 #[command(flatten)]
366 send_tx: SendTxOpts,
367 },
368
369 #[command(name = "rs", visible_alias = "remove-scope")]
371 RemoveScope {
372 key_address: Address,
374
375 target: Address,
377
378 #[arg(long)]
380 force: bool,
381
382 #[command(flatten)]
383 tx: TransactionOpts,
384
385 #[command(flatten)]
386 send_tx: SendTxOpts,
387 },
388
389 Policy {
391 #[arg(long, global = true)]
393 force: bool,
394
395 #[command(subcommand)]
396 command: KeychainPolicySubcommand,
397 },
398}
399
400#[derive(Debug, Parser)]
402pub enum KeyAuthorizationSubcommand {
403 Encode {
405 #[command(flatten)]
406 authorization: KeyAuthorizationArgs,
407
408 #[arg(long, value_name = "ADDRESS")]
413 account: Option<Address>,
414 },
415
416 Sign {
422 #[command(flatten)]
423 authorization: KeyAuthorizationArgs,
424
425 #[arg(long = "bind-account", value_name = "ADDRESS")]
429 account: Option<Address>,
430
431 #[command(flatten)]
432 wallet: Box<WalletOpts>,
433
434 #[command(flatten)]
435 browser: BrowserWalletOpts,
436 },
437
438 Inspect {
443 authorization: String,
445
446 #[arg(long, value_name = "ADDRESS")]
448 account: Option<Address>,
449 },
450}
451
452#[derive(Debug, Parser)]
454pub struct KeyAuthorizationArgs {
455 #[arg(long)]
457 chain_id: u64,
458
459 key_address: Address,
461
462 #[arg(long, default_value = "secp256k1", value_parser = parse_auth_signature_type)]
465 key_type: AuthSignatureType,
466
467 #[arg(long)]
469 expiry: Option<u64>,
470
471 #[arg(long)]
473 enforce_limits: bool,
474
475 #[arg(long = "limit", value_parser = parse_auth_limit)]
477 limits: Vec<AuthTokenLimit>,
478
479 #[arg(long = "scope", value_parser = parse_auth_scope)]
482 scope: Vec<AuthCallScope>,
483
484 #[arg(long = "scopes", value_parser = parse_auth_scopes_json_wrapped, conflicts_with = "scope")]
486 scopes_json: Option<AuthScopesJson>,
487
488 #[arg(long)]
492 witness: Option<B256>,
493
494 #[arg(long)]
500 admin: bool,
501}
502
503#[derive(Debug, Parser)]
505pub enum KeychainPolicySubcommand {
506 AddCall {
508 key_address: Address,
510
511 #[arg(long, visible_alias = "wallet-address", value_name = "ADDRESS")]
513 root_account: Option<Address>,
514
515 #[arg(long)]
517 target: Address,
518
519 #[arg(long, value_parser = parse_selector_bytes)]
521 selector: [u8; 4],
522
523 #[arg(long, value_delimiter = ',')]
525 recipients: Vec<Address>,
526
527 #[command(flatten)]
528 tx: TransactionOpts,
529
530 #[command(flatten)]
531 send_tx: SendTxOpts,
532 },
533
534 SetLimit {
536 key_address: Address,
538
539 #[arg(long, value_parser = parse_fee_token_address)]
541 token: Address,
542
543 #[arg(long)]
545 amount: U256,
546
547 #[arg(long, value_parser = parse_period)]
552 period: Option<u64>,
553
554 #[command(flatten)]
555 tx: TransactionOpts,
556
557 #[command(flatten)]
558 send_tx: SendTxOpts,
559 },
560
561 RemoveTarget {
563 key_address: Address,
565
566 #[arg(long)]
568 target: Address,
569
570 #[command(flatten)]
571 tx: TransactionOpts,
572
573 #[command(flatten)]
574 send_tx: SendTxOpts,
575 },
576}
577
578fn parse_auth_signature_type(s: &str) -> Result<AuthSignatureType, String> {
579 match s.to_lowercase().as_str() {
580 "secp256k1" => Ok(AuthSignatureType::Secp256k1),
581 "p256" => Ok(AuthSignatureType::P256),
582 "webauthn" => Ok(AuthSignatureType::WebAuthn),
583 _ => Err(format!("unknown signature type: {s} (expected secp256k1, p256, or webauthn)")),
584 }
585}
586
587fn parse_signature_type(s: &str) -> Result<SignatureType, String> {
588 parse_auth_signature_type(s).map(Into::into)
589}
590
591fn abi_key_type(t: SignatureType) -> Option<KeyType> {
593 AuthSignatureType::try_from(t).ok().map(KeyType::from)
594}
595
596const fn key_type_name(t: KeyType) -> &'static str {
597 match t {
598 KeyType::Secp256k1 => "secp256k1",
599 KeyType::P256 => "p256",
600 KeyType::WebAuthn => "webauthn",
601 }
602}
603
604const fn key_type_label(t: KeyType) -> &'static str {
605 match t {
606 KeyType::Secp256k1 => "Secp256k1",
607 KeyType::P256 => "P256",
608 KeyType::WebAuthn => "WebAuthn",
609 }
610}
611
612fn parse_auth_limit(s: &str) -> Result<AuthTokenLimit, String> {
614 let (token, amount, period) = match s.split(':').collect::<Vec<_>>()[..] {
615 [token, amount] => (token, amount, None),
616 [token, amount, period] => (token, amount, Some(period)),
617 _ => return Err(format!("invalid limit format: {s} (expected TOKEN:AMOUNT[:PERIOD])")),
618 };
619 Ok(AuthTokenLimit {
620 token: token.parse().map_err(|e| format!("invalid token address '{token}': {e}"))?,
621 limit: amount.parse().map_err(|e| format!("invalid amount '{amount}': {e}"))?,
622 period: period.map_or(Ok(0), parse_period)?,
623 })
624}
625
626fn parse_limit(s: &str) -> Result<TokenLimit, String> {
627 parse_auth_limit(s).map(|limit| TokenLimit {
628 token: limit.token,
629 amount: limit.limit,
630 period: limit.period,
631 })
632}
633
634fn parse_auth_scope(s: &str) -> Result<AuthCallScope, String> {
635 parse_scope(s).map(Into::into)
636}
637
638#[derive(Deserialize)]
640#[serde(deny_unknown_fields)]
641struct JsonCallScope {
642 target: Address,
643 #[serde(default)]
644 selectors: Option<Vec<JsonSelectorEntry>>,
645}
646
647#[derive(Deserialize)]
649#[serde(untagged)]
650enum JsonSelectorEntry {
651 Name(String),
652 WithRecipients(JsonSelectorWithRecipients),
653}
654
655#[derive(Deserialize)]
657#[serde(deny_unknown_fields)]
658struct JsonSelectorWithRecipients {
659 selector: String,
660 #[serde(default)]
661 recipients: Vec<Address>,
662}
663
664fn parse_scopes_json(s: &str) -> Result<Vec<CallScope>, String> {
666 let entries: Vec<JsonCallScope> =
667 parse_json(s).map_err(|e| format!("invalid --scopes JSON: {e}"))?;
668 entries
669 .into_iter()
670 .map(|entry| {
671 let selector_rules = entry
672 .selectors
673 .unwrap_or_default()
674 .into_iter()
675 .map(|sel| {
676 let (selector, recipients) = match sel {
677 JsonSelectorEntry::Name(name) => (name, vec![]),
678 JsonSelectorEntry::WithRecipients(JsonSelectorWithRecipients {
679 selector,
680 recipients,
681 }) => (selector, recipients),
682 };
683 let selector = parse_selector_bytes(&selector)
684 .map_err(|e| format!("in --scopes JSON: {e}"))?;
685 Ok(SelectorRule { selector: selector.into(), recipients })
686 })
687 .collect::<Result<_, String>>()?;
688 Ok(CallScope { target: entry.target, selectorRules: selector_rules })
689 })
690 .collect()
691}
692
693#[derive(Debug, Clone)]
695pub struct ScopesJson(Vec<CallScope>);
696
697fn parse_scopes_json_wrapped(s: &str) -> Result<ScopesJson, String> {
698 parse_scopes_json(s).map(ScopesJson)
699}
700
701#[derive(Debug, Clone)]
703pub struct AuthScopesJson(Vec<AuthCallScope>);
704
705fn parse_auth_scopes_json_wrapped(s: &str) -> Result<AuthScopesJson, String> {
706 parse_scopes_json(s).map(|scopes| AuthScopesJson(scopes.into_iter().map(Into::into).collect()))
707}
708
709impl KeychainSubcommand {
710 #[allow(clippy::large_stack_frames)]
711 pub async fn run(self) -> Result<()> {
712 match self {
713 Self::List => list_keys(None),
714 Self::Show { wallet_address } => list_keys(Some(wallet_address)),
715 Self::Check { wallet_address, key_address, rpc } => {
716 run_check(wallet_address, key_address, rpc).await
717 }
718 Self::Inspect { key_address, root_account, rpc } => {
719 run_inspect(key_address, root_account, rpc).await
720 }
721 Self::Doctor {
722 key_address,
723 root_account,
724 to,
725 selector,
726 recipient,
727 fee_token,
728 mut tempo,
729 rpc,
730 } => {
731 let fee_token = fee_token.or(tempo.fee_token).unwrap_or(DEFAULT_FEE_TOKEN);
732 let mut doctor = Doctor::new(root_account, key_address, fee_token);
733 doctor
734 .run(key_address, root_account, to, selector, recipient, &mut tempo, rpc)
735 .await;
736 doctor.finish()
737 }
738 Self::Authorize {
739 key_address,
740 key_type,
741 expiry,
742 enforce_limits,
743 limits,
744 scope,
745 scopes_json,
746 witness,
747 admin,
748 force,
749 tx,
750 send_tx,
751 } => {
752 let scopes_present = scopes_json.is_some() || !scope.is_empty();
753 let scopes = scopes_json.map_or(scope, |ScopesJson(scopes)| scopes);
754 run_authorize(
755 key_address,
756 key_type,
757 expiry,
758 enforce_limits,
759 limits,
760 scopes,
761 scopes_present,
762 witness,
763 admin,
764 tx,
765 send_tx,
766 force,
767 )
768 .await
769 }
770 Self::Revoke { key_address, force, tx, send_tx } => {
771 send_keychain_call(
772 &IAccountKeychain::revokeKeyCall { keyId: key_address },
773 tx,
774 &send_tx,
775 force,
776 )
777 .await
778 }
779 Self::BurnWitness { witness, force, tx, send_tx } => {
780 let (_, provider) = tempo_provider(&send_tx.eth.rpc)?;
781 require_hardfork(
782 &provider,
783 TempoHardfork::T5,
784 "burn-witness requires a Tempo T5-capable AccountKeychain RPC",
785 )
786 .await?;
787 send_keychain_call(
788 &IAccountKeychain::burnKeyAuthorizationWitnessCall { witness },
789 tx,
790 &send_tx,
791 force,
792 )
793 .await
794 }
795 Self::IsWitnessBurned { account, witness, rpc } => {
796 let (_, provider) = tempo_provider(&rpc)?;
797 require_hardfork(
798 &provider,
799 TempoHardfork::T5,
800 "is-witness-burned requires a Tempo T5-capable AccountKeychain RPC",
801 )
802 .await?;
803 let burned = provider
804 .account_keychain()
805 .isKeyAuthorizationWitnessBurned(account, witness)
806 .call()
807 .await?;
808 print_json_or(
809 json!({ "account": account, "witness": witness, "burned": burned }),
810 burned,
811 )
812 }
813 Self::IsAdmin { account, key_address, rpc } => {
814 let (_, provider) = tempo_provider(&rpc)?;
815 require_hardfork(
816 &provider,
817 TempoHardfork::T6,
818 "is-admin requires a Tempo T6-capable AccountKeychain RPC",
819 )
820 .await?;
821 let is_admin =
822 provider.account_keychain().isAdminKey(account, key_address).call().await?;
823 print_json_or(
824 json!({ "account": account, "key_address": key_address, "is_admin": is_admin }),
825 is_admin,
826 )
827 }
828 Self::Verify { account, hash, signature, rpc } => {
829 run_verify_keychain(account, hash, signature, rpc, false).await
830 }
831 Self::VerifyAdmin { account, hash, signature, rpc } => {
832 run_verify_keychain(account, hash, signature, rpc, true).await
833 }
834 Self::RemainingLimit { wallet_address, key_address, token, rpc } => {
835 let (_, provider) = tempo_provider(&rpc)?;
836 let is_t3 = is_tempo_hardfork_active(&provider, TempoHardfork::T3).await?;
837 let (remaining, _) =
838 remaining_limit(&provider, wallet_address, key_address, token, is_t3).await?;
839 if shell::is_json() {
840 sh_println!("{}", json!({ "remaining": remaining.to_string() }))?;
841 } else {
842 sh_println!("{remaining}")?;
843 }
844 Ok(())
845 }
846 Self::UpdateLimit { key_address, token, new_limit, force, tx, send_tx } => {
847 send_keychain_call(
848 &IAccountKeychain::updateSpendingLimitCall {
849 keyId: key_address,
850 token,
851 newLimit: new_limit,
852 },
853 tx,
854 &send_tx,
855 force,
856 )
857 .await
858 }
859 Self::SetScope { key_address, scope, force, tx, send_tx } => {
860 send_keychain_call(
861 &IAccountKeychain::setAllowedCallsCall { keyId: key_address, scopes: scope },
862 tx,
863 &send_tx,
864 force,
865 )
866 .await
867 }
868 Self::RemoveScope { key_address, target, force, tx, send_tx } => {
869 send_keychain_call(
870 &IAccountKeychain::removeAllowedCallsCall { keyId: key_address, target },
871 tx,
872 &send_tx,
873 force,
874 )
875 .await
876 }
877 Self::Policy { force, command } => command.run(force).await,
878 }
879 }
880}
881
882impl KeyAuthorizationSubcommand {
883 pub async fn run(self) -> Result<()> {
884 match self {
885 Self::Encode { authorization, account } => {
886 let authorization = authorization.into_authorization(account)?;
887 let encoded = alloy_rlp::encode(&authorization);
888 print_json_or(
889 json!({
890 "key_authorization": hex::encode_prefixed(&encoded),
891 "signature_hash": authorization.signature_hash(),
892 "rlp_length": encoded.len(),
893 "is_admin": authorization.is_admin(),
894 "account": authorization.account,
895 "witness": authorization.witness(),
896 }),
897 hex::encode_prefixed(&encoded),
898 )
899 }
900 Self::Sign { authorization, account, wallet, browser } => {
901 run_key_auth_sign(authorization, account, *wallet, browser).await
902 }
903 Self::Inspect { authorization, account } => {
904 run_key_auth_inspect(&authorization, account)
905 }
906 }
907 }
908}
909
910impl KeychainPolicySubcommand {
911 pub async fn run(self, force: bool) -> Result<()> {
912 match self {
913 Self::AddCall {
914 key_address,
915 root_account,
916 target,
917 selector,
918 recipients,
919 tx,
920 send_tx,
921 } => {
922 run_policy_add_call(
923 key_address,
924 root_account,
925 target,
926 selector,
927 recipients,
928 tx,
929 send_tx,
930 force,
931 )
932 .await
933 }
934 Self::SetLimit { key_address, token, amount, period, tx, send_tx } => {
935 if period.is_some_and(|period| period != 0) {
936 eyre::bail!(
937 "--period is not supported by the current AccountKeychain updateSpendingLimit \
938 precompile; periods can only be set when authorizing a key"
939 );
940 }
941 send_keychain_call(
944 &IAccountKeychain::updateSpendingLimitCall {
945 keyId: key_address,
946 token,
947 newLimit: amount,
948 },
949 tx,
950 &send_tx,
951 force,
952 )
953 .await
954 }
955 Self::RemoveTarget { key_address, target, tx, send_tx } => {
956 send_keychain_call(
957 &IAccountKeychain::removeAllowedCallsCall { keyId: key_address, target },
958 tx,
959 &send_tx,
960 force,
961 )
962 .await
963 }
964 }
965 }
966}
967
968fn list_keys(wallet_address: Option<Address>) -> Result<()> {
971 let store = load_accounts_store()?;
972 let entries: Vec<_> = store
973 .keys
974 .iter()
975 .filter(|e| wallet_address.is_none_or(|wallet| e.wallet_address == wallet))
976 .collect();
977
978 if shell::is_json() {
979 return print_json_object(entries.iter().map(|e| key_entry_to_json(e)).collect::<Vec<_>>());
980 }
981 if entries.is_empty() {
982 return match wallet_address {
983 Some(wallet) => sh_println!("No keys found for wallet {wallet}."),
984 None => sh_println!("No keys found in store.json."),
985 };
986 }
987 for (i, entry) in entries.iter().enumerate() {
988 if i > 0 {
989 sh_println!()?;
990 }
991 print_key_entry(entry)?;
992 }
993 Ok(())
994}
995
996struct InspectedLimit {
997 token: Address,
998 configured_amount: String,
999 remaining: U256,
1000 period_end: Option<u64>,
1001}
1002
1003enum AllowedCallsView {
1004 Unsupported,
1005 Unrestricted,
1006 Scoped(Vec<CallScope>),
1007}
1008
1009async fn run_inspect(
1011 key_address: Address,
1012 root_account: Option<Address>,
1013 rpc: RpcOpts,
1014) -> Result<()> {
1015 let (root_account, entry) = resolve_key_metadata(key_address, root_account)?;
1016 let (_, provider) = tempo_provider(&rpc)?;
1017
1018 let info = provider.get_keychain_key(root_account, key_address).await?;
1019 let provisioned = !info.keyId.is_zero();
1020 let is_t3 = is_tempo_hardfork_active(&provider, TempoHardfork::T3).await?;
1021 let is_admin = is_tempo_hardfork_active(&provider, TempoHardfork::T6).await?
1023 && provider.account_keychain().isAdminKey(root_account, key_address).call().await?;
1024 let role = key_role(key_address == root_account, is_admin);
1025
1026 let mut limits = Vec::new();
1027 if info.enforceLimits {
1028 for local in entry.iter().flat_map(|entry| &entry.limits) {
1029 let (remaining, period_end) =
1030 remaining_limit(&provider, root_account, key_address, local.currency, is_t3)
1031 .await?;
1032 limits.push(InspectedLimit {
1033 token: local.currency,
1034 configured_amount: local.limit.clone(),
1035 remaining,
1036 period_end,
1037 });
1038 }
1039 }
1040
1041 let allowed_calls = if is_t3 {
1042 let allowed =
1043 provider.account_keychain().getAllowedCalls(root_account, key_address).call().await?;
1044 if allowed.isScoped {
1045 AllowedCallsView::Scoped(allowed.scopes)
1046 } else {
1047 AllowedCallsView::Unrestricted
1048 }
1049 } else {
1050 AllowedCallsView::Unsupported
1051 };
1052
1053 let key_type =
1054 if provisioned { abi_key_type(info.signatureType) } else { entry.map(|e| e.key_type) };
1055
1056 if shell::is_json() {
1057 return print_json_object(json!({
1058 "root_account": root_account,
1059 "key_id": key_address,
1060 "provisioned": provisioned,
1061 "type": key_type.map_or("unknown", key_type_name),
1062 "role": role,
1063 "is_admin": is_admin,
1064 "expiry": provisioned.then_some(info.expiry),
1065 "expiry_human": provisioned.then(|| format_expiry_for_inspect(info.expiry)),
1066 "enforce_limits": info.enforceLimits,
1067 "is_revoked": info.isRevoked,
1068 "limits": limits.iter().map(inspected_limit_to_json).collect::<Vec<_>>(),
1069 "allowed_calls": allowed_calls_to_json(&allowed_calls),
1070 }));
1071 }
1072
1073 sh_println!("Root account: {root_account}")?;
1074 sh_println!("Key id: {key_address}")?;
1075 sh_println!("Type: {}", key_type.map_or("unknown", key_type_label))?;
1076 sh_println!("Role: {role}")?;
1077 if info.isRevoked {
1078 sh_println!("Status: revoked")?;
1079 } else if !provisioned {
1080 sh_println!("Status: not provisioned")?;
1081 } else {
1082 sh_println!("Status: active")?;
1083 sh_println!("Expiry: {}", format_expiry_for_inspect(info.expiry))?;
1084 }
1085 print_inspected_limits(info.enforceLimits, &limits)?;
1086 print_allowed_calls(&allowed_calls)
1087}
1088
1089async fn run_check(wallet_address: Address, key_address: Address, rpc: RpcOpts) -> Result<()> {
1091 let (_, provider) = tempo_provider(&rpc)?;
1092 let info = provider.get_keychain_key(wallet_address, key_address).await?;
1093 let provisioned = !info.keyId.is_zero();
1094 let signature_type = abi_key_type(info.signatureType).map_or("unknown", key_type_name);
1095
1096 if shell::is_json() {
1097 return print_json_object(json!({
1098 "wallet_address": wallet_address,
1099 "key_address": key_address,
1100 "provisioned": provisioned,
1101 "signatureType": signature_type,
1102 "key_id": info.keyId,
1103 "expiry": info.expiry,
1104 "expiry_human": format_expiry(info.expiry),
1105 "enforce_limits": info.enforceLimits,
1106 "is_revoked": info.isRevoked,
1107 }));
1108 }
1109
1110 sh_println!("Wallet: {wallet_address}")?;
1111 sh_println!("Key: {key_address}")?;
1112 if info.isRevoked {
1113 return sh_println!("Status: {} revoked", "✗".red());
1114 }
1115 if !provisioned {
1116 return sh_println!("Status: {} not provisioned", "✗".red());
1117 }
1118 sh_println!("Status: {} active", "✓".green())?;
1119 sh_println!("Signature Type: {signature_type}")?;
1120 sh_println!("Key ID: {}", info.keyId)?;
1121 let expiry = format_expiry(info.expiry);
1122 if info.expiry != u64::MAX && info.expiry <= now().as_secs() {
1123 sh_println!("Expiry: {expiry} ({})", "expired".red())?;
1124 } else {
1125 sh_println!("Expiry: {expiry}")?;
1126 }
1127 sh_println!("Spending Limits: {}", if info.enforceLimits { "enforced" } else { "none" })
1128}
1129
1130async fn run_verify_keychain(
1132 account: Address,
1133 hash: B256,
1134 signature: Bytes,
1135 rpc: RpcOpts,
1136 admin: bool,
1137) -> Result<()> {
1138 let (_, provider) = tempo_provider(&rpc)?;
1139 let command = if admin { "verify-admin" } else { "verify" };
1140 require_hardfork(
1141 &provider,
1142 TempoHardfork::T6,
1143 &format!("{command} requires a Tempo T6-capable SignatureVerifier RPC"),
1144 )
1145 .await?;
1146
1147 let verifier = ISignatureVerifier::new(SIGNATURE_VERIFIER_ADDRESS, &provider);
1148 let valid = if admin {
1149 verifier.verifyKeychainAdmin(account, hash, signature.clone()).call().await?
1150 } else {
1151 verifier.verifyKeychain(account, hash, signature.clone()).call().await?
1152 };
1153 print_json_or(
1154 json!({
1155 "account": account,
1156 "hash": hash,
1157 "signature": signature,
1158 "admin": admin,
1159 "valid": valid,
1160 }),
1161 valid,
1162 )
1163}
1164
1165async fn remaining_limit<P: Provider<TempoNetwork>>(
1167 provider: &P,
1168 root_account: Address,
1169 key_address: Address,
1170 token: Address,
1171 is_t3: bool,
1172) -> Result<(U256, Option<u64>)> {
1173 if is_t3 {
1174 let limit = provider
1175 .get_keychain_remaining_limit_with_period(root_account, key_address, token)
1176 .await?;
1177 Ok((limit.remaining, Some(limit.periodEnd)))
1178 } else {
1179 let remaining = provider
1180 .account_keychain()
1181 .getRemainingLimit(root_account, key_address, token)
1182 .call()
1183 .await?;
1184 Ok((remaining, None))
1185 }
1186}
1187
1188type Check = (&'static str, &'static str);
1204
1205const ACCOUNTS_STORE: Check = ("accounts_store", "Accounts store");
1206const RPC: Check = ("rpc_reachability", "RPC reachable");
1207const CHAIN_ID: Check = ("chain_id_match", "Chain ID match");
1208const LOCAL_SIGNING: Check = ("local_signing", "Local signing");
1209const KEY_REGISTRATION: Check = ("key_registration", "Key registration");
1210const REVOCATION: Check = ("revocation", "Revocation");
1211const EXPIRY: Check = ("expiry", "Expiry");
1212const HARDFORK: Check = ("hardfork", "Hardfork");
1213const SPENDING_LIMITS: Check = ("spending_limits", "Spending limits");
1214const ALLOWED_CALLS: Check = ("allowed_calls", "Allowed calls");
1215const FEE_TOKEN_BALANCE: Check = ("fee_token_balance", "Fee-token balance");
1216const EXPIRING_NONCE: Check = ("expiring_nonce", "Expiring nonce");
1217const SPONSORSHIP: Check = ("sponsorship", "Sponsorship");
1218
1219const HARDFORK_UNKNOWN_HINT: &str = "retry against an RPC that reports Tempo hardfork activation";
1220const WIDEN_POLICY_HINT: &str = "widen the policy with `cast keychain policy add-call ...`";
1221
1222#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
1223#[serde(rename_all = "lowercase")]
1224enum DoctorStatus {
1225 Pass,
1226 Warn,
1227 Fail,
1228}
1229
1230#[derive(Debug, Clone, serde::Serialize)]
1231struct DoctorStep {
1232 name: &'static str,
1233 label: &'static str,
1234 status: DoctorStatus,
1235 detail: String,
1236 #[serde(skip_serializing_if = "Option::is_none")]
1237 hint: Option<String>,
1238}
1239
1240impl DoctorStep {
1241 fn new(
1242 (name, label): Check,
1243 status: DoctorStatus,
1244 detail: impl Into<String>,
1245 hint: Option<String>,
1246 ) -> Self {
1247 Self { name, label, status, detail: detail.into(), hint }
1248 }
1249
1250 fn pass(check: Check, detail: impl Into<String>) -> Self {
1251 Self::new(check, DoctorStatus::Pass, detail, None)
1252 }
1253
1254 fn warn(check: Check, detail: impl Into<String>, hint: impl Into<String>) -> Self {
1255 Self::new(check, DoctorStatus::Warn, detail, Some(hint.into()))
1256 }
1257
1258 fn fail(check: Check, detail: impl Into<String>, hint: impl Into<String>) -> Self {
1259 Self::new(check, DoctorStatus::Fail, detail, Some(hint.into()))
1260 }
1261}
1262
1263#[derive(Debug, Clone, Copy, Default, serde::Serialize)]
1264struct DoctorContext {
1265 #[serde(skip_serializing_if = "Option::is_none")]
1266 root_account: Option<Address>,
1267 #[serde(skip_serializing_if = "Option::is_none")]
1268 key_address: Option<Address>,
1269 #[serde(skip_serializing_if = "Option::is_none")]
1270 chain_id: Option<u64>,
1271 fee_token: Address,
1272}
1273
1274#[derive(Debug)]
1276struct DoctorSubject {
1277 root_account: Address,
1278 key_address: Address,
1279 entry: Option<tempo::KeyEntry>,
1280 explicit: bool,
1281}
1282
1283#[derive(Debug)]
1285struct DoctorCandidate {
1286 root_account: Address,
1287 key_address: Address,
1288 chain_id: Option<u64>,
1289 entry: Option<tempo::KeyEntry>,
1290 explicit: bool,
1291}
1292
1293impl DoctorCandidate {
1294 const fn from_entry(entry: tempo::KeyEntry) -> Self {
1295 Self {
1296 root_account: entry.wallet_address,
1297 key_address: entry.key_address,
1298 chain_id: Some(entry.chain_id),
1299 entry: Some(entry),
1300 explicit: false,
1301 }
1302 }
1303
1304 const fn explicit(root_account: Address, key_address: Address) -> Self {
1305 Self { root_account, key_address, chain_id: None, entry: None, explicit: true }
1306 }
1307
1308 fn has_inline_key(&self) -> bool {
1309 self.entry.as_ref().is_some_and(|entry| entry.has_inline_key())
1310 }
1311}
1312
1313enum KeyRegistration {
1314 OnChain(KeyInfo),
1315 Pending(Box<SignedKeyAuthorization>),
1316}
1317
1318#[derive(Debug, Clone)]
1319enum ChainTimestamp {
1320 Known(u64),
1321 Unknown { detail: String, hint: &'static str },
1322}
1323
1324impl ChainTimestamp {
1325 const fn timestamp(&self) -> Option<u64> {
1326 match self {
1327 Self::Known(timestamp) => Some(*timestamp),
1328 Self::Unknown { .. } => None,
1329 }
1330 }
1331
1332 fn get(&self, check: Check, detail: impl Display) -> Result<u64, DoctorStep> {
1334 match self {
1335 Self::Known(timestamp) => Ok(*timestamp),
1336 Self::Unknown { detail: reason, hint } => {
1337 Err(DoctorStep::warn(check, format!("{detail}: {reason}"), *hint))
1338 }
1339 }
1340 }
1341}
1342
1343#[derive(Debug, PartialEq, Eq)]
1345enum AllowedCallMatch {
1346 Allowed(String),
1348 Denied(String),
1350 RecipientRestricted(Vec<Address>),
1352}
1353
1354struct Doctor {
1356 steps: Vec<DoctorStep>,
1357 context: DoctorContext,
1358}
1359
1360impl Doctor {
1361 const fn new(
1362 root_account: Option<Address>,
1363 key_address: Option<Address>,
1364 fee_token: Address,
1365 ) -> Self {
1366 let context = DoctorContext { root_account, key_address, chain_id: None, fee_token };
1367 Self { steps: Vec::new(), context }
1368 }
1369
1370 fn check(&mut self, step: DoctorStep) -> Option<()> {
1372 let failed = step.status == DoctorStatus::Fail;
1373 self.steps.push(step);
1374 (!failed).then_some(())
1375 }
1376
1377 fn attempt<T>(&mut self, result: Result<T, DoctorStep>) -> Option<T> {
1379 match result {
1380 Ok(value) => Some(value),
1381 Err(step) => {
1382 self.steps.push(step);
1383 None
1384 }
1385 }
1386 }
1387
1388 #[allow(clippy::too_many_arguments)]
1390 async fn run(
1391 &mut self,
1392 key_address: Option<Address>,
1393 root_account: Option<Address>,
1394 to: Option<Address>,
1395 selector: Option<[u8; 4]>,
1396 recipient: Option<Address>,
1397 tempo: &mut TempoOpts,
1398 rpc: RpcOpts,
1399 ) -> Option<()> {
1400 let resolved_expires_at = tempo.resolve_expires();
1401
1402 let (step, candidates) =
1404 self.attempt(collect_local_candidates(key_address, root_account))?;
1405 self.steps.push(step);
1406
1407 let config = self.attempt(rpc.load_config().map_err(|err| {
1409 DoctorStep::fail(
1410 RPC,
1411 format!("could not load RPC config: {err}"),
1412 "check --rpc-url and your foundry.toml",
1413 )
1414 }))?;
1415 let provider = self.attempt(
1416 ProviderBuilder::<TempoNetwork>::from_config(&config)
1417 .and_then(|builder| builder.build())
1418 .map_err(|err| {
1419 DoctorStep::fail(
1420 RPC,
1421 format!("could not build provider: {err}"),
1422 "verify --rpc-url is set and reachable",
1423 )
1424 }),
1425 )?;
1426 let rpc_chain_id = self.attempt(provider.get_chain_id().await.map_err(|err| {
1427 DoctorStep::fail(
1428 RPC,
1429 format!("eth_chainId failed: {err}"),
1430 "confirm the node is reachable and not rate-limited",
1431 )
1432 }))?;
1433 self.context.chain_id = Some(rpc_chain_id);
1434 self.steps.push(DoctorStep::pass(RPC, format!("chain id {rpc_chain_id}")));
1435 let chain_timestamp = fetch_chain_timestamp(&provider).await;
1436
1437 let subject = self.attempt(
1439 select_subject_for_chain(candidates, rpc_chain_id, root_account).map_err(|detail| {
1440 DoctorStep::fail(
1441 CHAIN_ID,
1442 detail,
1443 "use the RPC for the chain the local entry was created on, or pass --root-account",
1444 )
1445 }),
1446 )?;
1447 let DoctorSubject { root_account, key_address, .. } = subject;
1448 let detail = if subject.entry.is_some() {
1449 format!(
1450 "local entry on chain {rpc_chain_id} matches RPC (root {root_account}, key {key_address})"
1451 )
1452 } else {
1453 format!(
1454 "using explicit root {root_account} and key {key_address} on RPC chain {rpc_chain_id}"
1455 )
1456 };
1457 self.steps.push(DoctorStep::pass(CHAIN_ID, detail));
1458 self.context.root_account = Some(root_account);
1459 self.context.key_address = Some(key_address);
1460
1461 self.check(check_local_signing_readiness(&subject))?;
1463
1464 let registration = match provider.get_keychain_key(root_account, key_address).await {
1466 Ok(info) if !info.keyId.is_zero() => {
1467 let key_type = abi_key_type(info.signatureType).map_or("unknown", key_type_label);
1468 self.steps.push(DoctorStep::pass(
1469 KEY_REGISTRATION,
1470 format!("provisioned, type {key_type}"),
1471 ));
1472 KeyRegistration::OnChain(info)
1473 }
1474 Ok(_) => {
1475 let (signed, detail) = self.attempt(validate_pending_key_authorization(
1476 &subject,
1477 rpc_chain_id,
1478 &chain_timestamp,
1479 ))?;
1480 self.steps.push(DoctorStep::pass(KEY_REGISTRATION, detail));
1481 KeyRegistration::Pending(Box::new(signed))
1482 }
1483 Err(err) => {
1484 return self.check(DoctorStep::fail(
1485 KEY_REGISTRATION,
1486 format!("AccountKeychain.getKey failed: {err}"),
1487 "verify the RPC supports the AccountKeychain precompile",
1488 ));
1489 }
1490 };
1491
1492 let expiry = match ®istration {
1494 KeyRegistration::OnChain(info) => {
1495 if info.isRevoked {
1496 return self.check(DoctorStep::fail(
1497 REVOCATION,
1498 "key is revoked on-chain",
1499 "authorize a new key or re-authorize this one",
1500 ));
1501 }
1502 self.steps.push(DoctorStep::pass(REVOCATION, "active"));
1503 check_expiry(
1504 (info.expiry != u64::MAX).then_some(info.expiry),
1505 &chain_timestamp,
1506 "",
1507 "authorize a new key with a later expiry",
1508 )
1509 }
1510 KeyRegistration::Pending(signed) => {
1511 self.steps.push(DoctorStep::pass(
1512 REVOCATION,
1513 "not on-chain yet; key_authorization will provision a fresh key",
1514 ));
1515 check_expiry(
1516 signed.authorization.expiry.map(|expiry| expiry.get()),
1517 &chain_timestamp,
1518 "key_authorization ",
1519 "refresh the access key to get a later key_authorization expiry",
1520 )
1521 }
1522 };
1523 self.check(expiry)?;
1524
1525 let (step, hardfork) = check_hardfork(&provider).await;
1527 self.steps.push(step);
1528 let is_t3 = hardfork.map(|hardfork| hardfork.is_t3());
1529 let fee_token = self.context.fee_token;
1530 let (limits, pending) = match ®istration {
1531 KeyRegistration::OnChain(info) => {
1532 (check_spending_limits(&provider, &subject, info, fee_token, is_t3).await, None)
1533 }
1534 KeyRegistration::Pending(signed) => (
1535 check_authorization_spending_limits(signed, fee_token, is_t3),
1536 Some(&signed.authorization),
1537 ),
1538 };
1539 self.steps.push(limits);
1540 self.steps.push(
1541 check_allowed_calls(&provider, &subject, pending, is_t3, to, selector, recipient).await,
1542 );
1543
1544 self.steps.push(check_expiring_nonce(
1546 tempo,
1547 resolved_expires_at,
1548 &chain_timestamp,
1549 hardfork,
1550 ));
1551
1552 let (sponsorship, fee_payer) = check_sponsorship(tempo, root_account).await;
1553 let sponsor_failed = sponsorship.status == DoctorStatus::Fail;
1554 self.steps.push(sponsorship);
1555 let balance = if sponsor_failed && tempo.has_sponsor_submission() {
1556 DoctorStep::warn(
1557 FEE_TOKEN_BALANCE,
1558 "skipped; sponsorship config is invalid",
1559 "fix the sponsorship configuration before checking the fee payer balance",
1560 )
1561 } else {
1562 let (account, owner) = match fee_payer {
1563 Some(sponsor) => (sponsor, "sponsor"),
1564 None => (root_account, "root account"),
1565 };
1566 check_fee_token_balance(&provider, account, fee_token, owner).await
1567 };
1568 self.steps.push(balance);
1569 Some(())
1570 }
1571
1572 fn finish(self) -> Result<()> {
1574 let Self { steps, context } = self;
1575 let count = |status| steps.iter().filter(|s| s.status == status).count();
1576 let failure_count = count(DoctorStatus::Fail);
1577 let warning_count = count(DoctorStatus::Warn);
1578 let no_failures = failure_count == 0;
1579 let healthy = no_failures && warning_count == 0;
1580
1581 if shell::is_json() {
1582 let status = if !no_failures {
1583 "fail"
1584 } else if !healthy {
1585 "warn"
1586 } else {
1587 "pass"
1588 };
1589 return print_json_success(json!({
1590 "context": context,
1591 "steps": steps,
1592 "status": status,
1593 "no_failures": no_failures,
1594 "healthy": healthy,
1595 "warning_count": warning_count,
1596 "failure_count": failure_count,
1597 }));
1598 }
1599
1600 for step in &steps {
1601 let marker = match step.status {
1602 DoctorStatus::Pass => "✓".green().to_string(),
1603 DoctorStatus::Warn => "!".yellow().to_string(),
1604 DoctorStatus::Fail => "✗".red().to_string(),
1605 };
1606 sh_println!("{marker} {:<22} {}", step.label, step.detail)?;
1607 if let Some(hint) = &step.hint {
1608 sh_println!(" {} {hint}", "hint:".dim())?;
1609 }
1610 }
1611 sh_println!()?;
1612 if healthy {
1613 sh_println!("{} access-key signing path looks healthy", "✓".green())
1614 } else if no_failures {
1615 sh_println!("{} access-key signing path has warnings (see above)", "!".yellow())
1616 } else {
1617 sh_println!("{} access-key signing path has issues (see above)", "✗".red())
1618 }
1619 }
1620}
1621
1622fn collect_local_candidates(
1624 key_address: Option<Address>,
1625 root_account: Option<Address>,
1626) -> Result<(DoctorStep, Vec<DoctorCandidate>), DoctorStep> {
1627 let explicit = key_address
1628 .zip(root_account)
1629 .map(|(key_address, root_account)| DoctorCandidate::explicit(root_account, key_address));
1630 let store_path = tempo_accounts_store_path_display();
1631
1632 let Some(store) = read_tempo_accounts_store() else {
1633 return match explicit {
1634 Some(candidate) => Ok((
1635 DoctorStep::pass(
1636 ACCOUNTS_STORE,
1637 format!("could not read {store_path}; using explicit root/key"),
1638 ),
1639 vec![candidate],
1640 )),
1641 None => Err(DoctorStep::fail(
1642 ACCOUNTS_STORE,
1643 format!("could not read Tempo Accounts store at {store_path}"),
1644 "run `cast tempo login` or pass both KEY_ADDRESS and --root-account",
1645 )),
1646 };
1647 };
1648
1649 let matches: Vec<_> = store
1650 .keys
1651 .into_iter()
1652 .filter(|entry| {
1653 (key_address.is_some() || root_account.is_some())
1654 && key_address.is_none_or(|k| entry.key_address == k)
1655 && root_account.is_none_or(|r| entry.wallet_address == r)
1656 })
1657 .collect();
1658
1659 if matches.is_empty() {
1660 if let Some(candidate) = explicit {
1661 let detail = format!(
1662 "no local entry for key {} and root {}; using explicit root/key",
1663 candidate.key_address, candidate.root_account
1664 );
1665 return Ok((DoctorStep::pass(ACCOUNTS_STORE, detail), vec![candidate]));
1666 }
1667 let (descriptor, hint) = match (key_address, root_account) {
1668 (Some(k), None) => {
1669 (format!("key {k}"), "pass --root-account to diagnose an explicit key/root pair")
1670 }
1671 (None, Some(r)) => (
1672 format!("root account {r}"),
1673 "pass KEY_ADDRESS to diagnose a key absent from the Accounts store",
1674 ),
1675 _ => (
1676 "the requested key".to_string(),
1677 "run `cast tempo login` to add a key to ~/.tempo/wallet/store.json",
1678 ),
1679 };
1680 return Err(DoctorStep::fail(
1681 ACCOUNTS_STORE,
1682 format!("no entry for {descriptor} in {store_path}"),
1683 hint,
1684 ));
1685 }
1686
1687 let count = matches.len();
1688 let candidates = matches.into_iter().map(DoctorCandidate::from_entry).chain(explicit).collect();
1689 Ok((
1690 DoctorStep::pass(ACCOUNTS_STORE, format!("{count} candidate(s) in {store_path}")),
1691 candidates,
1692 ))
1693}
1694
1695fn select_subject_for_chain(
1697 candidates: Vec<DoctorCandidate>,
1698 rpc_chain_id: u64,
1699 explicit_root: Option<Address>,
1700) -> Result<DoctorSubject, String> {
1701 let local_chain_ids: Vec<u64> = candidates.iter().filter_map(|e| e.chain_id).collect();
1702 let chain_matched: Vec<_> = candidates
1703 .into_iter()
1704 .filter(|entry| entry.chain_id.is_none_or(|chain_id| chain_id == rpc_chain_id))
1705 .collect();
1706
1707 let Some(first) = chain_matched.first() else {
1708 return Err(format!(
1709 "no local entry matches RPC chain id {rpc_chain_id} (local entries on {local_chain_ids:?})"
1710 ));
1711 };
1712
1713 if explicit_root.is_none()
1715 && chain_matched.iter().any(|entry| entry.root_account != first.root_account)
1716 {
1717 return Err(
1718 "multiple local entries match this chain across different root accounts; pass --root-account"
1719 .to_string(),
1720 );
1721 }
1722
1723 let explicit = chain_matched.iter().any(|entry| entry.explicit);
1724 let preferred = chain_matched.iter().position(DoctorCandidate::has_inline_key).unwrap_or(0);
1726 let entry = chain_matched.into_iter().nth(preferred).expect("non-empty");
1727 Ok(DoctorSubject {
1728 root_account: entry.root_account,
1729 key_address: entry.key_address,
1730 entry: entry.entry,
1731 explicit,
1732 })
1733}
1734
1735fn check_local_signing_readiness(subject: &DoctorSubject) -> DoctorStep {
1737 let Some(entry) = &subject.entry else {
1738 return DoctorStep::warn(
1739 LOCAL_SIGNING,
1740 "not verified; using explicit root/key absent from the Accounts store",
1741 "pass --tempo.access-key in the send command or run `cast tempo login`",
1742 );
1743 };
1744 if entry.has_inline_key() {
1745 DoctorStep::pass(
1746 LOCAL_SIGNING,
1747 format!("inline {} key available", key_type_name(entry.key_type)),
1748 )
1749 } else if subject.explicit {
1750 DoctorStep::warn(
1751 LOCAL_SIGNING,
1752 "local entry has no inline access-key private key; explicit root/key can still use --tempo.access-key",
1753 "pass --tempo.access-key in the send command or refresh the local key material",
1754 )
1755 } else {
1756 DoctorStep::fail(
1757 LOCAL_SIGNING,
1758 "local entry has no inline access-key private key",
1759 "run `cast tempo login` again, restore the key material, or pass --tempo.access-key when sending",
1760 )
1761 }
1762}
1763
1764fn validate_pending_key_authorization(
1766 subject: &DoctorSubject,
1767 rpc_chain_id: u64,
1768 chain_timestamp: &ChainTimestamp,
1769) -> Result<(SignedKeyAuthorization, String), DoctorStep> {
1770 let fail = |detail: String, hint: &str| DoctorStep::fail(KEY_REGISTRATION, detail, hint);
1771 let not_registered = || {
1772 format!(
1773 "key {} is not registered for root account {}",
1774 subject.key_address, subject.root_account
1775 )
1776 };
1777
1778 let Some(entry) = &subject.entry else {
1779 return Err(fail(
1780 not_registered(),
1781 "authorize the key with `cast keychain authorize <KEY>` or add a local key_authorization",
1782 ));
1783 };
1784 let Some(signed) = entry.key_authorization.clone() else {
1785 return Err(fail(
1786 not_registered(),
1787 "authorize the key with `cast keychain authorize <KEY>` or refresh the local key_authorization",
1788 ));
1789 };
1790 let auth = &signed.authorization;
1791
1792 if auth.key_id != subject.key_address {
1793 return Err(fail(
1794 format!(
1795 "local key_authorization is for key {}, expected {}",
1796 auth.key_id, subject.key_address
1797 ),
1798 "refresh the access key for this root/key pair",
1799 ));
1800 }
1801 if auth.chain_id != rpc_chain_id {
1802 return Err(fail(
1803 format!(
1804 "local key_authorization is for chain {}, RPC is chain {rpc_chain_id}",
1805 auth.chain_id
1806 ),
1807 "use the RPC for the chain the authorization was created on",
1808 ));
1809 }
1810 if entry.key_type != KeyType::from(auth.key_type) {
1811 return Err(fail(
1812 format!(
1813 "local key type {} does not match key_authorization type {}",
1814 key_type_label(entry.key_type),
1815 key_type_label(auth.key_type.into())
1816 ),
1817 "refresh the local key entry so its key material and authorization agree",
1818 ));
1819 }
1820 if let Some(expiry) = auth.expiry
1821 && let Some(now) = chain_timestamp.timestamp()
1822 && expiry.get() <= now
1823 {
1824 return Err(fail(
1825 format!(
1826 "local key_authorization expired {}",
1827 format_relative_timestamp_from(expiry.get(), now)
1828 ),
1829 "refresh the access key to get a later key_authorization expiry",
1830 ));
1831 }
1832 match signed.recover_signer() {
1833 Ok(recovered) if recovered == subject.root_account => {}
1834 Ok(recovered) => {
1835 return Err(fail(
1836 format!(
1837 "local key_authorization recovers signer {recovered}, expected root {}",
1838 subject.root_account
1839 ),
1840 "refresh the authorization with the correct root account",
1841 ));
1842 }
1843 Err(err) => {
1844 return Err(fail(
1845 format!("local key_authorization signature could not be verified: {err}"),
1846 "refresh the access key with `cast tempo login`",
1847 ));
1848 }
1849 }
1850
1851 let expiry = auth.expiry.map_or_else(
1852 || "never expires".to_string(),
1853 |expiry| {
1854 let expiry = expiry.get();
1855 let relative = match chain_timestamp.timestamp() {
1856 Some(now) => format_relative_timestamp_from(expiry, now),
1857 None => format_relative_timestamp(expiry),
1858 };
1859 format!("{relative} ({})", format_timestamp_iso(expiry))
1860 },
1861 );
1862 let witness = auth.witness().map(|witness| format!(", witness {witness}")).unwrap_or_default();
1863 let detail = format!(
1864 "not on-chain; local key_authorization can provision atomically, type {}, expiry {expiry}{witness}",
1865 key_type_label(auth.key_type.into()),
1866 );
1867 Ok((signed, detail))
1868}
1869
1870async fn fetch_chain_timestamp<P: Provider<TempoNetwork>>(provider: &P) -> ChainTimestamp {
1871 match provider.get_block(BlockId::latest()).await {
1872 Ok(Some(block)) => ChainTimestamp::Known(block.header.timestamp()),
1873 Ok(None) => ChainTimestamp::Unknown {
1874 detail: "latest block not found; chain timestamp unavailable".to_string(),
1875 hint: "verify the RPC can serve latest block data",
1876 },
1877 Err(err) => ChainTimestamp::Unknown {
1878 detail: format!("latest block query failed: {err}"),
1879 hint: "validity windows and expiries could not be checked against chain time",
1880 },
1881 }
1882}
1883
1884fn check_expiry(
1887 expiry: Option<u64>,
1888 chain_timestamp: &ChainTimestamp,
1889 prefix: &str,
1890 hint: &str,
1891) -> DoctorStep {
1892 let Some(expiry) = expiry else {
1893 return DoctorStep::pass(EXPIRY, format!("{prefix}never expires"));
1894 };
1895 let subject = if prefix.is_empty() { "key " } else { prefix };
1896 let now = match chain_timestamp.get(EXPIRY, format!("{subject}expiry not checked")) {
1897 Ok(now) => now,
1898 Err(step) => return step,
1899 };
1900 let relative = format_relative_timestamp_from(expiry, now);
1901 if expiry <= now {
1902 DoctorStep::fail(EXPIRY, format!("{prefix}expired {relative}"), hint)
1903 } else {
1904 DoctorStep::pass(EXPIRY, format!("{prefix}{relative} ({})", format_timestamp_iso(expiry)))
1905 }
1906}
1907
1908async fn check_hardfork<P: Provider<TempoNetwork>>(
1909 provider: &P,
1910) -> (DoctorStep, Option<TempoHardfork>) {
1911 match active_tempo_hardfork(provider).await {
1912 Ok(hardfork) if hardfork.is_t3() => {
1913 (DoctorStep::pass(HARDFORK, "Tempo T3 active"), Some(hardfork))
1914 }
1915 Ok(hardfork) => {
1916 (DoctorStep::pass(HARDFORK, "pre-T3; TIP-1011 scopes not enforced"), Some(hardfork))
1917 }
1918 Err(err) => (
1919 DoctorStep::warn(
1920 HARDFORK,
1921 format!("could not determine Tempo T3 activation: {err}"),
1922 "TIP-1011 allowed-call and T3 spending-period checks will be skipped",
1923 ),
1924 None,
1925 ),
1926 }
1927}
1928
1929async fn check_spending_limits<P: Provider<TempoNetwork>>(
1931 provider: &P,
1932 subject: &DoctorSubject,
1933 info: &KeyInfo,
1934 fee_token: Address,
1935 is_t3: Option<bool>,
1936) -> DoctorStep {
1937 let Some(is_t3) = is_t3 else {
1938 return DoctorStep::warn(
1939 SPENDING_LIMITS,
1940 "skipped; hardfork unknown",
1941 HARDFORK_UNKNOWN_HINT,
1942 );
1943 };
1944 if !info.enforceLimits {
1945 return DoctorStep::pass(SPENDING_LIMITS, "limits not enforced for this key");
1946 }
1947
1948 let local_limits = subject.entry.as_ref().map_or(&[][..], |entry| entry.limits.as_slice());
1949 let mut tokens: Vec<Address> = local_limits.iter().map(|l| l.currency).collect();
1951 if !tokens.contains(&fee_token) {
1952 tokens.push(fee_token);
1953 }
1954
1955 let mut lines = Vec::new();
1956 let mut any_zero = false;
1957 for token in tokens {
1958 let (remaining, period_end) = match remaining_limit(
1959 provider,
1960 subject.root_account,
1961 subject.key_address,
1962 token,
1963 is_t3,
1964 )
1965 .await
1966 {
1967 Ok(limit) => limit,
1968 Err(err) => {
1969 return DoctorStep::warn(
1970 SPENDING_LIMITS,
1971 format!("{} query failed: {err}", address_label(token)),
1972 "verify the AccountKeychain precompile is reachable",
1973 );
1974 }
1975 };
1976 any_zero |= remaining.is_zero();
1977 let configured =
1978 local_limits.iter().find(|l| l.currency == token).map_or("?", |l| l.limit.as_str());
1979 lines.push(format!(
1980 "{} remaining {remaining} / {configured}{}",
1981 address_label(token),
1982 format_period_suffix(period_end)
1983 ));
1984 }
1985
1986 let detail = lines.join("; ");
1987 if any_zero {
1988 DoctorStep::warn(
1989 SPENDING_LIMITS,
1990 detail,
1991 "raise the limit (e.g. `cast keychain ul ...`) or wait for the window reset",
1992 )
1993 } else {
1994 DoctorStep::pass(SPENDING_LIMITS, detail)
1995 }
1996}
1997
1998fn check_authorization_spending_limits(
2000 signed: &SignedKeyAuthorization,
2001 fee_token: Address,
2002 is_t3: Option<bool>,
2003) -> DoctorStep {
2004 let auth = &signed.authorization;
2005 if is_t3.is_none() && auth.has_periodic_limits() {
2006 return DoctorStep::warn(
2007 SPENDING_LIMITS,
2008 "skipped; hardfork unknown and key_authorization uses periodic limits",
2009 HARDFORK_UNKNOWN_HINT,
2010 );
2011 }
2012 if is_t3 == Some(false) && !auth.is_legacy_compatible() {
2013 return DoctorStep::fail(
2014 SPENDING_LIMITS,
2015 "key_authorization uses T3-only limits or call scopes on a pre-T3 chain",
2016 "use a T3 RPC or refresh the authorization with legacy-compatible restrictions",
2017 );
2018 }
2019
2020 match auth.limits.as_deref() {
2021 None => DoctorStep::pass(SPENDING_LIMITS, "limits not enforced by key_authorization"),
2022 Some([]) => DoctorStep::warn(
2023 SPENDING_LIMITS,
2024 "key_authorization allows no token spending",
2025 "refresh the access key with spending limits if the transaction spends TIP-20 tokens",
2026 ),
2027 Some(limits) => {
2028 let mut lines: Vec<String> = limits
2029 .iter()
2030 .map(|limit| {
2031 let period = if limit.period == 0 {
2032 String::new()
2033 } else {
2034 format!(" per {}s", limit.period)
2035 };
2036 format!("{} limit {}{period}", address_label(limit.token), limit.limit)
2037 })
2038 .collect();
2039 let fee_limit = limits.iter().find(|limit| limit.token == fee_token);
2040 if fee_limit.is_none() {
2041 lines.push(format!(
2042 "{} not listed in key_authorization limits",
2043 address_label(fee_token)
2044 ));
2045 }
2046 let detail = lines.join("; ");
2047 match fee_limit {
2048 None => DoctorStep::warn(
2049 SPENDING_LIMITS,
2050 detail,
2051 "refresh the access key with a limit for the selected fee token",
2052 ),
2053 Some(limit) if limit.limit.is_zero() => DoctorStep::warn(
2054 SPENDING_LIMITS,
2055 detail,
2056 "raise the fee-token limit before sending with this authorization",
2057 ),
2058 Some(_) => DoctorStep::pass(SPENDING_LIMITS, detail),
2059 }
2060 }
2061 }
2062}
2063
2064async fn check_allowed_calls<P: Provider<TempoNetwork>>(
2067 provider: &P,
2068 subject: &DoctorSubject,
2069 pending: Option<&KeyAuthorization>,
2070 is_t3: Option<bool>,
2071 to: Option<Address>,
2072 selector: Option<[u8; 4]>,
2073 recipient: Option<Address>,
2074) -> DoctorStep {
2075 let Some(is_t3) = is_t3 else {
2076 return DoctorStep::warn(ALLOWED_CALLS, "skipped; hardfork unknown", HARDFORK_UNKNOWN_HINT);
2077 };
2078 if !is_t3 {
2079 return DoctorStep::pass(ALLOWED_CALLS, "TIP-1011 not enforced before T3");
2080 }
2081
2082 let scopes = match pending {
2083 Some(auth) => {
2084 let Some(scopes) = auth.allowed_calls.as_deref() else {
2085 return DoctorStep::pass(ALLOWED_CALLS, "any call permitted by key_authorization");
2086 };
2087 scopes.iter().cloned().map(Into::into).collect()
2088 }
2089 None => match provider
2090 .account_keychain()
2091 .getAllowedCalls(subject.root_account, subject.key_address)
2092 .call()
2093 .await
2094 {
2095 Ok(allowed) if !allowed.isScoped => {
2096 return DoctorStep::pass(ALLOWED_CALLS, "any call permitted");
2097 }
2098 Ok(allowed) => allowed.scopes,
2099 Err(err) => {
2100 return DoctorStep::warn(
2101 ALLOWED_CALLS,
2102 format!("getAllowedCalls failed: {err}"),
2103 "verify the AccountKeychain precompile is reachable",
2104 );
2105 }
2106 },
2107 };
2108 diagnose_allowed_scopes(&scopes, to, selector, recipient)
2109}
2110
2111fn diagnose_allowed_scopes(
2112 scopes: &[CallScope],
2113 to: Option<Address>,
2114 selector: Option<[u8; 4]>,
2115 recipient: Option<Address>,
2116) -> DoctorStep {
2117 if scopes.is_empty() {
2118 let detail = "scoped, but no targets permitted";
2119 return if to.is_some() && selector.is_some() {
2120 DoctorStep::fail(ALLOWED_CALLS, detail, WIDEN_POLICY_HINT)
2121 } else {
2122 DoctorStep::warn(ALLOWED_CALLS, detail, WIDEN_POLICY_HINT)
2123 };
2124 }
2125 let Some(to) = to else {
2126 return DoctorStep::pass(
2127 ALLOWED_CALLS,
2128 format!(
2129 "scoped to {} target(s); pass --to/--selector to test a specific call",
2130 scopes.len()
2131 ),
2132 );
2133 };
2134 let Some(selector) = selector else {
2135 return if scopes.iter().any(|s| s.target == to) {
2137 DoctorStep::pass(
2138 ALLOWED_CALLS,
2139 format!("target {to} is in scope; pass --selector to test the function"),
2140 )
2141 } else {
2142 DoctorStep::warn(
2143 ALLOWED_CALLS,
2144 format!("target {to} not in any allowed scope"),
2145 WIDEN_POLICY_HINT,
2146 )
2147 };
2148 };
2149
2150 match match_allowed_call(scopes, to, selector, recipient) {
2151 AllowedCallMatch::Allowed(detail) => DoctorStep::pass(ALLOWED_CALLS, detail),
2152 AllowedCallMatch::Denied(reason) => {
2153 DoctorStep::fail(ALLOWED_CALLS, reason, WIDEN_POLICY_HINT)
2154 }
2155 AllowedCallMatch::RecipientRestricted(recipients) => DoctorStep::pass(
2156 ALLOWED_CALLS,
2157 format!(
2158 "selector {} on {} allowed only for {}; pass --recipient to verify exact match",
2159 format_selector(&selector),
2160 address_label_with_address(to),
2161 format_recipients(&recipients)
2162 ),
2163 ),
2164 }
2165}
2166
2167fn match_allowed_call(
2169 scopes: &[CallScope],
2170 to: Address,
2171 selector: [u8; 4],
2172 recipient: Option<Address>,
2173) -> AllowedCallMatch {
2174 let target = address_label_with_address(to);
2175 let matching_scopes: Vec<_> = scopes.iter().filter(|scope| scope.target == to).collect();
2176 if matching_scopes.is_empty() {
2177 return AllowedCallMatch::Denied(format!("target {to} not in any allowed scope"));
2178 }
2179 if matching_scopes.iter().any(|scope| scope.selectorRules.is_empty()) {
2180 return AllowedCallMatch::Allowed(format!("any selector on {target} permitted"));
2181 }
2182
2183 let selector_str = format_selector(&selector);
2184 let matching_rules: Vec<_> = matching_scopes
2185 .iter()
2186 .flat_map(|scope| &scope.selectorRules)
2187 .filter(|rule| rule.selector.0 == selector)
2188 .collect();
2189 if matching_rules.is_empty() {
2190 return AllowedCallMatch::Denied(format!(
2191 "selector {selector_str} on {target} not in allowed list"
2192 ));
2193 }
2194 if matching_rules.iter().any(|rule| rule.recipients.is_empty()) {
2195 return AllowedCallMatch::Allowed(format!(
2196 "{selector_str} on {target} permitted (any recipient)"
2197 ));
2198 }
2199
2200 match recipient {
2201 Some(r) if matching_rules.iter().any(|rule| rule.recipients.contains(&r)) => {
2202 AllowedCallMatch::Allowed(format!(
2203 "{selector_str} on {target} to recipient {r} permitted"
2204 ))
2205 }
2206 Some(r) => AllowedCallMatch::Denied(format!(
2207 "recipient {r} not in allowed list for {selector_str} on {target}"
2208 )),
2209 None => {
2210 let mut recipients = Vec::new();
2211 for recipient in matching_rules.iter().flat_map(|rule| &rule.recipients) {
2212 if !recipients.contains(recipient) {
2213 recipients.push(*recipient);
2214 }
2215 }
2216 AllowedCallMatch::RecipientRestricted(recipients)
2217 }
2218 }
2219}
2220
2221async fn check_fee_token_balance<P: Provider<TempoNetwork>>(
2223 provider: &P,
2224 account: Address,
2225 fee_token: Address,
2226 owner_label: &str,
2227) -> DoctorStep {
2228 let token = address_label(fee_token);
2229 match ITIP20::new(fee_token, provider).balanceOf(account).call().await {
2230 Ok(balance) if balance.is_zero() => DoctorStep::warn(
2231 FEE_TOKEN_BALANCE,
2232 format!("0 {token} on {owner_label} {account}"),
2233 format!("fund {owner_label} {account} with {token}"),
2234 ),
2235 Ok(balance) => DoctorStep::pass(
2236 FEE_TOKEN_BALANCE,
2237 format!("{balance} {token} on {owner_label} {account}"),
2238 ),
2239 Err(err) => DoctorStep::warn(
2240 FEE_TOKEN_BALANCE,
2241 format!("balanceOf failed: {err}"),
2242 "verify --fee-token points to a TIP-20 token",
2243 ),
2244 }
2245}
2246
2247fn check_expiring_nonce(
2249 tempo: &TempoOpts,
2250 resolved_expires_at: Option<u64>,
2251 chain_timestamp: &ChainTimestamp,
2252 hardfork: Option<TempoHardfork>,
2253) -> DoctorStep {
2254 if !tempo.expiring_nonce && tempo.valid_before.is_none() && tempo.valid_after.is_none() {
2255 return DoctorStep::pass(EXPIRING_NONCE, "not requested");
2256 }
2257 match chain_timestamp.get(EXPIRING_NONCE, "validity window not checked") {
2258 Ok(now) => check_expiring_nonce_window(tempo, resolved_expires_at, now, hardfork),
2259 Err(step) => step,
2260 }
2261}
2262
2263fn check_expiring_nonce_window(
2266 tempo: &TempoOpts,
2267 resolved_expires_at: Option<u64>,
2268 chain_timestamp: u64,
2269 hardfork: Option<TempoHardfork>,
2270) -> DoctorStep {
2271 let valid_before = tempo.valid_before;
2272 let valid_after = tempo.valid_after;
2273
2274 if let (Some(after), Some(before)) = (valid_after, valid_before)
2275 && after >= before
2276 {
2277 return DoctorStep::fail(
2278 EXPIRING_NONCE,
2279 format!("valid-after {after} is not before valid-before {before}"),
2280 "choose a valid window where valid-after < valid-before",
2281 );
2282 }
2283
2284 if let Some(before) = valid_before {
2285 if before <= chain_timestamp {
2286 return DoctorStep::fail(
2287 EXPIRING_NONCE,
2288 format!(
2289 "valid-before {} is expired at chain timestamp {chain_timestamp}",
2290 format_timestamp_iso(before)
2291 ),
2292 "use a later --tempo.valid-before or rerun with --tempo.expires",
2293 );
2294 }
2295 let ttl = before - chain_timestamp;
2296 if ttl <= 3 {
2297 return DoctorStep::fail(
2298 EXPIRING_NONCE,
2299 format!(
2300 "valid-before must be more than 3s after chain timestamp {chain_timestamp}; current ttl is {ttl}s"
2301 ),
2302 "use a later --tempo.valid-before or rerun with --tempo.expires",
2303 );
2304 }
2305 if ttl <= 5 {
2306 return DoctorStep::warn(
2307 EXPIRING_NONCE,
2308 format!("valid for only {ttl}s at chain timestamp {chain_timestamp}"),
2309 "use a larger validity window before signing",
2310 );
2311 }
2312 if let Some(max_expiry_secs) =
2313 hardfork.map(|hardfork| hardfork.expiring_nonce_max_expiry_secs())
2314 && ttl > max_expiry_secs
2315 {
2316 return if resolved_expires_at.is_some() {
2317 DoctorStep::warn(
2318 EXPIRING_NONCE,
2319 format!(
2320 "--tempo.expires resolved to a deadline {ttl}s ahead of chain timestamp {chain_timestamp}; the active hardfork allows at most {max_expiry_secs}s"
2321 ),
2322 "use a shorter --tempo.expires or check local clock/RPC timestamp skew",
2323 )
2324 } else {
2325 DoctorStep::warn(
2326 EXPIRING_NONCE,
2327 format!(
2328 "valid-before is {ttl}s ahead of chain timestamp {chain_timestamp}; expiring nonce transactions must expire within {max_expiry_secs}s on the active hardfork"
2329 ),
2330 "prefer --tempo.expires for bounded retry-safe sends",
2331 )
2332 };
2333 }
2334 }
2335
2336 if let Some(after) = valid_after
2337 && after > chain_timestamp
2338 {
2339 return DoctorStep::warn(
2340 EXPIRING_NONCE,
2341 format!("transaction is not valid until {}", format_timestamp_iso(after)),
2342 "wait until valid-after or choose an earlier lower bound",
2343 );
2344 }
2345
2346 if (valid_before.is_some() || valid_after.is_some()) && !tempo.expiring_nonce {
2347 return DoctorStep::warn(
2348 EXPIRING_NONCE,
2349 "validity window set without --tempo.expiring-nonce",
2350 "use --tempo.expiring-nonce or --tempo.expires so nonce_key is set to the expiring lane",
2351 );
2352 }
2353
2354 let mut detail = format!("enabled at chain timestamp {chain_timestamp}");
2355 if let Some(before) = valid_before {
2356 detail.push_str(&format!(", valid-before {}", format_timestamp_iso(before)));
2357 }
2358 if let Some(after) = valid_after {
2359 detail.push_str(&format!(", valid-after {}", format_timestamp_iso(after)));
2360 }
2361 if let Some(expires_at) = resolved_expires_at {
2362 detail.push_str(&format!(
2363 ", --tempo.expires resolved to {}",
2364 format_timestamp_iso(expires_at)
2365 ));
2366 }
2367 DoctorStep::pass(EXPIRING_NONCE, detail)
2368}
2369
2370async fn check_sponsorship(tempo: &TempoOpts, sender: Address) -> (DoctorStep, Option<Address>) {
2372 if tempo.print_sponsor_hash {
2373 return (
2374 DoctorStep::pass(
2375 SPONSORSHIP,
2376 "--tempo.print-sponsor-hash requested, but doctor has no concrete tx payload",
2377 ),
2378 None,
2379 );
2380 }
2381 let not_requested = || (DoctorStep::pass(SPONSORSHIP, "not requested"), None);
2382 if !tempo.has_sponsor_submission() {
2383 return not_requested();
2384 }
2385 let sponsor = match tempo.sponsor_config().await {
2386 Ok(Some(sponsor)) => sponsor.sponsor(),
2387 Ok(None) => return not_requested(),
2388 Err(err) => {
2389 return (
2390 DoctorStep::fail(
2391 SPONSORSHIP,
2392 format!(
2393 "invalid sponsor config: {}",
2394 sanitize_sponsor_config_error(&err.to_string(), tempo)
2395 ),
2396 "pass --tempo.sponsor with either --tempo.sponsor-signer or --tempo.sponsor-sig",
2397 ),
2398 None,
2399 );
2400 }
2401 };
2402
2403 let step = if sponsor == sender {
2404 DoctorStep::fail(
2405 SPONSORSHIP,
2406 format!("sponsor {sponsor} equals transaction sender {sender}"),
2407 "use a different fee payer for sponsored transactions",
2408 )
2409 } else if tempo.sponsor_sig.is_some() {
2410 DoctorStep::warn(
2411 SPONSORSHIP,
2412 format!("signature syntax parsed for sponsor {sponsor}"),
2413 "doctor cannot recover fee_payer_signature without the exact transaction digest",
2414 )
2415 } else {
2416 DoctorStep::pass(SPONSORSHIP, format!("sponsor signer configured for {sponsor}"))
2417 };
2418 (step, Some(sponsor))
2419}
2420
2421fn sanitize_sponsor_config_error(message: &str, tempo: &TempoOpts) -> String {
2422 let mut sanitized = message.to_string();
2423 if let Some(spec) = tempo.sponsor_signer.as_deref()
2424 && spec.starts_with("private-key://")
2425 {
2426 sanitized = sanitized.replace(spec, "private-key://<redacted>");
2427 }
2428 redact_private_key_uri_tokens(&sanitized)
2429}
2430
2431fn redact_private_key_uri_tokens(message: &str) -> String {
2432 const PREFIX: &str = "private-key://";
2433 let mut redacted = String::with_capacity(message.len());
2434 let mut rest = message;
2435 while let Some(idx) = rest.find(PREFIX) {
2436 redacted.push_str(&rest[..idx + PREFIX.len()]);
2437 redacted.push_str("<redacted>");
2438 let after_prefix = &rest[idx + PREFIX.len()..];
2439 let end = after_prefix
2440 .find(|c: char| c.is_whitespace() || matches!(c, '`' | '\'' | '"' | ',' | ';' | ')'))
2441 .unwrap_or(after_prefix.len());
2442 rest = &after_prefix[end..];
2443 }
2444 redacted.push_str(rest);
2445 redacted
2446}
2447
2448#[allow(clippy::too_many_arguments)]
2450async fn run_authorize(
2451 key_address: Address,
2452 key_type: SignatureType,
2453 expiry: u64,
2454 enforce_limits: bool,
2455 limits: Vec<TokenLimit>,
2456 allowed_calls: Vec<CallScope>,
2457 scopes_present: bool,
2458 witness: Option<B256>,
2459 admin: bool,
2460 tx_opts: TransactionOpts,
2461 send_tx: SendTxOpts,
2462 force: bool,
2463) -> Result<()> {
2464 let enforce = enforce_limits || !limits.is_empty();
2465 let (_, provider) = tempo_provider(&send_tx.eth.rpc)?;
2466
2467 if admin {
2469 require_hardfork(
2470 &provider,
2471 TempoHardfork::T6,
2472 "--admin requires a Tempo T6-capable AccountKeychain RPC",
2473 )
2474 .await?;
2475 eyre::ensure!(expiry == u64::MAX, "--admin cannot be combined with an explicit --expiry");
2477 eyre::ensure!(
2478 !enforce,
2479 "--admin cannot be combined with spending limits (--enforce-limits / --limit)"
2480 );
2481 eyre::ensure!(
2482 !scopes_present,
2483 "--admin cannot be combined with call scopes (--scope / --scopes)"
2484 );
2485
2486 let call = authorizeAdminKeyCall {
2488 keyId: key_address,
2489 signatureType: key_type,
2490 witness: witness.unwrap_or(B256::ZERO),
2491 };
2492 return send_keychain_call(&call, tx_opts, &send_tx, force).await;
2493 }
2494
2495 let is_t3 = is_tempo_hardfork_active(&provider, TempoHardfork::T3).await?;
2496 if witness.is_some() {
2497 require_hardfork(
2498 &provider,
2499 TempoHardfork::T5,
2500 "--witness requires a Tempo T5-capable AccountKeychain RPC",
2501 )
2502 .await?;
2503 }
2504
2505 let calldata = if is_t3 {
2506 let config = KeyRestrictions {
2507 expiry,
2508 enforceLimits: enforce,
2509 limits,
2510 allowAnyCalls: !scopes_present,
2511 allowedCalls: allowed_calls,
2512 };
2513 match witness {
2514 Some(witness) => authorizeKeyWithWitnessCall {
2515 keyId: key_address,
2516 signatureType: key_type,
2517 config,
2518 witness,
2519 }
2520 .abi_encode(),
2521 None => authorizeKeyCall { keyId: key_address, signatureType: key_type, config }
2522 .abi_encode(),
2523 }
2524 } else {
2525 eyre::ensure!(
2527 !scopes_present,
2528 "call scopes (--scope / --scopes) require a Tempo T3-capable chain"
2529 );
2530 if let Some(limit) = limits.iter().find(|limit| limit.period != 0) {
2531 eyre::bail!(
2532 "legacy AccountKeychain authorization does not support periodic limits; remove \
2533 the period from --limit {}:{}:{} or use a Tempo T3-capable chain",
2534 limit.token,
2535 limit.amount,
2536 limit.period
2537 );
2538 }
2539 legacyAuthorizeKeyCall {
2540 keyId: key_address,
2541 signatureType: key_type,
2542 expiry,
2543 enforceLimits: enforce,
2544 limits: limits
2545 .into_iter()
2546 .map(|l| LegacyTokenLimit { token: l.token, amount: l.amount })
2547 .collect(),
2548 }
2549 .abi_encode()
2550 };
2551
2552 send_keychain_tx(calldata, tx_opts, &send_tx, None, force).await?;
2553 Ok(())
2554}
2555
2556async fn run_key_auth_sign(
2557 args: KeyAuthorizationArgs,
2558 account: Option<Address>,
2559 wallet: WalletOpts,
2560 browser: BrowserWalletOpts,
2561) -> Result<()> {
2562 let is_admin = args.admin;
2563 let chain_id = args.chain_id;
2564
2565 if browser.browser && (args.witness.is_some() || is_admin || account.is_some()) {
2568 eyre::bail!(
2569 "browser key authorization signing does not support T5/T6 fields yet: witness, admin, account"
2570 );
2571 }
2572
2573 if let Some(browser) = browser.run::<TempoNetwork>().await? {
2574 let signer_address = browser.address();
2575 ensure_root_sender(signer_address, wallet.from, "key authorization")?;
2576 let authorization = args.into_authorization(None)?;
2578 let key_type = authorization.key_type;
2579 let signature_hash = authorization.signature_hash();
2580 let signed = browser.sign_key_authorization(authorization).await?;
2581 return print_signed_key_authorization(&signed, signature_hash, signer_address, key_type);
2582 }
2583
2584 let (signer, tempo_access_key) = wallet.maybe_signer_for_chain(chain_id).await?;
2585 let signer_address = match (&signer, &tempo_access_key) {
2586 (Some(signer), None) => signer.address(),
2587 (None, Some(wallet)) => wallet.key_id()?,
2588 _ => eyre::bail!(
2589 "a signer is required to sign key authorizations; pass a signer with \
2590 --browser, --private-key, --keystore, Ledger, Trezor, AWS, GCP, or Turnkey"
2591 ),
2592 };
2593
2594 let bound_account = if let Some(access_key) = &tempo_access_key {
2596 if let Some(explicit) = account {
2598 eyre::ensure!(
2599 explicit == access_key.account(),
2600 "--bind-account {explicit} does not match the selected Tempo access key's root account {}",
2601 access_key.account(),
2602 );
2603 }
2604 Some(access_key.account())
2605 } else {
2606 ensure_root_sender(signer_address, wallet.from, "key authorization")?;
2607 account.or(is_admin.then_some(signer_address))
2608 };
2609
2610 let authorization = args.into_authorization(bound_account)?;
2611 let key_type = authorization.key_type;
2612 let signature_hash = authorization.signature_hash();
2613 let signature = match (&signer, &tempo_access_key) {
2614 (Some(signer), None) => {
2615 PrimitiveSignature::Secp256k1(signer.sign_hash(&signature_hash).await?)
2616 }
2617 (None, Some(wallet)) => wallet.sign_hash(&signature_hash).await?,
2618 _ => eyre::bail!("exactly one signer is required to sign a key authorization"),
2619 };
2620 let signed = authorization.into_signed(signature);
2621 print_signed_key_authorization(&signed, signature_hash, signer_address, key_type)
2622}
2623
2624fn print_signed_key_authorization(
2625 signed: &SignedKeyAuthorization,
2626 signature_hash: B256,
2627 signer_address: Address,
2628 authorized_key_type: AuthSignatureType,
2629) -> Result<()> {
2630 let encoded = alloy_rlp::encode(signed);
2631 print_json_or(
2632 json!({
2633 "signed_key_authorization": hex::encode_prefixed(&encoded),
2634 "signature_hash": signature_hash,
2635 "rlp_length": encoded.len(),
2636 "signer": signer_address,
2637 "authorized_key_type": key_type_name(authorized_key_type.into()),
2638 "signature_type": key_type_name(signed.signature.signature_type().into()),
2639 "witness": signed.authorization.witness(),
2640 "is_admin": signed.authorization.is_admin(),
2641 "account": signed.authorization.account,
2642 }),
2643 hex::encode_prefixed(&encoded),
2644 )
2645}
2646
2647fn decode_and_validate_key_authorization(
2653 authorization: &str,
2654 expected_account: Option<Address>,
2655) -> Result<(KeyAuthorization, bool, Option<Address>)> {
2656 let raw = authorization.trim();
2657 let (auth, signed, signer) =
2658 match tempo::decode_key_authorization::<SignedKeyAuthorization>(raw) {
2659 Ok(signed) => {
2661 let signer = signed.recover_signer().ok();
2662 (signed.authorization, true, signer)
2663 }
2664 Err(signed_err) => match tempo::decode_key_authorization::<KeyAuthorization>(raw) {
2665 Ok(unsigned) => (unsigned, false, None),
2666 Err(unsigned_err) => eyre::bail!(
2667 "could not decode key authorization as signed ({signed_err}) or unsigned \
2668 ({unsigned_err})"
2669 ),
2670 },
2671 };
2672
2673 eyre::ensure!(
2675 auth.account != Some(Address::ZERO),
2676 "key authorization account cannot be the zero address"
2677 );
2678 if auth.is_admin() {
2679 eyre::ensure!(auth.expiry.is_none(), "admin key authorization cannot carry an expiry");
2683 eyre::ensure!(
2684 auth.limits.is_none(),
2685 "admin key authorization cannot carry spending limits"
2686 );
2687 eyre::ensure!(
2688 auth.allowed_calls.is_none(),
2689 "admin key authorization cannot carry call scopes"
2690 );
2691 }
2692
2693 if let Some(expected) = expected_account {
2695 match auth.account {
2696 Some(account) if account == expected => {}
2697 Some(account) => eyre::bail!(
2698 "key authorization is bound to account {account} but {expected} was expected"
2699 ),
2700 None => eyre::bail!(
2701 "expected key authorization bound to account {expected} but it has no account field"
2702 ),
2703 }
2704 }
2705
2706 Ok((auth, signed, signer))
2707}
2708
2709fn run_key_auth_inspect(authorization: &str, expected_account: Option<Address>) -> Result<()> {
2712 let (auth, signed, signer) =
2713 decode_and_validate_key_authorization(authorization, expected_account)?;
2714 let key_type = key_type_name(auth.key_type.into());
2715
2716 if shell::is_json() {
2717 let json = json!({
2718 "signed": signed,
2719 "signer": signer,
2720 "chain_id": auth.chain_id,
2721 "key_address": auth.key_id,
2722 "key_type": key_type,
2723 "is_admin": auth.is_admin(),
2724 "account": auth.account,
2725 "expiry": auth.expiry,
2726 "witness": auth.witness(),
2727 "enforce_limits": auth.limits.is_some(),
2728 "scoped_calls": auth.allowed_calls.is_some(),
2729 });
2730 return sh_println!("{}", serde_json::to_string_pretty(&json)?);
2731 }
2732
2733 sh_println!("Signed: {signed}")?;
2734 if let Some(signer) = signer {
2735 sh_println!("Signer: {signer}")?;
2736 }
2737 sh_println!("Chain ID: {}", auth.chain_id)?;
2738 sh_println!("Key Address: {}", auth.key_id)?;
2739 sh_println!("Key Type: {key_type}")?;
2740 sh_println!("Admin: {}", auth.is_admin())?;
2741 if let Some(account) = auth.account {
2742 sh_println!("Account: {account}")?;
2743 }
2744 match auth.expiry {
2745 Some(expiry) => sh_println!("Expiry: {expiry}")?,
2746 None => sh_println!("Expiry: none")?,
2747 }
2748 match auth.witness() {
2749 Some(witness) => sh_println!("Witness: {witness}")?,
2750 None => sh_println!("Witness: none")?,
2751 }
2752 sh_println!("Enforce Lim: {}", auth.limits.is_some())?;
2753 sh_println!("Scoped Calls: {}", auth.allowed_calls.is_some())
2754}
2755
2756impl KeyAuthorizationArgs {
2757 fn into_authorization(self, account: Option<Address>) -> Result<KeyAuthorization> {
2763 let (scopes, scopes_present) = match self.scopes_json {
2764 Some(AuthScopesJson(scopes)) => (scopes, true),
2765 None => {
2766 let present = !self.scope.is_empty();
2767 (self.scope, present)
2768 }
2769 };
2770 let has_limits = self.enforce_limits || !self.limits.is_empty();
2771
2772 eyre::ensure!(account != Some(Address::ZERO), "--account cannot be the zero address");
2773 if self.admin {
2774 eyre::ensure!(account.is_some(), "--admin requires --account");
2775 eyre::ensure!(self.expiry.is_none(), "--admin cannot be combined with --expiry");
2776 eyre::ensure!(
2777 !has_limits,
2778 "--admin cannot be combined with spending limits (--enforce-limits / --limit)"
2779 );
2780 eyre::ensure!(
2781 !scopes_present,
2782 "--admin cannot be combined with call scopes (--scope / --scopes)"
2783 );
2784 }
2785
2786 let mut authorization =
2787 KeyAuthorization::unrestricted(self.chain_id, self.key_type, self.key_address);
2788 if let Some(expiry) = self.expiry {
2789 eyre::ensure!(expiry != 0, "--expiry must be greater than zero");
2790 authorization = authorization.with_expiry(expiry);
2791 }
2792 if has_limits {
2793 authorization = authorization.with_limits(self.limits);
2794 }
2795 if scopes_present {
2796 authorization = authorization.with_allowed_calls(scopes);
2797 }
2798 if let Some(witness) = self.witness {
2799 authorization = authorization.with_witness(witness);
2800 }
2801 Ok(match account {
2803 Some(account) if self.admin => authorization.into_admin(account),
2804 Some(account) => authorization.with_account(account),
2805 None => authorization,
2806 })
2807 }
2808}
2809
2810#[allow(clippy::too_many_arguments)]
2812async fn run_policy_add_call(
2813 key_address: Address,
2814 root_account: Option<Address>,
2815 target: Address,
2816 selector: [u8; 4],
2817 recipients: Vec<Address>,
2818 tx_opts: TransactionOpts,
2819 send_tx: SendTxOpts,
2820 force: bool,
2821) -> Result<()> {
2822 let (root_account, _) = resolve_key_metadata(key_address, root_account)?;
2823 let (_, provider) = tempo_provider(&send_tx.eth.rpc)?;
2824 require_hardfork(
2825 &provider,
2826 TempoHardfork::T3,
2827 "allowed-call policy editing requires the Tempo T3 hardfork",
2828 )
2829 .await?;
2830
2831 let allowed =
2832 provider.account_keychain().getAllowedCalls(root_account, key_address).call().await?;
2833 let new_rule = SelectorRule { selector: selector.into(), recipients };
2834 let existing = allowed
2835 .isScoped
2836 .then(|| allowed.scopes.into_iter().find(|scope| scope.target == target))
2837 .flatten();
2838 let (scope, changed) = match existing {
2839 Some(mut scope) => {
2840 if scope.selectorRules.is_empty() {
2841 sh_warn!(
2842 "Allowed calls for {} already allow any selector; leaving wildcard scope unchanged",
2843 address_label_with_address(target)
2844 )?;
2845 }
2846 let changed = add_selector_rule_to_scope(&mut scope, new_rule);
2847 (scope, changed)
2848 }
2849 None => (CallScope { target, selectorRules: vec![new_rule] }, true),
2850 };
2851
2852 if !changed {
2853 return if shell::is_json() {
2854 sh_println!("{}", json!({ "status": "already_present", "target": target }))
2855 } else {
2856 sh_status!("Allowed call already present for {}", address_label_with_address(target))
2857 };
2858 }
2859
2860 send_keychain_call(
2861 &IAccountKeychain::setAllowedCallsCall { keyId: key_address, scopes: vec![scope] },
2862 tx_opts,
2863 &send_tx,
2864 force,
2865 )
2866 .await
2867}
2868
2869#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2870pub(crate) enum KeychainTxOutcome {
2871 Aborted,
2872 Submitted,
2873 PrintedSponsorHash,
2874}
2875
2876pub(crate) enum KeychainRootSigner {
2877 Browser(BrowserSigner<TempoNetwork>),
2878 Wallet(Box<WalletSigner>),
2879}
2880
2881impl KeychainRootSigner {
2882 fn address(&self) -> Address {
2883 match self {
2884 Self::Browser(browser) => browser.address(),
2885 Self::Wallet(signer) => signer.address(),
2886 }
2887 }
2888
2889 fn sender(&self) -> SenderKind<'_> {
2890 match self {
2891 Self::Browser(browser) => browser.address().into(),
2892 Self::Wallet(signer) => signer.as_ref().into(),
2893 }
2894 }
2895}
2896
2897pub(crate) async fn resolve_keychain_root_signer(
2899 send_tx: &SendTxOpts,
2900 expected_from: Option<Address>,
2901 print_sponsor_hash: bool,
2902) -> Result<KeychainRootSigner> {
2903 const WHAT: &str = "AccountKeychain transaction";
2904 let (signer, tempo_access_key) = send_tx.eth.wallet.maybe_signer().await?;
2905 if let Some(browser) = send_tx.browser.run::<TempoNetwork>().await? {
2906 ensure_root_sender(browser.address(), expected_from, WHAT)?;
2907 return Ok(KeychainRootSigner::Browser(browser));
2908 }
2909
2910 if tempo_access_key.is_some() {
2916 eyre::bail!(
2917 "submitting AccountKeychain admin mutators (authorize / revoke / policy) signed by a \
2918 Tempo access key currently reverts on-chain with UnauthorizedCaller() on the pinned \
2919 Tempo build, even for an active admin key. Use a root account signer (--browser for \
2920 passkey roots, or --private-key / --keystore / Ledger / Trezor / AWS / GCP / Turnkey) \
2921 for direct mutations."
2922 );
2923 }
2924
2925 let signer = match signer {
2926 Some(signer) => signer,
2927 None if print_sponsor_hash => eyre::bail!(
2928 "--tempo.print-sponsor-hash requires a root account signer, such as \
2929 --browser, --private-key, or --keystore"
2930 ),
2931 None => send_tx.eth.wallet.signer().await?,
2932 };
2933 ensure_root_sender(signer.address(), expected_from, WHAT)?;
2934 Ok(KeychainRootSigner::Wallet(Box::new(signer)))
2935}
2936
2937async fn send_keychain_call(
2939 call: &impl SolCall,
2940 tx_opts: TransactionOpts,
2941 send_tx: &SendTxOpts,
2942 force: bool,
2943) -> Result<()> {
2944 send_keychain_tx(call.abi_encode(), tx_opts, send_tx, None, force).await?;
2945 Ok(())
2946}
2947
2948pub(crate) async fn send_keychain_tx(
2950 calldata: Vec<u8>,
2951 tx_opts: TransactionOpts,
2952 send_tx: &SendTxOpts,
2953 expected_from: Option<Address>,
2954 force: bool,
2955) -> Result<KeychainTxOutcome> {
2956 let root_signer =
2957 resolve_keychain_root_signer(send_tx, expected_from, tx_opts.tempo.print_sponsor_hash)
2958 .await?;
2959 send_keychain_tx_with_root_signer(calldata, tx_opts, send_tx, root_signer, force, || Ok(()))
2960 .await
2961}
2962
2963pub(crate) async fn send_keychain_tx_with_root_signer(
2965 calldata: Vec<u8>,
2966 mut tx_opts: TransactionOpts,
2967 send_tx: &SendTxOpts,
2968 root_signer: KeychainRootSigner,
2969 force: bool,
2970 before_submit: impl FnOnce() -> Result<()>,
2971) -> Result<KeychainTxOutcome> {
2972 if tx_opts.tempo.sponsor_url.is_some() {
2973 eyre::bail!(
2974 "--sponsor-url is not supported by cast keychain; use --tempo.sponsor with \
2975 --tempo.sponsor-signer or --tempo.sponsor-sig"
2976 );
2977 }
2978
2979 let print_sponsor_hash = tx_opts.tempo.print_sponsor_hash;
2980 let sponsor_fee_payer = tx_opts.tempo.sponsor;
2981 let expires_at = tx_opts.tempo.resolve_expires();
2982 let tempo_sponsor =
2983 if print_sponsor_hash { None } else { tx_opts.tempo.sponsor_config().await? };
2984
2985 let (config, provider) = tempo_provider(&send_tx.eth)?;
2986 apply_poll_interval(&provider, send_tx.poll_interval);
2987 let fee_provider = (!config.eth_rpc_curl).then_some(&provider);
2989
2990 let resolved_lane = resolve_lane(&mut tx_opts.tempo, &config.root)?;
2993
2994 let builder = CastTxBuilder::new(&provider, tx_opts, &config)
2995 .await?
2996 .with_to(Some(NameOrAddress::Address(ACCOUNT_KEYCHAIN_ADDRESS)))
2997 .await?
2998 .with_code_sig_and_args(None, Some(hex::encode_prefixed(&calldata)), vec![])
2999 .await?;
3000
3001 let from = root_signer.address();
3002 if print_sponsor_hash {
3003 let Some(mut tx) =
3004 confirm_and_build(builder, root_signer.sender(), force, None, false).await?
3005 else {
3006 return Ok(KeychainTxOutcome::Aborted);
3007 };
3008 let hash = sponsor_hash(fee_provider, &mut tx, from, sponsor_fee_payer).await?;
3009 if shell::is_json() {
3010 sh_println!("{}", json!({ "sponsor_hash": format!("{hash:?}") }))?;
3011 } else {
3012 sh_println!("{hash:?}")?;
3013 }
3014 return Ok(KeychainTxOutcome::PrintedSponsorHash);
3015 }
3016
3017 print_expires(expires_at)?;
3018
3019 let send_opts =
3020 SendOptions::new(send_tx, &config).resolving_fee_token(tempo_sponsor.is_none(), &config);
3021 let is_browser = matches!(root_signer, KeychainRootSigner::Browser(_));
3022 let (builder, lane) = if is_browser {
3023 (builder.with_browser_wallet(), None)
3024 } else {
3025 (builder, resolved_lane.as_ref())
3026 };
3027 let Some(mut tx) = confirm_and_build(builder, root_signer.sender(), force, lane, false).await?
3028 else {
3029 return Ok(KeychainTxOutcome::Aborted);
3030 };
3031 apply_fee_payment::<TempoNetwork, _>(tempo_sponsor.as_ref(), fee_provider, &mut tx, from)
3032 .await?;
3033 before_submit()?;
3034
3035 match root_signer {
3036 KeychainRootSigner::Browser(browser) => {
3037 tx.prep_for_submission();
3038 let tx_hash = browser.send_transaction_via_browser(tx).await?;
3039 send_opts.print_tx_result(&provider, tx_hash).await?;
3040 }
3041 KeychainRootSigner::Wallet(signer) => {
3042 let provider = AlloyProviderBuilder::<_, _, TempoNetwork>::default()
3043 .wallet(EthereumWallet::from(*signer))
3044 .connect_provider(&provider);
3045 cast_send(provider, tx, &send_opts).await?;
3046 }
3047 }
3048
3049 Ok(KeychainTxOutcome::Submitted)
3050}
3051
3052fn ensure_root_sender(actual: Address, expected: Option<Address>, what: &str) -> Result<()> {
3054 if let Some(expected) = expected
3055 && actual != expected
3056 {
3057 eyre::bail!(
3058 "{what} must be signed by root account {expected}; resolved signer is {actual}"
3059 );
3060 }
3061 Ok(())
3062}
3063
3064fn resolve_key_metadata(
3066 key_address: Address,
3067 root_account: Option<Address>,
3068) -> Result<(Address, Option<tempo::KeyEntry>)> {
3069 let store = read_tempo_accounts_store();
3070 if let Some(root_account) = root_account {
3071 let entry = store.and_then(|store| {
3072 store.keys.into_iter().find(|entry| {
3073 entry.wallet_address == root_account && entry.key_address == key_address
3074 })
3075 });
3076 return Ok((root_account, entry));
3077 }
3078
3079 let path = tempo_accounts_store_path_display();
3080 let Some(store) = store else {
3081 eyre::bail!(
3082 "key {key_address} was not found because the Tempo Accounts store could not be read at {path}; pass --root-account"
3083 );
3084 };
3085 let mut matches =
3086 store.keys.into_iter().filter(|entry| entry.key_address == key_address).peekable();
3087 let Some(root_account) = matches.peek().map(|entry| entry.wallet_address) else {
3088 eyre::bail!("key {key_address} was not found in {path}; pass --root-account");
3089 };
3090 let matches: Vec<_> = matches.collect();
3091 if matches.iter().any(|entry| entry.wallet_address != root_account) {
3092 eyre::bail!(
3093 "key {key_address} matches multiple root accounts in {path}; pass --root-account"
3094 );
3095 }
3096 let preferred = matches.iter().position(|entry| !entry.limits.is_empty()).unwrap_or(0);
3097 Ok((root_account, matches.into_iter().nth(preferred)))
3098}
3099
3100fn tempo_accounts_store_path_display() -> String {
3101 let Some(path) = tempo_accounts_store_path() else {
3102 return "(unknown)".to_string();
3103 };
3104 if let Some(home) =
3105 std::env::var_os("HOME").filter(|home| !home.is_empty()).map(std::path::PathBuf::from)
3106 && let Ok(relative) = path.strip_prefix(&home)
3107 && relative == std::path::Path::new(".tempo/wallet/store.json")
3108 {
3109 return "~/.tempo/wallet/store.json".to_string();
3110 }
3111 path.display().to_string()
3112}
3113
3114fn add_selector_rule_to_scope(scope: &mut CallScope, rule: SelectorRule) -> bool {
3116 if scope.selectorRules.is_empty() {
3117 return false;
3118 }
3119 let Some(existing) =
3120 scope.selectorRules.iter_mut().find(|existing| existing.selector == rule.selector)
3121 else {
3122 scope.selectorRules.push(rule);
3123 return true;
3124 };
3125 if existing.recipients.is_empty() {
3126 return false;
3127 }
3128 if rule.recipients.is_empty() {
3129 existing.recipients = Vec::new();
3130 return true;
3131 }
3132 let mut changed = false;
3133 for recipient in rule.recipients {
3134 if !existing.recipients.contains(&recipient) {
3135 existing.recipients.push(recipient);
3136 changed = true;
3137 }
3138 }
3139 changed
3140}
3141
3142fn inspected_limit_to_json(limit: &InspectedLimit) -> Value {
3143 json!({
3144 "token": limit.token,
3145 "token_label": address_label(limit.token),
3146 "configured_amount": limit.configured_amount,
3147 "remaining": limit.remaining.to_string(),
3148 "period_end": limit.period_end,
3149 "period_end_human": limit.period_end.filter(|&end| end != 0).map(format_period_end),
3150 })
3151}
3152
3153fn allowed_calls_to_json(allowed_calls: &AllowedCallsView) -> Value {
3154 let (mode, scopes) = match allowed_calls {
3155 AllowedCallsView::Unsupported => ("unsupported", &[][..]),
3156 AllowedCallsView::Unrestricted => ("any", &[][..]),
3157 AllowedCallsView::Scoped(scopes) => {
3158 (if scopes.is_empty() { "none" } else { "scoped" }, scopes.as_slice())
3159 }
3160 };
3161 let scopes: Vec<_> = scopes
3162 .iter()
3163 .map(|scope| {
3164 json!({
3165 "target": scope.target,
3166 "target_label": address_label(scope.target),
3167 "selectors": scope.selectorRules.iter().map(|rule| json!({
3168 "selector": hex::encode_prefixed(rule.selector),
3169 "signature": selector_signature(&rule.selector.0),
3170 "recipients": rule.recipients,
3171 })).collect::<Vec<_>>(),
3172 })
3173 })
3174 .collect();
3175 json!({ "mode": mode, "scopes": scopes })
3176}
3177
3178fn print_inspected_limits(enforce_limits: bool, limits: &[InspectedLimit]) -> Result<()> {
3179 if !enforce_limits {
3180 return sh_println!("Limits: none");
3181 }
3182 sh_println!("Limits:")?;
3183 if limits.is_empty() {
3184 return sh_println!(" enforced, but no local limit metadata was found");
3185 }
3186 for limit in limits {
3187 sh_println!(
3188 " {}: {} / {} remaining{}",
3189 address_label(limit.token),
3190 limit.remaining,
3191 limit.configured_amount,
3192 format_period_suffix(limit.period_end)
3193 )?;
3194 }
3195 Ok(())
3196}
3197
3198fn print_allowed_calls(allowed_calls: &AllowedCallsView) -> Result<()> {
3199 let scopes = match allowed_calls {
3200 AllowedCallsView::Unsupported => {
3201 return sh_println!("Allowed calls: unsupported before T3");
3202 }
3203 AllowedCallsView::Unrestricted => return sh_println!("Allowed calls: any"),
3204 AllowedCallsView::Scoped(scopes) if scopes.is_empty() => {
3205 return sh_println!("Allowed calls: none");
3206 }
3207 AllowedCallsView::Scoped(scopes) => scopes,
3208 };
3209 sh_println!("Allowed calls:")?;
3210 for scope in scopes {
3211 sh_println!(" {}:", address_label_with_address(scope.target))?;
3212 if scope.selectorRules.is_empty() {
3213 sh_println!(" any selector")?;
3214 }
3215 for rule in &scope.selectorRules {
3216 sh_println!(
3217 " {} -> {}",
3218 format_selector(&rule.selector.0),
3219 format_recipients(&rule.recipients)
3220 )?;
3221 }
3222 }
3223 Ok(())
3224}
3225
3226fn address_label(address: Address) -> String {
3227 if address == PATH_USD_ADDRESS { "PathUSD".to_string() } else { address.to_string() }
3228}
3229
3230fn address_label_with_address(address: Address) -> String {
3231 if address == PATH_USD_ADDRESS { format!("PathUSD ({address})") } else { address.to_string() }
3232}
3233
3234fn format_selector(selector: &[u8; 4]) -> String {
3235 selector_signature(selector).map_or_else(|| hex::encode_prefixed(selector), str::to_string)
3236}
3237
3238fn selector_signature(selector: &[u8; 4]) -> Option<&'static str> {
3239 const KNOWN: [([u8; 4], &str); 7] = [
3240 (ITIP20::transferCall::SELECTOR, "transfer(address,uint256)"),
3241 (ITIP20::approveCall::SELECTOR, "approve(address,uint256)"),
3242 (ITIP20::transferFromCall::SELECTOR, "transferFrom(address,address,uint256)"),
3243 (ITIP20::transferWithMemoCall::SELECTOR, "transferWithMemo(address,uint256,bytes32)"),
3244 (
3245 ITIP20::transferFromWithMemoCall::SELECTOR,
3246 "transferFromWithMemo(address,address,uint256,bytes32)",
3247 ),
3248 (ITIP20::mintCall::SELECTOR, "mint(address,uint256)"),
3249 (ITIP20::burnCall::SELECTOR, "burn(uint256)"),
3250 ];
3251 KNOWN.iter().find(|(known, _)| known == selector).map(|(_, signature)| *signature)
3252}
3253
3254fn format_recipients(recipients: &[Address]) -> String {
3255 if recipients.is_empty() {
3256 return "any recipient".to_string();
3257 }
3258 let recipients = recipients.iter().map(ToString::to_string).collect::<Vec<_>>().join(", ");
3259 format!("recipients [{recipients}]")
3260}
3261
3262fn format_expiry_for_inspect(expiry: u64) -> String {
3263 if expiry == u64::MAX {
3264 return "never".to_string();
3265 }
3266 format!("{} ({})", format_timestamp_iso(expiry), format_relative_timestamp(expiry))
3267}
3268
3269fn format_period_end(period_end: u64) -> String {
3270 format!("period resets {}", format_relative_timestamp(period_end))
3271}
3272
3273fn format_period_suffix(period_end: Option<u64>) -> String {
3275 period_end
3276 .filter(|&end| end != 0)
3277 .map(|end| format!(" ({})", format_period_end(end)))
3278 .unwrap_or_default()
3279}
3280
3281fn format_utc(timestamp: u64, format: &str) -> String {
3282 DateTime::from_timestamp(timestamp as i64, 0)
3283 .map_or_else(|| timestamp.to_string(), |dt| dt.format(format).to_string())
3284}
3285
3286fn format_timestamp_iso(timestamp: u64) -> String {
3287 format_utc(timestamp, "%Y-%m-%dT%H:%M:%SZ")
3288}
3289
3290fn format_relative_timestamp(timestamp: u64) -> String {
3291 format_relative_timestamp_from(timestamp, now().as_secs())
3292}
3293
3294fn format_relative_timestamp_from(timestamp: u64, now: u64) -> String {
3295 if timestamp == now {
3296 "now".to_string()
3297 } else if timestamp > now {
3298 format!("in {}", format_duration_words(timestamp - now))
3299 } else {
3300 format!("{} ago", format_duration_words(now - timestamp))
3301 }
3302}
3303
3304fn format_duration_words(seconds: u64) -> String {
3305 const MINUTE: u64 = 60;
3306 const HOUR: u64 = 60 * MINUTE;
3307 const DAY: u64 = 24 * HOUR;
3308 match seconds {
3309 DAY.. => {
3310 let days = seconds / DAY;
3311 if days == 1 { "1 day".to_string() } else { format!("{days} days") }
3312 }
3313 HOUR.. => format!("{}h", seconds / HOUR),
3314 MINUTE.. => format!("{}m", seconds / MINUTE),
3315 _ => format!("{seconds}s"),
3316 }
3317}
3318
3319fn format_expiry(expiry: u64) -> String {
3320 if expiry == u64::MAX {
3321 return "never".to_string();
3322 }
3323 format_utc(expiry, "%Y-%m-%d %H:%M:%S UTC")
3324}
3325
3326fn load_accounts_store() -> Result<AccountsStoreView> {
3327 read_tempo_accounts_store().ok_or_else(|| {
3328 let path = tempo_accounts_store_path()
3329 .map_or_else(|| "(unknown)".to_string(), |p| p.display().to_string());
3330 eyre::eyre!("could not read Tempo Accounts store at {path}")
3331 })
3332}
3333
3334const fn key_role(is_root: bool, is_admin: bool) -> &'static str {
3337 if is_root {
3338 "root"
3339 } else if is_admin {
3340 "admin"
3341 } else {
3342 "limited"
3343 }
3344}
3345
3346fn print_key_entry(entry: &tempo::KeyEntry) -> Result<()> {
3347 let is_direct = entry.key_address == entry.wallet_address;
3348 let auth = entry.key_authorization.as_ref().map(|signed| &signed.authorization);
3349 let is_admin = auth.is_some_and(KeyAuthorization::is_admin);
3350
3351 sh_println!("Wallet: {}", entry.wallet_address)?;
3352 sh_println!("Chain ID: {}", entry.chain_id)?;
3353 sh_println!("Key Type: {}", key_type_name(entry.key_type))?;
3354 sh_println!("Key Address: {}", entry.key_address)?;
3355 sh_println!(
3356 "Mode: {}",
3357 if is_direct { "direct (EOA)" } else { "keychain (access key)" }
3358 )?;
3359 if let Some(expiry) = entry.expiry {
3360 sh_println!("Expiry: {}", format_expiry(expiry))?;
3361 }
3362 sh_println!("Role: {}", key_role(is_direct, is_admin))?;
3363 sh_println!("Has Key: {}", entry.has_inline_key())?;
3364 sh_println!("Has Auth: {}", auth.is_some())?;
3365 if let Some(auth) = auth {
3366 let witness = auth.witness().map_or_else(|| "(none)".to_string(), |w| w.to_string());
3367 sh_println!("Auth Witness: {witness}")?;
3368 sh_println!("Auth Admin: {is_admin}")?;
3369 if let Some(account) = auth.account {
3370 sh_println!("Auth Account: {account}")?;
3371 }
3372 }
3373 if !entry.limits.is_empty() {
3374 sh_println!("Limits:")?;
3375 for limit in &entry.limits {
3376 sh_println!(" {} → {}", limit.currency, limit.limit)?;
3377 }
3378 }
3379 Ok(())
3380}
3381
3382fn key_entry_to_json(entry: &tempo::KeyEntry) -> Value {
3383 let is_direct = entry.key_address == entry.wallet_address;
3384 let auth = entry.key_authorization.as_ref().map(|signed| &signed.authorization);
3385 let is_admin = auth.is_some_and(KeyAuthorization::is_admin);
3386 let limits: Vec<_> =
3387 entry.limits.iter().map(|l| json!({ "currency": l.currency, "limit": l.limit })).collect();
3388 json!({
3389 "wallet_address": entry.wallet_address,
3390 "chain_id": entry.chain_id,
3391 "key_type": key_type_name(entry.key_type),
3392 "key_address": entry.key_address,
3393 "mode": if is_direct { "direct" } else { "keychain" },
3394 "expiry": entry.expiry,
3395 "expiry_human": entry.expiry.map(format_expiry),
3396 "has_key": entry.has_inline_key(),
3397 "has_authorization": auth.is_some(),
3398 "role": key_role(is_direct, is_admin),
3399 "authorization_witness": auth.and_then(KeyAuthorization::witness),
3400 "authorization_is_admin": is_admin,
3401 "authorization_account": auth.and_then(|auth| auth.account),
3402 "limits": limits,
3403 })
3404}
3405
3406#[cfg(test)]
3407mod tests {
3408 use super::*;
3409 use alloy_rlp::Decodable;
3410
3411 fn addr(byte: u8) -> Address {
3412 Address::repeat_byte(byte)
3413 }
3414
3415 fn rule(selector: [u8; 4], recipients: Vec<Address>) -> SelectorRule {
3416 SelectorRule { selector: selector.into(), recipients }
3417 }
3418
3419 fn scope(target: Address, rules: Vec<SelectorRule>) -> CallScope {
3420 CallScope { target, selectorRules: rules }
3421 }
3422
3423 fn stored_entry(wallet: Address, chain_id: u64, key: Address) -> tempo::KeyEntry {
3424 tempo::KeyEntry::new(wallet, chain_id, KeyType::Secp256k1, key)
3425 }
3426
3427 fn signed_authorization_with_limits(
3428 limits: Option<Vec<AuthTokenLimit>>,
3429 ) -> SignedKeyAuthorization {
3430 let mut authorization =
3431 KeyAuthorization::unrestricted(31337, AuthSignatureType::Secp256k1, addr(0x42));
3432 authorization.limits = limits;
3433 authorization.into_signed(PrimitiveSignature::default())
3434 }
3435
3436 fn key_auth_args() -> KeyAuthorizationArgs {
3437 KeyAuthorizationArgs {
3438 chain_id: 31337,
3439 key_address: addr(0x42),
3440 key_type: AuthSignatureType::Secp256k1,
3441 expiry: None,
3442 enforce_limits: false,
3443 limits: vec![],
3444 scope: vec![],
3445 scopes_json: None,
3446 witness: None,
3447 admin: false,
3448 }
3449 }
3450
3451 fn admin_args() -> KeyAuthorizationArgs {
3452 KeyAuthorizationArgs { admin: true, ..key_auth_args() }
3453 }
3454
3455 fn signed_hex(authorization: KeyAuthorization) -> String {
3456 let signed = authorization.into_signed(PrimitiveSignature::from_bytes(&[0u8; 65]).unwrap());
3457 hex::encode_prefixed(alloy_rlp::encode(&signed))
3458 }
3459
3460 #[test]
3461 fn parse_scopes_json_shapes() {
3462 let plain = r#"[{"target":"0x20c0000000000000000000000000000000000001","selectors":["transfer","approve"]},{"target":"0x86A2EE8FAf9A840F7a2c64CA3d51209F9A02081D"}]"#;
3463 let result = parse_scopes_json(plain).unwrap();
3464 assert_eq!(result.len(), 2);
3465 assert_eq!(result[0].selectorRules.len(), 2);
3466 assert!(result[1].selectorRules.is_empty());
3467
3468 let with_recipients = r#"[{"target":"0x20c0000000000000000000000000000000000001","selectors":[{"selector":"transfer","recipients":["0x1111111111111111111111111111111111111111"]}]}]"#;
3469 let result = parse_scopes_json(with_recipients).unwrap();
3470 assert_eq!(result[0].selectorRules[0].recipients.len(), 1);
3471
3472 let unknown_scope_field =
3473 r#"[{"target":"0x20c0000000000000000000000000000000000001","selector":["transfer"]}]"#;
3474 assert!(parse_scopes_json(unknown_scope_field).is_err());
3475 let unknown_selector_field = r#"[{"target":"0x20c0000000000000000000000000000000000001","selectors":[{"selector":"transfer","recipients":[],"bogus":true}]}]"#;
3476 assert!(parse_scopes_json(unknown_selector_field).is_err());
3477 }
3478
3479 #[test]
3480 fn parse_limit_grammar() {
3481 let token = "0x20c0000000000000000000000000000000000000";
3482 let limit = parse_auth_limit(&format!("{token}:10000000")).unwrap();
3483 assert_eq!(limit.token, token.parse::<Address>().unwrap());
3484 assert_eq!(limit.limit, U256::from(10_000_000));
3485 assert_eq!(limit.period, 0);
3486 assert_eq!(parse_auth_limit(&format!("{token}:5:1d")).unwrap().period, 86_400);
3487 assert_eq!(parse_limit(&format!("{token}:5:1d")).unwrap().period, 86_400);
3488 assert!(parse_auth_limit(token).unwrap_err().contains("invalid limit format"));
3489 assert!(parse_auth_limit(&format!("{token}:x")).unwrap_err().contains("invalid amount"));
3490 }
3491
3492 #[test]
3493 fn add_selector_rule_merging() {
3494 let transfer = parse_selector_bytes("transfer").unwrap();
3495 let (first, second) = (addr(0x11), addr(0x22));
3496
3497 let mut merged = scope(PATH_USD_ADDRESS, vec![rule(transfer, vec![first])]);
3498 assert!(add_selector_rule_to_scope(&mut merged, rule(transfer, vec![second])));
3499 assert_eq!(merged.selectorRules.len(), 1);
3500 assert_eq!(merged.selectorRules[0].recipients, vec![first, second]);
3501
3502 let mut widened = scope(PATH_USD_ADDRESS, vec![rule(transfer, vec![first])]);
3503 assert!(add_selector_rule_to_scope(&mut widened, rule(transfer, vec![])));
3504 assert!(widened.selectorRules[0].recipients.is_empty());
3505
3506 let mut wildcard = scope(PATH_USD_ADDRESS, vec![]);
3507 assert!(!add_selector_rule_to_scope(&mut wildcard, rule(transfer, vec![])));
3508 assert!(wildcard.selectorRules.is_empty());
3509 }
3510
3511 #[test]
3512 fn into_authorization_builds_fields() {
3513 let plain = key_auth_args().into_authorization(None).unwrap();
3514 assert!(!plain.is_admin());
3515 assert_eq!(plain.account, None);
3516 assert_eq!(plain.witness(), None);
3517 assert_eq!(plain.allowed_calls, None);
3518 assert!(plain.is_legacy_compatible());
3519
3520 let zero_witness = KeyAuthorizationArgs { witness: Some(B256::ZERO), ..key_auth_args() }
3522 .into_authorization(None)
3523 .unwrap();
3524 assert_eq!(zero_witness.witness(), Some(B256::ZERO));
3525 assert_ne!(plain.signature_hash(), zero_witness.signature_hash());
3526 assert_ne!(alloy_rlp::encode(&plain), alloy_rlp::encode(&zero_witness));
3527
3528 let deny_all =
3530 KeyAuthorizationArgs { scopes_json: Some(AuthScopesJson(vec![])), ..key_auth_args() }
3531 .into_authorization(None)
3532 .unwrap();
3533 assert_eq!(deny_all.allowed_calls, Some(vec![]));
3534 assert_ne!(plain.signature_hash(), deny_all.signature_hash());
3535
3536 let bound = key_auth_args().into_authorization(Some(addr(0xCD))).unwrap();
3538 assert!(!bound.is_admin());
3539 assert_eq!(bound.account, Some(addr(0xCD)));
3540 let admin_a = admin_args().into_authorization(Some(addr(0x01))).unwrap();
3541 let admin_b = admin_args().into_authorization(Some(addr(0x02))).unwrap();
3542 assert!(admin_a.is_admin());
3543 assert_ne!(admin_a.signature_hash(), admin_b.signature_hash());
3544
3545 let signed =
3546 admin_a.clone().into_signed(PrimitiveSignature::from_bytes(&[0u8; 65]).unwrap());
3547 let encoded = alloy_rlp::encode(&signed);
3548 let decoded = SignedKeyAuthorization::decode(&mut encoded.as_slice()).unwrap();
3549 assert_eq!(decoded.authorization, admin_a);
3550
3551 let witness = B256::repeat_byte(0x53);
3553 let signed =
3554 KeyAuthorization::unrestricted(31337, AuthSignatureType::Secp256k1, addr(0x42))
3555 .with_witness(witness)
3556 .into_signed(PrimitiveSignature::from_bytes(&[0u8; 65]).unwrap());
3557 let json = key_entry_to_json(&tempo::KeyEntry::default().with_key_authorization(signed));
3558 assert_eq!(json["authorization_witness"], witness.to_string());
3559 }
3560
3561 #[test]
3562 fn into_authorization_rejects_invalid_args() {
3563 let account = Some(addr(0xAB));
3564 let cases: [(KeyAuthorizationArgs, Option<Address>, &str); 6] = [
3565 (admin_args(), None, "--admin requires --account"),
3566 (
3567 KeyAuthorizationArgs { expiry: Some(1_782_647_677), ..admin_args() },
3568 account,
3569 "--expiry",
3570 ),
3571 (
3572 KeyAuthorizationArgs { enforce_limits: true, ..admin_args() },
3573 account,
3574 "spending limits",
3575 ),
3576 (
3577 KeyAuthorizationArgs { scopes_json: Some(AuthScopesJson(vec![])), ..admin_args() },
3578 account,
3579 "call scopes",
3580 ),
3581 (key_auth_args(), Some(Address::ZERO), "--account cannot be the zero address"),
3582 (
3583 KeyAuthorizationArgs { expiry: Some(0), ..key_auth_args() },
3584 None,
3585 "--expiry must be greater than zero",
3586 ),
3587 ];
3588 for (args, account, expected) in cases {
3589 let err = args.into_authorization(account).unwrap_err().to_string();
3590 assert!(err.contains(expected), "expected {expected:?}, got: {err}");
3591 }
3592 }
3593
3594 #[test]
3595 fn inspect_decodes_signed_and_unsigned_shapes() {
3596 let account = addr(0xAB);
3597 let hex = signed_hex(admin_args().into_authorization(Some(account)).unwrap());
3598 let (auth, signed, _) = decode_and_validate_key_authorization(&hex, None).unwrap();
3599 assert!(signed, "signed input must be reported as signed");
3600 assert!(auth.is_admin());
3601 assert_eq!(auth.account, Some(account));
3602
3603 let unsigned = key_auth_args().into_authorization(None).unwrap();
3604 let hex = hex::encode_prefixed(alloy_rlp::encode(&unsigned));
3605 let (auth, signed, signer) = decode_and_validate_key_authorization(&hex, None).unwrap();
3606 assert!(!signed, "unsigned input must be reported as unsigned");
3607 assert_eq!(auth, unsigned);
3608 assert!(signer.is_none(), "unsigned input must not recover a signer");
3609 }
3610
3611 #[test]
3612 fn inspect_enforces_admin_invariants_and_account_binding() {
3613 let unrestricted =
3614 || KeyAuthorization::unrestricted(31337, AuthSignatureType::Secp256k1, addr(0x42));
3615 let admin_with_expiry = unrestricted().with_expiry(1_782_647_677).into_admin(addr(0xAB));
3618 let mut admin_without_account = unrestricted();
3619 admin_without_account.is_admin = true;
3620
3621 let hex = hex::encode_prefixed(alloy_rlp::encode(&admin_without_account));
3624 let (auth, _, _) = decode_and_validate_key_authorization(&hex, None).unwrap();
3625 assert!(auth.is_admin());
3626 assert_eq!(auth.account, None);
3627
3628 let cases = [
3629 (
3630 signed_hex(admin_args().into_authorization(Some(addr(0xAB))).unwrap()),
3631 Some(addr(0xCD)),
3632 "was expected",
3633 ),
3634 (
3635 hex::encode_prefixed(alloy_rlp::encode(&admin_with_expiry)),
3636 None,
3637 "cannot carry an expiry",
3638 ),
3639 (hex, Some(addr(0xAB)), "no account field"),
3640 ];
3641 for (hex, expected_account, expected) in cases {
3642 let err = decode_and_validate_key_authorization(&hex, expected_account)
3643 .unwrap_err()
3644 .to_string();
3645 assert!(err.contains(expected), "expected {expected:?}, got: {err}");
3646 }
3647 }
3648
3649 #[test]
3650 fn root_sender_mismatch_message_names_the_artifact() {
3651 let (expected, actual) = (addr(0x11), addr(0x22));
3652 let err = ensure_root_sender(actual, Some(expected), "key authorization").unwrap_err();
3653 assert_eq!(
3654 err.to_string(),
3655 format!(
3656 "key authorization must be signed by root account {expected}; resolved signer is {actual}"
3657 )
3658 );
3659 assert!(ensure_root_sender(actual, None, "key authorization").is_ok());
3660 }
3661
3662 #[test]
3663 fn match_allowed_call_cases() {
3664 use AllowedCallMatch::{Allowed, Denied, RecipientRestricted};
3665 let transfer = ITIP20::transferCall::SELECTOR;
3666 let approve = ITIP20::approveCall::SELECTOR;
3667 let (target, other, bob, carol) = (addr(0xAA), addr(0xCC), addr(0xBB), addr(0xDD));
3668 let wildcard = vec![scope(target, vec![])];
3669 let any_recipient = vec![scope(target, vec![rule(transfer, vec![])])];
3670 let restricted = vec![scope(target, vec![rule(transfer, vec![bob])])];
3671 let duplicated = vec![
3672 scope(target, vec![rule(transfer, vec![bob])]),
3673 scope(target, vec![rule(approve, vec![]), rule(transfer, vec![carol])]),
3674 ];
3675 let kind = |m: &AllowedCallMatch| match m {
3676 Allowed(_) => "allowed",
3677 Denied(_) => "denied",
3678 RecipientRestricted(_) => "restricted",
3679 };
3680
3681 let cases = [
3682 (&wildcard, target, transfer, None, "allowed"),
3683 (&wildcard, other, transfer, None, "denied"),
3684 (&any_recipient, target, transfer, Some(bob), "allowed"),
3685 (&any_recipient, target, approve, None, "denied"),
3686 (&restricted, target, transfer, None, "restricted"),
3687 (&restricted, target, transfer, Some(bob), "allowed"),
3688 (&restricted, target, transfer, Some(carol), "denied"),
3689 (&duplicated, target, approve, None, "allowed"),
3690 (&duplicated, target, transfer, Some(carol), "allowed"),
3691 ];
3692 for (scopes, to, selector, recipient, expected) in cases {
3693 let result = match_allowed_call(scopes, to, selector, recipient);
3694 assert_eq!(kind(&result), expected, "{result:?}");
3695 }
3696
3697 assert_eq!(
3699 match_allowed_call(&duplicated, target, transfer, None),
3700 RecipientRestricted(vec![bob, carol])
3701 );
3702 }
3703
3704 #[test]
3705 fn doctor_args_parse() {
3706 let root = "0x1111111111111111111111111111111111111111";
3707 let key = "0x2222222222222222222222222222222222222222";
3708 let KeychainSubcommand::Doctor { key_address, root_account, .. } =
3709 KeychainSubcommand::try_parse_from(["keychain", "doctor", "--root-account", root])
3710 .unwrap()
3711 else {
3712 panic!("expected doctor");
3713 };
3714 assert!(key_address.is_none());
3715 assert!(root_account.is_some());
3716
3717 assert!(
3718 KeychainSubcommand::try_parse_from([
3719 "keychain",
3720 "doctor",
3721 key,
3722 "--selector",
3723 "transfer"
3724 ])
3725 .is_err(),
3726 "--selector without --to should error"
3727 );
3728
3729 let KeychainSubcommand::Doctor { fee_token, tempo, .. } =
3730 KeychainSubcommand::try_parse_from([
3731 "keychain",
3732 "doctor",
3733 key,
3734 "--root-account",
3735 root,
3736 "--fee-token",
3737 "PathUSD",
3738 "--tempo.expiring-nonce",
3739 "--tempo.valid-before",
3740 "9999999999",
3741 ])
3742 .unwrap()
3743 else {
3744 panic!("expected doctor");
3745 };
3746 assert_eq!(fee_token, Some(PATH_USD_ADDRESS));
3747 assert!(tempo.expiring_nonce);
3748 assert_eq!(tempo.valid_before, Some(9_999_999_999));
3749 }
3750
3751 #[test]
3752 fn select_subject_for_chain_preferences() {
3753 let (root, key, other_key) = (addr(0x11), addr(0x22), addr(0x33));
3754
3755 let subject =
3757 select_subject_for_chain(vec![DoctorCandidate::explicit(root, key)], 31337, Some(root))
3758 .unwrap();
3759 assert_eq!((subject.root_account, subject.key_address), (root, key));
3760 assert!(subject.entry.is_none());
3761 assert_eq!(check_local_signing_readiness(&subject).status, DoctorStatus::Warn);
3762
3763 let wrong_chain = stored_entry(root, 1, key).with_locally_signable(true);
3765 let subject = select_subject_for_chain(
3766 vec![DoctorCandidate::from_entry(wrong_chain), DoctorCandidate::explicit(root, key)],
3767 31337,
3768 Some(root),
3769 )
3770 .unwrap();
3771 assert_eq!(subject.key_address, key);
3772 assert!(subject.entry.is_none());
3773
3774 let subject = select_subject_for_chain(
3776 vec![
3777 DoctorCandidate::from_entry(stored_entry(root, 31337, key)),
3778 DoctorCandidate::from_entry(
3779 stored_entry(root, 31337, other_key).with_locally_signable(true),
3780 ),
3781 ],
3782 31337,
3783 Some(root),
3784 )
3785 .unwrap();
3786 assert_eq!(subject.key_address, other_key);
3787
3788 let stale = stored_entry(root, 31337, key)
3790 .with_key_authorization(signed_authorization_with_limits(None));
3791 let subject = select_subject_for_chain(
3792 vec![DoctorCandidate::from_entry(stale), DoctorCandidate::explicit(root, key)],
3793 31337,
3794 Some(root),
3795 )
3796 .unwrap();
3797 assert!(subject.explicit);
3798 assert!(subject.entry.as_ref().is_some_and(|entry| entry.key_authorization.is_some()));
3799 assert_eq!(check_local_signing_readiness(&subject).status, DoctorStatus::Warn);
3800
3801 let mut subject = DoctorSubject {
3803 root_account: root,
3804 key_address: key,
3805 entry: Some(stored_entry(root, 31337, key)),
3806 explicit: false,
3807 };
3808 assert_eq!(check_local_signing_readiness(&subject).status, DoctorStatus::Fail);
3809 subject.entry = Some(stored_entry(root, 31337, key).with_locally_signable(true));
3810 assert_eq!(check_local_signing_readiness(&subject).status, DoctorStatus::Pass);
3811 }
3812
3813 #[test]
3814 fn authorization_spending_limits_warnings() {
3815 let fee_token = addr(0xAA);
3816 let limit = |token, limit, period| AuthTokenLimit { token, limit, period };
3817 let cases = [
3818 (limit(addr(0xBB), U256::ONE, 0), Some(true), "not listed"),
3819 (limit(fee_token, U256::ZERO, 0), Some(true), ""),
3820 (limit(fee_token, U256::ONE, 60), None, "hardfork unknown"),
3821 ];
3822 for (limit, is_t3, detail) in cases {
3823 let signed = signed_authorization_with_limits(Some(vec![limit]));
3824 let step = check_authorization_spending_limits(&signed, fee_token, is_t3);
3825 assert_eq!(step.status, DoctorStatus::Warn, "{step:?}");
3826 assert!(step.detail.contains(detail), "{step:?}");
3827 }
3828 }
3829
3830 #[test]
3831 fn key_role_precedence() {
3832 assert_eq!(key_role(true, false), "root");
3833 assert_eq!(key_role(true, true), "root");
3834 assert_eq!(key_role(false, true), "admin");
3835 assert_eq!(key_role(false, false), "limited");
3836 }
3837
3838 #[tokio::test]
3839 async fn allowed_calls_hardfork_gates() {
3840 let provider = alloy_provider::ProviderBuilder::new_with_network::<TempoNetwork>()
3841 .connect_mocked_client(alloy_provider::mock::Asserter::new());
3842 let subject = DoctorSubject {
3843 root_account: addr(0x11),
3844 key_address: addr(0x22),
3845 entry: None,
3846 explicit: true,
3847 };
3848 let step = check_allowed_calls(&provider, &subject, None, None, None, None, None).await;
3849 assert_eq!(step.status, DoctorStatus::Warn);
3850 assert_eq!(step.detail, "skipped; hardfork unknown");
3851 let step =
3852 check_allowed_calls(&provider, &subject, None, Some(false), None, None, None).await;
3853 assert_eq!(step.status, DoctorStatus::Pass);
3854 assert_eq!(step.detail, "TIP-1011 not enforced before T3");
3855 }
3856
3857 #[test]
3858 fn expiry_uses_chain_timestamp() {
3859 let known = ChainTimestamp::Known(100);
3860 assert_eq!(check_expiry(Some(100), &known, "", "hint").status, DoctorStatus::Fail);
3861 assert_eq!(check_expiry(Some(101), &known, "", "hint").status, DoctorStatus::Pass);
3862 assert_eq!(check_expiry(None, &known, "", "hint").detail, "never expires");
3863
3864 let unknown =
3865 ChainTimestamp::Unknown { detail: "latest block not found".to_string(), hint: "h" };
3866 let step = check_expiry(Some(100), &unknown, "key_authorization ", "hint");
3867 assert_eq!(step.status, DoctorStatus::Warn);
3868 assert_eq!(step.detail, "key_authorization expiry not checked: latest block not found");
3869 }
3870
3871 #[test]
3872 fn expiring_nonce_window_thresholds() {
3873 let opts = |expiring_nonce, valid_after, valid_before| TempoOpts {
3874 expiring_nonce,
3875 valid_after,
3876 valid_before,
3877 ..Default::default()
3878 };
3879 let t10 = Some(TempoHardfork::T10);
3880 let t11 = Some(TempoHardfork::T11);
3881 let cases = [
3882 (opts(false, Some(20), Some(20)), 10, t10, DoctorStatus::Fail),
3884 (opts(false, None, Some(10)), 10, t10, DoctorStatus::Fail),
3885 (opts(true, None, Some(103)), 100, t10, DoctorStatus::Fail),
3886 (opts(true, None, Some(104)), 100, t10, DoctorStatus::Warn),
3887 (opts(true, None, Some(105)), 100, t10, DoctorStatus::Warn),
3888 (opts(true, None, Some(120)), 100, t10, DoctorStatus::Pass),
3889 (opts(true, None, Some(131)), 100, t10, DoctorStatus::Warn),
3891 (opts(true, None, Some(131)), 100, t11, DoctorStatus::Pass),
3892 (opts(true, None, Some(400)), 100, t11, DoctorStatus::Pass),
3893 (opts(true, None, Some(401)), 100, t11, DoctorStatus::Warn),
3894 (opts(true, None, Some(401)), 100, None, DoctorStatus::Pass),
3895 ];
3896 for (tempo, now, hardfork, expected) in cases {
3897 let step = check_expiring_nonce_window(&tempo, None, now, hardfork);
3898 assert_eq!(step.status, expected, "{step:?}");
3899 }
3900 }
3901
3902 #[test]
3903 fn diagnose_allowed_scopes_denials() {
3904 let exact =
3905 diagnose_allowed_scopes(&[], Some(addr(0x11)), Some([0xaa, 0xbb, 0xcc, 0xdd]), None);
3906 assert_eq!(exact.status, DoctorStatus::Fail);
3907
3908 let scopes = [scope(addr(0x11), vec![rule([0xaa, 0xbb, 0xcc, 0xdd], vec![])])];
3909 let target_only = diagnose_allowed_scopes(&scopes, Some(addr(0x22)), None, None);
3910 assert_eq!(target_only.status, DoctorStatus::Warn);
3911 }
3912
3913 #[test]
3914 fn sponsor_config_error_redacts_private_key_uri() {
3915 let tempo = TempoOpts {
3916 sponsor_signer: Some("private-key://super-secret".to_string()),
3917 ..Default::default()
3918 };
3919 let sanitized = sanitize_sponsor_config_error(
3920 "unsupported Tempo sponsor signer `private-key://super-secret`",
3921 &tempo,
3922 );
3923 assert!(sanitized.contains("private-key://<redacted>"));
3924 assert!(!sanitized.contains("super-secret"));
3925 }
3926}