1pub mod dex;
89pub mod ethereum;
90pub mod solana;
91pub mod tron;
92
93pub use dex::{DexClient, DexDataSource, DiscoverToken, TokenSearchResult};
94pub use ethereum::{ApiType, EthereumClient};
95pub use solana::{SolanaClient, validate_solana_address, validate_solana_signature};
96pub use tron::{TronClient, validate_tron_address, validate_tron_tx_hash};
97
98use crate::error::Result;
99use async_trait::async_trait;
100use serde::{Deserialize, Serialize};
101
102#[async_trait]
120pub trait ChainClient: Send + Sync {
121 fn chain_name(&self) -> &str;
123
124 fn native_token_symbol(&self) -> &str;
126
127 async fn get_balance(&self, address: &str) -> Result<Balance>;
137
138 async fn enrich_balance_usd(&self, balance: &mut Balance);
144
145 async fn get_transaction(&self, hash: &str) -> Result<Transaction>;
155
156 async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>>;
167
168 async fn get_block_number(&self) -> Result<u64>;
170
171 async fn get_token_balances(&self, address: &str) -> Result<Vec<TokenBalance>>;
176
177 async fn get_token_info(&self, _address: &str) -> Result<Token> {
182 Err(crate::error::ScopeError::Chain(
183 "Token info lookup not supported on this chain".to_string(),
184 ))
185 }
186
187 async fn get_token_holders(&self, _address: &str, _limit: u32) -> Result<Vec<TokenHolder>> {
192 Ok(Vec::new())
193 }
194
195 async fn get_token_holder_count(&self, _address: &str) -> Result<u64> {
200 Ok(0)
201 }
202
203 async fn get_code(&self, _address: &str) -> Result<String> {
207 Err(crate::error::ScopeError::Chain(
208 "Code lookup not supported on this chain".to_string(),
209 ))
210 }
211}
212
213pub trait ChainClientFactory: Send + Sync {
229 fn create_chain_client(&self, chain: &str) -> Result<Box<dyn ChainClient>>;
235
236 fn create_dex_client(&self) -> Box<dyn DexDataSource>;
238}
239
240pub struct DefaultClientFactory {
242 pub chains_config: crate::config::ChainsConfig,
244}
245
246impl ChainClientFactory for DefaultClientFactory {
247 fn create_chain_client(&self, chain: &str) -> Result<Box<dyn ChainClient>> {
248 match chain.to_lowercase().as_str() {
249 "solana" | "sol" => Ok(Box::new(SolanaClient::new(&self.chains_config)?)),
250 "tron" | "trx" => Ok(Box::new(TronClient::new(&self.chains_config)?)),
251 _ => Ok(Box::new(EthereumClient::for_chain(
252 chain,
253 &self.chains_config,
254 )?)),
255 }
256 }
257
258 fn create_dex_client(&self) -> Box<dyn DexDataSource> {
259 Box::new(DexClient::new())
260 }
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct Balance {
266 pub raw: String,
268
269 pub formatted: String,
271
272 pub decimals: u8,
274
275 pub symbol: String,
277
278 #[serde(skip_serializing_if = "Option::is_none")]
280 pub usd_value: Option<f64>,
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct Transaction {
286 pub hash: String,
288
289 pub block_number: Option<u64>,
291
292 pub timestamp: Option<u64>,
294
295 pub from: String,
297
298 pub to: Option<String>,
300
301 pub value: String,
303
304 pub gas_limit: u64,
306
307 pub gas_used: Option<u64>,
309
310 pub gas_price: String,
312
313 pub nonce: u64,
315
316 pub input: String,
318
319 pub status: Option<bool>,
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct Token {
326 pub contract_address: String,
328
329 pub symbol: String,
331
332 pub name: String,
334
335 pub decimals: u8,
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct TokenBalance {
342 pub token: Token,
344
345 pub balance: String,
347
348 pub formatted_balance: String,
350
351 #[serde(skip_serializing_if = "Option::is_none")]
353 pub usd_value: Option<f64>,
354}
355
356#[derive(Debug, Clone, Serialize, Deserialize)]
362pub struct TokenHolder {
363 pub address: String,
365
366 pub balance: String,
368
369 pub formatted_balance: String,
371
372 pub percentage: f64,
374
375 pub rank: u32,
377}
378
379#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct PricePoint {
382 pub timestamp: i64,
384
385 pub price: f64,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize)]
391pub struct VolumePoint {
392 pub timestamp: i64,
394
395 pub volume: f64,
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize)]
401pub struct HolderCountPoint {
402 pub timestamp: i64,
404
405 pub count: u64,
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize)]
411pub struct DexPair {
412 pub dex_name: String,
414
415 pub pair_address: String,
417
418 pub base_token: String,
420
421 pub quote_token: String,
423
424 pub price_usd: f64,
426
427 pub volume_24h: f64,
429
430 pub liquidity_usd: f64,
432
433 pub price_change_24h: f64,
435
436 pub buys_24h: u64,
438
439 pub sells_24h: u64,
441
442 pub buys_6h: u64,
444
445 pub sells_6h: u64,
447
448 pub buys_1h: u64,
450
451 pub sells_1h: u64,
453
454 pub pair_created_at: Option<i64>,
456
457 pub url: Option<String>,
459}
460
461#[derive(Debug, Clone, Serialize, Deserialize)]
463pub struct TokenAnalytics {
464 pub token: Token,
466
467 pub chain: String,
469
470 pub holders: Vec<TokenHolder>,
472
473 pub total_holders: u64,
475
476 pub volume_24h: f64,
478
479 pub volume_7d: f64,
481
482 pub price_usd: f64,
484
485 pub price_change_24h: f64,
487
488 pub price_change_7d: f64,
490
491 pub liquidity_usd: f64,
493
494 #[serde(skip_serializing_if = "Option::is_none")]
496 pub market_cap: Option<f64>,
497
498 #[serde(skip_serializing_if = "Option::is_none")]
500 pub fdv: Option<f64>,
501
502 #[serde(skip_serializing_if = "Option::is_none")]
504 pub total_supply: Option<String>,
505
506 #[serde(skip_serializing_if = "Option::is_none")]
508 pub circulating_supply: Option<String>,
509
510 pub price_history: Vec<PricePoint>,
512
513 pub volume_history: Vec<VolumePoint>,
515
516 pub holder_history: Vec<HolderCountPoint>,
518
519 pub dex_pairs: Vec<DexPair>,
521
522 pub fetched_at: i64,
524
525 #[serde(skip_serializing_if = "Option::is_none")]
527 pub top_10_concentration: Option<f64>,
528
529 #[serde(skip_serializing_if = "Option::is_none")]
531 pub top_50_concentration: Option<f64>,
532
533 #[serde(skip_serializing_if = "Option::is_none")]
535 pub top_100_concentration: Option<f64>,
536
537 pub price_change_6h: f64,
539
540 pub price_change_1h: f64,
542
543 pub total_buys_24h: u64,
545
546 pub total_sells_24h: u64,
548
549 pub total_buys_6h: u64,
551
552 pub total_sells_6h: u64,
554
555 pub total_buys_1h: u64,
557
558 pub total_sells_1h: u64,
560
561 #[serde(skip_serializing_if = "Option::is_none")]
563 pub token_age_hours: Option<f64>,
564
565 #[serde(skip_serializing_if = "Option::is_none")]
567 pub image_url: Option<String>,
568
569 pub websites: Vec<String>,
571
572 pub socials: Vec<TokenSocial>,
574
575 #[serde(skip_serializing_if = "Option::is_none")]
577 pub dexscreener_url: Option<String>,
578}
579
580#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
582pub struct TokenSocial {
583 pub platform: String,
585 pub url: String,
587}
588
589#[derive(Debug, Clone)]
597pub struct ChainMetadata {
598 pub chain_id: &'static str,
600 pub native_symbol: &'static str,
602 pub native_decimals: u8,
604 pub explorer_token_base: &'static str,
606}
607
608pub fn chain_metadata(chain: &str) -> Option<ChainMetadata> {
612 match chain.to_lowercase().as_str() {
613 "ethereum" | "eth" => Some(ChainMetadata {
614 chain_id: "ethereum",
615 native_symbol: "ETH",
616 native_decimals: 18,
617 explorer_token_base: "https://etherscan.io/token",
618 }),
619 "polygon" => Some(ChainMetadata {
620 chain_id: "polygon",
621 native_symbol: "MATIC",
622 native_decimals: 18,
623 explorer_token_base: "https://polygonscan.com/token",
624 }),
625 "arbitrum" => Some(ChainMetadata {
626 chain_id: "arbitrum",
627 native_symbol: "ETH",
628 native_decimals: 18,
629 explorer_token_base: "https://arbiscan.io/token",
630 }),
631 "optimism" => Some(ChainMetadata {
632 chain_id: "optimism",
633 native_symbol: "ETH",
634 native_decimals: 18,
635 explorer_token_base: "https://optimistic.etherscan.io/token",
636 }),
637 "base" => Some(ChainMetadata {
638 chain_id: "base",
639 native_symbol: "ETH",
640 native_decimals: 18,
641 explorer_token_base: "https://basescan.org/token",
642 }),
643 "bsc" => Some(ChainMetadata {
644 chain_id: "bsc",
645 native_symbol: "BNB",
646 native_decimals: 18,
647 explorer_token_base: "https://bscscan.com/token",
648 }),
649 "solana" | "sol" => Some(ChainMetadata {
650 chain_id: "solana",
651 native_symbol: "SOL",
652 native_decimals: 9,
653 explorer_token_base: "https://solscan.io/token",
654 }),
655 "tron" | "trx" => Some(ChainMetadata {
656 chain_id: "tron",
657 native_symbol: "TRX",
658 native_decimals: 6,
659 explorer_token_base: "https://tronscan.org/#/token20",
660 }),
661 _ => None,
662 }
663}
664
665pub fn native_symbol(chain: &str) -> &'static str {
667 chain_metadata(chain)
668 .map(|m| m.native_symbol)
669 .unwrap_or("???")
670}
671
672pub fn infer_chain_from_address(address: &str) -> Option<&'static str> {
698 if address.starts_with('T') && address.len() == 34 && bs58::decode(address).into_vec().is_ok() {
700 return Some("tron");
701 }
702
703 if address.starts_with("0x")
705 && address.len() == 42
706 && address[2..].chars().all(|c| c.is_ascii_hexdigit())
707 {
708 return Some("ethereum");
709 }
710
711 if address.len() >= 32
713 && address.len() <= 44
714 && let Ok(decoded) = bs58::decode(address).into_vec()
715 && decoded.len() == 32
716 {
717 return Some("solana");
718 }
719
720 None
721}
722
723pub fn infer_chain_from_hash(hash: &str) -> Option<&'static str> {
748 if hash.starts_with("0x")
750 && hash.len() == 66
751 && hash[2..].chars().all(|c| c.is_ascii_hexdigit())
752 {
753 return Some("ethereum");
754 }
755
756 if hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit()) {
758 return Some("tron");
759 }
760
761 if hash.len() >= 80
763 && hash.len() <= 90
764 && let Ok(decoded) = bs58::decode(hash).into_vec()
765 && decoded.len() == 64
766 {
767 return Some("solana");
768 }
769
770 None
771}
772
773#[cfg(test)]
778mod tests {
779 use super::*;
780
781 #[test]
782 fn test_balance_serialization() {
783 let balance = Balance {
784 raw: "1000000000000000000".to_string(),
785 formatted: "1.0".to_string(),
786 decimals: 18,
787 symbol: "ETH".to_string(),
788 usd_value: Some(3500.0),
789 };
790
791 let json = serde_json::to_string(&balance).unwrap();
792 assert!(json.contains("1000000000000000000"));
793 assert!(json.contains("1.0"));
794 assert!(json.contains("ETH"));
795 assert!(json.contains("3500"));
796
797 let deserialized: Balance = serde_json::from_str(&json).unwrap();
798 assert_eq!(deserialized.raw, balance.raw);
799 assert_eq!(deserialized.decimals, 18);
800 }
801
802 #[test]
803 fn test_balance_without_usd() {
804 let balance = Balance {
805 raw: "1000000000000000000".to_string(),
806 formatted: "1.0".to_string(),
807 decimals: 18,
808 symbol: "ETH".to_string(),
809 usd_value: None,
810 };
811
812 let json = serde_json::to_string(&balance).unwrap();
813 assert!(!json.contains("usd_value"));
814 }
815
816 #[test]
817 fn test_transaction_serialization() {
818 let tx = Transaction {
819 hash: "0xabc123".to_string(),
820 block_number: Some(12345678),
821 timestamp: Some(1700000000),
822 from: "0xfrom".to_string(),
823 to: Some("0xto".to_string()),
824 value: "1.0".to_string(),
825 gas_limit: 21000,
826 gas_used: Some(21000),
827 gas_price: "20000000000".to_string(),
828 nonce: 42,
829 input: "0x".to_string(),
830 status: Some(true),
831 };
832
833 let json = serde_json::to_string(&tx).unwrap();
834 assert!(json.contains("0xabc123"));
835 assert!(json.contains("12345678"));
836 assert!(json.contains("0xfrom"));
837 assert!(json.contains("0xto"));
838
839 let deserialized: Transaction = serde_json::from_str(&json).unwrap();
840 assert_eq!(deserialized.hash, tx.hash);
841 assert_eq!(deserialized.nonce, 42);
842 }
843
844 #[test]
845 fn test_pending_transaction_serialization() {
846 let tx = Transaction {
847 hash: "0xpending".to_string(),
848 block_number: None,
849 timestamp: None,
850 from: "0xfrom".to_string(),
851 to: Some("0xto".to_string()),
852 value: "1.0".to_string(),
853 gas_limit: 21000,
854 gas_used: None,
855 gas_price: "20000000000".to_string(),
856 nonce: 0,
857 input: "0x".to_string(),
858 status: None,
859 };
860
861 let json = serde_json::to_string(&tx).unwrap();
862 assert!(json.contains("0xpending"));
863 assert!(json.contains("null")); let deserialized: Transaction = serde_json::from_str(&json).unwrap();
866 assert!(deserialized.block_number.is_none());
867 assert!(deserialized.status.is_none());
868 }
869
870 #[test]
871 fn test_contract_creation_transaction() {
872 let tx = Transaction {
873 hash: "0xcreate".to_string(),
874 block_number: Some(100),
875 timestamp: Some(1700000000),
876 from: "0xdeployer".to_string(),
877 to: None, value: "0".to_string(),
879 gas_limit: 1000000,
880 gas_used: Some(500000),
881 gas_price: "20000000000".to_string(),
882 nonce: 0,
883 input: "0x608060...".to_string(),
884 status: Some(true),
885 };
886
887 let json = serde_json::to_string(&tx).unwrap();
888 assert!(json.contains("\"to\":null"));
889 }
890
891 #[test]
892 fn test_token_serialization() {
893 let token = Token {
894 contract_address: "0xtoken".to_string(),
895 symbol: "USDC".to_string(),
896 name: "USD Coin".to_string(),
897 decimals: 6,
898 };
899
900 let json = serde_json::to_string(&token).unwrap();
901 assert!(json.contains("USDC"));
902 assert!(json.contains("USD Coin"));
903 assert!(json.contains("\"decimals\":6"));
904
905 let deserialized: Token = serde_json::from_str(&json).unwrap();
906 assert_eq!(deserialized.decimals, 6);
907 }
908
909 #[test]
910 fn test_token_balance_serialization() {
911 let token_balance = TokenBalance {
912 token: Token {
913 contract_address: "0xtoken".to_string(),
914 symbol: "USDC".to_string(),
915 name: "USD Coin".to_string(),
916 decimals: 6,
917 },
918 balance: "1000000".to_string(),
919 formatted_balance: "1.0".to_string(),
920 usd_value: Some(1.0),
921 };
922
923 let json = serde_json::to_string(&token_balance).unwrap();
924 assert!(json.contains("USDC"));
925 assert!(json.contains("1000000"));
926 assert!(json.contains("1.0"));
927 }
928
929 #[test]
930 fn test_balance_debug() {
931 let balance = Balance {
932 raw: "1000".to_string(),
933 formatted: "0.001".to_string(),
934 decimals: 18,
935 symbol: "ETH".to_string(),
936 usd_value: None,
937 };
938
939 let debug_str = format!("{:?}", balance);
940 assert!(debug_str.contains("Balance"));
941 assert!(debug_str.contains("1000"));
942 }
943
944 #[test]
945 fn test_transaction_debug() {
946 let tx = Transaction {
947 hash: "0xtest".to_string(),
948 block_number: Some(1),
949 timestamp: Some(0),
950 from: "0x1".to_string(),
951 to: Some("0x2".to_string()),
952 value: "0".to_string(),
953 gas_limit: 21000,
954 gas_used: Some(21000),
955 gas_price: "0".to_string(),
956 nonce: 0,
957 input: "0x".to_string(),
958 status: Some(true),
959 };
960
961 let debug_str = format!("{:?}", tx);
962 assert!(debug_str.contains("Transaction"));
963 assert!(debug_str.contains("0xtest"));
964 }
965
966 #[test]
971 fn test_infer_chain_from_address_evm() {
972 assert_eq!(
974 super::infer_chain_from_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"),
975 Some("ethereum")
976 );
977 assert_eq!(
978 super::infer_chain_from_address("0x0000000000000000000000000000000000000000"),
979 Some("ethereum")
980 );
981 assert_eq!(
982 super::infer_chain_from_address("0xABCDEF1234567890abcdef1234567890ABCDEF12"),
983 Some("ethereum")
984 );
985 }
986
987 #[test]
988 fn test_infer_chain_from_address_tron() {
989 assert_eq!(
991 super::infer_chain_from_address("TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf"),
992 Some("tron")
993 );
994 assert_eq!(
995 super::infer_chain_from_address("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"),
996 Some("tron")
997 );
998 }
999
1000 #[test]
1001 fn test_infer_chain_from_address_solana() {
1002 assert_eq!(
1004 super::infer_chain_from_address("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy"),
1005 Some("solana")
1006 );
1007 assert_eq!(
1009 super::infer_chain_from_address("11111111111111111111111111111111"),
1010 Some("solana")
1011 );
1012 }
1013
1014 #[test]
1015 fn test_infer_chain_from_address_invalid() {
1016 assert_eq!(super::infer_chain_from_address("0x123"), None);
1018 assert_eq!(super::infer_chain_from_address("not_an_address"), None);
1020 assert_eq!(super::infer_chain_from_address(""), None);
1022 assert_eq!(super::infer_chain_from_address("0x123456"), None);
1024 assert_eq!(
1026 super::infer_chain_from_address("ADqSquXBgUCLYvYC4XZgrprLK589dkhSCf"),
1027 None
1028 );
1029 }
1030
1031 #[test]
1032 fn test_infer_chain_from_hash_evm() {
1033 assert_eq!(
1035 super::infer_chain_from_hash(
1036 "0xabc123def456789012345678901234567890123456789012345678901234abcd"
1037 ),
1038 Some("ethereum")
1039 );
1040 assert_eq!(
1041 super::infer_chain_from_hash(
1042 "0x0000000000000000000000000000000000000000000000000000000000000000"
1043 ),
1044 Some("ethereum")
1045 );
1046 }
1047
1048 #[test]
1049 fn test_infer_chain_from_hash_tron() {
1050 assert_eq!(
1052 super::infer_chain_from_hash(
1053 "abc123def456789012345678901234567890123456789012345678901234abcd"
1054 ),
1055 Some("tron")
1056 );
1057 assert_eq!(
1058 super::infer_chain_from_hash(
1059 "0000000000000000000000000000000000000000000000000000000000000000"
1060 ),
1061 Some("tron")
1062 );
1063 }
1064
1065 #[test]
1066 fn test_infer_chain_from_hash_solana() {
1067 let solana_sig = "5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW";
1070 assert_eq!(super::infer_chain_from_hash(solana_sig), Some("solana"));
1071 }
1072
1073 #[test]
1074 fn test_infer_chain_from_hash_invalid() {
1075 assert_eq!(super::infer_chain_from_hash("0x123"), None);
1077 assert_eq!(super::infer_chain_from_hash("not_a_hash"), None);
1079 assert_eq!(super::infer_chain_from_hash(""), None);
1081 assert_eq!(
1083 super::infer_chain_from_hash(
1084 "abc123gef456789012345678901234567890123456789012345678901234abcd"
1085 ),
1086 None
1087 );
1088 }
1089
1090 #[test]
1095 fn test_default_client_factory_create_dex_client() {
1096 let config = crate::config::ChainsConfig::default();
1097 let factory = DefaultClientFactory {
1098 chains_config: config,
1099 };
1100 let dex = factory.create_dex_client();
1101 let _ = format!("{:?}", std::mem::size_of_val(&dex));
1103 }
1104
1105 #[test]
1106 fn test_default_client_factory_create_ethereum_client() {
1107 let config = crate::config::ChainsConfig::default();
1108 let factory = DefaultClientFactory {
1109 chains_config: config,
1110 };
1111 let client = factory.create_chain_client("ethereum");
1113 assert!(client.is_ok());
1114 assert_eq!(client.unwrap().chain_name(), "ethereum");
1115 }
1116
1117 #[test]
1118 fn test_default_client_factory_create_polygon_client() {
1119 let config = crate::config::ChainsConfig::default();
1120 let factory = DefaultClientFactory {
1121 chains_config: config,
1122 };
1123 let client = factory.create_chain_client("polygon");
1124 assert!(client.is_ok());
1125 assert_eq!(client.unwrap().chain_name(), "polygon");
1126 }
1127
1128 #[test]
1129 fn test_default_client_factory_create_solana_client() {
1130 let config = crate::config::ChainsConfig::default();
1131 let factory = DefaultClientFactory {
1132 chains_config: config,
1133 };
1134 let client = factory.create_chain_client("solana");
1135 assert!(client.is_ok());
1136 assert_eq!(client.unwrap().chain_name(), "solana");
1137 }
1138
1139 #[test]
1140 fn test_default_client_factory_create_sol_alias() {
1141 let config = crate::config::ChainsConfig::default();
1142 let factory = DefaultClientFactory {
1143 chains_config: config,
1144 };
1145 let client = factory.create_chain_client("sol");
1146 assert!(client.is_ok());
1147 assert_eq!(client.unwrap().chain_name(), "solana");
1148 }
1149
1150 #[test]
1151 fn test_default_client_factory_create_tron_client() {
1152 let config = crate::config::ChainsConfig::default();
1153 let factory = DefaultClientFactory {
1154 chains_config: config,
1155 };
1156 let client = factory.create_chain_client("tron");
1157 assert!(client.is_ok());
1158 assert_eq!(client.unwrap().chain_name(), "tron");
1159 }
1160
1161 #[test]
1162 fn test_default_client_factory_create_trx_alias() {
1163 let config = crate::config::ChainsConfig::default();
1164 let factory = DefaultClientFactory {
1165 chains_config: config,
1166 };
1167 let client = factory.create_chain_client("trx");
1168 assert!(client.is_ok());
1169 assert_eq!(client.unwrap().chain_name(), "tron");
1170 }
1171
1172 #[tokio::test]
1177 async fn test_chain_client_default_get_token_info() {
1178 use super::mocks::MockChainClient;
1179 let client = MockChainClient::new("ethereum", "ETH");
1181 let result = client.get_token_info("0xsometoken").await;
1182 assert!(result.is_err());
1183 }
1184
1185 #[tokio::test]
1186 async fn test_chain_client_default_get_token_holders() {
1187 use super::mocks::MockChainClient;
1188 let client = MockChainClient::new("ethereum", "ETH");
1189 let holders = client.get_token_holders("0xsometoken", 10).await.unwrap();
1190 assert!(holders.is_empty());
1191 }
1192
1193 #[tokio::test]
1194 async fn test_chain_client_default_get_token_holder_count() {
1195 use super::mocks::MockChainClient;
1196 let client = MockChainClient::new("ethereum", "ETH");
1197 let count = client.get_token_holder_count("0xsometoken").await.unwrap();
1198 assert_eq!(count, 0);
1199 }
1200
1201 #[tokio::test]
1202 async fn test_mock_client_factory_creates_chain_client() {
1203 use super::mocks::MockClientFactory;
1204 let factory = MockClientFactory::new();
1205 let client = factory.create_chain_client("anything").unwrap();
1206 assert_eq!(client.chain_name(), "ethereum"); }
1208
1209 #[tokio::test]
1210 async fn test_mock_client_factory_creates_dex_client() {
1211 use super::mocks::MockClientFactory;
1212 let factory = MockClientFactory::new();
1213 let dex = factory.create_dex_client();
1214 let price = dex.get_token_price("ethereum", "0xtest").await;
1215 assert_eq!(price, Some(1.0));
1216 }
1217
1218 #[tokio::test]
1219 async fn test_mock_chain_client_balance() {
1220 use super::mocks::MockChainClient;
1221 let client = MockChainClient::new("ethereum", "ETH");
1222 let balance = client.get_balance("0xtest").await.unwrap();
1223 assert_eq!(balance.formatted, "1.0");
1224 assert_eq!(balance.symbol, "ETH");
1225 assert_eq!(balance.usd_value, Some(2500.0));
1226 }
1227
1228 #[tokio::test]
1229 async fn test_mock_chain_client_transaction() {
1230 use super::mocks::MockChainClient;
1231 let client = MockChainClient::new("ethereum", "ETH");
1232 let tx = client.get_transaction("0xanyhash").await.unwrap();
1233 assert_eq!(tx.hash, "0xmocktx");
1234 assert_eq!(tx.nonce, 42);
1235 }
1236
1237 #[tokio::test]
1238 async fn test_mock_chain_client_block_number() {
1239 use super::mocks::MockChainClient;
1240 let client = MockChainClient::new("ethereum", "ETH");
1241 let block = client.get_block_number().await.unwrap();
1242 assert_eq!(block, 12345678);
1243 }
1244
1245 #[tokio::test]
1246 async fn test_mock_dex_source_data() {
1247 use super::mocks::MockDexSource;
1248 let dex = MockDexSource::new();
1249 let data = dex.get_token_data("ethereum", "0xtest").await.unwrap();
1250 assert_eq!(data.symbol, "MOCK");
1251 assert_eq!(data.price_usd, 1.0);
1252 }
1253
1254 #[tokio::test]
1255 async fn test_mock_dex_source_search() {
1256 use super::mocks::MockDexSource;
1257 let dex = MockDexSource::new();
1258 let results = dex.search_tokens("test", None).await.unwrap();
1259 assert!(results.is_empty());
1260 }
1261
1262 #[tokio::test]
1263 async fn test_mock_dex_source_native_price() {
1264 use super::mocks::MockDexSource;
1265 let dex = MockDexSource::new();
1266 let price = dex.get_native_token_price("ethereum").await;
1267 assert_eq!(price, Some(2500.0));
1268 }
1269
1270 struct MinimalChainClient;
1276
1277 #[async_trait::async_trait]
1278 impl ChainClient for MinimalChainClient {
1279 fn chain_name(&self) -> &str {
1280 "test"
1281 }
1282
1283 fn native_token_symbol(&self) -> &str {
1284 "TEST"
1285 }
1286
1287 async fn get_balance(&self, _address: &str) -> Result<Balance> {
1288 Ok(Balance {
1289 raw: "0".to_string(),
1290 formatted: "0".to_string(),
1291 decimals: 18,
1292 symbol: "TEST".to_string(),
1293 usd_value: None,
1294 })
1295 }
1296
1297 async fn get_transaction(&self, _hash: &str) -> Result<Transaction> {
1298 unimplemented!()
1299 }
1300
1301 async fn get_transactions(&self, _address: &str, _limit: u32) -> Result<Vec<Transaction>> {
1302 Ok(Vec::new())
1303 }
1304
1305 async fn get_block_number(&self) -> Result<u64> {
1306 Ok(0)
1307 }
1308
1309 async fn get_token_balances(&self, _address: &str) -> Result<Vec<TokenBalance>> {
1310 Ok(Vec::new())
1311 }
1312
1313 async fn enrich_balance_usd(&self, _balance: &mut Balance) {}
1314 }
1315
1316 #[tokio::test]
1317 async fn test_default_get_token_info() {
1318 let client = MinimalChainClient;
1319 let result = client.get_token_info("0xtest").await;
1320 assert!(result.is_err());
1321 assert!(result.unwrap_err().to_string().contains("not supported"));
1322 }
1323
1324 #[tokio::test]
1325 async fn test_default_get_token_holders() {
1326 let client = MinimalChainClient;
1327 let holders = client.get_token_holders("0xtest", 10).await.unwrap();
1328 assert!(holders.is_empty());
1329 }
1330
1331 #[tokio::test]
1332 async fn test_default_get_token_holder_count() {
1333 let client = MinimalChainClient;
1334 let count = client.get_token_holder_count("0xtest").await.unwrap();
1335 assert_eq!(count, 0);
1336 }
1337
1338 #[test]
1343 fn test_chain_metadata_ethereum() {
1344 let meta = chain_metadata("ethereum").unwrap();
1345 assert_eq!(meta.chain_id, "ethereum");
1346 assert_eq!(meta.native_symbol, "ETH");
1347 assert_eq!(meta.native_decimals, 18);
1348 assert_eq!(meta.explorer_token_base, "https://etherscan.io/token");
1349 }
1350
1351 #[test]
1352 fn test_chain_metadata_ethereum_alias() {
1353 let meta = chain_metadata("eth").unwrap();
1354 assert_eq!(meta.chain_id, "ethereum");
1355 assert_eq!(meta.native_symbol, "ETH");
1356 }
1357
1358 #[test]
1359 fn test_chain_metadata_polygon() {
1360 let meta = chain_metadata("polygon").unwrap();
1361 assert_eq!(meta.chain_id, "polygon");
1362 assert_eq!(meta.native_symbol, "MATIC");
1363 assert_eq!(meta.native_decimals, 18);
1364 assert_eq!(meta.explorer_token_base, "https://polygonscan.com/token");
1365 }
1366
1367 #[test]
1368 fn test_chain_metadata_bsc() {
1369 let meta = chain_metadata("bsc").unwrap();
1370 assert_eq!(meta.chain_id, "bsc");
1371 assert_eq!(meta.native_symbol, "BNB");
1372 assert_eq!(meta.native_decimals, 18);
1373 assert_eq!(meta.explorer_token_base, "https://bscscan.com/token");
1374 }
1375
1376 #[test]
1377 fn test_chain_metadata_solana() {
1378 let meta = chain_metadata("solana").unwrap();
1379 assert_eq!(meta.chain_id, "solana");
1380 assert_eq!(meta.native_symbol, "SOL");
1381 assert_eq!(meta.native_decimals, 9);
1382 assert_eq!(meta.explorer_token_base, "https://solscan.io/token");
1383 }
1384
1385 #[test]
1386 fn test_chain_metadata_solana_alias() {
1387 let meta = chain_metadata("sol").unwrap();
1388 assert_eq!(meta.chain_id, "solana");
1389 assert_eq!(meta.native_symbol, "SOL");
1390 }
1391
1392 #[test]
1393 fn test_chain_metadata_tron() {
1394 let meta = chain_metadata("tron").unwrap();
1395 assert_eq!(meta.chain_id, "tron");
1396 assert_eq!(meta.native_symbol, "TRX");
1397 assert_eq!(meta.native_decimals, 6);
1398 assert_eq!(meta.explorer_token_base, "https://tronscan.org/#/token20");
1399 }
1400
1401 #[test]
1402 fn test_chain_metadata_tron_alias() {
1403 let meta = chain_metadata("trx").unwrap();
1404 assert_eq!(meta.chain_id, "tron");
1405 assert_eq!(meta.native_symbol, "TRX");
1406 }
1407
1408 #[test]
1409 fn test_chain_metadata_arbitrum() {
1410 let meta = chain_metadata("arbitrum").unwrap();
1411 assert_eq!(meta.chain_id, "arbitrum");
1412 assert_eq!(meta.native_symbol, "ETH");
1413 assert_eq!(meta.native_decimals, 18);
1414 assert_eq!(meta.explorer_token_base, "https://arbiscan.io/token");
1415 }
1416
1417 #[test]
1418 fn test_chain_metadata_optimism() {
1419 let meta = chain_metadata("optimism").unwrap();
1420 assert_eq!(meta.chain_id, "optimism");
1421 assert_eq!(meta.native_symbol, "ETH");
1422 assert_eq!(meta.native_decimals, 18);
1423 assert_eq!(
1424 meta.explorer_token_base,
1425 "https://optimistic.etherscan.io/token"
1426 );
1427 }
1428
1429 #[test]
1430 fn test_chain_metadata_base() {
1431 let meta = chain_metadata("base").unwrap();
1432 assert_eq!(meta.chain_id, "base");
1433 assert_eq!(meta.native_symbol, "ETH");
1434 assert_eq!(meta.native_decimals, 18);
1435 assert_eq!(meta.explorer_token_base, "https://basescan.org/token");
1436 }
1437
1438 #[test]
1439 fn test_chain_metadata_case_insensitive() {
1440 let meta1 = chain_metadata("ETHEREUM").unwrap();
1441 let meta2 = chain_metadata("Ethereum").unwrap();
1442 let meta3 = chain_metadata("ethereum").unwrap();
1443 assert_eq!(meta1.chain_id, meta2.chain_id);
1444 assert_eq!(meta2.chain_id, meta3.chain_id);
1445 }
1446
1447 #[test]
1448 fn test_chain_metadata_unknown() {
1449 assert!(chain_metadata("bitcoin").is_none());
1450 assert!(chain_metadata("litecoin").is_none());
1451 assert!(chain_metadata("unknown").is_none());
1452 assert!(chain_metadata("").is_none());
1453 }
1454
1455 #[test]
1456 fn test_native_symbol_ethereum() {
1457 assert_eq!(native_symbol("ethereum"), "ETH");
1458 assert_eq!(native_symbol("eth"), "ETH");
1459 }
1460
1461 #[test]
1462 fn test_native_symbol_polygon() {
1463 assert_eq!(native_symbol("polygon"), "MATIC");
1464 }
1465
1466 #[test]
1467 fn test_native_symbol_bsc() {
1468 assert_eq!(native_symbol("bsc"), "BNB");
1469 }
1470
1471 #[test]
1472 fn test_native_symbol_solana() {
1473 assert_eq!(native_symbol("solana"), "SOL");
1474 assert_eq!(native_symbol("sol"), "SOL");
1475 }
1476
1477 #[test]
1478 fn test_native_symbol_tron() {
1479 assert_eq!(native_symbol("tron"), "TRX");
1480 assert_eq!(native_symbol("trx"), "TRX");
1481 }
1482
1483 #[test]
1484 fn test_native_symbol_arbitrum() {
1485 assert_eq!(native_symbol("arbitrum"), "ETH");
1486 }
1487
1488 #[test]
1489 fn test_native_symbol_optimism() {
1490 assert_eq!(native_symbol("optimism"), "ETH");
1491 }
1492
1493 #[test]
1494 fn test_native_symbol_base() {
1495 assert_eq!(native_symbol("base"), "ETH");
1496 }
1497
1498 #[test]
1499 fn test_native_symbol_unknown() {
1500 assert_eq!(native_symbol("unknown"), "???");
1501 assert_eq!(native_symbol("bitcoin"), "???");
1502 assert_eq!(native_symbol(""), "???");
1503 }
1504
1505 #[test]
1506 fn test_native_symbol_case_insensitive() {
1507 assert_eq!(native_symbol("ETHEREUM"), "ETH");
1508 assert_eq!(native_symbol("Ethereum"), "ETH");
1509 assert_eq!(native_symbol("ethereum"), "ETH");
1510 }
1511
1512 #[tokio::test]
1513 async fn test_chain_client_default_get_code() {
1514 let client = MinimalChainClient;
1515 let result = client.get_code("0x1234").await;
1516 assert!(result.is_err());
1517 let err_msg = result.unwrap_err().to_string();
1518 assert!(err_msg.contains("not supported"));
1519 }
1520}
1521
1522#[cfg(test)]
1531pub mod mocks {
1532 use super::*;
1533 use crate::chains::dex::{DexDataSource, DexTokenData, TokenSearchResult};
1534 use async_trait::async_trait;
1535
1536 #[derive(Debug, Clone)]
1538 pub struct MockChainClient {
1539 pub chain: String,
1540 pub symbol: String,
1541 pub balance: Balance,
1542 pub transaction: Transaction,
1543 pub transactions: Vec<Transaction>,
1544 pub token_balances: Vec<TokenBalance>,
1545 pub block_number: u64,
1546 pub token_info: Option<Token>,
1547 pub token_holders: Vec<TokenHolder>,
1548 pub token_holder_count: u64,
1549 }
1550
1551 impl MockChainClient {
1552 pub fn new(chain: &str, symbol: &str) -> Self {
1554 Self {
1555 chain: chain.to_string(),
1556 symbol: symbol.to_string(),
1557 balance: Balance {
1558 raw: "1000000000000000000".to_string(),
1559 formatted: "1.0".to_string(),
1560 decimals: 18,
1561 symbol: symbol.to_string(),
1562 usd_value: Some(2500.0),
1563 },
1564 transaction: Transaction {
1565 hash: "0xmocktx".to_string(),
1566 block_number: Some(12345678),
1567 timestamp: Some(1700000000),
1568 from: "0xfrom".to_string(),
1569 to: Some("0xto".to_string()),
1570 value: "1.0".to_string(),
1571 gas_limit: 21000,
1572 gas_used: Some(21000),
1573 gas_price: "20000000000".to_string(),
1574 nonce: 42,
1575 input: "0x".to_string(),
1576 status: Some(true),
1577 },
1578 transactions: vec![],
1579 token_balances: vec![],
1580 block_number: 12345678,
1581 token_info: None,
1582 token_holders: vec![],
1583 token_holder_count: 0,
1584 }
1585 }
1586 }
1587
1588 #[async_trait]
1589 impl ChainClient for MockChainClient {
1590 fn chain_name(&self) -> &str {
1591 &self.chain
1592 }
1593
1594 fn native_token_symbol(&self) -> &str {
1595 &self.symbol
1596 }
1597
1598 async fn get_balance(&self, _address: &str) -> Result<Balance> {
1599 Ok(self.balance.clone())
1600 }
1601
1602 async fn enrich_balance_usd(&self, _balance: &mut Balance) {
1603 }
1605
1606 async fn get_transaction(&self, _hash: &str) -> Result<Transaction> {
1607 Ok(self.transaction.clone())
1608 }
1609
1610 async fn get_transactions(&self, _address: &str, _limit: u32) -> Result<Vec<Transaction>> {
1611 Ok(self.transactions.clone())
1612 }
1613
1614 async fn get_block_number(&self) -> Result<u64> {
1615 Ok(self.block_number)
1616 }
1617
1618 async fn get_token_balances(&self, _address: &str) -> Result<Vec<TokenBalance>> {
1619 Ok(self.token_balances.clone())
1620 }
1621
1622 async fn get_token_info(&self, _address: &str) -> Result<Token> {
1623 match &self.token_info {
1624 Some(t) => Ok(t.clone()),
1625 None => Err(crate::error::ScopeError::Chain(
1626 "Token info not available".to_string(),
1627 )),
1628 }
1629 }
1630
1631 async fn get_token_holders(&self, _address: &str, _limit: u32) -> Result<Vec<TokenHolder>> {
1632 Ok(self.token_holders.clone())
1633 }
1634
1635 async fn get_token_holder_count(&self, _address: &str) -> Result<u64> {
1636 Ok(self.token_holder_count)
1637 }
1638 }
1639
1640 #[derive(Debug, Clone)]
1642 pub struct MockDexSource {
1643 pub token_price: Option<f64>,
1644 pub native_price: Option<f64>,
1645 pub token_data: Option<DexTokenData>,
1646 pub search_results: Vec<TokenSearchResult>,
1647 }
1648
1649 impl Default for MockDexSource {
1650 fn default() -> Self {
1651 Self::new()
1652 }
1653 }
1654
1655 impl MockDexSource {
1656 pub fn new() -> Self {
1658 Self {
1659 token_price: Some(1.0),
1660 native_price: Some(2500.0),
1661 token_data: Some(DexTokenData {
1662 address: "0xmocktoken".to_string(),
1663 symbol: "MOCK".to_string(),
1664 name: "Mock Token".to_string(),
1665 price_usd: 1.0,
1666 price_change_24h: 5.0,
1667 price_change_6h: 2.0,
1668 price_change_1h: 0.5,
1669 price_change_5m: 0.1,
1670 volume_24h: 1_000_000.0,
1671 volume_6h: 250_000.0,
1672 volume_1h: 50_000.0,
1673 liquidity_usd: 5_000_000.0,
1674 market_cap: Some(100_000_000.0),
1675 fdv: Some(200_000_000.0),
1676 pairs: vec![],
1677 price_history: vec![],
1678 volume_history: vec![],
1679 total_buys_24h: 500,
1680 total_sells_24h: 450,
1681 total_buys_6h: 120,
1682 total_sells_6h: 110,
1683 total_buys_1h: 20,
1684 total_sells_1h: 18,
1685 earliest_pair_created_at: Some(1690000000),
1686 image_url: None,
1687 websites: vec![],
1688 socials: vec![],
1689 dexscreener_url: None,
1690 }),
1691 search_results: vec![],
1692 }
1693 }
1694 }
1695
1696 #[async_trait]
1697 impl DexDataSource for MockDexSource {
1698 async fn get_token_price(&self, _chain: &str, _address: &str) -> Option<f64> {
1699 self.token_price
1700 }
1701
1702 async fn get_native_token_price(&self, _chain: &str) -> Option<f64> {
1703 self.native_price
1704 }
1705
1706 async fn get_token_data(&self, _chain: &str, _address: &str) -> Result<DexTokenData> {
1707 match &self.token_data {
1708 Some(data) => Ok(data.clone()),
1709 None => Err(crate::error::ScopeError::NotFound(
1710 "No DEX data found".to_string(),
1711 )),
1712 }
1713 }
1714
1715 async fn search_tokens(
1716 &self,
1717 _query: &str,
1718 _chain: Option<&str>,
1719 ) -> Result<Vec<TokenSearchResult>> {
1720 Ok(self.search_results.clone())
1721 }
1722 }
1723
1724 pub struct MockClientFactory {
1726 pub mock_client: MockChainClient,
1727 pub mock_dex: MockDexSource,
1728 }
1729
1730 impl Default for MockClientFactory {
1731 fn default() -> Self {
1732 Self::new()
1733 }
1734 }
1735
1736 impl MockClientFactory {
1737 pub fn new() -> Self {
1739 Self {
1740 mock_client: MockChainClient::new("ethereum", "ETH"),
1741 mock_dex: MockDexSource::new(),
1742 }
1743 }
1744 }
1745
1746 impl ChainClientFactory for MockClientFactory {
1747 fn create_chain_client(&self, _chain: &str) -> Result<Box<dyn ChainClient>> {
1748 Ok(Box::new(self.mock_client.clone()))
1749 }
1750
1751 fn create_dex_client(&self) -> Box<dyn DexDataSource> {
1752 Box::new(self.mock_dex.clone())
1753 }
1754 }
1755}