Skip to main content

agent_first_pay/store/
wallet.rs

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// ═══════════════════════════════════════════
22// Shared types (always available)
23// ═══════════════════════════════════════════
24
25#[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
70// ═══════════════════════════════════════════
71// Shared helpers (always available)
72// ═══════════════════════════════════════════
73
74const 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
95/// Root path for all wallets: `{data_dir}/wallets/`
96fn 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// ═══════════════════════════════════════════
134// Redb-specific functions
135// ═══════════════════════════════════════════
136
137#[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    // catalog.redb: unified wallet index
172    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    // core.redb: per-wallet authoritative metadata
189    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    // Fast path: catalog
213    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    // Fallback: wallet core metadata
229    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    // Label fallback: if wallet_id doesn't start with "w_", try matching by label
252    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, // placeholder for corrupt entry
312                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    // Remove from catalog
347    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    // Remove wallet directory (core.redb + wallet-data/*)
367    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    // Search by label
387    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            // v0 → v1: stamp version (no data migration needed)
415            &|_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            // v0 → v1: no data migration, just stamp version
427            &|_db: &Database| Ok(()),
428        ],
429    )
430}
431
432// ═══════════════════════════════════════════
433// Tests
434// ═══════════════════════════════════════════
435
436#[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}