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