Skip to main content

cast/cmd/
keychain.rs

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