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