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