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
70const WALLETS_DIR: &str = "wallets";
75
76pub fn generate_wallet_identifier() -> Result<String, PayError> {
77 let mut buf = [0u8; 4];
78 getrandom::fill(&mut buf).map_err(|e| PayError::InternalError(format!("rng failed: {e}")))?;
79 Ok(format!("w_{}", hex::encode(buf)))
80}
81
82pub fn generate_transaction_identifier() -> Result<String, PayError> {
83 let mut buf = [0u8; 8];
84 getrandom::fill(&mut buf).map_err(|e| PayError::InternalError(format!("rng failed: {e}")))?;
85 Ok(format!("tx_{}", hex::encode(buf)))
86}
87
88pub fn now_epoch_seconds() -> u64 {
89 std::time::SystemTime::now()
90 .duration_since(std::time::UNIX_EPOCH)
91 .map(|d| d.as_secs())
92 .unwrap_or(0)
93}
94
95fn wallets_root(data_dir: &str) -> PathBuf {
97 Path::new(data_dir).join(WALLETS_DIR)
98}
99
100pub fn wallet_data_directory_path_for_wallet_metadata(
101 data_dir: &str,
102 wallet_metadata: &WalletMetadata,
103) -> PathBuf {
104 wallets_root(data_dir)
105 .join(&wallet_metadata.id)
106 .join("wallet-data")
107}
108
109pub fn wallet_directory_path(data_dir: &str, wallet_id: &str) -> Result<PathBuf, PayError> {
110 let dir = wallets_root(data_dir).join(wallet_id);
111 if dir.is_dir() {
112 Ok(dir)
113 } else {
114 Err(PayError::WalletNotFound(format!(
115 "wallet {wallet_id} not found"
116 )))
117 }
118}
119
120pub fn wallet_data_directory_path(data_dir: &str, wallet_id: &str) -> Result<PathBuf, PayError> {
121 Ok(wallet_directory_path(data_dir, wallet_id)?.join("wallet-data"))
122}
123
124#[cfg(feature = "redb")]
125pub(crate) fn parse_wallet_metadata(
126 raw: &str,
127 wallet_id: &str,
128) -> Result<WalletMetadata, PayError> {
129 serde_json::from_str(raw)
130 .map_err(|e| PayError::InternalError(format!("parse wallet {wallet_id}: {e}")))
131}
132
133#[cfg(feature = "redb")]
138use crate::store::db;
139#[cfg(feature = "redb")]
140use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
141
142#[cfg(feature = "redb")]
143const CATALOG_WALLET_BY_ID: TableDefinition<&str, &str> = TableDefinition::new("wallet_by_id");
144#[cfg(feature = "redb")]
145const CORE_METADATA_KEY_VALUE: TableDefinition<&str, &str> = TableDefinition::new("metadata_kv");
146#[cfg(feature = "redb")]
147const CORE_WALLET_METADATA_KEY: &str = "wallet_metadata";
148
149#[cfg(feature = "redb")]
150pub fn save_wallet_metadata(
151 data_dir: &str,
152 wallet_metadata: &WalletMetadata,
153) -> Result<(), PayError> {
154 let root = wallets_root(data_dir);
155 std::fs::create_dir_all(&root).map_err(|e| {
156 PayError::InternalError(format!("create wallets dir {}: {e}", root.display()))
157 })?;
158
159 let wallet_dir = root.join(&wallet_metadata.id);
160 let wallet_data_dir = wallet_dir.join("wallet-data");
161 std::fs::create_dir_all(&wallet_data_dir).map_err(|e| {
162 PayError::InternalError(format!(
163 "create wallet dir {}: {e}",
164 wallet_data_dir.display()
165 ))
166 })?;
167
168 let wallet_metadata_json = serde_json::to_string(wallet_metadata)
169 .map_err(|e| PayError::InternalError(format!("serialize wallet metadata: {e}")))?;
170
171 let catalog_db = open_catalog(&root)?;
173 let catalog_txn = catalog_db
174 .begin_write()
175 .map_err(|e| PayError::InternalError(format!("catalog begin_write: {e}")))?;
176 {
177 let mut table = catalog_txn
178 .open_table(CATALOG_WALLET_BY_ID)
179 .map_err(|e| PayError::InternalError(format!("catalog open wallet_by_id: {e}")))?;
180 table
181 .insert(wallet_metadata.id.as_str(), wallet_metadata_json.as_str())
182 .map_err(|e| PayError::InternalError(format!("catalog insert wallet: {e}")))?;
183 }
184 catalog_txn
185 .commit()
186 .map_err(|e| PayError::InternalError(format!("catalog commit: {e}")))?;
187
188 let core_db = open_core(&wallet_dir.join("core.redb"))?;
190 let core_txn = core_db
191 .begin_write()
192 .map_err(|e| PayError::InternalError(format!("core begin_write: {e}")))?;
193 {
194 let mut table = core_txn
195 .open_table(CORE_METADATA_KEY_VALUE)
196 .map_err(|e| PayError::InternalError(format!("core open metadata_kv: {e}")))?;
197 table
198 .insert(CORE_WALLET_METADATA_KEY, wallet_metadata_json.as_str())
199 .map_err(|e| PayError::InternalError(format!("core write wallet metadata: {e}")))?;
200 }
201 core_txn
202 .commit()
203 .map_err(|e| PayError::InternalError(format!("core commit wallet metadata: {e}")))?;
204
205 Ok(())
206}
207
208#[cfg(feature = "redb")]
209pub fn load_wallet_metadata(data_dir: &str, wallet_id: &str) -> Result<WalletMetadata, PayError> {
210 let root = wallets_root(data_dir);
211
212 let catalog_path = root.join("catalog.redb");
214 if catalog_path.exists() {
215 let db = open_catalog(&root)?;
216 let read_txn = db
217 .begin_read()
218 .map_err(|e| PayError::InternalError(format!("catalog begin_read: {e}")))?;
219 if let Ok(table) = read_txn.open_table(CATALOG_WALLET_BY_ID) {
220 if let Some(value) = table.get(wallet_id).map_err(|e| {
221 PayError::InternalError(format!("catalog read wallet {wallet_id}: {e}"))
222 })? {
223 return parse_wallet_metadata(value.value(), wallet_id);
224 }
225 }
226 }
227
228 let wallet_dir = root.join(wallet_id);
230 if wallet_dir.is_dir() {
231 let core_path = wallet_dir.join("core.redb");
232 if core_path.exists() {
233 let db = db::open_database(&core_path)?;
234 let read_txn = db
235 .begin_read()
236 .map_err(|e| PayError::InternalError(format!("core begin_read: {e}")))?;
237 let Ok(table) = read_txn.open_table(CORE_METADATA_KEY_VALUE) else {
238 return Err(PayError::WalletNotFound(format!(
239 "wallet {wallet_id} not found"
240 )));
241 };
242 if let Some(value) = table
243 .get(CORE_WALLET_METADATA_KEY)
244 .map_err(|e| PayError::InternalError(format!("core read wallet metadata: {e}")))?
245 {
246 return parse_wallet_metadata(value.value(), wallet_id);
247 }
248 }
249 }
250
251 if !wallet_id.starts_with("w_") {
253 let catalog_path = root.join("catalog.redb");
254 if catalog_path.exists() {
255 let db = open_catalog(&root)?;
256 let read_txn = db
257 .begin_read()
258 .map_err(|e| PayError::InternalError(format!("catalog begin_read: {e}")))?;
259 if let Ok(table) = read_txn.open_table(CATALOG_WALLET_BY_ID) {
260 for entry in table
261 .iter()
262 .map_err(|e| PayError::InternalError(format!("catalog iterate: {e}")))?
263 {
264 let (key, value) = entry
265 .map_err(|e| PayError::InternalError(format!("catalog read entry: {e}")))?;
266 if let Ok(meta) = parse_wallet_metadata(value.value(), key.value()) {
267 if meta.label.as_deref() == Some(wallet_id) {
268 return Ok(meta);
269 }
270 }
271 }
272 }
273 }
274 }
275
276 Err(PayError::WalletNotFound(format!(
277 "wallet {wallet_id} not found"
278 )))
279}
280
281#[cfg(feature = "redb")]
282pub fn list_wallet_metadata(
283 data_dir: &str,
284 network: Option<Network>,
285) -> Result<Vec<WalletMetadata>, PayError> {
286 let root = wallets_root(data_dir);
287 let catalog_path = root.join("catalog.redb");
288 if !catalog_path.exists() {
289 return Ok(vec![]);
290 }
291
292 let db = open_catalog(&root)?;
293 let read_txn = db
294 .begin_read()
295 .map_err(|e| PayError::InternalError(format!("catalog begin_read: {e}")))?;
296 let Ok(table) = read_txn.open_table(CATALOG_WALLET_BY_ID) else {
297 return Ok(vec![]);
298 };
299
300 let mut wallets = Vec::new();
301 for entry in table
302 .iter()
303 .map_err(|e| PayError::InternalError(format!("catalog iterate wallets: {e}")))?
304 {
305 let (key, value) = entry
306 .map_err(|e| PayError::InternalError(format!("catalog read wallet entry: {e}")))?;
307 let wallet_metadata: WalletMetadata = match serde_json::from_str(value.value()) {
308 Ok(m) => m,
309 Err(e) => WalletMetadata {
310 id: key.value().to_string(),
311 network: Network::Cashu, label: None,
313 mint_url: None,
314 sol_rpc_endpoints: None,
315 evm_rpc_endpoints: None,
316 evm_chain_id: None,
317 seed_secret: None,
318 backend: None,
319 btc_esplora_url: None,
320 btc_network: None,
321 btc_address_type: None,
322 btc_core_url: None,
323 btc_core_auth_secret: None,
324 btc_electrum_url: None,
325 custom_tokens: None,
326 created_at_epoch_s: 0,
327 error: Some(format!("corrupt metadata: {e}")),
328 },
329 };
330 if let Some(network) = network {
331 if wallet_metadata.network != network {
332 continue;
333 }
334 }
335 wallets.push(wallet_metadata);
336 }
337
338 wallets.sort_by(|a, b| a.id.cmp(&b.id));
339 Ok(wallets)
340}
341
342#[cfg(feature = "redb")]
343pub fn delete_wallet_metadata(data_dir: &str, wallet_id: &str) -> Result<(), PayError> {
344 let root = wallets_root(data_dir);
345
346 let catalog_path = root.join("catalog.redb");
348 if catalog_path.exists() {
349 let db = open_catalog(&root)?;
350 let write_txn = db
351 .begin_write()
352 .map_err(|e| PayError::InternalError(format!("catalog begin_write: {e}")))?;
353 {
354 let mut table = write_txn
355 .open_table(CATALOG_WALLET_BY_ID)
356 .map_err(|e| PayError::InternalError(format!("catalog open wallet_by_id: {e}")))?;
357 let _ = table
358 .remove(wallet_id)
359 .map_err(|e| PayError::InternalError(format!("catalog remove wallet: {e}")))?;
360 }
361 write_txn
362 .commit()
363 .map_err(|e| PayError::InternalError(format!("catalog commit delete: {e}")))?;
364 }
365
366 let wallet_dir = root.join(wallet_id);
368 if wallet_dir.exists() {
369 std::fs::remove_dir_all(&wallet_dir)
370 .map_err(|e| PayError::InternalError(format!("delete wallet dir: {e}")))?;
371 }
372
373 Ok(())
374}
375
376#[cfg(feature = "redb")]
377pub fn wallet_core_database_path(data_dir: &str, wallet_id: &str) -> Result<PathBuf, PayError> {
378 Ok(wallet_directory_path(data_dir, wallet_id)?.join("core.redb"))
379}
380
381#[cfg(feature = "redb")]
382pub fn resolve_wallet_id(data_dir: &str, id_or_label: &str) -> Result<String, PayError> {
383 if id_or_label.starts_with("w_") {
384 return Ok(id_or_label.to_string());
385 }
386 let all = list_wallet_metadata(data_dir, None)?;
388 let mut matches: Vec<&WalletMetadata> = all
389 .iter()
390 .filter(|w| w.label.as_deref() == Some(id_or_label))
391 .collect();
392 match matches.len() {
393 0 => Err(PayError::WalletNotFound(format!(
394 "no wallet found with ID or label '{id_or_label}'"
395 ))),
396 1 => Ok(matches.remove(0).id.clone()),
397 n => Err(PayError::InvalidAmount(format!(
398 "label '{id_or_label}' matches {n} wallets — use wallet ID instead"
399 ))),
400 }
401}
402
403#[cfg(feature = "redb")]
404const CATALOG_VERSION: u64 = 1;
405#[cfg(feature = "redb")]
406const CORE_VERSION: u64 = 1;
407
408#[cfg(feature = "redb")]
409fn open_catalog(wallets_dir: &Path) -> Result<Database, PayError> {
410 db::open_and_migrate(
411 &wallets_dir.join("catalog.redb"),
412 CATALOG_VERSION,
413 &[
414 &|_db: &Database| Ok(()),
416 ],
417 )
418}
419
420#[cfg(feature = "redb")]
421fn open_core(path: &Path) -> Result<Database, PayError> {
422 db::open_and_migrate(
423 path,
424 CORE_VERSION,
425 &[
426 &|_db: &Database| Ok(()),
428 ],
429 )
430}
431
432#[cfg(test)]
437mod tests {
438 use super::*;
439
440 #[test]
441 fn generate_wallet_id_format() {
442 let id = generate_wallet_identifier().unwrap();
443 assert!(id.starts_with("w_"), "should start with w_: {id}");
444 assert_eq!(id.len(), 10, "w_ + 8 hex chars = 10: {id}");
445 assert!(id[2..].chars().all(|c| c.is_ascii_hexdigit()));
446 }
447
448 #[test]
449 fn generate_tx_id_format() {
450 let id = generate_transaction_identifier().unwrap();
451 assert!(id.starts_with("tx_"), "should start with tx_: {id}");
452 assert_eq!(id.len(), 19, "tx_ + 16 hex chars = 19: {id}");
453 assert!(id[3..].chars().all(|c| c.is_ascii_hexdigit()));
454 }
455
456 #[cfg(feature = "redb")]
457 #[test]
458 fn save_and_load_roundtrip() {
459 let tmp = tempfile::tempdir().unwrap();
460 let dir = tmp.path().to_str().unwrap();
461 let meta = WalletMetadata {
462 id: "w_aabbccdd".to_string(),
463 network: Network::Cashu,
464 label: Some("test wallet".to_string()),
465 mint_url: Some("https://mint.example".to_string()),
466 sol_rpc_endpoints: None,
467 evm_rpc_endpoints: None,
468 evm_chain_id: None,
469 seed_secret: Some("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".to_string()),
470 backend: None,
471 btc_esplora_url: None,
472 btc_network: None,
473 btc_address_type: None,
474 btc_core_url: None,
475 btc_core_auth_secret: None,
476 btc_electrum_url: None,
477 custom_tokens: None,
478 created_at_epoch_s: 1700000000,
479 error: None,
480 };
481 save_wallet_metadata(dir, &meta).unwrap();
482 let loaded = load_wallet_metadata(dir, "w_aabbccdd").unwrap();
483 assert_eq!(loaded.id, meta.id);
484 assert_eq!(loaded.network, Network::Cashu);
485 assert_eq!(loaded.label, meta.label);
486 assert_eq!(loaded.mint_url, meta.mint_url);
487 assert_eq!(loaded.seed_secret, meta.seed_secret);
488 assert_eq!(loaded.created_at_epoch_s, meta.created_at_epoch_s);
489
490 let wallet_data_dir = wallet_data_directory_path(dir, "w_aabbccdd").unwrap();
491 assert!(wallet_data_dir.ends_with("wallet-data"));
492 assert!(wallet_data_dir.exists());
493 }
494
495 #[cfg(feature = "redb")]
496 #[test]
497 fn load_wallet_not_found() {
498 let tmp = tempfile::tempdir().unwrap();
499 let dir = tmp.path().to_str().unwrap();
500 let err = load_wallet_metadata(dir, "w_00000000").unwrap_err();
501 assert!(
502 matches!(err, PayError::WalletNotFound(_)),
503 "expected WalletNotFound, got: {err}"
504 );
505 }
506
507 #[cfg(feature = "redb")]
508 #[test]
509 fn list_wallets_filter_by_network() {
510 let tmp = tempfile::tempdir().unwrap();
511 let dir = tmp.path().to_str().unwrap();
512
513 let cashu = WalletMetadata {
514 id: "w_cashu001".to_string(),
515 network: Network::Cashu,
516 label: None,
517 mint_url: None,
518 sol_rpc_endpoints: None,
519 evm_rpc_endpoints: None,
520 evm_chain_id: None,
521 seed_secret: None,
522 backend: None,
523 btc_esplora_url: None,
524 btc_network: None,
525 btc_address_type: None,
526 btc_core_url: None,
527 btc_core_auth_secret: None,
528 btc_electrum_url: None,
529 custom_tokens: None,
530 created_at_epoch_s: 1,
531 error: None,
532 };
533 let ln = WalletMetadata {
534 id: "w_ln000001".to_string(),
535 network: Network::Ln,
536 label: None,
537 mint_url: None,
538 sol_rpc_endpoints: None,
539 evm_rpc_endpoints: None,
540 evm_chain_id: None,
541 seed_secret: None,
542 backend: Some("nwc".to_string()),
543 btc_esplora_url: None,
544 btc_network: None,
545 btc_address_type: None,
546 btc_core_url: None,
547 btc_core_auth_secret: None,
548 btc_electrum_url: None,
549 custom_tokens: None,
550 created_at_epoch_s: 2,
551 error: None,
552 };
553 save_wallet_metadata(dir, &cashu).unwrap();
554 save_wallet_metadata(dir, &ln).unwrap();
555
556 let all = list_wallet_metadata(dir, None).unwrap();
557 assert_eq!(all.len(), 2);
558
559 let only_cashu = list_wallet_metadata(dir, Some(Network::Cashu)).unwrap();
560 assert_eq!(only_cashu.len(), 1);
561 assert_eq!(only_cashu[0].id, "w_cashu001");
562
563 let only_ln = list_wallet_metadata(dir, Some(Network::Ln)).unwrap();
564 assert_eq!(only_ln.len(), 1);
565 assert_eq!(only_ln[0].id, "w_ln000001");
566 }
567
568 #[cfg(feature = "redb")]
569 #[test]
570 fn list_wallets_empty_dir() {
571 let tmp = tempfile::tempdir().unwrap();
572 let dir = tmp.path().to_str().unwrap();
573 let result = list_wallet_metadata(dir, None).unwrap();
574 assert!(result.is_empty());
575 }
576
577 #[cfg(feature = "redb")]
578 #[test]
579 fn delete_wallet_removes_wallet_dir_and_catalog_entry() {
580 let tmp = tempfile::tempdir().unwrap();
581 let dir = tmp.path().to_str().unwrap();
582 let meta = WalletMetadata {
583 id: "w_del001".to_string(),
584 network: Network::Cashu,
585 label: None,
586 mint_url: Some("https://mint.example".to_string()),
587 sol_rpc_endpoints: None,
588 evm_rpc_endpoints: None,
589 evm_chain_id: None,
590 seed_secret: Some("seed".to_string()),
591 backend: None,
592 btc_esplora_url: None,
593 btc_network: None,
594 btc_address_type: None,
595 btc_core_url: None,
596 btc_core_auth_secret: None,
597 btc_electrum_url: None,
598 custom_tokens: None,
599 created_at_epoch_s: 1,
600 error: None,
601 };
602 save_wallet_metadata(dir, &meta).unwrap();
603 let wallet_dir = wallet_directory_path(dir, &meta.id).unwrap();
604 assert!(wallet_dir.exists());
605
606 delete_wallet_metadata(dir, &meta.id).unwrap();
607
608 assert!(load_wallet_metadata(dir, &meta.id).is_err());
609 assert!(!wallet_dir.exists());
610 }
611}