1use serde::{Deserialize, Serialize};
19use smol_str::SmolStr;
20use std::collections::HashMap;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26#[non_exhaustive]
27pub enum CoinKind {
28 Perp,
31 Spot,
33 Unknown,
37}
38
39impl CoinKind {
40 pub fn of(coin: &str) -> Self {
45 fn is_name(name: &str) -> bool {
46 !name.is_empty() && name.bytes().all(|byte| byte.is_ascii_alphanumeric())
47 }
48
49 let (kind, valid) = if let Some(index) = coin.strip_prefix('@') {
50 (
51 Self::Spot,
52 !index.is_empty() && index.bytes().all(|byte| byte.is_ascii_digit()),
53 )
54 } else if let Some((base, quote)) = coin.split_once('/') {
55 (Self::Spot, is_name(base) && is_name(quote))
56 } else if let Some((deployer, asset)) = coin.split_once(':') {
57 (Self::Perp, is_name(deployer) && is_name(asset))
58 } else {
59 (Self::Perp, is_name(coin))
60 };
61
62 if valid { kind } else { Self::Unknown }
63 }
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
71#[serde(rename_all = "lowercase")]
72pub enum Network {
73 Mainnet,
75 Testnet,
77}
78
79#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
91#[serde(from = "PerpMeta")]
92pub struct Perps(HashMap<SmolStr, PerpCoin>);
93
94impl Perps {
95 pub fn get(&self, name: &str) -> Option<&PerpCoin> {
104 self.0
105 .get(name)
106 .or_else(|| unique(self.0.values(), |perp| perp.coin.eq_ignore_ascii_case(name)))
107 }
108
109 pub fn len(&self) -> usize {
111 self.0.len()
112 }
113
114 pub fn is_empty(&self) -> bool {
116 self.0.is_empty()
117 }
118
119 pub fn iter(&self) -> impl Iterator<Item = &PerpCoin> {
121 self.0.values()
122 }
123}
124
125impl FromIterator<PerpCoin> for Perps {
126 fn from_iter<I: IntoIterator<Item = PerpCoin>>(iter: I) -> Self {
127 Self(
128 iter.into_iter()
129 .map(|perp| (perp.coin.clone(), perp))
130 .collect(),
131 )
132 }
133}
134
135impl FromIterator<Perps> for Perps {
136 fn from_iter<I: IntoIterator<Item = Perps>>(iter: I) -> Self {
137 Self(iter.into_iter().flat_map(|perps| perps.0).collect())
138 }
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Hash)]
143pub struct PerpCoin {
144 coin: SmolStr,
145 sz_decimals: u32,
146 delisted: bool,
147}
148
149impl PerpCoin {
150 pub fn coin(&self) -> &str {
153 &self.coin
154 }
155
156 pub fn deployer(&self) -> Option<&str> {
159 self.coin.split_once(':').map(|(deployer, _)| deployer)
160 }
161
162 pub fn sz_decimals(&self) -> u32 {
166 self.sz_decimals
167 }
168
169 pub fn is_delisted(&self) -> bool {
172 self.delisted
173 }
174}
175
176#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
189#[serde(from = "SpotMeta")]
190pub struct SpotPairs(HashMap<SmolStr, SpotPair>);
191
192impl SpotPairs {
193 pub fn get(&self, coin: &str) -> Option<&SpotPair> {
197 self.0.get(coin)
198 }
199
200 pub fn find(&self, base: &str, quote: &str) -> Option<&SpotPair> {
207 unique(self.0.values(), |pair| {
208 pair.base == base && pair.quote == quote
209 })
210 .or_else(|| {
211 unique(self.0.values(), |pair| {
212 pair.base.eq_ignore_ascii_case(base) && pair.quote.eq_ignore_ascii_case(quote)
213 })
214 })
215 }
216
217 pub fn len(&self) -> usize {
219 self.0.len()
220 }
221
222 pub fn is_empty(&self) -> bool {
224 self.0.is_empty()
225 }
226
227 pub fn iter(&self) -> impl Iterator<Item = &SpotPair> {
229 self.0.values()
230 }
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Hash)]
235pub struct SpotPair {
236 coin: SmolStr,
237 index: u32,
238 base: SmolStr,
239 quote: SmolStr,
240 base_sz_decimals: u32,
241}
242
243impl SpotPair {
244 pub fn coin(&self) -> &str {
246 &self.coin
247 }
248
249 pub fn index(&self) -> u32 {
252 self.index
253 }
254
255 pub fn base(&self) -> &str {
257 &self.base
258 }
259
260 pub fn quote(&self) -> &str {
262 &self.quote
263 }
264
265 pub fn base_sz_decimals(&self) -> u32 {
268 self.base_sz_decimals
269 }
270}
271
272fn unique<'a, T>(
274 items: impl IntoIterator<Item = &'a T>,
275 matches: impl Fn(&T) -> bool,
276) -> Option<&'a T> {
277 let mut found = items.into_iter().filter(|item| matches(item));
278 let first = found.next()?;
279 found.next().is_none().then_some(first)
280}
281
282#[derive(Deserialize)]
284struct PerpMeta {
285 universe: Vec<PerpMetaAsset>,
286}
287
288#[derive(Deserialize)]
289struct PerpMetaAsset {
290 name: SmolStr,
291 #[serde(rename = "szDecimals")]
292 sz_decimals: u32,
293 #[serde(default, rename = "isDelisted")]
294 delisted: bool,
295}
296
297impl From<PerpMeta> for Perps {
298 fn from(meta: PerpMeta) -> Self {
299 meta.universe
300 .into_iter()
301 .map(|asset| PerpCoin {
302 coin: asset.name,
303 sz_decimals: asset.sz_decimals,
304 delisted: asset.delisted,
305 })
306 .collect()
307 }
308}
309
310#[derive(Deserialize)]
312struct SpotMeta {
313 universe: Vec<SpotMetaPair>,
314 tokens: Vec<SpotMetaToken>,
315}
316
317#[derive(Deserialize)]
318struct SpotMetaPair {
319 name: SmolStr,
320 index: u32,
321 tokens: [u32; 2],
324}
325
326#[derive(Deserialize)]
327#[serde(rename_all = "camelCase")]
328struct SpotMetaToken {
329 name: SmolStr,
330 index: u32,
331 sz_decimals: u32,
332}
333
334impl From<SpotMeta> for SpotPairs {
335 fn from(meta: SpotMeta) -> Self {
336 let tokens: HashMap<u32, (SmolStr, u32)> = meta
337 .tokens
338 .into_iter()
339 .map(|token| (token.index, (token.name, token.sz_decimals)))
340 .collect();
341
342 Self(
343 meta.universe
344 .into_iter()
345 .filter_map(|pair| {
346 let [base, quote] = pair.tokens;
347 let (base, base_sz_decimals) = tokens.get(&base)?.clone();
348 let pair = SpotPair {
349 index: pair.index,
350 base,
351 quote: tokens.get("e)?.0.clone(),
352 coin: pair.name,
353 base_sz_decimals,
354 };
355 Some((pair.coin.clone(), pair))
356 })
357 .collect(),
358 )
359 }
360}
361
362#[cfg(test)]
363#[allow(clippy::unwrap_used)]
365mod tests {
366 use super::*;
367
368 const SPOT_META: &str = r#"{
372 "universe": [
373 {"name": "PURR/USDC", "tokens": [1, 0], "index": 0, "isCanonical": true},
374 {"name": "@107", "tokens": [150, 0], "index": 107, "isCanonical": false},
375 {"name": "@207", "tokens": [150, 268], "index": 207, "isCanonical": false},
376 {"name": "@300", "tokens": [999, 0], "index": 300, "isCanonical": false}
377 ],
378 "tokens": [
379 {"name": "USDC", "szDecimals": 8, "weiDecimals": 8, "index": 0, "isCanonical": true},
380 {"name": "PURR", "szDecimals": 0, "weiDecimals": 5, "index": 1, "isCanonical": true},
381 {"name": "HYPE", "szDecimals": 2, "weiDecimals": 8, "index": 150, "isCanonical": false},
382 {"name": "USDT0", "szDecimals": 2, "weiDecimals": 8, "index": 268, "isCanonical": false}
383 ]
384 }"#;
385
386 fn pairs() -> SpotPairs {
387 serde_json::from_str(SPOT_META).unwrap()
388 }
389
390 #[test]
391 fn spot_pairs_resolve_a_coin_to_its_tokens_by_index() {
392 let pairs = pairs();
393
394 let hype = pairs.get("@107").unwrap();
395 assert_eq!(
396 (hype.coin(), hype.index(), hype.base(), hype.quote()),
397 ("@107", 107, "HYPE", "USDC")
398 );
399 let hype_usdt0 = pairs.get("@207").unwrap();
400 assert_eq!((hype_usdt0.base(), hype_usdt0.quote()), ("HYPE", "USDT0"));
401 let purr = pairs.get("PURR/USDC").unwrap();
402 assert_eq!(
403 (purr.index(), purr.base(), purr.quote()),
404 (0, "PURR", "USDC")
405 );
406 }
407
408 #[test]
409 fn a_spot_pair_carries_its_base_tokens_sz_decimals() {
410 let pairs = pairs();
411
412 assert_eq!(pairs.get("@107").unwrap().base_sz_decimals(), 2, "HYPE");
413 assert_eq!(
414 pairs.get("@207").unwrap().base_sz_decimals(),
415 2,
416 "HYPE, not USDT0"
417 );
418 assert_eq!(
419 pairs.get("PURR/USDC").unwrap().base_sz_decimals(),
420 0,
421 "PURR"
422 );
423 }
424
425 #[test]
426 fn spot_pairs_leave_out_a_pair_with_an_unlisted_token() {
427 let pairs = pairs();
428
429 assert_eq!(pairs.get("@300"), None);
430 assert_eq!(pairs.len(), 3);
431 assert_eq!(pairs.iter().count(), 3);
432 }
433
434 #[test]
435 fn spot_pairs_match_a_coin_exactly() {
436 let pairs = pairs();
437
438 assert_eq!(pairs.get("purr/usdc"), None);
439 assert_eq!(pairs.get("@0"), None, "PURR/USDC is named only PURR/USDC");
440 assert_eq!(pairs.get("@999"), None);
441 assert!(SpotPairs::default().is_empty());
442 }
443
444 #[test]
445 fn spot_pairs_find_a_pair_by_its_tokens_ignoring_case() {
446 let pairs = pairs();
447
448 assert_eq!(pairs.find("HYPE", "USDT0").unwrap().coin(), "@207");
449 assert_eq!(pairs.find("hype", "usdc").unwrap().coin(), "@107");
450 assert_eq!(pairs.find("Purr", "Usdc").unwrap().coin(), "PURR/USDC");
451 assert_eq!(
452 pairs.find("USDC", "HYPE"),
453 None,
454 "base and quote are not interchangeable"
455 );
456 assert_eq!(pairs.find("HYPE", "USDH"), None);
457 }
458
459 #[test]
460 fn spot_pairs_find_no_pair_when_ignoring_case_matches_several() {
461 let pairs: SpotPairs = serde_json::from_str(
462 r#"{
463 "universe": [
464 {"name": "@1", "tokens": [1, 0], "index": 1},
465 {"name": "@2", "tokens": [2, 0], "index": 2}
466 ],
467 "tokens": [
468 {"name": "USDC", "szDecimals": 8, "index": 0},
469 {"name": "ABC", "szDecimals": 2, "index": 1},
470 {"name": "abc", "szDecimals": 2, "index": 2}
471 ]
472 }"#,
473 )
474 .unwrap();
475
476 assert_eq!(pairs.find("ABC", "USDC").unwrap().coin(), "@1");
477 assert_eq!(pairs.find("abc", "USDC").unwrap().coin(), "@2");
478 assert_eq!(pairs.find("Abc", "USDC"), None);
479 }
480
481 const META: &str = r#"{
484 "universe": [
485 {"szDecimals": 5, "name": "BTC", "maxLeverage": 40, "marginTableId": 56},
486 {"szDecimals": 0, "name": "kPEPE", "maxLeverage": 10, "marginTableId": 10},
487 {"szDecimals": 1, "name": "MATIC", "maxLeverage": 20, "marginTableId": 20,
488 "isDelisted": true}
489 ],
490 "marginTables": [],
491 "collateralToken": 0
492 }"#;
493
494 const META_XYZ: &str = r#"{
496 "universe": [
497 {"szDecimals": 3, "name": "xyz:TSLA", "maxLeverage": 20, "marginTableId": 20,
498 "growthMode": "enabled", "deployerFeeScale": "1.0"}
499 ],
500 "marginTables": [],
501 "collateralToken": 0
502 }"#;
503
504 fn perps() -> Perps {
505 [META, META_XYZ]
506 .into_iter()
507 .map(|meta| serde_json::from_str::<Perps>(meta).unwrap())
508 .collect()
509 }
510
511 #[test]
512 fn perps_collect_the_default_and_a_deployers_perpetuals() {
513 let perps = perps();
514
515 assert_eq!(perps.len(), 4);
516 assert_eq!(perps.iter().count(), 4);
517 let tsla = perps.get("xyz:TSLA").unwrap();
518 assert_eq!((tsla.coin(), tsla.deployer()), ("xyz:TSLA", Some("xyz")));
519 let btc = perps.get("BTC").unwrap();
520 assert_eq!((btc.deployer(), btc.is_delisted()), (None, false));
521 assert!(perps.get("MATIC").unwrap().is_delisted());
522 assert!(Perps::default().is_empty());
523 }
524
525 #[test]
526 fn a_perpetual_carries_its_sz_decimals() {
527 let perps = perps();
528
529 assert_eq!(perps.get("BTC").unwrap().sz_decimals(), 5);
530 assert_eq!(perps.get("kPEPE").unwrap().sz_decimals(), 0);
531 assert_eq!(perps.get("xyz:TSLA").unwrap().sz_decimals(), 3);
532 }
533
534 #[test]
535 fn a_perpetual_without_sz_decimals_is_refused() {
536 assert!(serde_json::from_str::<Perps>(r#"{"universe": [{"name": "BTC"}]}"#).is_err());
537 }
538
539 #[test]
540 fn perps_get_returns_the_venue_spelling_whatever_the_case_asked() {
541 let perps = perps();
542
543 for name in ["kPEPE", "KPEPE", "kpepe"] {
544 assert_eq!(perps.get(name).unwrap().coin(), "kPEPE", "{name:?}");
545 }
546 assert_eq!(perps.get("XYZ:tsla").unwrap().coin(), "xyz:TSLA");
547 assert_eq!(
548 perps.get("TSLA"),
549 None,
550 "a HIP-3 perpetual is named with its deployer"
551 );
552 assert_eq!(perps.get("ETH"), None);
553 }
554
555 #[test]
556 fn perps_get_prefers_an_exact_match_and_refuses_an_ambiguous_one() {
557 let perps: Perps = serde_json::from_str(
558 r#"{"universe": [{"name": "ABC", "szDecimals": 0}, {"name": "abc", "szDecimals": 0}]}"#,
559 )
560 .unwrap();
561
562 assert_eq!(perps.get("ABC").unwrap().coin(), "ABC");
563 assert_eq!(perps.get("abc").unwrap().coin(), "abc");
564 assert_eq!(perps.get("Abc"), None);
565 }
566
567 #[test]
568 fn network_serializes_in_lower_case() {
569 assert_eq!(
570 serde_json::to_string(&Network::Mainnet).unwrap(),
571 r#""mainnet""#
572 );
573 assert_eq!(
574 serde_json::from_str::<Network>(r#""testnet""#).unwrap(),
575 Network::Testnet
576 );
577 }
578
579 #[test]
580 fn coin_kind_judges_a_coin_by_its_shape() {
581 for (coin, kind) in [
582 ("BTC", CoinKind::Perp),
583 ("kPEPE", CoinKind::Perp),
584 ("xyz:TSLA", CoinKind::Perp),
585 ("xyz:XYZ100", CoinKind::Perp),
586 ("@107", CoinKind::Spot),
587 ("@0", CoinKind::Spot),
588 ("PURR/USDC", CoinKind::Spot),
589 ("#12", CoinKind::Unknown),
590 ("+12", CoinKind::Unknown),
591 ("", CoinKind::Unknown),
592 ("@", CoinKind::Unknown),
593 ("@1x", CoinKind::Unknown),
594 ("PURR/", CoinKind::Unknown),
595 ("/USDC", CoinKind::Unknown),
596 ("A/B/C", CoinKind::Unknown),
597 (":TSLA", CoinKind::Unknown),
598 ("xyz:", CoinKind::Unknown),
599 ("a:b:c", CoinKind::Unknown),
600 ("BTC-USD", CoinKind::Unknown),
601 ] {
602 assert_eq!(CoinKind::of(coin), kind, "{coin:?}");
603 }
604 }
605}