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