1pub mod dex;
89pub mod ethereum;
90pub mod registry;
91pub mod solana;
92pub mod tron;
93
94pub use dex::{DexClient, DexDataSource, DiscoverToken, TokenSearchResult};
95pub use ethereum::{ApiType, EthereumClient};
96pub use solana::{SolanaClient, validate_solana_address, validate_solana_signature};
97pub use tron::{TronClient, validate_tron_address, validate_tron_tx_hash};
98
99use crate::error::Result;
100use crate::http::HttpClient;
101use async_trait::async_trait;
102use serde::{Deserialize, Serialize};
103use std::sync::Arc;
104
105#[async_trait]
123pub trait ChainClient: Send + Sync {
124 fn chain_name(&self) -> &str;
126
127 fn native_token_symbol(&self) -> &str;
129
130 async fn get_balance(&self, address: &str) -> Result<Balance>;
140
141 async fn enrich_balance_usd(&self, balance: &mut Balance);
147
148 async fn get_transaction(&self, hash: &str) -> Result<Transaction>;
158
159 async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>>;
170
171 async fn get_block_number(&self) -> Result<u64>;
173
174 async fn get_token_balances(&self, address: &str) -> Result<Vec<TokenBalance>>;
179
180 async fn get_token_info(&self, _address: &str) -> Result<Token> {
185 Err(crate::error::ScopeError::Chain(
186 "Token info lookup not supported on this chain".to_string(),
187 ))
188 }
189
190 async fn get_token_holders(&self, _address: &str, _limit: u32) -> Result<Vec<TokenHolder>> {
195 Ok(Vec::new())
196 }
197
198 async fn get_token_holder_count(&self, _address: &str) -> Result<u64> {
203 Ok(0)
204 }
205
206 async fn get_code(&self, _address: &str) -> Result<String> {
210 Err(crate::error::ScopeError::Chain(
211 "Code lookup not supported on this chain".to_string(),
212 ))
213 }
214
215 async fn get_storage_at(&self, _address: &str, _slot: &str) -> Result<String> {
219 Err(crate::error::ScopeError::Chain(
220 "Storage lookup not supported on this chain".to_string(),
221 ))
222 }
223}
224
225pub trait ChainClientFactory: Send + Sync {
244 fn create_chain_client(&self, chain: &str) -> Result<Box<dyn ChainClient>>;
250
251 fn create_dex_client(&self) -> Box<dyn DexDataSource>;
253}
254
255pub struct DefaultClientFactory {
260 pub chains_config: crate::config::ChainsConfig,
262 pub http: Arc<dyn HttpClient>,
264}
265
266impl ChainClientFactory for DefaultClientFactory {
267 fn create_chain_client(&self, chain: &str) -> Result<Box<dyn ChainClient>> {
268 match chain.to_lowercase().as_str() {
269 "solana" | "sol" => Ok(Box::new(SolanaClient::new_with_http(
270 &self.chains_config,
271 self.http.clone(),
272 )?)),
273 "tron" | "trx" => Ok(Box::new(TronClient::new_with_http(
274 &self.chains_config,
275 self.http.clone(),
276 )?)),
277 _ => Ok(Box::new(EthereumClient::for_chain_with_http(
278 chain,
279 &self.chains_config,
280 self.http.clone(),
281 )?)),
282 }
283 }
284
285 fn create_dex_client(&self) -> Box<dyn DexDataSource> {
286 Box::new(DexClient::new_with_http(self.http.clone()))
287 }
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct Balance {
293 pub raw: String,
295
296 pub formatted: String,
298
299 pub decimals: u8,
301
302 pub symbol: String,
304
305 #[serde(skip_serializing_if = "Option::is_none")]
307 pub usd_value: Option<f64>,
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct Transaction {
313 pub hash: String,
315
316 pub block_number: Option<u64>,
318
319 pub timestamp: Option<u64>,
321
322 pub from: String,
324
325 pub to: Option<String>,
327
328 pub value: String,
330
331 pub gas_limit: u64,
333
334 pub gas_used: Option<u64>,
336
337 pub gas_price: String,
339
340 pub nonce: u64,
342
343 pub input: String,
345
346 pub status: Option<bool>,
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct Token {
353 pub contract_address: String,
355
356 pub symbol: String,
358
359 pub name: String,
361
362 pub decimals: u8,
364}
365
366#[derive(Debug, Clone, Serialize, Deserialize)]
368pub struct TokenBalance {
369 pub token: Token,
371
372 pub balance: String,
374
375 pub formatted_balance: String,
377
378 #[serde(skip_serializing_if = "Option::is_none")]
380 pub usd_value: Option<f64>,
381}
382
383#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct TokenHolder {
390 pub address: String,
392
393 pub balance: String,
395
396 pub formatted_balance: String,
398
399 pub percentage: f64,
401
402 pub rank: u32,
404}
405
406#[derive(Debug, Clone, Serialize, Deserialize)]
408pub struct PricePoint {
409 pub timestamp: i64,
411
412 pub price: f64,
414}
415
416#[derive(Debug, Clone, Serialize, Deserialize)]
418pub struct VolumePoint {
419 pub timestamp: i64,
421
422 pub volume: f64,
424}
425
426#[derive(Debug, Clone, Serialize, Deserialize)]
428pub struct HolderCountPoint {
429 pub timestamp: i64,
431
432 pub count: u64,
434}
435
436#[derive(Debug, Clone, Serialize, Deserialize)]
438pub struct DexPair {
439 pub dex_name: String,
441
442 pub pair_address: String,
444
445 pub base_token: String,
447
448 pub quote_token: String,
450
451 pub price_usd: f64,
453
454 pub volume_24h: f64,
456
457 pub liquidity_usd: f64,
459
460 #[serde(default)]
463 pub liquidity_base: Option<f64>,
464
465 pub price_change_24h: f64,
467
468 pub buys_24h: u64,
470
471 pub sells_24h: u64,
473
474 pub buys_6h: u64,
476
477 pub sells_6h: u64,
479
480 pub buys_1h: u64,
482
483 pub sells_1h: u64,
485
486 pub pair_created_at: Option<i64>,
488
489 pub url: Option<String>,
491}
492
493#[derive(Debug, Clone, Serialize, Deserialize)]
495pub struct TokenAnalytics {
496 pub token: Token,
498
499 pub chain: String,
501
502 pub holders: Vec<TokenHolder>,
504
505 pub total_holders: u64,
507
508 pub volume_24h: f64,
510
511 pub volume_7d: f64,
513
514 pub price_usd: f64,
516
517 pub price_change_24h: f64,
519
520 pub price_change_7d: f64,
522
523 pub liquidity_usd: f64,
525
526 #[serde(skip_serializing_if = "Option::is_none")]
528 pub market_cap: Option<f64>,
529
530 #[serde(skip_serializing_if = "Option::is_none")]
532 pub fdv: Option<f64>,
533
534 #[serde(skip_serializing_if = "Option::is_none")]
536 pub total_supply: Option<String>,
537
538 #[serde(skip_serializing_if = "Option::is_none")]
540 pub circulating_supply: Option<String>,
541
542 pub price_history: Vec<PricePoint>,
544
545 pub volume_history: Vec<VolumePoint>,
547
548 pub holder_history: Vec<HolderCountPoint>,
550
551 pub dex_pairs: Vec<DexPair>,
553
554 pub fetched_at: i64,
556
557 #[serde(skip_serializing_if = "Option::is_none")]
559 pub top_10_concentration: Option<f64>,
560
561 #[serde(skip_serializing_if = "Option::is_none")]
563 pub top_50_concentration: Option<f64>,
564
565 #[serde(skip_serializing_if = "Option::is_none")]
567 pub top_100_concentration: Option<f64>,
568
569 pub price_change_6h: f64,
571
572 pub price_change_1h: f64,
574
575 pub total_buys_24h: u64,
577
578 pub total_sells_24h: u64,
580
581 pub total_buys_6h: u64,
583
584 pub total_sells_6h: u64,
586
587 pub total_buys_1h: u64,
589
590 pub total_sells_1h: u64,
592
593 #[serde(skip_serializing_if = "Option::is_none")]
595 pub token_age_hours: Option<f64>,
596
597 #[serde(skip_serializing_if = "Option::is_none")]
599 pub image_url: Option<String>,
600
601 pub websites: Vec<String>,
603
604 pub socials: Vec<TokenSocial>,
606
607 #[serde(skip_serializing_if = "Option::is_none")]
609 pub dexscreener_url: Option<String>,
610}
611
612#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
614pub struct TokenSocial {
615 pub platform: String,
617 pub url: String,
619}
620
621#[derive(Debug, Clone, Serialize, Deserialize)]
627pub struct NftMetadata {
628 pub token_id: String,
630 pub name: Option<String>,
632 pub description: Option<String>,
634 pub image_url: Option<String>,
636 pub token_uri: Option<String>,
638 pub standard: String,
640 pub collection_name: Option<String>,
642 pub attributes: Vec<NftAttribute>,
644}
645
646#[derive(Debug, Clone, Serialize, Deserialize)]
648pub struct NftAttribute {
649 pub trait_type: String,
651 pub value: String,
653}
654
655#[derive(Debug, Clone, Serialize, Deserialize)]
661pub struct GasAnalysis {
662 pub avg_gas_used: u64,
664 pub max_gas_used: u64,
666 pub min_gas_used: u64,
668 pub total_gas_cost_wei: String,
670 pub total_gas_cost_formatted: String,
672 pub tx_count: u64,
674 pub gas_by_function: Vec<GasByFunction>,
676 pub failed_tx_count: u64,
678 pub wasted_gas: u64,
680}
681
682#[derive(Debug, Clone, Serialize, Deserialize)]
684pub struct GasByFunction {
685 pub function: String,
687 pub call_count: u64,
689 pub avg_gas: u64,
691 pub total_gas: u64,
693}
694
695pub const DEFAULT_CHAIN: &str = "ethereum";
701
702#[derive(Debug, Clone)]
706pub struct ChainMetadata {
707 pub chain_id: &'static str,
709 pub native_symbol: &'static str,
711 pub native_decimals: u8,
713 pub explorer_token_base: &'static str,
715}
716
717pub fn chain_metadata(chain: &str) -> Option<ChainMetadata> {
721 let c = registry::chain(chain)?;
722 Some(ChainMetadata {
723 chain_id: c.id,
724 native_symbol: c.native_symbol,
725 native_decimals: c.native_decimals,
726 explorer_token_base: c.explorer_token_base?,
727 })
728}
729
730pub fn native_symbol(chain: &str) -> &'static str {
732 registry::chain(chain).map_or("???", |c| c.native_symbol)
733}
734
735pub fn infer_chain_from_address(address: &str) -> Option<&'static str> {
761 if address.starts_with('T') && address.len() == 34 && bs58::decode(address).into_vec().is_ok() {
763 return Some("tron");
764 }
765
766 if address.starts_with("0x")
768 && address.len() == 42
769 && address[2..].chars().all(|c| c.is_ascii_hexdigit())
770 {
771 return Some("ethereum");
772 }
773
774 if address.len() >= 32
776 && address.len() <= 44
777 && let Ok(decoded) = bs58::decode(address).into_vec()
778 && decoded.len() == 32
779 {
780 return Some("solana");
781 }
782
783 None
784}
785
786pub fn infer_chain_from_hash(hash: &str) -> Option<&'static str> {
811 if hash.starts_with("0x")
813 && hash.len() == 66
814 && hash[2..].chars().all(|c| c.is_ascii_hexdigit())
815 {
816 return Some("ethereum");
817 }
818
819 if hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit()) {
821 return Some("tron");
822 }
823
824 if hash.len() >= 80
826 && hash.len() <= 90
827 && let Ok(decoded) = bs58::decode(hash).into_vec()
828 && decoded.len() == 64
829 {
830 return Some("solana");
831 }
832
833 None
834}
835
836pub fn analyze_gas_usage(transactions: &[Transaction]) -> GasAnalysis {
841 if transactions.is_empty() {
842 return GasAnalysis {
843 avg_gas_used: 0,
844 max_gas_used: 0,
845 min_gas_used: 0,
846 total_gas_cost_wei: "0".to_string(),
847 total_gas_cost_formatted: "0".to_string(),
848 tx_count: 0,
849 gas_by_function: vec![],
850 failed_tx_count: 0,
851 wasted_gas: 0,
852 };
853 }
854
855 let mut total_gas: u64 = 0;
856 let mut max_gas: u64 = 0;
857 let mut min_gas: u64 = u64::MAX;
858 let mut failed_count: u64 = 0;
859 let mut wasted_gas: u64 = 0;
860 let mut function_gas: std::collections::HashMap<String, (u64, u64)> =
861 std::collections::HashMap::new();
862
863 for tx in transactions {
864 let gas_used = tx.gas_used.unwrap_or(0);
865 total_gas += gas_used;
866 if gas_used > max_gas {
867 max_gas = gas_used;
868 }
869 if gas_used < min_gas {
870 min_gas = gas_used;
871 }
872
873 if tx.status == Some(false) {
875 failed_count += 1;
876 wasted_gas += gas_used;
877 }
878
879 let selector = if tx.input.len() >= 10 {
881 tx.input[..10].to_string()
882 } else if tx.input.is_empty() || tx.input == "0x" {
883 "transfer()".to_string()
884 } else {
885 tx.input.clone()
886 };
887
888 let entry = function_gas.entry(selector).or_insert((0, 0));
889 entry.0 += 1; entry.1 += gas_used; }
892
893 let tx_count = transactions.len() as u64;
894 let avg_gas = total_gas.checked_div(tx_count).unwrap_or(0);
895
896 if min_gas == u64::MAX {
897 min_gas = 0;
898 }
899
900 let mut gas_by_function: Vec<GasByFunction> = function_gas
902 .into_iter()
903 .map(|(function, (call_count, total_gas_fn))| GasByFunction {
904 function,
905 call_count,
906 avg_gas: total_gas_fn.checked_div(call_count).unwrap_or(0),
907 total_gas: total_gas_fn,
908 })
909 .collect();
910 gas_by_function.sort_by_key(|g| std::cmp::Reverse(g.total_gas));
911
912 let total_gas_cost_formatted = format!("{} gas units", total_gas);
914
915 GasAnalysis {
916 avg_gas_used: avg_gas,
917 max_gas_used: max_gas,
918 min_gas_used: min_gas,
919 total_gas_cost_wei: total_gas.to_string(),
920 total_gas_cost_formatted,
921 tx_count,
922 gas_by_function,
923 failed_tx_count: failed_count,
924 wasted_gas,
925 }
926}
927
928#[cfg(test)]
933mod tests {
934 use super::*;
935
936 #[test]
937 fn test_balance_serialization() {
938 let balance = Balance {
939 raw: "1000000000000000000".to_string(),
940 formatted: "1.0".to_string(),
941 decimals: 18,
942 symbol: "ETH".to_string(),
943 usd_value: Some(3500.0),
944 };
945
946 let json = serde_json::to_string(&balance).unwrap();
947 assert!(json.contains("1000000000000000000"));
948 assert!(json.contains("1.0"));
949 assert!(json.contains("ETH"));
950 assert!(json.contains("3500"));
951
952 let deserialized: Balance = serde_json::from_str(&json).unwrap();
953 assert_eq!(deserialized.raw, balance.raw);
954 assert_eq!(deserialized.decimals, 18);
955 }
956
957 #[test]
958 fn test_balance_without_usd() {
959 let balance = Balance {
960 raw: "1000000000000000000".to_string(),
961 formatted: "1.0".to_string(),
962 decimals: 18,
963 symbol: "ETH".to_string(),
964 usd_value: None,
965 };
966
967 let json = serde_json::to_string(&balance).unwrap();
968 assert!(!json.contains("usd_value"));
969 }
970
971 #[test]
972 fn test_transaction_serialization() {
973 let tx = Transaction {
974 hash: "0xabc123".to_string(),
975 block_number: Some(12345678),
976 timestamp: Some(1700000000),
977 from: "0xfrom".to_string(),
978 to: Some("0xto".to_string()),
979 value: "1.0".to_string(),
980 gas_limit: 21000,
981 gas_used: Some(21000),
982 gas_price: "20000000000".to_string(),
983 nonce: 42,
984 input: "0x".to_string(),
985 status: Some(true),
986 };
987
988 let json = serde_json::to_string(&tx).unwrap();
989 assert!(json.contains("0xabc123"));
990 assert!(json.contains("12345678"));
991 assert!(json.contains("0xfrom"));
992 assert!(json.contains("0xto"));
993
994 let deserialized: Transaction = serde_json::from_str(&json).unwrap();
995 assert_eq!(deserialized.hash, tx.hash);
996 assert_eq!(deserialized.nonce, 42);
997 }
998
999 #[test]
1000 fn test_pending_transaction_serialization() {
1001 let tx = Transaction {
1002 hash: "0xpending".to_string(),
1003 block_number: None,
1004 timestamp: None,
1005 from: "0xfrom".to_string(),
1006 to: Some("0xto".to_string()),
1007 value: "1.0".to_string(),
1008 gas_limit: 21000,
1009 gas_used: None,
1010 gas_price: "20000000000".to_string(),
1011 nonce: 0,
1012 input: "0x".to_string(),
1013 status: None,
1014 };
1015
1016 let json = serde_json::to_string(&tx).unwrap();
1017 assert!(json.contains("0xpending"));
1018 assert!(json.contains("null")); let deserialized: Transaction = serde_json::from_str(&json).unwrap();
1021 assert!(deserialized.block_number.is_none());
1022 assert!(deserialized.status.is_none());
1023 }
1024
1025 #[test]
1026 fn test_contract_creation_transaction() {
1027 let tx = Transaction {
1028 hash: "0xcreate".to_string(),
1029 block_number: Some(100),
1030 timestamp: Some(1700000000),
1031 from: "0xdeployer".to_string(),
1032 to: None, value: "0".to_string(),
1034 gas_limit: 1000000,
1035 gas_used: Some(500000),
1036 gas_price: "20000000000".to_string(),
1037 nonce: 0,
1038 input: "0x608060...".to_string(),
1039 status: Some(true),
1040 };
1041
1042 let json = serde_json::to_string(&tx).unwrap();
1043 assert!(json.contains("\"to\":null"));
1044 }
1045
1046 #[test]
1047 fn test_token_serialization() {
1048 let token = Token {
1049 contract_address: "0xtoken".to_string(),
1050 symbol: "USDC".to_string(),
1051 name: "USD Coin".to_string(),
1052 decimals: 6,
1053 };
1054
1055 let json = serde_json::to_string(&token).unwrap();
1056 assert!(json.contains("USDC"));
1057 assert!(json.contains("USD Coin"));
1058 assert!(json.contains("\"decimals\":6"));
1059
1060 let deserialized: Token = serde_json::from_str(&json).unwrap();
1061 assert_eq!(deserialized.decimals, 6);
1062 }
1063
1064 #[test]
1065 fn test_token_balance_serialization() {
1066 let token_balance = TokenBalance {
1067 token: Token {
1068 contract_address: "0xtoken".to_string(),
1069 symbol: "USDC".to_string(),
1070 name: "USD Coin".to_string(),
1071 decimals: 6,
1072 },
1073 balance: "1000000".to_string(),
1074 formatted_balance: "1.0".to_string(),
1075 usd_value: Some(1.0),
1076 };
1077
1078 let json = serde_json::to_string(&token_balance).unwrap();
1079 assert!(json.contains("USDC"));
1080 assert!(json.contains("1000000"));
1081 assert!(json.contains("1.0"));
1082 }
1083
1084 #[test]
1085 fn test_balance_debug() {
1086 let balance = Balance {
1087 raw: "1000".to_string(),
1088 formatted: "0.001".to_string(),
1089 decimals: 18,
1090 symbol: "ETH".to_string(),
1091 usd_value: None,
1092 };
1093
1094 let debug_str = format!("{:?}", balance);
1095 assert!(debug_str.contains("Balance"));
1096 assert!(debug_str.contains("1000"));
1097 }
1098
1099 #[test]
1100 fn test_transaction_debug() {
1101 let tx = Transaction {
1102 hash: "0xtest".to_string(),
1103 block_number: Some(1),
1104 timestamp: Some(0),
1105 from: "0x1".to_string(),
1106 to: Some("0x2".to_string()),
1107 value: "0".to_string(),
1108 gas_limit: 21000,
1109 gas_used: Some(21000),
1110 gas_price: "0".to_string(),
1111 nonce: 0,
1112 input: "0x".to_string(),
1113 status: Some(true),
1114 };
1115
1116 let debug_str = format!("{:?}", tx);
1117 assert!(debug_str.contains("Transaction"));
1118 assert!(debug_str.contains("0xtest"));
1119 }
1120
1121 #[test]
1126 fn test_infer_chain_from_address_evm() {
1127 assert_eq!(
1129 super::infer_chain_from_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"),
1130 Some("ethereum")
1131 );
1132 assert_eq!(
1133 super::infer_chain_from_address("0x0000000000000000000000000000000000000000"),
1134 Some("ethereum")
1135 );
1136 assert_eq!(
1137 super::infer_chain_from_address("0xABCDEF1234567890abcdef1234567890ABCDEF12"),
1138 Some("ethereum")
1139 );
1140 }
1141
1142 #[test]
1143 fn test_infer_chain_from_address_tron() {
1144 assert_eq!(
1146 super::infer_chain_from_address("TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf"),
1147 Some("tron")
1148 );
1149 assert_eq!(
1150 super::infer_chain_from_address("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"),
1151 Some("tron")
1152 );
1153 }
1154
1155 #[test]
1156 fn test_infer_chain_from_address_solana() {
1157 assert_eq!(
1159 super::infer_chain_from_address("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy"),
1160 Some("solana")
1161 );
1162 assert_eq!(
1164 super::infer_chain_from_address("11111111111111111111111111111111"),
1165 Some("solana")
1166 );
1167 }
1168
1169 #[test]
1170 fn test_infer_chain_from_address_invalid() {
1171 assert_eq!(super::infer_chain_from_address("0x123"), None);
1173 assert_eq!(super::infer_chain_from_address("not_an_address"), None);
1175 assert_eq!(super::infer_chain_from_address(""), None);
1177 assert_eq!(super::infer_chain_from_address("0x123456"), None);
1179 assert_eq!(
1181 super::infer_chain_from_address("ADqSquXBgUCLYvYC4XZgrprLK589dkhSCf"),
1182 None
1183 );
1184 }
1185
1186 #[test]
1187 fn test_infer_chain_from_hash_evm() {
1188 assert_eq!(
1190 super::infer_chain_from_hash(
1191 "0xabc123def456789012345678901234567890123456789012345678901234abcd"
1192 ),
1193 Some("ethereum")
1194 );
1195 assert_eq!(
1196 super::infer_chain_from_hash(
1197 "0x0000000000000000000000000000000000000000000000000000000000000000"
1198 ),
1199 Some("ethereum")
1200 );
1201 }
1202
1203 #[test]
1204 fn test_infer_chain_from_hash_tron() {
1205 assert_eq!(
1207 super::infer_chain_from_hash(
1208 "abc123def456789012345678901234567890123456789012345678901234abcd"
1209 ),
1210 Some("tron")
1211 );
1212 assert_eq!(
1213 super::infer_chain_from_hash(
1214 "0000000000000000000000000000000000000000000000000000000000000000"
1215 ),
1216 Some("tron")
1217 );
1218 }
1219
1220 #[test]
1221 fn test_infer_chain_from_hash_solana() {
1222 let solana_sig = "5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW";
1225 assert_eq!(super::infer_chain_from_hash(solana_sig), Some("solana"));
1226 }
1227
1228 #[test]
1229 fn test_infer_chain_from_hash_invalid() {
1230 assert_eq!(super::infer_chain_from_hash("0x123"), None);
1232 assert_eq!(super::infer_chain_from_hash("not_a_hash"), None);
1234 assert_eq!(super::infer_chain_from_hash(""), None);
1236 assert_eq!(
1238 super::infer_chain_from_hash(
1239 "abc123gef456789012345678901234567890123456789012345678901234abcd"
1240 ),
1241 None
1242 );
1243 }
1244
1245 #[test]
1250 fn test_default_client_factory_create_dex_client() {
1251 let config = crate::config::ChainsConfig::default();
1252 let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1253 let factory = DefaultClientFactory {
1254 chains_config: config,
1255 http,
1256 };
1257 let dex = factory.create_dex_client();
1258 let _ = format!("{:?}", std::mem::size_of_val(&dex));
1260 }
1261
1262 #[test]
1263 fn test_default_client_factory_create_ethereum_client() {
1264 let config = crate::config::ChainsConfig::default();
1265 let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1266 let factory = DefaultClientFactory {
1267 chains_config: config,
1268 http,
1269 };
1270 let client = factory.create_chain_client("ethereum");
1272 assert!(client.is_ok());
1273 assert_eq!(client.unwrap().chain_name(), "ethereum");
1274 }
1275
1276 #[test]
1277 fn test_default_client_factory_create_polygon_client() {
1278 let config = crate::config::ChainsConfig::default();
1279 let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1280 let factory = DefaultClientFactory {
1281 chains_config: config,
1282 http,
1283 };
1284 let client = factory.create_chain_client("polygon");
1285 assert!(client.is_ok());
1286 assert_eq!(client.unwrap().chain_name(), "polygon");
1287 }
1288
1289 #[test]
1290 fn test_default_client_factory_create_solana_client() {
1291 let config = crate::config::ChainsConfig::default();
1292 let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1293 let factory = DefaultClientFactory {
1294 chains_config: config,
1295 http,
1296 };
1297 let client = factory.create_chain_client("solana");
1298 assert!(client.is_ok());
1299 assert_eq!(client.unwrap().chain_name(), "solana");
1300 }
1301
1302 #[test]
1303 fn test_default_client_factory_create_sol_alias() {
1304 let config = crate::config::ChainsConfig::default();
1305 let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1306 let factory = DefaultClientFactory {
1307 chains_config: config,
1308 http,
1309 };
1310 let client = factory.create_chain_client("sol");
1311 assert!(client.is_ok());
1312 assert_eq!(client.unwrap().chain_name(), "solana");
1313 }
1314
1315 #[test]
1316 fn test_default_client_factory_create_tron_client() {
1317 let config = crate::config::ChainsConfig::default();
1318 let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1319 let factory = DefaultClientFactory {
1320 chains_config: config,
1321 http,
1322 };
1323 let client = factory.create_chain_client("tron");
1324 assert!(client.is_ok());
1325 assert_eq!(client.unwrap().chain_name(), "tron");
1326 }
1327
1328 #[test]
1329 fn test_default_client_factory_create_trx_alias() {
1330 let config = crate::config::ChainsConfig::default();
1331 let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1332 let factory = DefaultClientFactory {
1333 chains_config: config,
1334 http,
1335 };
1336 let client = factory.create_chain_client("trx");
1337 assert!(client.is_ok());
1338 assert_eq!(client.unwrap().chain_name(), "tron");
1339 }
1340
1341 #[test]
1342 fn test_default_client_factory_create_arbitrum_client() {
1343 let config = crate::config::ChainsConfig::default();
1344 let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1345 let factory = DefaultClientFactory {
1346 chains_config: config,
1347 http,
1348 };
1349 let client = factory.create_chain_client("arbitrum");
1350 assert!(client.is_ok());
1351 assert_eq!(client.unwrap().chain_name(), "arbitrum");
1352 }
1353
1354 #[test]
1355 fn test_default_client_factory_create_optimism_client() {
1356 let config = crate::config::ChainsConfig::default();
1357 let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1358 let factory = DefaultClientFactory {
1359 chains_config: config,
1360 http,
1361 };
1362 let client = factory.create_chain_client("optimism");
1363 assert!(client.is_ok());
1364 assert_eq!(client.unwrap().chain_name(), "optimism");
1365 }
1366
1367 #[test]
1368 fn test_default_client_factory_create_base_client() {
1369 let config = crate::config::ChainsConfig::default();
1370 let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1371 let factory = DefaultClientFactory {
1372 chains_config: config,
1373 http,
1374 };
1375 let client = factory.create_chain_client("base");
1376 assert!(client.is_ok());
1377 assert_eq!(client.unwrap().chain_name(), "base");
1378 }
1379
1380 #[test]
1381 fn test_default_client_factory_create_unsupported_chain_returns_err() {
1382 let config = crate::config::ChainsConfig::default();
1383 let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1384 let factory = DefaultClientFactory {
1385 chains_config: config,
1386 http,
1387 };
1388 let client = factory.create_chain_client("avalanche");
1389 match &client {
1390 Err(e) => assert!(e.to_string().contains("Unsupported")),
1391 Ok(_) => panic!("expected Err for unsupported chain"),
1392 }
1393 }
1394
1395 #[test]
1400 fn test_solana_client_new_with_http() {
1401 let config = crate::config::ChainsConfig::default();
1402 let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1403 let client = SolanaClient::new_with_http(&config, http);
1404 assert!(client.is_ok());
1405 assert_eq!(client.unwrap().chain_name(), "solana");
1406 }
1407
1408 #[test]
1409 fn test_tron_client_new_with_http() {
1410 let config = crate::config::ChainsConfig::default();
1411 let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1412 let client = TronClient::new_with_http(&config, http);
1413 assert!(client.is_ok());
1414 assert_eq!(client.unwrap().chain_name(), "tron");
1415 }
1416
1417 #[test]
1418 fn test_dex_client_new_with_http() {
1419 let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1420 let client = DexClient::new_with_http(http);
1421 let _ = format!("{}", std::mem::size_of_val(&client));
1422 }
1423
1424 #[test]
1425 fn test_factory_shares_http_transport() {
1426 let config = crate::config::ChainsConfig::default();
1427 let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1428 let weak = Arc::downgrade(&http);
1429 let factory = DefaultClientFactory {
1430 chains_config: config,
1431 http,
1432 };
1433 let _eth = factory.create_chain_client("ethereum").unwrap();
1438 let _sol = factory.create_chain_client("solana").unwrap();
1439 let _trx = factory.create_chain_client("tron").unwrap();
1440 let _dex = factory.create_dex_client();
1441 assert!(weak.upgrade().is_some());
1443 }
1444
1445 #[test]
1446 fn test_ethereum_client_new_with_http() {
1447 let config = crate::config::ChainsConfig::default();
1448 let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1449 let client = EthereumClient::new_with_http(&config, http);
1450 assert!(client.is_ok());
1451 assert_eq!(client.unwrap().chain_name(), "ethereum");
1452 }
1453
1454 #[test]
1455 fn test_ethereum_client_for_chain_with_http() {
1456 let config = crate::config::ChainsConfig::default();
1457 let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1458 let client = EthereumClient::for_chain_with_http("polygon", &config, http);
1459 assert!(client.is_ok());
1460 assert_eq!(client.unwrap().chain_name(), "polygon");
1461 }
1462
1463 #[tokio::test]
1468 async fn test_chain_client_default_get_token_info() {
1469 use super::mocks::MockChainClient;
1470 let client = MockChainClient::new("ethereum", "ETH");
1472 let result = client.get_token_info("0xsometoken").await;
1473 assert!(result.is_err());
1474 }
1475
1476 #[tokio::test]
1477 async fn test_chain_client_default_get_token_holders() {
1478 use super::mocks::MockChainClient;
1479 let client = MockChainClient::new("ethereum", "ETH");
1480 let holders = client.get_token_holders("0xsometoken", 10).await.unwrap();
1481 assert!(holders.is_empty());
1482 }
1483
1484 #[tokio::test]
1485 async fn test_chain_client_default_get_token_holder_count() {
1486 use super::mocks::MockChainClient;
1487 let client = MockChainClient::new("ethereum", "ETH");
1488 let count = client.get_token_holder_count("0xsometoken").await.unwrap();
1489 assert_eq!(count, 0);
1490 }
1491
1492 #[tokio::test]
1493 async fn test_mock_client_factory_creates_chain_client() {
1494 use super::mocks::MockClientFactory;
1495 let factory = MockClientFactory::new();
1496 let client = factory.create_chain_client("anything").unwrap();
1497 assert_eq!(client.chain_name(), "ethereum"); }
1499
1500 #[tokio::test]
1501 async fn test_mock_client_factory_creates_dex_client() {
1502 use super::mocks::MockClientFactory;
1503 let factory = MockClientFactory::new();
1504 let dex = factory.create_dex_client();
1505 let price = dex.get_token_price("ethereum", "0xtest").await;
1506 assert_eq!(price, Some(1.0));
1507 }
1508
1509 #[tokio::test]
1510 async fn test_mock_chain_client_balance() {
1511 use super::mocks::MockChainClient;
1512 let client = MockChainClient::new("ethereum", "ETH");
1513 let balance = client.get_balance("0xtest").await.unwrap();
1514 assert_eq!(balance.formatted, "1.0");
1515 assert_eq!(balance.symbol, "ETH");
1516 assert_eq!(balance.usd_value, Some(2500.0));
1517 }
1518
1519 #[tokio::test]
1520 async fn test_mock_chain_client_transaction() {
1521 use super::mocks::MockChainClient;
1522 let client = MockChainClient::new("ethereum", "ETH");
1523 let tx = client.get_transaction("0xanyhash").await.unwrap();
1524 assert_eq!(tx.hash, "0xmocktx");
1525 assert_eq!(tx.nonce, 42);
1526 }
1527
1528 #[tokio::test]
1529 async fn test_mock_chain_client_block_number() {
1530 use super::mocks::MockChainClient;
1531 let client = MockChainClient::new("ethereum", "ETH");
1532 let block = client.get_block_number().await.unwrap();
1533 assert_eq!(block, 12345678);
1534 }
1535
1536 #[tokio::test]
1537 async fn test_mock_dex_source_data() {
1538 use super::mocks::MockDexSource;
1539 let dex = MockDexSource::new();
1540 let data = dex.get_token_data("ethereum", "0xtest").await.unwrap();
1541 assert_eq!(data.symbol, "MOCK");
1542 assert_eq!(data.price_usd, 1.0);
1543 }
1544
1545 #[tokio::test]
1546 async fn test_mock_dex_source_search() {
1547 use super::mocks::MockDexSource;
1548 let dex = MockDexSource::new();
1549 let results = dex.search_tokens("test", None).await.unwrap();
1550 assert!(results.is_empty());
1551 }
1552
1553 #[tokio::test]
1554 async fn test_mock_dex_source_native_price() {
1555 use super::mocks::MockDexSource;
1556 let dex = MockDexSource::new();
1557 let price = dex.get_native_token_price("ethereum").await;
1558 assert_eq!(price, Some(2500.0));
1559 }
1560
1561 struct MinimalChainClient;
1567
1568 #[async_trait::async_trait]
1569 impl ChainClient for MinimalChainClient {
1570 fn chain_name(&self) -> &str {
1571 "test"
1572 }
1573
1574 fn native_token_symbol(&self) -> &str {
1575 "TEST"
1576 }
1577
1578 async fn get_balance(&self, _address: &str) -> Result<Balance> {
1579 Ok(Balance {
1580 raw: "0".to_string(),
1581 formatted: "0".to_string(),
1582 decimals: 18,
1583 symbol: "TEST".to_string(),
1584 usd_value: None,
1585 })
1586 }
1587
1588 async fn get_transaction(&self, _hash: &str) -> Result<Transaction> {
1589 unimplemented!()
1590 }
1591
1592 async fn get_transactions(&self, _address: &str, _limit: u32) -> Result<Vec<Transaction>> {
1593 Ok(Vec::new())
1594 }
1595
1596 async fn get_block_number(&self) -> Result<u64> {
1597 Ok(0)
1598 }
1599
1600 async fn get_token_balances(&self, _address: &str) -> Result<Vec<TokenBalance>> {
1601 Ok(Vec::new())
1602 }
1603
1604 async fn enrich_balance_usd(&self, _balance: &mut Balance) {}
1605 }
1606
1607 #[tokio::test]
1608 async fn test_default_get_token_info() {
1609 let client = MinimalChainClient;
1610 let result = client.get_token_info("0xtest").await;
1611 assert!(result.is_err());
1612 assert!(result.unwrap_err().to_string().contains("not supported"));
1613 }
1614
1615 #[tokio::test]
1616 async fn test_default_get_token_holders() {
1617 let client = MinimalChainClient;
1618 let holders = client.get_token_holders("0xtest", 10).await.unwrap();
1619 assert!(holders.is_empty());
1620 }
1621
1622 #[tokio::test]
1623 async fn test_default_get_token_holder_count() {
1624 let client = MinimalChainClient;
1625 let count = client.get_token_holder_count("0xtest").await.unwrap();
1626 assert_eq!(count, 0);
1627 }
1628
1629 #[test]
1634 fn test_chain_metadata_ethereum() {
1635 let meta = chain_metadata("ethereum").unwrap();
1636 assert_eq!(meta.chain_id, "ethereum");
1637 assert_eq!(meta.native_symbol, "ETH");
1638 assert_eq!(meta.native_decimals, 18);
1639 assert_eq!(meta.explorer_token_base, "https://etherscan.io/token");
1640 }
1641
1642 #[test]
1643 fn test_chain_metadata_ethereum_alias() {
1644 let meta = chain_metadata("eth").unwrap();
1645 assert_eq!(meta.chain_id, "ethereum");
1646 assert_eq!(meta.native_symbol, "ETH");
1647 }
1648
1649 #[test]
1650 fn test_chain_metadata_polygon() {
1651 let meta = chain_metadata("polygon").unwrap();
1652 assert_eq!(meta.chain_id, "polygon");
1653 assert_eq!(meta.native_symbol, "MATIC");
1654 assert_eq!(meta.native_decimals, 18);
1655 assert_eq!(meta.explorer_token_base, "https://polygonscan.com/token");
1656 }
1657
1658 #[test]
1659 fn test_chain_metadata_bsc() {
1660 let meta = chain_metadata("bsc").unwrap();
1661 assert_eq!(meta.chain_id, "bsc");
1662 assert_eq!(meta.native_symbol, "BNB");
1663 assert_eq!(meta.native_decimals, 18);
1664 assert_eq!(meta.explorer_token_base, "https://bscscan.com/token");
1665 }
1666
1667 #[test]
1668 fn test_chain_metadata_solana() {
1669 let meta = chain_metadata("solana").unwrap();
1670 assert_eq!(meta.chain_id, "solana");
1671 assert_eq!(meta.native_symbol, "SOL");
1672 assert_eq!(meta.native_decimals, 9);
1673 assert_eq!(meta.explorer_token_base, "https://solscan.io/token");
1674 }
1675
1676 #[test]
1677 fn test_chain_metadata_solana_alias() {
1678 let meta = chain_metadata("sol").unwrap();
1679 assert_eq!(meta.chain_id, "solana");
1680 assert_eq!(meta.native_symbol, "SOL");
1681 }
1682
1683 #[test]
1684 fn test_chain_metadata_tron() {
1685 let meta = chain_metadata("tron").unwrap();
1686 assert_eq!(meta.chain_id, "tron");
1687 assert_eq!(meta.native_symbol, "TRX");
1688 assert_eq!(meta.native_decimals, 6);
1689 assert_eq!(meta.explorer_token_base, "https://tronscan.org/#/token20");
1690 }
1691
1692 #[test]
1693 fn test_chain_metadata_tron_alias() {
1694 let meta = chain_metadata("trx").unwrap();
1695 assert_eq!(meta.chain_id, "tron");
1696 assert_eq!(meta.native_symbol, "TRX");
1697 }
1698
1699 #[test]
1700 fn test_chain_metadata_arbitrum() {
1701 let meta = chain_metadata("arbitrum").unwrap();
1702 assert_eq!(meta.chain_id, "arbitrum");
1703 assert_eq!(meta.native_symbol, "ETH");
1704 assert_eq!(meta.native_decimals, 18);
1705 assert_eq!(meta.explorer_token_base, "https://arbiscan.io/token");
1706 }
1707
1708 #[test]
1709 fn test_chain_metadata_optimism() {
1710 let meta = chain_metadata("optimism").unwrap();
1711 assert_eq!(meta.chain_id, "optimism");
1712 assert_eq!(meta.native_symbol, "ETH");
1713 assert_eq!(meta.native_decimals, 18);
1714 assert_eq!(
1715 meta.explorer_token_base,
1716 "https://optimistic.etherscan.io/token"
1717 );
1718 }
1719
1720 #[test]
1721 fn test_chain_metadata_base() {
1722 let meta = chain_metadata("base").unwrap();
1723 assert_eq!(meta.chain_id, "base");
1724 assert_eq!(meta.native_symbol, "ETH");
1725 assert_eq!(meta.native_decimals, 18);
1726 assert_eq!(meta.explorer_token_base, "https://basescan.org/token");
1727 }
1728
1729 #[test]
1730 fn test_chain_metadata_case_insensitive() {
1731 let meta1 = chain_metadata("ETHEREUM").unwrap();
1732 let meta2 = chain_metadata("Ethereum").unwrap();
1733 let meta3 = chain_metadata("ethereum").unwrap();
1734 assert_eq!(meta1.chain_id, meta2.chain_id);
1735 assert_eq!(meta2.chain_id, meta3.chain_id);
1736 }
1737
1738 #[test]
1739 fn test_chain_metadata_unknown() {
1740 assert!(chain_metadata("bitcoin").is_none());
1741 assert!(chain_metadata("litecoin").is_none());
1742 assert!(chain_metadata("unknown").is_none());
1743 assert!(chain_metadata("").is_none());
1744 }
1745
1746 #[test]
1747 fn test_native_symbol_ethereum() {
1748 assert_eq!(native_symbol("ethereum"), "ETH");
1749 assert_eq!(native_symbol("eth"), "ETH");
1750 }
1751
1752 #[test]
1753 fn test_native_symbol_polygon() {
1754 assert_eq!(native_symbol("polygon"), "MATIC");
1755 }
1756
1757 #[test]
1758 fn test_native_symbol_bsc() {
1759 assert_eq!(native_symbol("bsc"), "BNB");
1760 }
1761
1762 #[test]
1763 fn test_native_symbol_solana() {
1764 assert_eq!(native_symbol("solana"), "SOL");
1765 assert_eq!(native_symbol("sol"), "SOL");
1766 }
1767
1768 #[test]
1769 fn test_native_symbol_tron() {
1770 assert_eq!(native_symbol("tron"), "TRX");
1771 assert_eq!(native_symbol("trx"), "TRX");
1772 }
1773
1774 #[test]
1775 fn test_native_symbol_arbitrum() {
1776 assert_eq!(native_symbol("arbitrum"), "ETH");
1777 }
1778
1779 #[test]
1780 fn test_native_symbol_optimism() {
1781 assert_eq!(native_symbol("optimism"), "ETH");
1782 }
1783
1784 #[test]
1785 fn test_native_symbol_base() {
1786 assert_eq!(native_symbol("base"), "ETH");
1787 }
1788
1789 #[test]
1790 fn test_native_symbol_unknown() {
1791 assert_eq!(native_symbol("unknown"), "???");
1792 assert_eq!(native_symbol("bitcoin"), "???");
1793 assert_eq!(native_symbol(""), "???");
1794 }
1795
1796 #[test]
1797 fn test_native_symbol_case_insensitive() {
1798 assert_eq!(native_symbol("ETHEREUM"), "ETH");
1799 assert_eq!(native_symbol("Ethereum"), "ETH");
1800 assert_eq!(native_symbol("ethereum"), "ETH");
1801 }
1802
1803 #[tokio::test]
1804 async fn test_chain_client_default_get_code() {
1805 let client = MinimalChainClient;
1806 let result = client.get_code("0x1234").await;
1807 assert!(result.is_err());
1808 let err_msg = result.unwrap_err().to_string();
1809 assert!(err_msg.contains("not supported"));
1810 }
1811
1812 fn tx(hash: &str, gas_used: Option<u64>, input: &str, status: Option<bool>) -> Transaction {
1817 Transaction {
1818 hash: hash.to_string(),
1819 block_number: Some(1),
1820 timestamp: Some(1700000000),
1821 from: "0xfrom".to_string(),
1822 to: Some("0xto".to_string()),
1823 value: "0".to_string(),
1824 gas_limit: 21000,
1825 gas_used,
1826 gas_price: "20000000000".to_string(),
1827 nonce: 0,
1828 input: input.to_string(),
1829 status,
1830 }
1831 }
1832
1833 #[test]
1834 fn test_analyze_gas_usage_empty_transactions() {
1835 let txs: Vec<Transaction> = vec![];
1836 let result = super::analyze_gas_usage(&txs);
1837 assert_eq!(result.avg_gas_used, 0);
1838 assert_eq!(result.max_gas_used, 0);
1839 assert_eq!(result.min_gas_used, 0);
1840 assert_eq!(result.tx_count, 0);
1841 assert_eq!(result.failed_tx_count, 0);
1842 assert_eq!(result.wasted_gas, 0);
1843 assert!(result.gas_by_function.is_empty());
1844 }
1845
1846 #[test]
1847 fn test_analyze_gas_usage_single_tx() {
1848 let txs = vec![tx("0x1", Some(100_000), "0x", Some(true))];
1849 let result = super::analyze_gas_usage(&txs);
1850 assert_eq!(result.avg_gas_used, 100_000);
1851 assert_eq!(result.max_gas_used, 100_000);
1852 assert_eq!(result.min_gas_used, 100_000);
1853 assert_eq!(result.tx_count, 1);
1854 }
1855
1856 #[test]
1857 fn test_analyze_gas_usage_multiple_txs() {
1858 let txs = vec![
1859 tx("0x1", Some(50_000), "0xa9059cbb", Some(true)),
1860 tx("0x2", Some(150_000), "0xa9059cbb", Some(true)),
1861 tx("0x3", Some(100_000), "0xa9059cbb", Some(true)),
1862 ];
1863 let result = super::analyze_gas_usage(&txs);
1864 assert_eq!(result.avg_gas_used, 100_000); assert_eq!(result.max_gas_used, 150_000);
1866 assert_eq!(result.min_gas_used, 50_000);
1867 assert_eq!(result.tx_count, 3);
1868 }
1869
1870 #[test]
1871 fn test_analyze_gas_usage_failed_tx() {
1872 let txs = vec![
1873 tx("0x1", Some(80_000), "0x", Some(true)),
1874 tx("0x2", Some(120_000), "0x", Some(false)),
1875 ];
1876 let result = super::analyze_gas_usage(&txs);
1877 assert_eq!(result.failed_tx_count, 1);
1878 assert_eq!(result.wasted_gas, 120_000);
1879 }
1880
1881 #[test]
1882 fn test_analyze_gas_usage_gas_by_function() {
1883 let txs = vec![
1885 tx("0x1", Some(100_000), "0xa9059cbb0000", Some(true)),
1886 tx("0x2", Some(200_000), "0xa9059cbb0000", Some(true)),
1887 tx("0x3", Some(50_000), "0x095ea7b30000", Some(true)),
1888 ];
1889 let result = super::analyze_gas_usage(&txs);
1890 assert_eq!(result.gas_by_function.len(), 2);
1891 let by_sel: std::collections::HashMap<_, _> = result
1892 .gas_by_function
1893 .iter()
1894 .map(|g| (g.function.as_str(), g))
1895 .collect();
1896 let transfer = by_sel.get("0xa9059cbb").unwrap();
1897 assert_eq!(transfer.call_count, 2);
1898 assert_eq!(transfer.total_gas, 300_000);
1899 assert_eq!(transfer.avg_gas, 150_000);
1900 let approve = by_sel.get("0x095ea7b3").unwrap();
1901 assert_eq!(approve.call_count, 1);
1902 assert_eq!(approve.total_gas, 50_000);
1903 }
1904
1905 #[test]
1906 fn test_analyze_gas_usage_input_0x_transfer() {
1907 let txs = vec![tx("0x1", Some(21_000), "0x", Some(true))];
1908 let result = super::analyze_gas_usage(&txs);
1909 assert_eq!(result.gas_by_function.len(), 1);
1910 assert_eq!(result.gas_by_function[0].function, "transfer()");
1911 }
1912
1913 #[test]
1914 fn test_analyze_gas_usage_input_empty_transfer() {
1915 let txs = vec![tx("0x1", Some(21_000), "", Some(true))];
1916 let result = super::analyze_gas_usage(&txs);
1917 assert_eq!(result.gas_by_function.len(), 1);
1918 assert_eq!(result.gas_by_function[0].function, "transfer()");
1919 }
1920
1921 #[test]
1922 fn test_analyze_gas_usage_gas_used_none() {
1923 let txs = vec![tx("0x1", None, "0x", Some(true))];
1924 let result = super::analyze_gas_usage(&txs);
1925 assert_eq!(result.avg_gas_used, 0);
1926 assert_eq!(result.max_gas_used, 0);
1927 assert_eq!(result.min_gas_used, 0);
1928 }
1929
1930 #[test]
1931 fn test_analyze_gas_usage_short_input_uses_full_input_as_selector() {
1932 let txs = vec![tx("0x1", Some(50_000), "0x1234567", Some(true))];
1933 let result = super::analyze_gas_usage(&txs);
1934 assert_eq!(result.gas_by_function.len(), 1);
1935 assert_eq!(result.gas_by_function[0].function, "0x1234567");
1936 }
1937}
1938
1939#[cfg(any(test, feature = "test-support"))]
1948pub mod mocks {
1949 use super::*;
1950 use crate::chains::dex::{DexDataSource, DexTokenData, TokenSearchResult};
1951 use async_trait::async_trait;
1952
1953 #[derive(Debug, Clone)]
1955 pub struct MockChainClient {
1956 pub chain: String,
1957 pub symbol: String,
1958 pub balance: Balance,
1959 pub transaction: Transaction,
1960 pub transactions: Vec<Transaction>,
1961 pub token_balances: Vec<TokenBalance>,
1962 pub block_number: u64,
1963 pub token_info: Option<Token>,
1964 pub token_holders: Vec<TokenHolder>,
1965 pub token_holder_count: u64,
1966 }
1967
1968 impl MockChainClient {
1969 pub fn new(chain: &str, symbol: &str) -> Self {
1971 Self {
1972 chain: chain.to_string(),
1973 symbol: symbol.to_string(),
1974 balance: Balance {
1975 raw: "1000000000000000000".to_string(),
1976 formatted: "1.0".to_string(),
1977 decimals: 18,
1978 symbol: symbol.to_string(),
1979 usd_value: Some(2500.0),
1980 },
1981 transaction: Transaction {
1982 hash: "0xmocktx".to_string(),
1983 block_number: Some(12345678),
1984 timestamp: Some(1700000000),
1985 from: "0xfrom".to_string(),
1986 to: Some("0xto".to_string()),
1987 value: "1.0".to_string(),
1988 gas_limit: 21000,
1989 gas_used: Some(21000),
1990 gas_price: "20000000000".to_string(),
1991 nonce: 42,
1992 input: "0x".to_string(),
1993 status: Some(true),
1994 },
1995 transactions: vec![],
1996 token_balances: vec![],
1997 block_number: 12345678,
1998 token_info: None,
1999 token_holders: vec![],
2000 token_holder_count: 0,
2001 }
2002 }
2003 }
2004
2005 #[async_trait]
2006 impl ChainClient for MockChainClient {
2007 fn chain_name(&self) -> &str {
2008 &self.chain
2009 }
2010
2011 fn native_token_symbol(&self) -> &str {
2012 &self.symbol
2013 }
2014
2015 async fn get_balance(&self, _address: &str) -> Result<Balance> {
2016 Ok(self.balance.clone())
2017 }
2018
2019 async fn enrich_balance_usd(&self, _balance: &mut Balance) {
2020 }
2022
2023 async fn get_transaction(&self, _hash: &str) -> Result<Transaction> {
2024 Ok(self.transaction.clone())
2025 }
2026
2027 async fn get_transactions(&self, _address: &str, _limit: u32) -> Result<Vec<Transaction>> {
2028 Ok(self.transactions.clone())
2029 }
2030
2031 async fn get_block_number(&self) -> Result<u64> {
2032 Ok(self.block_number)
2033 }
2034
2035 async fn get_token_balances(&self, _address: &str) -> Result<Vec<TokenBalance>> {
2036 Ok(self.token_balances.clone())
2037 }
2038
2039 async fn get_token_info(&self, _address: &str) -> Result<Token> {
2040 match &self.token_info {
2041 Some(t) => Ok(t.clone()),
2042 None => Err(crate::error::ScopeError::Chain(
2043 "Token info not available".to_string(),
2044 )),
2045 }
2046 }
2047
2048 async fn get_token_holders(&self, _address: &str, _limit: u32) -> Result<Vec<TokenHolder>> {
2049 Ok(self.token_holders.clone())
2050 }
2051
2052 async fn get_token_holder_count(&self, _address: &str) -> Result<u64> {
2053 Ok(self.token_holder_count)
2054 }
2055 }
2056
2057 #[derive(Debug, Clone)]
2059 pub struct MockDexSource {
2060 pub token_price: Option<f64>,
2061 pub native_price: Option<f64>,
2062 pub token_data: Option<DexTokenData>,
2063 pub search_results: Vec<TokenSearchResult>,
2064 }
2065
2066 impl Default for MockDexSource {
2067 fn default() -> Self {
2068 Self::new()
2069 }
2070 }
2071
2072 impl MockDexSource {
2073 pub fn new() -> Self {
2075 Self {
2076 token_price: Some(1.0),
2077 native_price: Some(2500.0),
2078 token_data: Some(DexTokenData {
2079 address: "0xmocktoken".to_string(),
2080 symbol: "MOCK".to_string(),
2081 name: "Mock Token".to_string(),
2082 price_usd: 1.0,
2083 price_change_24h: 5.0,
2084 price_change_6h: 2.0,
2085 price_change_1h: 0.5,
2086 price_change_5m: 0.1,
2087 volume_24h: 1_000_000.0,
2088 volume_6h: 250_000.0,
2089 volume_1h: 50_000.0,
2090 liquidity_usd: 5_000_000.0,
2091 market_cap: Some(100_000_000.0),
2092 fdv: Some(200_000_000.0),
2093 pairs: vec![],
2094 price_history: vec![],
2095 volume_history: vec![],
2096 total_buys_24h: 500,
2097 total_sells_24h: 450,
2098 total_buys_6h: 120,
2099 total_sells_6h: 110,
2100 total_buys_1h: 20,
2101 total_sells_1h: 18,
2102 earliest_pair_created_at: Some(1690000000),
2103 image_url: None,
2104 websites: vec![],
2105 socials: vec![],
2106 dexscreener_url: None,
2107 }),
2108 search_results: vec![],
2109 }
2110 }
2111 }
2112
2113 #[async_trait]
2114 impl DexDataSource for MockDexSource {
2115 async fn get_token_price(&self, _chain: &str, _address: &str) -> Option<f64> {
2116 self.token_price
2117 }
2118
2119 async fn get_native_token_price(&self, _chain: &str) -> Option<f64> {
2120 self.native_price
2121 }
2122
2123 async fn get_token_data(&self, _chain: &str, _address: &str) -> Result<DexTokenData> {
2124 match &self.token_data {
2125 Some(data) => Ok(data.clone()),
2126 None => Err(crate::error::ScopeError::NotFound(
2127 "No DEX data found".to_string(),
2128 )),
2129 }
2130 }
2131
2132 async fn search_tokens(
2133 &self,
2134 _query: &str,
2135 _chain: Option<&str>,
2136 ) -> Result<Vec<TokenSearchResult>> {
2137 Ok(self.search_results.clone())
2138 }
2139 }
2140
2141 pub struct MockClientFactory {
2143 pub mock_client: MockChainClient,
2144 pub mock_dex: MockDexSource,
2145 }
2146
2147 impl Default for MockClientFactory {
2148 fn default() -> Self {
2149 Self::new()
2150 }
2151 }
2152
2153 impl MockClientFactory {
2154 pub fn new() -> Self {
2156 Self {
2157 mock_client: MockChainClient::new("ethereum", "ETH"),
2158 mock_dex: MockDexSource::new(),
2159 }
2160 }
2161 }
2162
2163 impl ChainClientFactory for MockClientFactory {
2164 fn create_chain_client(&self, _chain: &str) -> Result<Box<dyn ChainClient>> {
2165 Ok(Box::new(self.mock_client.clone()))
2166 }
2167
2168 fn create_dex_client(&self) -> Box<dyn DexDataSource> {
2169 Box::new(self.mock_dex.clone())
2170 }
2171 }
2172}