1use super::TempoOpts;
2use alloy_primitives::{Address, B256};
3use eyre::{Result, WrapErr};
4use foundry_common::tempo::{ResolvedSessionSigner, resolve_live_session_signer};
5use foundry_wallets::{MultiWalletOpts, WalletOpts};
6use std::{
7 str::FromStr,
8 time::{SystemTime, UNIX_EPOCH},
9};
10
11pub const TEMPO_SESSION_ID_ENV: &str = "TEMPO_SESSION_ID";
13
14impl TempoOpts {
15 pub fn session_id(&self) -> Result<Option<B256>> {
17 if let Some(session) = self.session {
18 return Ok(Some(session));
19 }
20
21 let raw = match std::env::var(TEMPO_SESSION_ID_ENV) {
22 Ok(raw) => raw,
23 Err(std::env::VarError::NotPresent) => return Ok(None),
24 Err(std::env::VarError::NotUnicode(_)) => {
25 eyre::bail!("invalid {TEMPO_SESSION_ID_ENV}: value is not valid UTF-8");
26 }
27 };
28
29 let raw = raw.trim();
30 if raw.is_empty() {
31 return Ok(None);
32 }
33
34 B256::from_str(raw).map(Some).wrap_err_with(|| {
35 format!("invalid {TEMPO_SESSION_ID_ENV}: expected 32-byte hex session id")
36 })
37 }
38
39 pub fn session_signer_for_wallet(
44 &self,
45 wallet: &WalletOpts,
46 expected_chain_id: u64,
47 ) -> Result<Option<ResolvedSessionSigner>> {
48 let Some(session_id) = self.session_id()? else {
49 return Ok(None);
50 };
51 ensure_no_explicit_wallet_signer(wallet)?;
52 Ok(Some(resolve_session_signer(session_id, wallet.from, expected_chain_id)?))
53 }
54
55 pub fn session_signer_for_multi_wallet(
57 &self,
58 wallets: &MultiWalletOpts,
59 expected_sender: Option<Address>,
60 expected_chain_id: u64,
61 ) -> Result<Option<ResolvedSessionSigner>> {
62 let Some(session_id) = self.session_id()? else {
63 return Ok(None);
64 };
65 ensure_no_explicit_multi_wallet_signer(wallets)?;
66 Ok(Some(resolve_session_signer(session_id, expected_sender, expected_chain_id)?))
67 }
68
69 pub fn session_signer_for_multi_wallet_any_chain(
75 &self,
76 wallets: &MultiWalletOpts,
77 expected_sender: Option<Address>,
78 ) -> Result<Option<ResolvedSessionSigner>> {
79 let Some(session_id) = self.session_id()? else {
80 return Ok(None);
81 };
82 ensure_no_explicit_multi_wallet_signer(wallets)?;
83 let resolved = resolve_session(session_id)?;
84 ensure_expected_sender(expected_sender, resolved.access_key.account())?;
85 Ok(Some(resolved))
86 }
87
88 pub fn session_sender_for_multi_wallet(
94 &self,
95 wallets: &MultiWalletOpts,
96 expected_sender: Option<Address>,
97 ) -> Result<Option<Address>> {
98 Ok(self
99 .session_signer_for_multi_wallet_any_chain(wallets, expected_sender)?
100 .map(|resolved| resolved.access_key.account()))
101 }
102}
103
104fn resolve_session_signer(
109 session_id: B256,
110 expected_sender: Option<Address>,
111 expected_chain_id: u64,
112) -> Result<ResolvedSessionSigner> {
113 let resolved = resolve_session(session_id)?;
114
115 if resolved.session.chain_id != expected_chain_id {
116 eyre::bail!(
117 "Tempo session {session_id:?} is for chain {}, but command is using chain {}",
118 resolved.session.chain_id,
119 expected_chain_id
120 );
121 }
122
123 ensure_expected_sender(expected_sender, resolved.access_key.account())?;
124 Ok(resolved)
125}
126
127fn resolve_session(session_id: B256) -> Result<ResolvedSessionSigner> {
128 let now = SystemTime::now().duration_since(UNIX_EPOCH).expect("time went backwards");
129 resolve_live_session_signer(session_id, now.as_secs())?
130 .ok_or_else(|| eyre::eyre!("Tempo session {session_id:?} is not active or has no live key"))
131}
132
133fn ensure_expected_sender(expected_sender: Option<Address>, session_sender: Address) -> Result<()> {
134 if let Some(from) = expected_sender
135 && from != session_sender
136 {
137 eyre::bail!("sender {from} does not match Tempo session root account {session_sender}");
138 }
139
140 Ok(())
141}
142
143fn ensure_no_explicit_wallet_signer(wallet: &WalletOpts) -> Result<()> {
147 if wallet.has_explicit_signer() {
148 eyre::bail!(
149 "--tempo.session/TEMPO_SESSION_ID cannot be combined with explicit wallet signer options"
150 );
151 }
152 Ok(())
153}
154
155trait ExplicitSignerOpts {
156 fn has_explicit_signer(&self) -> bool;
157}
158
159impl ExplicitSignerOpts for WalletOpts {
160 fn has_explicit_signer(&self) -> bool {
161 self.raw.interactive
162 || self.raw.private_key.is_some()
163 || self.raw.mnemonic.is_some()
164 || self.keystore_path.is_some()
165 || self.keystore_account_name.is_some()
166 || self.ledger
167 || self.trezor
168 || self.aws
169 || self.gcp
170 || self.turnkey
171 || self.tempo_access_key.is_some()
172 }
173}
174
175fn ensure_no_explicit_multi_wallet_signer(wallets: &MultiWalletOpts) -> Result<()> {
180 if wallets.has_explicit_signer() {
181 eyre::bail!(
182 "--tempo.session/TEMPO_SESSION_ID cannot be combined with explicit wallet signer options"
183 );
184 }
185 Ok(())
186}
187
188impl ExplicitSignerOpts for MultiWalletOpts {
189 fn has_explicit_signer(&self) -> bool {
190 self.interactive
191 || self.interactives > 0
192 || self.private_key.is_some()
193 || self.private_keys.is_some()
194 || self.mnemonics.is_some()
195 || self.keystore_paths.is_some()
196 || self.keystore_account_names.is_some()
197 || self.ledger
198 || self.trezor
199 || self.aws
200 || self.gcp
201 || self.turnkey
202 || self.browser.browser
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use alloy_signer::{Signer, SignerSync};
210 use clap::Parser;
211 use foundry_common::tempo::{
212 GeneratedSessionKey, SessionAuthorizationRequest, SessionEntry, TEMPO_HOME_ENV,
213 upsert_session_entry,
214 };
215 use std::{num::NonZeroU64, sync::Mutex};
216 use tempo_primitives::transaction::{CallScope, PrimitiveSignature};
217
218 const SESSION_PRIVATE_KEY: &str =
219 "0x59c6995e998f97a5a004497e5da3b5d2b2b66a87f064d39c44da0b6d6e4f8ff0";
220 const ROOT_PRIVATE_KEY: &str =
221 "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
222
223 static ENV_MUTEX: Mutex<()> = Mutex::new(());
224
225 fn with_clean_session_env(test: impl FnOnce()) {
226 with_session_env(None, test);
227 }
228
229 fn with_clean_session_home(test: impl FnOnce()) {
230 let tmp = tempfile::tempdir().unwrap();
231 with_session_env(Some(tmp.path()), test);
232 }
233
234 fn with_session_env(tempo_home: Option<&std::path::Path>, test: impl FnOnce()) {
235 let _guard = ENV_MUTEX.lock().unwrap();
236 unsafe {
238 std::env::remove_var(TEMPO_SESSION_ID_ENV);
239 if let Some(tempo_home) = tempo_home {
240 std::env::set_var(TEMPO_HOME_ENV, tempo_home);
241 }
242 }
243 test();
244 unsafe {
246 std::env::remove_var(TEMPO_SESSION_ID_ENV);
247 std::env::remove_var(TEMPO_HOME_ENV);
248 }
249 }
250
251 fn session_id(byte: u8) -> B256 {
252 B256::from([byte; 32])
253 }
254
255 fn active_session_entry(session_id: B256) -> SessionEntry {
256 let foundry_wallets::WalletSigner::Local(root) =
257 foundry_wallets::utils::create_private_key_signer(ROOT_PRIVATE_KEY).unwrap()
258 else {
259 unreachable!("a raw private key always creates a local signer")
260 };
261 let key = GeneratedSessionKey::from_private_key(SESSION_PRIVATE_KEY).unwrap();
262 let prepared = SessionAuthorizationRequest {
263 session_id,
264 root_account: root.address(),
265 chain_id: 4217,
266 key_address: key.address(),
267 expiry: NonZeroU64::new(u64::MAX).unwrap(),
268 scope: vec![CallScope { target: Address::repeat_byte(0xaa), selector_rules: vec![] }],
269 spend_limits: vec![],
270 }
271 .prepare(0)
272 .unwrap();
273 let signature = root.sign_hash_sync(&prepared.authorization.signature_hash()).unwrap();
274 let authorization =
275 prepared.authorization.clone().into_signed(PrimitiveSignature::Secp256k1(signature));
276 prepared.into_active_entry(key, &authorization).unwrap()
277 }
278
279 fn root_address() -> Address {
280 foundry_wallets::utils::create_private_key_signer(ROOT_PRIVATE_KEY).unwrap().address()
281 }
282
283 #[test]
284 fn parses_tempo_session_cli_arg() {
285 with_clean_session_env(|| {
286 let id = session_id(0x11);
287 let opts =
288 TempoOpts::try_parse_from(["", "--tempo.session", &format!("{id:?}")]).unwrap();
289
290 assert_eq!(opts.session, Some(id));
291 assert_eq!(opts.session_id().unwrap(), Some(id));
292 });
293 }
294
295 #[test]
296 fn tempo_session_env_is_used_when_cli_arg_is_absent() {
297 with_clean_session_env(|| {
298 let id = session_id(0x22);
299 unsafe { std::env::set_var(TEMPO_SESSION_ID_ENV, format!("{id:?}")) };
301 let opts = TempoOpts::default();
302
303 assert_eq!(opts.session_id().unwrap(), Some(id));
304 });
305 }
306
307 #[test]
308 fn tempo_session_cli_arg_overrides_env() {
309 with_clean_session_env(|| {
310 let env_id = session_id(0x33);
311 let cli_id = session_id(0x44);
312 unsafe { std::env::set_var(TEMPO_SESSION_ID_ENV, format!("{env_id:?}")) };
314
315 let opts =
316 TempoOpts::try_parse_from(["", "--tempo.session", &format!("{cli_id:?}")]).unwrap();
317
318 assert_eq!(opts.session_id().unwrap(), Some(cli_id));
319 });
320 }
321
322 #[test]
323 fn invalid_tempo_session_env_fails_closed() {
324 with_clean_session_env(|| {
325 unsafe { std::env::set_var(TEMPO_SESSION_ID_ENV, "not-a-session-id") };
327 let err = TempoOpts::default().session_id().unwrap_err();
328
329 assert!(err.to_string().contains(TEMPO_SESSION_ID_ENV), "{err}");
330 });
331 }
332
333 #[cfg(unix)]
334 #[test]
335 fn non_unicode_tempo_session_env_fails_closed() {
336 use std::{ffi::OsString, os::unix::ffi::OsStringExt};
337
338 with_clean_session_env(|| {
339 unsafe {
341 std::env::set_var(TEMPO_SESSION_ID_ENV, OsString::from_vec(vec![0xff]));
342 }
343 let err = TempoOpts::default().session_id().unwrap_err();
344
345 assert!(err.to_string().contains("value is not valid UTF-8"), "{err}");
346 });
347 }
348
349 #[test]
350 fn tempo_session_rejects_explicit_wallet_signers() {
351 let opts = TempoOpts { session: Some(session_id(0x55)), ..Default::default() };
352 let wallet = WalletOpts {
353 raw: foundry_wallets::RawWalletOpts {
354 private_key: Some("0xdead".to_string()),
355 ..Default::default()
356 },
357 ..Default::default()
358 };
359
360 let err = opts.session_signer_for_wallet(&wallet, 4217).unwrap_err();
361 assert!(err.to_string().contains("explicit wallet signer"), "{err}");
362 }
363
364 #[test]
365 fn absent_tempo_session_does_not_reject_explicit_wallet_signers() {
366 with_clean_session_env(|| {
367 let opts = TempoOpts::default();
368 let wallet = WalletOpts {
369 raw: foundry_wallets::RawWalletOpts {
370 private_key: Some("0xdead".to_string()),
371 ..Default::default()
372 },
373 ..Default::default()
374 };
375
376 assert!(opts.session_signer_for_wallet(&wallet, 4217).unwrap().is_none());
377 });
378 }
379
380 #[test]
381 fn tempo_session_rejects_explicit_multi_wallet_signers() {
382 let opts = TempoOpts { session: Some(session_id(0x66)), ..Default::default() };
383 let wallets =
384 MultiWalletOpts { private_key: Some("0xdead".to_string()), ..Default::default() };
385
386 let err = opts.session_signer_for_multi_wallet(&wallets, None, 4217).unwrap_err();
387 assert!(err.to_string().contains("explicit wallet signer"), "{err}");
388 }
389
390 #[test]
391 fn absent_tempo_session_does_not_reject_explicit_multi_wallet_signers() {
392 with_clean_session_env(|| {
393 let opts = TempoOpts::default();
394 let wallets =
395 MultiWalletOpts { private_key: Some("0xdead".to_string()), ..Default::default() };
396
397 assert!(opts.session_signer_for_multi_wallet(&wallets, None, 4217).unwrap().is_none());
398 });
399 }
400
401 #[test]
402 fn tempo_session_rejects_wrong_chain() {
403 with_clean_session_home(|| {
404 let id = session_id(0x77);
405 upsert_session_entry(active_session_entry(id)).unwrap();
406 let opts = TempoOpts { session: Some(id), ..Default::default() };
407
408 let err = opts.session_signer_for_wallet(&WalletOpts::default(), 1).unwrap_err();
409
410 assert!(err.to_string().contains("is for chain 4217"), "{err}");
411 });
412 }
413
414 #[test]
415 fn tempo_session_sender_does_not_validate_chain() {
416 with_clean_session_home(|| {
417 let id = session_id(0x99);
418 upsert_session_entry(active_session_entry(id)).unwrap();
419 let opts = TempoOpts { session: Some(id), ..Default::default() };
420 let wallets = MultiWalletOpts::default();
421
422 let sender = opts.session_sender_for_multi_wallet(&wallets, None).unwrap();
423
424 assert_eq!(sender, Some(root_address()));
425 });
426 }
427
428 #[test]
429 fn tempo_session_signer_any_chain_returns_session_chain() {
430 with_clean_session_home(|| {
431 let id = session_id(0xaa);
432 upsert_session_entry(active_session_entry(id)).unwrap();
433 let opts = TempoOpts { session: Some(id), ..Default::default() };
434 let wallets = MultiWalletOpts::default();
435
436 let session = opts
437 .session_signer_for_multi_wallet_any_chain(&wallets, Some(root_address()))
438 .unwrap()
439 .unwrap();
440
441 assert_eq!(session.session.chain_id, 4217);
442 assert_eq!(session.access_key.account(), root_address());
443 });
444 }
445
446 #[test]
447 fn tempo_session_rejects_sender_mismatch() {
448 with_clean_session_home(|| {
449 let id = session_id(0x88);
450 upsert_session_entry(active_session_entry(id)).unwrap();
451 let opts = TempoOpts { session: Some(id), ..Default::default() };
452 let wallets = MultiWalletOpts::default();
453
454 let err = opts
455 .session_signer_for_multi_wallet(&wallets, Some(Address::from([0x22; 20])), 4217)
456 .unwrap_err();
457
458 assert!(err.to_string().contains("does not match Tempo session root account"), "{err}");
459 });
460 }
461}