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