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