Skip to main content

foundry_common/tempo/
keystore.rs

1//! Tempo wallet keystore types and discovery helpers.
2//!
3//! Shared types for reading keys from the Tempo CLI wallet keystore
4//! (`$TEMPO_HOME/wallet/keys.toml`, defaulting to `~/.tempo/wallet/keys.toml`).
5
6use 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
13/// Environment variable for an ephemeral Tempo private key.
14pub const TEMPO_PRIVATE_KEY_ENV: &str = "TEMPO_PRIVATE_KEY";
15
16/// Environment variable to override the Tempo home directory.
17pub const TEMPO_HOME_ENV: &str = "TEMPO_HOME";
18
19/// Default Tempo home directory relative to the user's home.
20pub const DEFAULT_TEMPO_HOME: &str = ".tempo";
21
22/// Relative path from Tempo home to the wallet keys file.
23pub const WALLET_KEYS_PATH: &str = "wallet/keys.toml";
24
25/// Wallet type matching `tempo-common`'s `WalletType` enum.
26#[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/// Cryptographic key type.
35#[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/// Per-token spending limit stored in `keys.toml`.
45#[derive(Debug, Default, Deserialize, Serialize)]
46pub struct StoredTokenLimit {
47    pub currency: Address,
48    pub limit: String,
49}
50
51/// A single key entry in `keys.toml`.
52///
53/// Mirrors the fields from `tempo-common::keys::model::KeyEntry`.
54/// Unknown fields are ignored by serde.
55#[derive(Debug, Default, Deserialize, Serialize)]
56pub struct KeyEntry {
57    /// Wallet type: "local" or "passkey".
58    #[serde(default)]
59    pub wallet_type: WalletType,
60    /// Smart wallet address (the on-chain account).
61    #[serde(default)]
62    pub wallet_address: Address,
63    /// Chain ID.
64    #[serde(default)]
65    pub chain_id: u64,
66    /// Cryptographic key type.
67    #[serde(default)]
68    pub key_type: KeyType,
69    /// Key address (the EOA derived from the private key).
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub key_address: Option<Address>,
72    /// Key private key, stored inline in keys.toml.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub key: Option<String>,
75    /// RLP-encoded signed key authorization (hex string).
76    /// Used in keychain mode to atomically provision the access key on-chain.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub key_authorization: Option<String>,
79    /// Expiry timestamp.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub expiry: Option<u64>,
82    /// Per-token spending limits.
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub limits: Vec<StoredTokenLimit>,
85}
86
87impl KeyEntry {
88    /// Whether this entry has a non-empty inline private key.
89    pub fn has_inline_key(&self) -> bool {
90        self.key.as_ref().is_some_and(|k| !k.trim().is_empty())
91    }
92}
93
94/// The top-level structure of `keys.toml`.
95#[derive(Debug, Default, Deserialize, Serialize)]
96pub struct KeysFile {
97    #[serde(default)]
98    pub keys: Vec<KeyEntry>,
99}
100
101/// Resolve the Tempo home directory.
102///
103/// Uses `TEMPO_HOME` env var if set, otherwise `~/.tempo`.
104pub 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
111/// Returns the path to the Tempo wallet keys file.
112pub fn tempo_keys_path() -> Option<PathBuf> {
113    tempo_home().map(|home| home.join(WALLET_KEYS_PATH))
114}
115
116/// Read and parse the Tempo wallet keys file.
117///
118/// Returns `None` if the file doesn't exist or can't be read/parsed.
119/// Errors are logged as warnings.
120pub 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
131/// Decodes a hex-encoded, RLP-encoded key authorization.
132///
133/// The input should be a hex string (with or without 0x prefix) containing
134/// RLP-encoded `SignedKeyAuthorization` data.
135pub 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
141/// Atomically upsert a [`KeyEntry`] into `keys.toml`.
142///
143/// Replaces any existing entry for the same `(wallet_address, chain_id)`.
144/// Each Tempo wallet has at most one active access key per chain, so a fresh
145/// login always supersedes the previous entry regardless of the new key
146/// address. Creates the file (and parent directories) if missing. Writes via
147/// temp file + rename so a crash mid-write cannot corrupt the file.
148pub(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            // Different chain_id => separate entry.
194            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        // Re-login produces a fresh random key address; the new entry must
205        // supersede the old one for the same (wallet, chain), not coexist.
206        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}