Skip to main content

cast/cmd/wallet/
session.rs

1use alloy_primitives::{Address, B256, U256};
2use alloy_provider::Provider;
3use alloy_signer::Signer;
4use alloy_sol_types::SolCall;
5use clap::{Args, Parser};
6use eyre::{Context, Result};
7use foundry_cli::{
8    opts::{TEMPO_SESSION_ID_ENV, TransactionOpts},
9    utils::LoadConfig,
10};
11use foundry_common::{
12    provider::ProviderBuilder,
13    sh_println, shell,
14    tempo::{
15        GeneratedSessionKey, SessionAuthorizationRequest, SessionEntry, SessionSpendLimit,
16        SessionStatus, read_session_entry, update_session_status, update_session_status_if,
17        upsert_session_entry,
18    },
19};
20use foundry_wallets::{WalletOpts, WalletSigner};
21use serde_json::json;
22use std::{
23    num::NonZeroU64,
24    process::{Command, ExitStatus},
25    time::{SystemTime, UNIX_EPOCH},
26};
27use tempo_alloy::{TempoNetwork, provider::TempoProviderExt};
28use tempo_contracts::precompiles::IAccountKeychain;
29use tempo_primitives::transaction::{CallScope, PrimitiveSignature, SelectorRule};
30use tokio::signal;
31
32use crate::{
33    cmd::{
34        keychain::{
35            KeychainTxOutcome, resolve_keychain_root_signer, send_keychain_tx_with_root_signer,
36        },
37        tempo_policy_args::{
38            parse_period, parse_policy_token, parse_scope as parse_policy_scope,
39            parse_selector_bytes,
40        },
41    },
42    tx::SendTxOpts,
43};
44
45use super::process_tree::ManagedChild;
46
47const PRINT_SPONSOR_HASH_REVOKE_ERROR: &str = "--tempo.print-sponsor-hash only prints a sponsor hash and does not revoke the session on-chain";
48const SESSION_CHILD_SIGNER_ENV: &[&str] = &[
49    "ETH_KEYSTORE",
50    "ETH_KEYSTORE_ACCOUNT",
51    "ETH_PASSWORD",
52    "TEMPO_ACCESS_KEY",
53    "TEMPO_ROOT_ACCOUNT",
54];
55
56/// Arguments for `cast wallet session`.
57///
58/// Without a subcommand, this runs an issue-style temporary session around `--for <COMMAND>`.
59/// The existing `create` and `revoke` subcommands remain explicit lifecycle operations.
60#[derive(Debug, Args)]
61#[command(args_conflicts_with_subcommands = true)]
62pub struct SessionArgs {
63    #[command(subcommand)]
64    pub command: Option<SessionSubcommands>,
65
66    /// Root account that will authorize the temporary session.
67    #[arg(long = "root", value_name = "ADDRESS")]
68    pub root_account: Option<Address>,
69
70    /// Session lifetime, expressed as a duration like `10m`, `2h`, or `7d`.
71    #[arg(long = "expires", id = "session_expires", value_name = "DURATION", value_parser = parse_period)]
72    pub expires: Option<u64>,
73
74    /// Allowed call scope, in `TARGET[:SELECTORS[@RECIPIENTS]]` format.
75    #[arg(long = "scope", value_parser = parse_scope)]
76    pub scope: Vec<CallScope>,
77
78    /// Allowed call target for issue-style `--target ... --selector ...` input.
79    #[arg(long = "target", value_name = "ADDRESS")]
80    pub target: Option<Address>,
81
82    /// Function selector allowed for `--target`, such as `register(address)`.
83    #[arg(long = "selector", value_name = "SELECTOR")]
84    pub selectors: Vec<String>,
85
86    /// Token spend limit, in `TOKEN:AMOUNT` or `TOKEN=AMOUNT` format.
87    #[arg(long = "spend-limit", value_parser = parse_spend_limit)]
88    pub spend_limits: Vec<SessionSpendLimit>,
89
90    /// Command to run with the temporary Tempo session.
91    #[arg(long = "for", value_name = "COMMAND")]
92    pub for_command: Option<String>,
93
94    #[command(flatten)]
95    pub tx: Box<TransactionOpts>,
96
97    #[command(flatten)]
98    pub send_tx: Box<SendTxOpts>,
99}
100
101impl SessionArgs {
102    pub async fn run(self) -> Result<()> {
103        let Self {
104            command,
105            root_account,
106            expires,
107            scope,
108            target,
109            selectors,
110            spend_limits,
111            for_command,
112            tx,
113            send_tx,
114        } = self;
115
116        if let Some(command) = command {
117            return command.run().await;
118        }
119
120        let root_account =
121            root_account.ok_or_else(|| eyre::eyre!("cast wallet session requires --root"))?;
122        let expires =
123            expires.ok_or_else(|| eyre::eyre!("cast wallet session requires --expires"))?;
124        let command =
125            for_command.ok_or_else(|| eyre::eyre!("cast wallet session requires --for"))?;
126        let command = InnerCommand::parse(command)?;
127        let scope = session_scope(scope, target, selectors)?;
128        let send_tx = *send_tx;
129        let chain_id = resolve_session_chain_id(&send_tx).await?;
130
131        let tx = *tx;
132        if tx.tempo.print_sponsor_hash {
133            eyre::bail!(PRINT_SPONSOR_HASH_REVOKE_ERROR);
134        }
135
136        let entry = build_session_entry(
137            root_account,
138            chain_id,
139            expires,
140            scope,
141            spend_limits,
142            send_tx.eth.wallet.clone(),
143        )
144        .await?;
145
146        run_for_command(entry, command, tx, send_tx).await
147    }
148}
149
150/// Tempo wallet session lifecycle commands.
151#[derive(Debug, Parser)]
152pub enum SessionSubcommands {
153    /// Create a temporary Tempo session and persist it locally.
154    Create {
155        /// Root account that will authorize the session.
156        #[arg(long = "root", value_name = "ADDRESS")]
157        root_account: Address,
158
159        /// Chain ID the session is valid on.
160        #[arg(long = "chain-id", value_name = "CHAIN_ID")]
161        chain_id: u64,
162
163        /// Session lifetime, expressed as a duration like `10m`, `2h`, or `7d`.
164        #[arg(long = "expires", value_name = "DURATION", value_parser = parse_period)]
165        expires: u64,
166
167        /// Allowed call scope, in `TARGET[:SELECTORS[@RECIPIENTS]]` format.
168        #[arg(long = "scope", value_parser = parse_scope, required = true)]
169        scope: Vec<CallScope>,
170
171        /// Token spend limit, in `TOKEN:AMOUNT` or `TOKEN=AMOUNT` format.
172        #[arg(long = "spend-limit", value_parser = parse_spend_limit)]
173        spend_limits: Vec<SessionSpendLimit>,
174
175        #[command(flatten)]
176        wallet: Box<WalletOpts>,
177    },
178
179    /// Revoke a Tempo session key on-chain when provisioned, then clear local key material.
180    Revoke {
181        /// Session identifier to revoke.
182        #[arg(value_name = "SESSION_ID")]
183        session_id: B256,
184
185        /// Only clear local session key material; do not query or submit an on-chain revoke.
186        #[arg(long)]
187        local: bool,
188
189        #[command(flatten)]
190        tx: Box<TransactionOpts>,
191
192        #[command(flatten)]
193        send_tx: Box<SendTxOpts>,
194    },
195}
196
197impl SessionSubcommands {
198    pub async fn run(self) -> Result<()> {
199        match self {
200            Self::Create { root_account, chain_id, expires, scope, spend_limits, wallet } => {
201                run_create(root_account, chain_id, expires, scope, spend_limits, *wallet).await
202            }
203            Self::Revoke { session_id, local, tx, send_tx } => {
204                run_revoke(session_id, local, *tx, *send_tx).await
205            }
206        }
207    }
208}
209
210async fn run_for_command(
211    entry: SessionEntry,
212    command: InnerCommand,
213    tx: TransactionOpts,
214    send_tx: SendTxOpts,
215) -> Result<()> {
216    let session_id = entry.session_id;
217    upsert_session_entry(entry)?;
218
219    let child_result = command.run(session_id).await;
220    let cleanup_result = cleanup_session_run(session_id, child_result.is_ok(), tx, send_tx).await;
221
222    finish_session_run(session_id, child_result, cleanup_result)
223}
224
225async fn cleanup_session_run(
226    session_id: B256,
227    child_succeeded: bool,
228    tx: TransactionOpts,
229    send_tx: SendTxOpts,
230) -> Result<()> {
231    let retire_result = if child_succeeded {
232        mark_session_run_revoking(session_id)
233    } else {
234        retire_session_run_locally(session_id)
235    };
236    let revoke_result =
237        run_revoke_with_policy(session_id, false, tx, send_tx, UnprovisionedKeyPolicy::Fail).await;
238
239    match (retire_result, revoke_result) {
240        (Ok(()), Ok(())) => Ok(()),
241        (Err(retire_err), Ok(())) => Err(retire_err),
242        (Ok(()), Err(revoke_err)) => Err(revoke_err),
243        (Err(retire_err), Err(revoke_err)) => {
244            Err(revoke_err
245                .wrap_err(format!("also failed to retire local Tempo session: {retire_err}")))
246        }
247    }
248}
249
250fn mark_session_run_revoking(session_id: B256) -> Result<()> {
251    update_session_run_status(session_id, SessionStatus::Revoking)
252        .wrap_err_with(|| format!("failed to mark Tempo session {session_id:?} as revoking"))
253}
254
255fn retire_session_run_locally(session_id: B256) -> Result<()> {
256    update_session_run_status(session_id, SessionStatus::Failed)
257        .wrap_err_with(|| format!("failed to retire local Tempo session {session_id:?}"))
258}
259
260fn update_session_run_status(session_id: B256, status: SessionStatus) -> Result<()> {
261    let Some(entry) = read_session_entry(session_id)? else {
262        return Ok(());
263    };
264    let status = if entry.status.is_terminal() { entry.status } else { status };
265    update_session_status(session_id, status).map(|_| ())
266}
267
268fn finish_session_run(
269    session_id: B256,
270    child_result: Result<()>,
271    revoke_result: Result<()>,
272) -> Result<()> {
273    match (child_result, revoke_result) {
274        (Ok(()), Ok(())) => Ok(()),
275        (Err(child_err), Ok(())) => Err(child_err),
276        (Ok(()), Err(revoke_err)) => {
277            Err(revoke_err.wrap_err("failed to clean up Tempo session after inner command"))
278        }
279        (Err(child_err), Err(revoke_err)) => Err(child_err.wrap_err(format!(
280            "also failed to clean up Tempo session {session_id:?}: {revoke_err}"
281        ))),
282    }
283}
284
285#[derive(Debug)]
286struct InnerCommand {
287    raw: String,
288    program: String,
289    args: Vec<String>,
290}
291
292impl InnerCommand {
293    fn parse(raw: String) -> Result<Self> {
294        let mut argv = split_for_command(&raw)?.into_iter();
295        let program = argv.next().ok_or_else(|| eyre::eyre!("--for command cannot be empty"))?;
296        let args = argv.collect();
297        Ok(Self { raw, program, args })
298    }
299
300    async fn run(&self, session_id: B256) -> Result<()> {
301        let mut interrupt = SessionInterrupt::new()?;
302        self.run_with_interrupt(session_id, interrupt.recv()).await
303    }
304
305    async fn run_with_interrupt<I>(&self, session_id: B256, interrupt: I) -> Result<()>
306    where
307        I: std::future::Future<Output = Result<&'static str>>,
308    {
309        let mut child = ManagedChild::spawn(self.command(session_id))
310            .wrap_err_with(|| format!("failed to run inner command `{}`", self.raw))?;
311
312        let status = tokio::select! {
313            status = child.wait() => status.wrap_err_with(|| {
314                format!("failed to wait for inner command `{}`", self.raw)
315            })?,
316            interrupt = interrupt => {
317                let _ = child.terminate_tree().await;
318
319                return match interrupt {
320                    Ok(interrupt) => Err(self.interrupted_error(interrupt)),
321                    Err(err) => Err(err),
322                };
323            }
324        };
325
326        let _ = child.terminate_tree().await;
327
328        if status.success() { Ok(()) } else { Err(self.status_error(status)) }
329    }
330
331    fn command(&self, session_id: B256) -> Command {
332        let mut command = Command::new(&self.program);
333        command.args(&self.args);
334        for key in SESSION_CHILD_SIGNER_ENV {
335            command.env_remove(key);
336        }
337        command.env(TEMPO_SESSION_ID_ENV, format!("{session_id:?}"));
338        command
339    }
340
341    fn status_error(&self, status: ExitStatus) -> eyre::Report {
342        match status.code() {
343            Some(code) => eyre::eyre!("inner command `{}` exited with code {code}", self.raw),
344            None => eyre::eyre!("inner command `{}` terminated by a signal", self.raw),
345        }
346    }
347
348    fn interrupted_error(&self, interrupt: &'static str) -> eyre::Report {
349        eyre::eyre!("inner command `{}` interrupted by {interrupt}", self.raw)
350    }
351}
352
353#[cfg(unix)]
354struct SessionInterrupt {
355    sigint: signal::unix::Signal,
356    sigterm: signal::unix::Signal,
357}
358
359#[cfg(unix)]
360impl SessionInterrupt {
361    fn new() -> Result<Self> {
362        Ok(Self {
363            sigint: signal::unix::signal(signal::unix::SignalKind::interrupt())
364                .wrap_err("failed to listen for SIGINT")?,
365            sigterm: signal::unix::signal(signal::unix::SignalKind::terminate())
366                .wrap_err("failed to listen for SIGTERM")?,
367        })
368    }
369
370    async fn recv(&mut self) -> Result<&'static str> {
371        tokio::select! {
372            _ = self.sigint.recv() => Ok("SIGINT"),
373            _ = self.sigterm.recv() => Ok("SIGTERM"),
374        }
375    }
376}
377
378#[cfg(not(unix))]
379struct SessionInterrupt;
380
381#[cfg(not(unix))]
382impl SessionInterrupt {
383    fn new() -> Result<Self> {
384        Ok(Self)
385    }
386
387    async fn recv(&mut self) -> Result<&'static str> {
388        signal::ctrl_c().await.wrap_err("failed to listen for Ctrl-C")?;
389        Ok("Ctrl-C")
390    }
391}
392
393async fn resolve_session_chain_id(send_tx: &SendTxOpts) -> Result<u64> {
394    let config = send_tx.eth.load_config()?;
395    if let Some(chain) = config.chain {
396        return Ok(chain.id());
397    }
398
399    let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
400    provider.get_chain_id().await.wrap_err(
401        "failed to resolve session chain id from RPC; pass --chain/--chain-id or --rpc-url",
402    )
403}
404
405fn session_scope(
406    mut scope: Vec<CallScope>,
407    target: Option<Address>,
408    selectors: Vec<String>,
409) -> Result<Vec<CallScope>> {
410    if !selectors.is_empty() && target.is_none() {
411        eyre::bail!("--selector requires --target");
412    }
413    if target.is_some() && selectors.is_empty() {
414        eyre::bail!(
415            "--target requires at least one --selector; use --scope TARGET for target-wide access"
416        );
417    }
418
419    if let Some(target) = target {
420        let selector_rules = selectors
421            .into_iter()
422            .map(|selector| {
423                parse_selector_bytes(&selector)
424                    .map(|selector| SelectorRule { selector, recipients: vec![] })
425                    .map_err(|err| eyre::eyre!("{err}"))
426            })
427            .collect::<Result<Vec<_>>>()?;
428        scope.push(CallScope { target, selector_rules });
429    }
430
431    if scope.is_empty() {
432        eyre::bail!("cast wallet session requires --scope or --target");
433    }
434
435    Ok(scope)
436}
437
438fn split_for_command(command: &str) -> Result<Vec<String>> {
439    let mut args = Vec::new();
440    let mut current = String::new();
441    let mut quote = None;
442    let mut escaped = false;
443    let mut in_token = false;
444
445    for ch in command.chars() {
446        if escaped {
447            current.push(ch);
448            escaped = false;
449            in_token = true;
450            continue;
451        }
452
453        match quote {
454            Some('\'') => {
455                if ch == '\'' {
456                    quote = None;
457                } else {
458                    current.push(ch);
459                }
460            }
461            Some('"') => {
462                if ch == '"' {
463                    quote = None;
464                } else if ch == '\\' {
465                    escaped = true;
466                } else {
467                    current.push(ch);
468                }
469            }
470            Some(_) => unreachable!(),
471            None if ch.is_whitespace() => {
472                if in_token {
473                    args.push(std::mem::take(&mut current));
474                    in_token = false;
475                }
476            }
477            None if ch == '\'' || ch == '"' => {
478                quote = Some(ch);
479                in_token = true;
480            }
481            None if ch == '\\' => {
482                escaped = true;
483                in_token = true;
484            }
485            None => {
486                current.push(ch);
487                in_token = true;
488            }
489        }
490    }
491
492    if escaped {
493        eyre::bail!("unterminated escape in --for command");
494    }
495    if let Some(quote) = quote {
496        eyre::bail!("unterminated {quote} quote in --for command");
497    }
498    if in_token {
499        args.push(current);
500    }
501    Ok(args)
502}
503
504/// Creates a signed session entry and stores it in the local registry.
505async fn run_create(
506    root_account: Address,
507    chain_id: u64,
508    expires: u64,
509    scope: Vec<CallScope>,
510    spend_limits: Vec<SessionSpendLimit>,
511    wallet: WalletOpts,
512) -> Result<()> {
513    let entry =
514        build_session_entry(root_account, chain_id, expires, scope, spend_limits, wallet).await?;
515    let session_id = entry.session_id;
516    let root_account = entry.root_account;
517    let chain_id = entry.chain_id;
518    let key_address = entry.key_address;
519    let expiry = entry.expiry;
520    let scope_count = entry.scope.as_ref().map_or(0, |scopes| scopes.len());
521    let spend_limit_count = entry.limits.as_ref().map_or(0, |limits| limits.len());
522    upsert_session_entry(entry)?;
523
524    if shell::is_json() {
525        sh_println!(
526            "{}",
527            serde_json::to_string_pretty(&json!({
528                "session_id": session_id.to_string(),
529                "root_account": root_account.to_string(),
530                "chain_id": chain_id,
531                "key_address": key_address.to_string(),
532                "expiry": expiry,
533                "status": "active",
534                "scope_count": scope_count,
535                "spend_limit_count": spend_limit_count,
536            }))?
537        )?;
538    } else {
539        sh_println!("Created Tempo session {}", session_id)?;
540        sh_println!("Root:  {}", root_account)?;
541        sh_println!("Chain: {}", chain_id)?;
542        sh_println!("Key:   {}", key_address)?;
543        sh_println!("Expiry: {}", expiry)?;
544    }
545
546    Ok(())
547}
548
549/// Revokes a session entry locally and on-chain when the key has been provisioned.
550async fn run_revoke(
551    session_id: B256,
552    local: bool,
553    tx: TransactionOpts,
554    send_tx: SendTxOpts,
555) -> Result<()> {
556    run_revoke_with_policy(session_id, local, tx, send_tx, UnprovisionedKeyPolicy::RevokeLocally)
557        .await
558}
559
560#[derive(Clone, Copy, Debug, PartialEq, Eq)]
561enum UnprovisionedKeyPolicy {
562    RevokeLocally,
563    Fail,
564}
565
566async fn run_revoke_with_policy(
567    session_id: B256,
568    local: bool,
569    tx: TransactionOpts,
570    send_tx: SendTxOpts,
571    unprovisioned_policy: UnprovisionedKeyPolicy,
572) -> Result<()> {
573    let Some(entry) = read_session_entry(session_id)? else {
574        print_revoke_status(session_id, None, SessionRevokeStatus::NotFound)?;
575        return Ok(());
576    };
577
578    if local {
579        update_session_status(session_id, SessionStatus::Revoked)?;
580        print_revoke_status(session_id, Some(&entry), SessionRevokeStatus::Local)?;
581        return Ok(());
582    }
583
584    if tx.tempo.print_sponsor_hash {
585        eyre::bail!(PRINT_SPONSOR_HASH_REVOKE_ERROR);
586    }
587
588    let config = send_tx.eth.load_config()?;
589    let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
590    let rpc_chain_id = provider.get_chain_id().await?;
591    if rpc_chain_id != entry.chain_id {
592        eyre::bail!(
593            "session {} was created for chain {}, but the RPC is connected to chain {}",
594            entry.session_id,
595            entry.chain_id,
596            rpc_chain_id
597        );
598    }
599
600    let info = provider.get_keychain_key(entry.root_account, entry.key_address).await?;
601    if info.isRevoked {
602        update_session_status(session_id, SessionStatus::Revoked)?;
603        print_revoke_status(session_id, Some(&entry), SessionRevokeStatus::AlreadyRevoked)?;
604        return Ok(());
605    }
606    if info.keyId == Address::ZERO {
607        return handle_unprovisioned_revoke(session_id, &entry, unprovisioned_policy);
608    }
609
610    let root_signer =
611        resolve_keychain_root_signer(&send_tx, Some(entry.root_account), false).await?;
612    let revoke_result = async {
613        let calldata = IAccountKeychain::revokeKeyCall { keyId: entry.key_address }.abi_encode();
614        let before_submit = || {
615            if entry.status != SessionStatus::Revoked {
616                update_session_status_if(session_id, entry.status, SessionStatus::Revoking)?;
617            }
618            Ok(())
619        };
620        match send_keychain_tx_with_root_signer(calldata, tx, &send_tx, root_signer, before_submit)
621            .await?
622        {
623            KeychainTxOutcome::Submitted => {}
624            KeychainTxOutcome::PrintedSponsorHash => eyre::bail!(PRINT_SPONSOR_HASH_REVOKE_ERROR),
625        }
626        Ok(())
627    }
628    .await;
629    if let Err(err) = revoke_result {
630        handle_revoke_error(&provider, session_id, &entry).await;
631        return Err(err.wrap_err("failed to revoke Tempo session key on-chain"));
632    }
633
634    update_session_status(session_id, SessionStatus::Revoked)?;
635
636    Ok(())
637}
638
639fn handle_unprovisioned_revoke(
640    session_id: B256,
641    entry: &SessionEntry,
642    policy: UnprovisionedKeyPolicy,
643) -> Result<()> {
644    match policy {
645        UnprovisionedKeyPolicy::RevokeLocally => {
646            update_session_status(session_id, SessionStatus::Revoked)?;
647            print_revoke_status(session_id, Some(entry), SessionRevokeStatus::NotProvisioned)?;
648            Ok(())
649        }
650        UnprovisionedKeyPolicy::Fail => {
651            eyre::bail!(
652                "session key is not provisioned on-chain yet; pending transactions from the \
653                 wrapped command may still provision it. Wait for pending transactions to settle, \
654                 then run `cast wallet session revoke {session_id}`."
655            )
656        }
657    }
658}
659
660async fn handle_revoke_error(
661    provider: &impl Provider<TempoNetwork>,
662    session_id: B256,
663    entry: &SessionEntry,
664) {
665    if provider
666        .get_keychain_key(entry.root_account, entry.key_address)
667        .await
668        .map(|info| info.isRevoked)
669        .unwrap_or(false)
670    {
671        let _ = update_session_status(session_id, SessionStatus::Revoked);
672    }
673}
674
675#[derive(Clone, Copy, Debug, PartialEq, Eq)]
676enum SessionRevokeStatus {
677    NotFound,
678    Local,
679    NotProvisioned,
680    AlreadyRevoked,
681}
682
683fn print_revoke_status(
684    session_id: B256,
685    entry: Option<&SessionEntry>,
686    status: SessionRevokeStatus,
687) -> Result<()> {
688    if shell::is_json() {
689        sh_println!(
690            "{}",
691            serde_json::to_string_pretty(&json!({
692                "session_id": session_id.to_string(),
693                "status": if status == SessionRevokeStatus::NotFound { "not_found" } else { "revoked" },
694                "reason": match status {
695                    SessionRevokeStatus::NotFound => "not_found",
696                    SessionRevokeStatus::Local => "local",
697                    SessionRevokeStatus::NotProvisioned => "not_provisioned",
698                    SessionRevokeStatus::AlreadyRevoked => "already_revoked",
699                },
700                "root_account": entry.map(|entry| entry.root_account.to_string()),
701                "chain_id": entry.map(|entry| entry.chain_id),
702                "key_address": entry.map(|entry| entry.key_address.to_string()),
703            }))?
704        )?;
705        return Ok(());
706    }
707
708    match status {
709        SessionRevokeStatus::NotFound => {
710            sh_status!("Tempo session {} was not found.", session_id)?;
711        }
712        SessionRevokeStatus::Local => {
713            sh_status!("Revoked local Tempo session {}", session_id)?;
714        }
715        SessionRevokeStatus::NotProvisioned => {
716            sh_status!(
717                "Revoked Tempo session {} locally; key was not provisioned on-chain",
718                session_id
719            )?;
720        }
721        SessionRevokeStatus::AlreadyRevoked => {
722            sh_status!(
723                "Revoked Tempo session {} locally; key was already revoked on-chain",
724                session_id
725            )?;
726        }
727    }
728
729    Ok(())
730}
731
732/// Builds an active session entry from CLI policy inputs and a root signature.
733async fn build_session_entry(
734    root_account: Address,
735    chain_id: u64,
736    expires: u64,
737    scope: Vec<CallScope>,
738    spend_limits: Vec<SessionSpendLimit>,
739    wallet: WalletOpts,
740) -> Result<foundry_common::tempo::SessionEntry> {
741    if expires == 0 {
742        eyre::bail!("--expires must be greater than 0");
743    }
744    if chain_id == 0 {
745        eyre::bail!("--chain-id must be greater than 0");
746    }
747    if wallet.from.is_some_and(|from| from != root_account) {
748        eyre::bail!("--from must match --root for cast wallet session create");
749    }
750
751    let signer = resolve_root_signer(wallet, root_account).await?;
752    let session_key = GeneratedSessionKey::random();
753    let session_id = B256::random();
754    let now = now_unix_timestamp()?;
755    let expiry = now
756        .checked_add(expires)
757        .ok_or_else(|| eyre::eyre!("session expiry overflows the unix timestamp range"))?;
758    let expiry =
759        NonZeroU64::new(expiry).ok_or_else(|| eyre::eyre!("session expiry cannot be zero"))?;
760
761    let request = SessionAuthorizationRequest {
762        session_id,
763        root_account,
764        chain_id,
765        key_address: session_key.address(),
766        expiry,
767        scope,
768        spend_limits,
769    };
770    let prepared = request.prepare(now)?;
771    let signature = signer.sign_hash(&prepared.authorization.signature_hash()).await?;
772    let signed_authorization =
773        prepared.authorization.clone().into_signed(PrimitiveSignature::Secp256k1(signature));
774    prepared.into_active_entry(session_key, &signed_authorization)
775}
776
777async fn resolve_root_signer(wallet: WalletOpts, root_account: Address) -> Result<WalletSigner> {
778    let (signer, tempo_access_key) = wallet.maybe_signer().await?;
779    if tempo_access_key.is_some() {
780        eyre::bail!(
781            "Tempo access keys cannot authorize Tempo sessions; use a persistent root signer"
782        );
783    }
784
785    let signer = signer.ok_or_else(|| eyre::eyre!("a root wallet signer is required"))?;
786    let signer_address = signer.address();
787    if signer_address != root_account {
788        eyre::bail!("resolved signer {} does not match --root {}", signer_address, root_account);
789    }
790
791    Ok(signer)
792}
793
794/// Adapts shared keychain scope parsing into the session authorization type.
795fn parse_scope(s: &str) -> Result<CallScope, String> {
796    parse_policy_scope(s).map(|scope| CallScope {
797        target: scope.target,
798        selector_rules: scope
799            .selectorRules
800            .into_iter()
801            .map(|rule| SelectorRule {
802                selector: rule.selector.into(),
803                recipients: rule.recipients,
804            })
805            .collect(),
806    })
807}
808
809/// Parses a session spend limit into the session policy model.
810fn parse_spend_limit(s: &str) -> Result<SessionSpendLimit, String> {
811    let Some((token_str, amount_str)) = s.split_once(':').or_else(|| s.split_once('=')) else {
812        return Err(format!("invalid limit format: {s} (expected TOKEN:AMOUNT or TOKEN=AMOUNT)"));
813    };
814
815    let token = parse_policy_token(token_str.trim())?;
816    let amount: U256 =
817        amount_str.trim().parse().map_err(|e| format!("invalid amount '{amount_str}': {e}"))?;
818    Ok(SessionSpendLimit { token, amount })
819}
820
821fn now_unix_timestamp() -> Result<u64> {
822    Ok(SystemTime::now()
823        .duration_since(UNIX_EPOCH)
824        .context("system time is before UNIX_EPOCH")?
825        .as_secs())
826}
827
828#[cfg(test)]
829mod tests {
830    use super::*;
831    use alloy_primitives::address;
832    use foundry_cli::opts::EthereumOpts;
833    use std::{ffi::OsStr, sync::Mutex};
834    use tempo_contracts::precompiles::PATH_USD_ADDRESS;
835
836    const ROOT_PRIVATE_KEY: &str =
837        "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
838
839    static ENV_MUTEX: Mutex<()> = Mutex::new(());
840
841    fn with_tempo_home(test: impl FnOnce()) {
842        let _guard = ENV_MUTEX.lock().unwrap();
843        let tmp = tempfile::tempdir().unwrap();
844        // SAFETY: tests serialize all Tempo environment mutation through the mutex.
845        unsafe { std::env::set_var("TEMPO_HOME", tmp.path()) };
846        test();
847        // SAFETY: restore the process environment after the critical section.
848        unsafe { std::env::remove_var("TEMPO_HOME") };
849    }
850
851    #[test]
852    fn session_revoke_is_idempotent_when_missing() {
853        with_tempo_home(|| {
854            let session_id = B256::from([0x42; 32]);
855            assert!(!update_session_status(session_id, SessionStatus::Revoked).unwrap());
856        });
857    }
858
859    #[test]
860    fn parse_spend_limit_accepts_path_usd_alias() {
861        let limit = parse_spend_limit("PathUSD=0").unwrap();
862        assert_eq!(limit.token, PATH_USD_ADDRESS);
863        assert_eq!(limit.amount, U256::ZERO);
864    }
865
866    #[test]
867    fn inner_command_parse_preserves_literal_argv() {
868        let raw =
869            r#"forge script "Deploy Script" --sig 'run(uint256)' value\ with\ spaces #literal"#;
870        let command = InnerCommand::parse(raw.to_string()).unwrap();
871
872        assert_eq!(command.raw, raw);
873        assert_eq!(command.program, "forge");
874        assert_eq!(
875            command.args,
876            ["script", "Deploy Script", "--sig", "run(uint256)", "value with spaces", "#literal",]
877        );
878    }
879
880    #[test]
881    fn inner_command_parse_rejects_invalid_input() {
882        let err = InnerCommand::parse("   ".to_string()).unwrap_err();
883        assert!(err.to_string().contains("--for command cannot be empty"), "{err}");
884
885        let err = InnerCommand::parse("forge 'script".to_string()).unwrap_err();
886        assert!(err.to_string().contains("unterminated"), "{err}");
887    }
888
889    #[test]
890    fn session_scope_requires_selector_for_target_shortcut() {
891        let target = address!("0x00000000000000000000000000000000000000aa");
892        let err = session_scope(vec![], Some(target), vec![]).unwrap_err();
893
894        assert!(err.to_string().contains("--target requires at least one --selector"), "{err}");
895    }
896
897    #[test]
898    fn session_scope_preserves_explicit_scope_target_wildcard() {
899        let target = address!("0x00000000000000000000000000000000000000aa");
900        let scope = vec![CallScope { target, selector_rules: vec![] }];
901
902        assert_eq!(session_scope(scope.clone(), None, vec![]).unwrap(), scope);
903    }
904
905    #[test]
906    fn inner_command_clears_inherited_signer_env_for_session_child() {
907        let session_id = B256::from([0x7a; 32]);
908        let command = InnerCommand::parse("forge script Deploy".to_string()).unwrap();
909        let child = command.command(session_id);
910
911        for key in SESSION_CHILD_SIGNER_ENV {
912            assert_eq!(
913                command_env(&child, key),
914                Some(None),
915                "expected {key} to be removed from session child environment"
916            );
917        }
918
919        let expected_session_id = format!("{session_id:?}");
920        assert_eq!(
921            command_env(&child, TEMPO_SESSION_ID_ENV),
922            Some(Some(OsStr::new(&expected_session_id)))
923        );
924        assert_eq!(
925            command_env(&child, "ETH_FROM"),
926            None,
927            "ETH_FROM is a sender hint and should not be stripped by session --for"
928        );
929    }
930
931    #[cfg(unix)]
932    #[test]
933    fn inner_command_interrupt_terminates_child() {
934        let runtime = tokio::runtime::Runtime::new().unwrap();
935        runtime.block_on(async {
936            let session_id = B256::from([0x7b; 32]);
937            let command = InnerCommand::parse("sh -c 'sleep 30'".to_string()).unwrap();
938            let err = command
939                .run_with_interrupt(session_id, std::future::ready(Ok("test interrupt")))
940                .await
941                .unwrap_err();
942
943            assert!(err.to_string().contains("interrupted by test interrupt"), "{err}");
944        });
945    }
946
947    fn command_env<'a>(command: &'a Command, key: &str) -> Option<Option<&'a OsStr>> {
948        command.get_envs().find_map(|(name, value)| (name == key).then_some(value))
949    }
950
951    #[test]
952    fn explicit_revoke_preflight_error_preserves_local_key_material_for_retry() {
953        with_tempo_home(|| {
954            let runtime = tokio::runtime::Runtime::new().unwrap();
955            runtime.block_on(async {
956                let session_id = B256::from([0xd0; 32]);
957                let entry = sample_session_entry(session_id, SessionStatus::Active);
958                upsert_session_entry(entry).unwrap();
959
960                let mut send_tx = empty_send_tx_opts();
961                send_tx.eth.rpc.common.rpc_url = Some("http://127.0.0.1:9".to_string());
962                let err =
963                    run_revoke(session_id, false, TransactionOpts::parse_from(["cast"]), send_tx)
964                        .await
965                        .unwrap_err();
966
967                let session = read_session_entry(session_id).unwrap().unwrap();
968                assert_eq!(session.status, SessionStatus::Active, "{err:#}");
969                assert!(session.key.is_some());
970            });
971        });
972    }
973
974    #[test]
975    fn run_for_success_marks_session_revoking_before_revoke_preflight() {
976        with_tempo_home(|| {
977            let runtime = tokio::runtime::Runtime::new().unwrap();
978            runtime.block_on(async {
979                let session_id = B256::from([0xd7; 32]);
980                upsert_session_entry(sample_session_entry(session_id, SessionStatus::Active))
981                    .unwrap();
982
983                let mut send_tx = empty_send_tx_opts();
984                send_tx.eth.rpc.common.rpc_url = Some("http://127.0.0.1:9".to_string());
985                let err = cleanup_session_run(
986                    session_id,
987                    true,
988                    TransactionOpts::parse_from(["cast"]),
989                    send_tx,
990                )
991                .await
992                .unwrap_err();
993
994                let session = read_session_entry(session_id).unwrap().unwrap();
995                assert_eq!(session.status, SessionStatus::Revoking, "{err:#}");
996                assert!(session.key.is_none());
997            });
998        });
999    }
1000
1001    #[test]
1002    fn run_for_retire_local_session_before_revoke_preflight() {
1003        with_tempo_home(|| {
1004            let runtime = tokio::runtime::Runtime::new().unwrap();
1005            runtime.block_on(async {
1006                let session_id = B256::from([0xd4; 32]);
1007                upsert_session_entry(sample_session_entry(session_id, SessionStatus::Active))
1008                    .unwrap();
1009
1010                retire_session_run_locally(session_id).unwrap();
1011
1012                let mut send_tx = empty_send_tx_opts();
1013                send_tx.eth.rpc.common.rpc_url = Some("http://127.0.0.1:9".to_string());
1014                let err =
1015                    run_revoke(session_id, false, TransactionOpts::parse_from(["cast"]), send_tx)
1016                        .await
1017                        .unwrap_err();
1018
1019                let session = read_session_entry(session_id).unwrap().unwrap();
1020                assert_eq!(session.status, SessionStatus::Failed, "{err:#}");
1021                assert!(session.key.is_none());
1022            });
1023        });
1024    }
1025
1026    #[test]
1027    fn run_for_unprovisioned_cleanup_remains_retryable() {
1028        with_tempo_home(|| {
1029            let session_id = B256::from([0xd6; 32]);
1030            upsert_session_entry(sample_session_entry(session_id, SessionStatus::Active)).unwrap();
1031
1032            retire_session_run_locally(session_id).unwrap();
1033            let entry = read_session_entry(session_id).unwrap().unwrap();
1034            let err = handle_unprovisioned_revoke(session_id, &entry, UnprovisionedKeyPolicy::Fail)
1035                .unwrap_err();
1036
1037            assert!(err.to_string().contains("pending transactions"), "{err}");
1038            let session = read_session_entry(session_id).unwrap().unwrap();
1039            assert_eq!(session.status, SessionStatus::Failed);
1040            assert!(session.key.is_none());
1041        });
1042    }
1043
1044    #[test]
1045    fn run_for_retire_local_session_does_not_downgrade_revoked_status() {
1046        with_tempo_home(|| {
1047            let session_id = B256::from([0xd5; 32]);
1048            upsert_session_entry(sample_session_entry(session_id, SessionStatus::Revoked)).unwrap();
1049
1050            retire_session_run_locally(session_id).unwrap();
1051
1052            let session = read_session_entry(session_id).unwrap().unwrap();
1053            assert_eq!(session.status, SessionStatus::Revoked);
1054            assert!(session.key.is_none());
1055        });
1056    }
1057
1058    #[test]
1059    fn revoke_error_does_not_downgrade_existing_revoked_status() {
1060        with_tempo_home(|| {
1061            let runtime = tokio::runtime::Runtime::new().unwrap();
1062            runtime.block_on(async {
1063                let session_id = B256::from([0xd1; 32]);
1064                upsert_session_entry(sample_session_entry(session_id, SessionStatus::Revoking))
1065                    .unwrap();
1066                update_session_status(session_id, SessionStatus::Revoked).unwrap();
1067
1068                let mut send_tx = empty_send_tx_opts();
1069                send_tx.eth.rpc.common.rpc_url = Some("http://127.0.0.1:9".to_string());
1070                let config = send_tx.eth.load_config().unwrap();
1071                let provider =
1072                    ProviderBuilder::<TempoNetwork>::from_config(&config).unwrap().build().unwrap();
1073                handle_revoke_error(
1074                    &provider,
1075                    session_id,
1076                    &sample_session_entry(session_id, SessionStatus::Revoking),
1077                )
1078                .await;
1079
1080                assert_eq!(
1081                    read_session_entry(session_id).unwrap().unwrap().status,
1082                    SessionStatus::Revoked
1083                );
1084            });
1085        });
1086    }
1087
1088    #[test]
1089    fn revoke_submit_error_keeps_revoking_session_retryable() {
1090        with_tempo_home(|| {
1091            let runtime = tokio::runtime::Runtime::new().unwrap();
1092            runtime.block_on(async {
1093                let session_id = B256::from([0xd3; 32]);
1094                let entry = sample_session_entry(session_id, SessionStatus::Active);
1095                upsert_session_entry(entry.clone()).unwrap();
1096                assert!(
1097                    update_session_status_if(
1098                        session_id,
1099                        SessionStatus::Active,
1100                        SessionStatus::Revoking,
1101                    )
1102                    .unwrap()
1103                );
1104
1105                let mut send_tx = empty_send_tx_opts();
1106                send_tx.eth.rpc.common.rpc_url = Some("http://127.0.0.1:9".to_string());
1107                let config = send_tx.eth.load_config().unwrap();
1108                let provider =
1109                    ProviderBuilder::<TempoNetwork>::from_config(&config).unwrap().build().unwrap();
1110                handle_revoke_error(&provider, session_id, &entry).await;
1111
1112                let session = read_session_entry(session_id).unwrap().unwrap();
1113                assert_eq!(session.status, SessionStatus::Revoking);
1114                assert!(session.key.is_none());
1115            });
1116        });
1117    }
1118
1119    #[test]
1120    fn revoke_retry_preflight_error_does_not_downgrade_revoked_status() {
1121        with_tempo_home(|| {
1122            let runtime = tokio::runtime::Runtime::new().unwrap();
1123            runtime.block_on(async {
1124                let session_id = B256::from([0xd2; 32]);
1125                upsert_session_entry(sample_session_entry(session_id, SessionStatus::Revoked))
1126                    .unwrap();
1127
1128                let mut send_tx = empty_send_tx_opts();
1129                send_tx.eth.rpc.common.rpc_url = Some("http://127.0.0.1:9".to_string());
1130                let _ =
1131                    run_revoke(session_id, false, TransactionOpts::parse_from(["cast"]), send_tx)
1132                        .await
1133                        .unwrap_err();
1134
1135                assert_eq!(
1136                    read_session_entry(session_id).unwrap().unwrap().status,
1137                    SessionStatus::Revoked
1138                );
1139            });
1140        });
1141    }
1142
1143    #[test]
1144    fn create_and_local_revoke_session_entry_round_trips() {
1145        with_tempo_home(|| {
1146            let runtime = tokio::runtime::Runtime::new().unwrap();
1147            runtime.block_on(async {
1148                let root = address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266");
1149                let private_key = ROOT_PRIVATE_KEY.to_string();
1150                let wallet = WalletOpts {
1151                    raw: foundry_wallets::RawWalletOpts {
1152                        private_key: Some(private_key),
1153                        ..Default::default()
1154                    },
1155                    ..Default::default()
1156                };
1157
1158                let entry = build_session_entry(
1159                    root,
1160                    4217,
1161                    600,
1162                    vec![CallScope {
1163                        target: address!("0x00000000000000000000000000000000000000aa"),
1164                        selector_rules: vec![],
1165                    }],
1166                    vec![],
1167                    wallet,
1168                )
1169                .await
1170                .unwrap();
1171                assert_eq!(entry.status, SessionStatus::Active);
1172                assert!(entry.key.is_some());
1173
1174                let session_id = entry.session_id;
1175                let expiry = entry.expiry;
1176                upsert_session_entry(entry).unwrap();
1177                let record = foundry_common::tempo::read_session_record().unwrap();
1178                assert_eq!(record.sessions.len(), 1);
1179                assert_eq!(record.sessions[0].session_id, session_id);
1180                assert!(record.sessions[0].has_live_key_at(expiry - 1));
1181
1182                assert!(update_session_status(session_id, SessionStatus::Revoked).unwrap());
1183                let record = foundry_common::tempo::read_session_record().unwrap();
1184                let session = record.get(session_id).unwrap();
1185                assert_eq!(session.status, SessionStatus::Revoked);
1186                assert!(session.key.is_none());
1187            });
1188        });
1189    }
1190
1191    fn empty_send_tx_opts() -> SendTxOpts {
1192        SendTxOpts {
1193            cast_async: false,
1194            sync: false,
1195            confirmations: 1,
1196            timeout: None,
1197            poll_interval: None,
1198            eth: EthereumOpts::default(),
1199            browser: Default::default(),
1200        }
1201    }
1202
1203    fn sample_session_entry(session_id: B256, status: SessionStatus) -> SessionEntry {
1204        let key = match status {
1205            SessionStatus::Revoking
1206            | SessionStatus::Revoked
1207            | SessionStatus::Expired
1208            | SessionStatus::Failed => None,
1209            _ => Some(foundry_common::tempo::SessionKeyMaterial {
1210                key_type: foundry_common::tempo::KeyType::Secp256k1,
1211                key: ROOT_PRIVATE_KEY.to_string(),
1212                key_authorization: None,
1213            }),
1214        };
1215
1216        SessionEntry {
1217            session_id,
1218            root_account: address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"),
1219            chain_id: 4217,
1220            key_address: address!("0x00000000000000000000000000000000000000bb"),
1221            expiry: 200,
1222            scope: None,
1223            limits: None,
1224            status,
1225            key,
1226        }
1227    }
1228}