Skip to main content

agent_first_pay/store/
wallet.rs

1#![cfg_attr(
2    not(any(
3        feature = "cashu",
4        feature = "ln-nwc",
5        feature = "ln-phoenixd",
6        feature = "ln-lnbits",
7        feature = "sol",
8        feature = "evm",
9        feature = "btc-esplora",
10        feature = "btc-core",
11        feature = "btc-electrum"
12    )),
13    allow(dead_code)
14)]
15
16use crate::provider::PayError;
17use crate::types::Network;
18use serde::{Deserialize, Serialize};
19use std::path::{Path, PathBuf};
20
21// ═══════════════════════════════════════════
22// Shared types (always available)
23// ═══════════════════════════════════════════
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct CustomToken {
27    pub symbol: String,
28    pub address: String,
29    pub decimals: u8,
30}
31
32#[derive(Clone, Serialize, Deserialize)]
33pub struct WalletMetadata {
34    pub id: String,
35    pub network: Network,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub label: Option<String>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub mint_url: Option<String>,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub sol_rpc_endpoints: Option<Vec<String>>,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub evm_rpc_endpoints: Option<Vec<String>>,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub evm_chain_id: Option<u64>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub seed_secret: Option<String>,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub backend: Option<String>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub btc_esplora_url: Option<String>,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub btc_network: Option<String>,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub btc_address_type: Option<String>,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub btc_core_url: Option<String>,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub btc_core_auth_secret: Option<String>,
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub btc_electrum_url: Option<String>,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub custom_tokens: Option<Vec<CustomToken>>,
64    #[serde(default)]
65    pub created_at_epoch_s: u64,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub error: Option<String>,
68}
69
70impl std::fmt::Debug for WalletMetadata {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        f.debug_struct("WalletMetadata")
73            .field("id", &self.id)
74            .field("network", &self.network)
75            .field("label", &self.label)
76            .field("mint_url", &self.mint_url)
77            .field("sol_rpc_endpoints", &self.sol_rpc_endpoints)
78            .field("evm_rpc_endpoints", &self.evm_rpc_endpoints)
79            .field("evm_chain_id", &self.evm_chain_id)
80            .field("seed_secret", &self.seed_secret.as_ref().map(|_| "***"))
81            .field("backend", &self.backend)
82            .field("btc_esplora_url", &self.btc_esplora_url)
83            .field("btc_network", &self.btc_network)
84            .field("btc_address_type", &self.btc_address_type)
85            .field("btc_core_url", &self.btc_core_url)
86            .field(
87                "btc_core_auth_secret",
88                &self.btc_core_auth_secret.as_ref().map(|_| "***"),
89            )
90            .field("btc_electrum_url", &self.btc_electrum_url)
91            .field("custom_tokens", &self.custom_tokens)
92            .field("created_at_epoch_s", &self.created_at_epoch_s)
93            .field("error", &self.error)
94            .finish()
95    }
96}
97
98// ═══════════════════════════════════════════
99// Shared helpers (always available)
100// ═══════════════════════════════════════════
101
102const WALLETS_DIR: &str = "wallets";
103
104pub fn generate_wallet_identifier() -> Result<String, PayError> {
105    let mut buf = [0u8; 4];
106    getrandom::fill(&mut buf).map_err(|e| PayError::InternalError(format!("rng failed: {e}")))?;
107    Ok(format!("w_{}", hex::encode(buf)))
108}
109
110pub fn generate_transaction_identifier() -> Result<String, PayError> {
111    let mut buf = [0u8; 8];
112    getrandom::fill(&mut buf).map_err(|e| PayError::InternalError(format!("rng failed: {e}")))?;
113    Ok(format!("tx_{}", hex::encode(buf)))
114}
115
116pub fn generate_request_identifier() -> Result<String, PayError> {
117    let mut buf = [0u8; 16];
118    getrandom::fill(&mut buf).map_err(|e| PayError::InternalError(format!("rng failed: {e}")))?;
119    Ok(format!("req_{}", hex::encode(buf)))
120}
121
122pub fn now_epoch_seconds() -> u64 {
123    std::time::SystemTime::now()
124        .duration_since(std::time::UNIX_EPOCH)
125        .map(|d| d.as_secs())
126        .unwrap_or(0)
127}
128
129/// Root path for all wallets: `{data_dir}/wallets/`
130fn wallets_root(data_dir: &str) -> PathBuf {
131    Path::new(data_dir).join(WALLETS_DIR)
132}
133
134pub fn wallet_data_directory_path_for_wallet_metadata(
135    data_dir: &str,
136    wallet_metadata: &WalletMetadata,
137) -> PathBuf {
138    wallets_root(data_dir)
139        .join(&wallet_metadata.id)
140        .join("wallet-data")
141}
142
143pub fn wallet_directory_path(data_dir: &str, wallet_id: &str) -> Result<PathBuf, PayError> {
144    let dir = wallets_root(data_dir).join(wallet_id);
145    if dir.is_dir() {
146        Ok(dir)
147    } else {
148        Err(PayError::WalletNotFound(format!(
149            "wallet {wallet_id} not found"
150        )))
151    }
152}
153
154pub fn wallet_data_directory_path(data_dir: &str, wallet_id: &str) -> Result<PathBuf, PayError> {
155    Ok(wallet_directory_path(data_dir, wallet_id)?.join("wallet-data"))
156}
157
158#[cfg(feature = "redb")]
159pub(crate) fn parse_wallet_metadata(
160    raw: &str,
161    wallet_id: &str,
162) -> Result<WalletMetadata, PayError> {
163    serde_json::from_str(raw)
164        .map_err(|e| PayError::InternalError(format!("parse wallet {wallet_id}: {e}")))
165}
166
167// ═══════════════════════════════════════════
168// Redb-specific functions
169// ═══════════════════════════════════════════
170
171#[cfg(feature = "redb")]
172use crate::store::db;
173#[cfg(feature = "redb")]
174use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
175
176#[cfg(feature = "redb")]
177const CATALOG_WALLET_BY_ID: TableDefinition<&str, &str> = TableDefinition::new("wallet_by_id");
178#[cfg(feature = "redb")]
179const CORE_METADATA_KEY_VALUE: TableDefinition<&str, &str> = TableDefinition::new("metadata_kv");
180#[cfg(feature = "redb")]
181const CORE_WALLET_METADATA_KEY: &str = "wallet_metadata";
182
183#[cfg(feature = "redb")]
184pub fn save_wallet_metadata(
185    data_dir: &str,
186    wallet_metadata: &WalletMetadata,
187) -> Result<(), PayError> {
188    let root = wallets_root(data_dir);
189    std::fs::create_dir_all(&root).map_err(|e| {
190        PayError::InternalError(format!("create wallets dir {}: {e}", root.display()))
191    })?;
192    set_private_dir_permissions(&root)?;
193
194    let wallet_dir = root.join(&wallet_metadata.id);
195    let wallet_data_dir = wallet_dir.join("wallet-data");
196    std::fs::create_dir_all(&wallet_data_dir).map_err(|e| {
197        PayError::InternalError(format!(
198            "create wallet dir {}: {e}",
199            wallet_data_dir.display()
200        ))
201    })?;
202    set_private_dir_permissions(&wallet_dir)?;
203    set_private_dir_permissions(&wallet_data_dir)?;
204
205    let wallet_metadata_json = serde_json::to_string(wallet_metadata)
206        .map_err(|e| PayError::InternalError(format!("serialize wallet metadata: {e}")))?;
207
208    // catalog.redb: unified wallet index
209    let catalog_db = open_catalog(&root)?;
210    let catalog_txn = catalog_db
211        .begin_write()
212        .map_err(|e| PayError::InternalError(format!("catalog begin_write: {e}")))?;
213    {
214        let mut table = catalog_txn
215            .open_table(CATALOG_WALLET_BY_ID)
216            .map_err(|e| PayError::InternalError(format!("catalog open wallet_by_id: {e}")))?;
217        table
218            .insert(wallet_metadata.id.as_str(), wallet_metadata_json.as_str())
219            .map_err(|e| PayError::InternalError(format!("catalog insert wallet: {e}")))?;
220    }
221    catalog_txn
222        .commit()
223        .map_err(|e| PayError::InternalError(format!("catalog commit: {e}")))?;
224
225    // core.redb: per-wallet authoritative metadata
226    let core_db = open_core(&wallet_dir.join("core.redb"))?;
227    let core_txn = core_db
228        .begin_write()
229        .map_err(|e| PayError::InternalError(format!("core begin_write: {e}")))?;
230    {
231        let mut table = core_txn
232            .open_table(CORE_METADATA_KEY_VALUE)
233            .map_err(|e| PayError::InternalError(format!("core open metadata_kv: {e}")))?;
234        table
235            .insert(CORE_WALLET_METADATA_KEY, wallet_metadata_json.as_str())
236            .map_err(|e| PayError::InternalError(format!("core write wallet metadata: {e}")))?;
237    }
238    core_txn
239        .commit()
240        .map_err(|e| PayError::InternalError(format!("core commit wallet metadata: {e}")))?;
241
242    Ok(())
243}
244
245#[cfg(unix)]
246fn set_private_dir_permissions(path: &Path) -> Result<(), PayError> {
247    use std::os::unix::fs::PermissionsExt;
248
249    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
250        .map_err(|e| PayError::InternalError(format!("chmod 700 {}: {e}", path.display())))
251}
252
253#[cfg(not(unix))]
254fn set_private_dir_permissions(_path: &Path) -> Result<(), PayError> {
255    Ok(())
256}
257
258#[cfg(feature = "redb")]
259pub fn load_wallet_metadata(data_dir: &str, wallet_id: &str) -> Result<WalletMetadata, PayError> {
260    let root = wallets_root(data_dir);
261
262    // Fast path: catalog
263    let catalog_path = root.join("catalog.redb");
264    if catalog_path.exists() {
265        let db = open_catalog(&root)?;
266        let read_txn = db
267            .begin_read()
268            .map_err(|e| PayError::InternalError(format!("catalog begin_read: {e}")))?;
269        if let Ok(table) = read_txn.open_table(CATALOG_WALLET_BY_ID) {
270            if let Some(value) = table.get(wallet_id).map_err(|e| {
271                PayError::InternalError(format!("catalog read wallet {wallet_id}: {e}"))
272            })? {
273                return parse_wallet_metadata(value.value(), wallet_id);
274            }
275        }
276    }
277
278    // Fallback: wallet core metadata
279    let wallet_dir = root.join(wallet_id);
280    if wallet_dir.is_dir() {
281        let core_path = wallet_dir.join("core.redb");
282        if core_path.exists() {
283            let db = db::open_database(&core_path)?;
284            let read_txn = db
285                .begin_read()
286                .map_err(|e| PayError::InternalError(format!("core begin_read: {e}")))?;
287            let Ok(table) = read_txn.open_table(CORE_METADATA_KEY_VALUE) else {
288                return Err(PayError::WalletNotFound(format!(
289                    "wallet {wallet_id} not found"
290                )));
291            };
292            if let Some(value) = table
293                .get(CORE_WALLET_METADATA_KEY)
294                .map_err(|e| PayError::InternalError(format!("core read wallet metadata: {e}")))?
295            {
296                return parse_wallet_metadata(value.value(), wallet_id);
297            }
298        }
299    }
300
301    // Label fallback: if wallet_id doesn't start with "w_", try matching by label
302    if !wallet_id.starts_with("w_") {
303        let catalog_path = root.join("catalog.redb");
304        if catalog_path.exists() {
305            let db = open_catalog(&root)?;
306            let read_txn = db
307                .begin_read()
308                .map_err(|e| PayError::InternalError(format!("catalog begin_read: {e}")))?;
309            if let Ok(table) = read_txn.open_table(CATALOG_WALLET_BY_ID) {
310                for entry in table
311                    .iter()
312                    .map_err(|e| PayError::InternalError(format!("catalog iterate: {e}")))?
313                {
314                    let (key, value) = entry
315                        .map_err(|e| PayError::InternalError(format!("catalog read entry: {e}")))?;
316                    if let Ok(meta) = parse_wallet_metadata(value.value(), key.value()) {
317                        if meta.label.as_deref() == Some(wallet_id) {
318                            return Ok(meta);
319                        }
320                    }
321                }
322            }
323        }
324    }
325
326    Err(PayError::WalletNotFound(format!(
327        "wallet {wallet_id} not found"
328    )))
329}
330
331#[cfg(feature = "redb")]
332pub fn list_wallet_metadata(
333    data_dir: &str,
334    network: Option<Network>,
335) -> Result<Vec<WalletMetadata>, PayError> {
336    let root = wallets_root(data_dir);
337    let catalog_path = root.join("catalog.redb");
338    if !catalog_path.exists() {
339        return Ok(vec![]);
340    }
341
342    let db = open_catalog(&root)?;
343    let read_txn = db
344        .begin_read()
345        .map_err(|e| PayError::InternalError(format!("catalog begin_read: {e}")))?;
346    let Ok(table) = read_txn.open_table(CATALOG_WALLET_BY_ID) else {
347        return Ok(vec![]);
348    };
349
350    let mut wallets = Vec::new();
351    for entry in table
352        .iter()
353        .map_err(|e| PayError::InternalError(format!("catalog iterate wallets: {e}")))?
354    {
355        let (key, value) = entry
356            .map_err(|e| PayError::InternalError(format!("catalog read wallet entry: {e}")))?;
357        let wallet_metadata: WalletMetadata = match serde_json::from_str(value.value()) {
358            Ok(m) => m,
359            Err(e) => WalletMetadata {
360                id: key.value().to_string(),
361                network: Network::Cashu, // placeholder for corrupt entry
362                label: None,
363                mint_url: None,
364                sol_rpc_endpoints: None,
365                evm_rpc_endpoints: None,
366                evm_chain_id: None,
367                seed_secret: None,
368                backend: None,
369                btc_esplora_url: None,
370                btc_network: None,
371                btc_address_type: None,
372                btc_core_url: None,
373                btc_core_auth_secret: None,
374                btc_electrum_url: None,
375                custom_tokens: None,
376                created_at_epoch_s: 0,
377                error: Some(format!("corrupt metadata: {e}")),
378            },
379        };
380        if let Some(network) = network {
381            if wallet_metadata.network != network {
382                continue;
383            }
384        }
385        wallets.push(wallet_metadata);
386    }
387
388    wallets.sort_by(|a, b| a.id.cmp(&b.id));
389    Ok(wallets)
390}
391
392#[cfg(feature = "redb")]
393pub fn delete_wallet_metadata(data_dir: &str, wallet_id: &str) -> Result<(), PayError> {
394    let root = wallets_root(data_dir);
395
396    // Remove from catalog
397    let catalog_path = root.join("catalog.redb");
398    if catalog_path.exists() {
399        let db = open_catalog(&root)?;
400        let write_txn = db
401            .begin_write()
402            .map_err(|e| PayError::InternalError(format!("catalog begin_write: {e}")))?;
403        {
404            let mut table = write_txn
405                .open_table(CATALOG_WALLET_BY_ID)
406                .map_err(|e| PayError::InternalError(format!("catalog open wallet_by_id: {e}")))?;
407            let _ = table
408                .remove(wallet_id)
409                .map_err(|e| PayError::InternalError(format!("catalog remove wallet: {e}")))?;
410        }
411        write_txn
412            .commit()
413            .map_err(|e| PayError::InternalError(format!("catalog commit delete: {e}")))?;
414    }
415
416    // Remove wallet directory (core.redb + wallet-data/*)
417    let wallet_dir = root.join(wallet_id);
418    if wallet_dir.exists() {
419        std::fs::remove_dir_all(&wallet_dir)
420            .map_err(|e| PayError::InternalError(format!("delete wallet dir: {e}")))?;
421    }
422
423    Ok(())
424}
425
426#[cfg(feature = "redb")]
427pub fn wallet_core_database_path(data_dir: &str, wallet_id: &str) -> Result<PathBuf, PayError> {
428    Ok(wallet_directory_path(data_dir, wallet_id)?.join("core.redb"))
429}
430
431#[cfg(feature = "redb")]
432pub fn resolve_wallet_id(data_dir: &str, id_or_label: &str) -> Result<String, PayError> {
433    if id_or_label.starts_with("w_") {
434        return Ok(id_or_label.to_string());
435    }
436    // Search by label
437    let all = list_wallet_metadata(data_dir, None)?;
438    let mut matches: Vec<&WalletMetadata> = all
439        .iter()
440        .filter(|w| w.label.as_deref() == Some(id_or_label))
441        .collect();
442    match matches.len() {
443        0 => Err(PayError::WalletNotFound(format!(
444            "no wallet found with ID or label '{id_or_label}'"
445        ))),
446        1 => Ok(matches.remove(0).id.clone()),
447        n => Err(PayError::InvalidAmount(format!(
448            "label '{id_or_label}' matches {n} wallets — use wallet ID instead"
449        ))),
450    }
451}
452
453#[cfg(feature = "redb")]
454const CATALOG_VERSION: u64 = 1;
455#[cfg(feature = "redb")]
456const CORE_VERSION: u64 = 1;
457
458#[cfg(feature = "redb")]
459fn open_catalog(wallets_dir: &Path) -> Result<Database, PayError> {
460    db::open_and_migrate(
461        &wallets_dir.join("catalog.redb"),
462        CATALOG_VERSION,
463        &[
464            // v0 → v1: stamp version (no data migration needed)
465            &|_db: &Database| Ok(()),
466        ],
467    )
468}
469
470#[cfg(feature = "redb")]
471fn open_core(path: &Path) -> Result<Database, PayError> {
472    db::open_and_migrate(
473        path,
474        CORE_VERSION,
475        &[
476            // v0 → v1: no data migration, just stamp version
477            &|_db: &Database| Ok(()),
478        ],
479    )
480}
481
482// ═══════════════════════════════════════════
483// Tests
484// ═══════════════════════════════════════════
485
486#[cfg(test)]
487#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
488mod tests {
489    use super::*;
490
491    #[test]
492    fn generate_wallet_id_format() {
493        let id = generate_wallet_identifier().unwrap();
494        assert!(id.starts_with("w_"), "should start with w_: {id}");
495        assert_eq!(id.len(), 10, "w_ + 8 hex chars = 10: {id}");
496        assert!(id[2..].chars().all(|c| c.is_ascii_hexdigit()));
497    }
498
499    #[test]
500    fn generate_tx_id_format() {
501        let id = generate_transaction_identifier().unwrap();
502        assert!(id.starts_with("tx_"), "should start with tx_: {id}");
503        assert_eq!(id.len(), 19, "tx_ + 16 hex chars = 19: {id}");
504        assert!(id[3..].chars().all(|c| c.is_ascii_hexdigit()));
505    }
506
507    #[test]
508    fn generate_request_id_format() {
509        let id = generate_request_identifier().unwrap();
510        assert!(id.starts_with("req_"), "should start with req_: {id}");
511        assert_eq!(id.len(), 36, "req_ + 32 hex chars = 36: {id}");
512        assert!(id[4..].chars().all(|c| c.is_ascii_hexdigit()));
513    }
514
515    #[test]
516    fn wallet_metadata_debug_redacts_secrets() {
517        let meta = WalletMetadata {
518            id: "w_aabbccdd".to_string(),
519            network: Network::Btc,
520            label: Some("btc".to_string()),
521            mint_url: None,
522            sol_rpc_endpoints: None,
523            evm_rpc_endpoints: None,
524            evm_chain_id: None,
525            seed_secret: Some("seed-secret-value".to_string()),
526            backend: Some("core-rpc".to_string()),
527            btc_esplora_url: None,
528            btc_network: Some("signet".to_string()),
529            btc_address_type: Some("taproot".to_string()),
530            btc_core_url: Some("http://127.0.0.1:8332".to_string()),
531            btc_core_auth_secret: Some("core-auth-secret-value".to_string()),
532            btc_electrum_url: None,
533            custom_tokens: None,
534            created_at_epoch_s: 1,
535            error: None,
536        };
537        let rendered = format!("{meta:?}");
538        assert!(!rendered.contains("seed-secret-value"));
539        assert!(!rendered.contains("core-auth-secret-value"));
540        assert!(rendered.contains("***"));
541    }
542
543    #[cfg(feature = "redb")]
544    #[test]
545    fn save_and_load_roundtrip() {
546        let tmp = tempfile::tempdir().unwrap();
547        let dir = tmp.path().to_str().unwrap();
548        let meta = WalletMetadata {
549            id: "w_aabbccdd".to_string(),
550            network: Network::Cashu,
551            label: Some("test wallet".to_string()),
552            mint_url: Some("https://mint.example".to_string()),
553            sol_rpc_endpoints: None,
554            evm_rpc_endpoints: None,
555            evm_chain_id: None,
556            seed_secret: Some("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".to_string()),
557            backend: None,
558            btc_esplora_url: None,
559            btc_network: None,
560            btc_address_type: None,
561            btc_core_url: None,
562            btc_core_auth_secret: None,
563            btc_electrum_url: None,
564            custom_tokens: None,
565            created_at_epoch_s: 1700000000,
566            error: None,
567        };
568        save_wallet_metadata(dir, &meta).unwrap();
569        let loaded = load_wallet_metadata(dir, "w_aabbccdd").unwrap();
570        assert_eq!(loaded.id, meta.id);
571        assert_eq!(loaded.network, Network::Cashu);
572        assert_eq!(loaded.label, meta.label);
573        assert_eq!(loaded.mint_url, meta.mint_url);
574        assert_eq!(loaded.seed_secret, meta.seed_secret);
575        assert_eq!(loaded.created_at_epoch_s, meta.created_at_epoch_s);
576
577        let wallet_data_dir = wallet_data_directory_path(dir, "w_aabbccdd").unwrap();
578        assert!(wallet_data_dir.ends_with("wallet-data"));
579        assert!(wallet_data_dir.exists());
580    }
581
582    #[cfg(feature = "redb")]
583    #[test]
584    fn load_wallet_not_found() {
585        let tmp = tempfile::tempdir().unwrap();
586        let dir = tmp.path().to_str().unwrap();
587        let err = load_wallet_metadata(dir, "w_00000000").unwrap_err();
588        assert!(
589            matches!(err, PayError::WalletNotFound(_)),
590            "expected WalletNotFound, got: {err}"
591        );
592    }
593
594    #[cfg(feature = "redb")]
595    #[test]
596    fn list_wallets_filter_by_network() {
597        let tmp = tempfile::tempdir().unwrap();
598        let dir = tmp.path().to_str().unwrap();
599
600        let cashu = WalletMetadata {
601            id: "w_cashu001".to_string(),
602            network: Network::Cashu,
603            label: None,
604            mint_url: None,
605            sol_rpc_endpoints: None,
606            evm_rpc_endpoints: None,
607            evm_chain_id: None,
608            seed_secret: None,
609            backend: None,
610            btc_esplora_url: None,
611            btc_network: None,
612            btc_address_type: None,
613            btc_core_url: None,
614            btc_core_auth_secret: None,
615            btc_electrum_url: None,
616            custom_tokens: None,
617            created_at_epoch_s: 1,
618            error: None,
619        };
620        let ln = WalletMetadata {
621            id: "w_ln000001".to_string(),
622            network: Network::Ln,
623            label: None,
624            mint_url: None,
625            sol_rpc_endpoints: None,
626            evm_rpc_endpoints: None,
627            evm_chain_id: None,
628            seed_secret: None,
629            backend: Some("nwc".to_string()),
630            btc_esplora_url: None,
631            btc_network: None,
632            btc_address_type: None,
633            btc_core_url: None,
634            btc_core_auth_secret: None,
635            btc_electrum_url: None,
636            custom_tokens: None,
637            created_at_epoch_s: 2,
638            error: None,
639        };
640        save_wallet_metadata(dir, &cashu).unwrap();
641        save_wallet_metadata(dir, &ln).unwrap();
642
643        let all = list_wallet_metadata(dir, None).unwrap();
644        assert_eq!(all.len(), 2);
645
646        let only_cashu = list_wallet_metadata(dir, Some(Network::Cashu)).unwrap();
647        assert_eq!(only_cashu.len(), 1);
648        assert_eq!(only_cashu[0].id, "w_cashu001");
649
650        let only_ln = list_wallet_metadata(dir, Some(Network::Ln)).unwrap();
651        assert_eq!(only_ln.len(), 1);
652        assert_eq!(only_ln[0].id, "w_ln000001");
653    }
654
655    #[cfg(feature = "redb")]
656    #[test]
657    fn list_wallets_empty_dir() {
658        let tmp = tempfile::tempdir().unwrap();
659        let dir = tmp.path().to_str().unwrap();
660        let result = list_wallet_metadata(dir, None).unwrap();
661        assert!(result.is_empty());
662    }
663
664    #[cfg(feature = "redb")]
665    #[test]
666    fn delete_wallet_removes_wallet_dir_and_catalog_entry() {
667        let tmp = tempfile::tempdir().unwrap();
668        let dir = tmp.path().to_str().unwrap();
669        let meta = WalletMetadata {
670            id: "w_del001".to_string(),
671            network: Network::Cashu,
672            label: None,
673            mint_url: Some("https://mint.example".to_string()),
674            sol_rpc_endpoints: None,
675            evm_rpc_endpoints: None,
676            evm_chain_id: None,
677            seed_secret: Some("seed".to_string()),
678            backend: None,
679            btc_esplora_url: None,
680            btc_network: None,
681            btc_address_type: None,
682            btc_core_url: None,
683            btc_core_auth_secret: None,
684            btc_electrum_url: None,
685            custom_tokens: None,
686            created_at_epoch_s: 1,
687            error: None,
688        };
689        save_wallet_metadata(dir, &meta).unwrap();
690        let wallet_dir = wallet_directory_path(dir, &meta.id).unwrap();
691        assert!(wallet_dir.exists());
692
693        delete_wallet_metadata(dir, &meta.id).unwrap();
694
695        assert!(load_wallet_metadata(dir, &meta.id).is_err());
696        assert!(!wallet_dir.exists());
697    }
698}