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