Skip to main content

tycho_common/models/
chain_config.rs

1//! Value types describing a blockchain's configuration: token metadata, TVL thresholds, and the
2//! full `CustomChainConfig` used to describe user-defined chains. Re-exported from `models`.
3
4use std::{collections::HashMap, sync::OnceLock};
5
6use arrayvec::ArrayString;
7use deepsize::DeepSizeOf;
8use serde::{Deserialize, Serialize};
9use thiserror::Error;
10use utoipa::ToSchema;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub struct ChainAddress {
14    bytes: [u8; 32],
15    len: u8,
16}
17
18impl ChainAddress {
19    pub fn new(bytes: &[u8]) -> Result<Self, ChainConfigError> {
20        if bytes.len() > 32 {
21            return Err(ChainConfigError::AddressTooLong(bytes.len()));
22        }
23        let mut arr = [0u8; 32];
24        arr[..bytes.len()].copy_from_slice(bytes);
25        Ok(Self { bytes: arr, len: bytes.len() as u8 })
26    }
27
28    pub fn as_bytes(&self) -> &[u8] {
29        &self.bytes[..self.len as usize]
30    }
31}
32
33/// Serialized as a `0x`-prefixed hex string so config files and wire payloads use the same
34/// representation a human writes (e.g. `"0x0000...0000"`).
35impl Serialize for ChainAddress {
36    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
37        serializer.serialize_str(&format!("0x{}", hex::encode(self.as_bytes())))
38    }
39}
40
41impl<'de> Deserialize<'de> for ChainAddress {
42    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
43        let s = String::deserialize(deserializer)?;
44        let raw = hex::decode(s.trim_start_matches("0x"))
45            .map_err(|e| serde::de::Error::custom(format!("invalid hex address '{s}': {e}")))?;
46        ChainAddress::new(&raw).map_err(serde::de::Error::custom)
47    }
48}
49
50#[derive(Error, Debug, PartialEq)]
51pub enum ChainConfigError {
52    #[error("invalid hex address '{0}': {1}")]
53    InvalidAddress(String, String),
54    #[error("address is {0} bytes, max is 32")]
55    AddressTooLong(usize),
56    #[error("symbol '{0}' too long (max 8 chars)")]
57    SymbolTooLong(String),
58    #[error("chain name '{0}' too long (max 32 chars)")]
59    NameTooLong(String),
60    #[error("unknown chain '{0}': not a built-in chain and no custom config registered")]
61    UnknownChain(String),
62    #[error("duplicate custom chain config for '{0}'")]
63    DuplicateChain(String),
64    #[error("failed to read chain config file: {0}")]
65    Io(String),
66    #[error("failed to parse chain config: {0}")]
67    Parse(String),
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema)]
71pub struct ChainTokenConfig {
72    #[schema(value_type = String)]
73    pub(crate) address: ChainAddress,
74    #[schema(value_type = String)]
75    pub(crate) symbol: ArrayString<8>,
76    pub(crate) decimals: u8,
77}
78
79impl ChainTokenConfig {
80    pub fn try_new(
81        address_hex: &str,
82        symbol: &str,
83        decimals: u8,
84    ) -> Result<Self, ChainConfigError> {
85        let raw = hex::decode(address_hex.trim_start_matches("0x"))
86            .map_err(|e| ChainConfigError::InvalidAddress(address_hex.to_owned(), e.to_string()))?;
87        let address = ChainAddress::new(&raw)?;
88        let symbol = ArrayString::from(symbol)
89            .map_err(|_| ChainConfigError::SymbolTooLong(symbol.to_owned()))?;
90        Ok(Self { address, symbol, decimals })
91    }
92}
93
94#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema)]
95pub struct TvlThresholds {
96    pub(crate) low: f64,
97    pub(crate) medium: f64,
98}
99
100impl TvlThresholds {
101    pub fn new(low: f64, medium: f64) -> Self {
102        Self { low, medium }
103    }
104}
105
106impl PartialEq for TvlThresholds {
107    fn eq(&self, other: &Self) -> bool {
108        self.low.to_bits() == other.low.to_bits() && self.medium.to_bits() == other.medium.to_bits()
109    }
110}
111
112impl Eq for TvlThresholds {}
113
114impl std::hash::Hash for TvlThresholds {
115    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
116        self.low.to_bits().hash(state);
117        self.medium.to_bits().hash(state);
118    }
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema)]
122pub struct CustomChainConfig {
123    #[schema(value_type = String)]
124    pub(crate) name: ArrayString<32>,
125    pub(crate) chain_id: u64,
126    pub block_time_secs: u64,
127    pub(crate) native: ChainTokenConfig,
128    pub(crate) wrapped_native: ChainTokenConfig,
129    pub(crate) default_tvl_thresholds: TvlThresholds,
130}
131
132impl CustomChainConfig {
133    pub fn try_new(
134        name: &str,
135        chain_id: u64,
136        block_time_secs: u64,
137        native: ChainTokenConfig,
138        wrapped_native: ChainTokenConfig,
139        default_tvl_thresholds: TvlThresholds,
140    ) -> Result<Self, ChainConfigError> {
141        let name =
142            ArrayString::from(name).map_err(|_| ChainConfigError::NameTooLong(name.to_owned()))?;
143        Ok(Self { name, chain_id, block_time_secs, native, wrapped_native, default_tvl_thresholds })
144    }
145
146    pub fn name(&self) -> &str {
147        self.name.as_str()
148    }
149}
150
151impl DeepSizeOf for CustomChainConfig {
152    fn deep_size_of_children(&self, _context: &mut deepsize::Context) -> usize {
153        0
154    }
155}
156
157/// Name of a custom chain, guaranteed at construction time to have a matching config in the chain
158/// registry. The inner name is private, so a `CustomChainId` — and therefore a `Chain::Custom` —
159/// can only be minted through the crate's validating constructors (`Chain::custom`,
160/// `Chain::from_str`, `From<dto::Chain>`), never fabricated directly. Serializes transparently as
161/// the bare chain name.
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
163pub struct CustomChainId(ArrayString<32>);
164
165impl CustomChainId {
166    /// Builds an id after confirming `name` is registered, returning
167    /// [`ChainConfigError::UnknownChain`] when it is absent and [`ChainConfigError::NameTooLong`]
168    /// when it exceeds 32 bytes.
169    pub(crate) fn checked(
170        name: &str,
171        registry: &ChainConfigRegistry,
172    ) -> Result<Self, ChainConfigError> {
173        if !registry.contains(name) {
174            return Err(ChainConfigError::UnknownChain(name.to_owned()));
175        }
176        ArrayString::from(name)
177            .map(Self)
178            .map_err(|_| ChainConfigError::NameTooLong(name.to_owned()))
179    }
180
181    pub fn as_str(&self) -> &str {
182        self.0.as_str()
183    }
184}
185
186/// TVL threshold tiers for chain-aware filtering defaults.
187///
188/// TVL is denominated in each chain's native token. Since native tokens have different USD values,
189/// the same numeric threshold produces wildly different USD-equivalent filters across chains.
190/// These tiers provide sensible defaults targeting equivalent USD values.
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
192pub enum TvlThresholdTier {
193    /// Filters out dust pools (~$20K USD equivalent in native token).
194    Low,
195    /// Filters for pools with meaningful liquidity (~$200K USD equivalent in native token).
196    Medium,
197}
198
199/// Env var holding the path the registry reads custom-chain config from. Unset means "no custom
200/// chains"; there is no default path, so loading never depends on the current working directory.
201const TYCHO_CHAINS_CONFIG_ENV: &str = "TYCHO_CHAINS_CONFIG";
202
203/// On-disk shape of the custom-chain config file: a top-level `chains:` list. This is the format of
204/// the indexer's `chains.yaml` (its `--chain-config` file), so a consumer can point
205/// `TYCHO_CHAINS_CONFIG` at the same file the indexer uses.
206#[derive(Debug, Default, Deserialize)]
207struct ChainConfigFile {
208    #[serde(default)]
209    chains: Vec<CustomChainConfig>,
210}
211
212/// Resolves the full configuration of custom chains by name.
213///
214/// Built-in chains are resolved without this registry; it only holds user-defined chains loaded
215/// from a YAML source. Build it explicitly via [`ChainConfigRegistry::from_configs`],
216/// [`ChainConfigRegistry::from_yaml_file`], or [`ChainConfigRegistry::load_default`], then install
217/// it as the process-wide registry with [`init_chain_registry`].
218#[derive(Debug, Default, Clone)]
219pub struct ChainConfigRegistry {
220    custom: HashMap<String, CustomChainConfig>,
221}
222
223impl ChainConfigRegistry {
224    /// An empty registry — only built-in chains will resolve.
225    pub fn empty() -> Self {
226        Self { custom: HashMap::new() }
227    }
228
229    /// Builds a registry from custom chain configs, keyed by chain name. Rejects duplicate names
230    /// with [`ChainConfigError::DuplicateChain`] rather than silently overriding, so an ambiguous
231    /// config fails loudly at construction.
232    pub fn from_configs(
233        configs: impl IntoIterator<Item = CustomChainConfig>,
234    ) -> Result<Self, ChainConfigError> {
235        let mut custom = HashMap::new();
236        for cfg in configs {
237            let name = cfg.name().to_owned();
238            if custom
239                .insert(name.clone(), cfg)
240                .is_some()
241            {
242                return Err(ChainConfigError::DuplicateChain(name));
243            }
244        }
245        Ok(Self { custom })
246    }
247
248    /// Reads custom chain configs from the path in `TYCHO_CHAINS_CONFIG`.
249    ///
250    /// Returns an empty registry when the env var is unset, so runs on built-in chains need no
251    /// config. When it is set, the file is loaded as-is: a missing, unreadable, or malformed file
252    /// is an error (the operator asked for a config, so a broken one is a mistake, not "no custom
253    /// chains"), leaving the caller to decide how to react. Install the result with
254    /// [`init_chain_registry`] to make it the process-wide registry.
255    pub fn load_default() -> Result<Self, ChainConfigError> {
256        let Ok(path) = std::env::var(TYCHO_CHAINS_CONFIG_ENV) else {
257            return Ok(Self::empty());
258        };
259        Self::from_yaml_file(&path)
260    }
261
262    /// Like [`load_default`](Self::load_default) but never fails: a broken `TYCHO_CHAINS_CONFIG`
263    /// (missing, unreadable, or malformed file) is logged and degrades to an empty registry rather
264    /// than aborting. Used by [`chain_registry`] to lazily initialise the process-wide registry, so
265    /// a consumer only needs to set `TYCHO_CHAINS_CONFIG` — no explicit init call. Call
266    /// [`load_default`](Self::load_default) directly when you need to react to the error.
267    pub fn load_default_or_empty() -> Self {
268        Self::load_default().unwrap_or_else(|e| {
269            tracing::warn!(
270                error = %e,
271                "failed to load custom chain config from TYCHO_CHAINS_CONFIG; falling back to an \
272                 empty registry"
273            );
274            Self::empty()
275        })
276    }
277
278    /// Parses a registry from a YAML file with a top-level `chains:` list.
279    pub fn from_yaml_file(path: &str) -> Result<Self, ChainConfigError> {
280        let contents =
281            std::fs::read_to_string(path).map_err(|e| ChainConfigError::Io(e.to_string()))?;
282        Self::from_yaml_str(&contents)
283    }
284
285    /// Parses a registry from a YAML string with a top-level `chains:` list.
286    pub fn from_yaml_str(contents: &str) -> Result<Self, ChainConfigError> {
287        let file: ChainConfigFile =
288            serde_yaml::from_str(contents).map_err(|e| ChainConfigError::Parse(e.to_string()))?;
289        Self::from_configs(file.chains)
290    }
291
292    /// Returns the config for a custom chain by name, if registered.
293    pub fn get(&self, name: &str) -> Option<&CustomChainConfig> {
294        self.custom.get(name)
295    }
296
297    /// Whether a custom chain with this name is registered.
298    pub fn contains(&self, name: &str) -> bool {
299        self.custom.contains_key(name)
300    }
301}
302
303static CHAIN_REGISTRY: OnceLock<ChainConfigRegistry> = OnceLock::new();
304
305/// Returns the process-wide chain config registry.
306///
307/// On first access, lazily initialises itself from `TYCHO_CHAINS_CONFIG` via
308/// [`ChainConfigRegistry::load_default_or_empty`] — so a consumer that wants custom chains only
309/// needs to set that env var, no explicit init call. A run with the env var unset resolves
310/// built-in chains from an empty registry. Install a registry explicitly with
311/// [`init_chain_registry`] before first access to override the lazy load (e.g. the indexer
312/// building one from its own config).
313pub fn chain_registry() -> &'static ChainConfigRegistry {
314    CHAIN_REGISTRY.get_or_init(ChainConfigRegistry::load_default_or_empty)
315}
316
317/// Installs the process-wide chain config registry explicitly, before any call to
318/// [`chain_registry`]. Returns the passed registry back as `Err` if it was already initialised.
319pub fn init_chain_registry(registry: ChainConfigRegistry) -> Result<(), ChainConfigRegistry> {
320    CHAIN_REGISTRY.set(registry)
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    const SAMPLE_YAML: &str = r#"
328chains:
329  - name: testchain
330    chain_id: 9999
331    block_time_secs: 5
332    native:
333      address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
334      symbol: TST
335      decimals: 18
336    wrapped_native:
337      address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
338      symbol: WTST
339      decimals: 18
340    default_tvl_thresholds:
341      low: 50.0
342      medium: 500.0
343"#;
344
345    #[test]
346    fn parses_yaml_and_resolves_by_name() {
347        let registry = ChainConfigRegistry::from_yaml_str(SAMPLE_YAML).unwrap();
348        assert!(registry.contains("testchain"));
349        let cfg = registry
350            .get("testchain")
351            .expect("testchain present");
352        assert_eq!(cfg.name(), "testchain");
353        assert_eq!(cfg.chain_id, 9999);
354        assert_eq!(cfg.block_time_secs, 5);
355    }
356
357    #[test]
358    fn unknown_chain_is_absent() {
359        let registry = ChainConfigRegistry::from_yaml_str(SAMPLE_YAML).unwrap();
360        assert!(!registry.contains("nope"));
361        assert!(registry.get("nope").is_none());
362    }
363
364    #[test]
365    fn empty_registry_has_no_custom_chains() {
366        assert!(!ChainConfigRegistry::empty().contains("testchain"));
367    }
368
369    #[test]
370    fn from_configs_keys_by_name() {
371        let cfg = CustomChainConfig::try_new(
372            "mychain",
373            42,
374            2,
375            ChainTokenConfig::try_new("0x00", "AAA", 18).unwrap(),
376            ChainTokenConfig::try_new("0x01", "WAAA", 18).unwrap(),
377            TvlThresholds::new(1.0, 2.0),
378        )
379        .unwrap();
380        let registry = ChainConfigRegistry::from_configs([cfg]).unwrap();
381        assert!(registry.contains("mychain"));
382        assert_eq!(
383            registry
384                .get("mychain")
385                .unwrap()
386                .chain_id,
387            42
388        );
389    }
390
391    #[test]
392    fn empty_chains_list_parses_to_empty_registry() {
393        let registry = ChainConfigRegistry::from_yaml_str("chains: []").unwrap();
394        assert!(!registry.contains("anything"));
395    }
396
397    // These tests mutate the process-global `TYCHO_CHAINS_CONFIG` env var; they rely on nextest
398    // running each test in its own process (repo convention). Under plain `cargo test` they would
399    // race on the shared env.
400    #[test]
401    fn load_default_returns_empty_when_env_unset() {
402        std::env::remove_var(TYCHO_CHAINS_CONFIG_ENV);
403        assert!(!ChainConfigRegistry::load_default()
404            .unwrap()
405            .contains("anything"));
406    }
407
408    #[test]
409    fn load_default_errors_when_env_points_at_missing_file() {
410        let missing = format!(
411            "{}/tycho-chain-config-missing-{}.yaml",
412            std::env::temp_dir().display(),
413            std::process::id()
414        );
415        std::env::set_var(TYCHO_CHAINS_CONFIG_ENV, &missing);
416        assert!(matches!(ChainConfigRegistry::load_default(), Err(ChainConfigError::Io(_))));
417    }
418
419    #[test]
420    fn chain_registry_lazy_loads_from_env() {
421        let path = format!(
422            "{}/tycho-lazy-chain-{}.yaml",
423            std::env::temp_dir().display(),
424            std::process::id()
425        );
426        std::fs::write(&path, SAMPLE_YAML).unwrap();
427        std::env::set_var(TYCHO_CHAINS_CONFIG_ENV, &path);
428        // No explicit init_chain_registry — first access lazily loads from the env path.
429        assert!(chain_registry().contains("testchain"));
430    }
431
432    #[test]
433    fn load_default_or_empty_degrades_to_empty_on_missing_file() {
434        let missing = format!(
435            "{}/tycho-chain-config-degrade-{}.yaml",
436            std::env::temp_dir().display(),
437            std::process::id()
438        );
439        std::env::set_var(TYCHO_CHAINS_CONFIG_ENV, &missing);
440        // A broken config must not panic here; it degrades to an empty registry.
441        assert!(!ChainConfigRegistry::load_default_or_empty().contains("anything"));
442    }
443
444    #[test]
445    fn from_configs_duplicate_name_errors() {
446        let first = CustomChainConfig::try_new(
447            "dup",
448            1,
449            2,
450            ChainTokenConfig::try_new("0x00", "AAA", 18).unwrap(),
451            ChainTokenConfig::try_new("0x01", "WAAA", 18).unwrap(),
452            TvlThresholds::new(1.0, 2.0),
453        )
454        .unwrap();
455        let second = CustomChainConfig::try_new(
456            "dup",
457            2,
458            2,
459            ChainTokenConfig::try_new("0x00", "AAA", 18).unwrap(),
460            ChainTokenConfig::try_new("0x01", "WAAA", 18).unwrap(),
461            TvlThresholds::new(1.0, 2.0),
462        )
463        .unwrap();
464        assert_eq!(
465            ChainConfigRegistry::from_configs([first, second]).unwrap_err(),
466            ChainConfigError::DuplicateChain("dup".to_owned())
467        );
468    }
469}