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