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