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