Skip to main content

agent_first_pay/store/
wallet.rs

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