Skip to main content

radion_sdk/realtime/
protocol.rs

1//! Wire protocol: subscriptions, filters, and inbound/outbound frames.
2
3use 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/// Server-side filters narrowing the events delivered on a channel.
10///
11/// Some channels require a filter (for example `wallets` needs `wallets`,
12/// `markets` needs `market_ids` or `token_ids`); see the channel docs.
13#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
14pub struct ChannelFilters {
15    /// Wallet addresses to match (required by `wallets`, optional on `trading`).
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub wallets: Option<Vec<String>>,
18    /// Condition / market ids to match (required by `markets`).
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub market_ids: Option<Vec<String>>,
21    /// ERC-1155 token ids to match (required by `markets` and every `clob.*`).
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub token_ids: Option<Vec<String>>,
24    /// Minimum trade notional in USD (optional on `trading`).
25    ///
26    /// On the confirmed feed this is the actual filled USD; on the pending feed
27    /// it is the intended fill notional (`call.notional_usd`).
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub min_usd: Option<f64>,
30}
31
32impl ChannelFilters {
33    /// Whether the given filter key carries a value.
34    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/// A single channel subscription.
45///
46/// `id` is a client-defined string echoed back on acknowledgements and on every
47/// event frame, so multiple subscriptions to the same channel can be told
48/// apart. `channel` is a topic channel or a CLOB channel.
49///
50/// `confirmed` picks the feed: `true` (the default) streams confirmed on-chain
51/// events; `false` streams pending mempool transactions before block inclusion.
52/// It has no effect on CLOB channels, which have no pending feed.
53#[derive(Debug, Clone, PartialEq)]
54pub struct Subscription {
55    /// Client-defined id, echoed back on confirmations and event frames.
56    pub id: String,
57    /// Channel name: a topic channel or a `clob.`-prefixed channel.
58    pub channel: SubscribableChannel,
59    /// Which feed to stream: `true` = confirmed (default), `false` = pending.
60    pub confirmed: bool,
61    /// Optional server-side filters.
62    pub filters: Option<ChannelFilters>,
63}
64
65impl Subscription {
66    /// Create a subscription with no filters, on the confirmed feed.
67    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    /// Attach server-side filters.
77    #[must_use]
78    pub fn with_filters(mut self, filters: ChannelFilters) -> Self {
79        self.filters = Some(filters);
80        self
81    }
82
83    /// Choose the feed: `true` = confirmed on-chain events, `false` = pending
84    /// mempool transactions. Ignored for CLOB channels.
85    #[must_use]
86    pub fn confirmed(mut self, confirmed: bool) -> Self {
87        self.confirmed = confirmed;
88        self
89    }
90
91    /// Stream the pending (mempool) feed instead of confirmed events. Shorthand
92    /// for [`confirmed(false)`](Self::confirmed).
93    #[must_use]
94    pub fn pending(self) -> Self {
95        self.confirmed(false)
96    }
97
98    /// Validate that this subscription carries the filters its channel requires.
99    ///
100    /// # Errors
101    ///
102    /// Returns [`RadionError::Connection`] describing the first violation. The
103    /// requirement is the same for the confirmed and pending feeds; every CLOB
104    /// channel requires a `token_ids` filter.
105    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/// A frame sent from the client to the Radion server.
125#[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/// A data event delivered on a subscribed channel.
157///
158/// `id` identifies the subscription it belongs to; `channel` is the bare channel
159/// name (same for both feeds). `confirmed` says which feed it came from: `true` =
160/// confirmed on-chain event, `false` = pending mempool transaction. Route by
161/// `id`, not by the channel name. `seq` and `sent_at_ms` describe the envelope:
162/// gap detection and server send time. `data` is the typed payload — match on it
163/// to handle a specific event shape.
164#[derive(Debug, Clone)]
165pub struct ChannelEvent {
166    /// Subscription id this event belongs to.
167    pub id: String,
168    /// Bare channel name.
169    pub channel: String,
170    /// Which feed this event came from: `true` = confirmed, `false` = pending.
171    pub confirmed: bool,
172    /// Per-connection monotonic counter, starting at 0 and incremented by one
173    /// for every event frame across all subscriptions. A jump means frames
174    /// were dropped.
175    pub seq: u64,
176    /// Unix milliseconds when the server sent the frame. Server→client latency
177    /// is your receive time (ms) minus `sent_at_ms`.
178    pub sent_at_ms: u64,
179    /// Typed payload.
180    pub data: Payload,
181}
182
183/// A frame received from the server.
184#[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
228/// Default for a missing `confirmed` envelope field: confirmed feed.
229fn confirmed_default() -> bool {
230    true
231}
232
233/// Parse and validate a raw text frame into a typed [`InboundFrame`].
234///
235/// Returns `None` when the payload is not valid JSON or does not match a known
236/// frame envelope, so callers can drop malformed frames without erroring.
237pub(crate) fn parse_inbound_frame(raw: &str) -> Option<InboundFrame> {
238    serde_json::from_str(raw).ok()
239}
240
241impl InboundFrame {
242    /// Convert an `event` frame into a typed [`ChannelEvent`], decoding `data`
243    /// against the channel and the envelope's `confirmed` flag. Returns `None`
244    /// for non-event frames.
245    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        // `wallets` requires a wallets filter.
287        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        // `markets` accepts either market_ids or token_ids.
295        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        // `trading` requires nothing.
302        assert!(Subscription::new("t", Channel::Trading).validate().is_ok());
303
304        // Every clob channel requires token_ids.
305        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        // No filters key when none are set.
351        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}