1use std::fmt;
6
7use crate::utils::{Address, Json};
8
9pub use crate::markets::candle::Interval;
11
12#[derive(Debug, Clone, PartialEq, Eq, Hash)]
15pub enum Subscription {
16 AllMids {
18 dex: String,
19 },
20 Notification {
22 user: Address,
23 },
24 WebData3 {
26 user: Address,
27 },
28 TwapStates {
29 user: Address,
30 dex: String,
31 },
32 ClearinghouseState {
33 user: Address,
34 dex: String,
35 },
36 OpenOrders {
37 user: Address,
38 dex: String,
39 },
40 Candle {
41 coin: String,
42 interval: Interval,
43 },
44 L2Book {
47 coin: String,
48 n_sig_figs: Option<u8>,
49 mantissa: Option<u8>,
50 fast: bool,
51 },
52 Trades {
53 coin: String,
54 },
55 OrderUpdates {
56 user: Address,
57 },
58 UserEvents {
60 user: Address,
61 },
62 UserFills {
64 user: Address,
65 aggregate_by_time: bool,
66 },
67 UserFundings {
68 user: Address,
69 },
70 UserNonFundingLedgerUpdates {
71 user: Address,
72 },
73 ActiveAssetCtx {
75 coin: String,
76 },
77 ActiveAssetData {
79 user: Address,
80 coin: String,
81 },
82 UserTwapSliceFills {
83 user: Address,
84 },
85 UserTwapHistory {
86 user: Address,
87 },
88 Bbo {
90 coin: String,
91 },
92 SpotState {
95 user: Address,
96 ignore_portfolio_margin: bool,
97 },
98 AllDexsClearinghouseState {
99 user: Address,
100 },
101 AllDexsAssetCtxs,
103 OutcomeMetaUpdates,
105 FastAssetCtxs,
107 AssetCtxs {
109 dex: String,
110 },
111 SpotAssetCtxs,
113 UserHistoricalOrders {
115 user: Address,
116 },
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
121pub enum BadSubscription {
122 #[error("a subscription is an object")]
123 NotAnObject,
124 #[error("`{0}` is not a subscription the venue serves")]
125 UnknownType(String),
126 #[error("`{kind}` needs `{field}`")]
127 Missing { kind: &'static str, field: &'static str },
128 #[error("`{field}` of `{kind}` must be {expected}")]
129 Wrong { kind: &'static str, field: &'static str, expected: &'static str },
130 #[error("a coin is never an empty string")]
131 EmptyCoin,
132 #[error("nSigFigs is 2, 3, 4 or 5, not {0}")]
133 SigFigs(u8),
134 #[error("mantissa is 1, 2 or 5, and only with nSigFigs 5; {0} is refused here")]
135 Mantissa(u8),
136}
137
138const KINDS: [&str; 27] = [
140 "allMids",
141 "notification",
142 "webData3",
143 "twapStates",
144 "clearinghouseState",
145 "openOrders",
146 "candle",
147 "l2Book",
148 "trades",
149 "orderUpdates",
150 "userEvents",
151 "userFills",
152 "userFundings",
153 "userNonFundingLedgerUpdates",
154 "activeAssetCtx",
155 "activeAssetData",
156 "userTwapSliceFills",
157 "userTwapHistory",
158 "bbo",
159 "spotState",
160 "allDexsClearinghouseState",
161 "allDexsAssetCtxs",
162 "outcomeMetaUpdates",
163 "fastAssetCtxs",
164 "assetCtxs",
165 "spotAssetCtxs",
166 "userHistoricalOrders",
167];
168
169impl Subscription {
170 #[must_use]
172 pub const fn kind(&self) -> &'static str {
173 match self {
174 Subscription::AllMids { .. } => "allMids",
175 Subscription::Notification { .. } => "notification",
176 Subscription::WebData3 { .. } => "webData3",
177 Subscription::TwapStates { .. } => "twapStates",
178 Subscription::ClearinghouseState { .. } => "clearinghouseState",
179 Subscription::OpenOrders { .. } => "openOrders",
180 Subscription::Candle { .. } => "candle",
181 Subscription::L2Book { .. } => "l2Book",
182 Subscription::Trades { .. } => "trades",
183 Subscription::OrderUpdates { .. } => "orderUpdates",
184 Subscription::UserEvents { .. } => "userEvents",
185 Subscription::UserFills { .. } => "userFills",
186 Subscription::UserFundings { .. } => "userFundings",
187 Subscription::UserNonFundingLedgerUpdates { .. } => "userNonFundingLedgerUpdates",
188 Subscription::ActiveAssetCtx { .. } => "activeAssetCtx",
189 Subscription::ActiveAssetData { .. } => "activeAssetData",
190 Subscription::UserTwapSliceFills { .. } => "userTwapSliceFills",
191 Subscription::UserTwapHistory { .. } => "userTwapHistory",
192 Subscription::Bbo { .. } => "bbo",
193 Subscription::SpotState { .. } => "spotState",
194 Subscription::AllDexsClearinghouseState { .. } => "allDexsClearinghouseState",
195 Subscription::AllDexsAssetCtxs => "allDexsAssetCtxs",
196 Subscription::OutcomeMetaUpdates => "outcomeMetaUpdates",
197 Subscription::FastAssetCtxs => "fastAssetCtxs",
198 Subscription::AssetCtxs { .. } => "assetCtxs",
199 Subscription::SpotAssetCtxs => "spotAssetCtxs",
200 Subscription::UserHistoricalOrders { .. } => "userHistoricalOrders",
201 }
202 }
203
204 #[must_use]
208 pub fn channel(&self) -> &'static str {
209 match self {
210 Subscription::UserEvents { .. } => "user",
211 Subscription::ActiveAssetCtx { coin } if is_spot(coin) => "activeSpotAssetCtx",
212 other => other.kind(),
213 }
214 }
215
216 #[must_use]
219 pub const fn user(&self) -> Option<Address> {
220 match self {
221 Subscription::Notification { user }
222 | Subscription::WebData3 { user }
223 | Subscription::TwapStates { user, .. }
224 | Subscription::ClearinghouseState { user, .. }
225 | Subscription::OpenOrders { user, .. }
226 | Subscription::OrderUpdates { user }
227 | Subscription::UserEvents { user }
228 | Subscription::UserFills { user, .. }
229 | Subscription::UserFundings { user }
230 | Subscription::UserNonFundingLedgerUpdates { user }
231 | Subscription::ActiveAssetData { user, .. }
232 | Subscription::UserTwapSliceFills { user }
233 | Subscription::UserTwapHistory { user }
234 | Subscription::SpotState { user, .. }
235 | Subscription::AllDexsClearinghouseState { user }
236 | Subscription::UserHistoricalOrders { user } => Some(*user),
237 _ => None,
238 }
239 }
240
241 #[must_use]
243 pub fn coin(&self) -> Option<&str> {
244 match self {
245 Subscription::Candle { coin, .. }
246 | Subscription::L2Book { coin, .. }
247 | Subscription::Trades { coin }
248 | Subscription::ActiveAssetCtx { coin }
249 | Subscription::ActiveAssetData { coin, .. }
250 | Subscription::Bbo { coin } => Some(coin),
251 _ => None,
252 }
253 }
254
255 #[must_use]
260 pub fn to_json(&self) -> Json {
261 let mut fields = vec![("type", text(self.kind()))];
262 match self {
263 Subscription::AllMids { dex } if !dex.is_empty() => fields.push(("dex", text(dex))),
264 Subscription::TwapStates { user, dex }
265 | Subscription::ClearinghouseState { user, dex }
266 | Subscription::OpenOrders { user, dex } => {
267 fields.push(("user", text(&user.to_hex())));
268 fields.push(("dex", text(dex)));
269 }
270 Subscription::Candle { coin, interval } => {
271 fields.push(("coin", text(coin)));
272 fields.push(("interval", text(interval.wire())));
273 }
274 Subscription::L2Book { coin, n_sig_figs, mantissa, fast } => {
275 fields.push(("coin", text(coin)));
276 if let Some(figures) = n_sig_figs {
277 fields.push(("nSigFigs", Json::Number(figures.to_string())));
278 }
279 if let Some(mantissa) = mantissa {
280 fields.push(("mantissa", Json::Number(mantissa.to_string())));
281 }
282 if *fast {
283 fields.push(("fast", Json::Bool(true)));
284 }
285 }
286 Subscription::Trades { coin } | Subscription::ActiveAssetCtx { coin } | Subscription::Bbo { coin } => {
287 fields.push(("coin", text(coin)));
288 }
289 Subscription::UserFills { user, aggregate_by_time } => {
290 fields.push(("user", text(&user.to_hex())));
291 if *aggregate_by_time {
292 fields.push(("aggregateByTime", Json::Bool(true)));
293 }
294 }
295 Subscription::ActiveAssetData { user, coin } => {
296 fields.push(("user", text(&user.to_hex())));
297 fields.push(("coin", text(coin)));
298 }
299 Subscription::SpotState { user, ignore_portfolio_margin } => {
300 fields.push(("user", text(&user.to_hex())));
301 if *ignore_portfolio_margin {
302 fields.push(("ignorePortfolioMargin", Json::Bool(true)));
303 }
304 }
305 Subscription::AssetCtxs { dex } => fields.push(("dex", text(dex))),
306 Subscription::Notification { user }
307 | Subscription::WebData3 { user }
308 | Subscription::OrderUpdates { user }
309 | Subscription::UserEvents { user }
310 | Subscription::UserFundings { user }
311 | Subscription::UserNonFundingLedgerUpdates { user }
312 | Subscription::UserTwapSliceFills { user }
313 | Subscription::UserTwapHistory { user }
314 | Subscription::AllDexsClearinghouseState { user }
315 | Subscription::UserHistoricalOrders { user } => fields.push(("user", text(&user.to_hex()))),
316 Subscription::AllMids { .. }
317 | Subscription::AllDexsAssetCtxs
318 | Subscription::OutcomeMetaUpdates
319 | Subscription::FastAssetCtxs
320 | Subscription::SpotAssetCtxs => {}
321 }
322 Json::Object(fields.into_iter().map(|(key, value)| (key.to_owned(), value)).collect())
323 }
324
325 pub fn check(&self) -> Result<(), BadSubscription> {
329 if self.coin().is_some_and(str::is_empty) {
330 return Err(BadSubscription::EmptyCoin);
331 }
332 if let Subscription::L2Book { n_sig_figs, mantissa, .. } = self {
333 if let Some(figures) = n_sig_figs.filter(|figures| !(2..=5).contains(figures)) {
334 return Err(BadSubscription::SigFigs(figures));
335 }
336 if let Some(mantissa) = mantissa.filter(|mantissa| *n_sig_figs != Some(5) || ![1, 2, 5].contains(mantissa))
337 {
338 return Err(BadSubscription::Mantissa(mantissa));
339 }
340 }
341 Ok(())
342 }
343
344 pub fn parse(json: &Json) -> Result<Subscription, BadSubscription> {
347 if !matches!(json, Json::Object(_)) {
348 return Err(BadSubscription::NotAnObject);
349 }
350 let named = json.get("type").ok_or(BadSubscription::Missing { kind: "subscription", field: "type" })?;
351 let named = named.as_str().ok_or(BadSubscription::Wrong {
352 kind: "subscription",
353 field: "type",
354 expected: "a string",
355 })?;
356 let kind = KINDS
357 .into_iter()
358 .find(|kind| *kind == named)
359 .ok_or_else(|| BadSubscription::UnknownType(named.to_owned()))?;
360 let fields = Fields { json, kind };
361 Ok(match kind {
362 "allMids" => Subscription::AllMids { dex: fields.dex()? },
363 "notification" => Subscription::Notification { user: fields.user()? },
364 "webData3" => Subscription::WebData3 { user: fields.user()? },
365 "twapStates" => Subscription::TwapStates { user: fields.user()?, dex: fields.dex()? },
366 "clearinghouseState" => Subscription::ClearinghouseState { user: fields.user()?, dex: fields.dex()? },
367 "openOrders" => Subscription::OpenOrders { user: fields.user()?, dex: fields.dex()? },
368 "candle" => Subscription::Candle { coin: fields.coin()?, interval: fields.interval()? },
369 "l2Book" => Subscription::L2Book {
370 coin: fields.coin()?,
371 n_sig_figs: fields.small("nSigFigs")?,
372 mantissa: fields.small("mantissa")?,
373 fast: fields.flag("fast")?,
374 },
375 "trades" => Subscription::Trades { coin: fields.coin()? },
376 "orderUpdates" => Subscription::OrderUpdates { user: fields.user()? },
377 "userEvents" => Subscription::UserEvents { user: fields.user()? },
378 "userFills" => {
379 Subscription::UserFills { user: fields.user()?, aggregate_by_time: fields.flag("aggregateByTime")? }
380 }
381 "userFundings" => Subscription::UserFundings { user: fields.user()? },
382 "userNonFundingLedgerUpdates" => Subscription::UserNonFundingLedgerUpdates { user: fields.user()? },
383 "activeAssetCtx" => Subscription::ActiveAssetCtx { coin: fields.coin()? },
384 "activeAssetData" => Subscription::ActiveAssetData { user: fields.user()?, coin: fields.coin()? },
385 "userTwapSliceFills" => Subscription::UserTwapSliceFills { user: fields.user()? },
386 "userTwapHistory" => Subscription::UserTwapHistory { user: fields.user()? },
387 "bbo" => Subscription::Bbo { coin: fields.coin()? },
388 "spotState" => Subscription::SpotState {
389 user: fields.user()?,
390 ignore_portfolio_margin: fields.flag("ignorePortfolioMargin")?,
391 },
392 "allDexsClearinghouseState" => Subscription::AllDexsClearinghouseState { user: fields.user()? },
393 "allDexsAssetCtxs" => Subscription::AllDexsAssetCtxs,
394 "outcomeMetaUpdates" => Subscription::OutcomeMetaUpdates,
395 "fastAssetCtxs" => Subscription::FastAssetCtxs,
396 "assetCtxs" => Subscription::AssetCtxs { dex: fields.dex()? },
397 "spotAssetCtxs" => Subscription::SpotAssetCtxs,
398 "userHistoricalOrders" => Subscription::UserHistoricalOrders { user: fields.user()? },
399 other => return Err(BadSubscription::UnknownType(other.to_owned())),
400 })
401 }
402}
403
404impl fmt::Display for Subscription {
405 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
407 f.write_str(&self.to_json().compact())
408 }
409}
410
411fn text(value: &str) -> Json {
412 Json::String(value.to_owned())
413}
414
415fn is_spot(coin: &str) -> bool {
417 coin.starts_with(['@', '#', '+']) || coin.contains('/')
418}
419
420struct Fields<'a> {
422 json: &'a Json,
423 kind: &'static str,
424}
425
426impl Fields<'_> {
427 fn wrong(&self, field: &'static str, expected: &'static str) -> BadSubscription {
428 BadSubscription::Wrong { kind: self.kind, field, expected }
429 }
430
431 fn present(&self, field: &str) -> Option<&Json> {
433 self.json.get(field).filter(|value| !matches!(value, Json::Null))
434 }
435
436 fn string(&self, field: &'static str) -> Result<&str, BadSubscription> {
437 let value = self.present(field).ok_or(BadSubscription::Missing { kind: self.kind, field })?;
438 value.as_str().ok_or_else(|| self.wrong(field, "a string"))
439 }
440
441 fn user(&self) -> Result<Address, BadSubscription> {
442 self.string("user")?.parse().map_err(|_| self.wrong("user", "an address"))
443 }
444
445 fn coin(&self) -> Result<String, BadSubscription> {
446 self.string("coin").map(ToOwned::to_owned)
447 }
448
449 fn interval(&self) -> Result<Interval, BadSubscription> {
450 Interval::parse(self.string("interval")?).ok_or_else(|| self.wrong("interval", "an interval the docs list"))
451 }
452
453 fn dex(&self) -> Result<String, BadSubscription> {
454 match self.present("dex") {
455 None => Ok(String::new()),
456 Some(value) => value.as_str().map(ToOwned::to_owned).ok_or_else(|| self.wrong("dex", "a string")),
457 }
458 }
459
460 fn flag(&self, field: &'static str) -> Result<bool, BadSubscription> {
461 match self.present(field) {
462 None => Ok(false),
463 Some(value) => value.as_bool().ok_or_else(|| self.wrong(field, "true or false")),
464 }
465 }
466
467 fn small(&self, field: &'static str) -> Result<Option<u8>, BadSubscription> {
468 match self.present(field) {
469 None => Ok(None),
470 Some(value) => value
471 .as_u64()
472 .and_then(|number| u8::try_from(number).ok())
473 .map(Some)
474 .ok_or_else(|| self.wrong(field, "a small whole number")),
475 }
476 }
477}
478
479#[cfg(test)]
480#[path = "tests/subscription.rs"]
481mod tests;