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#[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
70pub 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
120pub(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#[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 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 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 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 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 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 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 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 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 &|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 &|_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(()), };
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 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#[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 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 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 }
827
828 let db = open_catalog(&provider_root).unwrap();
830
831 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 assert_eq!(db::read_schema_version_pub(&db).unwrap(), 1);
840 drop(t);
841 drop(r);
842 drop(db);
843
844 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 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}