Skip to main content

perpcity_sdk/
events.rs

1//! Event decoding for PerpManager and Beacon contracts.
2//!
3//! Decodes raw [`Log`] entries from WebSocket subscriptions into typed
4//! [`MarketEvent`] values. Consumers get human-readable f64 prices and
5//! amounts without touching ABI encoding or Q96 math.
6//!
7//! # Usage
8//!
9//! ```rust,no_run
10//! use perpcity_sdk::events::{MarketEvent, decode_log};
11//! # use alloy::rpc::types::Log;
12//! # fn example(log: &Log) {
13//! if let Some(event) = decode_log(log) {
14//!     match event {
15//!         MarketEvent::PositionOpened { mark_price, pos_id, .. } => {
16//!             println!("position {pos_id} opened at {mark_price}");
17//!         }
18//!         _ => {}
19//!     }
20//! }
21//! # }
22//! ```
23
24use alloy::primitives::{B256, U256};
25use alloy::rpc::types::Log;
26use alloy::sol_types::SolEvent;
27use serde::{Deserialize, Serialize};
28
29use crate::contracts::{IBeacon, PerpManager};
30use crate::convert::{price_x96_to_f64, scale_from_6dec, sqrt_price_x96_to_price};
31
32/// A decoded market event with human-readable f64 values.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34#[allow(missing_docs)]
35pub enum MarketEvent {
36    /// A position was opened.
37    PositionOpened {
38        perp_id: B256,
39        mark_price: f64,
40        long_oi: f64,
41        short_oi: f64,
42        pos_id: U256,
43        is_maker: bool,
44        perp_delta: f64,
45        usd_delta: f64,
46        tick_lower: i32,
47        tick_upper: i32,
48    },
49    /// Notional was adjusted (swap).
50    NotionalAdjusted {
51        perp_id: B256,
52        mark_price: f64,
53        long_oi: f64,
54        short_oi: f64,
55        pos_id: U256,
56        new_perp_delta: f64,
57        swap_perp_delta: f64,
58        swap_usd_delta: f64,
59        funding: f64,
60        trading_fees: f64,
61    },
62    /// A position was closed (or liquidated).
63    PositionClosed {
64        perp_id: B256,
65        mark_price: f64,
66        long_oi: f64,
67        short_oi: f64,
68        pos_id: U256,
69        was_maker: bool,
70        was_liquidated: bool,
71        was_partial_close: bool,
72        exit_perp_delta: f64,
73        exit_usd_delta: f64,
74        net_margin: f64,
75        funding: f64,
76    },
77    /// Index price updated (from Beacon contract).
78    IndexUpdated { index: f64 },
79}
80
81/// Decode a raw Alloy [`Log`] into a [`MarketEvent`], if recognized.
82///
83/// Returns `None` for unrecognized events (`MarginAdjusted`, `PerpCreated`,
84/// module registration events, ERC20 events, etc.).
85///
86/// # Errors
87///
88/// Returns `None` (not an error) if ABI decoding or value conversion fails.
89/// This is intentional — a malformed log should not crash the event stream.
90pub fn decode_log(log: &Log) -> Option<MarketEvent> {
91    let topic0 = *log.topic0()?;
92
93    if topic0 == PerpManager::PositionOpened::SIGNATURE_HASH {
94        decode_position_opened(log)
95    } else if topic0 == PerpManager::NotionalAdjusted::SIGNATURE_HASH {
96        decode_notional_adjusted(log)
97    } else if topic0 == PerpManager::PositionClosed::SIGNATURE_HASH {
98        decode_position_closed(log)
99    } else if topic0 == IBeacon::IndexUpdated::SIGNATURE_HASH {
100        decode_index_updated(log)
101    } else {
102        None
103    }
104}
105
106fn decode_position_opened(log: &Log) -> Option<MarketEvent> {
107    let decoded = PerpManager::PositionOpened::decode_raw_log(
108        log.inner.data.topics().iter().copied(),
109        log.inner.data.data.as_ref(),
110    )
111    .ok()?;
112
113    Some(MarketEvent::PositionOpened {
114        perp_id: decoded.perpId,
115        mark_price: sqrt_price_x96_to_price(decoded.sqrtPriceX96).ok()?,
116        long_oi: scale_from_6dec(decoded.longOI.try_into().ok()?),
117        short_oi: scale_from_6dec(decoded.shortOI.try_into().ok()?),
118        pos_id: decoded.posId,
119        is_maker: decoded.isMaker,
120        perp_delta: scale_from_6dec(decoded.perpDelta.try_into().ok()?),
121        usd_delta: scale_from_6dec(decoded.usdDelta.try_into().ok()?),
122        tick_lower: decoded.tickLower.as_i32(),
123        tick_upper: decoded.tickUpper.as_i32(),
124    })
125}
126
127fn decode_notional_adjusted(log: &Log) -> Option<MarketEvent> {
128    let decoded = PerpManager::NotionalAdjusted::decode_raw_log(
129        log.inner.data.topics().iter().copied(),
130        log.inner.data.data.as_ref(),
131    )
132    .ok()?;
133
134    Some(MarketEvent::NotionalAdjusted {
135        perp_id: decoded.perpId,
136        mark_price: sqrt_price_x96_to_price(decoded.sqrtPriceX96).ok()?,
137        long_oi: scale_from_6dec(decoded.longOI.try_into().ok()?),
138        short_oi: scale_from_6dec(decoded.shortOI.try_into().ok()?),
139        pos_id: decoded.posId,
140        new_perp_delta: scale_from_6dec(decoded.newPerpDelta.try_into().ok()?),
141        swap_perp_delta: scale_from_6dec(decoded.swapPerpDelta.try_into().ok()?),
142        swap_usd_delta: scale_from_6dec(decoded.swapUsdDelta.try_into().ok()?),
143        funding: scale_from_6dec(decoded.funding.try_into().ok()?),
144        trading_fees: scale_from_6dec(decoded.tradingFees.try_into().ok()?),
145    })
146}
147
148fn decode_position_closed(log: &Log) -> Option<MarketEvent> {
149    let decoded = PerpManager::PositionClosed::decode_raw_log(
150        log.inner.data.topics().iter().copied(),
151        log.inner.data.data.as_ref(),
152    )
153    .ok()?;
154
155    Some(MarketEvent::PositionClosed {
156        perp_id: decoded.perpId,
157        mark_price: sqrt_price_x96_to_price(decoded.sqrtPriceX96).ok()?,
158        long_oi: scale_from_6dec(decoded.longOI.try_into().ok()?),
159        short_oi: scale_from_6dec(decoded.shortOI.try_into().ok()?),
160        pos_id: decoded.posId,
161        was_maker: decoded.wasMaker,
162        was_liquidated: decoded.wasLiquidated,
163        was_partial_close: decoded.wasPartialClose,
164        exit_perp_delta: scale_from_6dec(decoded.exitPerpDelta.try_into().ok()?),
165        exit_usd_delta: scale_from_6dec(decoded.exitUsdDelta.try_into().ok()?),
166        net_margin: scale_from_6dec(decoded.netMargin.try_into().ok()?),
167        funding: scale_from_6dec(decoded.funding.try_into().ok()?),
168    })
169}
170
171fn decode_index_updated(log: &Log) -> Option<MarketEvent> {
172    let decoded = IBeacon::IndexUpdated::decode_raw_log(
173        log.inner.data.topics().iter().copied(),
174        log.inner.data.data.as_ref(),
175    )
176    .ok()?;
177
178    Some(MarketEvent::IndexUpdated {
179        index: price_x96_to_f64(decoded.index).ok()?,
180    })
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use alloy::primitives::{Address, LogData, Signed, U256};
187    use alloy::rpc::types::Log as RpcLog;
188
189    use crate::constants::{Q96, Q96_PRECISION};
190
191    /// Build a synthetic RPC Log from an event that implements SolEvent.
192    fn make_log<E: SolEvent>(event: &E, address: Address) -> RpcLog {
193        let log_data = event.encode_log_data();
194        RpcLog {
195            inner: alloy::primitives::Log {
196                address,
197                data: log_data,
198            },
199            block_hash: None,
200            block_number: None,
201            block_timestamp: None,
202            transaction_hash: None,
203            transaction_index: None,
204            log_index: None,
205            removed: false,
206        }
207    }
208
209    #[test]
210    fn decode_position_opened_event() {
211        let perp_id = B256::repeat_byte(0x01);
212        let event = PerpManager::PositionOpened {
213            perpId: perp_id,
214            sqrtPriceX96: Q96, // price = 1.0
215            longOI: U256::from(1_000_000u64),
216            shortOI: U256::from(500_000u64),
217            posId: U256::from(42u64),
218            isMaker: false,
219            perpDelta: alloy::primitives::I256::try_from(100_000_000i64).unwrap(),
220            usdDelta: alloy::primitives::I256::try_from(-100_000_000i64).unwrap(),
221            tickLower: Signed::try_from(-100i32).unwrap(),
222            tickUpper: Signed::try_from(100i32).unwrap(),
223        };
224
225        let log = make_log(&event, Address::ZERO);
226        let decoded = decode_log(&log).expect("should decode PositionOpened");
227
228        match decoded {
229            MarketEvent::PositionOpened {
230                perp_id: pid,
231                mark_price,
232                pos_id,
233                is_maker,
234                ..
235            } => {
236                assert_eq!(pid, perp_id);
237                assert!((mark_price - 1.0).abs() < Q96_PRECISION);
238                assert_eq!(pos_id, U256::from(42u64));
239                assert!(!is_maker);
240            }
241            _ => panic!("expected PositionOpened"),
242        }
243    }
244
245    #[test]
246    fn decode_index_updated_event() {
247        let event = IBeacon::IndexUpdated {
248            index: Q96 * U256::from(100u64), // index = 100.0
249        };
250
251        let log = make_log(&event, Address::ZERO);
252        let decoded = decode_log(&log).expect("should decode IndexUpdated");
253
254        match decoded {
255            MarketEvent::IndexUpdated { index } => {
256                assert!((index - 100.0).abs() < Q96_PRECISION);
257            }
258            _ => panic!("expected IndexUpdated"),
259        }
260    }
261
262    #[test]
263    fn decode_position_closed_event() {
264        let perp_id = B256::repeat_byte(0x02);
265        let event = PerpManager::PositionClosed {
266            perpId: perp_id,
267            sqrtPriceX96: Q96 * U256::from(10u64), // sqrt price → price = 100.0
268            longOI: U256::from(2_000_000u64),
269            shortOI: U256::from(1_000_000u64),
270            posId: U256::from(7u64),
271            wasMaker: false,
272            wasLiquidated: true,
273            wasPartialClose: false,
274            exitPerpDelta: alloy::primitives::I256::try_from(-50_000_000i64).unwrap(),
275            exitUsdDelta: alloy::primitives::I256::try_from(50_000_000i64).unwrap(),
276            tickLower: Signed::try_from(0i32).unwrap(),
277            tickUpper: Signed::try_from(0i32).unwrap(),
278            netUsdDelta: alloy::primitives::I256::try_from(48_000_000i64).unwrap(),
279            funding: alloy::primitives::I256::try_from(-1_000_000i64).unwrap(),
280            utilizationFee: U256::from(500_000u64),
281            adl: U256::ZERO,
282            liquidationFee: U256::from(1_000_000u64),
283            netMargin: alloy::primitives::I256::try_from(45_000_000i64).unwrap(),
284        };
285
286        let log = make_log(&event, Address::ZERO);
287        let decoded = decode_log(&log).expect("should decode PositionClosed");
288
289        match decoded {
290            MarketEvent::PositionClosed {
291                perp_id: pid,
292                mark_price,
293                pos_id,
294                was_liquidated,
295                net_margin,
296                funding,
297                ..
298            } => {
299                assert_eq!(pid, perp_id);
300                assert!((mark_price - 100.0).abs() < Q96_PRECISION);
301                assert_eq!(pos_id, U256::from(7u64));
302                assert!(was_liquidated);
303                assert!((net_margin - 45.0).abs() < Q96_PRECISION);
304                assert!((funding - (-1.0)).abs() < Q96_PRECISION);
305            }
306            _ => panic!("expected PositionClosed"),
307        }
308    }
309
310    #[test]
311    fn unrecognized_event_returns_none() {
312        // A log with an unknown topic0 should return None.
313        let log = RpcLog {
314            inner: alloy::primitives::Log {
315                address: Address::ZERO,
316                data: LogData::new_unchecked(vec![B256::repeat_byte(0xFF)], vec![].into()),
317            },
318            block_hash: None,
319            block_number: None,
320            block_timestamp: None,
321            transaction_hash: None,
322            transaction_index: None,
323            log_index: None,
324            removed: false,
325        };
326        assert!(decode_log(&log).is_none());
327    }
328
329    #[test]
330    fn empty_log_returns_none() {
331        let log = RpcLog {
332            inner: alloy::primitives::Log {
333                address: Address::ZERO,
334                data: LogData::new_unchecked(vec![], vec![].into()),
335            },
336            block_hash: None,
337            block_number: None,
338            block_timestamp: None,
339            transaction_hash: None,
340            transaction_index: None,
341            log_index: None,
342            removed: false,
343        };
344        assert!(decode_log(&log).is_none());
345    }
346}