Skip to main content

foundry_cli/opts/tempo/
session.rs

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
9/// Environment variable used to pass a Tempo wallet session to child commands.
10pub const TEMPO_SESSION_ID_ENV: &str = "TEMPO_SESSION_ID";
11
12impl TempoOpts {
13    /// Returns the effective session id, preferring the CLI flag over `TEMPO_SESSION_ID`.
14    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    /// Resolves the configured Tempo wallet session for single-wallet commands.
38    ///
39    /// Explicit session configuration is fail-closed: if a session id was provided but no live
40    /// session can be loaded, callers must not fall back to any long-lived signer.
41    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    /// Resolves the configured Tempo wallet session for multi-wallet commands.
54    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    /// Resolves the configured Tempo wallet session for multi-chain commands.
68    ///
69    /// This validates the session and root sender without forcing a command-level chain. Callers
70    /// that select signers per transaction must scope the returned signer to
71    /// `session.chain_id`.
72    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    /// Resolves only the root sender for a configured Tempo wallet session.
87    ///
88    /// Multi-chain scripts need the sender before execution so `vm.startBroadcast()` records the
89    /// session root, but their chain validation must wait until broadcast sequences reveal the real
90    /// transaction chains.
91    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
102/// Loads the live session signer and validates it against the command context.
103///
104/// The session must be active on the requested chain, and any explicit sender must match the
105/// session root account.
106fn 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
140/// Rejects single-wallet signer options when a Tempo session is already selected.
141///
142/// Session signing must fail closed instead of falling back to a long-lived or explicit signer.
143fn 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
172/// Rejects multi-wallet signer options when a Tempo session is already selected.
173///
174/// Script and broadcast session signing must not fall back to long-lived, browser, or explicit
175/// signers.
176fn 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        // 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::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            // SAFETY: serialized with other tests that mutate Tempo env vars.
297            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            // SAFETY: serialized with other tests that mutate Tempo env vars.
310            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            // SAFETY: serialized with other tests that mutate Tempo env vars.
323            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            // SAFETY: serialized with other tests that mutate Tempo env vars.
337            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}