1use std::sync::Arc;
6
7use serde::Deserialize;
8
9const ETHERSCAN_API_BASE: &str = "https://api.etherscan.io";
11
12#[derive(Debug, Clone)]
14pub struct DataSources {
15 etherscan_api_key: String,
16}
17
18impl DataSources {
19 pub fn new(etherscan_api_key: String) -> Self {
21 Self { etherscan_api_key }
22 }
23
24 pub fn etherscan_key(&self) -> &str {
26 &self.etherscan_api_key
27 }
28}
29
30#[derive(Debug, Clone, Deserialize, serde::Serialize)]
32pub struct EtherscanTransaction {
33 pub block_number: String,
34 pub timestamp: String,
35 pub hash: String,
36 pub from: String,
37 pub to: String,
38 pub value: String,
39 pub gas: String,
40 pub gas_price: String,
41 pub is_error: String,
42 pub txreceipt_status: String,
43 pub input: String,
44 pub contract_address: String,
45 pub cumulative_gas_used: String,
46 pub gas_used: String,
47 pub confirmations: String,
48}
49
50#[derive(Debug, Clone, Deserialize)]
52pub struct EtherscanResponse<T> {
53 pub status: String,
54 pub message: String,
55 pub result: Option<T>,
56}
57
58#[derive(Clone)]
60pub struct BlockchainDataClient {
61 sources: DataSources,
62 http_client: Arc<dyn crate::http::HttpClient>,
63 base_url: String,
64}
65
66impl std::fmt::Debug for BlockchainDataClient {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 f.debug_struct("BlockchainDataClient")
69 .field("sources", &self.sources)
70 .field("http_client", &"<dyn HttpClient>")
71 .finish()
72 }
73}
74
75impl BlockchainDataClient {
76 pub fn new(sources: DataSources) -> Self {
78 Self {
79 sources,
80 http_client: Arc::new(crate::http::NativeHttpClient::new().unwrap()),
81 base_url: ETHERSCAN_API_BASE.to_string(),
82 }
83 }
84
85 pub fn with_base_url(sources: DataSources, base_url: &str) -> Self {
87 Self {
88 sources,
89 http_client: Arc::new(crate::http::NativeHttpClient::new().unwrap()),
90 base_url: base_url.to_string(),
91 }
92 }
93
94 pub fn from_env_opt() -> Option<Self> {
97 std::env::var("ETHERSCAN_API_KEY").ok().map(|key| {
98 let sources = DataSources::new(key);
99 Self::new(sources)
100 })
101 }
102
103 pub async fn get_transactions(
105 &self,
106 address: &str,
107 chain: &str,
108 ) -> anyhow::Result<Vec<EtherscanTransaction>> {
109 match chain {
110 "ethereum" | "mainnet" => self.get_etherscan_transactions(address).await,
111 _ => anyhow::bail!(
112 "Chain '{}' not yet supported for transaction fetching",
113 chain
114 ),
115 }
116 }
117
118 async fn get_etherscan_transactions(
120 &self,
121 address: &str,
122 ) -> anyhow::Result<Vec<EtherscanTransaction>> {
123 let url = format!(
124 "{}/api?module=account&action=txlist&address={}&startblock=0&endblock=99999999&sort=asc&apikey={}",
125 self.base_url,
126 address,
127 self.sources.etherscan_key()
128 );
129
130 let response = self
131 .http_client
132 .send(crate::http::Request::get(&url))
133 .await
134 .map_err(|e| anyhow::anyhow!("{}", e))?;
135
136 let data: EtherscanResponse<Vec<EtherscanTransaction>> =
137 response.json().map_err(|e| anyhow::anyhow!("{}", e))?;
138
139 if data.status != "1" {
140 anyhow::bail!("Etherscan API error: {}", data.message);
141 }
142
143 Ok(data.result.unwrap_or_default())
144 }
145
146 pub async fn get_internal_transactions(
148 &self,
149 address: &str,
150 ) -> anyhow::Result<Vec<EtherscanTransaction>> {
151 let url = format!(
152 "{}/api?module=account&action=txlistinternal&address={}&startblock=0&endblock=99999999&sort=asc&apikey={}",
153 self.base_url,
154 address,
155 self.sources.etherscan_key()
156 );
157
158 let response = self
159 .http_client
160 .send(crate::http::Request::get(&url))
161 .await
162 .map_err(|e| anyhow::anyhow!("{}", e))?;
163
164 let data: EtherscanResponse<Vec<EtherscanTransaction>> =
165 response.json().map_err(|e| anyhow::anyhow!("{}", e))?;
166
167 Ok(data.result.unwrap_or_default())
168 }
169
170 pub async fn get_token_transfers(
172 &self,
173 address: &str,
174 ) -> anyhow::Result<Vec<EtherscanTransaction>> {
175 let url = format!(
176 "{}/api?module=account&action=tokentx&address={}&startblock=0&endblock=99999999&sort=asc&apikey={}",
177 self.base_url,
178 address,
179 self.sources.etherscan_key()
180 );
181
182 let response = self
183 .http_client
184 .send(crate::http::Request::get(&url))
185 .await
186 .map_err(|e| anyhow::anyhow!("{}", e))?;
187
188 let data: EtherscanResponse<Vec<EtherscanTransaction>> =
189 response.json().map_err(|e| anyhow::anyhow!("{}", e))?;
190
191 Ok(data.result.unwrap_or_default())
192 }
193
194 pub async fn trace_transaction(
196 &self,
197 tx_hash: &str,
198 _depth: u32,
199 ) -> anyhow::Result<TransactionTrace> {
200 let mut trace = TransactionTrace {
201 root_hash: tx_hash.to_string(),
202 hops: Vec::new(),
203 };
204
205 let url = format!(
207 "{}/api?module=proxy&action=eth_getTransactionByHash&txhash={}&apikey={}",
208 self.base_url,
209 tx_hash,
210 self.sources.etherscan_key()
211 );
212
213 let _response = self
214 .http_client
215 .send(crate::http::Request::get(&url))
216 .await
217 .map_err(|e| anyhow::anyhow!("{}", e))?;
218
219 trace.hops.push(TraceHop {
222 depth: 0,
223 address: "unknown".to_string(),
224 amount: "0".to_string(),
225 timestamp: 0,
226 });
227
228 Ok(trace)
229 }
230}
231
232#[derive(Debug, Clone)]
234pub struct TransactionTrace {
235 pub root_hash: String,
236 pub hops: Vec<TraceHop>,
237}
238
239#[derive(Debug, Clone)]
241pub struct TraceHop {
242 pub depth: u32,
243 pub address: String,
244 pub amount: String,
245 pub timestamp: u64,
246}
247
248pub fn analyze_patterns(transactions: &[EtherscanTransaction]) -> PatternAnalysis {
250 let mut analysis = PatternAnalysis {
251 total_transactions: transactions.len(),
252 velocity_score: 0.0,
253 structuring_detected: false,
254 round_number_pattern: false,
255 time_clustering: false,
256 unusual_hours: 0,
257 };
258
259 if transactions.is_empty() {
260 return analysis;
261 }
262
263 let timestamps: Vec<u64> = transactions
265 .iter()
266 .filter_map(|tx| tx.timestamp.parse::<u64>().ok())
267 .collect();
268
269 if timestamps.len() >= 2 {
270 let min_ts = *timestamps.iter().min().unwrap();
271 let max_ts = *timestamps.iter().max().unwrap();
272 let days = (max_ts - min_ts) / 86400;
273 if days > 0 {
274 analysis.velocity_score = transactions.len() as f32 / days as f32;
275 }
276 }
277
278 let amounts: Vec<f64> = transactions
280 .iter()
281 .filter_map(|tx| {
282 let value_eth = tx.value.parse::<f64>().ok()? / 1e18;
283 Some(value_eth)
284 })
285 .collect();
286
287 let structuring_count = amounts
288 .iter()
289 .filter(|&&amt| {
290 let fractional = amt.fract();
292 fractional > 0.9 && fractional < 1.0
293 })
294 .count();
295
296 analysis.structuring_detected = structuring_count > amounts.len() / 3;
297
298 let round_count = amounts
300 .iter()
301 .filter(|&&amt| amt.fract() == 0.0 || amt.fract() < 0.01)
302 .count();
303
304 analysis.round_number_pattern = round_count > amounts.len() / 2;
305
306 let unusual_hour_count = timestamps
308 .iter()
309 .filter(|&&ts| {
310 let hour = (ts % 86400) / 3600;
311 !(6..=23).contains(&hour)
312 })
313 .count();
314
315 analysis.unusual_hours = unusual_hour_count;
316
317 analysis
318}
319
320#[derive(Debug, Clone, Default)]
322pub struct PatternAnalysis {
323 pub total_transactions: usize,
324 pub velocity_score: f32,
325 pub structuring_detected: bool,
326 pub round_number_pattern: bool,
327 pub time_clustering: bool,
328 pub unusual_hours: usize,
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 #[test]
336 fn test_pattern_analysis_velocity() {
337 let txs = vec![
338 EtherscanTransaction {
339 block_number: "1".to_string(),
340 timestamp: "1609459200".to_string(), hash: "0x1".to_string(),
342 from: "0xa".to_string(),
343 to: "0xb".to_string(),
344 value: "1000000000000000000".to_string(),
345 gas: "21000".to_string(),
346 gas_price: "20000000000".to_string(),
347 is_error: "0".to_string(),
348 txreceipt_status: "1".to_string(),
349 input: "0x".to_string(),
350 contract_address: "".to_string(),
351 cumulative_gas_used: "21000".to_string(),
352 gas_used: "21000".to_string(),
353 confirmations: "100".to_string(),
354 },
355 EtherscanTransaction {
356 block_number: "2".to_string(),
357 timestamp: "1609545600".to_string(), hash: "0x2".to_string(),
359 from: "0xb".to_string(),
360 to: "0xc".to_string(),
361 value: "2000000000000000000".to_string(),
362 gas: "21000".to_string(),
363 gas_price: "20000000000".to_string(),
364 is_error: "0".to_string(),
365 txreceipt_status: "1".to_string(),
366 input: "0x".to_string(),
367 contract_address: "".to_string(),
368 cumulative_gas_used: "21000".to_string(),
369 gas_used: "21000".to_string(),
370 confirmations: "100".to_string(),
371 },
372 ];
373
374 let analysis = analyze_patterns(&txs);
375 assert_eq!(analysis.total_transactions, 2);
376 assert_eq!(analysis.velocity_score, 2.0); }
378
379 #[test]
380 fn test_pattern_analysis_empty() {
381 let analysis = analyze_patterns(&[]);
382 assert_eq!(analysis.total_transactions, 0);
383 assert_eq!(analysis.velocity_score, 0.0);
384 assert!(!analysis.structuring_detected);
385 assert!(!analysis.round_number_pattern);
386 assert!(!analysis.time_clustering);
387 assert_eq!(analysis.unusual_hours, 0);
388 }
389
390 #[test]
391 fn test_pattern_analysis_structuring_detection() {
392 let txs = vec![
394 create_test_tx("1609459200", "0.99"),
395 create_test_tx("1609459300", "0.95"),
396 create_test_tx("1609459400", "0.98"),
397 ];
398
399 let analysis = analyze_patterns(&txs);
400 assert!(analysis.structuring_detected);
401 }
402
403 #[test]
404 fn test_pattern_analysis_round_numbers() {
405 let txs = vec![
407 create_test_tx("1609459200", "1.0"),
408 create_test_tx("1609459300", "2.0"),
409 create_test_tx("1609459400", "5.0"),
410 ];
411
412 let analysis = analyze_patterns(&txs);
413 assert!(analysis.round_number_pattern);
414 }
415
416 #[test]
417 fn test_pattern_analysis_unusual_hours() {
418 let txs = vec![EtherscanTransaction {
422 block_number: "1".to_string(),
423 timestamp: "1609470000".to_string(),
424 hash: "0x1".to_string(),
425 from: "0xa".to_string(),
426 to: "0xb".to_string(),
427 value: "1000000000000000000".to_string(),
428 gas: "21000".to_string(),
429 gas_price: "20000000000".to_string(),
430 is_error: "0".to_string(),
431 txreceipt_status: "1".to_string(),
432 input: "0x".to_string(),
433 contract_address: "".to_string(),
434 cumulative_gas_used: "21000".to_string(),
435 gas_used: "21000".to_string(),
436 confirmations: "100".to_string(),
437 }];
438
439 let analysis = analyze_patterns(&txs);
440 assert!(analysis.unusual_hours > 0);
441 }
442
443 #[test]
444 fn test_data_sources_creation() {
445 let sources = DataSources::new("test_api_key".to_string());
446 assert_eq!(sources.etherscan_key(), "test_api_key");
447 }
448
449 #[test]
450 fn test_pattern_analysis_high_velocity() {
451 let mut txs = Vec::new();
453 let base_time = 1609459200u64; for i in 0..100 {
456 txs.push(EtherscanTransaction {
458 block_number: i.to_string(),
459 timestamp: (base_time + i * 1800).to_string(),
460 hash: format!("0x{}", i),
461 from: "0xa".to_string(),
462 to: "0xb".to_string(),
463 value: "1000000000000000000".to_string(),
464 gas: "21000".to_string(),
465 gas_price: "20000000000".to_string(),
466 is_error: "0".to_string(),
467 txreceipt_status: "1".to_string(),
468 input: "0x".to_string(),
469 contract_address: "".to_string(),
470 cumulative_gas_used: "21000".to_string(),
471 gas_used: "21000".to_string(),
472 confirmations: "100".to_string(),
473 });
474 }
475
476 let analysis = analyze_patterns(&txs);
477 assert!(analysis.velocity_score > 40.0); assert_eq!(analysis.total_transactions, 100);
480 }
481
482 #[test]
483 fn test_pattern_analysis_failed_transactions() {
484 let txs = vec![EtherscanTransaction {
485 block_number: "1".to_string(),
486 timestamp: "1609459200".to_string(),
487 hash: "0x1".to_string(),
488 from: "0xa".to_string(),
489 to: "0xb".to_string(),
490 value: "1000000000000000000".to_string(),
491 gas: "21000".to_string(),
492 gas_price: "20000000000".to_string(),
493 is_error: "1".to_string(), txreceipt_status: "0".to_string(),
495 input: "0x".to_string(),
496 contract_address: "".to_string(),
497 cumulative_gas_used: "21000".to_string(),
498 gas_used: "21000".to_string(),
499 confirmations: "100".to_string(),
500 }];
501
502 let analysis = analyze_patterns(&txs);
503 assert_eq!(analysis.total_transactions, 1);
504 }
505
506 fn create_test_tx(timestamp: &str, value_eth: &str) -> EtherscanTransaction {
508 let value_wei = (value_eth.parse::<f64>().unwrap() * 1e18) as u64;
509 EtherscanTransaction {
510 block_number: "1".to_string(),
511 timestamp: timestamp.to_string(),
512 hash: "0x1".to_string(),
513 from: "0xa".to_string(),
514 to: "0xb".to_string(),
515 value: value_wei.to_string(),
516 gas: "21000".to_string(),
517 gas_price: "20000000000".to_string(),
518 is_error: "0".to_string(),
519 txreceipt_status: "1".to_string(),
520 input: "0x".to_string(),
521 contract_address: "".to_string(),
522 cumulative_gas_used: "21000".to_string(),
523 gas_used: "21000".to_string(),
524 confirmations: "100".to_string(),
525 }
526 }
527
528 #[test]
529 fn test_data_sources_clone() {
530 let sources = DataSources::new("key1".to_string());
531 let cloned = sources.clone();
532 assert_eq!(cloned.etherscan_key(), "key1");
533 }
534
535 #[test]
536 fn test_data_sources_debug() {
537 let sources = DataSources::new("secret_key".to_string());
538 let debug = format!("{:?}", sources);
539 assert!(debug.contains("DataSources"));
540 }
541
542 #[test]
543 fn test_blockchain_data_client_creation() {
544 let sources = DataSources::new("test_key".to_string());
545 let client = BlockchainDataClient::new(sources);
546 let debug = format!("{:?}", client);
547 assert!(debug.contains("BlockchainDataClient"));
548 assert!(debug.contains("<dyn HttpClient>"));
549 }
550
551 #[test]
552 fn test_etherscan_response_deserialization() {
553 let json = r#"{"status":"1","message":"OK","result":null}"#;
554 let response: EtherscanResponse<Vec<EtherscanTransaction>> =
555 serde_json::from_str(json).unwrap();
556 assert_eq!(response.status, "1");
557 assert_eq!(response.message, "OK");
558 assert!(response.result.is_none());
559 }
560
561 #[test]
562 fn test_etherscan_response_with_result() {
563 let json = r#"{"status":"1","message":"OK","result":[]}"#;
564 let response: EtherscanResponse<Vec<EtherscanTransaction>> =
565 serde_json::from_str(json).unwrap();
566 assert_eq!(response.status, "1");
567 assert!(response.result.is_some());
568 assert!(response.result.unwrap().is_empty());
569 }
570
571 #[test]
572 fn test_transaction_trace_creation() {
573 let trace = TransactionTrace {
574 root_hash: "0xabc".to_string(),
575 hops: vec![
576 TraceHop {
577 depth: 0,
578 address: "0x111".to_string(),
579 amount: "1.5".to_string(),
580 timestamp: 1700000000,
581 },
582 TraceHop {
583 depth: 1,
584 address: "0x222".to_string(),
585 amount: "0.8".to_string(),
586 timestamp: 1700003600,
587 },
588 ],
589 };
590 assert_eq!(trace.root_hash, "0xabc");
591 assert_eq!(trace.hops.len(), 2);
592 assert_eq!(trace.hops[0].depth, 0);
593 assert_eq!(trace.hops[1].address, "0x222");
594 }
595
596 #[test]
597 fn test_trace_hop_debug_and_clone() {
598 let hop = TraceHop {
599 depth: 2,
600 address: "0x333".to_string(),
601 amount: "3.14".to_string(),
602 timestamp: 1700000000,
603 };
604 let cloned = hop.clone();
605 assert_eq!(cloned.depth, 2);
606 let debug = format!("{:?}", hop);
607 assert!(debug.contains("TraceHop"));
608 }
609
610 #[test]
611 fn test_pattern_analysis_debug_and_clone() {
612 let analysis = analyze_patterns(&[]);
613 let cloned = analysis.clone();
614 assert_eq!(cloned.total_transactions, 0);
615 let debug = format!("{:?}", analysis);
616 assert!(debug.contains("PatternAnalysis"));
617 }
618
619 #[test]
620 fn test_pattern_analysis_mixed_patterns() {
621 let txs = vec![
623 create_test_tx("1609459200", "1.0"), create_test_tx("1609459300", "2.345"), create_test_tx("1609459400", "5.0"), create_test_tx("1609459500", "0.123"), create_test_tx("1609459600", "7.891"), ];
629 let analysis = analyze_patterns(&txs);
630 assert!(!analysis.round_number_pattern); }
632
633 #[test]
634 fn test_pattern_analysis_all_unusual_hours() {
635 let txs = vec![
639 create_test_tx("1609466400", "1.0"), create_test_tx("1609470000", "2.0"), create_test_tx("1609473600", "3.0"), ];
643 let analysis = analyze_patterns(&txs);
644 assert_eq!(analysis.unusual_hours, 3);
645 }
646
647 #[test]
648 fn test_etherscan_transaction_clone_and_debug() {
649 let tx = create_test_tx("1609459200", "1.0");
650 let cloned = tx.clone();
651 assert_eq!(cloned.hash, "0x1");
652 let debug = format!("{:?}", tx);
653 assert!(debug.contains("EtherscanTransaction"));
654 }
655
656 #[tokio::test]
657 async fn test_get_transactions_unsupported_chain() {
658 let sources = DataSources::new("test_key".to_string());
659 let client = BlockchainDataClient::new(sources);
660 let result = client.get_transactions("0xabc", "polygon").await;
661 assert!(result.is_err());
662 assert!(
663 result
664 .unwrap_err()
665 .to_string()
666 .contains("not yet supported")
667 );
668 }
669
670 #[test]
671 fn test_data_sources_etherscan_key() {
672 let sources = DataSources::new("my_key_123".to_string());
673 assert_eq!(sources.etherscan_key(), "my_key_123");
674 }
675
676 #[test]
677 fn test_pattern_analysis_default() {
678 let analysis = PatternAnalysis::default();
679 assert_eq!(analysis.total_transactions, 0);
680 assert_eq!(analysis.velocity_score, 0.0);
681 assert!(!analysis.structuring_detected);
682 assert!(!analysis.round_number_pattern);
683 assert_eq!(analysis.unusual_hours, 0);
684 }
685
686 fn mock_etherscan_tx_response(txs: &[EtherscanTransaction]) -> String {
687 let result_json = serde_json::to_string(txs).unwrap();
688 format!(
689 r#"{{"status":"1","message":"OK","result":{}}}"#,
690 result_json
691 )
692 }
693
694 #[tokio::test]
695 async fn test_get_etherscan_transactions_success() {
696 let mut server = mockito::Server::new_async().await;
697 let tx = create_test_tx("1700000000", "1.0");
698 let body = mock_etherscan_tx_response(&[tx]);
699 let _mock = server
700 .mock("GET", mockito::Matcher::Any)
701 .with_status(200)
702 .with_header("content-type", "application/json")
703 .with_body(&body)
704 .create_async()
705 .await;
706
707 let sources = DataSources::new("test_key".to_string());
708 let client = BlockchainDataClient::with_base_url(sources, &server.url());
709 let txs = client.get_transactions("0xabc", "ethereum").await.unwrap();
710 assert_eq!(txs.len(), 1);
711 assert_eq!(txs[0].hash, "0x1");
712 }
713
714 #[tokio::test]
715 async fn test_get_etherscan_transactions_api_error() {
716 let mut server = mockito::Server::new_async().await;
717 let _mock = server
718 .mock("GET", mockito::Matcher::Any)
719 .with_status(200)
720 .with_header("content-type", "application/json")
721 .with_body(r#"{"status":"0","message":"NOTOK","result":null}"#)
722 .create_async()
723 .await;
724
725 let sources = DataSources::new("test_key".to_string());
726 let client = BlockchainDataClient::with_base_url(sources, &server.url());
727 let result = client.get_transactions("0xabc", "ethereum").await;
728 assert!(result.is_err());
729 assert!(
730 result
731 .unwrap_err()
732 .to_string()
733 .contains("Etherscan API error")
734 );
735 }
736
737 #[tokio::test]
738 async fn test_get_internal_transactions() {
739 let mut server = mockito::Server::new_async().await;
740 let tx = create_test_tx("1700000000", "0.5");
741 let body = mock_etherscan_tx_response(&[tx]);
742 let _mock = server
743 .mock("GET", mockito::Matcher::Any)
744 .with_status(200)
745 .with_header("content-type", "application/json")
746 .with_body(&body)
747 .create_async()
748 .await;
749
750 let sources = DataSources::new("test_key".to_string());
751 let client = BlockchainDataClient::with_base_url(sources, &server.url());
752 let txs = client.get_internal_transactions("0xabc").await.unwrap();
753 assert_eq!(txs.len(), 1);
754 }
755
756 #[tokio::test]
757 async fn test_get_token_transfers() {
758 let mut server = mockito::Server::new_async().await;
759 let body = mock_etherscan_tx_response(&[]);
760 let _mock = server
761 .mock("GET", mockito::Matcher::Any)
762 .with_status(200)
763 .with_header("content-type", "application/json")
764 .with_body(&body)
765 .create_async()
766 .await;
767
768 let sources = DataSources::new("test_key".to_string());
769 let client = BlockchainDataClient::with_base_url(sources, &server.url());
770 let txs = client.get_token_transfers("0xabc").await.unwrap();
771 assert!(txs.is_empty());
772 }
773
774 #[tokio::test]
775 async fn test_trace_transaction() {
776 let mut server = mockito::Server::new_async().await;
777 let _mock = server
778 .mock("GET", mockito::Matcher::Any)
779 .with_status(200)
780 .with_header("content-type", "application/json")
781 .with_body(r#"{"jsonrpc":"2.0","result":{"hash":"0xabc"}}"#)
782 .create_async()
783 .await;
784
785 let sources = DataSources::new("test_key".to_string());
786 let client = BlockchainDataClient::with_base_url(sources, &server.url());
787 let trace = client.trace_transaction("0xabc123", 3).await.unwrap();
788 assert_eq!(trace.root_hash, "0xabc123");
789 assert_eq!(trace.hops.len(), 1);
790 assert_eq!(trace.hops[0].depth, 0);
791 }
792
793 #[tokio::test]
794 async fn test_get_transactions_mainnet_alias() {
795 let mut server = mockito::Server::new_async().await;
796 let body = mock_etherscan_tx_response(&[]);
797 let _mock = server
798 .mock("GET", mockito::Matcher::Any)
799 .with_status(200)
800 .with_header("content-type", "application/json")
801 .with_body(&body)
802 .create_async()
803 .await;
804
805 let sources = DataSources::new("test_key".to_string());
806 let client = BlockchainDataClient::with_base_url(sources, &server.url());
807 let txs = client.get_transactions("0xabc", "mainnet").await.unwrap();
809 assert!(txs.is_empty());
810 }
811
812 #[tokio::test]
813 async fn test_get_etherscan_transactions_http_500() {
814 let mut server = mockito::Server::new_async().await;
815 let _mock = server
816 .mock("GET", mockito::Matcher::Any)
817 .with_status(500)
818 .with_body("Internal Server Error")
819 .create_async()
820 .await;
821
822 let sources = DataSources::new("test_key".to_string());
823 let client = BlockchainDataClient::with_base_url(sources, &server.url());
824 let result = client.get_transactions("0xabc", "ethereum").await;
825 assert!(result.is_err());
826 }
827
828 #[tokio::test]
829 async fn test_get_etherscan_transactions_malformed_json() {
830 let mut server = mockito::Server::new_async().await;
831 let _mock = server
832 .mock("GET", mockito::Matcher::Any)
833 .with_status(200)
834 .with_header("content-type", "application/json")
835 .with_body(r#"{"status":"1","message":"OK","result":not valid json}"#)
836 .create_async()
837 .await;
838
839 let sources = DataSources::new("test_key".to_string());
840 let client = BlockchainDataClient::with_base_url(sources, &server.url());
841 let result = client.get_transactions("0xabc", "ethereum").await;
842 assert!(result.is_err());
843 }
844
845 #[tokio::test]
846 async fn test_get_internal_transactions_null_result() {
847 let mut server = mockito::Server::new_async().await;
848 let _mock = server
849 .mock("GET", mockito::Matcher::Any)
850 .with_status(200)
851 .with_header("content-type", "application/json")
852 .with_body(r#"{"status":"1","message":"OK","result":null}"#)
853 .create_async()
854 .await;
855
856 let sources = DataSources::new("test_key".to_string());
857 let client = BlockchainDataClient::with_base_url(sources, &server.url());
858 let txs = client.get_internal_transactions("0xabc").await.unwrap();
859 assert!(txs.is_empty());
860 }
861
862 #[tokio::test]
863 async fn test_get_internal_transactions_api_error() {
864 let mut server = mockito::Server::new_async().await;
865 let _mock = server
866 .mock("GET", mockito::Matcher::Any)
867 .with_status(200)
868 .with_header("content-type", "application/json")
869 .with_body(r#"{"status":"0","message":"Rate limit exceeded","result":null}"#)
870 .create_async()
871 .await;
872
873 let sources = DataSources::new("test_key".to_string());
874 let client = BlockchainDataClient::with_base_url(sources, &server.url());
875 let txs = client.get_internal_transactions("0xabc").await.unwrap();
878 assert!(txs.is_empty());
879 }
880
881 #[test]
882 fn test_analyze_patterns_invalid_timestamps_skipped() {
883 let txs = vec![
885 EtherscanTransaction {
886 block_number: "1".to_string(),
887 timestamp: "invalid".to_string(),
888 hash: "0x1".to_string(),
889 from: "0xa".to_string(),
890 to: "0xb".to_string(),
891 value: "1000000000000000000".to_string(),
892 gas: "21000".to_string(),
893 gas_price: "20000000000".to_string(),
894 is_error: "0".to_string(),
895 txreceipt_status: "1".to_string(),
896 input: "0x".to_string(),
897 contract_address: "".to_string(),
898 cumulative_gas_used: "21000".to_string(),
899 gas_used: "21000".to_string(),
900 confirmations: "100".to_string(),
901 },
902 create_test_tx("1609459200", "1.0"),
903 ];
904 let analysis = analyze_patterns(&txs);
905 assert_eq!(analysis.total_transactions, 2);
906 assert_eq!(analysis.velocity_score, 0.0);
908 }
909
910 #[test]
911 fn test_analyze_patterns_single_transaction_no_velocity() {
912 let txs = vec![create_test_tx("1609459200", "1.0")];
913 let analysis = analyze_patterns(&txs);
914 assert_eq!(analysis.total_transactions, 1);
915 assert_eq!(analysis.velocity_score, 0.0);
916 }
917
918 #[test]
919 fn test_analyze_patterns_invalid_value_skipped() {
920 let txs = vec![EtherscanTransaction {
921 block_number: "1".to_string(),
922 timestamp: "1609459200".to_string(),
923 hash: "0x1".to_string(),
924 from: "0xa".to_string(),
925 to: "0xb".to_string(),
926 value: "not_a_number".to_string(),
927 gas: "21000".to_string(),
928 gas_price: "20000000000".to_string(),
929 is_error: "0".to_string(),
930 txreceipt_status: "1".to_string(),
931 input: "0x".to_string(),
932 contract_address: "".to_string(),
933 cumulative_gas_used: "21000".to_string(),
934 gas_used: "21000".to_string(),
935 confirmations: "100".to_string(),
936 }];
937 let analysis = analyze_patterns(&txs);
938 assert_eq!(analysis.total_transactions, 1);
939 assert!(!analysis.structuring_detected);
941 assert!(!analysis.round_number_pattern);
942 }
943}