Skip to main content

polyester/catalogs/
mod.rs

1//! Spot / zipper catalog cache for scale lookups.
2//!
3//! Zipper live supply is read through [`Manager::supply_for_zipped_asset_id`]
4//! (keyed by `zipped_asset_id`). Full enriched zipper config rows are not
5//! mutated; use [`Manager::patch_zipper_supply`] from
6//! `subscribe_zipped_asset_supply(true)`.
7
8use 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    /// Live supply strings by `zipped_asset_id` (updated via [`Manager::patch_zipper_supply`]).
67    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    /// True only after both spot and zipper snapshots were validated and
242    /// installed atomically.
243    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    /// Validate and install spot config atomically (no partial row mutation).
249    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    /// Typed Zipper hydrate — consumers do not need a direct `serde_json` dependency.
257    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    /// Validate and install zipper config atomically (no partial row mutation).
264    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    /// Validate spot + zipper, then commit both under one write lock.
272    ///
273    /// On any validation error neither catalog is mutated.
274    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    /// Returns the pair base quantity scale, or `None` when unknown/unhydrated.
291    ///
292    /// Never invents scale 8 for missing symbols — callers that need a decode
293    /// fallback must choose it explicitly.
294    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    /// Latest supply string for a zipped asset id, if patched from realtime updates.
331    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    /// Apply supply updates to the in-memory `zipped_asset_id -> supply` map.
339    ///
340    /// Returns `true` when at least one entry changed. Unlike Go/Python, Rust
341    /// catalogs do not store full enriched zipper chain rows with a `supply`
342    /// field; this map is the live-supply source of truth.
343    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    // Replace spot maps wholesale so a refresh cannot leave stale symbols.
377    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    // Preserve live supply patches across zipper catalog replacement.
390}
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        // Prior catalog untouched; partial new rows must not install.
492        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        // Poison must not masquerade as "symbol/asset not found" — that would
746        // route around fail-closed scale lookups by reporting absence.
747        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        // Live supply map is preserved across zipper replacement.
796        assert_eq!(mgr.supply_for_zipped_asset_id(42).as_deref(), Some("200"));
797    }
798}