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 = match compact_action_from_scan(action) {
112            Some(c) => c,
113            None => return None,
114        };
115
116        // trial decrypt
117        let (note, recipient) = try_compact_note_decryption(&domain, &self.prepared_ivk, &compact)?;
118
119        Some(DecryptedNote {
120            value: note.value().inner(),
121            note,
122            recipient,
123            nullifier: action.nullifier,
124            cmx: action.cmx,
125            block_height: action.block_height,
126            position_in_block: action.position_in_block,
127        })
128    }
129
130    /// scan multiple actions sequentially
131    /// suitable for WASM (no threading) or when parallelism not needed
132    pub fn scan_sequential(&self, actions: &[ScanAction]) -> Vec<DecryptedNote> {
133        actions.iter().filter_map(|a| self.try_decrypt(a)).collect()
134    }
135
136    /// scan multiple actions in parallel using rayon
137    /// only available on native (not WASM)
138    #[cfg(feature = "parallel")]
139    pub fn scan_parallel(&self, actions: &[ScanAction]) -> Vec<DecryptedNote> {
140        use rayon::prelude::*;
141        actions
142            .par_iter()
143            .filter_map(|a| self.try_decrypt(a))
144            .collect()
145    }
146
147    /// scan actions with automatic parallelism selection
148    /// uses parallel on native, sequential on WASM
149    pub fn scan(&self, actions: &[ScanAction]) -> Vec<DecryptedNote> {
150        #[cfg(feature = "parallel")]
151        {
152            self.scan_parallel(actions)
153        }
154        #[cfg(not(feature = "parallel"))]
155        {
156            self.scan_sequential(actions)
157        }
158    }
159
160    /// scan in chunks for progress reporting
161    /// callback receives (chunk_index, total_chunks, found_notes)
162    pub fn scan_with_progress<F>(
163        &self,
164        actions: &[ScanAction],
165        chunk_size: usize,
166        mut on_progress: F,
167    ) -> Vec<DecryptedNote>
168    where
169        F: FnMut(usize, usize, &[DecryptedNote]),
170    {
171        let total_chunks = (actions.len() + chunk_size - 1) / chunk_size;
172        let mut all_notes = Vec::new();
173
174        for (i, chunk) in actions.chunks(chunk_size).enumerate() {
175            let notes = self.scan(chunk);
176            on_progress(i, total_chunks, &notes);
177            all_notes.extend(notes);
178        }
179
180        all_notes
181    }
182}
183
184/// convert ScanAction to orchard CompactAction
185fn compact_action_from_scan(action: &ScanAction) -> Option<CompactAction> {
186    // parse nullifier using Option::from for CtOption
187    let nullifier = Option::from(Nullifier::from_bytes(&action.nullifier))?;
188
189    // parse cmx (extracted note commitment)
190    let cmx = Option::from(ExtractedNoteCommitment::from_bytes(&action.cmx))?;
191
192    // ephemeral key is just bytes - EphemeralKeyBytes wraps [u8; 32]
193    let epk = EphemeralKeyBytes::from(action.ephemeral_key);
194
195    // enc_ciphertext should be COMPACT_NOTE_SIZE (52) bytes for compact actions
196    if action.enc_ciphertext.len() < 52 {
197        return None;
198    }
199
200    Some(CompactAction::from_parts(
201        nullifier,
202        cmx,
203        epk,
204        action.enc_ciphertext[..52].try_into().ok()?,
205    ))
206}
207
208/// batch scanner for processing multiple blocks efficiently
209pub struct BatchScanner {
210    scanner: Scanner,
211    /// found notes across all scanned blocks
212    pub notes: Vec<DecryptedNote>,
213    /// nullifiers we've seen (for detecting spends)
214    pub seen_nullifiers: std::collections::HashSet<[u8; 32]>,
215    /// last scanned height
216    pub last_height: u32,
217}
218
219impl BatchScanner {
220    pub fn new(ivk: &IncomingViewingKey) -> Self {
221        Self {
222            scanner: Scanner::new(ivk),
223            notes: Vec::new(),
224            seen_nullifiers: std::collections::HashSet::new(),
225            last_height: 0,
226        }
227    }
228
229    pub fn from_fvk(fvk: &FullViewingKey) -> Self {
230        Self::new(&fvk.to_ivk(Scope::External))
231    }
232
233    /// scan a batch of actions from a block
234    pub fn scan_block(&mut self, height: u32, actions: &[ScanAction]) {
235        // check for spent notes first
236        for action in actions {
237            self.seen_nullifiers.insert(action.nullifier);
238        }
239
240        // trial decrypt
241        let found = self.scanner.scan(actions);
242        self.notes.extend(found);
243        self.last_height = height;
244    }
245
246    /// get unspent balance
247    pub fn unspent_balance(&self) -> u64 {
248        self.notes
249            .iter()
250            .filter(|n| !self.seen_nullifiers.contains(&n.nullifier))
251            .map(|n| n.value)
252            .sum()
253    }
254
255    /// get spent balance
256    pub fn spent_balance(&self) -> u64 {
257        self.notes
258            .iter()
259            .filter(|n| self.seen_nullifiers.contains(&n.nullifier))
260            .map(|n| n.value)
261            .sum()
262    }
263
264    /// get unspent notes
265    pub fn unspent_notes(&self) -> Vec<&DecryptedNote> {
266        self.notes
267            .iter()
268            .filter(|n| !self.seen_nullifiers.contains(&n.nullifier))
269            .collect()
270    }
271}
272
273/// detection hint for fast filtering (optional FMD-like optimization)
274/// Server can compute these without knowing the viewing key
275#[derive(Debug, Clone)]
276pub struct DetectionHint {
277    /// diversified tag (from address)
278    pub tag: [u8; 4],
279    /// false positive rate bucket
280    pub bucket: u8,
281}
282
283impl DetectionHint {
284    /// check if action might be for us based on hint
285    /// false positives possible, false negatives never
286    pub fn might_match(&self, action_tag: &[u8; 4]) -> bool {
287        // simple prefix match for now
288        // real FMD would use clamped multiplication
289        self.tag[..2] == action_tag[..2]
290    }
291}
292
293/// hint generator from viewing key
294pub struct HintGenerator {
295    /// diversifier key for generating hints
296    _dk: [u8; 32],
297}
298
299impl HintGenerator {
300    /// create from full viewing key
301    pub fn from_fvk(_fvk: &FullViewingKey) -> Self {
302        // TODO: extract diversifier key from fvk
303        Self { _dk: [0u8; 32] }
304    }
305
306    /// generate detection hints for a diversifier index range
307    pub fn hints_for_range(&self, _start: u32, _count: u32) -> Vec<DetectionHint> {
308        // TODO: generate actual hints
309        // For now return empty - full scan fallback
310        Vec::new()
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn test_scan_action_size() {
320        // compact action should be: nullifier(32) + cmx(32) + epk(32) + enc(52) = 148 bytes
321        let action = ScanAction {
322            nullifier: [0u8; 32],
323            cmx: [0u8; 32],
324            ephemeral_key: [0u8; 32],
325            enc_ciphertext: vec![0u8; 52],
326            block_height: 0,
327            position_in_block: 0,
328        };
329
330        let total_size = action.nullifier.len()
331            + action.cmx.len()
332            + action.ephemeral_key.len()
333            + action.enc_ciphertext.len();
334        assert_eq!(total_size, 148);
335    }
336
337    #[test]
338    fn test_batch_scanner_balance() {
339        // would need actual keys to test decryption
340        // just verify balance tracking works
341        let fvk = test_fvk();
342        let scanner = BatchScanner::from_fvk(&fvk);
343        assert_eq!(scanner.unspent_balance(), 0);
344        assert_eq!(scanner.spent_balance(), 0);
345    }
346
347    fn test_fvk() -> FullViewingKey {
348        use orchard::keys::SpendingKey;
349        let sk = SpendingKey::from_bytes([42u8; 32]).unwrap();
350        FullViewingKey::from(&sk)
351    }
352}
353
354// ============================================================================
355// WASM BINDINGS
356// ============================================================================
357
358/// Initialize rayon thread pool for WASM parallel execution
359/// Must be called once before using parallel scanning in WASM
360///
361/// Usage from JavaScript:
362/// ```javascript
363/// import init, { initThreadPool } from './zync_core.js';
364/// await init();
365/// await initThreadPool(navigator.hardwareConcurrency);
366/// ```
367#[cfg(feature = "wasm-parallel")]
368#[wasm_bindgen]
369pub fn init_thread_pool(num_threads: usize) -> js_sys::Promise {
370    // wasm-bindgen-rayon provides this function to set up Web Workers
371    wasm_bindgen_rayon::init_thread_pool(num_threads)
372}
373
374/// Initialize panic hook for better error messages in browser console
375#[cfg(feature = "wasm")]
376#[wasm_bindgen(start)]
377pub fn wasm_init() {
378    console_error_panic_hook::set_once();
379}
380
381// Re-export wasm-bindgen-rayon's pub_thread_pool_size for JS access
382#[cfg(feature = "wasm-parallel")]
383pub use wasm_bindgen_rayon::init_thread_pool as _rayon_init;