1use 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
33impl 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
163pub struct CustomChainId(ArrayString<32>);
164
165impl CustomChainId {
166 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
192pub enum TvlThresholdTier {
193 Low,
195 Medium,
197}
198
199const TYCHO_CHAINS_CONFIG_ENV: &str = "TYCHO_CHAINS_CONFIG";
202
203#[derive(Debug, Default, Deserialize)]
207struct ChainConfigFile {
208 #[serde(default)]
209 chains: Vec<CustomChainConfig>,
210}
211
212#[derive(Debug, Default, Clone)]
219pub struct ChainConfigRegistry {
220 custom: HashMap<String, CustomChainConfig>,
221}
222
223impl ChainConfigRegistry {
224 pub fn empty() -> Self {
226 Self { custom: HashMap::new() }
227 }
228
229 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 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 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 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 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 pub fn get(&self, name: &str) -> Option<&CustomChainConfig> {
294 self.custom.get(name)
295 }
296
297 pub fn contains(&self, name: &str) -> bool {
299 self.custom.contains_key(name)
300 }
301}
302
303static CHAIN_REGISTRY: OnceLock<ChainConfigRegistry> = OnceLock::new();
304
305pub fn chain_registry() -> &'static ChainConfigRegistry {
314 CHAIN_REGISTRY.get_or_init(ChainConfigRegistry::load_default_or_empty)
315}
316
317pub 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 #[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 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 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}