Skip to main content

zync_core/
scanner.rs

1//! WASM-compatible parallel note scanner for Orchard
2//!
3//! This module provides trial decryption of Orchard notes using PreparedIncomingViewingKey
4//! from orchard 0.7. It's designed to work in both native and WASM environments:
5//!
6//! - Native: Uses rayon for parallel scanning across CPU cores
7//! - WASM: Uses wasm-bindgen-rayon for multi-threaded scanning via Web Workers
8//!   (requires SharedArrayBuffer + CORS headers)
9//!
10//! Performance characteristics:
11//! - Trial decryption: ~3μs per action (invalid), ~50μs per action (valid)
12//! - Full chain scan (1.46M blocks): ~5-15 min single-threaded
13//! - With parallelism: scales linearly with cores (8 cores = 8x speedup)
14//!
15//! Build for WASM with parallelism:
16//! ```bash
17//! RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals' \
18//!   cargo build --target wasm32-unknown-unknown --features wasm-parallel --no-default-features
19//! ```
20//!
21//! Usage:
22//! ```ignore
23//! let ivk = fvk.to_ivk(Scope::External);
24//! let scanner = Scanner::new(&ivk);
25//! let results = scanner.scan_actions(&compact_actions);
26//! ```
27
28use orchard::{
29    keys::{FullViewingKey, IncomingViewingKey, PreparedIncomingViewingKey, Scope},
30    note::{ExtractedNoteCommitment, Note, Nullifier},
31    note_encryption::{CompactAction, OrchardDomain},
32    Address,
33};
34use subtle::CtOption;
35use zcash_note_encryption::{try_compact_note_decryption, EphemeralKeyBytes};
36
37#[cfg(feature = "wasm")]
38use wasm_bindgen::prelude::*;
39
40/// compact action for scanning (matches proto/zidecar wire format)
41#[derive(Debug, Clone)]
42pub struct ScanAction {
43    /// nullifier from action (32 bytes)
44    pub nullifier: [u8; 32],
45    /// note commitment (cmx, 32 bytes)
46    pub cmx: [u8; 32],
47    /// ephemeral public key (32 bytes)
48    pub ephemeral_key: [u8; 32],
49    /// encrypted note ciphertext (compact: 52 bytes)
50    pub enc_ciphertext: Vec<u8>,
51    /// block height where action appeared
52    pub block_height: u32,
53    /// position within block (for merkle path construction)
54    pub position_in_block: u32,
55}
56
57/// successfully decrypted note
58#[derive(Debug, Clone)]
59pub struct DecryptedNote {
60    /// the decrypted note
61    pub note: Note,
62    /// recipient address
63    pub recipient: Address,
64    /// note value in zatoshis
65    pub value: u64,
66    /// nullifier for spending
67    pub nullifier: [u8; 32],
68    /// note commitment
69    pub cmx: [u8; 32],
70    /// block height
71    pub block_height: u32,
72    /// position in block
73    pub position_in_block: u32,
74}
75
76/// scanner for trial decryption of Orchard notes
77///
78/// Thread-safe: can be cloned and used from multiple threads.
79/// For WASM, process in chunks via async.
80pub struct Scanner {
81    /// prepared ivk for fast decryption (expensive to create, cheap to use)
82    prepared_ivk: PreparedIncomingViewingKey,
83}
84
85impl Scanner {
86    /// create scanner from incoming viewing key
87    pub fn new(ivk: &IncomingViewingKey) -> Self {
88        Self {
89            prepared_ivk: PreparedIncomingViewingKey::new(ivk),
90        }
91    }
92
93    /// create scanner from full viewing key (external scope)
94    pub fn from_fvk(fvk: &FullViewingKey) -> Self {
95        Self::new(&fvk.to_ivk(Scope::External))
96    }
97
98    /// create scanner from full viewing key with specific scope
99    pub fn from_fvk_scoped(fvk: &FullViewingKey, scope: Scope) -> Self {
100        Self::new(&fvk.to_ivk(scope))
101    }
102
103    /// scan a single action, returning decrypted note if owned
104    pub fn try_decrypt(&self, action: &ScanAction) -> Option<DecryptedNote> {
105        // construct domain from nullifier
106        let nullifier: CtOption<Nullifier> = Nullifier::from_bytes(&action.nullifier);
107        let nullifier = Option::from(nullifier)?;
108        let domain = OrchardDomain::for_nullifier(nullifier);
109
110        // construct compact action for decryption
111        let compact = compact_action_from_scan(action)?;
112
113        // trial decrypt
114        let (note, recipient) = try_compact_note_decryption(&domain, &self.prepared_ivk, &compact)?;
115
116        // Verify note commitment: recompute cmx from decrypted note fields and check
117        // it matches the cmx the server sent. Without this, a malicious server can
118        // craft ciphertexts that decrypt to fake notes with arbitrary values.
119        let recomputed = ExtractedNoteCommitment::from(note.commitment());
120        if recomputed.to_bytes() != action.cmx {
121            return None;
122        }
123
124        Some(DecryptedNote {
125            value: note.value().inner(),
126            note,
127            recipient,
128            nullifier: action.nullifier,
129            cmx: action.cmx,
130            block_height: action.block_height,
131            position_in_block: action.position_in_block,
132        })
133    }
134
135    /// scan multiple actions sequentially
136    /// suitable for WASM (no threading) or when parallelism not needed
137    pub fn scan_sequential(&self, actions: &[ScanAction]) -> Vec<DecryptedNote> {
138        actions.iter().filter_map(|a| self.try_decrypt(a)).collect()
139    }
140
141    /// scan multiple actions in parallel using rayon
142    /// only available on native (not WASM)
143    #[cfg(feature = "parallel")]
144    pub fn scan_parallel(&self, actions: &[ScanAction]) -> Vec<DecryptedNote> {
145        use rayon::prelude::*;
146        actions
147            .par_iter()
148            .filter_map(|a| self.try_decrypt(a))
149            .collect()
150    }
151
152    /// scan actions with automatic parallelism selection
153    /// uses parallel on native, sequential on WASM
154    pub fn scan(&self, actions: &[ScanAction]) -> Vec<DecryptedNote> {
155        #[cfg(feature = "parallel")]
156        {
157            self.scan_parallel(actions)
158        }
159        #[cfg(not(feature = "parallel"))]
160        {
161            self.scan_sequential(actions)
162        }
163    }
164
165    /// scan in chunks for progress reporting
166    /// callback receives (chunk_index, total_chunks, found_notes)
167    pub fn scan_with_progress<F>(
168        &self,
169        actions: &[ScanAction],
170        chunk_size: usize,
171        mut on_progress: F,
172    ) -> Vec<DecryptedNote>
173    where
174        F: FnMut(usize, usize, &[DecryptedNote]),
175    {
176        let total_chunks = actions.len().div_ceil(chunk_size);
177        let mut all_notes = Vec::new();
178
179        for (i, chunk) in actions.chunks(chunk_size).enumerate() {
180            let notes = self.scan(chunk);
181            on_progress(i, total_chunks, &notes);
182            all_notes.extend(notes);
183        }
184
185        all_notes
186    }
187}
188
189/// convert ScanAction to orchard CompactAction
190fn compact_action_from_scan(action: &ScanAction) -> Option<CompactAction> {
191    // parse nullifier using Option::from for CtOption
192    let nullifier = Option::from(Nullifier::from_bytes(&action.nullifier))?;
193
194    // parse cmx (extracted note commitment)
195    let cmx = Option::from(ExtractedNoteCommitment::from_bytes(&action.cmx))?;
196
197    // ephemeral key is just bytes - EphemeralKeyBytes wraps [u8; 32]
198    let epk = EphemeralKeyBytes::from(action.ephemeral_key);
199
200    // enc_ciphertext should be COMPACT_NOTE_SIZE (52) bytes for compact actions
201    if action.enc_ciphertext.len() < 52 {
202        return None;
203    }
204
205    Some(CompactAction::from_parts(
206        nullifier,
207        cmx,
208        epk,
209        action.enc_ciphertext[..52].try_into().ok()?,
210    ))
211}
212
213/// batch scanner for processing multiple blocks efficiently
214pub struct BatchScanner {
215    scanner: Scanner,
216    /// found notes across all scanned blocks
217    pub notes: Vec<DecryptedNote>,
218    /// nullifiers we've seen (for detecting spends)
219    pub seen_nullifiers: std::collections::HashSet<[u8; 32]>,
220    /// last scanned height
221    pub last_height: u32,
222}
223
224impl BatchScanner {
225    pub fn new(ivk: &IncomingViewingKey) -> Self {
226        Self {
227            scanner: Scanner::new(ivk),
228            notes: Vec::new(),
229            seen_nullifiers: std::collections::HashSet::new(),
230            last_height: 0,
231        }
232    }
233
234    pub fn from_fvk(fvk: &FullViewingKey) -> Self {
235        Self::new(&fvk.to_ivk(Scope::External))
236    }
237
238    /// scan a batch of actions from a block
239    pub fn scan_block(&mut self, height: u32, actions: &[ScanAction]) {
240        // check for spent notes first
241        for action in actions {
242            self.seen_nullifiers.insert(action.nullifier);
243        }
244
245        // trial decrypt
246        let found = self.scanner.scan(actions);
247        self.notes.extend(found);
248        self.last_height = height;
249    }
250
251    /// get unspent balance
252    pub fn unspent_balance(&self) -> u64 {
253        self.notes
254            .iter()
255            .filter(|n| !self.seen_nullifiers.contains(&n.nullifier))
256            .map(|n| n.value)
257            .sum()
258    }
259
260    /// get spent balance
261    pub fn spent_balance(&self) -> u64 {
262        self.notes
263            .iter()
264            .filter(|n| self.seen_nullifiers.contains(&n.nullifier))
265            .map(|n| n.value)
266            .sum()
267    }
268
269    /// get unspent notes
270    pub fn unspent_notes(&self) -> Vec<&DecryptedNote> {
271        self.notes
272            .iter()
273            .filter(|n| !self.seen_nullifiers.contains(&n.nullifier))
274            .collect()
275    }
276}
277
278/// detection hint for fast filtering (optional FMD-like optimization)
279/// Server can compute these without knowing the viewing key
280#[derive(Debug, Clone)]
281pub struct DetectionHint {
282    /// diversified tag (from address)
283    pub tag: [u8; 4],
284    /// false positive rate bucket
285    pub bucket: u8,
286}
287
288impl DetectionHint {
289    /// check if action might be for us based on hint
290    /// false positives possible, false negatives never
291    pub fn might_match(&self, action_tag: &[u8; 4]) -> bool {
292        // simple prefix match for now
293        // real FMD would use clamped multiplication
294        self.tag[..2] == action_tag[..2]
295    }
296}
297
298/// hint generator from viewing key
299pub struct HintGenerator {
300    /// diversifier key for generating hints
301    _dk: [u8; 32],
302}
303
304impl HintGenerator {
305    /// create from full viewing key
306    pub fn from_fvk(_fvk: &FullViewingKey) -> Self {
307        // TODO: extract diversifier key from fvk
308        Self { _dk: [0u8; 32] }
309    }
310
311    /// generate detection hints for a diversifier index range
312    pub fn hints_for_range(&self, _start: u32, _count: u32) -> Vec<DetectionHint> {
313        // TODO: generate actual hints
314        // For now return empty - full scan fallback
315        Vec::new()
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn test_scan_action_size() {
325        // compact action should be: nullifier(32) + cmx(32) + epk(32) + enc(52) = 148 bytes
326        let action = ScanAction {
327            nullifier: [0u8; 32],
328            cmx: [0u8; 32],
329            ephemeral_key: [0u8; 32],
330            enc_ciphertext: vec![0u8; 52],
331            block_height: 0,
332            position_in_block: 0,
333        };
334
335        let total_size = action.nullifier.len()
336            + action.cmx.len()
337            + action.ephemeral_key.len()
338            + action.enc_ciphertext.len();
339        assert_eq!(total_size, 148);
340    }
341
342    #[test]
343    fn test_batch_scanner_balance() {
344        // would need actual keys to test decryption
345        // just verify balance tracking works
346        let fvk = test_fvk();
347        let scanner = BatchScanner::from_fvk(&fvk);
348        assert_eq!(scanner.unspent_balance(), 0);
349        assert_eq!(scanner.spent_balance(), 0);
350    }
351
352    fn test_fvk() -> FullViewingKey {
353        use orchard::keys::SpendingKey;
354        let sk = SpendingKey::from_bytes([42u8; 32]).unwrap();
355        FullViewingKey::from(&sk)
356    }
357}
358
359// ============================================================================
360// WASM BINDINGS
361// ============================================================================
362
363/// Initialize rayon thread pool for WASM parallel execution
364/// Must be called once before using parallel scanning in WASM
365///
366/// Usage from JavaScript:
367/// ```javascript
368/// import init, { initThreadPool } from './zync_core.js';
369/// await init();
370/// await initThreadPool(navigator.hardwareConcurrency);
371/// ```
372#[cfg(feature = "wasm-parallel")]
373#[wasm_bindgen]
374pub fn init_thread_pool(num_threads: usize) -> js_sys::Promise {
375    // wasm-bindgen-rayon provides this function to set up Web Workers
376    wasm_bindgen_rayon::init_thread_pool(num_threads)
377}
378
379/// Initialize panic hook for better error messages in browser console
380#[cfg(feature = "wasm")]
381#[wasm_bindgen(start)]
382pub fn wasm_init() {
383    console_error_panic_hook::set_once();
384}
385
386// Re-export wasm-bindgen-rayon's pub_thread_pool_size for JS access
387#[cfg(feature = "wasm-parallel")]
388pub use wasm_bindgen_rayon::init_thread_pool as _rayon_init;