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