1use crate::error::{EvmCommonError, EvmResult};
8
9#[derive(Debug, Clone)]
11pub struct ChainInfo {
12 pub chain_id: u64,
14 pub name: String,
16 pub symbol: String,
18 pub block_explorer: Option<String>,
20 pub default_rpc: Option<String>,
22}
23
24pub 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
87pub 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_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 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
154fn 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
165pub 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
212pub 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
241pub fn is_supported_chain(chain_id: u64) -> bool {
260 chain_id_to_rpc_url(chain_id).is_ok()
261}
262
263pub 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 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 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
310pub 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
331pub 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
357pub 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 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 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 assert!(get_chain_info(999999).is_none());
414 }
415
416 #[test]
417 fn test_rpc_url_resolution() {
418 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 env::remove_var("RPC_URL_1");
428 let result = chain_id_to_rpc_url(1);
429 assert!(result.is_ok()); }
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 assert!(chains.contains(&1)); }
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 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 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 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 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 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 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 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 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 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 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 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 assert!(chains.contains(&1)); assert!(chains.contains(&137)); assert!(chains.contains(&42161)); assert!(chains.contains(&10)); assert!(chains.contains(&8453)); assert!(chains.contains(&56)); assert!(chains.contains(&43114)); assert!(chains.contains(&250)); }
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 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 let debug_str = format!("{:?}", info);
768 assert!(debug_str.contains("ChainInfo"));
769 assert!(debug_str.contains("Ethereum"));
770 }
771}