Skip to main content

darkpool_client/
note_processor.rs

1//! Processes blockchain events to recover notes belonging to this wallet.
2//! Path A (`NewNote`): deposits/change via ephemeral PK match.
3//! Path B (`NewMemo`): transfers via tag match + 3-party ECDH.
4
5use ethers::types::U256;
6use tracing::trace;
7
8use crate::crypto_helpers::{
9    decrypt_note_from_fields, derive_nullifier_path_a, derive_nullifier_path_b,
10    recipient_decrypt_3party,
11};
12use crate::key_repository::KeyRepository;
13use crate::proof_inputs::NotePlaintext;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum EventType {
17    NewNote,
18    NewMemo,
19}
20
21#[derive(Debug, Clone)]
22pub struct UnprocessedEvent {
23    pub event_type: EventType,
24    pub block_number: u64,
25    pub tx_hash: String,
26    pub leaf_index: u64,
27    pub commitment: U256,
28    pub epk_x: U256,
29    pub epk_y: U256,
30    pub packed_ciphertext: [U256; 7],
31    pub tag: Option<U256>,
32    pub intermediate_bob_x: Option<U256>,
33    pub intermediate_bob_y: Option<U256>,
34}
35
36#[derive(Debug, Clone)]
37pub struct WalletNote {
38    pub note: NotePlaintext,
39    pub commitment: U256,
40    pub leaf_index: u64,
41    pub nullifier: U256,
42    /// Path A: `ephemeral_sk`, Path B: `shared_secret`
43    pub spending_secret: U256,
44    pub is_transfer: bool,
45    pub derivation_index: u64,
46    pub spent: bool,
47}
48
49pub struct NoteProcessor<'a> {
50    key_repo: &'a KeyRepository,
51    compliance_pk: (U256, U256),
52}
53
54impl<'a> NoteProcessor<'a> {
55    #[must_use]
56    pub fn new(key_repo: &'a KeyRepository, compliance_pk: (U256, U256)) -> Self {
57        Self {
58            key_repo,
59            compliance_pk,
60        }
61    }
62
63    #[must_use]
64    pub fn process(&self, event: &UnprocessedEvent) -> Option<WalletNote> {
65        match event.event_type {
66            EventType::NewNote => self.process_new_note(event),
67            EventType::NewMemo => self.process_memo(event),
68        }
69    }
70
71    fn process_new_note(&self, event: &UnprocessedEvent) -> Option<WalletNote> {
72        let (ephemeral_sk, derivation_index) =
73            self.key_repo.try_match_deposit(event.epk_x, event.epk_y)?;
74
75        let note = match decrypt_note_from_fields(
76            &event.packed_ciphertext,
77            ephemeral_sk,
78            self.compliance_pk,
79        ) {
80            Ok(n) => n,
81            Err(e) => {
82                trace!(
83                    "NewNote decryption failed for leaf_index={}, block={}, tx={}: {:?}",
84                    event.leaf_index,
85                    event.block_number,
86                    event.tx_hash,
87                    e
88                );
89                return None;
90            }
91        };
92
93        let nullifier = derive_nullifier_path_a(note.nullifier);
94
95        Some(WalletNote {
96            note,
97            commitment: event.commitment,
98            leaf_index: event.leaf_index,
99            nullifier,
100            spending_secret: ephemeral_sk,
101            is_transfer: false,
102            derivation_index,
103            spent: false,
104        })
105    }
106
107    fn process_memo(&self, event: &UnprocessedEvent) -> Option<WalletNote> {
108        let tag = event.tag?;
109        let int_bob_x = event.intermediate_bob_x?;
110        let int_bob_y = event.intermediate_bob_y?;
111
112        let (recipient_sk_mod, derivation_index) = self.key_repo.try_match_transfer(tag)?;
113        let intermediate_point = (int_bob_x, int_bob_y);
114        let (note, shared_secret) = match recipient_decrypt_3party(
115            recipient_sk_mod,
116            intermediate_point,
117            &event.packed_ciphertext,
118        ) {
119            Ok((n, ss)) => (n, ss),
120            Err(e) => {
121                trace!(
122                    "NewMemo decryption failed for leaf_index={}, block={}, tx={}: {:?}",
123                    event.leaf_index,
124                    event.block_number,
125                    event.tx_hash,
126                    e
127                );
128                return None;
129            }
130        };
131
132        let nullifier = derive_nullifier_path_b(shared_secret, event.commitment, event.leaf_index);
133
134        Some(WalletNote {
135            note,
136            commitment: event.commitment,
137            leaf_index: event.leaf_index,
138            nullifier,
139            spending_secret: shared_secret,
140            is_transfer: true,
141            derivation_index,
142            spent: false,
143        })
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use crate::crypto_helpers::{
151        aes128_encrypt, encrypt_note_for_deposit_aes, fr_to_u256, kdf_to_aes_key_iv,
152        pack_ciphertext_to_fields, pack_note_plaintext, random_field,
153    };
154    use crate::identity::DarkAccount;
155    use darkpool_crypto::BASE8;
156
157    fn create_compliance_keypair() -> (U256, (U256, U256)) {
158        let compliance_sk_bytes = [0x42u8; 32];
159        let compliance_pk_point = BASE8
160            .mul_scalar(&compliance_sk_bytes)
161            .expect("valid test key");
162        let compliance_pk = (
163            fr_to_u256(compliance_pk_point.x()),
164            fr_to_u256(compliance_pk_point.y()),
165        );
166        (U256::from_big_endian(&compliance_sk_bytes), compliance_pk)
167    }
168
169    fn create_test_note() -> NotePlaintext {
170        NotePlaintext {
171            asset_id: U256::from(1),
172            value: U256::from(1_000_000_000_000_000_000u64), // 1 ETH
173            secret: random_field(),
174            nullifier: random_field(),
175            timelock: U256::zero(),
176            hashlock: U256::zero(),
177        }
178    }
179
180    #[test]
181    fn test_path_a_deposit_decryption() {
182        let account = DarkAccount::from_seed(b"test_path_a_seed");
183        let (_, compliance_pk) = create_compliance_keypair();
184        let mut key_repo = KeyRepository::new(account, compliance_pk);
185        let (ephemeral_sk, _nonce) = key_repo.next_ephemeral_params();
186        let note = create_test_note();
187
188        let (packed_ciphertext, epk) =
189            encrypt_note_for_deposit_aes(ephemeral_sk, compliance_pk, &note)
190                .expect("encryption should succeed");
191        let commitment = crate::crypto_helpers::poseidon_hash(&packed_ciphertext);
192
193        let event = UnprocessedEvent {
194            event_type: EventType::NewNote,
195            block_number: 100,
196            tx_hash: "0x1234".to_string(),
197            leaf_index: 0,
198            commitment,
199            epk_x: epk.0,
200            epk_y: epk.1,
201            packed_ciphertext,
202            tag: None,
203            intermediate_bob_x: None,
204            intermediate_bob_y: None,
205        };
206
207        let processor = NoteProcessor::new(&key_repo, compliance_pk);
208        let wallet_note = processor
209            .process(&event)
210            .expect("should decrypt our deposit");
211        assert_eq!(wallet_note.note.asset_id, note.asset_id);
212        assert_eq!(wallet_note.note.value, note.value);
213        assert_eq!(wallet_note.note.secret, note.secret);
214        assert_eq!(wallet_note.note.nullifier, note.nullifier);
215        assert!(!wallet_note.is_transfer);
216        assert_eq!(wallet_note.derivation_index, 0);
217        assert!(!wallet_note.spent);
218    }
219
220    #[test]
221    fn test_path_a_unknown_epk_returns_none() {
222        let account = DarkAccount::from_seed(b"test_path_a_unknown");
223        let (_, compliance_pk) = create_compliance_keypair();
224        let key_repo = KeyRepository::new(account, compliance_pk);
225
226        let event = UnprocessedEvent {
227            event_type: EventType::NewNote,
228            block_number: 100,
229            tx_hash: "0x1234".to_string(),
230            leaf_index: 0,
231            commitment: U256::from(12345),
232            epk_x: U256::from(999999), // Unknown PK
233            epk_y: U256::from(888888),
234            packed_ciphertext: [U256::zero(); 7],
235            tag: None,
236            intermediate_bob_x: None,
237            intermediate_bob_y: None,
238        };
239
240        let processor = NoteProcessor::new(&key_repo, compliance_pk);
241        let result = processor.process(&event);
242
243        assert!(result.is_none(), "Should not match unknown ephemeral PK");
244    }
245
246    #[test]
247    fn test_path_b_transfer_decryption() {
248        use num_bigint::BigUint;
249
250        // Helper to reduce U256 mod subgroup order (matches KeyRepository)
251        fn reduce_mod_subgroup(value: U256) -> U256 {
252            let mut bytes = [0u8; 32];
253            value.to_big_endian(&mut bytes);
254            let bigint = BigUint::from_bytes_be(&bytes);
255            let order = BigUint::parse_bytes(darkpool_crypto::SUBGROUP_ORDER.as_bytes(), 10)
256                .expect("valid subgroup order");
257            let reduced = bigint % order;
258            let mut result_bytes = reduced.to_bytes_be();
259            while result_bytes.len() < 32 {
260                result_bytes.insert(0, 0);
261            }
262            U256::from_big_endian(&result_bytes)
263        }
264
265        let mut recipient_account = DarkAccount::from_seed(b"test_path_b_recipient");
266        let (_, compliance_pk) = create_compliance_keypair();
267        let mut key_repo = KeyRepository::new(recipient_account.clone(), compliance_pk);
268        key_repo.advance_incoming_keys(5);
269
270        // Recipient's ivk reduced mod subgroup order (matches KeyRepository internals)
271        let recipient_ivk = recipient_account.get_incoming_viewing_key(0);
272        let recipient_ivk_mod = reduce_mod_subgroup(recipient_ivk);
273
274        let mut ivk_mod_bytes = [0u8; 32];
275        recipient_ivk_mod.to_big_endian(&mut ivk_mod_bytes);
276        ivk_mod_bytes.reverse();
277        let recipient_pk_from_mod = BASE8.mul_scalar(&ivk_mod_bytes).expect("valid test key");
278
279        let sender_r = U256::from(98765u64);
280        let mut r_bytes = [0u8; 32];
281        sender_r.to_big_endian(&mut r_bytes);
282        r_bytes.reverse();
283
284        let intermediate_bob_point = BASE8.mul_scalar(&r_bytes).expect("valid test key");
285        let intermediate_bob = (
286            fr_to_u256(intermediate_bob_point.x()),
287            fr_to_u256(intermediate_bob_point.y()),
288        );
289
290        let sender_shared_point = recipient_pk_from_mod
291            .mul_scalar(&r_bytes)
292            .expect("valid test key");
293        let sender_shared_secret = fr_to_u256(sender_shared_point.x());
294
295        let note = create_test_note();
296        let (key, iv) = kdf_to_aes_key_iv(sender_shared_secret);
297        let plaintext = pack_note_plaintext(&note);
298
299        let ciphertext = aes128_encrypt(&plaintext, &key, &iv);
300        let packed_ciphertext = pack_ciphertext_to_fields(&ciphertext);
301
302        use darkpool_crypto::PublicKey;
303        let compliance_pk_point = PublicKey::new_unchecked(
304            crate::crypto_helpers::u256_to_fr(compliance_pk.0),
305            crate::crypto_helpers::u256_to_fr(compliance_pk.1),
306        );
307        let tag_point = compliance_pk_point
308            .mul_scalar(&ivk_mod_bytes)
309            .expect("valid test key");
310        let tag = fr_to_u256(tag_point.x());
311
312        let commitment = crate::crypto_helpers::poseidon_hash(&packed_ciphertext);
313
314        let event = UnprocessedEvent {
315            event_type: EventType::NewMemo,
316            block_number: 200,
317            tx_hash: "0x5678".to_string(),
318            leaf_index: 10,
319            commitment,
320            epk_x: U256::zero(),
321            epk_y: U256::zero(),
322            packed_ciphertext,
323            tag: Some(tag),
324            intermediate_bob_x: Some(intermediate_bob.0),
325            intermediate_bob_y: Some(intermediate_bob.1),
326        };
327
328        let processor = NoteProcessor::new(&key_repo, compliance_pk);
329        let wallet_note = processor
330            .process(&event)
331            .expect("should decrypt transfer memo");
332        assert_eq!(wallet_note.note.asset_id, note.asset_id);
333        assert_eq!(wallet_note.note.value, note.value);
334        assert!(wallet_note.is_transfer);
335        assert!(!wallet_note.spent);
336    }
337
338    #[test]
339    fn test_path_b_unknown_tag_returns_none() {
340        let account = DarkAccount::from_seed(b"test_path_b_unknown");
341        let (_, compliance_pk) = create_compliance_keypair();
342        let key_repo = KeyRepository::new(account, compliance_pk);
343
344        let event = UnprocessedEvent {
345            event_type: EventType::NewMemo,
346            block_number: 200,
347            tx_hash: "0x5678".to_string(),
348            leaf_index: 10,
349            commitment: U256::from(12345),
350            epk_x: U256::zero(),
351            epk_y: U256::zero(),
352            packed_ciphertext: [U256::zero(); 7],
353            tag: Some(U256::from(99999)), // Unknown tag
354            intermediate_bob_x: Some(U256::from(111)),
355            intermediate_bob_y: Some(U256::from(222)),
356        };
357
358        let processor = NoteProcessor::new(&key_repo, compliance_pk);
359        let result = processor.process(&event);
360
361        assert!(result.is_none(), "Should not match unknown tag");
362    }
363
364    #[test]
365    fn test_memo_missing_fields_returns_none() {
366        let account = DarkAccount::from_seed(b"test_memo_missing");
367        let (_, compliance_pk) = create_compliance_keypair();
368        let key_repo = KeyRepository::new(account, compliance_pk);
369
370        // Missing tag
371        let event1 = UnprocessedEvent {
372            event_type: EventType::NewMemo,
373            block_number: 200,
374            tx_hash: "0x5678".to_string(),
375            leaf_index: 10,
376            commitment: U256::from(12345),
377            epk_x: U256::zero(),
378            epk_y: U256::zero(),
379            packed_ciphertext: [U256::zero(); 7],
380            tag: None, // Missing!
381            intermediate_bob_x: Some(U256::from(111)),
382            intermediate_bob_y: Some(U256::from(222)),
383        };
384
385        let processor = NoteProcessor::new(&key_repo, compliance_pk);
386        assert!(processor.process(&event1).is_none());
387
388        // Missing intermediate point
389        let event2 = UnprocessedEvent {
390            event_type: EventType::NewMemo,
391            block_number: 200,
392            tx_hash: "0x5678".to_string(),
393            leaf_index: 10,
394            commitment: U256::from(12345),
395            epk_x: U256::zero(),
396            epk_y: U256::zero(),
397            packed_ciphertext: [U256::zero(); 7],
398            tag: Some(U256::from(12345)),
399            intermediate_bob_x: None, // Missing!
400            intermediate_bob_y: Some(U256::from(222)),
401        };
402
403        assert!(processor.process(&event2).is_none());
404    }
405
406    #[test]
407    fn test_nullifier_path_a_deterministic() {
408        let account = DarkAccount::from_seed(b"test_nullifier_a");
409        let (_, compliance_pk) = create_compliance_keypair();
410        let mut key_repo = KeyRepository::new(account, compliance_pk);
411
412        // Create and encrypt a note
413        let (ephemeral_sk, _nonce) = key_repo.next_ephemeral_params();
414        let note = create_test_note();
415        let (packed_ciphertext, epk) =
416            encrypt_note_for_deposit_aes(ephemeral_sk, compliance_pk, &note).unwrap();
417        let commitment = crate::crypto_helpers::poseidon_hash(&packed_ciphertext);
418
419        // Process twice
420        let event = UnprocessedEvent {
421            event_type: EventType::NewNote,
422            block_number: 100,
423            tx_hash: "0x1234".to_string(),
424            leaf_index: 0,
425            commitment,
426            epk_x: epk.0,
427            epk_y: epk.1,
428            packed_ciphertext,
429            tag: None,
430            intermediate_bob_x: None,
431            intermediate_bob_y: None,
432        };
433
434        let processor = NoteProcessor::new(&key_repo, compliance_pk);
435        let result1 = processor.process(&event).unwrap();
436        let result2 = processor.process(&event).unwrap();
437
438        // Nullifiers should be identical
439        assert_eq!(result1.nullifier, result2.nullifier);
440        // And should match derive_nullifier_path_a(note.nullifier)
441        let expected_nullifier = derive_nullifier_path_a(note.nullifier);
442        assert_eq!(result1.nullifier, expected_nullifier);
443    }
444}