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