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