1use super::{registry::*, tempo_home};
4use alloy_primitives::{Address, B256, Selector};
5use serde::{Deserialize, Serialize};
6use std::path::PathBuf;
7
8pub 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#[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 pub const fn is_terminal(self) -> bool {
30 matches!(self, Self::Revoked | Self::Expired | Self::Failed)
31 }
32
33 pub const fn is_live(self) -> bool {
35 !self.is_terminal()
36 }
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
41pub struct SessionTokenLimit {
42 pub currency: Address,
43 pub limit: String,
44}
45
46#[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#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
56pub struct SessionCallScope {
57 pub target: Address,
58 #[serde(default, skip_serializing_if = "Vec::is_empty")]
60 pub selector_rules: Vec<SessionSelectorRule>,
61}
62
63#[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 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 pub const fn is_expired_at(&self, now: u64) -> bool {
86 now >= self.expiry
87 }
88}
89
90#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)]
92pub struct SessionRecord {
93 #[serde(default)]
94 pub sessions: Vec<SessionEntry>,
95}
96
97impl SessionRecord {
98 pub const fn is_empty(&self) -> bool {
100 self.sessions.is_empty()
101 }
102
103 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 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 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
129pub fn session_registry_path() -> Option<PathBuf> {
131 tempo_home().map(|home| home.join(WALLET_SESSIONS_PATH))
132}
133
134pub 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
149pub 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
159pub 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}