1use alloy_primitives::{Address, hex};
7use alloy_rlp::Decodable;
8use serde::{Deserialize, Serialize};
9use std::{env, fmt, path::PathBuf};
10
11use super::registry::{read_toml_file, write_toml_file_atomic};
12
13pub const TEMPO_PRIVATE_KEY_ENV: &str = "TEMPO_PRIVATE_KEY";
15
16pub const TEMPO_HOME_ENV: &str = "TEMPO_HOME";
18
19pub const DEFAULT_TEMPO_HOME: &str = ".tempo";
21
22pub const WALLET_KEYS_PATH: &str = "wallet/keys.toml";
24
25#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
27#[serde(rename_all = "lowercase")]
28pub enum WalletType {
29 #[default]
30 Local,
31 Passkey,
32}
33
34#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
36#[serde(rename_all = "lowercase")]
37pub enum KeyType {
38 #[default]
39 Secp256k1,
40 P256,
41 WebAuthn,
42}
43
44#[derive(Debug, Default, Deserialize, Serialize)]
46pub struct StoredTokenLimit {
47 pub currency: Address,
48 pub limit: String,
49}
50
51#[derive(Default, Deserialize, Serialize)]
56pub struct KeyEntry {
57 #[serde(default)]
59 pub wallet_type: WalletType,
60 #[serde(default)]
62 pub wallet_address: Address,
63 #[serde(default)]
65 pub chain_id: u64,
66 #[serde(default)]
68 pub key_type: KeyType,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub key_address: Option<Address>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub key: Option<String>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub key_authorization: Option<String>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub expiry: Option<u64>,
82 #[serde(default, skip_serializing_if = "Vec::is_empty")]
84 pub limits: Vec<StoredTokenLimit>,
85}
86
87impl fmt::Debug for KeyEntry {
89 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90 f.debug_struct("KeyEntry")
91 .field("wallet_type", &self.wallet_type)
92 .field("wallet_address", &self.wallet_address)
93 .field("chain_id", &self.chain_id)
94 .field("key_type", &self.key_type)
95 .field("key_address", &self.key_address)
96 .field("key", &self.key.as_deref().map(super::redacted_debug))
97 .field(
98 "key_authorization",
99 &self.key_authorization.as_deref().map(super::redacted_debug),
100 )
101 .field("expiry", &self.expiry)
102 .field("limits", &self.limits)
103 .finish()
104 }
105}
106
107impl KeyEntry {
108 pub fn has_inline_key(&self) -> bool {
110 self.key.as_ref().is_some_and(|k| !k.trim().is_empty())
111 }
112}
113
114#[derive(Debug, Default, Deserialize, Serialize)]
116pub struct KeysFile {
117 #[serde(default)]
118 pub keys: Vec<KeyEntry>,
119}
120
121pub fn tempo_home() -> Option<PathBuf> {
125 if let Ok(home) = env::var(TEMPO_HOME_ENV) {
126 return Some(PathBuf::from(home));
127 }
128 dirs::home_dir().map(|h| h.join(DEFAULT_TEMPO_HOME))
129}
130
131pub fn tempo_keys_path() -> Option<PathBuf> {
133 tempo_home().map(|home| home.join(WALLET_KEYS_PATH))
134}
135
136pub fn read_tempo_keys_file() -> Option<KeysFile> {
141 let keys_path = tempo_keys_path()?;
142 match read_toml_file(&keys_path, "tempo keys") {
143 Ok(value) => value,
144 Err(e) => {
145 tracing::warn!(?keys_path, %e, "failed to load tempo keys file");
146 None
147 }
148 }
149}
150
151pub fn decode_key_authorization<T: Decodable>(hex_str: &str) -> eyre::Result<T> {
156 let bytes = hex::decode(hex_str)?;
157 let auth = T::decode(&mut bytes.as_slice())?;
158 Ok(auth)
159}
160
161pub(crate) fn upsert_key_entry(entry: KeyEntry) -> eyre::Result<()> {
169 let path = tempo_keys_path().ok_or_else(|| eyre::eyre!("could not resolve tempo home"))?;
170 let mut file = read_toml_file::<KeysFile>(&path, "tempo keys")?.unwrap_or_default();
171 file.keys
172 .retain(|k| !(k.wallet_address == entry.wallet_address && k.chain_id == entry.chain_id));
173 file.keys.push(entry);
174
175 write_toml_file_atomic(
176 &path,
177 &file,
178 "# Tempo wallet keys — managed by Foundry / Tempo CLI.\n# Do not edit manually.",
179 )
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185 use crate::tempo::with_tempo_home;
186 use std::{fs, str::FromStr};
187
188 #[test]
189 fn debug_redacts_key_entry_secrets() {
190 let entry = KeyEntry {
191 key: Some("0xPRIVATE_KEY_MUST_NOT_LEAK".to_string()),
192 key_authorization: Some("0xKEY_AUTH_MUST_NOT_LEAK".to_string()),
193 ..Default::default()
194 };
195
196 let entry_dbg = format!("{entry:?}");
197 let file_dbg = format!("{:?}", KeysFile { keys: vec![entry] });
198
199 for rendered in [&entry_dbg, &file_dbg] {
200 assert!(!rendered.contains("PRIVATE_KEY_MUST_NOT_LEAK"), "key leaked in: {rendered}");
201 assert!(!rendered.contains("KEY_AUTH_MUST_NOT_LEAK"), "auth leaked in: {rendered}");
202 }
203 assert!(entry_dbg.contains("key: Some(\"<redacted>\")"), "got: {entry_dbg}");
204 assert!(entry_dbg.contains("key_authorization: Some(\"<redacted>\")"), "got: {entry_dbg}");
205 assert!(entry_dbg.contains("wallet_type"));
207 }
208
209 #[test]
210 fn upsert_replaces_matching_entry_atomically() {
211 with_tempo_home(|| {
212 let wallet = Address::from_str("0x0000000000000000000000000000000000000001").unwrap();
213 let key = Address::from_str("0x0000000000000000000000000000000000000abc").unwrap();
214
215 let mk = |expiry: u64| KeyEntry {
216 wallet_type: WalletType::Passkey,
217 wallet_address: wallet,
218 chain_id: 4217,
219 key_type: KeyType::Secp256k1,
220 key_address: Some(key),
221 key: Some("0xdead".to_string()),
222 key_authorization: Some("0xbeef".to_string()),
223 expiry: Some(expiry),
224 limits: vec![],
225 };
226
227 upsert_key_entry(mk(100)).unwrap();
228 upsert_key_entry(mk(200)).unwrap();
229
230 let file = read_tempo_keys_file().unwrap();
231 assert_eq!(file.keys.len(), 1);
232 assert_eq!(file.keys[0].expiry, Some(200));
233
234 let mut other = mk(300);
236 other.chain_id = 42431;
237 upsert_key_entry(other).unwrap();
238 let file = read_tempo_keys_file().unwrap();
239 assert_eq!(file.keys.len(), 2);
240 });
241 }
242
243 #[test]
244 fn upsert_replaces_when_key_address_changes() {
245 with_tempo_home(|| {
248 let wallet = Address::from_str("0x0000000000000000000000000000000000000001").unwrap();
249 let old_key = Address::from_str("0x000000000000000000000000000000000000aaaa").unwrap();
250 let new_key = Address::from_str("0x000000000000000000000000000000000000bbbb").unwrap();
251
252 let mk = |key_addr: Address| KeyEntry {
253 wallet_type: WalletType::Passkey,
254 wallet_address: wallet,
255 chain_id: 4217,
256 key_type: KeyType::Secp256k1,
257 key_address: Some(key_addr),
258 key: Some("0xdead".to_string()),
259 key_authorization: Some("0xbeef".to_string()),
260 expiry: Some(100),
261 limits: vec![],
262 };
263
264 upsert_key_entry(mk(old_key)).unwrap();
265 upsert_key_entry(mk(new_key)).unwrap();
266
267 let file = read_tempo_keys_file().unwrap();
268 assert_eq!(file.keys.len(), 1, "old entry must be replaced, not duplicated");
269 assert_eq!(file.keys[0].key_address, Some(new_key));
270 });
271 }
272
273 #[test]
274 fn upsert_fails_closed_when_keys_file_is_corrupt() {
275 with_tempo_home(|| {
276 let path = tempo_keys_path().unwrap();
277 fs::create_dir_all(path.parent().unwrap()).unwrap();
278 fs::write(&path, "keys = [").unwrap();
279 let original = fs::read_to_string(&path).unwrap();
280
281 let wallet = Address::from_str("0x0000000000000000000000000000000000000001").unwrap();
282 let key = Address::from_str("0x0000000000000000000000000000000000000abc").unwrap();
283 let entry = KeyEntry {
284 wallet_type: WalletType::Passkey,
285 wallet_address: wallet,
286 chain_id: 4217,
287 key_type: KeyType::Secp256k1,
288 key_address: Some(key),
289 key: Some("0xdead".to_string()),
290 key_authorization: Some("0xbeef".to_string()),
291 expiry: Some(100),
292 limits: vec![],
293 };
294
295 assert!(read_tempo_keys_file().is_none());
296 assert!(upsert_key_entry(entry).is_err());
297 assert_eq!(fs::read_to_string(&path).unwrap(), original);
298 });
299 }
300}