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