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
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    /// Live supply strings by `zipped_asset_id` (updated via [`Manager::patch_zipper_supply`]).
75    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    /// True only after both spot and zipper snapshots were validated and
262    /// installed atomically.
263    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    /// Validate and install spot config atomically (no partial row mutation).
269    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    /// Typed Zipper hydrate — consumers do not need a direct `serde_json` dependency.
277    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    /// Validate and install zipper config atomically (no partial row mutation).
284    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    /// Validate spot + zipper, then commit both under one write lock.
292    ///
293    /// On any validation error neither catalog is mutated.
294    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    /// Returns the pair base quantity scale, or `None` when unknown/unhydrated.
311    ///
312    /// Never invents scale 8 for missing symbols — callers that need a decode
313    /// fallback must choose it explicitly.
314    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    /// Returns the pair quote quantity scale, or `None` when unknown/unhydrated.
329    ///
330    /// Quote-debit budgets must use this scale. Callers must not infer it from
331    /// the base quantity scale or from the quote asset's display decimals.
332    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    /// Latest supply string for a zipped asset id, if patched from realtime updates.
369    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    /// Apply supply updates to the in-memory `zipped_asset_id -> supply` map.
377    ///
378    /// Returns `true` when at least one entry changed. Unlike Go/Python, Rust
379    /// catalogs do not store full enriched zipper chain rows with a `supply`
380    /// field; this map is the live-supply source of truth.
381    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    // Replace spot maps wholesale so a refresh cannot leave stale symbols.
415    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    // Preserve live supply patches across zipper catalog replacement.
430}
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        // Prior catalog untouched; partial new rows must not install.
535        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        // Poison must not masquerade as "symbol/asset not found" — that would
809        // route around fail-closed scale lookups by reporting absence.
810        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        // Live supply map is preserved across zipper replacement.
859        assert_eq!(mgr.supply_for_zipped_asset_id(42).as_deref(), Some("200"));
860    }
861}