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
56pub struct BlockchainDataClient {
58 sources: DataSources,
59 http_client: reqwest::Client,
60 base_url: String,
61}
62
63impl std::fmt::Debug for BlockchainDataClient {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 f.debug_struct("BlockchainDataClient")
66 .field("sources", &self.sources)
67 .field("http_client", &"<reqwest::Client>")
68 .finish()
69 }
70}
71
72impl BlockchainDataClient {
73 pub fn new(sources: DataSources) -> Self {
75 Self {
76 sources,
77 http_client: reqwest::Client::new(),
78 base_url: ETHERSCAN_API_BASE.to_string(),
79 }
80 }
81
82 pub fn with_base_url(sources: DataSources, base_url: &str) -> Self {
84 Self {
85 sources,
86 http_client: reqwest::Client::new(),
87 base_url: base_url.to_string(),
88 }
89 }
90
91 pub async fn get_transactions(
93 &self,
94 address: &str,
95 chain: &str,
96 ) -> anyhow::Result<Vec<EtherscanTransaction>> {
97 match chain {
98 "ethereum" | "mainnet" => self.get_etherscan_transactions(address).await,
99 _ => anyhow::bail!(
100 "Chain '{}' not yet supported for transaction fetching",
101 chain
102 ),
103 }
104 }
105
106 async fn get_etherscan_transactions(
108 &self,
109 address: &str,
110 ) -> anyhow::Result<Vec<EtherscanTransaction>> {
111 let url = format!(
112 "{}/api?module=account&action=txlist&address={}&startblock=0&endblock=99999999&sort=asc&apikey={}",
113 self.base_url,
114 address,
115 self.sources.etherscan_key()
116 );
117
118 let response = self.http_client.get(&url).send().await?;
119
120 let data: EtherscanResponse<Vec<EtherscanTransaction>> = response.json().await?;
121
122 if data.status != "1" {
123 anyhow::bail!("Etherscan API error: {}", data.message);
124 }
125
126 Ok(data.result.unwrap_or_default())
127 }
128
129 pub async fn get_internal_transactions(
131 &self,
132 address: &str,
133 ) -> anyhow::Result<Vec<EtherscanTransaction>> {
134 let url = format!(
135 "{}/api?module=account&action=txlistinternal&address={}&startblock=0&endblock=99999999&sort=asc&apikey={}",
136 self.base_url,
137 address,
138 self.sources.etherscan_key()
139 );
140
141 let response = self.http_client.get(&url).send().await?;
142
143 let data: EtherscanResponse<Vec<EtherscanTransaction>> = response.json().await?;
144
145 Ok(data.result.unwrap_or_default())
146 }
147
148 pub async fn get_token_transfers(
150 &self,
151 address: &str,
152 ) -> anyhow::Result<Vec<EtherscanTransaction>> {
153 let url = format!(
154 "{}/api?module=account&action=tokentx&address={}&startblock=0&endblock=99999999&sort=asc&apikey={}",
155 self.base_url,
156 address,
157 self.sources.etherscan_key()
158 );
159
160 let response = self.http_client.get(&url).send().await?;
161
162 let data: EtherscanResponse<Vec<EtherscanTransaction>> = response.json().await?;
163
164 Ok(data.result.unwrap_or_default())
165 }
166
167 pub async fn trace_transaction(
169 &self,
170 tx_hash: &str,
171 _depth: u32,
172 ) -> anyhow::Result<TransactionTrace> {
173 let mut trace = TransactionTrace {
174 root_hash: tx_hash.to_string(),
175 hops: Vec::new(),
176 };
177
178 let url = format!(
180 "{}/api?module=proxy&action=eth_getTransactionByHash&txhash={}&apikey={}",
181 self.base_url,
182 tx_hash,
183 self.sources.etherscan_key()
184 );
185
186 let _response = self.http_client.get(&url).send().await?;
187
188 trace.hops.push(TraceHop {
191 depth: 0,
192 address: "unknown".to_string(),
193 amount: "0".to_string(),
194 timestamp: 0,
195 });
196
197 Ok(trace)
198 }
199}
200
201#[derive(Debug, Clone)]
203pub struct TransactionTrace {
204 pub root_hash: String,
205 pub hops: Vec<TraceHop>,
206}
207
208#[derive(Debug, Clone)]
210pub struct TraceHop {
211 pub depth: u32,
212 pub address: String,
213 pub amount: String,
214 pub timestamp: u64,
215}
216
217pub fn analyze_patterns(transactions: &[EtherscanTransaction]) -> PatternAnalysis {
219 let mut analysis = PatternAnalysis {
220 total_transactions: transactions.len(),
221 velocity_score: 0.0,
222 structuring_detected: false,
223 round_number_pattern: false,
224 time_clustering: false,
225 unusual_hours: 0,
226 };
227
228 if transactions.is_empty() {
229 return analysis;
230 }
231
232 let timestamps: Vec<u64> = transactions
234 .iter()
235 .filter_map(|tx| tx.timestamp.parse::<u64>().ok())
236 .collect();
237
238 if timestamps.len() >= 2 {
239 let min_ts = *timestamps.iter().min().unwrap();
240 let max_ts = *timestamps.iter().max().unwrap();
241 let days = (max_ts - min_ts) / 86400;
242 if days > 0 {
243 analysis.velocity_score = transactions.len() as f32 / days as f32;
244 }
245 }
246
247 let amounts: Vec<f64> = transactions
249 .iter()
250 .filter_map(|tx| {
251 let value_eth = tx.value.parse::<f64>().ok()? / 1e18;
252 Some(value_eth)
253 })
254 .collect();
255
256 let structuring_count = amounts
257 .iter()
258 .filter(|&&amt| {
259 let fractional = amt.fract();
261 fractional > 0.9 && fractional < 1.0
262 })
263 .count();
264
265 analysis.structuring_detected = structuring_count > amounts.len() / 3;
266
267 let round_count = amounts
269 .iter()
270 .filter(|&&amt| amt.fract() == 0.0 || amt.fract() < 0.01)
271 .count();
272
273 analysis.round_number_pattern = round_count > amounts.len() / 2;
274
275 let unusual_hour_count = timestamps
277 .iter()
278 .filter(|&&ts| {
279 let hour = (ts % 86400) / 3600;
280 !(6..=23).contains(&hour)
281 })
282 .count();
283
284 analysis.unusual_hours = unusual_hour_count;
285
286 analysis
287}
288
289#[derive(Debug, Clone, Default)]
291pub struct PatternAnalysis {
292 pub total_transactions: usize,
293 pub velocity_score: f32,
294 pub structuring_detected: bool,
295 pub round_number_pattern: bool,
296 pub time_clustering: bool,
297 pub unusual_hours: usize,
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 #[test]
305 fn test_pattern_analysis_velocity() {
306 let txs = vec![
307 EtherscanTransaction {
308 block_number: "1".to_string(),
309 timestamp: "1609459200".to_string(), hash: "0x1".to_string(),
311 from: "0xa".to_string(),
312 to: "0xb".to_string(),
313 value: "1000000000000000000".to_string(),
314 gas: "21000".to_string(),
315 gas_price: "20000000000".to_string(),
316 is_error: "0".to_string(),
317 txreceipt_status: "1".to_string(),
318 input: "0x".to_string(),
319 contract_address: "".to_string(),
320 cumulative_gas_used: "21000".to_string(),
321 gas_used: "21000".to_string(),
322 confirmations: "100".to_string(),
323 },
324 EtherscanTransaction {
325 block_number: "2".to_string(),
326 timestamp: "1609545600".to_string(), hash: "0x2".to_string(),
328 from: "0xb".to_string(),
329 to: "0xc".to_string(),
330 value: "2000000000000000000".to_string(),
331 gas: "21000".to_string(),
332 gas_price: "20000000000".to_string(),
333 is_error: "0".to_string(),
334 txreceipt_status: "1".to_string(),
335 input: "0x".to_string(),
336 contract_address: "".to_string(),
337 cumulative_gas_used: "21000".to_string(),
338 gas_used: "21000".to_string(),
339 confirmations: "100".to_string(),
340 },
341 ];
342
343 let analysis = analyze_patterns(&txs);
344 assert_eq!(analysis.total_transactions, 2);
345 assert_eq!(analysis.velocity_score, 2.0); }
347
348 #[test]
349 fn test_pattern_analysis_empty() {
350 let analysis = analyze_patterns(&[]);
351 assert_eq!(analysis.total_transactions, 0);
352 assert_eq!(analysis.velocity_score, 0.0);
353 assert!(!analysis.structuring_detected);
354 assert!(!analysis.round_number_pattern);
355 assert!(!analysis.time_clustering);
356 assert_eq!(analysis.unusual_hours, 0);
357 }
358
359 #[test]
360 fn test_pattern_analysis_structuring_detection() {
361 let txs = vec![
363 create_test_tx("1609459200", "0.99"),
364 create_test_tx("1609459300", "0.95"),
365 create_test_tx("1609459400", "0.98"),
366 ];
367
368 let analysis = analyze_patterns(&txs);
369 assert!(analysis.structuring_detected);
370 }
371
372 #[test]
373 fn test_pattern_analysis_round_numbers() {
374 let txs = vec![
376 create_test_tx("1609459200", "1.0"),
377 create_test_tx("1609459300", "2.0"),
378 create_test_tx("1609459400", "5.0"),
379 ];
380
381 let analysis = analyze_patterns(&txs);
382 assert!(analysis.round_number_pattern);
383 }
384
385 #[test]
386 fn test_pattern_analysis_unusual_hours() {
387 let txs = vec![EtherscanTransaction {
391 block_number: "1".to_string(),
392 timestamp: "1609470000".to_string(),
393 hash: "0x1".to_string(),
394 from: "0xa".to_string(),
395 to: "0xb".to_string(),
396 value: "1000000000000000000".to_string(),
397 gas: "21000".to_string(),
398 gas_price: "20000000000".to_string(),
399 is_error: "0".to_string(),
400 txreceipt_status: "1".to_string(),
401 input: "0x".to_string(),
402 contract_address: "".to_string(),
403 cumulative_gas_used: "21000".to_string(),
404 gas_used: "21000".to_string(),
405 confirmations: "100".to_string(),
406 }];
407
408 let analysis = analyze_patterns(&txs);
409 assert!(analysis.unusual_hours > 0);
410 }
411
412 #[test]
413 fn test_data_sources_creation() {
414 let sources = DataSources::new("test_api_key".to_string());
415 assert_eq!(sources.etherscan_key(), "test_api_key");
416 }
417
418 #[test]
419 fn test_pattern_analysis_high_velocity() {
420 let mut txs = Vec::new();
422 let base_time = 1609459200u64; for i in 0..100 {
425 txs.push(EtherscanTransaction {
427 block_number: i.to_string(),
428 timestamp: (base_time + i * 1800).to_string(),
429 hash: format!("0x{}", i),
430 from: "0xa".to_string(),
431 to: "0xb".to_string(),
432 value: "1000000000000000000".to_string(),
433 gas: "21000".to_string(),
434 gas_price: "20000000000".to_string(),
435 is_error: "0".to_string(),
436 txreceipt_status: "1".to_string(),
437 input: "0x".to_string(),
438 contract_address: "".to_string(),
439 cumulative_gas_used: "21000".to_string(),
440 gas_used: "21000".to_string(),
441 confirmations: "100".to_string(),
442 });
443 }
444
445 let analysis = analyze_patterns(&txs);
446 assert!(analysis.velocity_score > 40.0); assert_eq!(analysis.total_transactions, 100);
449 }
450
451 #[test]
452 fn test_pattern_analysis_failed_transactions() {
453 let txs = vec![EtherscanTransaction {
454 block_number: "1".to_string(),
455 timestamp: "1609459200".to_string(),
456 hash: "0x1".to_string(),
457 from: "0xa".to_string(),
458 to: "0xb".to_string(),
459 value: "1000000000000000000".to_string(),
460 gas: "21000".to_string(),
461 gas_price: "20000000000".to_string(),
462 is_error: "1".to_string(), txreceipt_status: "0".to_string(),
464 input: "0x".to_string(),
465 contract_address: "".to_string(),
466 cumulative_gas_used: "21000".to_string(),
467 gas_used: "21000".to_string(),
468 confirmations: "100".to_string(),
469 }];
470
471 let analysis = analyze_patterns(&txs);
472 assert_eq!(analysis.total_transactions, 1);
473 }
474
475 fn create_test_tx(timestamp: &str, value_eth: &str) -> EtherscanTransaction {
477 let value_wei = (value_eth.parse::<f64>().unwrap() * 1e18) as u64;
478 EtherscanTransaction {
479 block_number: "1".to_string(),
480 timestamp: timestamp.to_string(),
481 hash: "0x1".to_string(),
482 from: "0xa".to_string(),
483 to: "0xb".to_string(),
484 value: value_wei.to_string(),
485 gas: "21000".to_string(),
486 gas_price: "20000000000".to_string(),
487 is_error: "0".to_string(),
488 txreceipt_status: "1".to_string(),
489 input: "0x".to_string(),
490 contract_address: "".to_string(),
491 cumulative_gas_used: "21000".to_string(),
492 gas_used: "21000".to_string(),
493 confirmations: "100".to_string(),
494 }
495 }
496
497 #[test]
498 fn test_data_sources_clone() {
499 let sources = DataSources::new("key1".to_string());
500 let cloned = sources.clone();
501 assert_eq!(cloned.etherscan_key(), "key1");
502 }
503
504 #[test]
505 fn test_data_sources_debug() {
506 let sources = DataSources::new("secret_key".to_string());
507 let debug = format!("{:?}", sources);
508 assert!(debug.contains("DataSources"));
509 }
510
511 #[test]
512 fn test_blockchain_data_client_creation() {
513 let sources = DataSources::new("test_key".to_string());
514 let client = BlockchainDataClient::new(sources);
515 let debug = format!("{:?}", client);
516 assert!(debug.contains("BlockchainDataClient"));
517 assert!(debug.contains("<reqwest::Client>"));
518 }
519
520 #[test]
521 fn test_etherscan_response_deserialization() {
522 let json = r#"{"status":"1","message":"OK","result":null}"#;
523 let response: EtherscanResponse<Vec<EtherscanTransaction>> =
524 serde_json::from_str(json).unwrap();
525 assert_eq!(response.status, "1");
526 assert_eq!(response.message, "OK");
527 assert!(response.result.is_none());
528 }
529
530 #[test]
531 fn test_etherscan_response_with_result() {
532 let json = r#"{"status":"1","message":"OK","result":[]}"#;
533 let response: EtherscanResponse<Vec<EtherscanTransaction>> =
534 serde_json::from_str(json).unwrap();
535 assert_eq!(response.status, "1");
536 assert!(response.result.is_some());
537 assert!(response.result.unwrap().is_empty());
538 }
539
540 #[test]
541 fn test_transaction_trace_creation() {
542 let trace = TransactionTrace {
543 root_hash: "0xabc".to_string(),
544 hops: vec![
545 TraceHop {
546 depth: 0,
547 address: "0x111".to_string(),
548 amount: "1.5".to_string(),
549 timestamp: 1700000000,
550 },
551 TraceHop {
552 depth: 1,
553 address: "0x222".to_string(),
554 amount: "0.8".to_string(),
555 timestamp: 1700003600,
556 },
557 ],
558 };
559 assert_eq!(trace.root_hash, "0xabc");
560 assert_eq!(trace.hops.len(), 2);
561 assert_eq!(trace.hops[0].depth, 0);
562 assert_eq!(trace.hops[1].address, "0x222");
563 }
564
565 #[test]
566 fn test_trace_hop_debug_and_clone() {
567 let hop = TraceHop {
568 depth: 2,
569 address: "0x333".to_string(),
570 amount: "3.14".to_string(),
571 timestamp: 1700000000,
572 };
573 let cloned = hop.clone();
574 assert_eq!(cloned.depth, 2);
575 let debug = format!("{:?}", hop);
576 assert!(debug.contains("TraceHop"));
577 }
578
579 #[test]
580 fn test_pattern_analysis_debug_and_clone() {
581 let analysis = analyze_patterns(&[]);
582 let cloned = analysis.clone();
583 assert_eq!(cloned.total_transactions, 0);
584 let debug = format!("{:?}", analysis);
585 assert!(debug.contains("PatternAnalysis"));
586 }
587
588 #[test]
589 fn test_pattern_analysis_mixed_patterns() {
590 let txs = vec![
592 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"), ];
598 let analysis = analyze_patterns(&txs);
599 assert!(!analysis.round_number_pattern); }
601
602 #[test]
603 fn test_pattern_analysis_all_unusual_hours() {
604 let txs = vec![
608 create_test_tx("1609466400", "1.0"), create_test_tx("1609470000", "2.0"), create_test_tx("1609473600", "3.0"), ];
612 let analysis = analyze_patterns(&txs);
613 assert_eq!(analysis.unusual_hours, 3);
614 }
615
616 #[test]
617 fn test_etherscan_transaction_clone_and_debug() {
618 let tx = create_test_tx("1609459200", "1.0");
619 let cloned = tx.clone();
620 assert_eq!(cloned.hash, "0x1");
621 let debug = format!("{:?}", tx);
622 assert!(debug.contains("EtherscanTransaction"));
623 }
624
625 #[tokio::test]
626 async fn test_get_transactions_unsupported_chain() {
627 let sources = DataSources::new("test_key".to_string());
628 let client = BlockchainDataClient::new(sources);
629 let result = client.get_transactions("0xabc", "polygon").await;
630 assert!(result.is_err());
631 assert!(
632 result
633 .unwrap_err()
634 .to_string()
635 .contains("not yet supported")
636 );
637 }
638
639 #[test]
640 fn test_data_sources_etherscan_key() {
641 let sources = DataSources::new("my_key_123".to_string());
642 assert_eq!(sources.etherscan_key(), "my_key_123");
643 }
644
645 #[test]
646 fn test_pattern_analysis_default() {
647 let analysis = PatternAnalysis::default();
648 assert_eq!(analysis.total_transactions, 0);
649 assert_eq!(analysis.velocity_score, 0.0);
650 assert!(!analysis.structuring_detected);
651 assert!(!analysis.round_number_pattern);
652 assert_eq!(analysis.unusual_hours, 0);
653 }
654
655 fn mock_etherscan_tx_response(txs: &[EtherscanTransaction]) -> String {
656 let result_json = serde_json::to_string(txs).unwrap();
657 format!(
658 r#"{{"status":"1","message":"OK","result":{}}}"#,
659 result_json
660 )
661 }
662
663 #[tokio::test]
664 async fn test_get_etherscan_transactions_success() {
665 let mut server = mockito::Server::new_async().await;
666 let tx = create_test_tx("1700000000", "1.0");
667 let body = mock_etherscan_tx_response(&[tx]);
668 let _mock = server
669 .mock("GET", mockito::Matcher::Any)
670 .with_status(200)
671 .with_header("content-type", "application/json")
672 .with_body(&body)
673 .create_async()
674 .await;
675
676 let sources = DataSources::new("test_key".to_string());
677 let client = BlockchainDataClient::with_base_url(sources, &server.url());
678 let txs = client.get_transactions("0xabc", "ethereum").await.unwrap();
679 assert_eq!(txs.len(), 1);
680 assert_eq!(txs[0].hash, "0x1");
681 }
682
683 #[tokio::test]
684 async fn test_get_etherscan_transactions_api_error() {
685 let mut server = mockito::Server::new_async().await;
686 let _mock = server
687 .mock("GET", mockito::Matcher::Any)
688 .with_status(200)
689 .with_header("content-type", "application/json")
690 .with_body(r#"{"status":"0","message":"NOTOK","result":null}"#)
691 .create_async()
692 .await;
693
694 let sources = DataSources::new("test_key".to_string());
695 let client = BlockchainDataClient::with_base_url(sources, &server.url());
696 let result = client.get_transactions("0xabc", "ethereum").await;
697 assert!(result.is_err());
698 assert!(
699 result
700 .unwrap_err()
701 .to_string()
702 .contains("Etherscan API error")
703 );
704 }
705
706 #[tokio::test]
707 async fn test_get_internal_transactions() {
708 let mut server = mockito::Server::new_async().await;
709 let tx = create_test_tx("1700000000", "0.5");
710 let body = mock_etherscan_tx_response(&[tx]);
711 let _mock = server
712 .mock("GET", mockito::Matcher::Any)
713 .with_status(200)
714 .with_header("content-type", "application/json")
715 .with_body(&body)
716 .create_async()
717 .await;
718
719 let sources = DataSources::new("test_key".to_string());
720 let client = BlockchainDataClient::with_base_url(sources, &server.url());
721 let txs = client.get_internal_transactions("0xabc").await.unwrap();
722 assert_eq!(txs.len(), 1);
723 }
724
725 #[tokio::test]
726 async fn test_get_token_transfers() {
727 let mut server = mockito::Server::new_async().await;
728 let body = mock_etherscan_tx_response(&[]);
729 let _mock = server
730 .mock("GET", mockito::Matcher::Any)
731 .with_status(200)
732 .with_header("content-type", "application/json")
733 .with_body(&body)
734 .create_async()
735 .await;
736
737 let sources = DataSources::new("test_key".to_string());
738 let client = BlockchainDataClient::with_base_url(sources, &server.url());
739 let txs = client.get_token_transfers("0xabc").await.unwrap();
740 assert!(txs.is_empty());
741 }
742
743 #[tokio::test]
744 async fn test_trace_transaction() {
745 let mut server = mockito::Server::new_async().await;
746 let _mock = server
747 .mock("GET", mockito::Matcher::Any)
748 .with_status(200)
749 .with_header("content-type", "application/json")
750 .with_body(r#"{"jsonrpc":"2.0","result":{"hash":"0xabc"}}"#)
751 .create_async()
752 .await;
753
754 let sources = DataSources::new("test_key".to_string());
755 let client = BlockchainDataClient::with_base_url(sources, &server.url());
756 let trace = client.trace_transaction("0xabc123", 3).await.unwrap();
757 assert_eq!(trace.root_hash, "0xabc123");
758 assert_eq!(trace.hops.len(), 1);
759 assert_eq!(trace.hops[0].depth, 0);
760 }
761
762 #[tokio::test]
763 async fn test_get_transactions_mainnet_alias() {
764 let mut server = mockito::Server::new_async().await;
765 let body = mock_etherscan_tx_response(&[]);
766 let _mock = server
767 .mock("GET", mockito::Matcher::Any)
768 .with_status(200)
769 .with_header("content-type", "application/json")
770 .with_body(&body)
771 .create_async()
772 .await;
773
774 let sources = DataSources::new("test_key".to_string());
775 let client = BlockchainDataClient::with_base_url(sources, &server.url());
776 let txs = client.get_transactions("0xabc", "mainnet").await.unwrap();
778 assert!(txs.is_empty());
779 }
780}