1use ethers::types::U256;
4use serde::{Deserialize, Serialize};
5use std::path::Path;
6use tracing::{debug, info};
7
8use crate::merkle_tree::LocalMerkleTree;
9use crate::utxo_store::{OwnedNote, UtxoStore};
10
11#[derive(Debug, Serialize, Deserialize)]
12struct UtxoSnapshot {
13 notes: Vec<(String, OwnedNote)>,
14 spent_nullifiers: Vec<String>,
15 nullifier_to_commitment: Vec<(String, String)>,
16}
17
18#[derive(Debug, Serialize, Deserialize)]
19pub struct WalletState {
20 version: u32,
21 pub last_synced_block: u64,
22 utxo_snapshot: UtxoSnapshot,
23 merkle_leaves: Vec<String>,
24}
25
26const CURRENT_VERSION: u32 = 1;
27
28fn u256_to_hex(v: &U256) -> String {
29 format!("{v:#066x}")
30}
31
32fn hex_to_u256(s: &str) -> Result<U256, PersistenceError> {
33 U256::from_str_radix(s.strip_prefix("0x").unwrap_or(s), 16)
34 .map_err(|e| PersistenceError::Deserialization(format!("invalid U256 hex '{s}': {e}")))
35}
36
37#[derive(Debug, thiserror::Error)]
38pub enum PersistenceError {
39 #[error("IO error: {0}")]
40 Io(#[from] std::io::Error),
41 #[error("JSON serialization error: {0}")]
42 Serialization(String),
43 #[error("JSON deserialization error: {0}")]
44 Deserialization(String),
45 #[error("Version mismatch: file version {file} > supported {supported}")]
46 VersionMismatch { file: u32, supported: u32 },
47}
48
49impl WalletState {
50 #[must_use]
51 pub fn capture(utxos: &UtxoStore, tree: &LocalMerkleTree, last_synced_block: u64) -> Self {
52 let notes: Vec<_> = utxos
53 .get_unspent()
54 .into_iter()
55 .map(|n| (u256_to_hex(&n.commitment), n.clone()))
56 .collect();
57
58 let spent_nullifiers: Vec<_> = utxos.spent_nullifiers_iter().map(u256_to_hex).collect();
59
60 let nullifier_to_commitment: Vec<_> = utxos
61 .nullifier_map_iter()
62 .map(|(k, v)| (u256_to_hex(k), u256_to_hex(v)))
63 .collect();
64
65 let merkle_leaves: Vec<_> = tree.leaves().iter().map(u256_to_hex).collect();
66
67 Self {
68 version: CURRENT_VERSION,
69 last_synced_block,
70 utxo_snapshot: UtxoSnapshot {
71 notes,
72 spent_nullifiers,
73 nullifier_to_commitment,
74 },
75 merkle_leaves,
76 }
77 }
78
79 pub fn restore(self) -> Result<(UtxoStore, LocalMerkleTree, u64), PersistenceError> {
80 if self.version > CURRENT_VERSION {
81 return Err(PersistenceError::VersionMismatch {
82 file: self.version,
83 supported: CURRENT_VERSION,
84 });
85 }
86
87 let mut utxos = UtxoStore::new();
88 for (commitment_hex, note) in &self.utxo_snapshot.notes {
89 let _commitment = hex_to_u256(commitment_hex)?;
90 let nullifier_hash = self
92 .utxo_snapshot
93 .nullifier_to_commitment
94 .iter()
95 .find(|(_, c)| c == commitment_hex)
96 .map(|(n, _)| hex_to_u256(n))
97 .transpose()?
98 .unwrap_or(U256::zero());
99 utxos.add_note(note.clone(), nullifier_hash);
100 }
101 for nullifier_hex in &self.utxo_snapshot.spent_nullifiers {
102 let nullifier_hash = hex_to_u256(nullifier_hex)?;
103 utxos.mark_spent(nullifier_hash);
104 }
105
106 let mut tree = LocalMerkleTree::new();
107 let leaves: Vec<U256> = self
108 .merkle_leaves
109 .iter()
110 .map(|h| hex_to_u256(h))
111 .collect::<Result<Vec<_>, _>>()?;
112 tree.load_from_leaves(&leaves);
113
114 info!(
115 notes = utxos.count(),
116 leaves = tree.size(),
117 block = self.last_synced_block,
118 "Wallet state restored from snapshot"
119 );
120
121 Ok((utxos, tree, self.last_synced_block))
122 }
123
124 pub fn save_to_file(&self, path: &Path) -> Result<(), PersistenceError> {
125 let json = serde_json::to_string_pretty(self)
126 .map_err(|e| PersistenceError::Serialization(e.to_string()))?;
127
128 let tmp_path = path.with_extension("tmp");
129 std::fs::write(&tmp_path, json)?;
130 std::fs::rename(&tmp_path, path)?;
131
132 debug!(
133 path = %path.display(),
134 notes = self.utxo_snapshot.notes.len(),
135 leaves = self.merkle_leaves.len(),
136 block = self.last_synced_block,
137 "Wallet state saved"
138 );
139 Ok(())
140 }
141
142 pub fn load_from_file(path: &Path) -> Result<Self, PersistenceError> {
143 let json = std::fs::read_to_string(path)?;
144 let state: Self = serde_json::from_str(&json)
145 .map_err(|e| PersistenceError::Deserialization(e.to_string()))?;
146
147 if state.version > CURRENT_VERSION {
148 return Err(PersistenceError::VersionMismatch {
149 file: state.version,
150 supported: CURRENT_VERSION,
151 });
152 }
153
154 info!(
155 path = %path.display(),
156 version = state.version,
157 block = state.last_synced_block,
158 "Wallet state loaded"
159 );
160 Ok(state)
161 }
162}
163
164pub fn save_wallet_state(
165 path: &Path,
166 utxos: &UtxoStore,
167 tree: &LocalMerkleTree,
168 last_synced_block: u64,
169) -> Result<(), PersistenceError> {
170 let state = WalletState::capture(utxos, tree, last_synced_block);
171 state.save_to_file(path)
172}
173
174pub fn load_wallet_state(
176 path: &Path,
177) -> Result<Option<(UtxoStore, LocalMerkleTree, u64)>, PersistenceError> {
178 if !path.exists() {
179 debug!(path = %path.display(), "No wallet state file found, starting fresh");
180 return Ok(None);
181 }
182
183 let state = WalletState::load_from_file(path)?;
184 let (utxos, tree, block) = state.restore()?;
185 Ok(Some((utxos, tree, block)))
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191 use crate::proof_inputs::NotePlaintext;
192
193 fn test_note(value: u64, commitment: U256, leaf_index: u64) -> OwnedNote {
194 OwnedNote {
195 plaintext: NotePlaintext {
196 value: U256::from(value),
197 asset_id: U256::from(1),
198 secret: U256::from(42),
199 nullifier: U256::from(leaf_index + 1000),
200 timelock: U256::zero(),
201 hashlock: U256::zero(),
202 },
203 commitment,
204 leaf_index,
205 spending_secret: U256::from(leaf_index + 2000),
206 is_transfer: false,
207 received_block: 100,
208 }
209 }
210
211 #[test]
212 fn test_roundtrip_empty_state() {
213 let utxos = UtxoStore::new();
214 let tree = LocalMerkleTree::new();
215
216 let state = WalletState::capture(&utxos, &tree, 0);
217 let (restored_utxos, restored_tree, block) = state.restore().unwrap();
218
219 assert_eq!(restored_utxos.count(), 0);
220 assert_eq!(restored_tree.size(), 0);
221 assert_eq!(block, 0);
222 }
223
224 #[test]
225 fn test_roundtrip_with_notes_and_leaves() {
226 let mut utxos = UtxoStore::new();
227 let mut tree = LocalMerkleTree::new();
228
229 let note1 = test_note(100, U256::from(1), 0);
231 let note2 = test_note(200, U256::from(2), 1);
232 utxos.add_note(note1, U256::from(5001));
233 utxos.add_note(note2, U256::from(5002));
234
235 tree.insert(U256::from(1));
237 tree.insert(U256::from(2));
238 tree.insert(U256::from(3));
239
240 let original_root = tree.root();
241 let original_balance = utxos.count();
242
243 let state = WalletState::capture(&utxos, &tree, 42);
245 let (restored_utxos, restored_tree, block) = state.restore().unwrap();
246
247 assert_eq!(restored_utxos.count(), original_balance);
248 assert_eq!(restored_tree.root(), original_root);
249 assert_eq!(restored_tree.size(), 3);
250 assert_eq!(block, 42);
251 }
252
253 #[test]
254 fn test_roundtrip_with_spent_nullifiers() {
255 let mut utxos = UtxoStore::new();
256 let tree = LocalMerkleTree::new();
257
258 let note = test_note(500, U256::from(10), 0);
259 let nullifier_hash = U256::from(9999);
260 utxos.add_note(note, nullifier_hash);
261 utxos.mark_spent(nullifier_hash);
262
263 assert_eq!(utxos.count(), 0);
264 assert!(utxos.is_spent(&nullifier_hash));
265
266 let state = WalletState::capture(&utxos, &tree, 100);
267 let (restored_utxos, _, _) = state.restore().unwrap();
268
269 assert_eq!(restored_utxos.count(), 0);
270 assert!(restored_utxos.is_spent(&nullifier_hash));
271 }
272
273 #[test]
274 fn test_file_roundtrip() {
275 let mut utxos = UtxoStore::new();
276 let mut tree = LocalMerkleTree::new();
277
278 utxos.add_note(test_note(100, U256::from(1), 0), U256::from(5001));
279 tree.insert(U256::from(1));
280
281 let dir = std::env::temp_dir().join("nox_test_persistence");
282 std::fs::create_dir_all(&dir).unwrap();
283 let path = dir.join("test_state.json");
284
285 save_wallet_state(&path, &utxos, &tree, 50).unwrap();
287 assert!(path.exists());
288
289 let loaded = load_wallet_state(&path).unwrap();
291 assert!(loaded.is_some());
292 let (r_utxos, r_tree, r_block) = loaded.unwrap();
293 assert_eq!(r_utxos.count(), 1);
294 assert_eq!(r_tree.root(), tree.root());
295 assert_eq!(r_block, 50);
296
297 std::fs::remove_dir_all(&dir).ok();
299 }
300
301 #[test]
302 fn test_load_nonexistent_returns_none() {
303 let path = Path::new("/tmp/nox_test_nonexistent_state.json");
304 let result = load_wallet_state(path).unwrap();
305 assert!(result.is_none());
306 }
307
308 #[test]
309 fn test_version_mismatch() {
310 let json = r#"{"version": 999, "last_synced_block": 0, "utxo_snapshot": {"notes": [], "spent_nullifiers": [], "nullifier_to_commitment": []}, "merkle_leaves": []}"#;
311 let state: WalletState = serde_json::from_str(json).unwrap();
312 let result = state.restore();
313 assert!(result.is_err());
314 assert!(matches!(
315 result.unwrap_err(),
316 PersistenceError::VersionMismatch { .. }
317 ));
318 }
319
320 #[test]
321 fn test_pending_spends_not_persisted() {
322 let mut utxos = UtxoStore::new();
323 let tree = LocalMerkleTree::new();
324
325 let note = test_note(100, U256::from(1), 0);
326 utxos.add_note(note, U256::from(5001));
327 utxos.mark_pending_spend(&U256::from(1));
328 assert!(utxos.is_pending_spend(&U256::from(1)));
329
330 let state = WalletState::capture(&utxos, &tree, 10);
331 let (restored_utxos, _, _) = state.restore().unwrap();
332
333 assert!(!restored_utxos.is_pending_spend(&U256::from(1)));
334 assert_eq!(restored_utxos.count(), 1);
335 }
336}