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)]
89#[serde(from = "PerpMeta")]
90pub struct Perps(HashMap<SmolStr, PerpCoin>);
91
92impl Perps {
93 pub fn get(&self, name: &str) -> Option<&PerpCoin> {
102 self.0
103 .get(name)
104 .or_else(|| unique(self.0.values(), |perp| perp.coin.eq_ignore_ascii_case(name)))
105 }
106
107 pub fn len(&self) -> usize {
109 self.0.len()
110 }
111
112 pub fn is_empty(&self) -> bool {
114 self.0.is_empty()
115 }
116
117 pub fn iter(&self) -> impl Iterator<Item = &PerpCoin> {
119 self.0.values()
120 }
121}
122
123impl FromIterator<PerpCoin> for Perps {
124 fn from_iter<I: IntoIterator<Item = PerpCoin>>(iter: I) -> Self {
125 Self(
126 iter.into_iter()
127 .map(|perp| (perp.coin.clone(), perp))
128 .collect(),
129 )
130 }
131}
132
133impl FromIterator<Perps> for Perps {
134 fn from_iter<I: IntoIterator<Item = Perps>>(iter: I) -> Self {
135 Self(iter.into_iter().flat_map(|perps| perps.0).collect())
136 }
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Hash)]
141pub struct PerpCoin {
142 coin: SmolStr,
143 delisted: bool,
144}
145
146impl PerpCoin {
147 pub fn coin(&self) -> &str {
150 &self.coin
151 }
152
153 pub fn deployer(&self) -> Option<&str> {
156 self.coin.split_once(':').map(|(deployer, _)| deployer)
157 }
158
159 pub fn is_delisted(&self) -> bool {
162 self.delisted
163 }
164}
165
166#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
179#[serde(from = "SpotMeta")]
180pub struct SpotPairs(HashMap<SmolStr, SpotPair>);
181
182impl SpotPairs {
183 pub fn get(&self, coin: &str) -> Option<&SpotPair> {
187 self.0.get(coin)
188 }
189
190 pub fn find(&self, base: &str, quote: &str) -> Option<&SpotPair> {
197 unique(self.0.values(), |pair| {
198 pair.base == base && pair.quote == quote
199 })
200 .or_else(|| {
201 unique(self.0.values(), |pair| {
202 pair.base.eq_ignore_ascii_case(base) && pair.quote.eq_ignore_ascii_case(quote)
203 })
204 })
205 }
206
207 pub fn len(&self) -> usize {
209 self.0.len()
210 }
211
212 pub fn is_empty(&self) -> bool {
214 self.0.is_empty()
215 }
216
217 pub fn iter(&self) -> impl Iterator<Item = &SpotPair> {
219 self.0.values()
220 }
221}
222
223#[derive(Debug, Clone, PartialEq, Eq, Hash)]
225pub struct SpotPair {
226 coin: SmolStr,
227 index: u32,
228 base: SmolStr,
229 quote: SmolStr,
230 base_sz_decimals: u32,
231}
232
233impl SpotPair {
234 pub fn coin(&self) -> &str {
236 &self.coin
237 }
238
239 pub fn index(&self) -> u32 {
242 self.index
243 }
244
245 pub fn base(&self) -> &str {
247 &self.base
248 }
249
250 pub fn quote(&self) -> &str {
252 &self.quote
253 }
254
255 pub fn base_sz_decimals(&self) -> u32 {
258 self.base_sz_decimals
259 }
260}
261
262fn unique<'a, T>(
264 items: impl IntoIterator<Item = &'a T>,
265 matches: impl Fn(&T) -> bool,
266) -> Option<&'a T> {
267 let mut found = items.into_iter().filter(|item| matches(item));
268 let first = found.next()?;
269 found.next().is_none().then_some(first)
270}
271
272#[derive(Deserialize)]
274struct PerpMeta {
275 universe: Vec<PerpMetaAsset>,
276}
277
278#[derive(Deserialize)]
279struct PerpMetaAsset {
280 name: SmolStr,
281 #[serde(default, rename = "isDelisted")]
282 delisted: bool,
283}
284
285impl From<PerpMeta> for Perps {
286 fn from(meta: PerpMeta) -> Self {
287 meta.universe
288 .into_iter()
289 .map(|asset| PerpCoin {
290 coin: asset.name,
291 delisted: asset.delisted,
292 })
293 .collect()
294 }
295}
296
297#[derive(Deserialize)]
299struct SpotMeta {
300 universe: Vec<SpotMetaPair>,
301 tokens: Vec<SpotMetaToken>,
302}
303
304#[derive(Deserialize)]
305struct SpotMetaPair {
306 name: SmolStr,
307 index: u32,
308 tokens: [u32; 2],
311}
312
313#[derive(Deserialize)]
314#[serde(rename_all = "camelCase")]
315struct SpotMetaToken {
316 name: SmolStr,
317 index: u32,
318 sz_decimals: u32,
319}
320
321impl From<SpotMeta> for SpotPairs {
322 fn from(meta: SpotMeta) -> Self {
323 let tokens: HashMap<u32, (SmolStr, u32)> = meta
324 .tokens
325 .into_iter()
326 .map(|token| (token.index, (token.name, token.sz_decimals)))
327 .collect();
328
329 Self(
330 meta.universe
331 .into_iter()
332 .filter_map(|pair| {
333 let [base, quote] = pair.tokens;
334 let (base, base_sz_decimals) = tokens.get(&base)?.clone();
335 let pair = SpotPair {
336 index: pair.index,
337 base,
338 quote: tokens.get("e)?.0.clone(),
339 coin: pair.name,
340 base_sz_decimals,
341 };
342 Some((pair.coin.clone(), pair))
343 })
344 .collect(),
345 )
346 }
347}
348
349#[cfg(test)]
350#[allow(clippy::unwrap_used)]
352mod tests {
353 use super::*;
354
355 const SPOT_META: &str = r#"{
359 "universe": [
360 {"name": "PURR/USDC", "tokens": [1, 0], "index": 0, "isCanonical": true},
361 {"name": "@107", "tokens": [150, 0], "index": 107, "isCanonical": false},
362 {"name": "@207", "tokens": [150, 268], "index": 207, "isCanonical": false},
363 {"name": "@300", "tokens": [999, 0], "index": 300, "isCanonical": false}
364 ],
365 "tokens": [
366 {"name": "USDC", "szDecimals": 8, "weiDecimals": 8, "index": 0, "isCanonical": true},
367 {"name": "PURR", "szDecimals": 0, "weiDecimals": 5, "index": 1, "isCanonical": true},
368 {"name": "HYPE", "szDecimals": 2, "weiDecimals": 8, "index": 150, "isCanonical": false},
369 {"name": "USDT0", "szDecimals": 2, "weiDecimals": 8, "index": 268, "isCanonical": false}
370 ]
371 }"#;
372
373 fn pairs() -> SpotPairs {
374 serde_json::from_str(SPOT_META).unwrap()
375 }
376
377 #[test]
378 fn spot_pairs_resolve_a_coin_to_its_tokens_by_index() {
379 let pairs = pairs();
380
381 let hype = pairs.get("@107").unwrap();
382 assert_eq!(
383 (hype.coin(), hype.index(), hype.base(), hype.quote()),
384 ("@107", 107, "HYPE", "USDC")
385 );
386 let hype_usdt0 = pairs.get("@207").unwrap();
387 assert_eq!((hype_usdt0.base(), hype_usdt0.quote()), ("HYPE", "USDT0"));
388 let purr = pairs.get("PURR/USDC").unwrap();
389 assert_eq!(
390 (purr.index(), purr.base(), purr.quote()),
391 (0, "PURR", "USDC")
392 );
393 }
394
395 #[test]
396 fn a_spot_pair_carries_its_base_tokens_sz_decimals() {
397 let pairs = pairs();
398
399 assert_eq!(pairs.get("@107").unwrap().base_sz_decimals(), 2, "HYPE");
400 assert_eq!(
401 pairs.get("@207").unwrap().base_sz_decimals(),
402 2,
403 "HYPE, not USDT0"
404 );
405 assert_eq!(
406 pairs.get("PURR/USDC").unwrap().base_sz_decimals(),
407 0,
408 "PURR"
409 );
410 }
411
412 #[test]
413 fn spot_pairs_leave_out_a_pair_with_an_unlisted_token() {
414 let pairs = pairs();
415
416 assert_eq!(pairs.get("@300"), None);
417 assert_eq!(pairs.len(), 3);
418 assert_eq!(pairs.iter().count(), 3);
419 }
420
421 #[test]
422 fn spot_pairs_match_a_coin_exactly() {
423 let pairs = pairs();
424
425 assert_eq!(pairs.get("purr/usdc"), None);
426 assert_eq!(pairs.get("@0"), None, "PURR/USDC is named only PURR/USDC");
427 assert_eq!(pairs.get("@999"), None);
428 assert!(SpotPairs::default().is_empty());
429 }
430
431 #[test]
432 fn spot_pairs_find_a_pair_by_its_tokens_ignoring_case() {
433 let pairs = pairs();
434
435 assert_eq!(pairs.find("HYPE", "USDT0").unwrap().coin(), "@207");
436 assert_eq!(pairs.find("hype", "usdc").unwrap().coin(), "@107");
437 assert_eq!(pairs.find("Purr", "Usdc").unwrap().coin(), "PURR/USDC");
438 assert_eq!(
439 pairs.find("USDC", "HYPE"),
440 None,
441 "base and quote are not interchangeable"
442 );
443 assert_eq!(pairs.find("HYPE", "USDH"), None);
444 }
445
446 #[test]
447 fn spot_pairs_find_no_pair_when_ignoring_case_matches_several() {
448 let pairs: SpotPairs = serde_json::from_str(
449 r#"{
450 "universe": [
451 {"name": "@1", "tokens": [1, 0], "index": 1},
452 {"name": "@2", "tokens": [2, 0], "index": 2}
453 ],
454 "tokens": [
455 {"name": "USDC", "szDecimals": 8, "index": 0},
456 {"name": "ABC", "szDecimals": 2, "index": 1},
457 {"name": "abc", "szDecimals": 2, "index": 2}
458 ]
459 }"#,
460 )
461 .unwrap();
462
463 assert_eq!(pairs.find("ABC", "USDC").unwrap().coin(), "@1");
464 assert_eq!(pairs.find("abc", "USDC").unwrap().coin(), "@2");
465 assert_eq!(pairs.find("Abc", "USDC"), None);
466 }
467
468 const META: &str = r#"{
471 "universe": [
472 {"szDecimals": 5, "name": "BTC", "maxLeverage": 40, "marginTableId": 56},
473 {"szDecimals": 0, "name": "kPEPE", "maxLeverage": 10, "marginTableId": 10},
474 {"szDecimals": 1, "name": "MATIC", "maxLeverage": 20, "marginTableId": 20,
475 "isDelisted": true}
476 ],
477 "marginTables": [],
478 "collateralToken": 0
479 }"#;
480
481 const META_XYZ: &str = r#"{
483 "universe": [
484 {"szDecimals": 3, "name": "xyz:TSLA", "maxLeverage": 20, "marginTableId": 20,
485 "growthMode": "enabled", "deployerFeeScale": "1.0"}
486 ],
487 "marginTables": [],
488 "collateralToken": 0
489 }"#;
490
491 fn perps() -> Perps {
492 [META, META_XYZ]
493 .into_iter()
494 .map(|meta| serde_json::from_str::<Perps>(meta).unwrap())
495 .collect()
496 }
497
498 #[test]
499 fn perps_collect_the_default_and_a_deployers_perpetuals() {
500 let perps = perps();
501
502 assert_eq!(perps.len(), 4);
503 assert_eq!(perps.iter().count(), 4);
504 let tsla = perps.get("xyz:TSLA").unwrap();
505 assert_eq!((tsla.coin(), tsla.deployer()), ("xyz:TSLA", Some("xyz")));
506 let btc = perps.get("BTC").unwrap();
507 assert_eq!((btc.deployer(), btc.is_delisted()), (None, false));
508 assert!(perps.get("MATIC").unwrap().is_delisted());
509 assert!(Perps::default().is_empty());
510 }
511
512 #[test]
513 fn perps_get_returns_the_venue_spelling_whatever_the_case_asked() {
514 let perps = perps();
515
516 for name in ["kPEPE", "KPEPE", "kpepe"] {
517 assert_eq!(perps.get(name).unwrap().coin(), "kPEPE", "{name:?}");
518 }
519 assert_eq!(perps.get("XYZ:tsla").unwrap().coin(), "xyz:TSLA");
520 assert_eq!(
521 perps.get("TSLA"),
522 None,
523 "a HIP-3 perpetual is named with its deployer"
524 );
525 assert_eq!(perps.get("ETH"), None);
526 }
527
528 #[test]
529 fn perps_get_prefers_an_exact_match_and_refuses_an_ambiguous_one() {
530 let perps: Perps =
531 serde_json::from_str(r#"{"universe": [{"name": "ABC"}, {"name": "abc"}]}"#).unwrap();
532
533 assert_eq!(perps.get("ABC").unwrap().coin(), "ABC");
534 assert_eq!(perps.get("abc").unwrap().coin(), "abc");
535 assert_eq!(perps.get("Abc"), None);
536 }
537
538 #[test]
539 fn network_serializes_in_lower_case() {
540 assert_eq!(
541 serde_json::to_string(&Network::Mainnet).unwrap(),
542 r#""mainnet""#
543 );
544 assert_eq!(
545 serde_json::from_str::<Network>(r#""testnet""#).unwrap(),
546 Network::Testnet
547 );
548 }
549
550 #[test]
551 fn coin_kind_judges_a_coin_by_its_shape() {
552 for (coin, kind) in [
553 ("BTC", CoinKind::Perp),
554 ("kPEPE", CoinKind::Perp),
555 ("xyz:TSLA", CoinKind::Perp),
556 ("xyz:XYZ100", CoinKind::Perp),
557 ("@107", CoinKind::Spot),
558 ("@0", CoinKind::Spot),
559 ("PURR/USDC", CoinKind::Spot),
560 ("#12", CoinKind::Unknown),
561 ("+12", CoinKind::Unknown),
562 ("", CoinKind::Unknown),
563 ("@", CoinKind::Unknown),
564 ("@1x", CoinKind::Unknown),
565 ("PURR/", CoinKind::Unknown),
566 ("/USDC", CoinKind::Unknown),
567 ("A/B/C", CoinKind::Unknown),
568 (":TSLA", CoinKind::Unknown),
569 ("xyz:", CoinKind::Unknown),
570 ("a:b:c", CoinKind::Unknown),
571 ("BTC-USD", CoinKind::Unknown),
572 ] {
573 assert_eq!(CoinKind::of(coin), kind, "{coin:?}");
574 }
575 }
576}