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#[derive(Debug, Clone)]
52pub struct DecodedNoteAdded {
53    pub cmx: [u8; 32],
54    pub enc_ciphertext: Vec<u8>,
55    /// 80-byte outgoing ciphertext (empty on legacy `NoteAdded` logs).
56    pub out_ciphertext: Vec<u8>,
57    pub epk: [u8; 32],
58    pub nf_old: [u8; 32],
59    /// `pubFields[1]` = cv_net_x (BE). `None` on legacy logs.
60    pub cv_net_x: Option<[u8; 32]>,
61}
62
63#[derive(Debug, Error)]
64pub enum LogDecodeError {
65    #[error("invalid topics/data for NoteAdded")]
66    BadNoteAdded,
67    #[error("invalid topics/data for NoteConfirmed")]
68    BadNoteConfirmed,
69    #[error("invalid topics/data for ShieldCompleted")]
70    BadShieldCompleted,
71    #[error("ethabi decode: {0}")]
72    EthAbi(String),
73}
74
75fn topic_to_bytes32(topic: &str) -> Option<[u8; 32]> {
76    let t = topic.strip_prefix("0x").unwrap_or(topic);
77    let b = hex::decode(t).ok()?;
78    if b.len() != 32 {
79        return None;
80    }
81    Some(b.try_into().ok()?)
82}
83
84fn decode_note_added_v2(data: &[u8]) -> Result<DecodedNoteAdded, LogDecodeError> {
85    let tokens = decode(
86        &[
87            ParamType::Bytes,
88            ParamType::Bytes,
89            ParamType::FixedBytes(32),
90            ParamType::FixedBytes(32),
91            ParamType::FixedBytes(32),
92        ],
93        data,
94    )
95    .map_err(|e| LogDecodeError::EthAbi(e.to_string()))?;
96    if tokens.len() != 5 {
97        return Err(LogDecodeError::BadNoteAdded);
98    }
99    let enc = match &tokens[0] {
100        Token::Bytes(b) => b.clone(),
101        _ => return Err(LogDecodeError::BadNoteAdded),
102    };
103    let out = match &tokens[1] {
104        Token::Bytes(b) => b.clone(),
105        _ => return Err(LogDecodeError::BadNoteAdded),
106    };
107    let epk = token_bytes32(&tokens[2])?;
108    let nf_old = token_bytes32(&tokens[3])?;
109    let cv_net_x = token_bytes32(&tokens[4])?;
110    Ok(DecodedNoteAdded {
111        cmx: [0u8; 32], // filled by caller
112        enc_ciphertext: enc,
113        out_ciphertext: out,
114        epk,
115        nf_old,
116        cv_net_x: Some(cv_net_x),
117    })
118}
119
120fn decode_note_added_legacy(data: &[u8]) -> Result<DecodedNoteAdded, LogDecodeError> {
121    let tokens = decode(
122        &[
123            ParamType::Bytes,
124            ParamType::FixedBytes(32),
125            ParamType::FixedBytes(32),
126        ],
127        data,
128    )
129    .map_err(|e| LogDecodeError::EthAbi(e.to_string()))?;
130    if tokens.len() != 3 {
131        return Err(LogDecodeError::BadNoteAdded);
132    }
133    let enc = match &tokens[0] {
134        Token::Bytes(b) => b.clone(),
135        _ => return Err(LogDecodeError::BadNoteAdded),
136    };
137    let epk = token_bytes32(&tokens[1])?;
138    let nf_old = token_bytes32(&tokens[2])?;
139    Ok(DecodedNoteAdded {
140        cmx: [0u8; 32],
141        enc_ciphertext: enc,
142        out_ciphertext: Vec::new(),
143        epk,
144        nf_old,
145        cv_net_x: None,
146    })
147}
148
149/// Decode `NoteAdded` from `eth_getLogs` / WebSocket log entry.
150///
151/// Supports the current event (with `outCiphertext` + `cvNetX`) and the legacy
152/// 4-field layout. `topics[1]` = cmx (indexed).
153pub fn decode_note_added_log(topics: &[String], data_hex: &str) -> Result<DecodedNoteAdded, LogDecodeError> {
154    let cmx = topics
155        .get(1)
156        .and_then(|t| topic_to_bytes32(t))
157        .ok_or(LogDecodeError::BadNoteAdded)?;
158    let raw = hex::decode(data_hex.strip_prefix("0x").unwrap_or(data_hex))
159        .map_err(|_| LogDecodeError::BadNoteAdded)?;
160
161    let norm = |s: &str| {
162        s.strip_prefix("0x")
163            .unwrap_or(s)
164            .to_ascii_lowercase()
165    };
166    let topic0 = topics.first().map(|s| norm(s));
167    let legacy = norm(&note_added_legacy_topic0_hex());
168    let current = norm(&note_added_topic0_hex());
169    let mut decoded = if topic0.as_deref() == Some(current.as_str()) {
170        decode_note_added_v2(&raw)?
171    } else if topic0.as_deref() == Some(legacy.as_str()) {
172        // Deployed pools may still use the legacy topic0 while emitting the extended
173        // 5-field log body (outCiphertext + cvNetX). Prefer v2 when the payload fits.
174        decode_note_added_v2(&raw).or_else(|_| decode_note_added_legacy(&raw))?
175    } else {
176        decode_note_added_v2(&raw).or_else(|_| decode_note_added_legacy(&raw))?
177    };
178    decoded.cmx = cmx;
179    Ok(decoded)
180}
181
182fn token_bytes32(t: &Token) -> Result<[u8; 32], LogDecodeError> {
183    match t {
184        Token::FixedBytes(b) if b.len() == 32 => Ok(b[..].try_into().unwrap()),
185        Token::Bytes(b) if b.len() == 32 => Ok(b[..].try_into().unwrap()),
186        _ => Err(LogDecodeError::BadNoteAdded),
187    }
188}
189
190/// Returns `(cmx, newRoot, position)`.
191pub fn decode_note_confirmed_log(topics: &[String], data_hex: &str) -> Result<([u8; 32], [u8; 32], u64), LogDecodeError> {
192    let cmx = topics
193        .get(1)
194        .and_then(|t| topic_to_bytes32(t))
195        .ok_or(LogDecodeError::BadNoteConfirmed)?;
196    let raw = hex::decode(data_hex.strip_prefix("0x").unwrap_or(data_hex))
197        .map_err(|_| LogDecodeError::BadNoteConfirmed)?;
198    // data: abi.encode(bytes32 newRoot, uint256 position)
199    let tokens = decode(&[ParamType::FixedBytes(32), ParamType::Uint(256)], &raw)
200        .map_err(|e| LogDecodeError::EthAbi(e.to_string()))?;
201    let root = token_bytes32_confirmed(tokens.first().ok_or(LogDecodeError::BadNoteConfirmed)?)?;
202    let position = match tokens.get(1) {
203        Some(Token::Uint(u)) => u64::try_from(*u).unwrap_or(u64::MAX),
204        _ => return Err(LogDecodeError::BadNoteConfirmed),
205    };
206    Ok((cmx, root, position))
207}
208
209fn token_bytes32_confirmed(t: &Token) -> Result<[u8; 32], LogDecodeError> {
210    match t {
211        Token::FixedBytes(b) if b.len() == 32 => Ok(b[..].try_into().unwrap()),
212        _ => Err(LogDecodeError::BadNoteConfirmed),
213    }
214}
215
216pub fn decode_shield_completed_log(topics: &[String], data_hex: &str) -> Result<([u8; 32], u128), LogDecodeError> {
217    let cmx = topics
218        .get(1)
219        .and_then(|t| topic_to_bytes32(t))
220        .ok_or(LogDecodeError::BadShieldCompleted)?;
221    let raw = hex::decode(data_hex.strip_prefix("0x").unwrap_or(data_hex))
222        .map_err(|_| LogDecodeError::BadShieldCompleted)?;
223    let tokens =
224        decode(&[ParamType::Uint(256)], &raw).map_err(|e| LogDecodeError::EthAbi(e.to_string()))?;
225    let amt = match tokens.get(0) {
226        Some(Token::Uint(u)) => u128::try_from(*u).map_err(|_| LogDecodeError::BadShieldCompleted)?,
227        _ => return Err(LogDecodeError::BadShieldCompleted),
228    };
229    Ok((cmx, amt))
230}
231
232#[cfg(test)]
233mod tests {
234    use ethabi::{encode, Token};
235
236    use super::*;
237
238    #[test]
239    fn topic0_lengths() {
240        assert_eq!(
241            note_added_topic0_hex().len(),
242            2 + 64,
243            "topic0 is 32 bytes hex"
244        );
245        assert_ne!(
246            note_added_topic0_hex(),
247            note_added_legacy_topic0_hex(),
248            "new NoteAdded changes topic0"
249        );
250    }
251
252    #[test]
253    fn note_added_v2_roundtrip() {
254        let cmx = [0x11u8; 32];
255        let enc = vec![0xABu8; 580];
256        let out = vec![0xCDu8; 80];
257        let epk = [0x22u8; 32];
258        let nf = [0x33u8; 32];
259        let cv = [0x44u8; 32];
260        let data = encode(&[
261            Token::Bytes(enc.clone()),
262            Token::Bytes(out.clone()),
263            Token::FixedBytes(epk.to_vec()),
264            Token::FixedBytes(nf.to_vec()),
265            Token::FixedBytes(cv.to_vec()),
266        ]);
267        let topics = vec![
268            note_added_topic0_hex(),
269            format!("0x{}", hex::encode(cmx)),
270        ];
271        let decoded =
272            decode_note_added_log(&topics, &format!("0x{}", hex::encode(&data))).unwrap();
273        assert_eq!(decoded.cmx, cmx);
274        assert_eq!(decoded.enc_ciphertext, enc);
275        assert_eq!(decoded.out_ciphertext, out);
276        assert_eq!(decoded.epk, epk);
277        assert_eq!(decoded.nf_old, nf);
278        assert_eq!(decoded.cv_net_x, Some(cv));
279    }
280
281    #[test]
282    fn note_added_legacy_roundtrip() {
283        let cmx = [0x55u8; 32];
284        let enc = vec![0x01u8; 580];
285        let epk = [0x66u8; 32];
286        let nf = [0x77u8; 32];
287        let data = encode(&[
288            Token::Bytes(enc.clone()),
289            Token::FixedBytes(epk.to_vec()),
290            Token::FixedBytes(nf.to_vec()),
291        ]);
292        let topics = vec![
293            note_added_legacy_topic0_hex(),
294            format!("0x{}", hex::encode(cmx)),
295        ];
296        let decoded =
297            decode_note_added_log(&topics, &format!("0x{}", hex::encode(&data))).unwrap();
298        assert_eq!(decoded.cmx, cmx);
299        assert_eq!(decoded.enc_ciphertext, enc);
300        assert!(decoded.out_ciphertext.is_empty());
301        assert_eq!(decoded.cv_net_x, None);
302    }
303
304    #[test]
305    fn note_added_legacy_topic_v2_body_roundtrip() {
306        let cmx = [0x88u8; 32];
307        let enc = vec![0xABu8; 580];
308        let out = vec![0xCDu8; 80];
309        let epk = [0x22u8; 32];
310        let nf = [0x33u8; 32];
311        let cv = [0x44u8; 32];
312        let data = encode(&[
313            Token::Bytes(enc.clone()),
314            Token::Bytes(out.clone()),
315            Token::FixedBytes(epk.to_vec()),
316            Token::FixedBytes(nf.to_vec()),
317            Token::FixedBytes(cv.to_vec()),
318        ]);
319        let topics = vec![
320            note_added_legacy_topic0_hex(),
321            format!("0x{}", hex::encode(cmx)),
322        ];
323        let decoded =
324            decode_note_added_log(&topics, &format!("0x{}", hex::encode(&data))).unwrap();
325        assert_eq!(decoded.out_ciphertext, out);
326        assert_eq!(decoded.cv_net_x, Some(cv));
327    }
328}