Skip to main content

tycho_common/models/
mod.rs

1pub mod blockchain;
2pub mod chain_config;
3pub mod contract;
4pub mod error;
5pub mod protocol;
6pub mod token;
7
8use std::{collections::HashMap, fmt::Display, str::FromStr};
9
10pub use blockchain::{BlockChanges, TxWithContractChanges};
11use chain_config::{
12    chain_registry, ChainConfigError, ChainConfigRegistry, CustomChainConfig, CustomChainId,
13    TvlThresholdTier,
14};
15use deepsize::DeepSizeOf;
16use serde::{Deserialize, Serialize};
17use thiserror::Error;
18use token::Token;
19
20use crate::{dto, Bytes};
21
22/// Address hash literal type to uniquely identify contracts/accounts on a
23/// blockchain.
24pub type Address = Bytes;
25
26/// Block hash literal type to uniquely identify a block in the chain and
27/// likely across chains.
28pub type BlockHash = Bytes;
29
30/// Transaction hash literal type to uniquely identify a transaction in the
31/// chain and likely across chains.
32pub type TxHash = Bytes;
33
34/// Smart contract code is represented as a byte vector containing opcodes.
35pub type Code = Bytes;
36
37/// The hash of a contract's code is used to identify it.
38pub type CodeHash = Bytes;
39
40/// The balance of an account is a big endian serialised integer of variable size.
41pub type Balance = Bytes;
42
43/// Key literal type of the contract store.
44pub type StoreKey = Bytes;
45
46/// Key literal type of the attribute store.
47pub type AttrStoreKey = String;
48
49/// Value literal type of the contract store.
50pub type StoreVal = Bytes;
51
52/// A binary key-value store for an account.
53pub type ContractStore = HashMap<StoreKey, StoreVal>;
54pub type ContractStoreDeltas = HashMap<StoreKey, Option<StoreVal>>;
55pub type AccountToContractStoreDeltas = HashMap<Address, ContractStoreDeltas>;
56
57/// Component id literal type to uniquely identify a component.
58pub type ComponentId = String;
59
60/// Protocol system literal type to uniquely identify a protocol system.
61pub type ProtocolSystem = String;
62
63/// Entry point id literal type to uniquely identify an entry point.
64pub type EntryPointId = String;
65
66/// A blockchain Tycho indexes or simulates over.
67///
68/// Built-in chains are first-class variants: their config (id, native token, block time, TVL
69/// tiers) is compile-time and total, so they never fail to resolve. `Custom` is the escape hatch
70/// for a chain a self-hoster runs without upstreaming a variant — its config lives in the
71/// process-wide registry (see [`chain_config`]) rather than in the enum.
72///
73/// `Custom` wraps a [`CustomChainId`] whose inner name is private, so a `Chain::Custom` can only be
74/// built through the crate's registry-validating constructors ([`Chain::custom`],
75/// [`Chain::from_str`], `From<dto::Chain>`) — it cannot be fabricated directly. Because the
76/// registry is set-once, a validated `Chain::Custom` stays resolvable for its whole lifetime, so
77/// the config accessors only panic on an unreachable invariant; [`Chain::try_id`] and its siblings
78/// expose non-panicking variants. The one reachable failure is decoding wire data for a chain the
79/// local registry lacks (`From<dto::Chain>`), which panics at the decode boundary.
80///
81/// Prefer adding a first-class variant for any chain Tycho officially supports; reserve `Custom`
82/// for the self-host case. Full rationale in the custom-chain decision record.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
84#[serde(rename_all = "lowercase")]
85#[non_exhaustive]
86pub enum Chain {
87    #[default]
88    Ethereum,
89    Starknet,
90    ZkSync,
91    Arbitrum,
92    Base,
93    Bsc,
94    Unichain,
95    Polygon,
96    /// User-defined chain resolved via the [`chain_config`] registry; see the enum docs.
97    Custom(CustomChainId),
98}
99
100impl DeepSizeOf for Chain {
101    fn deep_size_of_children(&self, _context: &mut deepsize::Context) -> usize {
102        0
103    }
104}
105
106impl Chain {
107    /// Parses only the built-in chains, ignoring any custom-chain registry.
108    pub fn builtin_from_str(s: &str) -> Option<Self> {
109        match s {
110            "ethereum" => Some(Chain::Ethereum),
111            "starknet" => Some(Chain::Starknet),
112            "zksync" => Some(Chain::ZkSync),
113            "arbitrum" => Some(Chain::Arbitrum),
114            "base" => Some(Chain::Base),
115            "bsc" => Some(Chain::Bsc),
116            "unichain" => Some(Chain::Unichain),
117            "polygon" => Some(Chain::Polygon),
118            _ => None,
119        }
120    }
121
122    /// Builds a custom-chain identity, validating that `name` has a config in the chain registry.
123    /// Returns [`ChainConfigError::UnknownChain`] when it is absent so typos never silently become
124    /// custom chains.
125    pub fn custom(name: &str) -> Result<Self, ChainConfigError> {
126        CustomChainId::checked(name, chain_registry()).map(Chain::Custom)
127    }
128}
129
130impl FromStr for Chain {
131    type Err = ChainConfigError;
132
133    /// Resolves a chain name. Built-in chains parse directly; any other name is accepted only if it
134    /// is registered in the global chain config registry, otherwise
135    /// [`ChainConfigError::UnknownChain`] is returned so typos never silently become custom chains.
136    fn from_str(s: &str) -> Result<Self, Self::Err> {
137        if let Some(chain) = Self::builtin_from_str(s) {
138            return Ok(chain);
139        }
140        Self::custom(s)
141    }
142}
143
144impl Display for Chain {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        match self {
147            Chain::Ethereum => f.write_str("ethereum"),
148            Chain::Starknet => f.write_str("starknet"),
149            Chain::ZkSync => f.write_str("zksync"),
150            Chain::Arbitrum => f.write_str("arbitrum"),
151            Chain::Base => f.write_str("base"),
152            Chain::Bsc => f.write_str("bsc"),
153            Chain::Unichain => f.write_str("unichain"),
154            Chain::Polygon => f.write_str("polygon"),
155            Chain::Custom(name) => f.write_str(name.as_str()),
156        }
157    }
158}
159
160impl From<dto::Chain> for Chain {
161    fn from(value: dto::Chain) -> Self {
162        match value {
163            dto::Chain::Ethereum => Chain::Ethereum,
164            dto::Chain::Starknet => Chain::Starknet,
165            dto::Chain::ZkSync => Chain::ZkSync,
166            dto::Chain::Arbitrum => Chain::Arbitrum,
167            dto::Chain::Base => Chain::Base,
168            dto::Chain::Bsc => Chain::Bsc,
169            dto::Chain::Unichain => Chain::Unichain,
170            dto::Chain::Polygon => Chain::Polygon,
171            dto::Chain::Custom(name) => Chain::custom(name.as_str()).unwrap_or_else(|e| {
172                panic!(
173                    "received custom chain '{name}' with no registered config: {e}; install it via \
174                     the chain config file (TYCHO_CHAINS_CONFIG, default ./chains.yaml) or \
175                     init_chain_registry before decoding wire data"
176                )
177            }),
178        }
179    }
180}
181
182impl From<dto::ChangeType> for ChangeType {
183    fn from(value: dto::ChangeType) -> Self {
184        match value {
185            dto::ChangeType::Update => ChangeType::Update,
186            dto::ChangeType::Creation => ChangeType::Creation,
187            dto::ChangeType::Deletion => ChangeType::Deletion,
188            dto::ChangeType::Unspecified => ChangeType::Update,
189        }
190    }
191}
192
193fn native_eth(chain: Chain) -> Token {
194    Token::new(
195        &Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(),
196        "ETH",
197        18,
198        0,
199        &[Some(2300)],
200        chain,
201        100,
202    )
203}
204
205fn native_bsc(chain: Chain) -> Token {
206    Token::new(
207        &Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(),
208        "BNB",
209        18,
210        0,
211        &[Some(2300)],
212        chain,
213        100,
214    )
215}
216
217fn wrapped_native_eth(chain: Chain, address: &str) -> Token {
218    Token::new(&Bytes::from_str(address).unwrap(), "WETH", 18, 0, &[Some(2300)], chain, 100)
219}
220
221fn native_pol(chain: Chain) -> Token {
222    Token::new(
223        &Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(),
224        "POL",
225        18,
226        0,
227        &[Some(2300)],
228        chain,
229        100,
230    )
231}
232
233/// Looks up a custom chain's config in the registry, returning [`ChainConfigError::UnknownChain`]
234/// when it is absent.
235fn try_resolve_custom<'a>(
236    id: &CustomChainId,
237    registry: &'a ChainConfigRegistry,
238) -> Result<&'a CustomChainConfig, ChainConfigError> {
239    registry
240        .get(id.as_str())
241        .ok_or_else(|| ChainConfigError::UnknownChain(id.as_str().to_owned()))
242}
243
244/// Unwraps a registry lookup that cannot fail for a validly-constructed `Chain::Custom`: a
245/// [`CustomChainId`] is only minted after registry validation and the registry is set-once, so a
246/// missing entry is an internal invariant violation rather than bad input.
247fn expect_registered<T>(result: Result<T, ChainConfigError>) -> T {
248    result.unwrap_or_else(|e| {
249        panic!(
250            "internal invariant violation resolving custom chain config: {e}; Chain::Custom is \
251             validated against the set-once chain registry at construction"
252        )
253    })
254}
255
256fn native_custom(chain: Chain, cfg: &CustomChainConfig) -> Token {
257    let addr = Bytes::from(cfg.native.address.as_bytes().to_vec());
258    Token::new(
259        &addr,
260        cfg.native.symbol.as_str(),
261        cfg.native.decimals as u32,
262        0,
263        &[Some(2300)],
264        chain,
265        100,
266    )
267}
268
269fn wrapped_native_bsc(chain: Chain, address: &str) -> Token {
270    Token::new(&Bytes::from_str(address).unwrap(), "WBNB", 18, 0, &[Some(2300)], chain, 100)
271}
272
273fn wrapped_native_pol(chain: Chain, address: &str) -> Token {
274    Token::new(&Bytes::from_str(address).unwrap(), "WMATIC", 18, 0, &[Some(2300)], chain, 100)
275}
276
277fn wrapped_native_custom(chain: Chain, cfg: &CustomChainConfig) -> Token {
278    let addr = Bytes::from(
279        cfg.wrapped_native
280            .address
281            .as_bytes()
282            .to_vec(),
283    );
284    Token::new(
285        &addr,
286        cfg.wrapped_native.symbol.as_str(),
287        cfg.wrapped_native.decimals as u32,
288        0,
289        &[Some(2300)],
290        chain,
291        100,
292    )
293}
294
295impl Chain {
296    /// Returns the numeric chain id. Panics if a custom chain has no registered config — an
297    /// unreachable invariant for a validly-constructed `Chain::Custom`; use [`Chain::try_id`] for a
298    /// non-panicking variant.
299    pub fn id(&self) -> u64 {
300        expect_registered(self.try_id())
301    }
302
303    /// Returns the numeric chain id, or [`ChainConfigError::UnknownChain`] when a custom chain has
304    /// no registered config.
305    pub fn try_id(&self) -> Result<u64, ChainConfigError> {
306        Ok(match self {
307            Chain::Ethereum => 1,
308            Chain::ZkSync => 324,
309            Chain::Arbitrum => 42161,
310            Chain::Starknet => 0,
311            Chain::Base => 8453,
312            Chain::Bsc => 56,
313            Chain::Unichain => 130,
314            Chain::Polygon => 137,
315            Chain::Custom(id) => try_resolve_custom(id, chain_registry())?.chain_id,
316        })
317    }
318
319    /// Returns a default TVL threshold in native token units for the given tier.
320    ///
321    /// Values are approximate and target a USD-equivalent range, not a precise conversion.
322    /// Native token prices used: ETH ~$2,000, POL ~$0.10, BNB ~$630.
323    /// These prices are volatile, and used as a reference. They should not be updated often,
324    /// unless big price movements occour, making an update necessary.
325    ///
326    /// Panics if a custom chain has no registered config; use [`Chain::try_default_tvl_threshold`]
327    /// for a non-panicking variant.
328    pub fn default_tvl_threshold(&self, tier: TvlThresholdTier) -> f64 {
329        expect_registered(self.try_default_tvl_threshold(tier))
330    }
331
332    /// Like [`Chain::default_tvl_threshold`] but returns [`ChainConfigError::UnknownChain`] when a
333    /// custom chain has no registered config.
334    pub fn try_default_tvl_threshold(
335        &self,
336        tier: TvlThresholdTier,
337    ) -> Result<f64, ChainConfigError> {
338        Ok(match (self, tier) {
339            // ETH-native chains: 10 ETH ≈ $20K, 100 ETH ≈ $200K.
340            // Starknet uses ETH-denominated TVL in Tycho (STRK tracked separately).
341            (
342                Chain::Ethereum |
343                Chain::Starknet |
344                Chain::ZkSync |
345                Chain::Arbitrum |
346                Chain::Base |
347                Chain::Unichain,
348                TvlThresholdTier::Low,
349            ) => 10.0,
350            (
351                Chain::Ethereum |
352                Chain::Starknet |
353                Chain::ZkSync |
354                Chain::Arbitrum |
355                Chain::Base |
356                Chain::Unichain,
357                TvlThresholdTier::Medium,
358            ) => 100.0,
359
360            // Polygon (POL ≈ $0.10): 200_000 POL ≈ $20K, 2_000_000 POL ≈ $200K
361            (Chain::Polygon, TvlThresholdTier::Low) => 200_000.0,
362            (Chain::Polygon, TvlThresholdTier::Medium) => 2_000_000.0,
363
364            // BSC (BNB ≈ $630): 32 BNB ≈ $20K, 320 BNB ≈ $200K
365            (Chain::Bsc, TvlThresholdTier::Low) => 32.0,
366            (Chain::Bsc, TvlThresholdTier::Medium) => 320.0,
367
368            (Chain::Custom(id), TvlThresholdTier::Low) => {
369                try_resolve_custom(id, chain_registry())?
370                    .default_tvl_thresholds
371                    .low
372            }
373            (Chain::Custom(id), TvlThresholdTier::Medium) => {
374                try_resolve_custom(id, chain_registry())?
375                    .default_tvl_thresholds
376                    .medium
377            }
378        })
379    }
380
381    /// Returns the native token for the chain. Panics if a custom chain has no registered config;
382    /// use [`Chain::try_native_token`] for a non-panicking variant.
383    pub fn native_token(&self) -> Token {
384        expect_registered(self.try_native_token())
385    }
386
387    /// Like [`Chain::native_token`] but returns [`ChainConfigError::UnknownChain`] when a custom
388    /// chain has no registered config.
389    pub fn try_native_token(&self) -> Result<Token, ChainConfigError> {
390        Ok(match self {
391            Chain::Ethereum => native_eth(Chain::Ethereum),
392            // It was decided that STRK token will be tracked as a dedicated AccountBalance on
393            // Starknet accounts and ETH balances will be tracked as a native balance.
394            Chain::Starknet => native_eth(Chain::Starknet),
395            Chain::ZkSync => native_eth(Chain::ZkSync),
396            Chain::Arbitrum => native_eth(Chain::Arbitrum),
397            Chain::Base => native_eth(Chain::Base),
398            Chain::Bsc => native_bsc(Chain::Bsc),
399            Chain::Unichain => native_eth(Chain::Unichain),
400            Chain::Polygon => native_pol(Chain::Polygon),
401            Chain::Custom(id) => native_custom(*self, try_resolve_custom(id, chain_registry())?),
402        })
403    }
404
405    /// Returns the wrapped native token for the chain. Panics if a custom chain has no registered
406    /// config; use [`Chain::try_wrapped_native_token`] for a non-panicking variant.
407    pub fn wrapped_native_token(&self) -> Token {
408        expect_registered(self.try_wrapped_native_token())
409    }
410
411    /// Like [`Chain::wrapped_native_token`] but returns [`ChainConfigError::UnknownChain`] when a
412    /// custom chain has no registered config.
413    pub fn try_wrapped_native_token(&self) -> Result<Token, ChainConfigError> {
414        Ok(match self {
415            Chain::Ethereum => {
416                wrapped_native_eth(Chain::Ethereum, "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2")
417            }
418            // Starknet does not have a wrapped native token
419            Chain::Starknet => {
420                wrapped_native_eth(Chain::Starknet, "0x0000000000000000000000000000000000000000")
421            }
422            Chain::ZkSync => {
423                wrapped_native_eth(Chain::ZkSync, "0x5AEa5775959fBC2557Cc8789bC1bf90A239D9a91")
424            }
425            Chain::Arbitrum => {
426                wrapped_native_eth(Chain::Arbitrum, "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1")
427            }
428            Chain::Base => {
429                wrapped_native_eth(Chain::Base, "0x4200000000000000000000000000000000000006")
430            }
431            Chain::Bsc => {
432                wrapped_native_bsc(Chain::Bsc, "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c")
433            }
434            Chain::Unichain => {
435                wrapped_native_eth(Chain::Unichain, "0x4200000000000000000000000000000000000006")
436            }
437            Chain::Polygon => {
438                wrapped_native_pol(Chain::Polygon, "0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270")
439            }
440            Chain::Custom(id) => {
441                wrapped_native_custom(*self, try_resolve_custom(id, chain_registry())?)
442            }
443        })
444    }
445
446    /// Returns the expected block time in seconds for the chain. Panics if a custom chain has no
447    /// registered config; use [`Chain::try_block_time_secs`] for a non-panicking variant.
448    pub fn block_time_secs(&self) -> u64 {
449        expect_registered(self.try_block_time_secs())
450    }
451
452    /// Like [`Chain::block_time_secs`] but returns [`ChainConfigError::UnknownChain`] when a custom
453    /// chain has no registered config.
454    pub fn try_block_time_secs(&self) -> Result<u64, ChainConfigError> {
455        Ok(match self {
456            Chain::Ethereum => 12,
457            Chain::Starknet => 2,
458            Chain::ZkSync => 3,
459            Chain::Arbitrum => 1,
460            Chain::Base => 2,
461            Chain::Bsc => 1,
462            Chain::Unichain => 1,
463            Chain::Polygon => 2,
464            Chain::Custom(id) => try_resolve_custom(id, chain_registry())?.block_time_secs,
465        })
466    }
467}
468
469#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, Default)]
470pub struct ExtractorIdentity {
471    pub chain: Chain,
472    pub name: String,
473}
474
475impl ExtractorIdentity {
476    pub fn new(chain: Chain, name: &str) -> Self {
477        Self { chain, name: name.to_owned() }
478    }
479}
480
481impl std::fmt::Display for ExtractorIdentity {
482    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
483        write!(f, "{}:{}", self.chain, self.name)
484    }
485}
486
487impl From<ExtractorIdentity> for dto::ExtractorIdentity {
488    fn from(value: ExtractorIdentity) -> Self {
489        dto::ExtractorIdentity { chain: value.chain.into(), name: value.name }
490    }
491}
492
493impl From<dto::ExtractorIdentity> for ExtractorIdentity {
494    fn from(value: dto::ExtractorIdentity) -> Self {
495        Self { chain: value.chain.into(), name: value.name }
496    }
497}
498
499#[derive(Debug, PartialEq, Clone)]
500pub struct ExtractionState {
501    pub name: String,
502    pub chain: Chain,
503    pub attributes: serde_json::Value,
504    pub cursor: Vec<u8>,
505    pub block_hash: Bytes,
506}
507
508impl ExtractionState {
509    pub fn new(
510        name: String,
511        chain: Chain,
512        attributes: Option<serde_json::Value>,
513        cursor: &[u8],
514        block_hash: Bytes,
515    ) -> Self {
516        ExtractionState {
517            name,
518            chain,
519            attributes: attributes.unwrap_or_default(),
520            cursor: cursor.to_vec(),
521            block_hash,
522        }
523    }
524}
525
526#[derive(PartialEq, Debug, Clone, Default, Deserialize, Serialize)]
527pub enum ImplementationType {
528    #[default]
529    Vm,
530    Custom,
531}
532
533#[derive(PartialEq, Debug, Clone, Default, Deserialize, Serialize)]
534pub enum FinancialType {
535    #[default]
536    Swap,
537    Psm,
538    Debt,
539    Leverage,
540}
541
542#[derive(Debug, PartialEq, Clone, Default, Deserialize, Serialize)]
543pub struct ProtocolType {
544    pub name: String,
545    pub financial_type: FinancialType,
546    pub attribute_schema: Option<serde_json::Value>,
547    pub implementation: ImplementationType,
548}
549
550impl ProtocolType {
551    pub fn new(
552        name: String,
553        financial_type: FinancialType,
554        attribute_schema: Option<serde_json::Value>,
555        implementation: ImplementationType,
556    ) -> Self {
557        ProtocolType { name, financial_type, attribute_schema, implementation }
558    }
559}
560
561#[derive(Debug, PartialEq, Eq, Default, Copy, Clone, Deserialize, Serialize, DeepSizeOf)]
562pub enum ChangeType {
563    #[default]
564    Update,
565    Deletion,
566    Creation,
567}
568
569#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
570pub struct ContractId {
571    pub address: Address,
572    pub chain: Chain,
573}
574
575/// Uniquely identifies a contract on a specific chain.
576impl ContractId {
577    pub fn new(chain: Chain, address: Address) -> Self {
578        Self { address, chain }
579    }
580
581    pub fn address(&self) -> &Address {
582        &self.address
583    }
584}
585
586impl Display for ContractId {
587    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
588        write!(f, "{:?}: 0x{}", self.chain, hex::encode(&self.address))
589    }
590}
591
592#[derive(Debug, PartialEq, Clone, Default, Deserialize, Serialize)]
593pub struct PaginationParams {
594    pub page: i64,
595    pub page_size: i64,
596}
597
598impl PaginationParams {
599    pub fn new(page: i64, page_size: i64) -> Self {
600        Self { page, page_size }
601    }
602
603    pub fn offset(&self) -> i64 {
604        self.page * self.page_size
605    }
606}
607
608impl From<&dto::PaginationParams> for PaginationParams {
609    fn from(value: &dto::PaginationParams) -> Self {
610        PaginationParams { page: value.page, page_size: value.page_size }
611    }
612}
613
614#[derive(Error, Debug, PartialEq)]
615pub enum MergeError {
616    #[error("Can't merge {0} from differring idendities: Expected {1}, got {2}")]
617    IdMismatch(String, String, String),
618    #[error("Can't merge {0} from different blocks: 0x{1:x} != 0x{2:x}")]
619    BlockMismatch(String, Bytes, Bytes),
620    #[error("Can't merge {0} from the same transaction: 0x{1:x}")]
621    SameTransaction(String, Bytes),
622    #[error("Can't merge {0} with lower transaction index: {1} > {2}")]
623    TransactionOrderError(String, u64, u64),
624    #[error("Cannot merge: {0}")]
625    InvalidState(String),
626}
627
628// The custom-chain tests below install the process-wide chain registry, which is a set-once
629// `OnceLock`. They must run in isolated processes (we use nextest), so each test gets a fresh
630// registry; under a shared-process runner they would contend over the same global.
631#[cfg(test)]
632mod tests {
633    use arrayvec::ArrayString;
634
635    use super::{
636        chain_config::{
637            init_chain_registry, ChainAddress, ChainConfigError, ChainTokenConfig, TvlThresholds,
638        },
639        *,
640    };
641
642    fn test_config() -> CustomChainConfig {
643        CustomChainConfig::try_new(
644            "testchain",
645            9999,
646            5,
647            ChainTokenConfig::try_new("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "TST", 18)
648                .unwrap(),
649            ChainTokenConfig::try_new("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "WTST", 18)
650                .unwrap(),
651            TvlThresholds::new(50.0, 500.0),
652        )
653        .unwrap()
654    }
655
656    fn init_test_registry() {
657        init_chain_registry(ChainConfigRegistry::from_configs([test_config()]).unwrap())
658            .expect("chain registry already initialised; run tests under nextest");
659    }
660
661    #[test]
662    fn test_custom_chain_display() {
663        init_test_registry();
664        assert_eq!(
665            Chain::custom("testchain")
666                .unwrap()
667                .to_string(),
668            "testchain"
669        );
670    }
671
672    #[test]
673    fn test_from_str_custom_returns_err() {
674        assert!("custom".parse::<Chain>().is_err());
675        assert!("unknown".parse::<Chain>().is_err());
676    }
677
678    #[test]
679    fn test_custom_unregistered_returns_err() {
680        init_test_registry();
681        assert_eq!(Chain::custom("nope"), Err(ChainConfigError::UnknownChain("nope".to_owned())));
682    }
683
684    #[test]
685    fn test_from_dto_registered_custom_roundtrips() {
686        init_test_registry();
687        let dto_chain: dto::Chain = Chain::custom("testchain")
688            .unwrap()
689            .into();
690        let chain: Chain = dto_chain.into();
691        assert_eq!(chain.id(), 9999);
692    }
693
694    #[test]
695    #[should_panic(expected = "no registered config")]
696    fn test_from_dto_unregistered_custom_panics() {
697        let dto_chain = dto::Chain::Custom(ArrayString::from("nope").unwrap());
698        let _: Chain = dto_chain.into();
699    }
700
701    #[test]
702    fn test_try_accessors_ok_for_registered_custom() {
703        init_test_registry();
704        let chain = Chain::custom("testchain").unwrap();
705        assert_eq!(chain.try_id().unwrap(), 9999);
706        assert_eq!(chain.try_block_time_secs().unwrap(), 5);
707        assert_eq!(
708            chain
709                .try_default_tvl_threshold(TvlThresholdTier::Low)
710                .unwrap(),
711            50.0
712        );
713        assert_eq!(chain.try_native_token().unwrap().symbol, "TST");
714        assert_eq!(
715            chain
716                .try_wrapped_native_token()
717                .unwrap()
718                .symbol,
719            "WTST"
720        );
721    }
722
723    #[test]
724    fn test_try_accessors_err_for_unregistered_custom() {
725        // A `CustomChainId` that bypassed registry validation (only reachable via direct
726        // deserialization) surfaces as an error rather than a panic through the `try_*` accessors.
727        let ghost: Chain = serde_json::from_str(r#"{"custom":"ghostchain"}"#).unwrap();
728        assert_eq!(ghost.try_id(), Err(ChainConfigError::UnknownChain("ghostchain".to_owned())));
729        assert!(ghost.try_native_token().is_err());
730    }
731
732    #[test]
733    fn test_chain_stays_small() {
734        // Regression: Custom carries only a 32-char name, so Chain stays a small Copy enum rather
735        // than embedding the full config (~200 bytes) as it did when Custom held CustomChainConfig.
736        assert!(
737            std::mem::size_of::<Chain>() <= 40,
738            "Chain is {} bytes",
739            std::mem::size_of::<Chain>()
740        );
741    }
742
743    #[test]
744    fn test_custom_chain_id() {
745        init_test_registry();
746        let chain = Chain::custom("testchain").unwrap();
747        assert_eq!(chain.id(), 9999);
748    }
749
750    #[test]
751    fn test_custom_chain_tvl_thresholds() {
752        init_test_registry();
753        let chain = Chain::custom("testchain").unwrap();
754        assert_eq!(chain.default_tvl_threshold(TvlThresholdTier::Low), 50.0);
755        assert_eq!(chain.default_tvl_threshold(TvlThresholdTier::Medium), 500.0);
756    }
757
758    #[test]
759    fn test_custom_chain_native_token() {
760        init_test_registry();
761        let chain = Chain::custom("testchain").unwrap();
762        let token = chain.native_token();
763        assert_eq!(token.symbol, "TST");
764        assert_eq!(token.decimals, 18);
765        assert_eq!(token.chain, chain);
766        assert_eq!(token.address, Bytes::from(vec![0xAA; 20]));
767    }
768
769    #[test]
770    fn test_custom_chain_wrapped_native_token() {
771        init_test_registry();
772        let chain = Chain::custom("testchain").unwrap();
773        let token = chain.wrapped_native_token();
774        assert_eq!(token.symbol, "WTST");
775        assert_eq!(token.chain, chain);
776        assert_eq!(token.address, Bytes::from(vec![0xBB; 20]));
777    }
778
779    #[test]
780    fn test_chain_address_new_rejects_oversized_input() {
781        assert_eq!(ChainAddress::new(&[0u8; 33]), Err(ChainConfigError::AddressTooLong(33)));
782    }
783
784    #[test]
785    fn test_chain_address_as_bytes_returns_active_slice() {
786        let addr = ChainAddress::new(&[0xAA; 20]).unwrap();
787        assert_eq!(addr.as_bytes(), &[0xAA; 20]);
788        assert_eq!(addr.as_bytes().len(), 20);
789    }
790}