Skip to main content

cast/cmd/
keychain.rs

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