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