Skip to main content

cast/cmd/
keychain.rs

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