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