Skip to main content

privacy_core/ethereum/
events.rs

1//! `PrivacyBTC.sol` log topics and ABI decoders (matches `contracts/PrivacyPool.sol`).
2
3use ethabi::{decode, ParamType, Token};
4use sha3::{Digest, Keccak256};
5use thiserror::Error;
6
7/// keccak256("NoteAdded(bytes32,bytes,bytes,bytes32,bytes32,bytes32)")
8pub fn note_added_topic0_hex() -> String {
9    format!(
10        "0x{}",
11        hex::encode(Keccak256::digest(
12            b"NoteAdded(bytes32,bytes,bytes,bytes32,bytes32,bytes32)"
13        ))
14    )
15}
16
17/// Pre-OVK-extension pools (no `outCiphertext` / `cvNetX` in the log).
18pub fn note_added_legacy_topic0_hex() -> String {
19    format!(
20        "0x{}",
21        hex::encode(Keccak256::digest(
22            b"NoteAdded(bytes32,bytes,bytes32,bytes32)"
23        ))
24    )
25}
26
27/// Topic0 values to subscribe for all `NoteAdded` variants.
28pub fn note_added_topic0_alternatives() -> Vec<String> {
29    vec![
30        note_added_topic0_hex(),
31        note_added_legacy_topic0_hex(),
32    ]
33}
34
35/// keccak256("NoteConfirmed(bytes32,bytes32,uint256)")
36pub fn note_confirmed_topic0_hex() -> String {
37    format!(
38        "0x{}",
39        hex::encode(Keccak256::digest(b"NoteConfirmed(bytes32,bytes32,uint256)"))
40    )
41}
42
43/// keccak256("ShieldCompleted(bytes32,uint256)")
44pub fn shield_completed_topic0_hex() -> String {
45    format!(
46        "0x{}",
47        hex::encode(Keccak256::digest(b"ShieldCompleted(bytes32,uint256)"))
48    )
49}
50
51/// keccak256("Perc20Created(address,address,string,string,uint8)") — issuer-minted pool genesis.
52pub fn perc20_created_topic0_hex() -> String {
53    format!(
54        "0x{}",
55        hex::encode(Keccak256::digest(b"Perc20Created(address,address,string,string,uint8)"))
56    )
57}
58
59/// keccak256("Shielded(address,uint256,uint256)") — WrappedPERC20 deposit accounting event.
60pub fn shielded_topic0_hex() -> String {
61    format!("0x{}", hex::encode(Keccak256::digest(b"Shielded(address,uint256,uint256)")))
62}
63
64/// keccak256("Unshielded(address,uint256,uint256)") — WrappedPERC20 withdrawal accounting event.
65pub fn unshielded_topic0_hex() -> String {
66    format!("0x{}", hex::encode(Keccak256::digest(b"Unshielded(address,uint256,uint256)")))
67}
68
69/// keccak256("WrappedCreated(address,address,uint256,string,string,uint8)") — pool init metadata.
70pub fn wrapped_created_topic0_hex() -> String {
71    format!(
72        "0x{}",
73        hex::encode(Keccak256::digest(
74            b"WrappedCreated(address,address,uint256,string,string,uint8)"
75        ))
76    )
77}
78
79/// keccak256("WrappedDeployed(address,address,address,uint256)") — factory deployment event.
80pub fn wrapped_deployed_topic0_hex() -> String {
81    format!(
82        "0x{}",
83        hex::encode(Keccak256::digest(b"WrappedDeployed(address,address,address,uint256)"))
84    )
85}
86
87/// Decoded `Shielded` / `Unshielded` accounting event (the underlying-custody side of a
88/// WrappedPERC20 shield/unshield). The note cmx itself arrives via `NoteAdded`; this event
89/// carries the public deposit/withdraw amounts and the EVM actor.
90#[derive(Debug, Clone)]
91pub struct DecodedShielded {
92    /// `depositor` (Shielded) or `recipient` (Unshielded), low-20-bytes EVM address.
93    pub actor: [u8; 20],
94    /// Amount in note units.
95    pub amount_units: u128,
96    /// Amount in the underlying token's smallest unit (`amount_units * scale`).
97    pub wei_amount: u128,
98}
99
100/// Decoded `WrappedCreated` pool-init metadata (used for discovery/verification + the
101/// pool-metadata API). `pool`/`underlying` come from indexed topics.
102#[derive(Debug, Clone)]
103pub struct DecodedWrappedCreated {
104    pub pool: [u8; 20],
105    pub underlying: [u8; 20],
106    pub scale: u128,
107    pub name: String,
108    pub symbol: String,
109    pub decimals: u8,
110}
111
112#[derive(Debug, Clone)]
113pub struct DecodedNoteAdded {
114    pub cmx: [u8; 32],
115    pub enc_ciphertext: Vec<u8>,
116    /// 80-byte outgoing ciphertext (empty on legacy `NoteAdded` logs).
117    pub out_ciphertext: Vec<u8>,
118    pub epk: [u8; 32],
119    pub nf_old: [u8; 32],
120    /// `pubFields[1]` = cv_net_x (BE). `None` on legacy logs.
121    pub cv_net_x: Option<[u8; 32]>,
122}
123
124#[derive(Debug, Error)]
125pub enum LogDecodeError {
126    #[error("invalid topics/data for NoteAdded")]
127    BadNoteAdded,
128    #[error("invalid topics/data for NoteConfirmed")]
129    BadNoteConfirmed,
130    #[error("invalid topics/data for ShieldCompleted")]
131    BadShieldCompleted,
132    #[error("invalid topics/data for Shielded/Unshielded")]
133    BadShielded,
134    #[error("invalid topics/data for WrappedCreated")]
135    BadWrappedCreated,
136    #[error("ethabi decode: {0}")]
137    EthAbi(String),
138}
139
140/// Extract a low-20-byte EVM address from a 32-byte indexed topic.
141fn topic_to_address(topic: &str) -> Option<[u8; 20]> {
142    let b = topic_to_bytes32(topic)?;
143    Some(b[12..32].try_into().ok()?)
144}
145
146fn topic_to_bytes32(topic: &str) -> Option<[u8; 32]> {
147    let t = topic.strip_prefix("0x").unwrap_or(topic);
148    let b = hex::decode(t).ok()?;
149    if b.len() != 32 {
150        return None;
151    }
152    Some(b.try_into().ok()?)
153}
154
155fn decode_note_added_v2(data: &[u8]) -> Result<DecodedNoteAdded, LogDecodeError> {
156    let tokens = decode(
157        &[
158            ParamType::Bytes,
159            ParamType::Bytes,
160            ParamType::FixedBytes(32),
161            ParamType::FixedBytes(32),
162            ParamType::FixedBytes(32),
163        ],
164        data,
165    )
166    .map_err(|e| LogDecodeError::EthAbi(e.to_string()))?;
167    if tokens.len() != 5 {
168        return Err(LogDecodeError::BadNoteAdded);
169    }
170    let enc = match &tokens[0] {
171        Token::Bytes(b) => b.clone(),
172        _ => return Err(LogDecodeError::BadNoteAdded),
173    };
174    let out = match &tokens[1] {
175        Token::Bytes(b) => b.clone(),
176        _ => return Err(LogDecodeError::BadNoteAdded),
177    };
178    let epk = token_bytes32(&tokens[2])?;
179    let nf_old = token_bytes32(&tokens[3])?;
180    let cv_net_x = token_bytes32(&tokens[4])?;
181    Ok(DecodedNoteAdded {
182        cmx: [0u8; 32], // filled by caller
183        enc_ciphertext: enc,
184        out_ciphertext: out,
185        epk,
186        nf_old,
187        cv_net_x: Some(cv_net_x),
188    })
189}
190
191fn decode_note_added_legacy(data: &[u8]) -> Result<DecodedNoteAdded, LogDecodeError> {
192    let tokens = decode(
193        &[
194            ParamType::Bytes,
195            ParamType::FixedBytes(32),
196            ParamType::FixedBytes(32),
197        ],
198        data,
199    )
200    .map_err(|e| LogDecodeError::EthAbi(e.to_string()))?;
201    if tokens.len() != 3 {
202        return Err(LogDecodeError::BadNoteAdded);
203    }
204    let enc = match &tokens[0] {
205        Token::Bytes(b) => b.clone(),
206        _ => return Err(LogDecodeError::BadNoteAdded),
207    };
208    let epk = token_bytes32(&tokens[1])?;
209    let nf_old = token_bytes32(&tokens[2])?;
210    Ok(DecodedNoteAdded {
211        cmx: [0u8; 32],
212        enc_ciphertext: enc,
213        out_ciphertext: Vec::new(),
214        epk,
215        nf_old,
216        cv_net_x: None,
217    })
218}
219
220/// Decode `NoteAdded` from `eth_getLogs` / WebSocket log entry.
221///
222/// Supports the current event (with `outCiphertext` + `cvNetX`) and the legacy
223/// 4-field layout. `topics[1]` = cmx (indexed).
224pub fn decode_note_added_log(topics: &[String], data_hex: &str) -> Result<DecodedNoteAdded, LogDecodeError> {
225    let cmx = topics
226        .get(1)
227        .and_then(|t| topic_to_bytes32(t))
228        .ok_or(LogDecodeError::BadNoteAdded)?;
229    let raw = hex::decode(data_hex.strip_prefix("0x").unwrap_or(data_hex))
230        .map_err(|_| LogDecodeError::BadNoteAdded)?;
231
232    let norm = |s: &str| {
233        s.strip_prefix("0x")
234            .unwrap_or(s)
235            .to_ascii_lowercase()
236    };
237    let topic0 = topics.first().map(|s| norm(s));
238    let legacy = norm(&note_added_legacy_topic0_hex());
239    let current = norm(&note_added_topic0_hex());
240    let mut decoded = if topic0.as_deref() == Some(current.as_str()) {
241        decode_note_added_v2(&raw)?
242    } else if topic0.as_deref() == Some(legacy.as_str()) {
243        // Deployed pools may still use the legacy topic0 while emitting the extended
244        // 5-field log body (outCiphertext + cvNetX). Prefer v2 when the payload fits.
245        decode_note_added_v2(&raw).or_else(|_| decode_note_added_legacy(&raw))?
246    } else {
247        decode_note_added_v2(&raw).or_else(|_| decode_note_added_legacy(&raw))?
248    };
249    decoded.cmx = cmx;
250    Ok(decoded)
251}
252
253fn token_bytes32(t: &Token) -> Result<[u8; 32], LogDecodeError> {
254    match t {
255        Token::FixedBytes(b) if b.len() == 32 => Ok(b[..].try_into().unwrap()),
256        Token::Bytes(b) if b.len() == 32 => Ok(b[..].try_into().unwrap()),
257        _ => Err(LogDecodeError::BadNoteAdded),
258    }
259}
260
261/// Returns `(cmx, newRoot, position)`.
262pub fn decode_note_confirmed_log(topics: &[String], data_hex: &str) -> Result<([u8; 32], [u8; 32], u64), LogDecodeError> {
263    let cmx = topics
264        .get(1)
265        .and_then(|t| topic_to_bytes32(t))
266        .ok_or(LogDecodeError::BadNoteConfirmed)?;
267    let raw = hex::decode(data_hex.strip_prefix("0x").unwrap_or(data_hex))
268        .map_err(|_| LogDecodeError::BadNoteConfirmed)?;
269    // data: abi.encode(bytes32 newRoot, uint256 position)
270    let tokens = decode(&[ParamType::FixedBytes(32), ParamType::Uint(256)], &raw)
271        .map_err(|e| LogDecodeError::EthAbi(e.to_string()))?;
272    let root = token_bytes32_confirmed(tokens.first().ok_or(LogDecodeError::BadNoteConfirmed)?)?;
273    let position = match tokens.get(1) {
274        Some(Token::Uint(u)) => u64::try_from(*u).unwrap_or(u64::MAX),
275        _ => return Err(LogDecodeError::BadNoteConfirmed),
276    };
277    Ok((cmx, root, position))
278}
279
280fn token_bytes32_confirmed(t: &Token) -> Result<[u8; 32], LogDecodeError> {
281    match t {
282        Token::FixedBytes(b) if b.len() == 32 => Ok(b[..].try_into().unwrap()),
283        _ => Err(LogDecodeError::BadNoteConfirmed),
284    }
285}
286
287pub fn decode_shield_completed_log(topics: &[String], data_hex: &str) -> Result<([u8; 32], u128), LogDecodeError> {
288    let cmx = topics
289        .get(1)
290        .and_then(|t| topic_to_bytes32(t))
291        .ok_or(LogDecodeError::BadShieldCompleted)?;
292    let raw = hex::decode(data_hex.strip_prefix("0x").unwrap_or(data_hex))
293        .map_err(|_| LogDecodeError::BadShieldCompleted)?;
294    let tokens =
295        decode(&[ParamType::Uint(256)], &raw).map_err(|e| LogDecodeError::EthAbi(e.to_string()))?;
296    let amt = match tokens.get(0) {
297        Some(Token::Uint(u)) => u128::try_from(*u).map_err(|_| LogDecodeError::BadShieldCompleted)?,
298        _ => return Err(LogDecodeError::BadShieldCompleted),
299    };
300    Ok((cmx, amt))
301}
302
303fn decode_shielded_like(
304    topics: &[String],
305    data_hex: &str,
306) -> Result<DecodedShielded, LogDecodeError> {
307    let actor = topics
308        .get(1)
309        .and_then(|t| topic_to_address(t))
310        .ok_or(LogDecodeError::BadShielded)?;
311    let raw = hex::decode(data_hex.strip_prefix("0x").unwrap_or(data_hex))
312        .map_err(|_| LogDecodeError::BadShielded)?;
313    // data: abi.encode(uint256 amountUnits, uint256 weiAmount)
314    let tokens = decode(&[ParamType::Uint(256), ParamType::Uint(256)], &raw)
315        .map_err(|e| LogDecodeError::EthAbi(e.to_string()))?;
316    let amount_units = match tokens.first() {
317        Some(Token::Uint(u)) => u128::try_from(*u).map_err(|_| LogDecodeError::BadShielded)?,
318        _ => return Err(LogDecodeError::BadShielded),
319    };
320    let wei_amount = match tokens.get(1) {
321        Some(Token::Uint(u)) => u128::try_from(*u).map_err(|_| LogDecodeError::BadShielded)?,
322        _ => return Err(LogDecodeError::BadShielded),
323    };
324    Ok(DecodedShielded { actor, amount_units, wei_amount })
325}
326
327/// Decode a `Shielded(address indexed depositor, uint256 amountUnits, uint256 weiAmount)` log.
328pub fn decode_shielded_log(topics: &[String], data_hex: &str) -> Result<DecodedShielded, LogDecodeError> {
329    decode_shielded_like(topics, data_hex)
330}
331
332/// Decode an `Unshielded(address indexed recipient, uint256 amountUnits, uint256 weiAmount)` log.
333pub fn decode_unshielded_log(topics: &[String], data_hex: &str) -> Result<DecodedShielded, LogDecodeError> {
334    decode_shielded_like(topics, data_hex)
335}
336
337/// Decode a `WrappedCreated(address indexed pool, address indexed underlying, uint256 scale,
338/// string name, string symbol, uint8 decimals)` log.
339pub fn decode_wrapped_created_log(
340    topics: &[String],
341    data_hex: &str,
342) -> Result<DecodedWrappedCreated, LogDecodeError> {
343    let pool = topics
344        .get(1)
345        .and_then(|t| topic_to_address(t))
346        .ok_or(LogDecodeError::BadWrappedCreated)?;
347    let underlying = topics
348        .get(2)
349        .and_then(|t| topic_to_address(t))
350        .ok_or(LogDecodeError::BadWrappedCreated)?;
351    let raw = hex::decode(data_hex.strip_prefix("0x").unwrap_or(data_hex))
352        .map_err(|_| LogDecodeError::BadWrappedCreated)?;
353    let tokens = decode(
354        &[
355            ParamType::Uint(256),
356            ParamType::String,
357            ParamType::String,
358            ParamType::Uint(8),
359        ],
360        &raw,
361    )
362    .map_err(|e| LogDecodeError::EthAbi(e.to_string()))?;
363    let scale = match tokens.first() {
364        Some(Token::Uint(u)) => u128::try_from(*u).map_err(|_| LogDecodeError::BadWrappedCreated)?,
365        _ => return Err(LogDecodeError::BadWrappedCreated),
366    };
367    let name = match &tokens[1] {
368        Token::String(s) => s.clone(),
369        _ => return Err(LogDecodeError::BadWrappedCreated),
370    };
371    let symbol = match &tokens[2] {
372        Token::String(s) => s.clone(),
373        _ => return Err(LogDecodeError::BadWrappedCreated),
374    };
375    let decimals = match tokens.get(3) {
376        Some(Token::Uint(u)) => u8::try_from(*u).map_err(|_| LogDecodeError::BadWrappedCreated)?,
377        _ => return Err(LogDecodeError::BadWrappedCreated),
378    };
379    Ok(DecodedWrappedCreated { pool, underlying, scale, name, symbol, decimals })
380}
381
382#[cfg(test)]
383mod tests {
384    use ethabi::{encode, Token};
385
386    use super::*;
387
388    #[test]
389    fn topic0_lengths() {
390        assert_eq!(
391            note_added_topic0_hex().len(),
392            2 + 64,
393            "topic0 is 32 bytes hex"
394        );
395        assert_ne!(
396            note_added_topic0_hex(),
397            note_added_legacy_topic0_hex(),
398            "new NoteAdded changes topic0"
399        );
400    }
401
402    #[test]
403    fn note_added_v2_roundtrip() {
404        let cmx = [0x11u8; 32];
405        let enc = vec![0xABu8; 580];
406        let out = vec![0xCDu8; 80];
407        let epk = [0x22u8; 32];
408        let nf = [0x33u8; 32];
409        let cv = [0x44u8; 32];
410        let data = encode(&[
411            Token::Bytes(enc.clone()),
412            Token::Bytes(out.clone()),
413            Token::FixedBytes(epk.to_vec()),
414            Token::FixedBytes(nf.to_vec()),
415            Token::FixedBytes(cv.to_vec()),
416        ]);
417        let topics = vec![
418            note_added_topic0_hex(),
419            format!("0x{}", hex::encode(cmx)),
420        ];
421        let decoded =
422            decode_note_added_log(&topics, &format!("0x{}", hex::encode(&data))).unwrap();
423        assert_eq!(decoded.cmx, cmx);
424        assert_eq!(decoded.enc_ciphertext, enc);
425        assert_eq!(decoded.out_ciphertext, out);
426        assert_eq!(decoded.epk, epk);
427        assert_eq!(decoded.nf_old, nf);
428        assert_eq!(decoded.cv_net_x, Some(cv));
429    }
430
431    #[test]
432    fn note_added_legacy_roundtrip() {
433        let cmx = [0x55u8; 32];
434        let enc = vec![0x01u8; 580];
435        let epk = [0x66u8; 32];
436        let nf = [0x77u8; 32];
437        let data = encode(&[
438            Token::Bytes(enc.clone()),
439            Token::FixedBytes(epk.to_vec()),
440            Token::FixedBytes(nf.to_vec()),
441        ]);
442        let topics = vec![
443            note_added_legacy_topic0_hex(),
444            format!("0x{}", hex::encode(cmx)),
445        ];
446        let decoded =
447            decode_note_added_log(&topics, &format!("0x{}", hex::encode(&data))).unwrap();
448        assert_eq!(decoded.cmx, cmx);
449        assert_eq!(decoded.enc_ciphertext, enc);
450        assert!(decoded.out_ciphertext.is_empty());
451        assert_eq!(decoded.cv_net_x, None);
452    }
453
454    #[test]
455    fn new_topic0s_are_distinct_and_well_formed() {
456        for t in [
457            shielded_topic0_hex(),
458            unshielded_topic0_hex(),
459            wrapped_created_topic0_hex(),
460            wrapped_deployed_topic0_hex(),
461            perc20_created_topic0_hex(),
462        ] {
463            assert_eq!(t.len(), 2 + 64);
464        }
465        assert_ne!(shielded_topic0_hex(), unshielded_topic0_hex());
466        assert_ne!(wrapped_created_topic0_hex(), wrapped_deployed_topic0_hex());
467    }
468
469    #[test]
470    fn shielded_roundtrip() {
471        let actor = [0xABu8; 20];
472        let mut actor_topic = [0u8; 32];
473        actor_topic[12..].copy_from_slice(&actor);
474        let data = encode(&[
475            Token::Uint(1_000u64.into()),
476            Token::Uint(1_000_000_000_000u64.into()),
477        ]);
478        let topics = vec![shielded_topic0_hex(), format!("0x{}", hex::encode(actor_topic))];
479        let d = decode_shielded_log(&topics, &format!("0x{}", hex::encode(&data))).unwrap();
480        assert_eq!(d.actor, actor);
481        assert_eq!(d.amount_units, 1_000);
482        assert_eq!(d.wei_amount, 1_000_000_000_000);
483    }
484
485    #[test]
486    fn wrapped_created_roundtrip() {
487        let pool = [0x11u8; 20];
488        let underlying = [0x22u8; 20];
489        let mut pool_t = [0u8; 32];
490        pool_t[12..].copy_from_slice(&pool);
491        let mut und_t = [0u8; 32];
492        und_t[12..].copy_from_slice(&underlying);
493        let data = encode(&[
494            Token::Uint(1_000_000u64.into()),
495            Token::String("Wrapped USDC".to_string()),
496            Token::String("wUSDC".to_string()),
497            Token::Uint(6u8.into()),
498        ]);
499        let topics = vec![
500            wrapped_created_topic0_hex(),
501            format!("0x{}", hex::encode(pool_t)),
502            format!("0x{}", hex::encode(und_t)),
503        ];
504        let d = decode_wrapped_created_log(&topics, &format!("0x{}", hex::encode(&data))).unwrap();
505        assert_eq!(d.pool, pool);
506        assert_eq!(d.underlying, underlying);
507        assert_eq!(d.scale, 1_000_000);
508        assert_eq!(d.name, "Wrapped USDC");
509        assert_eq!(d.symbol, "wUSDC");
510        assert_eq!(d.decimals, 6);
511    }
512
513    #[test]
514    fn note_added_legacy_topic_v2_body_roundtrip() {
515        let cmx = [0x88u8; 32];
516        let enc = vec![0xABu8; 580];
517        let out = vec![0xCDu8; 80];
518        let epk = [0x22u8; 32];
519        let nf = [0x33u8; 32];
520        let cv = [0x44u8; 32];
521        let data = encode(&[
522            Token::Bytes(enc.clone()),
523            Token::Bytes(out.clone()),
524            Token::FixedBytes(epk.to_vec()),
525            Token::FixedBytes(nf.to_vec()),
526            Token::FixedBytes(cv.to_vec()),
527        ]);
528        let topics = vec![
529            note_added_legacy_topic0_hex(),
530            format!("0x{}", hex::encode(cmx)),
531        ];
532        let decoded =
533            decode_note_added_log(&topics, &format!("0x{}", hex::encode(&data))).unwrap();
534        assert_eq!(decoded.out_ciphertext, out);
535        assert_eq!(decoded.cv_net_x, Some(cv));
536    }
537}