Skip to main content

scope/compliance/
datasource.rs

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