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