1use crate::codecs::scalars::{MAX_PROTOCOL_SCALE, validate_protocol_scale};
9use crate::errors::{Error, Result};
10use crate::models::{DepositWithdrawConfig, ZippedAssetSupplyUpdate};
11use crate::realtime::{read_unpoisoned, write_unpoisoned};
12use serde_json::Value;
13use std::collections::HashMap;
14use std::sync::RwLock;
15
16fn parse_u32_id(value: Option<&Value>, field: &str) -> Result<u32> {
17 let Some(value) = value else {
18 return Err(Error::validation(format!("catalog {field} is required")));
19 };
20 let Some(raw) = value.as_u64() else {
21 return Err(Error::validation(format!(
22 "catalog {field} must be a positive integer"
23 )));
24 };
25 let id = u32::try_from(raw)
26 .map_err(|_| Error::validation(format!("catalog {field} {raw} exceeds u32 range")))?;
27 if id == 0 {
28 return Err(Error::validation(format!(
29 "catalog {field} must be non-zero"
30 )));
31 }
32 Ok(id)
33}
34
35fn parse_scale(value: Option<&Value>, field: &str) -> Result<u32> {
36 let Some(value) = value else {
37 return Err(Error::validation(format!("catalog {field} is required")));
38 };
39 let Some(raw) = value.as_u64() else {
40 return Err(Error::validation(format!(
41 "catalog {field} must be a non-negative integer"
42 )));
43 };
44 let scale = u32::try_from(raw).map_err(|_| {
45 Error::validation(format!(
46 "catalog {field} {raw} exceeds u32 range (max protocol scale {MAX_PROTOCOL_SCALE})"
47 ))
48 })?;
49 validate_protocol_scale(scale)?;
50 Ok(scale)
51}
52
53fn parse_optional_scale(value: Option<&Value>, field: &str) -> Result<Option<u32>> {
54 value
55 .map(|value| parse_scale(Some(value), field))
56 .transpose()
57}
58
59#[derive(Debug, Default)]
60pub struct Manager {
61 inner: RwLock<Inner>,
62}
63
64#[derive(Debug, Default)]
65struct Inner {
66 symbol_to_id: HashMap<String, u32>,
67 id_to_base_scale: HashMap<u32, u32>,
68 symbol_to_base_scale: HashMap<String, u32>,
69 id_to_quote_scale: HashMap<u32, u32>,
70 symbol_to_quote_scale: HashMap<String, u32>,
71 asset_to_ledger_id: HashMap<String, u32>,
72 asset_to_qty_scale: HashMap<String, u32>,
73 zipped_id_to_scale: HashMap<u32, u32>,
74 zipped_id_to_supply: HashMap<u32, String>,
76 orderbook_buckets: HashMap<String, Vec<String>>,
77 spot_config: Option<Value>,
78 zipper_config: Option<Value>,
79}
80
81#[derive(Default)]
82struct SpotSnapshot {
83 symbol_to_id: HashMap<String, u32>,
84 id_to_base_scale: HashMap<u32, u32>,
85 symbol_to_base_scale: HashMap<String, u32>,
86 id_to_quote_scale: HashMap<u32, u32>,
87 symbol_to_quote_scale: HashMap<String, u32>,
88 orderbook_buckets: HashMap<String, Vec<String>>,
89 spot_config: Value,
90}
91
92#[derive(Default)]
93struct ZipperSnapshot {
94 asset_to_ledger_id: HashMap<String, u32>,
95 asset_to_qty_scale: HashMap<String, u32>,
96 zipped_id_to_scale: HashMap<u32, u32>,
97 zipper_config: Value,
98}
99
100fn build_spot_snapshot(value: Value) -> Result<SpotSnapshot> {
101 let mut snap = SpotSnapshot {
102 spot_config: value.clone(),
103 ..Default::default()
104 };
105 let markets = value
106 .get("pairs")
107 .or_else(|| value.get("markets"))
108 .and_then(|m| m.as_array());
109 let Some(markets) = markets else {
110 return Err(Error::validation(
111 "catalog spot config must contain a pairs or markets array",
112 ));
113 };
114 let mut id_to_symbol = HashMap::<u32, String>::new();
115 for m in markets {
116 let symbol = m
117 .get("symbol")
118 .and_then(Value::as_str)
119 .map(str::trim)
120 .filter(|symbol| !symbol.is_empty())
121 .ok_or_else(|| Error::validation("catalog symbol must be non-empty"))?;
122 let symbol_id = parse_u32_id(
123 m.get("symbol_id").or_else(|| m.get("symbolId")),
124 "symbol_id",
125 )?;
126 let scale = parse_scale(
127 m.get("base_quantity_scale")
128 .or_else(|| m.get("baseQuantityScale")),
129 "base_quantity_scale",
130 )?;
131 let quote_scale = parse_optional_scale(
132 m.get("quote_quantity_scale")
133 .or_else(|| m.get("quoteQuantityScale")),
134 "quote_quantity_scale",
135 )?;
136 if snap.symbol_to_id.contains_key(symbol) {
137 return Err(Error::validation(format!(
138 "catalog contains duplicate symbol {symbol}"
139 )));
140 }
141 if let Some(existing) = id_to_symbol.get(&symbol_id) {
142 return Err(Error::validation(format!(
143 "catalog symbol_id {symbol_id} is shared by {existing} and {symbol}"
144 )));
145 }
146 snap.symbol_to_id.insert(symbol.to_owned(), symbol_id);
147 snap.symbol_to_base_scale.insert(symbol.to_owned(), scale);
148 snap.id_to_base_scale.insert(symbol_id, scale);
149 if let Some(quote_scale) = quote_scale {
150 snap.symbol_to_quote_scale
151 .insert(symbol.to_owned(), quote_scale);
152 snap.id_to_quote_scale.insert(symbol_id, quote_scale);
153 }
154 id_to_symbol.insert(symbol_id, symbol.to_owned());
155 let buckets = m
156 .get("orderbook_price_buckets")
157 .or_else(|| m.get("orderbookPriceBuckets"))
158 .or_else(|| {
159 m.get("marketdata")
160 .and_then(|md| md.get("orderbook_price_buckets"))
161 })
162 .and_then(|b| b.as_array());
163 if let Some(buckets) = buckets {
164 let list: Vec<String> = buckets
165 .iter()
166 .filter_map(|b| match b {
167 Value::String(s) => Some(s.clone()),
168 Value::Number(n) => Some(n.to_string()),
169 _ => None,
170 })
171 .collect();
172 if !list.is_empty() {
173 snap.orderbook_buckets.insert(symbol.to_owned(), list);
174 }
175 }
176 }
177 if snap.symbol_to_base_scale.is_empty() {
178 return Err(Error::validation(
179 "catalog spot config contains no usable markets",
180 ));
181 }
182 Ok(snap)
183}
184
185fn build_zipper_snapshot(value: Value) -> Result<ZipperSnapshot> {
186 let mut snap = ZipperSnapshot {
187 zipper_config: value.clone(),
188 ..Default::default()
189 };
190 let assets = value
191 .get("assets")
192 .and_then(Value::as_array)
193 .ok_or_else(|| Error::validation("catalog zipper config must contain an assets array"))?;
194 let mut ledger_id_to_asset = HashMap::<u32, String>::new();
195 let mut zipped_id_to_asset = HashMap::<u32, String>::new();
196 for a in assets {
197 let sym = a
198 .get("asset")
199 .or_else(|| a.get("symbol"))
200 .and_then(Value::as_str)
201 .map(str::trim)
202 .filter(|asset| !asset.is_empty())
203 .ok_or_else(|| Error::validation("catalog asset must be non-empty"))?;
204 let ledger_id = parse_u32_id(
205 a.get("ledger_id").or_else(|| a.get("ledgerId")),
206 "ledger_id",
207 )?;
208 let scale = parse_scale(
209 a.get("quantity_scale").or_else(|| a.get("quantityScale")),
210 "quantity_scale",
211 )?;
212 if snap.asset_to_ledger_id.contains_key(sym) {
213 return Err(Error::validation(format!(
214 "catalog contains duplicate asset {sym}"
215 )));
216 }
217 if let Some(existing) = ledger_id_to_asset.get(&ledger_id) {
218 return Err(Error::validation(format!(
219 "catalog ledger_id {ledger_id} is shared by {existing} and {sym}"
220 )));
221 }
222 snap.asset_to_ledger_id.insert(sym.to_owned(), ledger_id);
223 snap.asset_to_qty_scale.insert(sym.to_owned(), scale);
224 ledger_id_to_asset.insert(ledger_id, sym.to_owned());
225
226 if let Some(variants) = a.get("variants").and_then(Value::as_array) {
227 for variant in variants {
228 let zipped_id = parse_u32_id(
229 variant
230 .get("zipped_asset_id")
231 .or_else(|| variant.get("zippedAssetId")),
232 "zipped_asset_id",
233 )?;
234 if let Some(existing) = zipped_id_to_asset.get(&zipped_id) {
235 return Err(Error::validation(format!(
236 "catalog zipped_asset_id {zipped_id} is shared by {existing} and {sym}"
237 )));
238 }
239 if snap.zipped_id_to_scale.insert(zipped_id, scale).is_some() {
240 return Err(Error::validation(format!(
241 "catalog contains duplicate zipped_asset_id {zipped_id}"
242 )));
243 }
244 zipped_id_to_asset.insert(zipped_id, sym.to_owned());
245 }
246 }
247 }
248 if snap.asset_to_qty_scale.is_empty() {
249 return Err(Error::validation(
250 "catalog zipper config contains no usable assets",
251 ));
252 }
253 Ok(snap)
254}
255
256impl Manager {
257 pub fn new() -> Self {
258 Self::default()
259 }
260
261 pub fn is_ready(&self) -> bool {
264 let inner = read_unpoisoned(&self.inner);
265 inner.spot_config.is_some() && inner.zipper_config.is_some()
266 }
267
268 pub fn hydrate_spot_config_json(&self, value: Value) -> Result<()> {
270 let snap = build_spot_snapshot(value)?;
271 let mut inner = write_unpoisoned(&self.inner);
272 apply_spot(&mut inner, snap);
273 Ok(())
274 }
275
276 pub fn hydrate_zipper_config(&self, config: &DepositWithdrawConfig) -> Result<()> {
278 let value = serde_json::to_value(config)
279 .map_err(|e| Error::validation(format!("catalog zipper encode failed: {e}")))?;
280 self.hydrate_zipper_config_json(value)
281 }
282
283 pub fn hydrate_zipper_config_json(&self, value: Value) -> Result<()> {
285 let snap = build_zipper_snapshot(value)?;
286 let mut inner = write_unpoisoned(&self.inner);
287 apply_zipper(&mut inner, snap);
288 Ok(())
289 }
290
291 pub fn hydrate_spot_and_zipper_json(&self, spot: Value, zipper: Value) -> Result<()> {
295 let spot_snap = build_spot_snapshot(spot)?;
296 let zipper_snap = build_zipper_snapshot(zipper)?;
297 let mut inner = write_unpoisoned(&self.inner);
298 apply_spot(&mut inner, spot_snap);
299 apply_zipper(&mut inner, zipper_snap);
300 Ok(())
301 }
302
303 pub fn symbol_id_for_symbol(&self, symbol: &str) -> Option<u32> {
304 read_unpoisoned(&self.inner)
305 .symbol_to_id
306 .get(symbol)
307 .copied()
308 }
309
310 pub fn base_quantity_scale_for_symbol(&self, symbol: &str) -> Option<u32> {
315 read_unpoisoned(&self.inner)
316 .symbol_to_base_scale
317 .get(symbol)
318 .copied()
319 }
320
321 pub fn base_quantity_scale_for_symbol_id(&self, id: u32) -> Option<u32> {
322 read_unpoisoned(&self.inner)
323 .id_to_base_scale
324 .get(&id)
325 .copied()
326 }
327
328 pub fn quote_quantity_scale_for_symbol(&self, symbol: &str) -> Option<u32> {
333 read_unpoisoned(&self.inner)
334 .symbol_to_quote_scale
335 .get(symbol)
336 .copied()
337 }
338
339 pub fn quote_quantity_scale_for_symbol_id(&self, id: u32) -> Option<u32> {
340 read_unpoisoned(&self.inner)
341 .id_to_quote_scale
342 .get(&id)
343 .copied()
344 }
345
346 pub fn quantity_scale_for_zipped_asset_id(&self, id: u32) -> Option<u32> {
347 read_unpoisoned(&self.inner)
348 .zipped_id_to_scale
349 .get(&id)
350 .copied()
351 }
352
353 pub fn orderbook_price_buckets_for_symbol(&self, symbol: &str) -> Vec<String> {
354 read_unpoisoned(&self.inner)
355 .orderbook_buckets
356 .get(symbol)
357 .cloned()
358 .unwrap_or_default()
359 }
360
361 pub fn ledger_id_for_asset(&self, symbol: &str) -> Option<u32> {
362 read_unpoisoned(&self.inner)
363 .asset_to_ledger_id
364 .get(symbol)
365 .copied()
366 }
367
368 pub fn supply_for_zipped_asset_id(&self, id: u32) -> Option<String> {
370 read_unpoisoned(&self.inner)
371 .zipped_id_to_supply
372 .get(&id)
373 .cloned()
374 }
375
376 pub fn patch_zipper_supply(&self, updates: &[ZippedAssetSupplyUpdate]) -> bool {
382 if updates.is_empty() {
383 return false;
384 }
385 let mut inner = write_unpoisoned(&self.inner);
386 let mut changed = false;
387 for update in updates {
388 let prev = inner.zipped_id_to_supply.get(&update.zipped_asset_id);
389 if prev.map(String::as_str) != Some(update.supply.as_str()) {
390 inner
391 .zipped_id_to_supply
392 .insert(update.zipped_asset_id, update.supply.clone());
393 changed = true;
394 }
395 }
396 changed
397 }
398
399 #[cfg(test)]
400 fn poison_for_test(&self) {
401 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
402 let _guard = self.inner.write().expect("catalog lock");
403 panic!("poison catalog");
404 }));
405 assert!(result.is_err(), "poison panic must unwind");
406 assert!(
407 self.inner.read().is_err(),
408 "catalog RwLock must be poisoned for this test"
409 );
410 }
411}
412
413fn apply_spot(inner: &mut Inner, snap: SpotSnapshot) {
414 inner.symbol_to_id = snap.symbol_to_id;
416 inner.id_to_base_scale = snap.id_to_base_scale;
417 inner.symbol_to_base_scale = snap.symbol_to_base_scale;
418 inner.id_to_quote_scale = snap.id_to_quote_scale;
419 inner.symbol_to_quote_scale = snap.symbol_to_quote_scale;
420 inner.orderbook_buckets = snap.orderbook_buckets;
421 inner.spot_config = Some(snap.spot_config);
422}
423
424fn apply_zipper(inner: &mut Inner, snap: ZipperSnapshot) {
425 inner.asset_to_ledger_id = snap.asset_to_ledger_id;
426 inner.asset_to_qty_scale = snap.asset_to_qty_scale;
427 inner.zipped_id_to_scale = snap.zipped_id_to_scale;
428 inner.zipper_config = Some(snap.zipper_config);
429 }
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435 use serde_json::json;
436
437 #[test]
438 fn hydrate_spot_pairs_sets_symbol_scale_and_buckets() {
439 let mgr = Manager::new();
440 mgr.hydrate_spot_config_json(json!({
441 "pairs": [{
442 "symbol": "BTC-USDT",
443 "symbol_id": 1,
444 "base_quantity_scale": 8,
445 "quote_quantity_scale": 6,
446 "orderbook_price_buckets": [0.01, 0.1, 1.0]
447 }]
448 }))
449 .expect("hydrate");
450 assert_eq!(mgr.symbol_id_for_symbol("BTC-USDT"), Some(1));
451 assert_eq!(mgr.base_quantity_scale_for_symbol("BTC-USDT"), Some(8));
452 assert_eq!(mgr.base_quantity_scale_for_symbol_id(1), Some(8));
453 assert_eq!(mgr.quote_quantity_scale_for_symbol("BTC-USDT"), Some(6));
454 assert_eq!(mgr.quote_quantity_scale_for_symbol_id(1), Some(6));
455 assert_eq!(
456 mgr.orderbook_price_buckets_for_symbol("BTC-USDT"),
457 vec!["0.01".to_owned(), "0.1".to_owned(), "1.0".to_owned()]
458 );
459 }
460
461 #[test]
462 fn readiness_requires_usable_spot_and_zipper_snapshots() {
463 let mgr = Manager::new();
464 assert!(!mgr.is_ready());
465 assert!(mgr.hydrate_spot_config_json(json!({"pairs": []})).is_err());
466 assert!(
467 mgr.hydrate_zipper_config_json(json!({"assets": []}))
468 .is_err()
469 );
470 mgr.hydrate_spot_config_json(json!({
471 "pairs": [{
472 "symbol": "BTC-USDT",
473 "symbol_id": 1,
474 "base_quantity_scale": 8
475 }]
476 }))
477 .expect("spot");
478 assert!(!mgr.is_ready());
479 mgr.hydrate_zipper_config_json(json!({
480 "assets": [{
481 "asset": "USDT",
482 "ledger_id": 99,
483 "quantity_scale": 6
484 }]
485 }))
486 .expect("zipper");
487 assert!(mgr.is_ready());
488 }
489
490 #[test]
491 fn hydrate_rejects_oversized_scale_without_truncating() {
492 let mgr = Manager::new();
493 let err = mgr
494 .hydrate_spot_config_json(json!({
495 "pairs": [{
496 "symbol": "BTC-USDT",
497 "symbol_id": 1,
498 "base_quantity_scale": 65535
499 }]
500 }))
501 .expect_err("scale 65535 must fail");
502 assert!(err.to_string().contains("scale"));
503 assert_eq!(mgr.base_quantity_scale_for_symbol("BTC-USDT"), None);
504 }
505
506 #[test]
507 fn hydrate_spot_invalid_later_row_does_not_mutate_existing_catalog() {
508 let mgr = Manager::new();
509 mgr.hydrate_spot_config_json(json!({
510 "pairs": [{
511 "symbol": "BTC-USDT",
512 "symbol_id": 1,
513 "base_quantity_scale": 8
514 }]
515 }))
516 .expect("seed");
517 let err = mgr
518 .hydrate_spot_config_json(json!({
519 "pairs": [
520 {
521 "symbol": "ETH-USDT",
522 "symbol_id": 2,
523 "base_quantity_scale": 6
524 },
525 {
526 "symbol": "BAD-USDT",
527 "symbol_id": 3,
528 "base_quantity_scale": 65535
529 }
530 ]
531 }))
532 .expect_err("later invalid row must fail");
533 assert!(err.to_string().contains("scale"));
534 assert_eq!(mgr.base_quantity_scale_for_symbol("BTC-USDT"), Some(8));
536 assert_eq!(mgr.base_quantity_scale_for_symbol("ETH-USDT"), None);
537 assert_eq!(mgr.symbol_id_for_symbol("ETH-USDT"), None);
538 }
539
540 #[test]
541 fn hydrate_zipper_invalid_later_row_does_not_mutate_existing_catalog() {
542 let mgr = Manager::new();
543 mgr.hydrate_zipper_config_json(json!({
544 "assets": [{
545 "asset": "USDT",
546 "ledger_id": 99,
547 "quantity_scale": 6
548 }]
549 }))
550 .expect("seed");
551 let err = mgr
552 .hydrate_zipper_config_json(json!({
553 "assets": [
554 {
555 "asset": "BTC",
556 "ledger_id": 1,
557 "quantity_scale": 8
558 },
559 {
560 "asset": "BAD",
561 "ledger_id": 2,
562 "quantity_scale": 65535
563 }
564 ]
565 }))
566 .expect_err("later invalid row must fail");
567 assert!(err.to_string().contains("scale"));
568 assert_eq!(mgr.ledger_id_for_asset("USDT"), Some(99));
569 assert_eq!(mgr.ledger_id_for_asset("BTC"), None);
570 }
571
572 #[test]
573 fn catalog_refresh_replaces_stale_entries() {
574 let mgr = Manager::new();
575 mgr.hydrate_spot_config_json(json!({
576 "pairs": [{
577 "symbol": "OLD-USDT",
578 "symbol_id": 9,
579 "base_quantity_scale": 8
580 }]
581 }))
582 .expect("seed");
583 mgr.hydrate_spot_config_json(json!({
584 "pairs": [{
585 "symbol": "BTC-USDT",
586 "symbol_id": 1,
587 "base_quantity_scale": 8
588 }]
589 }))
590 .expect("refresh");
591 assert_eq!(mgr.symbol_id_for_symbol("OLD-USDT"), None);
592 assert_eq!(mgr.symbol_id_for_symbol("BTC-USDT"), Some(1));
593 }
594
595 #[test]
596 fn hydrate_spot_and_zipper_commits_neither_on_zipper_failure() {
597 let mgr = Manager::new();
598 let err = mgr
599 .hydrate_spot_and_zipper_json(
600 json!({
601 "pairs": [{
602 "symbol": "BTC-USDT",
603 "symbol_id": 1,
604 "base_quantity_scale": 8
605 }]
606 }),
607 json!({
608 "assets": [{
609 "asset": "USDT",
610 "ledger_id": 99,
611 "quantity_scale": 65535
612 }]
613 }),
614 )
615 .expect_err("zipper invalid must fail");
616 assert!(err.to_string().contains("scale"));
617 assert_eq!(mgr.base_quantity_scale_for_symbol("BTC-USDT"), None);
618 assert_eq!(mgr.ledger_id_for_asset("USDT"), None);
619 }
620
621 #[test]
622 fn hydrate_zipper_assets_sets_ledger_id() {
623 let mgr = Manager::new();
624 mgr.hydrate_zipper_config_json(json!({
625 "assets": [{
626 "asset": "USDT",
627 "ledger_id": 99,
628 "quantity_scale": 6,
629 "variants": [{
630 "zipped_asset_id": 42
631 }]
632 }]
633 }))
634 .expect("hydrate");
635 assert_eq!(mgr.ledger_id_for_asset("USDT"), Some(99));
636 assert_eq!(mgr.quantity_scale_for_zipped_asset_id(42), Some(6));
637 assert_eq!(mgr.quantity_scale_for_zipped_asset_id(99), None);
638 }
639
640 #[test]
641 fn unknown_symbol_returns_none_not_default_scale() {
642 let mgr = Manager::new();
643 assert_eq!(mgr.base_quantity_scale_for_symbol("NOPE"), None);
644 assert_eq!(mgr.base_quantity_scale_for_symbol("ETH-USDT"), None);
645 assert_eq!(mgr.quote_quantity_scale_for_symbol("NOPE"), None);
646 assert_eq!(mgr.quote_quantity_scale_for_symbol_id(999), None);
647 }
648
649 #[test]
650 fn malformed_quote_scale_rejects_catalog_atomically() {
651 let mgr = Manager::new();
652 let err = mgr
653 .hydrate_spot_config_json(json!({
654 "pairs": [{
655 "symbol": "BTC-USDT",
656 "symbol_id": 1,
657 "base_quantity_scale": 8,
658 "quote_quantity_scale": 65535
659 }]
660 }))
661 .expect_err("invalid quote scale must fail");
662 assert!(err.to_string().contains("scale"));
663 assert_eq!(mgr.symbol_id_for_symbol("BTC-USDT"), None);
664 assert_eq!(mgr.quote_quantity_scale_for_symbol("BTC-USDT"), None);
665 }
666
667 #[test]
668 fn hydrated_eth_usdt_uses_scale_6() {
669 let mgr = Manager::new();
670 mgr.hydrate_spot_config_json(json!({
671 "pairs": [{
672 "symbol": "ETH-USDT",
673 "symbol_id": 2,
674 "base_quantity_scale": 6
675 }]
676 }))
677 .expect("hydrate");
678 assert_eq!(mgr.base_quantity_scale_for_symbol("ETH-USDT"), Some(6));
679 }
680
681 #[test]
682 fn patch_zipper_supply_updates_map() {
683 let mgr = Manager::new();
684 assert!(mgr.patch_zipper_supply(&[ZippedAssetSupplyUpdate {
685 zipped_asset_id: 42,
686 supply: "100.5".to_owned(),
687 }]));
688 assert_eq!(mgr.supply_for_zipped_asset_id(42).as_deref(), Some("100.5"));
689 assert!(!mgr.patch_zipper_supply(&[ZippedAssetSupplyUpdate {
690 zipped_asset_id: 42,
691 supply: "100.5".to_owned(),
692 }]));
693 assert!(mgr.patch_zipper_supply(&[ZippedAssetSupplyUpdate {
694 zipped_asset_id: 42,
695 supply: "200".to_owned(),
696 }]));
697 assert_eq!(mgr.supply_for_zipped_asset_id(42).as_deref(), Some("200"));
698 }
699
700 #[test]
701 fn contradictory_spot_identities_fail_without_replacing_previous_catalog() {
702 let mgr = Manager::new();
703 mgr.hydrate_spot_config_json(json!({
704 "pairs": [{
705 "symbol": "BTC-USDT",
706 "symbol_id": 1,
707 "base_quantity_scale": 8
708 }]
709 }))
710 .unwrap();
711
712 for malformed in [
713 json!({"pairs": [
714 {"symbol": "ETH-USDT", "symbol_id": 2, "base_quantity_scale": 6},
715 {"symbol": "SOL-USDT", "symbol_id": 2, "base_quantity_scale": 8}
716 ]}),
717 json!({"pairs": [
718 {"symbol": "ETH-USDT", "symbol_id": 2, "base_quantity_scale": 6},
719 {"symbol": "ETH-USDT", "symbol_id": 3, "base_quantity_scale": 8}
720 ]}),
721 json!({"pairs": [
722 {"symbol": "", "symbol_id": 2, "base_quantity_scale": 6}
723 ]}),
724 json!({"pairs": [
725 {"symbol": "ETH-USDT", "symbol_id": 2}
726 ]}),
727 ] {
728 assert!(mgr.hydrate_spot_config_json(malformed).is_err());
729 assert_eq!(mgr.symbol_id_for_symbol("BTC-USDT"), Some(1));
730 assert_eq!(mgr.symbol_id_for_symbol("ETH-USDT"), None);
731 }
732 }
733
734 #[test]
735 fn contradictory_zipper_identities_fail_without_replacing_previous_catalog() {
736 let mgr = Manager::new();
737 mgr.hydrate_zipper_config_json(json!({
738 "assets": [{
739 "asset": "USDT",
740 "ledger_id": 99,
741 "quantity_scale": 6,
742 "variants": [{"zipped_asset_id": 42}]
743 }]
744 }))
745 .unwrap();
746
747 for malformed in [
748 json!({"assets": [
749 {"asset": "BTC", "ledger_id": 1, "quantity_scale": 8},
750 {"asset": "ETH", "ledger_id": 1, "quantity_scale": 6}
751 ]}),
752 json!({"assets": [
753 {"asset": "BTC", "ledger_id": 1, "quantity_scale": 8},
754 {"asset": "BTC", "ledger_id": 2, "quantity_scale": 6}
755 ]}),
756 json!({"assets": [
757 {"asset": "", "ledger_id": 1, "quantity_scale": 8}
758 ]}),
759 json!({"assets": [
760 {"asset": "BTC", "ledger_id": 1}
761 ]}),
762 json!({"assets": [
763 {"asset": "BTC", "ledger_id": 1, "quantity_scale": 8,
764 "variants": [{"zipped_asset_id": 7}]},
765 {"asset": "ETH", "ledger_id": 2, "quantity_scale": 6,
766 "variants": [{"zipped_asset_id": 7}]}
767 ]}),
768 ] {
769 assert!(mgr.hydrate_zipper_config_json(malformed).is_err());
770 assert_eq!(mgr.ledger_id_for_asset("USDT"), Some(99));
771 assert_eq!(mgr.ledger_id_for_asset("BTC"), None);
772 assert_eq!(mgr.quantity_scale_for_zipped_asset_id(42), Some(6));
773 }
774 }
775
776 fn seed_ready_catalog(mgr: &Manager) {
777 mgr.hydrate_spot_and_zipper_json(
778 json!({
779 "pairs": [{
780 "symbol": "BTC-USDT",
781 "symbol_id": 1,
782 "base_quantity_scale": 8,
783 "orderbook_price_buckets": [0.01, 0.1]
784 }]
785 }),
786 json!({
787 "assets": [{
788 "asset": "USDT",
789 "ledger_id": 99,
790 "quantity_scale": 6,
791 "variants": [{"zipped_asset_id": 42}]
792 }]
793 }),
794 )
795 .expect("hydrate");
796 assert!(mgr.patch_zipper_supply(&[ZippedAssetSupplyUpdate {
797 zipped_asset_id: 42,
798 supply: "100.5".to_owned(),
799 }]));
800 }
801
802 #[test]
803 fn poisoned_catalog_reads_still_return_hydrated_scale_data() {
804 let mgr = Manager::new();
805 seed_ready_catalog(&mgr);
806 mgr.poison_for_test();
807
808 assert!(mgr.is_ready());
811 assert_eq!(mgr.symbol_id_for_symbol("BTC-USDT"), Some(1));
812 assert_eq!(mgr.base_quantity_scale_for_symbol("BTC-USDT"), Some(8));
813 assert_eq!(mgr.base_quantity_scale_for_symbol_id(1), Some(8));
814 assert_eq!(mgr.quantity_scale_for_zipped_asset_id(42), Some(6));
815 assert_eq!(mgr.ledger_id_for_asset("USDT"), Some(99));
816 assert_eq!(
817 mgr.orderbook_price_buckets_for_symbol("BTC-USDT"),
818 vec!["0.01".to_owned(), "0.1".to_owned()]
819 );
820 assert_eq!(mgr.supply_for_zipped_asset_id(42).as_deref(), Some("100.5"));
821 assert_eq!(mgr.base_quantity_scale_for_symbol("NOPE"), None);
822 }
823
824 #[test]
825 fn poisoned_catalog_writes_still_hydrate_and_patch() {
826 let mgr = Manager::new();
827 seed_ready_catalog(&mgr);
828 mgr.poison_for_test();
829
830 mgr.hydrate_spot_config_json(json!({
831 "pairs": [{
832 "symbol": "ETH-USDT",
833 "symbol_id": 2,
834 "base_quantity_scale": 6
835 }]
836 }))
837 .expect("spot write after poison");
838 assert_eq!(mgr.base_quantity_scale_for_symbol("ETH-USDT"), Some(6));
839 assert_eq!(mgr.base_quantity_scale_for_symbol("BTC-USDT"), None);
840
841 assert!(mgr.patch_zipper_supply(&[ZippedAssetSupplyUpdate {
842 zipped_asset_id: 42,
843 supply: "200".to_owned(),
844 }]));
845 assert_eq!(mgr.supply_for_zipped_asset_id(42).as_deref(), Some("200"));
846
847 mgr.hydrate_zipper_config_json(json!({
848 "assets": [{
849 "asset": "BTC",
850 "ledger_id": 1,
851 "quantity_scale": 8,
852 "variants": [{"zipped_asset_id": 7}]
853 }]
854 }))
855 .expect("zipper write after poison");
856 assert_eq!(mgr.ledger_id_for_asset("BTC"), Some(1));
857 assert_eq!(mgr.quantity_scale_for_zipped_asset_id(7), Some(8));
858 assert_eq!(mgr.supply_for_zipped_asset_id(42).as_deref(), Some("200"));
860 }
861}