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