Skip to main content

foundry_cli/opts/tempo/
session.rs

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
11/// Environment variable used to pass a Tempo wallet session to child commands.
12pub const TEMPO_SESSION_ID_ENV: &str = "TEMPO_SESSION_ID";
13
14impl TempoOpts {
15    /// Returns the effective session id, preferring the CLI flag over `TEMPO_SESSION_ID`.
16    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    /// Resolves the configured Tempo wallet session for single-wallet commands.
40    ///
41    /// Explicit session configuration is fail-closed: if a session id was provided but no live
42    /// session can be loaded, callers must not fall back to any long-lived signer.
43    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    /// Resolves the configured Tempo wallet session for multi-wallet commands.
56    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    /// Resolves the configured Tempo wallet session for multi-chain commands.
70    ///
71    /// This validates the session and root sender without forcing a command-level chain. Callers
72    /// that select signers per transaction must scope the returned signer to
73    /// `session.chain_id`.
74    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.wallet_address)?;
85        Ok(Some(resolved))
86    }
87
88    /// Resolves only the root sender for a configured Tempo wallet session.
89    ///
90    /// Multi-chain scripts need the sender before execution so `vm.startBroadcast()` records the
91    /// session root, but their chain validation must wait until broadcast sequences reveal the real
92    /// transaction chains.
93    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.wallet_address))
101    }
102}
103
104/// Loads the live session signer and validates it against the command context.
105///
106/// The session must be active on the requested chain, and any explicit sender must match the
107/// session root account.
108fn 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.wallet_address)?;
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
143/// Rejects single-wallet signer options when a Tempo session is already selected.
144///
145/// Session signing must fail closed instead of falling back to a long-lived or explicit signer.
146fn 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
175/// Rejects multi-wallet signer options when a Tempo session is already selected.
176///
177/// Script and broadcast session signing must not fall back to long-lived, browser, or explicit
178/// signers.
179fn 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;
210    use clap::Parser;
211    use foundry_common::tempo::{
212        KeyType, SessionEntry, SessionKeyMaterial, SessionStatus, TEMPO_HOME_ENV,
213        upsert_session_entry,
214    };
215    use std::sync::Mutex;
216
217    const SESSION_PRIVATE_KEY: &str =
218        "0x59c6995e998f97a5a004497e5da3b5d2b2b66a87f064d39c44da0b6d6e4f8ff0";
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        // SAFETY: serialized with other tests that mutate Tempo env vars.
234        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        // SAFETY: serialized with other tests that mutate Tempo env vars.
242        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::from([byte; 32])
250    }
251
252    fn active_session_entry(session_id: B256) -> SessionEntry {
253        let key = foundry_wallets::utils::create_private_key_signer(SESSION_PRIVATE_KEY).unwrap();
254        SessionEntry {
255            session_id,
256            root_account: Address::from([0x11; 20]),
257            chain_id: 4217,
258            key_address: key.address(),
259            expiry: u64::MAX,
260            scope: None,
261            limits: None,
262            status: SessionStatus::Active,
263            key: Some(SessionKeyMaterial {
264                key_type: KeyType::Secp256k1,
265                key: SESSION_PRIVATE_KEY.to_string(),
266                key_authorization: None,
267            }),
268        }
269    }
270
271    #[test]
272    fn parses_tempo_session_cli_arg() {
273        with_clean_session_env(|| {
274            let id = session_id(0x11);
275            let opts =
276                TempoOpts::try_parse_from(["", "--tempo.session", &format!("{id:?}")]).unwrap();
277
278            assert_eq!(opts.session, Some(id));
279            assert_eq!(opts.session_id().unwrap(), Some(id));
280        });
281    }
282
283    #[test]
284    fn tempo_session_env_is_used_when_cli_arg_is_absent() {
285        with_clean_session_env(|| {
286            let id = session_id(0x22);
287            // SAFETY: serialized with other tests that mutate Tempo env vars.
288            unsafe { std::env::set_var(TEMPO_SESSION_ID_ENV, format!("{id:?}")) };
289            let opts = TempoOpts::default();
290
291            assert_eq!(opts.session_id().unwrap(), Some(id));
292        });
293    }
294
295    #[test]
296    fn tempo_session_cli_arg_overrides_env() {
297        with_clean_session_env(|| {
298            let env_id = session_id(0x33);
299            let cli_id = session_id(0x44);
300            // SAFETY: serialized with other tests that mutate Tempo env vars.
301            unsafe { std::env::set_var(TEMPO_SESSION_ID_ENV, format!("{env_id:?}")) };
302
303            let opts =
304                TempoOpts::try_parse_from(["", "--tempo.session", &format!("{cli_id:?}")]).unwrap();
305
306            assert_eq!(opts.session_id().unwrap(), Some(cli_id));
307        });
308    }
309
310    #[test]
311    fn invalid_tempo_session_env_fails_closed() {
312        with_clean_session_env(|| {
313            // SAFETY: serialized with other tests that mutate Tempo env vars.
314            unsafe { std::env::set_var(TEMPO_SESSION_ID_ENV, "not-a-session-id") };
315            let err = TempoOpts::default().session_id().unwrap_err();
316
317            assert!(err.to_string().contains(TEMPO_SESSION_ID_ENV), "{err}");
318        });
319    }
320
321    #[cfg(unix)]
322    #[test]
323    fn non_unicode_tempo_session_env_fails_closed() {
324        use std::{ffi::OsString, os::unix::ffi::OsStringExt};
325
326        with_clean_session_env(|| {
327            // SAFETY: serialized with other tests that mutate Tempo env vars.
328            unsafe {
329                std::env::set_var(TEMPO_SESSION_ID_ENV, OsString::from_vec(vec![0xff]));
330            }
331            let err = TempoOpts::default().session_id().unwrap_err();
332
333            assert!(err.to_string().contains("value is not valid UTF-8"), "{err}");
334        });
335    }
336
337    #[test]
338    fn tempo_session_rejects_explicit_wallet_signers() {
339        let opts = TempoOpts { session: Some(session_id(0x55)), ..Default::default() };
340        let wallet = WalletOpts {
341            raw: foundry_wallets::RawWalletOpts {
342                private_key: Some("0xdead".to_string()),
343                ..Default::default()
344            },
345            ..Default::default()
346        };
347
348        let err = opts.session_signer_for_wallet(&wallet, 4217).unwrap_err();
349        assert!(err.to_string().contains("explicit wallet signer"), "{err}");
350    }
351
352    #[test]
353    fn absent_tempo_session_does_not_reject_explicit_wallet_signers() {
354        with_clean_session_env(|| {
355            let opts = TempoOpts::default();
356            let wallet = WalletOpts {
357                raw: foundry_wallets::RawWalletOpts {
358                    private_key: Some("0xdead".to_string()),
359                    ..Default::default()
360                },
361                ..Default::default()
362            };
363
364            assert!(opts.session_signer_for_wallet(&wallet, 4217).unwrap().is_none());
365        });
366    }
367
368    #[test]
369    fn tempo_session_rejects_explicit_multi_wallet_signers() {
370        let opts = TempoOpts { session: Some(session_id(0x66)), ..Default::default() };
371        let wallets =
372            MultiWalletOpts { private_key: Some("0xdead".to_string()), ..Default::default() };
373
374        let err = opts.session_signer_for_multi_wallet(&wallets, None, 4217).unwrap_err();
375        assert!(err.to_string().contains("explicit wallet signer"), "{err}");
376    }
377
378    #[test]
379    fn absent_tempo_session_does_not_reject_explicit_multi_wallet_signers() {
380        with_clean_session_env(|| {
381            let opts = TempoOpts::default();
382            let wallets =
383                MultiWalletOpts { private_key: Some("0xdead".to_string()), ..Default::default() };
384
385            assert!(opts.session_signer_for_multi_wallet(&wallets, None, 4217).unwrap().is_none());
386        });
387    }
388
389    #[test]
390    fn tempo_session_rejects_wrong_chain() {
391        with_clean_session_home(|| {
392            let id = session_id(0x77);
393            upsert_session_entry(active_session_entry(id)).unwrap();
394            let opts = TempoOpts { session: Some(id), ..Default::default() };
395
396            let err = opts.session_signer_for_wallet(&WalletOpts::default(), 1).unwrap_err();
397
398            assert!(err.to_string().contains("is for chain 4217"), "{err}");
399        });
400    }
401
402    #[test]
403    fn tempo_session_sender_does_not_validate_chain() {
404        with_clean_session_home(|| {
405            let id = session_id(0x99);
406            upsert_session_entry(active_session_entry(id)).unwrap();
407            let opts = TempoOpts { session: Some(id), ..Default::default() };
408            let wallets = MultiWalletOpts::default();
409
410            let sender = opts.session_sender_for_multi_wallet(&wallets, None).unwrap();
411
412            assert_eq!(sender, Some(Address::from([0x11; 20])));
413        });
414    }
415
416    #[test]
417    fn tempo_session_signer_any_chain_returns_session_chain() {
418        with_clean_session_home(|| {
419            let id = session_id(0xaa);
420            upsert_session_entry(active_session_entry(id)).unwrap();
421            let opts = TempoOpts { session: Some(id), ..Default::default() };
422            let wallets = MultiWalletOpts::default();
423
424            let session = opts
425                .session_signer_for_multi_wallet_any_chain(
426                    &wallets,
427                    Some(Address::from([0x11; 20])),
428                )
429                .unwrap()
430                .unwrap();
431
432            assert_eq!(session.session.chain_id, 4217);
433            assert_eq!(session.access_key.wallet_address, Address::from([0x11; 20]));
434        });
435    }
436
437    #[test]
438    fn tempo_session_rejects_sender_mismatch() {
439        with_clean_session_home(|| {
440            let id = session_id(0x88);
441            upsert_session_entry(active_session_entry(id)).unwrap();
442            let opts = TempoOpts { session: Some(id), ..Default::default() };
443            let wallets = MultiWalletOpts::default();
444
445            let err = opts
446                .session_signer_for_multi_wallet(&wallets, Some(Address::from([0x22; 20])), 4217)
447                .unwrap_err();
448
449            assert!(err.to_string().contains("does not match Tempo session root account"), "{err}");
450        });
451    }
452}