Skip to main content

riglr_evm_common/
chain.rs

1//! Chain management utilities shared across riglr crates
2//!
3//! This module provides unified chain ID mapping, RPC URL management,
4//! and chain validation functions that eliminate duplication between
5//! riglr-evm-tools and riglr-cross-chain-tools.
6
7use crate::error::{EvmCommonError, EvmResult};
8
9/// Chain information structure
10#[derive(Debug, Clone)]
11pub struct ChainInfo {
12    /// Numeric chain ID (e.g., 1 for Ethereum, 137 for Polygon)
13    pub chain_id: u64,
14    /// Human-readable chain name (e.g., "Ethereum", "Polygon")
15    pub name: String,
16    /// Native token symbol (e.g., "ETH", "MATIC")
17    pub symbol: String,
18    /// Block explorer base URL for this chain
19    pub block_explorer: Option<String>,
20    /// Default RPC endpoint URL for this chain
21    pub default_rpc: Option<String>,
22}
23
24/// Get chain information by chain ID
25pub fn get_chain_info(chain_id: u64) -> Option<ChainInfo> {
26    match chain_id {
27        1 => Some(ChainInfo {
28            chain_id: 1,
29            name: "Ethereum".to_string(),
30            symbol: "ETH".to_string(),
31            block_explorer: Some("https://etherscan.io".to_string()),
32            default_rpc: Some("https://eth.llamarpc.com".to_string()),
33        }),
34        137 => Some(ChainInfo {
35            chain_id: 137,
36            name: "Polygon".to_string(),
37            symbol: "MATIC".to_string(),
38            block_explorer: Some("https://polygonscan.com".to_string()),
39            default_rpc: Some("https://polygon-rpc.com".to_string()),
40        }),
41        42161 => Some(ChainInfo {
42            chain_id: 42161,
43            name: "Arbitrum".to_string(),
44            symbol: "ETH".to_string(),
45            block_explorer: Some("https://arbiscan.io".to_string()),
46            default_rpc: Some("https://arb1.arbitrum.io/rpc".to_string()),
47        }),
48        10 => Some(ChainInfo {
49            chain_id: 10,
50            name: "Optimism".to_string(),
51            symbol: "ETH".to_string(),
52            block_explorer: Some("https://optimistic.etherscan.io".to_string()),
53            default_rpc: Some("https://mainnet.optimism.io".to_string()),
54        }),
55        8453 => Some(ChainInfo {
56            chain_id: 8453,
57            name: "Base".to_string(),
58            symbol: "ETH".to_string(),
59            block_explorer: Some("https://basescan.org".to_string()),
60            default_rpc: Some("https://mainnet.base.org".to_string()),
61        }),
62        56 => Some(ChainInfo {
63            chain_id: 56,
64            name: "BNB Smart Chain".to_string(),
65            symbol: "BNB".to_string(),
66            block_explorer: Some("https://bscscan.com".to_string()),
67            default_rpc: Some("https://bsc-dataseed.binance.org".to_string()),
68        }),
69        43114 => Some(ChainInfo {
70            chain_id: 43114,
71            name: "Avalanche".to_string(),
72            symbol: "AVAX".to_string(),
73            block_explorer: Some("https://snowtrace.io".to_string()),
74            default_rpc: Some("https://api.avax.network/ext/bc/C/rpc".to_string()),
75        }),
76        250 => Some(ChainInfo {
77            chain_id: 250,
78            name: "Fantom".to_string(),
79            symbol: "FTM".to_string(),
80            block_explorer: Some("https://ftmscan.com".to_string()),
81            default_rpc: Some("https://rpc.ftm.tools".to_string()),
82        }),
83        _ => None,
84    }
85}
86
87/// Maps chain IDs to RPC URLs using convention-based environment variable lookup.
88/// Uses format: RPC_URL_{CHAIN_ID}
89///
90/// This is the UNIFIED approach that eliminates conflicts between different
91/// chain management systems across riglr crates.
92///
93/// # Arguments
94/// * `chain_id` - Numeric chain ID (e.g., 1 for Ethereum, 137 for Polygon)
95///
96/// # Returns
97/// * RPC URL string from environment or error if not configured
98///
99/// # Environment Variables
100/// * `RPC_URL_1` - Ethereum mainnet
101/// * `RPC_URL_137` - Polygon
102/// * `RPC_URL_42161` - Arbitrum
103/// * `RPC_URL_10` - Optimism
104/// * `RPC_URL_8453` - Base
105/// * etc.
106///
107/// # Examples
108/// ```rust,ignore
109/// use riglr_evm_common::chain::chain_id_to_rpc_url;
110///
111/// // Configure environment
112/// std::env::set_var("RPC_URL_1", "https://eth.llamarpc.com");
113///
114/// let url = chain_id_to_rpc_url(1)?;
115/// assert_eq!(url, "https://eth.llamarpc.com");
116/// ```
117pub fn chain_id_to_rpc_url(chain_id: u64) -> EvmResult<String> {
118    let env_var = format!("RPC_URL_{}", chain_id);
119
120    match std::env::var(&env_var) {
121        Ok(url) => {
122            if url.trim().is_empty() {
123                return Err(EvmCommonError::InvalidConfig(format!(
124                    "RPC URL for chain {} is empty. Set {} environment variable.",
125                    chain_id, env_var
126                )));
127            }
128
129            // Validate URL format
130            validate_rpc_url(&url, chain_id)?;
131
132            tracing::debug!(
133                "✅ Found RPC URL for chain {}: {}",
134                chain_id,
135                &url[..std::cmp::min(50, url.len())]
136            );
137            Ok(url)
138        }
139        Err(_) => {
140            // Try to use default RPC if available
141            if let Some(chain_info) = get_chain_info(chain_id) {
142                if let Some(default_rpc) = chain_info.default_rpc {
143                    tracing::warn!("⚠️  Using default RPC for chain {}: {}. Consider setting {} for production use.",
144                                   chain_id, default_rpc, env_var);
145                    return Ok(default_rpc);
146                }
147            }
148
149            Err(EvmCommonError::UnsupportedChain(chain_id))
150        }
151    }
152}
153
154/// Validate RPC URL format
155fn validate_rpc_url(url: &str, chain_id: u64) -> EvmResult<()> {
156    if !url.starts_with("http://") && !url.starts_with("https://") && !url.starts_with("wss://") {
157        return Err(EvmCommonError::InvalidConfig(format!(
158            "Invalid RPC URL format for chain {}: {}. Must start with http://, https://, or wss://",
159            chain_id, url
160        )));
161    }
162    Ok(())
163}
164
165/// Convert chain name to chain ID
166///
167/// This provides a bridge between human-readable names and numeric IDs,
168/// useful for cross-chain operations and user interfaces.
169///
170/// # Arguments
171/// * `name` - Chain name (case-insensitive)
172///
173/// # Returns
174/// * Numeric chain ID
175///
176/// # Supported Names
177/// * "ethereum", "eth" → 1
178/// * "polygon", "matic" → 137
179/// * "arbitrum", "arb" → 42161
180/// * "optimism", "op" → 10
181/// * "base" → 8453
182/// * "bsc", "binance" → 56
183/// * "avalanche", "avax" → 43114
184/// * "fantom", "ftm" → 250
185///
186/// # Examples
187/// ```rust,ignore
188/// use riglr_evm_common::chain::chain_name_to_id;
189///
190/// assert_eq!(chain_name_to_id("ethereum")?, 1);
191/// assert_eq!(chain_name_to_id("ETH")?, 1);
192/// assert_eq!(chain_name_to_id("polygon")?, 137);
193/// ```
194pub fn chain_name_to_id(name: &str) -> EvmResult<u64> {
195    let normalized = name.to_lowercase();
196    match normalized.as_str() {
197        "ethereum" | "eth" => Ok(1),
198        "polygon" | "matic" => Ok(137),
199        "arbitrum" | "arb" => Ok(42161),
200        "optimism" | "op" => Ok(10),
201        "base" => Ok(8453),
202        "bsc" | "binance" => Ok(56),
203        "avalanche" | "avax" => Ok(43114),
204        "fantom" | "ftm" => Ok(250),
205        _ => Err(EvmCommonError::InvalidChainName(format!(
206            "Unsupported chain name: {}. Supported: ethereum, polygon, arbitrum, optimism, base, bsc, avalanche, fantom",
207            name
208        ))),
209    }
210}
211
212/// Convert chain ID to human-readable name
213///
214/// # Arguments
215/// * `id` - Numeric chain ID
216///
217/// # Returns
218/// * Human-readable chain name
219///
220/// # Examples
221/// ```rust,ignore
222/// use riglr_evm_common::chain::chain_id_to_name;
223///
224/// assert_eq!(chain_id_to_name(1)?, "ethereum");
225/// assert_eq!(chain_id_to_name(137)?, "polygon");
226/// ```
227pub fn chain_id_to_name(id: u64) -> EvmResult<String> {
228    match id {
229        1 => Ok("ethereum".to_string()),
230        137 => Ok("polygon".to_string()),
231        42161 => Ok("arbitrum".to_string()),
232        10 => Ok("optimism".to_string()),
233        8453 => Ok("base".to_string()),
234        56 => Ok("bsc".to_string()),
235        43114 => Ok("avalanche".to_string()),
236        250 => Ok("fantom".to_string()),
237        _ => Err(EvmCommonError::UnsupportedChain(id)),
238    }
239}
240
241/// Check if a chain is supported (has RPC URL configured or has default)
242///
243/// # Arguments
244/// * `chain_id` - Numeric chain ID to check
245///
246/// # Returns
247/// * `true` if chain is supported, `false` otherwise
248///
249/// # Examples
250/// ```rust,ignore
251/// use riglr_evm_common::chain::is_supported_chain;
252///
253/// // If RPC_URL_1 is configured or Ethereum has defaults
254/// assert!(is_supported_chain(1));
255///
256/// // Unsupported chain
257/// assert!(!is_supported_chain(999999));
258/// ```
259pub fn is_supported_chain(chain_id: u64) -> bool {
260    chain_id_to_rpc_url(chain_id).is_ok()
261}
262
263/// Get list of all supported chain IDs
264///
265/// Scans environment variables for RPC_URL_* patterns and includes
266/// chains with default RPC endpoints.
267///
268/// # Returns
269/// * Vector of supported chain IDs
270///
271/// # Examples
272/// ```rust,ignore
273/// use riglr_evm_common::chain::get_supported_chains;
274///
275/// let chains = get_supported_chains();
276/// if chains.contains(&1) {
277///     println!("Ethereum is supported!");
278/// }
279/// ```
280pub fn get_supported_chains() -> Vec<u64> {
281    let mut chains: Vec<u64> = std::env::vars()
282        .filter_map(|(key, _value)| {
283            if key.starts_with("RPC_URL_") {
284                key.strip_prefix("RPC_URL_")
285                    .and_then(|chain_id_str| chain_id_str.parse::<u64>().ok())
286            } else {
287                None
288            }
289        })
290        .collect();
291
292    // Add chains with default RPCs that aren't already configured
293    let default_chains = [1, 137, 42161, 10, 8453, 56, 43114, 250];
294    for chain_id in default_chains {
295        if !chains.contains(&chain_id) {
296            // Check if this chain has default RPC
297            if get_chain_info(chain_id)
298                .and_then(|info| info.default_rpc)
299                .is_some()
300            {
301                chains.push(chain_id);
302            }
303        }
304    }
305
306    chains.sort();
307    chains
308}
309
310/// Get block explorer URL for a chain
311///
312/// # Arguments
313/// * `chain_id` - Numeric chain ID
314///
315/// # Returns
316/// * Block explorer base URL if known
317///
318/// # Examples
319/// ```rust,ignore
320/// use riglr_evm_common::chain::get_block_explorer_url;
321///
322/// let url = get_block_explorer_url(1)?;
323/// assert_eq!(url, "https://etherscan.io");
324/// ```
325pub fn get_block_explorer_url(chain_id: u64) -> EvmResult<String> {
326    get_chain_info(chain_id)
327        .and_then(|info| info.block_explorer)
328        .ok_or(EvmCommonError::UnsupportedChain(chain_id))
329}
330
331/// Get transaction URL for a specific transaction
332///
333/// # Arguments
334/// * `chain_id` - Numeric chain ID
335/// * `tx_hash` - Transaction hash (with or without 0x prefix)
336///
337/// # Returns
338/// * Full URL to view transaction in block explorer
339///
340/// # Examples
341/// ```rust,ignore
342/// use riglr_evm_common::chain::get_transaction_url;
343///
344/// let url = get_transaction_url(1, "0x123abc...")?;
345/// // Returns: https://etherscan.io/tx/0x123abc...
346/// ```
347pub fn get_transaction_url(chain_id: u64, tx_hash: &str) -> EvmResult<String> {
348    let base_url = get_block_explorer_url(chain_id)?;
349    let hash = if tx_hash.starts_with("0x") {
350        tx_hash
351    } else {
352        &format!("0x{}", tx_hash)
353    };
354    Ok(format!("{}/tx/{}", base_url, hash))
355}
356
357/// Get address URL for viewing an address in block explorer
358///
359/// # Arguments
360/// * `chain_id` - Numeric chain ID
361/// * `address` - Address (with or without 0x prefix)
362///
363/// # Returns
364/// * Full URL to view address in block explorer
365///
366/// # Examples
367/// ```rust,ignore
368/// use riglr_evm_common::chain::get_address_url;
369///
370/// let url = get_address_url(1, "0x742d35Cc...")?;
371/// // Returns: https://etherscan.io/address/0x742d35Cc...
372/// ```
373pub fn get_address_url(chain_id: u64, address: &str) -> EvmResult<String> {
374    let base_url = get_block_explorer_url(chain_id)?;
375    let addr = crate::address::ensure_0x_prefix(address);
376    Ok(format!("{}/address/{}", base_url, addr))
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use std::env;
383
384    #[test]
385    fn test_chain_name_conversion() {
386        assert_eq!(chain_name_to_id("ethereum").unwrap(), 1);
387        assert_eq!(chain_name_to_id("ETH").unwrap(), 1);
388        assert_eq!(chain_name_to_id("polygon").unwrap(), 137);
389        assert_eq!(chain_name_to_id("ARBITRUM").unwrap(), 42161);
390
391        // Test invalid name
392        assert!(chain_name_to_id("invalid").is_err());
393    }
394
395    #[test]
396    fn test_chain_id_to_name() {
397        assert_eq!(chain_id_to_name(1).unwrap(), "ethereum");
398        assert_eq!(chain_id_to_name(137).unwrap(), "polygon");
399        assert_eq!(chain_id_to_name(42161).unwrap(), "arbitrum");
400
401        // Test invalid ID
402        assert!(chain_id_to_name(999999).is_err());
403    }
404
405    #[test]
406    fn test_chain_info() {
407        let eth_info = get_chain_info(1).unwrap();
408        assert_eq!(eth_info.name, "Ethereum");
409        assert_eq!(eth_info.symbol, "ETH");
410        assert!(eth_info.block_explorer.is_some());
411
412        // Test unknown chain
413        assert!(get_chain_info(999999).is_none());
414    }
415
416    #[test]
417    fn test_rpc_url_resolution() {
418        // Test with environment variable
419        env::set_var("RPC_URL_999", "https://test-rpc.example.com");
420        let result = chain_id_to_rpc_url(999);
421        assert!(result.is_ok());
422        assert_eq!(result.unwrap(), "https://test-rpc.example.com");
423        env::remove_var("RPC_URL_999");
424
425        // Test with default (Ethereum should have default)
426        // Clear any existing env var to test default behavior
427        env::remove_var("RPC_URL_1");
428        let result = chain_id_to_rpc_url(1);
429        assert!(result.is_ok()); // Should use default RPC
430    }
431
432    #[test]
433    fn test_invalid_rpc_url() {
434        env::set_var("RPC_URL_998", "invalid-url");
435        let result = chain_id_to_rpc_url(998);
436        assert!(result.is_err());
437        env::remove_var("RPC_URL_998");
438    }
439
440    #[test]
441    fn test_supported_chains() {
442        let chains = get_supported_chains();
443        assert!(!chains.is_empty());
444
445        // Should include major chains with defaults
446        assert!(chains.contains(&1)); // Ethereum
447    }
448
449    #[test]
450    fn test_block_explorer_urls() {
451        let eth_url = get_block_explorer_url(1).unwrap();
452        assert_eq!(eth_url, "https://etherscan.io");
453
454        let polygon_url = get_block_explorer_url(137).unwrap();
455        assert_eq!(polygon_url, "https://polygonscan.com");
456
457        // Test invalid chain
458        assert!(get_block_explorer_url(999999).is_err());
459    }
460
461    #[test]
462    fn test_transaction_url() {
463        let url = get_transaction_url(1, "0x123abc").unwrap();
464        assert_eq!(url, "https://etherscan.io/tx/0x123abc");
465
466        // Test without 0x prefix
467        let url = get_transaction_url(1, "123abc").unwrap();
468        assert_eq!(url, "https://etherscan.io/tx/0x123abc");
469    }
470
471    #[test]
472    fn test_address_url() {
473        let url = get_address_url(1, "0x742d35Cc67A5b747bE4C506C5e8b0A146d7b2E9e").unwrap();
474        assert_eq!(
475            url,
476            "https://etherscan.io/address/0x742d35Cc67A5b747bE4C506C5e8b0A146d7b2E9e"
477        );
478
479        // Test without 0x prefix
480        let url = get_address_url(1, "742d35Cc67A5b747bE4C506C5e8b0A146d7b2E9e").unwrap();
481        assert_eq!(
482            url,
483            "https://etherscan.io/address/0x742d35Cc67A5b747bE4C506C5e8b0A146d7b2E9e"
484        );
485    }
486
487    #[test]
488    fn test_validate_rpc_url_when_valid_should_return_ok() {
489        // Test all valid URL schemes
490        assert!(validate_rpc_url("https://example.com", 1).is_ok());
491        assert!(validate_rpc_url("http://example.com", 1).is_ok());
492        assert!(validate_rpc_url("wss://example.com", 1).is_ok());
493    }
494
495    #[test]
496    fn test_validate_rpc_url_when_invalid_scheme_should_return_err() {
497        let result = validate_rpc_url("ftp://example.com", 1);
498        assert!(result.is_err());
499        if let Err(EvmCommonError::InvalidConfig(msg)) = result {
500            assert!(msg.contains("Invalid RPC URL format for chain 1"));
501            assert!(msg.contains("Must start with http://, https://, or wss://"));
502        }
503    }
504
505    #[test]
506    fn test_validate_rpc_url_when_no_scheme_should_return_err() {
507        let result = validate_rpc_url("example.com", 1);
508        assert!(result.is_err());
509        if let Err(EvmCommonError::InvalidConfig(msg)) = result {
510            assert!(msg.contains("Invalid RPC URL format for chain 1"));
511        }
512    }
513
514    #[test]
515    fn test_chain_id_to_rpc_url_when_empty_env_var_should_return_err() {
516        // Test empty environment variable
517        env::set_var("RPC_URL_997", "   ");
518        let result = chain_id_to_rpc_url(997);
519        assert!(result.is_err());
520        if let Err(EvmCommonError::InvalidConfig(msg)) = result {
521            assert!(msg.contains("RPC URL for chain 997 is empty"));
522        }
523        env::remove_var("RPC_URL_997");
524    }
525
526    #[test]
527    fn test_chain_id_to_rpc_url_when_whitespace_only_should_return_err() {
528        // Test whitespace-only environment variable
529        env::set_var("RPC_URL_996", "\t\n  \r");
530        let result = chain_id_to_rpc_url(996);
531        assert!(result.is_err());
532        env::remove_var("RPC_URL_996");
533    }
534
535    #[test]
536    fn test_chain_id_to_rpc_url_when_unsupported_chain_no_default_should_return_err() {
537        // Test completely unsupported chain (no env var, no default)
538        env::remove_var("RPC_URL_999999");
539        let result = chain_id_to_rpc_url(999999);
540        assert!(result.is_err());
541        if let Err(EvmCommonError::UnsupportedChain(chain_id)) = result {
542            assert_eq!(chain_id, 999999);
543        }
544    }
545
546    #[test]
547    fn test_get_chain_info_for_all_supported_chains() {
548        // Test all supported chains individually
549        let eth_info = get_chain_info(1).unwrap();
550        assert_eq!(eth_info.chain_id, 1);
551        assert_eq!(eth_info.name, "Ethereum");
552        assert_eq!(eth_info.symbol, "ETH");
553        assert!(eth_info.block_explorer.is_some());
554        assert!(eth_info.default_rpc.is_some());
555
556        let polygon_info = get_chain_info(137).unwrap();
557        assert_eq!(polygon_info.chain_id, 137);
558        assert_eq!(polygon_info.name, "Polygon");
559        assert_eq!(polygon_info.symbol, "MATIC");
560
561        let arbitrum_info = get_chain_info(42161).unwrap();
562        assert_eq!(arbitrum_info.chain_id, 42161);
563        assert_eq!(arbitrum_info.name, "Arbitrum");
564        assert_eq!(arbitrum_info.symbol, "ETH");
565
566        let optimism_info = get_chain_info(10).unwrap();
567        assert_eq!(optimism_info.chain_id, 10);
568        assert_eq!(optimism_info.name, "Optimism");
569        assert_eq!(optimism_info.symbol, "ETH");
570
571        let base_info = get_chain_info(8453).unwrap();
572        assert_eq!(base_info.chain_id, 8453);
573        assert_eq!(base_info.name, "Base");
574        assert_eq!(base_info.symbol, "ETH");
575
576        let bsc_info = get_chain_info(56).unwrap();
577        assert_eq!(bsc_info.chain_id, 56);
578        assert_eq!(bsc_info.name, "BNB Smart Chain");
579        assert_eq!(bsc_info.symbol, "BNB");
580
581        let avalanche_info = get_chain_info(43114).unwrap();
582        assert_eq!(avalanche_info.chain_id, 43114);
583        assert_eq!(avalanche_info.name, "Avalanche");
584        assert_eq!(avalanche_info.symbol, "AVAX");
585
586        let fantom_info = get_chain_info(250).unwrap();
587        assert_eq!(fantom_info.chain_id, 250);
588        assert_eq!(fantom_info.name, "Fantom");
589        assert_eq!(fantom_info.symbol, "FTM");
590    }
591
592    #[test]
593    fn test_chain_name_to_id_all_supported_aliases() {
594        // Test all supported chain name aliases
595        assert_eq!(chain_name_to_id("ethereum").unwrap(), 1);
596        assert_eq!(chain_name_to_id("eth").unwrap(), 1);
597        assert_eq!(chain_name_to_id("ETHEREUM").unwrap(), 1);
598        assert_eq!(chain_name_to_id("ETH").unwrap(), 1);
599
600        assert_eq!(chain_name_to_id("polygon").unwrap(), 137);
601        assert_eq!(chain_name_to_id("matic").unwrap(), 137);
602        assert_eq!(chain_name_to_id("POLYGON").unwrap(), 137);
603        assert_eq!(chain_name_to_id("MATIC").unwrap(), 137);
604
605        assert_eq!(chain_name_to_id("arbitrum").unwrap(), 42161);
606        assert_eq!(chain_name_to_id("arb").unwrap(), 42161);
607        assert_eq!(chain_name_to_id("ARBITRUM").unwrap(), 42161);
608        assert_eq!(chain_name_to_id("ARB").unwrap(), 42161);
609
610        assert_eq!(chain_name_to_id("optimism").unwrap(), 10);
611        assert_eq!(chain_name_to_id("op").unwrap(), 10);
612        assert_eq!(chain_name_to_id("OPTIMISM").unwrap(), 10);
613        assert_eq!(chain_name_to_id("OP").unwrap(), 10);
614
615        assert_eq!(chain_name_to_id("base").unwrap(), 8453);
616        assert_eq!(chain_name_to_id("BASE").unwrap(), 8453);
617
618        assert_eq!(chain_name_to_id("bsc").unwrap(), 56);
619        assert_eq!(chain_name_to_id("binance").unwrap(), 56);
620        assert_eq!(chain_name_to_id("BSC").unwrap(), 56);
621        assert_eq!(chain_name_to_id("BINANCE").unwrap(), 56);
622
623        assert_eq!(chain_name_to_id("avalanche").unwrap(), 43114);
624        assert_eq!(chain_name_to_id("avax").unwrap(), 43114);
625        assert_eq!(chain_name_to_id("AVALANCHE").unwrap(), 43114);
626        assert_eq!(chain_name_to_id("AVAX").unwrap(), 43114);
627
628        assert_eq!(chain_name_to_id("fantom").unwrap(), 250);
629        assert_eq!(chain_name_to_id("ftm").unwrap(), 250);
630        assert_eq!(chain_name_to_id("FANTOM").unwrap(), 250);
631        assert_eq!(chain_name_to_id("FTM").unwrap(), 250);
632    }
633
634    #[test]
635    fn test_chain_name_to_id_when_invalid_name_should_return_err() {
636        let result = chain_name_to_id("unknown_chain");
637        assert!(result.is_err());
638        if let Err(EvmCommonError::InvalidChainName(msg)) = result {
639            assert!(msg.contains("Unsupported chain name: unknown_chain"));
640            assert!(msg.contains(
641                "Supported: ethereum, polygon, arbitrum, optimism, base, bsc, avalanche, fantom"
642            ));
643        }
644    }
645
646    #[test]
647    fn test_chain_name_to_id_when_empty_string_should_return_err() {
648        let result = chain_name_to_id("");
649        assert!(result.is_err());
650    }
651
652    #[test]
653    fn test_chain_id_to_name_all_supported_chains() {
654        assert_eq!(chain_id_to_name(1).unwrap(), "ethereum");
655        assert_eq!(chain_id_to_name(137).unwrap(), "polygon");
656        assert_eq!(chain_id_to_name(42161).unwrap(), "arbitrum");
657        assert_eq!(chain_id_to_name(10).unwrap(), "optimism");
658        assert_eq!(chain_id_to_name(8453).unwrap(), "base");
659        assert_eq!(chain_id_to_name(56).unwrap(), "bsc");
660        assert_eq!(chain_id_to_name(43114).unwrap(), "avalanche");
661        assert_eq!(chain_id_to_name(250).unwrap(), "fantom");
662    }
663
664    #[test]
665    fn test_chain_id_to_name_when_unsupported_should_return_err() {
666        let result = chain_id_to_name(999999);
667        assert!(result.is_err());
668        if let Err(EvmCommonError::UnsupportedChain(chain_id)) = result {
669            assert_eq!(chain_id, 999999);
670        }
671    }
672
673    #[test]
674    fn test_is_supported_chain_when_env_var_configured() {
675        env::set_var("RPC_URL_995", "https://test.example.com");
676        assert!(is_supported_chain(995));
677        env::remove_var("RPC_URL_995");
678    }
679
680    #[test]
681    fn test_is_supported_chain_when_has_default() {
682        // Ethereum should be supported due to default RPC
683        env::remove_var("RPC_URL_1");
684        assert!(is_supported_chain(1));
685    }
686
687    #[test]
688    fn test_is_supported_chain_when_not_supported() {
689        env::remove_var("RPC_URL_999999");
690        assert!(!is_supported_chain(999999));
691    }
692
693    #[test]
694    fn test_get_supported_chains_includes_env_vars() {
695        // Set a custom environment variable
696        env::set_var("RPC_URL_994", "https://test.example.com");
697        let chains = get_supported_chains();
698        assert!(chains.contains(&994));
699        env::remove_var("RPC_URL_994");
700    }
701
702    #[test]
703    fn test_get_supported_chains_includes_defaults() {
704        let chains = get_supported_chains();
705        // Should include all chains with default RPCs
706        assert!(chains.contains(&1)); // Ethereum
707        assert!(chains.contains(&137)); // Polygon
708        assert!(chains.contains(&42161)); // Arbitrum
709        assert!(chains.contains(&10)); // Optimism
710        assert!(chains.contains(&8453)); // Base
711        assert!(chains.contains(&56)); // BSC
712        assert!(chains.contains(&43114)); // Avalanche
713        assert!(chains.contains(&250)); // Fantom
714    }
715
716    #[test]
717    fn test_get_supported_chains_sorted() {
718        let chains = get_supported_chains();
719        let mut sorted_chains = chains.clone();
720        sorted_chains.sort();
721        assert_eq!(chains, sorted_chains);
722    }
723
724    #[test]
725    fn test_get_supported_chains_no_duplicates() {
726        // Set env var for a chain that already has defaults
727        env::set_var("RPC_URL_1", "https://custom-eth.example.com");
728        let chains = get_supported_chains();
729        let unique_count = chains.len();
730        let mut deduped = chains.clone();
731        deduped.sort();
732        deduped.dedup();
733        assert_eq!(unique_count, deduped.len());
734        env::remove_var("RPC_URL_1");
735    }
736
737    #[test]
738    fn test_get_block_explorer_url_when_unsupported_chain_should_return_err() {
739        let result = get_block_explorer_url(999999);
740        assert!(result.is_err());
741        if let Err(EvmCommonError::UnsupportedChain(chain_id)) = result {
742            assert_eq!(chain_id, 999999);
743        }
744    }
745
746    #[test]
747    fn test_get_transaction_url_when_unsupported_chain_should_return_err() {
748        let result = get_transaction_url(999999, "0x123");
749        assert!(result.is_err());
750    }
751
752    #[test]
753    fn test_get_address_url_when_unsupported_chain_should_return_err() {
754        let result = get_address_url(999999, "0x123");
755        assert!(result.is_err());
756    }
757
758    #[test]
759    fn test_chain_info_debug_clone() {
760        let info = get_chain_info(1).unwrap();
761        let cloned = info.clone();
762        assert_eq!(info.chain_id, cloned.chain_id);
763        assert_eq!(info.name, cloned.name);
764        assert_eq!(info.symbol, cloned.symbol);
765
766        // Test debug format works
767        let debug_str = format!("{:?}", info);
768        assert!(debug_str.contains("ChainInfo"));
769        assert!(debug_str.contains("Ethereum"));
770    }
771}