Skip to main content

abtc_adapters/wallet/
file_store.rs

1//! File-Based Wallet Store Implementation
2//!
3//! Provides JSON file persistence for wallet state. Uses atomic writes
4//! (temp file + rename) to prevent corruption. File permissions are set
5//! to 0o600 (owner read/write only) to protect private keys.
6
7use abtc_ports::wallet::store::{WalletKeyEntry, WalletSnapshot, WalletStore, WalletUtxoEntry};
8use async_trait::async_trait;
9use serde::{Deserialize, Serialize};
10use std::path::{Path, PathBuf};
11
12/// JSON-serializable representation of a wallet snapshot.
13///
14/// This mirrors `WalletSnapshot` but with serde derives for JSON encoding.
15/// We keep serde out of the port types themselves.
16#[derive(Serialize, Deserialize)]
17struct WalletFileData {
18    version: u32,
19    mainnet: bool,
20    address_type: String,
21    key_counter: u64,
22    keys: Vec<KeyFileEntry>,
23    utxos: Vec<UtxoFileEntry>,
24}
25
26#[derive(Serialize, Deserialize)]
27struct KeyFileEntry {
28    address: String,
29    wif: String,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    label: Option<String>,
32}
33
34#[derive(Serialize, Deserialize)]
35struct UtxoFileEntry {
36    txid: String,
37    vout: u32,
38    amount_sat: i64,
39    script_pubkey_hex: String,
40    confirmations: u32,
41    is_coinbase: bool,
42}
43
44/// File-based wallet store using JSON format.
45///
46/// Saves wallet state to a JSON file with atomic writes and restricted
47/// file permissions. Suitable for development and testing; production
48/// wallets should use encrypted storage.
49pub struct FileBasedWalletStore {
50    path: PathBuf,
51}
52
53impl FileBasedWalletStore {
54    /// Create a new file-based wallet store at the given path.
55    pub fn new<P: AsRef<Path>>(path: P) -> Self {
56        FileBasedWalletStore {
57            path: path.as_ref().to_path_buf(),
58        }
59    }
60
61    /// Get the file path.
62    pub fn path(&self) -> &Path {
63        &self.path
64    }
65
66    /// Convert a port-layer snapshot to a file-layer data struct.
67    fn to_file_data(snapshot: &WalletSnapshot) -> WalletFileData {
68        WalletFileData {
69            version: snapshot.version,
70            mainnet: snapshot.mainnet,
71            address_type: snapshot.address_type.clone(),
72            key_counter: snapshot.key_counter,
73            keys: snapshot
74                .keys
75                .iter()
76                .map(|k| KeyFileEntry {
77                    address: k.address.clone(),
78                    wif: k.wif.clone(),
79                    label: k.label.clone(),
80                })
81                .collect(),
82            utxos: snapshot
83                .utxos
84                .iter()
85                .map(|u| UtxoFileEntry {
86                    txid: u.txid_hex.clone(),
87                    vout: u.vout,
88                    amount_sat: u.amount_sat,
89                    script_pubkey_hex: u.script_pubkey_hex.clone(),
90                    confirmations: u.confirmations,
91                    is_coinbase: u.is_coinbase,
92                })
93                .collect(),
94        }
95    }
96
97    /// Convert a file-layer data struct back to a port-layer snapshot.
98    fn from_file_data(data: WalletFileData) -> WalletSnapshot {
99        WalletSnapshot {
100            version: data.version,
101            mainnet: data.mainnet,
102            address_type: data.address_type,
103            key_counter: data.key_counter,
104            keys: data
105                .keys
106                .into_iter()
107                .map(|k| WalletKeyEntry {
108                    address: k.address,
109                    wif: k.wif,
110                    label: k.label,
111                })
112                .collect(),
113            utxos: data
114                .utxos
115                .into_iter()
116                .map(|u| WalletUtxoEntry {
117                    txid_hex: u.txid,
118                    vout: u.vout,
119                    amount_sat: u.amount_sat,
120                    script_pubkey_hex: u.script_pubkey_hex,
121                    confirmations: u.confirmations,
122                    is_coinbase: u.is_coinbase,
123                })
124                .collect(),
125        }
126    }
127}
128
129#[async_trait]
130impl WalletStore for FileBasedWalletStore {
131    async fn save(
132        &self,
133        snapshot: &WalletSnapshot,
134    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
135        let data = Self::to_file_data(snapshot);
136        let json = serde_json::to_string_pretty(&data)
137            .map_err(|e| format!("JSON serialization failed: {}", e))?;
138
139        // Ensure parent directory exists
140        if let Some(parent) = self.path.parent() {
141            if !parent.exists() {
142                tokio::fs::create_dir_all(parent).await.map_err(|e| {
143                    format!("failed to create directory {}: {}", parent.display(), e)
144                })?;
145            }
146        }
147
148        // Atomic write: write to temp file, then rename
149        let temp_path = self.path.with_extension("tmp");
150        tokio::fs::write(&temp_path, json.as_bytes())
151            .await
152            .map_err(|e| format!("failed to write temp file {}: {}", temp_path.display(), e))?;
153
154        // Set restrictive permissions (Unix only) — 0o600 = owner read/write
155        #[cfg(unix)]
156        {
157            use std::os::unix::fs::PermissionsExt;
158            let perms = std::fs::Permissions::from_mode(0o600);
159            tokio::fs::set_permissions(&temp_path, perms)
160                .await
161                .map_err(|e| format!("failed to set permissions: {}", e))?;
162        }
163
164        // Atomic rename
165        tokio::fs::rename(&temp_path, &self.path)
166            .await
167            .map_err(|e| {
168                format!(
169                    "failed to rename {} → {}: {}",
170                    temp_path.display(),
171                    self.path.display(),
172                    e
173                )
174            })?;
175
176        tracing::debug!("Wallet state saved to {}", self.path.display());
177        Ok(())
178    }
179
180    async fn load(
181        &self,
182    ) -> Result<Option<WalletSnapshot>, Box<dyn std::error::Error + Send + Sync>> {
183        // Missing file is not an error — it means first run
184        if !self.path.exists() {
185            return Ok(None);
186        }
187
188        let contents = tokio::fs::read_to_string(&self.path)
189            .await
190            .map_err(|e| format!("failed to read wallet file {}: {}", self.path.display(), e))?;
191
192        let data: WalletFileData = serde_json::from_str(&contents)
193            .map_err(|e| format!("failed to parse wallet file {}: {}", self.path.display(), e))?;
194
195        // Version check for forward compatibility
196        if data.version != 1 {
197            return Err(format!(
198                "unsupported wallet file version {} (expected 1)",
199                data.version
200            )
201            .into());
202        }
203
204        tracing::debug!("Wallet state loaded from {}", self.path.display());
205        Ok(Some(Self::from_file_data(data)))
206    }
207
208    async fn delete(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
209        if self.path.exists() {
210            tokio::fs::remove_file(&self.path).await.map_err(|e| {
211                format!(
212                    "failed to delete wallet file {}: {}",
213                    self.path.display(),
214                    e
215                )
216            })?;
217            tracing::debug!("Wallet file deleted: {}", self.path.display());
218        }
219        Ok(())
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use std::path::PathBuf;
227
228    use std::sync::atomic::{AtomicU64, Ordering};
229
230    /// Atomic counter to guarantee unique temp paths even when tests run
231    /// concurrently and share the same nanosecond timestamp.
232    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
233
234    fn temp_wallet_path() -> PathBuf {
235        let mut path = std::env::temp_dir();
236        let ts: u64 = std::time::SystemTime::now()
237            .duration_since(std::time::UNIX_EPOCH)
238            .unwrap()
239            .as_nanos() as u64;
240        let seq = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
241        path.push(format!("test_wallet_{}_{}.json", ts, seq));
242        path
243    }
244
245    fn sample_snapshot() -> WalletSnapshot {
246        WalletSnapshot {
247            version: 1,
248            mainnet: true,
249            address_type: "p2wpkh".to_string(),
250            key_counter: 3,
251            keys: vec![
252                WalletKeyEntry {
253                    address: "bc1qtest1".to_string(),
254                    wif: "L1aW4aubDFB7yfras2S1mN3bqg9nwySY8nkoLmJebSLD5BWv3ENZ".to_string(),
255                    label: Some("first".to_string()),
256                },
257                WalletKeyEntry {
258                    address: "bc1qtest2".to_string(),
259                    wif: "KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn".to_string(),
260                    label: None,
261                },
262            ],
263            utxos: vec![WalletUtxoEntry {
264                txid_hex: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
265                    .to_string(),
266                vout: 0,
267                amount_sat: 100_000,
268                script_pubkey_hex: "0014abcdef1234567890abcdef1234567890abcdef12".to_string(),
269                confirmations: 6,
270                is_coinbase: false,
271            }],
272        }
273    }
274
275    #[tokio::test]
276    async fn test_save_and_load_roundtrip() {
277        let path = temp_wallet_path();
278        let store = FileBasedWalletStore::new(&path);
279        let snapshot = sample_snapshot();
280
281        // Save
282        store.save(&snapshot).await.unwrap();
283        assert!(path.exists());
284
285        // Load
286        let loaded = store.load().await.unwrap().expect("should load snapshot");
287        assert_eq!(loaded.version, 1);
288        assert_eq!(loaded.mainnet, true);
289        assert_eq!(loaded.address_type, "p2wpkh");
290        assert_eq!(loaded.key_counter, 3);
291        assert_eq!(loaded.keys.len(), 2);
292        assert_eq!(loaded.keys[0].address, snapshot.keys[0].address);
293        assert_eq!(loaded.keys[0].wif, snapshot.keys[0].wif);
294        assert_eq!(loaded.keys[0].label, Some("first".to_string()));
295        assert_eq!(loaded.keys[1].label, None);
296        assert_eq!(loaded.utxos.len(), 1);
297        assert_eq!(loaded.utxos[0].amount_sat, 100_000);
298        assert_eq!(loaded.utxos[0].confirmations, 6);
299
300        // Cleanup
301        let _ = tokio::fs::remove_file(&path).await;
302    }
303
304    #[tokio::test]
305    async fn test_load_missing_file_returns_none() {
306        let path = temp_wallet_path();
307        let store = FileBasedWalletStore::new(&path);
308
309        let result = store.load().await.unwrap();
310        assert!(result.is_none());
311    }
312
313    #[tokio::test]
314    async fn test_load_corrupt_json_returns_error() {
315        let path = temp_wallet_path();
316        tokio::fs::write(&path, b"not valid json{{{").await.unwrap();
317
318        let store = FileBasedWalletStore::new(&path);
319        let result = store.load().await;
320        assert!(result.is_err());
321
322        let _ = tokio::fs::remove_file(&path).await;
323    }
324
325    #[tokio::test]
326    async fn test_load_wrong_version_returns_error() {
327        let path = temp_wallet_path();
328        let bad_data = serde_json::json!({
329            "version": 99,
330            "mainnet": true,
331            "address_type": "p2wpkh",
332            "key_counter": 0,
333            "keys": [],
334            "utxos": []
335        });
336        tokio::fs::write(&path, serde_json::to_string(&bad_data).unwrap().as_bytes())
337            .await
338            .unwrap();
339
340        let store = FileBasedWalletStore::new(&path);
341        let result = store.load().await;
342        assert!(result.is_err());
343        assert!(result.unwrap_err().to_string().contains("version"));
344
345        let _ = tokio::fs::remove_file(&path).await;
346    }
347
348    #[tokio::test]
349    async fn test_delete_existing_file() {
350        let path = temp_wallet_path();
351        let store = FileBasedWalletStore::new(&path);
352        let snapshot = sample_snapshot();
353
354        store.save(&snapshot).await.unwrap();
355        assert!(path.exists());
356
357        store.delete().await.unwrap();
358        assert!(!path.exists());
359
360        // Load after delete should return None
361        let result = store.load().await.unwrap();
362        assert!(result.is_none());
363    }
364
365    #[tokio::test]
366    async fn test_delete_nonexistent_file_ok() {
367        let path = temp_wallet_path();
368        let store = FileBasedWalletStore::new(&path);
369
370        // Should not error
371        store.delete().await.unwrap();
372    }
373
374    #[tokio::test]
375    async fn test_save_creates_parent_directories() {
376        let mut path = std::env::temp_dir();
377        let id: u64 = std::time::SystemTime::now()
378            .duration_since(std::time::UNIX_EPOCH)
379            .unwrap()
380            .as_nanos() as u64;
381        path.push(format!("nested_{}", id));
382        path.push("subdir");
383        path.push("wallet.json");
384
385        let store = FileBasedWalletStore::new(&path);
386        let snapshot = sample_snapshot();
387
388        store.save(&snapshot).await.unwrap();
389        assert!(path.exists());
390
391        // Cleanup
392        let grandparent = path.parent().unwrap().parent().unwrap();
393        let _ = tokio::fs::remove_dir_all(grandparent).await;
394    }
395
396    #[tokio::test]
397    async fn test_save_overwrites_existing() {
398        let path = temp_wallet_path();
399        let store = FileBasedWalletStore::new(&path);
400
401        let mut snapshot1 = sample_snapshot();
402        snapshot1.key_counter = 1;
403        store.save(&snapshot1).await.unwrap();
404
405        let mut snapshot2 = sample_snapshot();
406        snapshot2.key_counter = 42;
407        store.save(&snapshot2).await.unwrap();
408
409        let loaded = store.load().await.unwrap().unwrap();
410        assert_eq!(loaded.key_counter, 42);
411
412        let _ = tokio::fs::remove_file(&path).await;
413    }
414
415    #[cfg(unix)]
416    #[tokio::test]
417    async fn test_file_permissions_0o600() {
418        use std::os::unix::fs::PermissionsExt;
419
420        let path = temp_wallet_path();
421        let store = FileBasedWalletStore::new(&path);
422        store.save(&sample_snapshot()).await.unwrap();
423
424        let metadata = tokio::fs::metadata(&path).await.unwrap();
425        let mode = metadata.permissions().mode() & 0o777;
426        assert_eq!(mode, 0o600, "wallet file should have 0o600 permissions");
427
428        let _ = tokio::fs::remove_file(&path).await;
429    }
430
431    #[tokio::test]
432    async fn test_empty_wallet_roundtrip() {
433        let path = temp_wallet_path();
434        let store = FileBasedWalletStore::new(&path);
435
436        let snapshot = WalletSnapshot {
437            version: 1,
438            mainnet: false,
439            address_type: "p2tr".to_string(),
440            key_counter: 0,
441            keys: vec![],
442            utxos: vec![],
443        };
444
445        store.save(&snapshot).await.unwrap();
446        let loaded = store.load().await.unwrap().unwrap();
447
448        assert_eq!(loaded.mainnet, false);
449        assert_eq!(loaded.address_type, "p2tr");
450        assert_eq!(loaded.key_counter, 0);
451        assert!(loaded.keys.is_empty());
452        assert!(loaded.utxos.is_empty());
453
454        let _ = tokio::fs::remove_file(&path).await;
455    }
456}