1use abtc_ports::wallet::store::{WalletKeyEntry, WalletSnapshot, WalletStore, WalletUtxoEntry};
8use async_trait::async_trait;
9use serde::{Deserialize, Serialize};
10use std::path::{Path, PathBuf};
11
12#[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
44pub struct FileBasedWalletStore {
50 path: PathBuf,
51}
52
53impl FileBasedWalletStore {
54 pub fn new<P: AsRef<Path>>(path: P) -> Self {
56 FileBasedWalletStore {
57 path: path.as_ref().to_path_buf(),
58 }
59 }
60
61 pub fn path(&self) -> &Path {
63 &self.path
64 }
65
66 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 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 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 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 #[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 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 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 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 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 store.save(&snapshot).await.unwrap();
283 assert!(path.exists());
284
285 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 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 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 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 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}