Skip to main content

foundry_common/tempo/
session.rs

1//! Tempo session registry and local lifecycle metadata.
2
3use super::{registry::*, tempo_home};
4use alloy_primitives::{Address, B256, Selector};
5use serde::{Deserialize, Serialize};
6use std::path::PathBuf;
7
8/// Relative path from Tempo home to the session registry file.
9pub const WALLET_SESSIONS_PATH: &str = "wallet/sessions.toml";
10
11const SESSIONS_HEADER: &str =
12    "# Tempo session registry — managed by Foundry / Tempo CLI.\n# Do not edit manually.";
13
14/// Status of a local session entry.
15#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
16#[serde(rename_all = "snake_case")]
17pub enum SessionStatus {
18    #[default]
19    Pending,
20    Active,
21    Revoking,
22    Revoked,
23    Expired,
24    Failed,
25}
26
27impl SessionStatus {
28    /// Returns `true` if the session is no longer expected to be usable.
29    pub const fn is_terminal(self) -> bool {
30        matches!(self, Self::Revoked | Self::Expired | Self::Failed)
31    }
32
33    /// Returns `true` if the session is still in-flight or usable.
34    pub const fn is_live(self) -> bool {
35        !self.is_terminal()
36    }
37}
38
39/// Spending limit stored for a session entry.
40#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
41pub struct SessionTokenLimit {
42    pub currency: Address,
43    pub limit: String,
44}
45
46/// A single selector rule in a session scope.
47#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
48pub struct SessionSelectorRule {
49    pub selector: Selector,
50    #[serde(default, skip_serializing_if = "Vec::is_empty")]
51    pub recipients: Vec<Address>,
52}
53
54/// A single target scope in a session entry.
55#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
56pub struct SessionCallScope {
57    pub target: Address,
58    /// Empty selector list means wildcard access for the target.
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub selector_rules: Vec<SessionSelectorRule>,
61}
62
63/// Persisted metadata for one temporary session.
64#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
65pub struct SessionEntry {
66    pub session_id: B256,
67    pub root_account: Address,
68    pub chain_id: u64,
69    pub key_address: Address,
70    /// Unix timestamp in seconds when the session expires.
71    ///
72    /// Tempo sessions are always bounded-lifetime. `0` is not a "never
73    /// expires" sentinel; it is already expired.
74    pub expiry: u64,
75    #[serde(default, skip_serializing_if = "Vec::is_empty")]
76    pub scope: Vec<SessionCallScope>,
77    #[serde(default, skip_serializing_if = "Vec::is_empty")]
78    pub limits: Vec<SessionTokenLimit>,
79    #[serde(default)]
80    pub status: SessionStatus,
81}
82
83impl SessionEntry {
84    /// Returns `true` if the session has passed its expiry timestamp.
85    pub const fn is_expired_at(&self, now: u64) -> bool {
86        now >= self.expiry
87    }
88}
89
90/// Top-level registry persisted in `wallet/sessions.toml`.
91#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)]
92pub struct SessionRecord {
93    #[serde(default)]
94    pub sessions: Vec<SessionEntry>,
95}
96
97impl SessionRecord {
98    /// Returns `true` if the registry has no session entries.
99    pub const fn is_empty(&self) -> bool {
100        self.sessions.is_empty()
101    }
102
103    /// Insert or replace a session by `session_id`.
104    pub fn upsert(&mut self, entry: SessionEntry) {
105        self.sessions.retain(|session| session.session_id != entry.session_id);
106        self.sessions.push(entry);
107    }
108
109    /// Remove a session by `session_id`. Returns `true` if an entry was removed.
110    pub fn remove(&mut self, session_id: B256) -> bool {
111        let before = self.sessions.len();
112        self.sessions.retain(|session| session.session_id != session_id);
113        self.sessions.len() != before
114    }
115
116    /// Mark expired live entries as expired. Returns the number updated.
117    pub fn mark_expired(&mut self, now: u64) -> usize {
118        let mut updated = 0;
119        for session in &mut self.sessions {
120            if session.status.is_live() && session.is_expired_at(now) {
121                session.status = SessionStatus::Expired;
122                updated += 1;
123            }
124        }
125        updated
126    }
127}
128
129/// Returns the path to the Tempo session registry file.
130pub fn session_registry_path() -> Option<PathBuf> {
131    tempo_home().map(|home| home.join(WALLET_SESSIONS_PATH))
132}
133
134/// Read and parse the Tempo session registry.
135///
136/// Returns `None` if the file doesn't exist or can't be read/parsed.
137/// Errors are logged as warnings.
138pub fn read_session_record() -> Option<SessionRecord> {
139    let path = session_registry_path()?;
140    match read_toml_file(&path, "tempo sessions") {
141        Ok(value) => value,
142        Err(e) => {
143            tracing::warn!(?path, %e, "failed to load tempo sessions file");
144            None
145        }
146    }
147}
148
149/// Atomically upsert a [`SessionEntry`] into the session registry.
150pub fn upsert_session_entry(entry: SessionEntry) -> eyre::Result<()> {
151    let path =
152        session_registry_path().ok_or_else(|| eyre::eyre!("could not resolve tempo home"))?;
153    let mut record = read_toml_file::<SessionRecord>(&path, "tempo sessions")?.unwrap_or_default();
154    record.upsert(entry);
155
156    write_toml_file_atomic(&path, &record, SESSIONS_HEADER)
157}
158
159/// Atomically remove a session from the registry.
160pub fn remove_session_entry(session_id: B256) -> eyre::Result<bool> {
161    let path =
162        session_registry_path().ok_or_else(|| eyre::eyre!("could not resolve tempo home"))?;
163    let mut record = read_toml_file::<SessionRecord>(&path, "tempo sessions")?.unwrap_or_default();
164    let removed = record.remove(session_id);
165    if removed {
166        write_toml_file_atomic(&path, &record, SESSIONS_HEADER)?;
167    }
168    Ok(removed)
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::tempo::with_tempo_home;
175    use std::{fs, str::FromStr};
176
177    fn sample_entry(session_id: B256, expiry: u64, status: SessionStatus) -> SessionEntry {
178        SessionEntry {
179            session_id,
180            root_account: Address::from_str("0x0000000000000000000000000000000000000001").unwrap(),
181            chain_id: 4217,
182            key_address: Address::from_str("0x0000000000000000000000000000000000000abc").unwrap(),
183            expiry,
184            scope: vec![SessionCallScope {
185                target: Address::from_str("0x00000000000000000000000000000000000000aa").unwrap(),
186                selector_rules: vec![SessionSelectorRule {
187                    selector: Selector::from_slice(&[0x12, 0x34, 0x56, 0x78]),
188                    recipients: vec![],
189                }],
190            }],
191            limits: vec![SessionTokenLimit {
192                currency: Address::from_str("0x00000000000000000000000000000000000000ff").unwrap(),
193                limit: "0".to_string(),
194            }],
195            status,
196        }
197    }
198
199    #[test]
200    fn session_registry_is_separate_from_keys_registry() {
201        with_tempo_home(|| {
202            let session_id = B256::from([0x11; 32]);
203            upsert_session_entry(sample_entry(session_id, 100, SessionStatus::Pending)).unwrap();
204
205            let session_path = session_registry_path().unwrap();
206            let keys_path = crate::tempo::tempo_keys_path().unwrap();
207            assert_eq!(session_path.file_name().and_then(|s| s.to_str()), Some("sessions.toml"));
208            assert_eq!(keys_path.file_name().and_then(|s| s.to_str()), Some("keys.toml"));
209            assert_ne!(session_path, keys_path);
210
211            let record = read_session_record().unwrap();
212            assert_eq!(record.sessions.len(), 1);
213            assert_eq!(record.sessions[0].session_id, session_id);
214        });
215    }
216
217    #[test]
218    fn session_registry_upsert_replaces_matching_session_id() {
219        with_tempo_home(|| {
220            let session_id = B256::from([0x22; 32]);
221            upsert_session_entry(sample_entry(session_id, 100, SessionStatus::Pending)).unwrap();
222            upsert_session_entry(sample_entry(session_id, 200, SessionStatus::Active)).unwrap();
223
224            let record = read_session_record().unwrap();
225            assert_eq!(record.sessions.len(), 1);
226            assert_eq!(record.sessions[0].expiry, 200);
227            assert_eq!(record.sessions[0].status, SessionStatus::Active);
228        });
229    }
230
231    #[test]
232    fn session_registry_remove_deletes_entry() {
233        with_tempo_home(|| {
234            let session_id = B256::from([0x33; 32]);
235            upsert_session_entry(sample_entry(session_id, 100, SessionStatus::Active)).unwrap();
236            assert!(remove_session_entry(session_id).unwrap());
237            assert!(read_session_record().unwrap().is_empty());
238        });
239    }
240
241    #[test]
242    fn session_record_marks_expired_live_entries() {
243        let mut record = SessionRecord {
244            sessions: vec![
245                sample_entry(B256::from([0x44; 32]), 10, SessionStatus::Pending),
246                sample_entry(B256::from([0x55; 32]), 10, SessionStatus::Revoked),
247            ],
248        };
249
250        assert_eq!(record.mark_expired(11), 1);
251        assert_eq!(record.sessions[0].status, SessionStatus::Expired);
252        assert_eq!(record.sessions[1].status, SessionStatus::Revoked);
253    }
254
255    #[test]
256    fn session_entry_roundtrips_scope_limits_and_status() {
257        let entry = sample_entry(B256::from([0x66; 32]), 1234, SessionStatus::Revoking);
258        let toml = toml::to_string(&entry).unwrap();
259        let decoded: SessionEntry = toml::from_str(&toml).unwrap();
260
261        assert_eq!(decoded.session_id, entry.session_id);
262        assert_eq!(decoded.scope.len(), 1);
263        assert_eq!(decoded.limits.len(), 1);
264        assert_eq!(decoded.status, SessionStatus::Revoking);
265        assert!(decoded.is_expired_at(1234));
266    }
267
268    #[test]
269    fn upsert_fails_closed_when_session_file_is_corrupt() {
270        with_tempo_home(|| {
271            let path = session_registry_path().unwrap();
272            fs::create_dir_all(path.parent().unwrap()).unwrap();
273            fs::write(&path, "sessions = [").unwrap();
274            let original = fs::read_to_string(&path).unwrap();
275
276            let session_id = B256::from([0x77; 32]);
277            let entry = sample_entry(session_id, 100, SessionStatus::Pending);
278
279            assert!(read_session_record().is_none());
280            assert!(upsert_session_entry(entry).is_err());
281            assert_eq!(fs::read_to_string(&path).unwrap(), original);
282        });
283    }
284
285    #[test]
286    fn remove_fails_closed_when_session_file_is_corrupt() {
287        with_tempo_home(|| {
288            let path = session_registry_path().unwrap();
289            fs::create_dir_all(path.parent().unwrap()).unwrap();
290            fs::write(&path, "sessions = [").unwrap();
291            let original = fs::read_to_string(&path).unwrap();
292
293            assert!(remove_session_entry(B256::from([0x88; 32])).is_err());
294            assert_eq!(fs::read_to_string(&path).unwrap(), original);
295        });
296    }
297}