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