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(Debug, 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
70// ═══════════════════════════════════════════
71// Shared helpers (always available)
72// ═══════════════════════════════════════════
73
74pub fn generate_wallet_identifier() -> Result<String, PayError> {
75    let mut buf = [0u8; 4];
76    getrandom::fill(&mut buf).map_err(|e| PayError::InternalError(format!("rng failed: {e}")))?;
77    Ok(format!("w_{}", hex::encode(buf)))
78}
79
80pub fn generate_transaction_identifier() -> Result<String, PayError> {
81    let mut buf = [0u8; 8];
82    getrandom::fill(&mut buf).map_err(|e| PayError::InternalError(format!("rng failed: {e}")))?;
83    Ok(format!("tx_{}", hex::encode(buf)))
84}
85
86pub fn now_epoch_seconds() -> u64 {
87    std::time::SystemTime::now()
88        .duration_since(std::time::UNIX_EPOCH)
89        .map(|d| d.as_secs())
90        .unwrap_or(0)
91}
92
93pub fn wallet_data_directory_path_for_wallet_metadata(
94    data_dir: &str,
95    wallet_metadata: &WalletMetadata,
96) -> PathBuf {
97    provider_root_path_for_wallet_metadata(data_dir, wallet_metadata)
98        .join(&wallet_metadata.id)
99        .join("wallet-data")
100}
101
102pub fn wallet_directory_path(data_dir: &str, wallet_id: &str) -> Result<PathBuf, PayError> {
103    find_wallet_dir(data_dir, wallet_id)?
104        .ok_or_else(|| PayError::WalletNotFound(format!("wallet {wallet_id} not found")))
105}
106
107pub fn wallet_data_directory_path(data_dir: &str, wallet_id: &str) -> Result<PathBuf, PayError> {
108    Ok(wallet_directory_path(data_dir, wallet_id)?.join("wallet-data"))
109}
110
111#[cfg(feature = "redb")]
112pub(crate) fn parse_wallet_metadata(
113    raw: &str,
114    wallet_id: &str,
115) -> Result<WalletMetadata, PayError> {
116    serde_json::from_str(raw)
117        .map_err(|e| PayError::InternalError(format!("parse wallet {wallet_id}: {e}")))
118}
119
120// ═══════════════════════════════════════════
121// Path / filesystem helpers (always available)
122// ═══════════════════════════════════════════
123
124pub(crate) fn provider_root_path_for_wallet_metadata(
125    data_dir: &str,
126    wallet_metadata: &WalletMetadata,
127) -> PathBuf {
128    Path::new(data_dir).join(provider_directory_name_for_wallet_metadata(wallet_metadata))
129}
130
131fn provider_directory_name_for_wallet_metadata(wallet_metadata: &WalletMetadata) -> String {
132    match wallet_metadata.network {
133        Network::Cashu => "wallets-cashu".to_string(),
134        Network::Ln => {
135            let backend = wallet_metadata
136                .backend
137                .as_deref()
138                .unwrap_or("default")
139                .to_ascii_lowercase();
140            format!("wallets-ln-{backend}")
141        }
142        Network::Sol => "wallets-sol".to_string(),
143        Network::Evm => "wallets-evm".to_string(),
144        Network::Btc => "wallets-btc".to_string(),
145    }
146}
147
148#[cfg(feature = "redb")]
149pub(crate) fn network_from_provider_dir(name: &str) -> Option<Network> {
150    if name == "wallets-cashu" {
151        Some(Network::Cashu)
152    } else if name.starts_with("wallets-ln-") {
153        Some(Network::Ln)
154    } else if name == "wallets-sol" || name.starts_with("wallets-sol-") {
155        Some(Network::Sol)
156    } else if name == "wallets-evm" || name.starts_with("wallets-evm-") {
157        Some(Network::Evm)
158    } else if name == "wallets-btc" || name.starts_with("wallets-btc-") {
159        Some(Network::Btc)
160    } else {
161        None
162    }
163}
164
165fn provider_dir_matches_network(name: &str, network: Network) -> bool {
166    match network {
167        Network::Cashu => name == "wallets-cashu",
168        Network::Ln => name.starts_with("wallets-ln-"),
169        Network::Sol => name == "wallets-sol" || name.starts_with("wallets-sol-"),
170        Network::Evm => name == "wallets-evm" || name.starts_with("wallets-evm-"),
171        Network::Btc => name == "wallets-btc" || name.starts_with("wallets-btc-"),
172    }
173}
174
175fn provider_dir_supported(name: &str) -> bool {
176    name == "wallets-cashu"
177        || name == "wallets-sol"
178        || name == "wallets-evm"
179        || name == "wallets-btc"
180        || name.starts_with("wallets-ln-")
181        || name.starts_with("wallets-sol-")
182        || name.starts_with("wallets-evm-")
183        || name.starts_with("wallets-btc-")
184}
185
186pub(crate) fn provider_roots(
187    data_dir: &str,
188    network: Option<Network>,
189) -> Result<Vec<PathBuf>, PayError> {
190    let root = Path::new(data_dir);
191    if !root.exists() {
192        return Ok(vec![]);
193    }
194
195    let mut roots = Vec::new();
196    for entry in std::fs::read_dir(root)
197        .map_err(|e| PayError::InternalError(format!("read data_dir {}: {e}", root.display())))?
198    {
199        let entry = entry.map_err(|e| PayError::InternalError(format!("read dir entry: {e}")))?;
200        let path = entry.path();
201        if !path.is_dir() {
202            continue;
203        }
204        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
205            continue;
206        };
207        if !provider_dir_supported(name) {
208            continue;
209        }
210        if let Some(network) = network {
211            if !provider_dir_matches_network(name, network) {
212                continue;
213            }
214        }
215        roots.push(path);
216    }
217    roots.sort();
218    Ok(roots)
219}
220
221pub(crate) fn find_wallet_dir(
222    data_dir: &str,
223    wallet_id: &str,
224) -> Result<Option<PathBuf>, PayError> {
225    for root in provider_roots(data_dir, None)? {
226        let dir = root.join(wallet_id);
227        if dir.is_dir() {
228            return Ok(Some(dir));
229        }
230    }
231    Ok(None)
232}
233
234// ═══════════════════════════════════════════
235// Redb-specific functions
236// ═══════════════════════════════════════════
237
238#[cfg(feature = "redb")]
239use crate::store::db;
240#[cfg(feature = "redb")]
241use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
242
243#[cfg(feature = "redb")]
244const CATALOG_WALLET_BY_ID: TableDefinition<&str, &str> = TableDefinition::new("wallet_by_id");
245#[cfg(feature = "redb")]
246const CORE_METADATA_KEY_VALUE: TableDefinition<&str, &str> = TableDefinition::new("metadata_kv");
247#[cfg(feature = "redb")]
248const CORE_WALLET_METADATA_KEY: &str = "wallet_metadata";
249
250#[cfg(feature = "redb")]
251pub fn save_wallet_metadata(
252    data_dir: &str,
253    wallet_metadata: &WalletMetadata,
254) -> Result<(), PayError> {
255    let provider_root = provider_root_path_for_wallet_metadata(data_dir, wallet_metadata);
256    std::fs::create_dir_all(&provider_root).map_err(|e| {
257        PayError::InternalError(format!(
258            "create provider wallet dir {}: {e}",
259            provider_root.display()
260        ))
261    })?;
262
263    let wallet_dir = provider_root.join(&wallet_metadata.id);
264    let wallet_data_dir = wallet_dir.join("wallet-data");
265    std::fs::create_dir_all(&wallet_data_dir).map_err(|e| {
266        PayError::InternalError(format!(
267            "create wallet dir {}: {e}",
268            wallet_data_dir.display()
269        ))
270    })?;
271
272    let wallet_metadata_json = serde_json::to_string(wallet_metadata)
273        .map_err(|e| PayError::InternalError(format!("serialize wallet metadata: {e}")))?;
274
275    // catalog.redb: provider-level wallet index
276    let catalog_db = open_catalog(&provider_root)?;
277    let catalog_txn = catalog_db
278        .begin_write()
279        .map_err(|e| PayError::InternalError(format!("catalog begin_write: {e}")))?;
280    {
281        let mut table = catalog_txn
282            .open_table(CATALOG_WALLET_BY_ID)
283            .map_err(|e| PayError::InternalError(format!("catalog open wallet_by_id: {e}")))?;
284        table
285            .insert(wallet_metadata.id.as_str(), wallet_metadata_json.as_str())
286            .map_err(|e| PayError::InternalError(format!("catalog insert wallet: {e}")))?;
287    }
288    catalog_txn
289        .commit()
290        .map_err(|e| PayError::InternalError(format!("catalog commit: {e}")))?;
291
292    // core.redb: per-wallet authoritative metadata
293    let core_db = open_core(&wallet_dir.join("core.redb"))?;
294    let core_txn = core_db
295        .begin_write()
296        .map_err(|e| PayError::InternalError(format!("core begin_write: {e}")))?;
297    {
298        let mut table = core_txn
299            .open_table(CORE_METADATA_KEY_VALUE)
300            .map_err(|e| PayError::InternalError(format!("core open metadata_kv: {e}")))?;
301        table
302            .insert(CORE_WALLET_METADATA_KEY, wallet_metadata_json.as_str())
303            .map_err(|e| PayError::InternalError(format!("core write wallet metadata: {e}")))?;
304    }
305    core_txn
306        .commit()
307        .map_err(|e| PayError::InternalError(format!("core commit wallet metadata: {e}")))?;
308
309    Ok(())
310}
311
312#[cfg(feature = "redb")]
313pub fn load_wallet_metadata(data_dir: &str, wallet_id: &str) -> Result<WalletMetadata, PayError> {
314    // Fast path: provider catalogs
315    for provider_root in provider_roots(data_dir, None)? {
316        let catalog_path = provider_root.join("catalog.redb");
317        if !catalog_path.exists() {
318            continue;
319        }
320        let db = open_catalog(&provider_root)?;
321        let read_txn = db
322            .begin_read()
323            .map_err(|e| PayError::InternalError(format!("catalog begin_read: {e}")))?;
324        let Ok(table) = read_txn.open_table(CATALOG_WALLET_BY_ID) else {
325            continue;
326        };
327        if let Some(value) = table
328            .get(wallet_id)
329            .map_err(|e| PayError::InternalError(format!("catalog read wallet {wallet_id}: {e}")))?
330        {
331            return parse_wallet_metadata(value.value(), wallet_id);
332        }
333    }
334
335    // Fallback: wallet core metadata
336    if let Some(dir) = find_wallet_dir(data_dir, wallet_id)? {
337        let core_path = dir.join("core.redb");
338        if core_path.exists() {
339            let db = db::open_database(&core_path)?;
340            let read_txn = db
341                .begin_read()
342                .map_err(|e| PayError::InternalError(format!("core begin_read: {e}")))?;
343            let Ok(table) = read_txn.open_table(CORE_METADATA_KEY_VALUE) else {
344                return Err(PayError::WalletNotFound(format!(
345                    "wallet {wallet_id} not found"
346                )));
347            };
348            if let Some(value) = table
349                .get(CORE_WALLET_METADATA_KEY)
350                .map_err(|e| PayError::InternalError(format!("core read wallet metadata: {e}")))?
351            {
352                return parse_wallet_metadata(value.value(), wallet_id);
353            }
354        }
355    }
356
357    // Label fallback: if wallet_id doesn't start with "w_", try matching by label
358    if !wallet_id.starts_with("w_") {
359        for provider_root in provider_roots(data_dir, None)? {
360            let catalog_path = provider_root.join("catalog.redb");
361            if !catalog_path.exists() {
362                continue;
363            }
364            let db = open_catalog(&provider_root)?;
365            let read_txn = db
366                .begin_read()
367                .map_err(|e| PayError::InternalError(format!("catalog begin_read: {e}")))?;
368            let Ok(table) = read_txn.open_table(CATALOG_WALLET_BY_ID) else {
369                continue;
370            };
371            for entry in table
372                .iter()
373                .map_err(|e| PayError::InternalError(format!("catalog iterate: {e}")))?
374            {
375                let (key, value) = entry
376                    .map_err(|e| PayError::InternalError(format!("catalog read entry: {e}")))?;
377                if let Ok(meta) = parse_wallet_metadata(value.value(), key.value()) {
378                    if meta.label.as_deref() == Some(wallet_id) {
379                        return Ok(meta);
380                    }
381                }
382            }
383        }
384    }
385
386    Err(PayError::WalletNotFound(format!(
387        "wallet {wallet_id} not found"
388    )))
389}
390
391#[cfg(feature = "redb")]
392pub fn list_wallet_metadata(
393    data_dir: &str,
394    network: Option<Network>,
395) -> Result<Vec<WalletMetadata>, PayError> {
396    let mut wallets = Vec::new();
397
398    for provider_root in provider_roots(data_dir, network)? {
399        let catalog_path = provider_root.join("catalog.redb");
400        if !catalog_path.exists() {
401            continue;
402        }
403        let dir_network = provider_root
404            .file_name()
405            .and_then(|n| n.to_str())
406            .and_then(network_from_provider_dir);
407        let db = open_catalog(&provider_root)?;
408        let read_txn = db
409            .begin_read()
410            .map_err(|e| PayError::InternalError(format!("catalog begin_read: {e}")))?;
411        let Ok(table) = read_txn.open_table(CATALOG_WALLET_BY_ID) else {
412            continue;
413        };
414
415        for entry in table
416            .iter()
417            .map_err(|e| PayError::InternalError(format!("catalog iterate wallets: {e}")))?
418        {
419            let (key, value) = entry
420                .map_err(|e| PayError::InternalError(format!("catalog read wallet entry: {e}")))?;
421            let wallet_metadata: WalletMetadata = match serde_json::from_str(value.value()) {
422                Ok(m) => m,
423                Err(e) => {
424                    let Some(dn) = dir_network else { continue };
425                    WalletMetadata {
426                        id: key.value().to_string(),
427                        network: dn,
428                        label: None,
429                        mint_url: None,
430                        sol_rpc_endpoints: None,
431                        evm_rpc_endpoints: None,
432                        evm_chain_id: None,
433                        seed_secret: None,
434                        backend: None,
435                        btc_esplora_url: None,
436                        btc_network: None,
437                        btc_address_type: None,
438                        btc_core_url: None,
439                        btc_core_auth_secret: None,
440                        btc_electrum_url: None,
441                        custom_tokens: None,
442                        created_at_epoch_s: 0,
443                        error: Some(format!("corrupt metadata: {e}")),
444                    }
445                }
446            };
447            if let Some(network) = network {
448                if wallet_metadata.network != network {
449                    continue;
450                }
451            }
452            wallets.push(wallet_metadata);
453        }
454    }
455
456    wallets.sort_by(|a, b| a.id.cmp(&b.id));
457    Ok(wallets)
458}
459
460#[cfg(feature = "redb")]
461pub fn delete_wallet_metadata(data_dir: &str, wallet_id: &str) -> Result<(), PayError> {
462    let wallet_metadata = load_wallet_metadata(data_dir, wallet_id)?;
463    let provider_root = provider_root_path_for_wallet_metadata(data_dir, &wallet_metadata);
464
465    // Remove from provider catalog
466    let catalog_path = provider_root.join("catalog.redb");
467    if catalog_path.exists() {
468        let db = open_catalog(&provider_root)?;
469        let write_txn = db
470            .begin_write()
471            .map_err(|e| PayError::InternalError(format!("catalog begin_write: {e}")))?;
472        {
473            let mut table = write_txn
474                .open_table(CATALOG_WALLET_BY_ID)
475                .map_err(|e| PayError::InternalError(format!("catalog open wallet_by_id: {e}")))?;
476            let _ = table
477                .remove(wallet_id)
478                .map_err(|e| PayError::InternalError(format!("catalog remove wallet: {e}")))?;
479        }
480        write_txn
481            .commit()
482            .map_err(|e| PayError::InternalError(format!("catalog commit delete: {e}")))?;
483    }
484
485    // Remove wallet directory (core.redb + wallet-data/*)
486    let wallet_dir = provider_root.join(wallet_id);
487    if wallet_dir.exists() {
488        std::fs::remove_dir_all(&wallet_dir)
489            .map_err(|e| PayError::InternalError(format!("delete wallet dir: {e}")))?;
490    }
491
492    Ok(())
493}
494
495#[cfg(feature = "redb")]
496pub fn wallet_core_database_path(data_dir: &str, wallet_id: &str) -> Result<PathBuf, PayError> {
497    Ok(wallet_directory_path(data_dir, wallet_id)?.join("core.redb"))
498}
499
500#[cfg(feature = "redb")]
501pub fn resolve_wallet_id(data_dir: &str, id_or_label: &str) -> Result<String, PayError> {
502    if id_or_label.starts_with("w_") {
503        return Ok(id_or_label.to_string());
504    }
505    // Search by label
506    let all = list_wallet_metadata(data_dir, None)?;
507    let mut matches: Vec<&WalletMetadata> = all
508        .iter()
509        .filter(|w| w.label.as_deref() == Some(id_or_label))
510        .collect();
511    match matches.len() {
512        0 => Err(PayError::WalletNotFound(format!(
513            "no wallet found with ID or label '{id_or_label}'"
514        ))),
515        1 => Ok(matches.remove(0).id.clone()),
516        n => Err(PayError::InvalidAmount(format!(
517            "label '{id_or_label}' matches {n} wallets — use wallet ID instead"
518        ))),
519    }
520}
521
522#[cfg(feature = "redb")]
523const CATALOG_VERSION: u64 = 1;
524#[cfg(feature = "redb")]
525const CORE_VERSION: u64 = 1;
526
527#[cfg(feature = "redb")]
528fn open_catalog(provider_root: &Path) -> Result<Database, PayError> {
529    let dir_name = provider_root
530        .file_name()
531        .and_then(|n| n.to_str())
532        .unwrap_or("");
533    let dir_name_owned = dir_name.to_string();
534
535    db::open_and_migrate(
536        &provider_root.join("catalog.redb"),
537        CATALOG_VERSION,
538        &[
539            // v0 → v1: backfill `network` from provider directory name
540            &|db: &Database| migrate_catalog_v0_to_v1(db, &dir_name_owned),
541        ],
542    )
543}
544
545#[cfg(feature = "redb")]
546fn open_core(path: &Path) -> Result<Database, PayError> {
547    db::open_and_migrate(
548        path,
549        CORE_VERSION,
550        &[
551            // v0 → v1: no data migration, just stamp version
552            &|_db: &Database| Ok(()),
553        ],
554    )
555}
556
557#[cfg(feature = "redb")]
558fn migrate_catalog_v0_to_v1(db: &Database, provider_dir_name: &str) -> Result<(), PayError> {
559    let network = match network_from_provider_dir(provider_dir_name) {
560        Some(n) => n,
561        None => return Ok(()), // unknown provider dir — skip
562    };
563    let network_str = match network {
564        Network::Cashu => "cashu",
565        Network::Ln => "ln",
566        Network::Sol => "sol",
567        Network::Evm => "evm",
568        Network::Btc => "btc",
569    };
570
571    // Collect keys needing update (can't mutate during iteration)
572    let read_txn = db
573        .begin_read()
574        .map_err(|e| PayError::InternalError(format!("catalog migration begin_read: {e}")))?;
575    let Ok(table) = read_txn.open_table(CATALOG_WALLET_BY_ID) else {
576        return Ok(());
577    };
578    let mut updates: Vec<(String, String)> = Vec::new();
579    for entry in table
580        .iter()
581        .map_err(|e| PayError::InternalError(format!("catalog migration iterate: {e}")))?
582    {
583        let (key, value) = entry
584            .map_err(|e| PayError::InternalError(format!("catalog migration read entry: {e}")))?;
585        let raw = value.value();
586        let mut obj: serde_json::Value = serde_json::from_str(raw).map_err(|e| {
587            PayError::InternalError(format!("catalog migration parse {}: {e}", key.value()))
588        })?;
589        if obj.get("network").is_none() {
590            obj["network"] = serde_json::Value::String(network_str.to_string());
591            let updated = serde_json::to_string(&obj).map_err(|e| {
592                PayError::InternalError(format!("catalog migration serialize {}: {e}", key.value()))
593            })?;
594            updates.push((key.value().to_string(), updated));
595        }
596    }
597    drop(table);
598    drop(read_txn);
599
600    if updates.is_empty() {
601        return Ok(());
602    }
603
604    let write_txn = db
605        .begin_write()
606        .map_err(|e| PayError::InternalError(format!("catalog migration begin_write: {e}")))?;
607    {
608        let mut table = write_txn.open_table(CATALOG_WALLET_BY_ID).map_err(|e| {
609            PayError::InternalError(format!("catalog migration open wallet_by_id: {e}"))
610        })?;
611        for (key, value) in &updates {
612            table.insert(key.as_str(), value.as_str()).map_err(|e| {
613                PayError::InternalError(format!("catalog migration update {key}: {e}"))
614            })?;
615        }
616    }
617    write_txn
618        .commit()
619        .map_err(|e| PayError::InternalError(format!("catalog migration commit: {e}")))?;
620
621    Ok(())
622}
623
624// ═══════════════════════════════════════════
625// Tests
626// ═══════════════════════════════════════════
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631
632    #[test]
633    fn generate_wallet_id_format() {
634        let id = generate_wallet_identifier().unwrap();
635        assert!(id.starts_with("w_"), "should start with w_: {id}");
636        assert_eq!(id.len(), 10, "w_ + 8 hex chars = 10: {id}");
637        assert!(id[2..].chars().all(|c| c.is_ascii_hexdigit()));
638    }
639
640    #[test]
641    fn generate_tx_id_format() {
642        let id = generate_transaction_identifier().unwrap();
643        assert!(id.starts_with("tx_"), "should start with tx_: {id}");
644        assert_eq!(id.len(), 19, "tx_ + 16 hex chars = 19: {id}");
645        assert!(id[3..].chars().all(|c| c.is_ascii_hexdigit()));
646    }
647
648    #[cfg(feature = "redb")]
649    #[test]
650    fn save_and_load_roundtrip() {
651        let tmp = tempfile::tempdir().unwrap();
652        let dir = tmp.path().to_str().unwrap();
653        let meta = WalletMetadata {
654            id: "w_aabbccdd".to_string(),
655            network: Network::Cashu,
656            label: Some("test wallet".to_string()),
657            mint_url: Some("https://mint.example".to_string()),
658            sol_rpc_endpoints: None,
659            evm_rpc_endpoints: None,
660            evm_chain_id: None,
661            seed_secret: Some("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".to_string()),
662            backend: None,
663            btc_esplora_url: None,
664            btc_network: None,
665            btc_address_type: None,
666            btc_core_url: None,
667            btc_core_auth_secret: None,
668            btc_electrum_url: None,
669            custom_tokens: None,
670            created_at_epoch_s: 1700000000,
671            error: None,
672        };
673        save_wallet_metadata(dir, &meta).unwrap();
674        let loaded = load_wallet_metadata(dir, "w_aabbccdd").unwrap();
675        assert_eq!(loaded.id, meta.id);
676        assert_eq!(loaded.network, Network::Cashu);
677        assert_eq!(loaded.label, meta.label);
678        assert_eq!(loaded.mint_url, meta.mint_url);
679        assert_eq!(loaded.seed_secret, meta.seed_secret);
680        assert_eq!(loaded.created_at_epoch_s, meta.created_at_epoch_s);
681
682        let wallet_data_dir = wallet_data_directory_path(dir, "w_aabbccdd").unwrap();
683        assert!(wallet_data_dir.ends_with("wallet-data"));
684        assert!(wallet_data_dir.exists());
685    }
686
687    #[cfg(feature = "redb")]
688    #[test]
689    fn load_wallet_not_found() {
690        let tmp = tempfile::tempdir().unwrap();
691        let dir = tmp.path().to_str().unwrap();
692        let err = load_wallet_metadata(dir, "w_00000000").unwrap_err();
693        assert!(
694            matches!(err, PayError::WalletNotFound(_)),
695            "expected WalletNotFound, got: {err}"
696        );
697    }
698
699    #[cfg(feature = "redb")]
700    #[test]
701    fn list_wallets_filter_by_network() {
702        let tmp = tempfile::tempdir().unwrap();
703        let dir = tmp.path().to_str().unwrap();
704
705        let cashu = WalletMetadata {
706            id: "w_cashu001".to_string(),
707            network: Network::Cashu,
708            label: None,
709            mint_url: None,
710            sol_rpc_endpoints: None,
711            evm_rpc_endpoints: None,
712            evm_chain_id: None,
713            seed_secret: None,
714            backend: None,
715            btc_esplora_url: None,
716            btc_network: None,
717            btc_address_type: None,
718            btc_core_url: None,
719            btc_core_auth_secret: None,
720            btc_electrum_url: None,
721            custom_tokens: None,
722            created_at_epoch_s: 1,
723            error: None,
724        };
725        let ln = WalletMetadata {
726            id: "w_ln000001".to_string(),
727            network: Network::Ln,
728            label: None,
729            mint_url: None,
730            sol_rpc_endpoints: None,
731            evm_rpc_endpoints: None,
732            evm_chain_id: None,
733            seed_secret: None,
734            backend: Some("nwc".to_string()),
735            btc_esplora_url: None,
736            btc_network: None,
737            btc_address_type: None,
738            btc_core_url: None,
739            btc_core_auth_secret: None,
740            btc_electrum_url: None,
741            custom_tokens: None,
742            created_at_epoch_s: 2,
743            error: None,
744        };
745        save_wallet_metadata(dir, &cashu).unwrap();
746        save_wallet_metadata(dir, &ln).unwrap();
747
748        let all = list_wallet_metadata(dir, None).unwrap();
749        assert_eq!(all.len(), 2);
750
751        let only_cashu = list_wallet_metadata(dir, Some(Network::Cashu)).unwrap();
752        assert_eq!(only_cashu.len(), 1);
753        assert_eq!(only_cashu[0].id, "w_cashu001");
754
755        let only_ln = list_wallet_metadata(dir, Some(Network::Ln)).unwrap();
756        assert_eq!(only_ln.len(), 1);
757        assert_eq!(only_ln[0].id, "w_ln000001");
758    }
759
760    #[cfg(feature = "redb")]
761    #[test]
762    fn list_wallets_empty_dir() {
763        let tmp = tempfile::tempdir().unwrap();
764        let dir = tmp.path().to_str().unwrap();
765        let result = list_wallet_metadata(dir, None).unwrap();
766        assert!(result.is_empty());
767    }
768
769    #[cfg(feature = "redb")]
770    #[test]
771    fn delete_wallet_removes_wallet_dir_and_catalog_entry() {
772        let tmp = tempfile::tempdir().unwrap();
773        let dir = tmp.path().to_str().unwrap();
774        let meta = WalletMetadata {
775            id: "w_del001".to_string(),
776            network: Network::Cashu,
777            label: None,
778            mint_url: Some("https://mint.example".to_string()),
779            sol_rpc_endpoints: None,
780            evm_rpc_endpoints: None,
781            evm_chain_id: None,
782            seed_secret: Some("seed".to_string()),
783            backend: None,
784            btc_esplora_url: None,
785            btc_network: None,
786            btc_address_type: None,
787            btc_core_url: None,
788            btc_core_auth_secret: None,
789            btc_electrum_url: None,
790            custom_tokens: None,
791            created_at_epoch_s: 1,
792            error: None,
793        };
794        save_wallet_metadata(dir, &meta).unwrap();
795        let wallet_dir = wallet_directory_path(dir, &meta.id).unwrap();
796        assert!(wallet_dir.exists());
797
798        delete_wallet_metadata(dir, &meta.id).unwrap();
799
800        assert!(load_wallet_metadata(dir, &meta.id).is_err());
801        assert!(!wallet_dir.exists());
802    }
803
804    #[cfg(feature = "redb")]
805    #[test]
806    fn catalog_migration_v0_to_v1_backfills_network() {
807        use crate::store::db;
808
809        let tmp = tempfile::tempdir().unwrap();
810        let provider_root = tmp.path().join("wallets-cashu");
811        std::fs::create_dir_all(&provider_root).unwrap();
812
813        // Create a legacy catalog.redb with an entry missing "network"
814        let catalog_path = provider_root.join("catalog.redb");
815        {
816            let db = db::open_database(&catalog_path).unwrap();
817            let w = db.begin_write().unwrap();
818            {
819                let mut t = w.open_table(CATALOG_WALLET_BY_ID).unwrap();
820                // JSON without "network" field — simulates pre-migration data
821                let legacy_json = r#"{"id":"w_legacy01","label":"old","mint_url":"https://mint.example","created_at_epoch_s":1700000000}"#;
822                t.insert("w_legacy01", legacy_json).unwrap();
823            }
824            w.commit().unwrap();
825            // No _schema table — version 0
826        }
827
828        // Open via open_catalog which triggers migration
829        let db = open_catalog(&provider_root).unwrap();
830
831        // Verify: entry now has "network": "cashu"
832        let r = db.begin_read().unwrap();
833        let t = r.open_table(CATALOG_WALLET_BY_ID).unwrap();
834        let raw = t.get("w_legacy01").unwrap().unwrap();
835        let obj: serde_json::Value = serde_json::from_str(raw.value()).unwrap();
836        assert_eq!(obj["network"], "cashu");
837
838        // Verify: schema version is 1
839        assert_eq!(db::read_schema_version_pub(&db).unwrap(), 1);
840        drop(t);
841        drop(r);
842        drop(db);
843
844        // Reopen — should not re-migrate, version still 1
845        let db2 = open_catalog(&provider_root).unwrap();
846        assert_eq!(db::read_schema_version_pub(&db2).unwrap(), 1);
847    }
848
849    #[cfg(feature = "redb")]
850    #[test]
851    fn catalog_migration_preserves_existing_network() {
852        use crate::store::db;
853
854        let tmp = tempfile::tempdir().unwrap();
855        let provider_root = tmp.path().join("wallets-cashu");
856        std::fs::create_dir_all(&provider_root).unwrap();
857
858        let catalog_path = provider_root.join("catalog.redb");
859        {
860            let db = db::open_database(&catalog_path).unwrap();
861            let w = db.begin_write().unwrap();
862            {
863                let mut t = w.open_table(CATALOG_WALLET_BY_ID).unwrap();
864                // JSON with "network" already present
865                let json =
866                    r#"{"id":"w_has_net","network":"cashu","created_at_epoch_s":1700000000}"#;
867                t.insert("w_has_net", json).unwrap();
868            }
869            w.commit().unwrap();
870        }
871
872        let db = open_catalog(&provider_root).unwrap();
873        let r = db.begin_read().unwrap();
874        let t = r.open_table(CATALOG_WALLET_BY_ID).unwrap();
875        let raw = t.get("w_has_net").unwrap().unwrap();
876        let obj: serde_json::Value = serde_json::from_str(raw.value()).unwrap();
877        assert_eq!(
878            obj["network"], "cashu",
879            "existing network field should be untouched"
880        );
881    }
882}