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