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