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