Skip to main content

cast/cmd/
keychain.rs

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