From d9ea557fe97735635d33043b835e8a08178bfbe7 Mon Sep 17 00:00:00 2001 From: Andrew McConnell Date: Fri, 8 May 2026 10:16:43 -0500 Subject: [PATCH] instruments: use API-prefixed builder-dex names verbatim, no double-prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HL `meta {dex}` endpoint returns asset `name` already prefixed with the dex (e.g. `"xyz:XYZ100"`, `"cash:USA500"`). hl-node also writes those same prefixed names into the streaming/by-block files. So the registry's coin key MUST equal the wire name verbatim. The current code was constructing `coin = format!("{dex}:{name}")` after reading `name = "xyz:XYZ100"` from the API, producing the doubly-prefixed key `"xyz:xyz:XYZ100"`. The DoB tap and TOB publish paths then failed to resolve every event for every builder-dex coin, dropping them all from both feeds. ~584 unknown-coin warns/sec on aws-tyo-hl-mainnet across all seven configured DEXes (xyz, cash, hyna, km, flx, vntl, para) — 172 distinct coins missing. The xyz dex alone accounts for 77% of the unknown-coin volume (real-world equities, commodities, FX, indices) — the data subscribers most want. Fix: prefer the API name verbatim, but defensively prefix only when the name doesn't already start with `:`. That keeps the registry key correct under the current API and survives a hypothetical future API change to unprefixed names. New tests: - parse_builder_dex_asset_uses_api_prefixed_name_verbatim — asserts the new invariant against the real API shape (`name: "xyz:XYZ100"`). - parse_builder_dex_asset_prefixes_unprefixed_name_defensively — keeps the old behavior available for an unprefixed name. The pre-existing `parse_builder_dex_asset_prefixes_coin` and `_delisted` tests still pass: they were written against the old unprefixed assumption and now exercise the defensive branch. cargo test --workspace: 138 passed, 0 failed. --- server/src/instruments/hyperliquid.rs | 36 ++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/server/src/instruments/hyperliquid.rs b/server/src/instruments/hyperliquid.rs index fea6a3c..5f56663 100644 --- a/server/src/instruments/hyperliquid.rs +++ b/server/src/instruments/hyperliquid.rs @@ -157,7 +157,15 @@ fn parse_builder_dex_asset(asset: &serde_json::Value, dex: &str, id: u32) -> Opt let price_exponent = derive_price_exponent(asset); let is_delisted = asset.get("isDelisted").and_then(serde_json::Value::as_bool).unwrap_or(false); - let coin = format!("{dex}:{name}"); + // The HL `meta {dex}` endpoint already returns names prefixed with the dex + // (e.g. `"xyz:XYZ100"`, `"cash:USA500"`) — that's the format hl-node also + // writes into the streaming/by-block files. Re-prefixing here would + // double-up (`"xyz:xyz:XYZ100"`) and the resolver would never match the + // wire form, silently dropping every event for that dex. Use the API + // name as-is, but defend against an API change by prefixing only if the + // name doesn't already start with `:`. + let prefix = format!("{dex}:"); + let coin = if name.starts_with(&prefix) { name.to_string() } else { format!("{prefix}{name}") }; let info = InstrumentInfo { instrument_id: id, price_exponent, qty_exponent, symbol: make_symbol(&coin) }; Some(UniverseEntry { instrument_id: id, coin, is_delisted, info }) @@ -403,6 +411,32 @@ mod tests { assert!(parse_builder_dex_asset(&serde_json::json!({"name": "CL"}), "xyz", 20_000).is_none()); } + /// The actual HL `meta {dex}` endpoint returns `name` already prefixed + /// with `:` (verified against `xyz`, `cash`, `hyna`, `km`, `flx`, + /// `vntl`, `para` on mainnet 2026-05-08). hl-node also writes coin names + /// with that prefix into the streaming/by-block files. So the registry + /// `coin` key MUST equal the API name verbatim — no extra prefix layer. + /// Without this, every event for a builder-dex coin silently fails the + /// resolver (584 unknown-coin warns/sec on mainnet pre-fix). + #[test] + fn parse_builder_dex_asset_uses_api_prefixed_name_verbatim() { + let asset = serde_json::json!({"name": "xyz:XYZ100", "szDecimals": 4, "maxDecimals": 2}); + let e = parse_builder_dex_asset(&asset, "xyz", 21_000).unwrap(); + // Critical invariant: the registry key matches what hl-node writes + // on the wire (`xyz:XYZ100`), NOT a doubled `xyz:xyz:XYZ100`. + assert_eq!(e.coin, "xyz:XYZ100"); + } + + /// Defensive: should the API ever return an unprefixed name (it does not + /// today), we still produce the correctly-prefixed registry key so the + /// resolver continues to work. + #[test] + fn parse_builder_dex_asset_prefixes_unprefixed_name_defensively() { + let asset = serde_json::json!({"name": "BAREONLY", "szDecimals": 3}); + let e = parse_builder_dex_asset(&asset, "newdex", 22_000).unwrap(); + assert_eq!(e.coin, "newdex:BAREONLY"); + } + #[test] fn parse_perp_asset_missing_fields() { assert!(parse_perp_asset(&serde_json::json!({"szDecimals": 5}), 0).is_none());