Skip to main content

cast/cmd/
keychain.rs

1use alloy_ens::NameOrAddress;
2use alloy_network::EthereumWallet;
3use alloy_primitives::{Address, U256, hex, keccak256};
4use alloy_provider::{Provider, ProviderBuilder as AlloyProviderBuilder};
5use alloy_signer::Signer;
6use alloy_sol_types::{SolCall, sol};
7use chrono::DateTime;
8use clap::Parser;
9use eyre::Result;
10use foundry_cli::{
11    opts::{RpcOpts, TransactionOpts},
12    utils::LoadConfig,
13};
14use foundry_common::{
15    FoundryTransactionBuilder,
16    provider::ProviderBuilder,
17    shell,
18    tempo::{self, KeyType, KeysFile, WalletType, read_tempo_keys_file, tempo_keys_path},
19};
20use tempo_alloy::{TempoNetwork, provider::TempoProviderExt};
21use tempo_contracts::precompiles::{
22    ACCOUNT_KEYCHAIN_ADDRESS, IAccountKeychain,
23    IAccountKeychain::{KeyInfo, SignatureType, TokenLimit},
24};
25use yansi::Paint;
26
27use crate::tx::{CastTxBuilder, CastTxSender, SendTxOpts};
28
29// Extended AccountKeychain ABI for functions not yet in the pinned tempo-contracts.
30// These types mirror the T3+ precompile interface.
31sol! {
32    #[derive(Debug)]
33    struct SelectorRule {
34        bytes4 selector;
35        address[] recipients;
36    }
37
38    #[derive(Debug)]
39    struct CallScope {
40        address target;
41        SelectorRule[] selectorRules;
42    }
43
44    #[derive(Debug)]
45    struct ExtTokenLimit {
46        address token;
47        uint256 amount;
48    }
49
50    #[derive(Debug)]
51    struct KeyRestrictions {
52        uint64 expiry;
53        bool enforceLimits;
54        ExtTokenLimit[] limits;
55        bool allowAnyCalls;
56        CallScope[] allowedCalls;
57    }
58
59    function authorizeKeyWithRestrictions(
60        address keyId,
61        uint8 signatureType,
62        KeyRestrictions calldata config
63    ) external;
64
65    function setAllowedCalls(
66        address keyId,
67        CallScope[] calldata scopes
68    ) external;
69
70    function removeAllowedCalls(address keyId, address target) external;
71}
72
73/// Tempo keychain management commands.
74///
75/// Manage access keys stored in `~/.tempo/wallet/keys.toml` and query or modify
76/// on-chain key state via the AccountKeychain precompile.
77#[derive(Debug, Parser)]
78pub enum KeychainSubcommand {
79    /// List all keys from the local keys.toml file.
80    #[command(visible_alias = "ls")]
81    List,
82
83    /// Show all keys for a specific wallet address from the local keys.toml file.
84    Show {
85        /// The wallet address to look up.
86        wallet_address: Address,
87    },
88
89    /// Check on-chain provisioning status of a key via the AccountKeychain precompile.
90    #[command(visible_alias = "info")]
91    Check {
92        /// The wallet (account) address.
93        wallet_address: Address,
94
95        /// The key address to check.
96        key_address: Address,
97
98        #[command(flatten)]
99        rpc: RpcOpts,
100    },
101
102    /// Authorize a new key on-chain via the AccountKeychain precompile.
103    #[command(visible_alias = "auth")]
104    Authorize {
105        /// The key address to authorize.
106        key_address: Address,
107
108        /// Signature type: secp256k1, p256, or webauthn.
109        #[arg(default_value = "secp256k1", value_parser = parse_signature_type)]
110        key_type: SignatureType,
111
112        /// Expiry timestamp (unix seconds). Defaults to u64::MAX (never expires).
113        #[arg(default_value_t = u64::MAX)]
114        expiry: u64,
115
116        /// Enforce spending limits for this key.
117        #[arg(long)]
118        enforce_limits: bool,
119
120        /// Spending limit in TOKEN:AMOUNT format. Can be specified multiple times.
121        #[arg(long = "limit", value_parser = parse_limit)]
122        limits: Vec<TokenLimit>,
123
124        /// Call scope restriction in `TARGET[:SELECTORS[@RECIPIENTS]]` format.
125        /// TARGET alone allows all calls. `TARGET:transfer,approve` restricts to those selectors.
126        /// `TARGET:transfer@0x123` restricts selector to specific recipients.
127        #[arg(long = "scope", value_parser = parse_scope)]
128        scope: Vec<CallScope>,
129
130        /// Call scope restrictions as a JSON array.
131        /// Format: [{"target":"0x...","selectors":["transfer"]}] or
132        /// [{"target":"0x...","selectors":[{"selector":"transfer","recipients":["0x..."]}]}]
133        #[arg(long = "scopes", value_parser = parse_scopes_json, conflicts_with = "scope")]
134        scopes_json: Option<Vec<CallScope>>,
135
136        #[command(flatten)]
137        tx: TransactionOpts,
138
139        #[command(flatten)]
140        send_tx: SendTxOpts,
141    },
142
143    /// Revoke an authorized key on-chain via the AccountKeychain precompile.
144    #[command(visible_alias = "rev")]
145    Revoke {
146        /// The key address to revoke.
147        key_address: Address,
148
149        #[command(flatten)]
150        tx: TransactionOpts,
151
152        #[command(flatten)]
153        send_tx: SendTxOpts,
154    },
155
156    /// Query the remaining spending limit for a key on a specific token.
157    #[command(name = "rl", visible_alias = "remaining-limit")]
158    RemainingLimit {
159        /// The wallet (account) address.
160        wallet_address: Address,
161
162        /// The key address.
163        key_address: Address,
164
165        /// The token address.
166        token: Address,
167
168        #[command(flatten)]
169        rpc: RpcOpts,
170    },
171
172    /// Update the spending limit for a key on a specific token.
173    #[command(name = "ul", visible_alias = "update-limit")]
174    UpdateLimit {
175        /// The key address.
176        key_address: Address,
177
178        /// The token address.
179        token: Address,
180
181        /// The new spending limit.
182        new_limit: U256,
183
184        #[command(flatten)]
185        tx: TransactionOpts,
186
187        #[command(flatten)]
188        send_tx: SendTxOpts,
189    },
190
191    /// Set allowed call scopes for a key.
192    #[command(name = "ss", visible_alias = "set-scope")]
193    SetScope {
194        /// The key address.
195        key_address: Address,
196
197        /// Call scope restriction in `TARGET[:SELECTORS[@RECIPIENTS]]` format.
198        #[arg(long = "scope", required = true, value_parser = parse_scope)]
199        scope: Vec<CallScope>,
200
201        #[command(flatten)]
202        tx: TransactionOpts,
203
204        #[command(flatten)]
205        send_tx: SendTxOpts,
206    },
207
208    /// Remove call scope for a key on a target.
209    #[command(name = "rs", visible_alias = "remove-scope")]
210    RemoveScope {
211        /// The key address.
212        key_address: Address,
213
214        /// The target address to remove scope for.
215        target: Address,
216
217        #[command(flatten)]
218        tx: TransactionOpts,
219
220        #[command(flatten)]
221        send_tx: SendTxOpts,
222    },
223}
224
225fn parse_signature_type(s: &str) -> Result<SignatureType, String> {
226    match s.to_lowercase().as_str() {
227        "secp256k1" => Ok(SignatureType::Secp256k1),
228        "p256" => Ok(SignatureType::P256),
229        "webauthn" => Ok(SignatureType::WebAuthn),
230        _ => Err(format!("unknown signature type: {s} (expected secp256k1, p256, or webauthn)")),
231    }
232}
233
234fn signature_type_name(t: &SignatureType) -> &'static str {
235    match t {
236        SignatureType::Secp256k1 => "secp256k1",
237        SignatureType::P256 => "p256",
238        SignatureType::WebAuthn => "webauthn",
239        _ => "unknown",
240    }
241}
242
243fn key_type_name(t: &KeyType) -> &'static str {
244    match t {
245        KeyType::Secp256k1 => "secp256k1",
246        KeyType::P256 => "p256",
247        KeyType::WebAuthn => "webauthn",
248    }
249}
250
251fn wallet_type_name(t: &WalletType) -> &'static str {
252    match t {
253        WalletType::Local => "local",
254        WalletType::Passkey => "passkey",
255    }
256}
257
258/// Parse a `--limit TOKEN:AMOUNT` flag value.
259fn parse_limit(s: &str) -> Result<TokenLimit, String> {
260    let (token_str, amount_str) = s
261        .split_once(':')
262        .ok_or_else(|| format!("invalid limit format: {s} (expected TOKEN:AMOUNT)"))?;
263    let token: Address =
264        token_str.parse().map_err(|e| format!("invalid token address '{token_str}': {e}"))?;
265    let amount: U256 =
266        amount_str.parse().map_err(|e| format!("invalid amount '{amount_str}': {e}"))?;
267    Ok(TokenLimit { token, amount })
268}
269
270/// Parse a `--scope TARGET[:SELECTORS[@RECIPIENTS]]` flag value.
271///
272/// Formats:
273/// - `0xAddr` — allow all calls to target
274/// - `0xAddr:transfer,approve` — allow only those selectors (by name or 4-byte hex)
275/// - `0xAddr:transfer@0xRecipient` — selector with recipient restriction
276fn parse_scope(s: &str) -> Result<CallScope, String> {
277    let (target_str, selectors_str) = match s.split_once(':') {
278        Some((t, sel)) => (t, Some(sel)),
279        None => (s, None),
280    };
281
282    let target: Address =
283        target_str.parse().map_err(|e| format!("invalid target address '{target_str}': {e}"))?;
284
285    let selector_rules = match selectors_str {
286        None => vec![],
287        Some(sel_str) => parse_selector_rules(sel_str)?,
288    };
289
290    Ok(CallScope { target, selectorRules: selector_rules })
291}
292
293/// Parse comma-separated selectors, each optionally with `@recipient1,recipient2,...`.
294///
295/// Example: `transfer,approve` or `transfer@0x123` or `0xd09de08a`
296fn parse_selector_rules(s: &str) -> Result<Vec<SelectorRule>, String> {
297    let mut rules = Vec::new();
298
299    for part in s.split(',') {
300        let part = part.trim();
301        if part.is_empty() {
302            continue;
303        }
304
305        let (selector_str, recipients_str) = match part.split_once('@') {
306            Some((sel, recip)) => (sel, Some(recip)),
307            None => (part, None),
308        };
309
310        let selector = parse_selector_bytes(selector_str)?;
311
312        let recipients = match recipients_str {
313            None => vec![],
314            Some(r) => r
315                .split(',')
316                .filter(|s| !s.trim().is_empty())
317                .map(|addr_str| {
318                    let addr_str = addr_str.trim();
319                    addr_str
320                        .parse::<Address>()
321                        .map_err(|e| format!("invalid recipient address '{addr_str}': {e}"))
322                })
323                .collect::<Result<Vec<_>, _>>()?,
324        };
325
326        rules.push(SelectorRule { selector: selector.into(), recipients });
327    }
328
329    Ok(rules)
330}
331
332/// Parse a selector string: either a 4-byte hex (`0xd09de08a`) or a function name
333/// (computed as the first 4 bytes of keccak256 of `name()`).
334fn parse_selector_bytes(s: &str) -> Result<[u8; 4], String> {
335    let s = s.trim();
336    if s.starts_with("0x") || s.starts_with("0X") {
337        let hex_str = &s[2..];
338        if hex_str.len() != 8 {
339            return Err(format!("hex selector must be 4 bytes (8 hex chars), got: {s}"));
340        }
341        let bytes = hex::decode(hex_str).map_err(|e| format!("invalid hex selector '{s}': {e}"))?;
342        let mut arr = [0u8; 4];
343        arr.copy_from_slice(&bytes);
344        Ok(arr)
345    } else {
346        let sig = if s.contains('(') { s.to_string() } else { format!("{s}()") };
347        let hash = keccak256(sig.as_bytes());
348        let mut arr = [0u8; 4];
349        arr.copy_from_slice(&hash[..4]);
350        Ok(arr)
351    }
352}
353
354/// Represents a single scope entry in JSON format for `--scopes`.
355#[derive(serde::Deserialize)]
356struct JsonCallScope {
357    target: Address,
358    #[serde(default)]
359    selectors: Option<Vec<JsonSelectorEntry>>,
360}
361
362/// A selector entry can be either a plain string or an object with recipients.
363#[derive(serde::Deserialize)]
364#[serde(untagged)]
365enum JsonSelectorEntry {
366    Name(String),
367    WithRecipients { selector: String, recipients: Vec<Address> },
368}
369
370/// Parse `--scopes` JSON flag value.
371fn parse_scopes_json(s: &str) -> Result<Vec<CallScope>, String> {
372    let entries: Vec<JsonCallScope> =
373        serde_json::from_str(s).map_err(|e| format!("invalid --scopes JSON: {e}"))?;
374
375    let mut scopes = Vec::new();
376    for entry in entries {
377        let selector_rules = match entry.selectors {
378            None => vec![],
379            Some(sels) => {
380                let mut rules = Vec::new();
381                for sel_entry in sels {
382                    let (selector_str, recipients) = match sel_entry {
383                        JsonSelectorEntry::Name(name) => (name, vec![]),
384                        JsonSelectorEntry::WithRecipients { selector, recipients } => {
385                            (selector, recipients)
386                        }
387                    };
388                    let selector = parse_selector_bytes(&selector_str)
389                        .map_err(|e| format!("in --scopes JSON: {e}"))?;
390                    rules.push(SelectorRule { selector: selector.into(), recipients });
391                }
392                rules
393            }
394        };
395        scopes.push(CallScope { target: entry.target, selectorRules: selector_rules });
396    }
397
398    Ok(scopes)
399}
400
401impl KeychainSubcommand {
402    pub async fn run(self) -> Result<()> {
403        match self {
404            Self::List => run_list(),
405            Self::Show { wallet_address } => run_show(wallet_address),
406            Self::Check { wallet_address, key_address, rpc } => {
407                run_check(wallet_address, key_address, rpc).await
408            }
409            Self::Authorize {
410                key_address,
411                key_type,
412                expiry,
413                enforce_limits,
414                limits,
415                scope,
416                scopes_json,
417                tx,
418                send_tx,
419            } => {
420                let all_scopes =
421                    if let Some(json_scopes) = scopes_json { json_scopes } else { scope };
422                run_authorize(
423                    key_address,
424                    key_type,
425                    expiry,
426                    enforce_limits,
427                    limits,
428                    all_scopes,
429                    tx,
430                    send_tx,
431                )
432                .await
433            }
434            Self::Revoke { key_address, tx, send_tx } => run_revoke(key_address, tx, send_tx).await,
435            Self::RemainingLimit { wallet_address, key_address, token, rpc } => {
436                run_remaining_limit(wallet_address, key_address, token, rpc).await
437            }
438            Self::UpdateLimit { key_address, token, new_limit, tx, send_tx } => {
439                run_update_limit(key_address, token, new_limit, tx, send_tx).await
440            }
441            Self::SetScope { key_address, scope, tx, send_tx } => {
442                run_set_scope(key_address, scope, tx, send_tx).await
443            }
444            Self::RemoveScope { key_address, target, tx, send_tx } => {
445                run_remove_scope(key_address, target, tx, send_tx).await
446            }
447        }
448    }
449}
450
451/// `cast keychain list` — display all entries from keys.toml.
452fn run_list() -> Result<()> {
453    let keys_file = load_keys_file()?;
454
455    if keys_file.keys.is_empty() {
456        sh_println!("No keys found in keys.toml.")?;
457        return Ok(());
458    }
459
460    if shell::is_json() {
461        let entries: Vec<_> = keys_file.keys.iter().map(key_entry_to_json).collect();
462        sh_println!("{}", serde_json::to_string_pretty(&entries)?)?;
463        return Ok(());
464    }
465
466    for (i, entry) in keys_file.keys.iter().enumerate() {
467        if i > 0 {
468            sh_println!()?;
469        }
470        print_key_entry(entry)?;
471    }
472
473    Ok(())
474}
475
476/// `cast keychain show <wallet_address>` — show keys for a specific wallet.
477fn run_show(wallet_address: Address) -> Result<()> {
478    let keys_file = load_keys_file()?;
479
480    let entries: Vec<_> =
481        keys_file.keys.iter().filter(|e| e.wallet_address == wallet_address).collect();
482
483    if entries.is_empty() {
484        sh_println!("No keys found for wallet {wallet_address}.")?;
485        return Ok(());
486    }
487
488    if shell::is_json() {
489        let json: Vec<_> = entries.iter().map(|e| key_entry_to_json(e)).collect();
490        sh_println!("{}", serde_json::to_string_pretty(&json)?)?;
491        return Ok(());
492    }
493
494    for (i, entry) in entries.iter().enumerate() {
495        if i > 0 {
496            sh_println!()?;
497        }
498        print_key_entry(entry)?;
499    }
500
501    Ok(())
502}
503
504/// `cast keychain check` / `cast keychain info` — query on-chain key status.
505async fn run_check(wallet_address: Address, key_address: Address, rpc: RpcOpts) -> Result<()> {
506    let config = rpc.load_config()?;
507    let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
508
509    let info: KeyInfo = provider.get_keychain_key(wallet_address, key_address).await?;
510
511    let provisioned = info.keyId != Address::ZERO;
512
513    if shell::is_json() {
514        let json = serde_json::json!({
515            "wallet_address": wallet_address.to_string(),
516            "key_address": key_address.to_string(),
517            "provisioned": provisioned,
518            "signatureType": signature_type_name(&info.signatureType),
519            "key_id": info.keyId.to_string(),
520            "expiry": info.expiry,
521            "expiry_human": format_expiry(info.expiry),
522            "enforce_limits": info.enforceLimits,
523            "is_revoked": info.isRevoked,
524        });
525        sh_println!("{}", serde_json::to_string_pretty(&json)?)?;
526        return Ok(());
527    }
528
529    sh_println!("Wallet:         {wallet_address}")?;
530    sh_println!("Key:            {key_address}")?;
531
532    if !provisioned {
533        sh_println!("Status:         {} not provisioned", "✗".red())?;
534        return Ok(());
535    }
536
537    // Status line: combine provisioned + revoked into a single indicator.
538    if info.isRevoked {
539        sh_println!("Status:         {} revoked", "✗".red())?;
540    } else {
541        sh_println!("Status:         {} active", "✓".green())?;
542    }
543
544    sh_println!("Signature Type: {}", signature_type_name(&info.signatureType))?;
545    sh_println!("Key ID:         {}", info.keyId)?;
546
547    // Expiry: show human-readable date and whether it's expired.
548    let expiry_str = format_expiry(info.expiry);
549    if info.expiry == u64::MAX {
550        sh_println!("Expiry:         {}", expiry_str)?;
551    } else {
552        let now = std::time::SystemTime::now()
553            .duration_since(std::time::UNIX_EPOCH)
554            .unwrap_or_default()
555            .as_secs();
556        if info.expiry <= now {
557            sh_println!("Expiry:         {} ({})", expiry_str, "expired".red())?;
558        } else {
559            sh_println!("Expiry:         {}", expiry_str)?;
560        }
561    }
562
563    sh_println!("Spending Limits: {}", if info.enforceLimits { "enforced" } else { "none" })?;
564
565    Ok(())
566}
567
568/// `cast keychain authorize` / `cast keychain auth` — authorize a key on-chain.
569#[allow(clippy::too_many_arguments)]
570async fn run_authorize(
571    key_address: Address,
572    key_type: SignatureType,
573    expiry: u64,
574    enforce_limits: bool,
575    limits: Vec<TokenLimit>,
576    allowed_calls: Vec<CallScope>,
577    tx_opts: TransactionOpts,
578    send_tx: SendTxOpts,
579) -> Result<()> {
580    let enforce = enforce_limits || !limits.is_empty();
581
582    let calldata = if allowed_calls.is_empty() {
583        // Use the legacy authorizeKey when no scopes are needed.
584        IAccountKeychain::authorizeKeyCall {
585            keyId: key_address,
586            signatureType: key_type,
587            expiry,
588            enforceLimits: enforce,
589            limits,
590        }
591        .abi_encode()
592    } else {
593        // Use the T3+ authorizeKey overload with KeyRestrictions when scopes are provided.
594        let sig_type_u8 = match key_type {
595            SignatureType::Secp256k1 => 0u8,
596            SignatureType::P256 => 1u8,
597            SignatureType::WebAuthn => 2u8,
598            _ => eyre::bail!("unknown signature type"),
599        };
600        let restrictions = KeyRestrictions {
601            expiry,
602            enforceLimits: enforce,
603            limits: limits
604                .into_iter()
605                .map(|l| ExtTokenLimit { token: l.token, amount: l.amount })
606                .collect(),
607            allowAnyCalls: false,
608            allowedCalls: allowed_calls,
609        };
610        authorizeKeyWithRestrictionsCall {
611            keyId: key_address,
612            signatureType: sig_type_u8,
613            config: restrictions,
614        }
615        .abi_encode()
616    };
617
618    send_keychain_tx(calldata, tx_opts, &send_tx).await
619}
620
621/// `cast keychain revoke` / `cast keychain rev` — revoke a key on-chain.
622async fn run_revoke(
623    key_address: Address,
624    tx_opts: TransactionOpts,
625    send_tx: SendTxOpts,
626) -> Result<()> {
627    let calldata = IAccountKeychain::revokeKeyCall { keyId: key_address }.abi_encode();
628    send_keychain_tx(calldata, tx_opts, &send_tx).await
629}
630
631/// `cast keychain rl` — query remaining spending limit.
632async fn run_remaining_limit(
633    wallet_address: Address,
634    key_address: Address,
635    token: Address,
636    rpc: RpcOpts,
637) -> Result<()> {
638    let config = rpc.load_config()?;
639    let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
640
641    let remaining: U256 =
642        provider.get_keychain_remaining_limit(wallet_address, key_address, token).await?;
643
644    if shell::is_json() {
645        sh_println!("{}", serde_json::to_string(&remaining.to_string())?)?;
646    } else {
647        sh_println!("{remaining}")?;
648    }
649
650    Ok(())
651}
652
653/// `cast keychain ul` — update spending limit.
654async fn run_update_limit(
655    key_address: Address,
656    token: Address,
657    new_limit: U256,
658    tx_opts: TransactionOpts,
659    send_tx: SendTxOpts,
660) -> Result<()> {
661    let calldata = IAccountKeychain::updateSpendingLimitCall {
662        keyId: key_address,
663        token,
664        newLimit: new_limit,
665    }
666    .abi_encode();
667    send_keychain_tx(calldata, tx_opts, &send_tx).await
668}
669
670/// `cast keychain ss` — set allowed call scopes.
671async fn run_set_scope(
672    key_address: Address,
673    scopes: Vec<CallScope>,
674    tx_opts: TransactionOpts,
675    send_tx: SendTxOpts,
676) -> Result<()> {
677    let calldata = setAllowedCallsCall { keyId: key_address, scopes }.abi_encode();
678    send_keychain_tx(calldata, tx_opts, &send_tx).await
679}
680
681/// `cast keychain rs` — remove call scope for a target.
682async fn run_remove_scope(
683    key_address: Address,
684    target: Address,
685    tx_opts: TransactionOpts,
686    send_tx: SendTxOpts,
687) -> Result<()> {
688    let calldata = removeAllowedCallsCall { keyId: key_address, target }.abi_encode();
689    send_keychain_tx(calldata, tx_opts, &send_tx).await
690}
691
692/// Shared helper to send a keychain precompile transaction.
693async fn send_keychain_tx(
694    calldata: Vec<u8>,
695    mut tx_opts: TransactionOpts,
696    send_tx: &SendTxOpts,
697) -> Result<()> {
698    let (signer, tempo_access_key) = send_tx.eth.wallet.maybe_signer().await?;
699
700    let config = send_tx.eth.load_config()?;
701    let timeout = send_tx.timeout.unwrap_or(config.transaction_timeout);
702    let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
703
704    // Inject key_id for correct gas estimation with keychain signature overhead.
705    if let Some(ref ak) = tempo_access_key {
706        tx_opts.tempo.key_id = Some(ak.key_address);
707    }
708
709    let builder = CastTxBuilder::new(&provider, tx_opts, &config)
710        .await?
711        .with_to(Some(NameOrAddress::Address(ACCOUNT_KEYCHAIN_ADDRESS)))
712        .await?
713        .with_code_sig_and_args(None, Some(hex::encode_prefixed(&calldata)), vec![])
714        .await?;
715
716    if let Some(ref ak) = tempo_access_key {
717        let signer = signer.as_ref().expect("signer required for access key");
718        let from = ak.wallet_address;
719        let (tx, _) = builder.build(from).await?;
720
721        let raw_tx = tx
722            .sign_with_access_key(
723                &provider,
724                signer,
725                ak.wallet_address,
726                ak.key_address,
727                ak.key_authorization.as_ref(),
728            )
729            .await?;
730
731        let tx_hash = *provider.send_raw_transaction(&raw_tx).await?.tx_hash();
732        let cast = CastTxSender::new(&provider);
733        cast.print_tx_result(tx_hash, send_tx.cast_async, send_tx.confirmations, timeout).await?;
734    } else {
735        let signer = match signer {
736            Some(s) => s,
737            None => send_tx.eth.wallet.signer().await?,
738        };
739        let from = signer.address();
740        let (tx, _) = builder.build(from).await?;
741
742        let wallet = EthereumWallet::from(signer);
743        let provider = AlloyProviderBuilder::<_, _, TempoNetwork>::default()
744            .wallet(wallet)
745            .connect_provider(&provider);
746
747        let cast = CastTxSender::new(provider);
748        let pending_tx = cast.send(tx).await?;
749        let tx_hash = *pending_tx.inner().tx_hash();
750        cast.print_tx_result(tx_hash, send_tx.cast_async, send_tx.confirmations, timeout).await?;
751    }
752
753    Ok(())
754}
755
756fn format_expiry(expiry: u64) -> String {
757    if expiry == u64::MAX {
758        return "never".to_string();
759    }
760    DateTime::from_timestamp(expiry as i64, 0)
761        .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
762        .unwrap_or_else(|| expiry.to_string())
763}
764
765fn load_keys_file() -> Result<KeysFile> {
766    match read_tempo_keys_file() {
767        Some(f) => Ok(f),
768        None => {
769            let path = tempo_keys_path()
770                .map(|p| p.display().to_string())
771                .unwrap_or_else(|| "(unknown)".to_string());
772            eyre::bail!("could not read keys file at {path}")
773        }
774    }
775}
776
777fn print_key_entry(entry: &tempo::KeyEntry) -> Result<()> {
778    sh_println!("Wallet:       {}", entry.wallet_address)?;
779    sh_println!("Wallet Type:  {}", wallet_type_name(&entry.wallet_type))?;
780    sh_println!("Chain ID:     {}", entry.chain_id)?;
781    sh_println!("Key Type:     {}", key_type_name(&entry.key_type))?;
782
783    if let Some(key_address) = entry.key_address {
784        sh_println!("Key Address:  {key_address}")?;
785
786        if key_address == entry.wallet_address {
787            sh_println!("Mode:         direct (EOA)")?;
788        } else {
789            sh_println!("Mode:         keychain (access key)")?;
790        }
791    } else {
792        sh_println!("Key Address:  (not set)")?;
793        sh_println!("Mode:         direct (EOA)")?;
794    }
795
796    if let Some(expiry) = entry.expiry {
797        sh_println!("Expiry:       {}", format_expiry(expiry))?;
798    }
799
800    sh_println!("Has Key:      {}", entry.has_inline_key())?;
801    sh_println!("Has Auth:     {}", entry.key_authorization.is_some())?;
802
803    if !entry.limits.is_empty() {
804        sh_println!("Limits:")?;
805        for limit in &entry.limits {
806            sh_println!("  {} → {}", limit.currency, limit.limit)?;
807        }
808    }
809
810    Ok(())
811}
812
813fn key_entry_to_json(entry: &tempo::KeyEntry) -> serde_json::Value {
814    let is_direct = entry.key_address.is_none() || entry.key_address == Some(entry.wallet_address);
815
816    let limits: Vec<_> = entry
817        .limits
818        .iter()
819        .map(|l| {
820            serde_json::json!({
821                "currency": l.currency.to_string(),
822                "limit": l.limit,
823            })
824        })
825        .collect();
826
827    serde_json::json!({
828        "wallet_address": entry.wallet_address.to_string(),
829        "wallet_type": wallet_type_name(&entry.wallet_type),
830        "chain_id": entry.chain_id,
831        "key_type": key_type_name(&entry.key_type),
832        "key_address": entry.key_address.map(|a: Address| a.to_string()),
833        "mode": if is_direct { "direct" } else { "keychain" },
834        "expiry": entry.expiry,
835        "expiry_human": entry.expiry.map(format_expiry),
836        "has_key": entry.has_inline_key(),
837        "has_authorization": entry.key_authorization.is_some(),
838        "limits": limits,
839    })
840}