1use alloy_primitives::{Address, hex};
7use alloy_rlp::Decodable;
8use serde::{Deserialize, Serialize};
9use std::{env, 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(Debug, 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 KeyEntry {
88 pub fn has_inline_key(&self) -> bool {
90 self.key.as_ref().is_some_and(|k| !k.trim().is_empty())
91 }
92}
93
94#[derive(Debug, Default, Deserialize, Serialize)]
96pub struct KeysFile {
97 #[serde(default)]
98 pub keys: Vec<KeyEntry>,
99}
100
101pub fn tempo_home() -> Option<PathBuf> {
105 if let Ok(home) = env::var(TEMPO_HOME_ENV) {
106 return Some(PathBuf::from(home));
107 }
108 dirs::home_dir().map(|h| h.join(DEFAULT_TEMPO_HOME))
109}
110
111pub fn tempo_keys_path() -> Option<PathBuf> {
113 tempo_home().map(|home| home.join(WALLET_KEYS_PATH))
114}
115
116pub fn read_tempo_keys_file() -> Option<KeysFile> {
121 let keys_path = tempo_keys_path()?;
122 match read_toml_file(&keys_path, "tempo keys") {
123 Ok(value) => value,
124 Err(e) => {
125 tracing::warn!(?keys_path, %e, "failed to load tempo keys file");
126 None
127 }
128 }
129}
130
131pub fn decode_key_authorization<T: Decodable>(hex_str: &str) -> eyre::Result<T> {
136 let bytes = hex::decode(hex_str)?;
137 let auth = T::decode(&mut bytes.as_slice())?;
138 Ok(auth)
139}
140
141pub(crate) fn upsert_key_entry(entry: KeyEntry) -> eyre::Result<()> {
149 let path = tempo_keys_path().ok_or_else(|| eyre::eyre!("could not resolve tempo home"))?;
150 let mut file = read_toml_file::<KeysFile>(&path, "tempo keys")?.unwrap_or_default();
151 file.keys
152 .retain(|k| !(k.wallet_address == entry.wallet_address && k.chain_id == entry.chain_id));
153 file.keys.push(entry);
154
155 write_toml_file_atomic(
156 &path,
157 &file,
158 "# Tempo wallet keys — managed by Foundry / Tempo CLI.\n# Do not edit manually.",
159 )
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use crate::tempo::with_tempo_home;
166 use std::{fs, str::FromStr};
167
168 #[test]
169 fn upsert_replaces_matching_entry_atomically() {
170 with_tempo_home(|| {
171 let wallet = Address::from_str("0x0000000000000000000000000000000000000001").unwrap();
172 let key = Address::from_str("0x0000000000000000000000000000000000000abc").unwrap();
173
174 let mk = |expiry: u64| KeyEntry {
175 wallet_type: WalletType::Passkey,
176 wallet_address: wallet,
177 chain_id: 4217,
178 key_type: KeyType::Secp256k1,
179 key_address: Some(key),
180 key: Some("0xdead".to_string()),
181 key_authorization: Some("0xbeef".to_string()),
182 expiry: Some(expiry),
183 limits: vec![],
184 };
185
186 upsert_key_entry(mk(100)).unwrap();
187 upsert_key_entry(mk(200)).unwrap();
188
189 let file = read_tempo_keys_file().unwrap();
190 assert_eq!(file.keys.len(), 1);
191 assert_eq!(file.keys[0].expiry, Some(200));
192
193 let mut other = mk(300);
195 other.chain_id = 42431;
196 upsert_key_entry(other).unwrap();
197 let file = read_tempo_keys_file().unwrap();
198 assert_eq!(file.keys.len(), 2);
199 });
200 }
201
202 #[test]
203 fn upsert_replaces_when_key_address_changes() {
204 with_tempo_home(|| {
207 let wallet = Address::from_str("0x0000000000000000000000000000000000000001").unwrap();
208 let old_key = Address::from_str("0x000000000000000000000000000000000000aaaa").unwrap();
209 let new_key = Address::from_str("0x000000000000000000000000000000000000bbbb").unwrap();
210
211 let mk = |key_addr: Address| KeyEntry {
212 wallet_type: WalletType::Passkey,
213 wallet_address: wallet,
214 chain_id: 4217,
215 key_type: KeyType::Secp256k1,
216 key_address: Some(key_addr),
217 key: Some("0xdead".to_string()),
218 key_authorization: Some("0xbeef".to_string()),
219 expiry: Some(100),
220 limits: vec![],
221 };
222
223 upsert_key_entry(mk(old_key)).unwrap();
224 upsert_key_entry(mk(new_key)).unwrap();
225
226 let file = read_tempo_keys_file().unwrap();
227 assert_eq!(file.keys.len(), 1, "old entry must be replaced, not duplicated");
228 assert_eq!(file.keys[0].key_address, Some(new_key));
229 });
230 }
231
232 #[test]
233 fn upsert_fails_closed_when_keys_file_is_corrupt() {
234 with_tempo_home(|| {
235 let path = tempo_keys_path().unwrap();
236 fs::create_dir_all(path.parent().unwrap()).unwrap();
237 fs::write(&path, "keys = [").unwrap();
238 let original = fs::read_to_string(&path).unwrap();
239
240 let wallet = Address::from_str("0x0000000000000000000000000000000000000001").unwrap();
241 let key = Address::from_str("0x0000000000000000000000000000000000000abc").unwrap();
242 let entry = KeyEntry {
243 wallet_type: WalletType::Passkey,
244 wallet_address: wallet,
245 chain_id: 4217,
246 key_type: KeyType::Secp256k1,
247 key_address: Some(key),
248 key: Some("0xdead".to_string()),
249 key_authorization: Some("0xbeef".to_string()),
250 expiry: Some(100),
251 limits: vec![],
252 };
253
254 assert!(read_tempo_keys_file().is_none());
255 assert!(upsert_key_entry(entry).is_err());
256 assert_eq!(fs::read_to_string(&path).unwrap(), original);
257 });
258 }
259}