chainlist_rs/
lib.rs

1//! Chain metadata and helpers
2//!
3//! Access chain IDs, names, native currency, RPC URLs and convenience helpers.
4//!
5//! ## Build-time data
6//!
7//! By default the build script downloads `chains.json` from
8//! `https://chainid.network/chains.json`. This requires network access during
9//! compilation. Override with:
10//! - `CHAINS_JSON_URL` to point to another source.
11//! - `CHAINS_JSON_PATH` to supply a local file and skip the download.
12//!
13//! ## Examples
14//!
15//! ```rust
16//! use chainlist_rs::Chain;
17//!
18//! let mainnet = Chain::Mainnet;
19//! assert_eq!(mainnet.id(), 1);
20//! println!("{} -> native {}", mainnet.name(), mainnet.native_currency().1);
21//! ```
22
23use alloy_primitives::U256;
24use serde::{de, Deserialize, Deserializer};
25use std::time::Duration;
26use thiserror::Error;
27
28pub mod eip;
29pub mod schema;
30
31include!(concat!(env!("OUT_DIR"), "/chain_generated.rs"));
32
33#[cfg(test)]
34mod test {
35    use super::{all_chains, Chain};
36    use crate::schema;
37    use serde_json::Value;
38    use std::collections::HashSet;
39    use strum::IntoEnumIterator;
40
41    #[test]
42    fn test_chain_count_vs_json() {
43        use std::fs;
44        use std::path::PathBuf;
45
46        let enum_count = Chain::iter().count();
47        println!("enum_count: {enum_count}");
48
49        let local_chain_ids: HashSet<u64> = Chain::iter().map(|chain| chain.id()).collect();
50
51        // Load local chains.json shipped with the crate to keep tests offline and deterministic
52        let path = option_env!("CHAINS_JSON_PATH")
53            .map(PathBuf::from)
54            .expect("CHAINS_JSON_PATH not set; build script should have downloaded chains.json");
55        let json_text = fs::read_to_string(&path)
56            .unwrap_or_else(|e| panic!("Failed to read {:?}: {}", path, e));
57        let chains: Vec<Value> = serde_json::from_str(&json_text).expect("Failed to parse JSON");
58        let json_chain_count = chains.len();
59
60        println!("chains.json contains {json_chain_count} chains");
61        assert_eq!(
62            enum_count, json_chain_count,
63            "enum_count and json_chain_count should be equal"
64        );
65
66        if enum_count != json_chain_count {
67            for chain_data in chains {
68                if let Some(chain_id) = chain_data.get("chainId").and_then(|id| id.as_u64()) {
69                    if !local_chain_ids.contains(&chain_id) {
70                        let name = chain_data
71                            .get("name")
72                            .and_then(|n| n.as_str())
73                            .unwrap_or("Unknown");
74                        let short_name = chain_data
75                            .get("shortName")
76                            .and_then(|n| n.as_str())
77                            .unwrap_or("Unknown");
78                        println!(
79                            "Missing chain: ID={chain_id}, Name={name}, ShortName={short_name}"
80                        );
81                    }
82                }
83            }
84        }
85    }
86
87    #[test]
88    fn test_chain_properties() {
89        // Test for Mainnet
90        let mainnet = Chain::Mainnet;
91        assert_eq!(mainnet.id(), 1);
92        assert!(mainnet.name().contains("Ethereum"));
93
94        // Check native currency for a few chains
95        let (_name, symbol, decimals) = mainnet.native_currency();
96        assert_eq!(symbol.to_uppercase(), "ETH");
97        assert_eq!(decimals, 18);
98
99        // Test RPC URLs - should return non-empty list for mainnet
100        assert!(!Chain::Mainnet.rpc_urls().is_empty());
101
102        // Test info_url - should be valid URL
103        assert!(Chain::Mainnet.info_url().starts_with("http"));
104
105        // Test short name
106        assert_eq!(Chain::Mainnet.short_name().to_uppercase(), "ETH");
107
108        // Test SLIP44 value for Ethereum
109        assert_eq!(Chain::Mainnet.slip44(), Some(60));
110    }
111
112    #[test]
113    fn test_blocks_in() {
114        const TARGET_AGE: u64 = 6 * 60 * 60 * 1000; // 6h in ms
115
116        assert_eq!(Chain::Mainnet.blocks_in(TARGET_AGE).round(), 1800.0);
117        assert_eq!(Chain::Sepolia.blocks_in(TARGET_AGE).round(), 1800.0);
118        // Only check chains present in local chains.json
119    }
120
121    #[test]
122    fn test_deserialize_from_str() {
123        // Test valid string deserialization
124        let json_data = "\"1\""; // Should parse to u64 1 and then to Network::Mainnet
125        let network: Chain = serde_json::from_str(json_data).unwrap();
126        assert_eq!(network, Chain::Mainnet);
127
128        let json_data = "\"11155111\""; // Should parse to u64 11155111 and then to Network::Sepolia
129        let network: Chain = serde_json::from_str(json_data).unwrap();
130        assert_eq!(network, Chain::Sepolia);
131
132        // Skip Gnosis: not present in current local chains.json
133
134        // Test invalid string deserialization (should return an error)
135        let json_data = "\"invalid\""; // Cannot be parsed as u64
136        let result: Result<Chain, _> = serde_json::from_str(json_data);
137        assert!(result.is_err());
138    }
139
140    #[test]
141    fn chains_sorted_and_unique() {
142        let ids: Vec<u64> = Chain::iter().map(|c| c.id()).collect();
143        assert!(!ids.is_empty(), "Chain list should not be empty");
144
145        let mut sorted = ids.clone();
146        sorted.sort_unstable();
147        assert_eq!(
148            ids, sorted,
149            "Chain variants should stay ordered by chain id"
150        );
151
152        let unique: HashSet<u64> = ids.iter().copied().collect();
153        assert_eq!(
154            ids.len(),
155            unique.len(),
156            "Chain ids should be unique across the enum"
157        );
158    }
159
160    #[test]
161    fn chain_records_have_basic_fields() {
162        let mut short_names = HashSet::new();
163
164        for record in all_chains() {
165            assert!(
166                Chain::try_from(record.chain_id).is_ok(),
167                "Chain::try_from should cover chain_id {}",
168                record.chain_id
169            );
170            assert!(
171                record.chain_id > 0,
172                "chain_id should be positive for {}",
173                record.name
174            );
175            assert!(
176                !record.name.trim().is_empty(),
177                "name should not be empty for chain_id {}",
178                record.chain_id
179            );
180            assert!(
181                !record.chain.trim().is_empty(),
182                "chain slug should not be empty for chain_id {}",
183                record.chain_id
184            );
185            assert!(
186                !record.short_name.trim().is_empty(),
187                "short_name should not be empty for chain_id {}",
188                record.chain_id
189            );
190            assert!(
191                short_names.insert(record.short_name.as_str()),
192                "short_name {} reused for chain_id {}",
193                record.short_name,
194                record.chain_id
195            );
196            assert!(
197                !record.native_currency.name.trim().is_empty(),
198                "native currency name missing for chain_id {}",
199                record.chain_id
200            );
201            assert!(
202                !record.native_currency.symbol.trim().is_empty(),
203                "native currency symbol missing for chain_id {}",
204                record.chain_id
205            );
206            assert!(
207                record.native_currency.decimals > 0,
208                "native currency decimals must be >0 for chain_id {}",
209                record.chain_id
210            );
211        }
212    }
213
214    #[test]
215    fn schema_loader_matches_bundled_data() {
216        let loaded = schema::load_chains().expect("schema::load_chains should succeed");
217        let bundled = all_chains();
218
219        assert_eq!(
220            loaded.len(),
221            bundled.len(),
222            "schema loader should match bundled chain count"
223        );
224
225        let loaded_ids: HashSet<u64> = loaded.iter().map(|c| c.chain_id).collect();
226        let bundled_ids: HashSet<u64> = bundled.iter().map(|c| c.chain_id).collect();
227        assert_eq!(
228            loaded_ids, bundled_ids,
229            "schema loader and bundled data should agree on chain ids"
230        );
231    }
232}