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