Skip to main content

cast/cmd/
keychain.rs

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