1use crate::provider::PayError;
2use crate::types::Network;
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5
6#[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
55pub 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
104pub(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#[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 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 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 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 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 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 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 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 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 &|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 &|_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(()), };
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 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#[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 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 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 }
810
811 let db = open_catalog(&provider_root).unwrap();
813
814 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 assert_eq!(db::read_schema_version_pub(&db).unwrap(), 1);
823 drop(t);
824 drop(r);
825 drop(db);
826
827 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 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}