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] = &[
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#[derive(Debug, Args)]
72#[command(args_conflicts_with_subcommands = true)]
73pub struct SessionArgs {
74 #[command(subcommand)]
75 pub command: Option<SessionSubcommands>,
76
77 #[arg(long)]
79 pub force: bool,
80
81 #[arg(long = "root", value_name = "ADDRESS")]
83 pub root_account: Option<Address>,
84
85 #[arg(long = "expires", id = "session_expires", value_name = "DURATION", value_parser = parse_period)]
87 pub expires: Option<u64>,
88
89 #[arg(long = "scope", value_parser = parse_scope)]
91 pub scope: Vec<CallScope>,
92
93 #[arg(long = "target", value_name = "ADDRESS")]
95 pub target: Option<Address>,
96
97 #[arg(long = "selector", value_name = "SELECTOR")]
99 pub selectors: Vec<String>,
100
101 #[arg(long = "spend-limit", value_parser = parse_spend_limit)]
103 pub spend_limits: Vec<SessionSpendLimit>,
104
105 #[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 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 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#[derive(Debug, Parser)]
194pub enum SessionSubcommands {
195 Create {
197 #[arg(long = "root", value_name = "ADDRESS")]
199 root_account: Address,
200
201 #[arg(long = "chain-id", value_name = "CHAIN_ID")]
203 chain_id: u64,
204
205 #[arg(long = "expires", value_name = "DURATION", value_parser = parse_period)]
207 expires: u64,
208
209 #[arg(long = "scope", value_parser = parse_scope, required = true)]
211 scope: Vec<CallScope>,
212
213 #[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 {
223 #[arg(value_name = "SESSION_ID")]
225 session_id: B256,
226
227 #[arg(long)]
229 local: bool,
230
231 #[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
477async 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
509enum UnprovisionedKeyPolicy {
510 RevokeLocally,
512 Fail,
514}
515
516async 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 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 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
659async 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
725fn parse_scope(s: &str) -> Result<CallScope, String> {
727 parse_policy_scope(s).map(CallScope::from)
728}
729
730fn 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 unsafe { std::env::set_var("TEMPO_HOME", tmp.path()) };
762 test();
763 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 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 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}