Skip to main content

foundry_common/provider/mpp/
keys.rs

1//! Auto-discovery of MPP signing keys from the Tempo wallet.
2//!
3//! Uses the shared Tempo keystore types from [`crate::tempo`] and adds
4//! MPP-specific primary key selection logic (passkey > first entry with
5//! inline key > first entry, mirroring `Keystore::primary_key()` in
6//! `tempo-common`).
7
8use crate::tempo::{TEMPO_PRIVATE_KEY_ENV, WalletType, read_tempo_keys_file};
9use alloy_primitives::Address;
10use tracing::debug;
11
12/// Options for MPP key discovery filtering.
13#[derive(Debug, Default, Clone)]
14pub struct DiscoverOptions {
15    /// Only consider keys matching this chain ID.
16    pub chain_id: Option<u64>,
17    /// Only consider keys whose spending limits include this currency.
18    pub currency: Option<Address>,
19}
20
21/// Discovered MPP key configuration.
22///
23/// Contains the private key and optional keychain metadata for signing mode
24/// configuration.
25#[derive(Debug, Clone)]
26pub struct MppKeyConfig {
27    /// The hex-encoded private key.
28    pub key: String,
29    /// Smart wallet address (for keychain signing mode).
30    pub wallet_address: Option<Address>,
31    /// Key address / signer address (for keychain authorized signer).
32    pub key_address: Option<Address>,
33    /// RLP-encoded signed key authorization (hex string).
34    pub key_authorization: Option<String>,
35    /// Chain ID from the key entry in `keys.toml`. `None` when discovered from
36    /// the `TEMPO_PRIVATE_KEY` env var (no keychain metadata available).
37    pub chain_id: Option<u64>,
38    /// Currencies from the key's spending limits.
39    pub currencies: Vec<Address>,
40}
41
42/// Attempt to auto-discover an MPP signing key from the Tempo wallet.
43///
44/// Returns `Some(hex_key)` if a key is found, `None` otherwise.
45/// Never fails — discovery errors are silently ignored (logged at debug level).
46pub fn discover_mpp_key() -> Option<String> {
47    discover_mpp_config(Default::default()).map(|c| c.key)
48}
49
50/// Discover MPP key configuration filtered by chain ID and/or currency.
51///
52/// Filters keys.toml entries by `chain_id` and `currency` simultaneously,
53/// then applies the standard priority rule (passkey > inline key > first)
54/// within the filtered set. This ensures the selected key matches both the
55/// target chain and the required currency.
56pub fn discover_mpp_config(opts: DiscoverOptions) -> Option<MppKeyConfig> {
57    // 1. Check TEMPO_PRIVATE_KEY env var (no keychain metadata available)
58    if let Ok(key) = std::env::var(TEMPO_PRIVATE_KEY_ENV) {
59        let key = key.trim().to_string();
60        if !key.is_empty() {
61            debug!("using MPP key from {TEMPO_PRIVATE_KEY_ENV} env var");
62            return Some(MppKeyConfig {
63                key,
64                wallet_address: None,
65                key_address: None,
66                key_authorization: None,
67                chain_id: None,
68                currencies: vec![],
69            });
70        }
71    }
72
73    // 2. Read $TEMPO_HOME/wallet/keys.toml (default: ~/.tempo/wallet/keys.toml)
74    let keys_file = read_tempo_keys_file()?;
75
76    // Pick primary key using the same deterministic order as
77    // `Keystore::primary_key()` in tempo-common:
78    //   passkey > first entry with inline key > first entry
79    // Only entries with a usable inline key can provide a signing key.
80    // Filter by chain_id and currency when provided.
81    let candidates: Vec<_> = keys_file
82        .keys
83        .iter()
84        .filter(|k| opts.chain_id.is_none_or(|cid| k.chain_id == cid))
85        .filter(|k| {
86            opts.currency
87                .is_none_or(|cur| k.limits.is_empty() || k.limits.iter().any(|l| l.currency == cur))
88        })
89        .collect();
90
91    let primary = candidates
92        .iter()
93        .find(|k| k.wallet_type == WalletType::Passkey && k.has_inline_key())
94        .or_else(|| candidates.iter().find(|k| k.has_inline_key()))
95        .or(candidates.first())
96        .copied();
97
98    if let Some(entry) = primary
99        && let Some(key) = &entry.key
100    {
101        let key = key.trim().to_string();
102        if !key.is_empty() {
103            debug!("using MPP key from tempo wallet keys file");
104            return Some(MppKeyConfig {
105                key,
106                wallet_address: Some(entry.wallet_address),
107                key_address: entry.key_address,
108                key_authorization: entry.key_authorization.clone(),
109                chain_id: Some(entry.chain_id),
110                currencies: entry.limits.iter().map(|l| l.currency).collect(),
111            });
112        }
113    }
114
115    debug!("no usable key found in tempo keys file");
116    None
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::tempo::KeysFile;
123    use std::{io::Write, path::PathBuf};
124
125    /// Write a keys.toml to a temp dir and set TEMPO_HOME to point at it.
126    fn setup_keys_toml(toml_content: &str) -> (tempfile::TempDir, PathBuf) {
127        let dir = tempfile::tempdir().expect("tempdir");
128        let wallet_dir = dir.path().join("wallet");
129        std::fs::create_dir_all(&wallet_dir).expect("create wallet dir");
130        let keys_path = wallet_dir.join("keys.toml");
131        let mut f = std::fs::File::create(&keys_path).expect("create keys.toml");
132        f.write_all(toml_content.as_bytes()).expect("write keys.toml");
133        (dir, keys_path)
134    }
135
136    #[test]
137    fn discover_from_tempo_home_keys_toml() {
138        let key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
139        let toml_content = format!(
140            r#"
141[[keys]]
142wallet_address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
143key_address = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
144key = "{key}"
145chain_id = 4217
146"#
147        );
148        let (dir, _) = setup_keys_toml(&toml_content);
149
150        unsafe {
151            std::env::set_var("TEMPO_HOME", dir.path());
152            std::env::remove_var("TEMPO_PRIVATE_KEY");
153        }
154
155        let discovered = discover_mpp_key();
156        assert_eq!(discovered.as_deref(), Some(key));
157
158        unsafe { std::env::remove_var("TEMPO_HOME") };
159    }
160
161    #[test]
162    fn discover_env_var_takes_priority_over_keys_toml() {
163        let file_key = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
164        let env_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
165        let toml_content = format!(
166            r#"
167[[keys]]
168wallet_address = "0x0000000000000000000000000000000000000001"
169key = "{file_key}"
170"#
171        );
172        let (dir, _) = setup_keys_toml(&toml_content);
173
174        unsafe {
175            std::env::set_var("TEMPO_HOME", dir.path());
176            std::env::set_var("TEMPO_PRIVATE_KEY", env_key);
177        }
178
179        let discovered = discover_mpp_key();
180        assert_eq!(discovered.as_deref(), Some(env_key));
181
182        unsafe {
183            std::env::remove_var("TEMPO_HOME");
184            std::env::remove_var("TEMPO_PRIVATE_KEY");
185        }
186    }
187
188    #[test]
189    fn discover_returns_none_when_no_keys() {
190        let (dir, _) = setup_keys_toml("");
191
192        unsafe {
193            std::env::set_var("TEMPO_HOME", dir.path());
194            std::env::remove_var("TEMPO_PRIVATE_KEY");
195        }
196
197        let discovered = discover_mpp_key();
198        assert!(discovered.is_none());
199
200        unsafe { std::env::remove_var("TEMPO_HOME") };
201    }
202
203    #[test]
204    fn discover_skips_entries_without_inline_key() {
205        let key = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
206        let toml_content = format!(
207            r#"
208[[keys]]
209wallet_address = "0x0000000000000000000000000000000000000001"
210chain_id = 4217
211
212[[keys]]
213wallet_address = "0x0000000000000000000000000000000000000002"
214key = "{key}"
215chain_id = 4217
216"#
217        );
218        let (dir, _) = setup_keys_toml(&toml_content);
219
220        unsafe {
221            std::env::set_var("TEMPO_HOME", dir.path());
222            std::env::remove_var("TEMPO_PRIVATE_KEY");
223        }
224
225        let discovered = discover_mpp_key();
226        assert_eq!(discovered.as_deref(), Some(key));
227
228        unsafe { std::env::remove_var("TEMPO_HOME") };
229    }
230
231    #[test]
232    fn parse_keys_toml() {
233        let toml_str = r#"
234[[keys]]
235wallet_address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
236key_address = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
237key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
238chain_id = 4217
239"#;
240        let keys_file: KeysFile = toml::from_str(toml_str).unwrap();
241        assert_eq!(keys_file.keys.len(), 1);
242        assert_eq!(
243            keys_file.keys[0].key.as_deref(),
244            Some("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80")
245        );
246    }
247
248    #[test]
249    fn parse_keys_toml_no_inline_key() {
250        let toml_str = r#"
251[[keys]]
252wallet_address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
253key_address = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
254chain_id = 4217
255"#;
256        let keys_file: KeysFile = toml::from_str(toml_str).unwrap();
257        assert_eq!(keys_file.keys.len(), 1);
258        assert!(keys_file.keys[0].key.is_none());
259    }
260
261    #[test]
262    fn parse_keys_toml_multiple_entries() {
263        let toml_str = r#"
264[[keys]]
265wallet_address = "0x0000000000000000000000000000000000000001"
266key_address = "0x0000000000000000000000000000000000000002"
267chain_id = 4217
268
269[[keys]]
270wallet_address = "0x0000000000000000000000000000000000000003"
271key_address = "0x0000000000000000000000000000000000000004"
272key = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
273chain_id = 4217
274"#;
275        let keys_file: KeysFile = toml::from_str(toml_str).unwrap();
276        assert_eq!(keys_file.keys.len(), 2);
277        assert!(keys_file.keys[0].key.is_none());
278        assert!(keys_file.keys[1].key.is_some());
279    }
280
281    #[test]
282    fn parse_keys_toml_with_wallet_type() {
283        let toml_str = r#"
284[[keys]]
285wallet_type = "passkey"
286wallet_address = "0x0000000000000000000000000000000000000001"
287key = "0xpasskey_secret"
288chain_id = 4217
289
290[[keys]]
291wallet_type = "local"
292wallet_address = "0x0000000000000000000000000000000000000002"
293key = "0xlocal_secret"
294chain_id = 4217
295"#;
296        let keys_file: KeysFile = toml::from_str(toml_str).unwrap();
297        assert_eq!(keys_file.keys.len(), 2);
298        assert_eq!(keys_file.keys[0].wallet_type, WalletType::Passkey);
299        assert_eq!(keys_file.keys[1].wallet_type, WalletType::Local);
300    }
301
302    #[test]
303    fn primary_key_passkey_wins() {
304        let toml_str = r#"
305[[keys]]
306wallet_type = "local"
307wallet_address = "0x0000000000000000000000000000000000000001"
308key = "0xlocal_key"
309
310[[keys]]
311wallet_type = "passkey"
312wallet_address = "0x0000000000000000000000000000000000000002"
313key = "0xpasskey_key"
314"#;
315        let keys_file: KeysFile = toml::from_str(toml_str).unwrap();
316        let primary = keys_file
317            .keys
318            .iter()
319            .find(|k| k.wallet_type == WalletType::Passkey)
320            .or_else(|| keys_file.keys.iter().find(|k| k.has_inline_key()))
321            .or(keys_file.keys.first());
322        assert_eq!(primary.unwrap().key.as_deref(), Some("0xpasskey_key"));
323    }
324
325    #[test]
326    fn primary_key_inline_key_over_no_key() {
327        let toml_str = r#"
328[[keys]]
329wallet_address = "0x0000000000000000000000000000000000000001"
330
331[[keys]]
332wallet_address = "0x0000000000000000000000000000000000000002"
333key = "0xthe_key"
334"#;
335        let keys_file: KeysFile = toml::from_str(toml_str).unwrap();
336        let primary = keys_file
337            .keys
338            .iter()
339            .find(|k| k.wallet_type == WalletType::Passkey)
340            .or_else(|| keys_file.keys.iter().find(|k| k.has_inline_key()))
341            .or(keys_file.keys.first());
342        assert_eq!(primary.unwrap().key.as_deref(), Some("0xthe_key"));
343    }
344
345    #[test]
346    fn discover_filters_by_chain_id() {
347        let mainnet_key = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
348        let testnet_key = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
349        let toml_content = format!(
350            r#"
351[[keys]]
352wallet_type = "passkey"
353wallet_address = "0x0000000000000000000000000000000000000001"
354key = "{mainnet_key}"
355chain_id = 4217
356
357[[keys]]
358wallet_type = "passkey"
359wallet_address = "0x0000000000000000000000000000000000000002"
360key = "{testnet_key}"
361chain_id = 42431
362"#
363        );
364        let (dir, _) = setup_keys_toml(&toml_content);
365        unsafe {
366            std::env::set_var("TEMPO_HOME", dir.path());
367            std::env::remove_var("TEMPO_PRIVATE_KEY");
368        }
369
370        // Filter by testnet chain_id → returns testnet key (even though mainnet is first)
371        let config =
372            discover_mpp_config(DiscoverOptions { chain_id: Some(42431), ..Default::default() });
373        assert_eq!(config.as_ref().unwrap().key, testnet_key);
374
375        // Filter by mainnet chain_id → returns mainnet key
376        let config =
377            discover_mpp_config(DiscoverOptions { chain_id: Some(4217), ..Default::default() });
378        assert_eq!(config.as_ref().unwrap().key, mainnet_key);
379
380        // No filter → returns first key (mainnet)
381        let config = discover_mpp_config(Default::default());
382        assert_eq!(config.as_ref().unwrap().key, mainnet_key);
383
384        // Filter by unknown chain_id → None
385        let config =
386            discover_mpp_config(DiscoverOptions { chain_id: Some(9999), ..Default::default() });
387        assert!(config.is_none());
388
389        // Passkey priority within filtered set
390        let toml_mixed = format!(
391            r#"
392[[keys]]
393wallet_type = "local"
394wallet_address = "0x0000000000000000000000000000000000000001"
395key = "{mainnet_key}"
396chain_id = 4217
397
398[[keys]]
399wallet_type = "passkey"
400wallet_address = "0x0000000000000000000000000000000000000002"
401key = "{testnet_key}"
402chain_id = 4217
403"#
404        );
405        let (dir2, _) = setup_keys_toml(&toml_mixed);
406        unsafe { std::env::set_var("TEMPO_HOME", dir2.path()) };
407
408        let config =
409            discover_mpp_config(DiscoverOptions { chain_id: Some(4217), ..Default::default() });
410        assert_eq!(
411            config.as_ref().unwrap().key,
412            testnet_key,
413            "passkey should win over local within the same chain_id"
414        );
415
416        unsafe { std::env::remove_var("TEMPO_HOME") };
417    }
418
419    #[test]
420    fn parse_keys_toml_unknown_fields_ignored() {
421        let toml_str = r#"
422[[keys]]
423wallet_address = "0x0000000000000000000000000000000000000001"
424key = "0xsecret"
425chain_id = 4217
426key_authorization = "0xauth_data"
427expiry = 1750000000
428unknown_future_field = "should be ignored"
429
430[[keys.limits]]
431currency = "0x20c000000000000000000000b9537d11c60e8b50"
432limit = "1000"
433"#;
434        let keys_file: KeysFile = toml::from_str(toml_str).unwrap();
435        assert_eq!(keys_file.keys.len(), 1);
436        assert_eq!(keys_file.keys[0].key.as_deref(), Some("0xsecret"));
437    }
438}