1use serde::{Deserialize, Serialize};
4
5use super::channels::{Channel, ClobChannel, FilterKey, SubscribableChannel, join_filter_keys};
6use super::payloads::Payload;
7use crate::error::{RadionError, Result};
8
9#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
14pub struct ChannelFilters {
15 #[serde(skip_serializing_if = "Option::is_none")]
17 pub wallets: Option<Vec<String>>,
18 #[serde(skip_serializing_if = "Option::is_none")]
20 pub market_ids: Option<Vec<String>>,
21 #[serde(skip_serializing_if = "Option::is_none")]
23 pub token_ids: Option<Vec<String>>,
24 #[serde(skip_serializing_if = "Option::is_none")]
29 pub min_usd: Option<f64>,
30}
31
32impl ChannelFilters {
33 fn has(&self, key: FilterKey) -> bool {
35 match key {
36 FilterKey::Wallets => self.wallets.as_ref().is_some_and(|v| !v.is_empty()),
37 FilterKey::MarketIds => self.market_ids.as_ref().is_some_and(|v| !v.is_empty()),
38 FilterKey::TokenIds => self.token_ids.as_ref().is_some_and(|v| !v.is_empty()),
39 FilterKey::MinUsd => self.min_usd.is_some(),
40 }
41 }
42}
43
44#[derive(Debug, Clone, PartialEq)]
54pub struct Subscription {
55 pub id: String,
57 pub channel: SubscribableChannel,
59 pub confirmed: bool,
61 pub filters: Option<ChannelFilters>,
63}
64
65impl Subscription {
66 pub fn new(id: impl Into<String>, channel: impl Into<SubscribableChannel>) -> Self {
68 Self {
69 id: id.into(),
70 channel: channel.into(),
71 confirmed: true,
72 filters: None,
73 }
74 }
75
76 #[must_use]
78 pub fn with_filters(mut self, filters: ChannelFilters) -> Self {
79 self.filters = Some(filters);
80 self
81 }
82
83 #[must_use]
86 pub fn confirmed(mut self, confirmed: bool) -> Self {
87 self.confirmed = confirmed;
88 self
89 }
90
91 #[must_use]
94 pub fn pending(self) -> Self {
95 self.confirmed(false)
96 }
97
98 pub fn validate(&self) -> Result<()> {
106 let Some(requirement) = self.channel.filter_requirement() else {
107 return Ok(());
108 };
109 let satisfied = requirement
110 .required_any_of
111 .iter()
112 .any(|key| self.filters.as_ref().is_some_and(|f| f.has(*key)));
113 if satisfied {
114 return Ok(());
115 }
116 Err(RadionError::connection(format!(
117 "channel \"{}\" requires a {} filter",
118 self.channel,
119 join_filter_keys(requirement.required_any_of),
120 )))
121 }
122}
123
124#[cfg(feature = "realtime")]
126#[derive(Debug, Clone, Serialize)]
127#[serde(tag = "action", rename_all = "snake_case")]
128pub(crate) enum OutboundFrame {
129 Subscribe {
130 id: String,
131 channel: SubscribableChannel,
132 #[serde(skip_serializing_if = "Option::is_none")]
133 confirmed: Option<bool>,
134 #[serde(skip_serializing_if = "Option::is_none")]
135 filters: Option<ChannelFilters>,
136 },
137 Unsubscribe {
138 id: String,
139 },
140 Ping,
141}
142
143#[cfg(feature = "realtime")]
144impl OutboundFrame {
145 pub(crate) fn subscribe(subscription: &Subscription) -> Self {
146 let confirmed = subscription.channel.topic().map(|_| subscription.confirmed);
147 Self::Subscribe {
148 id: subscription.id.clone(),
149 channel: subscription.channel,
150 confirmed,
151 filters: subscription.filters.clone(),
152 }
153 }
154}
155
156#[derive(Debug, Clone)]
165pub struct ChannelEvent {
166 pub id: String,
168 pub channel: String,
170 pub confirmed: bool,
172 pub seq: u64,
176 pub sent_at_ms: u64,
179 pub data: Payload,
181}
182
183#[derive(Debug, Clone, Deserialize)]
185#[serde(tag = "type", rename_all = "snake_case")]
186pub(crate) enum InboundFrame {
187 Event {
188 id: String,
189 channel: String,
190 #[serde(default = "confirmed_default")]
191 confirmed: bool,
192 seq: u64,
193 sent_at_ms: u64,
194 data: serde_json::Value,
195 },
196 Subscribed {
197 #[allow(dead_code)]
198 id: String,
199 #[allow(dead_code)]
200 channel: Option<String>,
201 #[allow(dead_code)]
202 confirmed: Option<bool>,
203 },
204 Unsubscribed {
205 #[allow(dead_code)]
206 id: String,
207 #[allow(dead_code)]
208 channel: Option<String>,
209 },
210 #[cfg_attr(not(feature = "realtime"), allow(dead_code))]
211 Warning {
212 code: String,
213 id: Option<String>,
214 message: String,
215 },
216 Pong,
217 #[cfg_attr(not(feature = "realtime"), allow(dead_code))]
218 Error {
219 message: String,
220 code: Option<String>,
221 id: Option<String>,
222 channel: Option<String>,
223 #[allow(dead_code)]
224 skipped: Option<u64>,
225 },
226}
227
228fn confirmed_default() -> bool {
230 true
231}
232
233pub(crate) fn parse_inbound_frame(raw: &str) -> Option<InboundFrame> {
238 serde_json::from_str(raw).ok()
239}
240
241impl InboundFrame {
242 pub(crate) fn into_channel_event(self) -> Option<ChannelEvent> {
246 let Self::Event {
247 id,
248 channel,
249 confirmed,
250 seq,
251 sent_at_ms,
252 data,
253 } = self
254 else {
255 return None;
256 };
257 let payload = if let Ok(clob) = channel.parse::<ClobChannel>() {
258 Payload::from_clob_channel(clob, data)
259 } else if confirmed {
260 match channel.parse::<Channel>() {
261 Ok(topic) => Payload::from_channel(topic, data),
262 Err(_) => Payload::Other(data),
263 }
264 } else {
265 Payload::from_pending(data)
266 };
267 Some(ChannelEvent {
268 id,
269 channel,
270 confirmed,
271 seq,
272 sent_at_ms,
273 data: payload,
274 })
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use crate::realtime::channels::ClobChannel;
282 use crate::realtime::payloads::{OrderSide, Payload, TradingEventType};
283
284 #[test]
285 fn validates_required_filters() {
286 assert!(Subscription::new("w", Channel::Wallets).validate().is_err());
288 let ok = Subscription::new("w", Channel::Wallets).with_filters(ChannelFilters {
289 wallets: Some(vec!["0x1".into()]),
290 ..Default::default()
291 });
292 assert!(ok.validate().is_ok());
293
294 let markets = Subscription::new("m", Channel::Markets).with_filters(ChannelFilters {
296 token_ids: Some(vec!["1".into()]),
297 ..Default::default()
298 });
299 assert!(markets.validate().is_ok());
300
301 assert!(Subscription::new("t", Channel::Trading).validate().is_ok());
303
304 assert!(
306 Subscription::new("b", ClobChannel::Book)
307 .validate()
308 .is_err()
309 );
310 let clob = Subscription::new("b", ClobChannel::Book).with_filters(ChannelFilters {
311 token_ids: Some(vec!["1".into()]),
312 ..Default::default()
313 });
314 assert!(clob.validate().is_ok());
315 }
316
317 #[test]
318 fn parses_and_types_clob_event_frames() {
319 let raw = r#"{"type":"event","id":"mid","channel":"clob.midpoint","seq":42,"sent_at_ms":1721818200123,"data":{"asset_id":"7","market":"0xm","midpoint":0.5,"timestamp":1}}"#;
320 let event = parse_inbound_frame(raw)
321 .expect("valid frame")
322 .into_channel_event()
323 .expect("event");
324 assert_eq!(event.channel, "clob.midpoint");
325 assert_eq!(event.seq, 42);
326 assert_eq!(event.sent_at_ms, 1_721_818_200_123);
327 match event.data {
328 Payload::ClobMidpoint(mid) => {
329 assert_eq!(mid.asset_id, "7");
330 assert_eq!(mid.midpoint, 0.5);
331 }
332 other => panic!("expected clob midpoint payload, got {other:?}"),
333 }
334 }
335
336 #[test]
337 fn serializes_outbound_frames() {
338 let ping = serde_json::to_string(&OutboundFrame::Ping).unwrap();
339 assert_eq!(ping, r#"{"action":"ping"}"#);
340
341 let unsub = serde_json::to_string(&OutboundFrame::Unsubscribe { id: "x".into() }).unwrap();
342 assert_eq!(unsub, r#"{"action":"unsubscribe","id":"x"}"#);
343
344 let sub = OutboundFrame::subscribe(&Subscription::new("trading", Channel::Trading));
345 let json: serde_json::Value =
346 serde_json::from_str(&serde_json::to_string(&sub).unwrap()).unwrap();
347 assert_eq!(json["action"], "subscribe");
348 assert_eq!(json["channel"], "trading");
349 assert_eq!(json["confirmed"], true);
350 assert!(json.get("filters").is_none());
352
353 let pending = OutboundFrame::subscribe(&Subscription::new("t", Channel::Trading).pending());
354 let json: serde_json::Value =
355 serde_json::from_str(&serde_json::to_string(&pending).unwrap()).unwrap();
356 assert_eq!(json["channel"], "trading");
357 assert_eq!(json["confirmed"], false);
358
359 let clob = OutboundFrame::subscribe(
360 &Subscription::new("b", ClobChannel::Book).with_filters(ChannelFilters {
361 token_ids: Some(vec!["1".into()]),
362 ..Default::default()
363 }),
364 );
365 let json: serde_json::Value =
366 serde_json::from_str(&serde_json::to_string(&clob).unwrap()).unwrap();
367 assert!(json.get("confirmed").is_none());
368 }
369
370 #[test]
371 fn parses_and_types_confirmed_event_frames() {
372 let raw = r#"{"type":"event","id":"t","channel":"trading","confirmed":true,"seq":42,"sent_at_ms":1721818200123,"data":{"type":"order_filled_v2","side":1,"tokenId":"0xabc"}}"#;
373 let frame = parse_inbound_frame(raw).expect("valid frame");
374 let event = frame.into_channel_event().expect("event");
375 assert_eq!(event.id, "t");
376 assert!(event.confirmed);
377 assert_eq!(event.seq, 42);
378 assert_eq!(event.sent_at_ms, 1_721_818_200_123);
379 match event.data {
380 Payload::Trading(trade) => {
381 assert_eq!(trade.kind, TradingEventType::OrderFilledV2);
382 assert_eq!(trade.side, Some(1));
383 assert_eq!(trade.token_id.as_deref(), Some("0xabc"));
384 }
385 other => panic!("expected trading payload, got {other:?}"),
386 }
387 }
388
389 #[test]
390 fn parses_and_types_pending_event_frames() {
391 let raw = r#"{"type":"event","id":"t","channel":"trading","confirmed":false,"seq":42,"sent_at_ms":1721818200123,"data":{"seen_at_ms":1782027489000,"transaction_hash":"0xhash","from":"0xfrom","to":"0xto","contract_kinds":["exchange"],"method_selector":"0xabcdef12","input":"0xdead","value":"0","call":{"method":"fillOrder","market_ids":["0xm"],"token_ids":["7"],"wallets":["0xw"],"notional_usd":192.5,"orders":[{"maker":"0xa","taker":null,"token_id":"7","side":"buy","maker_amount":"100","taker_amount":"50"}]}}}"#;
392 let event = parse_inbound_frame(raw)
393 .expect("valid frame")
394 .into_channel_event()
395 .expect("event");
396 assert_eq!(event.channel, "trading");
397 assert!(!event.confirmed);
398 match event.data {
399 Payload::Mempool(tx) => {
400 assert_eq!(tx.transaction_hash, "0xhash");
401 assert_eq!(tx.from, "0xfrom");
402 let call = tx.call.expect("call present");
403 assert_eq!(call.method, "fillOrder");
404 assert_eq!(call.notional_usd, Some(192.5));
405 assert_eq!(call.orders.len(), 1);
406 assert_eq!(call.orders[0].side, OrderSide::Buy);
407 assert!(call.orders[0].taker.is_none());
408 }
409 other => panic!("expected mempool payload, got {other:?}"),
410 }
411 }
412
413 #[test]
414 fn missing_confirmed_defaults_to_confirmed_feed() {
415 let raw = r#"{"type":"event","id":"t","channel":"trading","seq":42,"sent_at_ms":1721818200123,"data":{"type":"order_cancelled"}}"#;
416 let event = parse_inbound_frame(raw)
417 .unwrap()
418 .into_channel_event()
419 .unwrap();
420 assert!(event.confirmed);
421 }
422
423 #[test]
424 fn unknown_channel_falls_back_to_other() {
425 let raw = r#"{"type":"event","id":"m","channel":"unknownz","confirmed":true,"seq":42,"sent_at_ms":1721818200123,"data":{"foo":1}}"#;
426 let event = parse_inbound_frame(raw)
427 .unwrap()
428 .into_channel_event()
429 .unwrap();
430 assert!(matches!(event.data, Payload::Other(_)));
431 }
432
433 #[test]
434 fn parses_warning_frame() {
435 let raw = r#"{"type":"warning","code":"mempool_unavailable","id":"t","message":"no pending stream"}"#;
436 assert!(matches!(
437 parse_inbound_frame(raw),
438 Some(InboundFrame::Warning { .. })
439 ));
440 }
441
442 #[test]
443 fn subscribed_ack_echoes_confirmed() {
444 let raw = r#"{"type":"subscribed","id":"t","channel":"trading","confirmed":false}"#;
445 assert!(matches!(
446 parse_inbound_frame(raw),
447 Some(InboundFrame::Subscribed {
448 confirmed: Some(false),
449 ..
450 })
451 ));
452 }
453
454 #[test]
455 fn drops_malformed_frames() {
456 assert!(parse_inbound_frame("not json").is_none());
457 assert!(parse_inbound_frame(r#"{"type":"mystery"}"#).is_none());
458 }
459
460 #[test]
461 fn drops_event_frames_missing_seq_or_sent_at_ms() {
462 let no_envelope =
463 r#"{"type":"event","id":"t","channel":"trading","data":{"type":"order_cancelled"}}"#;
464 assert!(parse_inbound_frame(no_envelope).is_none());
465
466 let no_sent_at_ms = r#"{"type":"event","id":"t","channel":"trading","seq":42,"data":{"type":"order_cancelled"}}"#;
467 assert!(parse_inbound_frame(no_sent_at_ms).is_none());
468 }
469
470 #[test]
471 fn parses_error_frame() {
472 let raw = r#"{"type":"error","message":"boom","code":"bad","id":"x"}"#;
473 assert!(matches!(
474 parse_inbound_frame(raw),
475 Some(InboundFrame::Error { .. })
476 ));
477 }
478}