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. Etherscan names its fields in
31/// camelCase (`blockNumber`, `timeStamp`, …); a field it leaves out (some
32/// endpoints omit a few) is empty.
33#[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/// Response from Etherscan API
61#[derive(Debug, Clone, Deserialize)]
62pub struct EtherscanResponse<T> {
63    pub status: String,
64    pub message: String,
65    pub result: Option<T>,
66}
67
68/// Client for fetching blockchain data
69#[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    /// Create new client with data sources
87    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    /// Create new client with a custom base URL (for testing)
96    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    /// A client for [`super::etherscan::etherscan_key`], or
105    /// `None` when there is no key (then use `RiskEngine::new()`).
106    pub fn from_env_opt() -> Option<Self> {
107        super::etherscan::etherscan_key().map(|key| Self::new(DataSources::new(key)))
108    }
109
110    /// Fetch transaction history for an address
111    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    /// Fetch from Etherscan API
126    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    /// Get internal transactions (contract calls)
154    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    /// Get ERC-20 token transfers
178    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    /// Trace transaction by following outputs
202    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        // Get the transaction
213        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        // For now, return basic trace
227        // Full implementation would recursively follow outputs
228        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/// Transaction trace result
240#[derive(Debug, Clone)]
241pub struct TransactionTrace {
242    pub root_hash: String,
243    pub hops: Vec<TraceHop>,
244}
245
246/// Individual hop in transaction trace
247#[derive(Debug, Clone)]
248pub struct TraceHop {
249    pub depth: u32,
250    pub address: String,
251    pub amount: String,
252    pub timestamp: u64,
253}
254
255/// Analyze transaction patterns
256pub 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    // Velocity: transactions per day
271    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    // Structuring: amounts just under round numbers
286    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            // Check if amount is just under a round number (e.g., 0.99, 1.99, etc.)
298            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    // Round numbers (indicates automation)
306    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    // Time analysis (unusual hours)
314    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/// Pattern analysis results
328#[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        // The live API names fields in camelCase. The struct used snake
345        // case, so every real response failed to parse.
346        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(), // 2021-01-01
362                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(), // 2021-01-02
379                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); // 2 transactions over 1 day
398    }
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        // Transactions just under 1 ETH (structuring pattern)
414        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        // Round number transactions
427        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        // Transaction at 3 AM (unusual hour)
440        // 1609459200 = 2021-01-01 00:00:00 UTC
441        // 1609470000 = 2021-01-01 03:00:00 UTC
442        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        // Many transactions spread over multiple days
473        let mut txs = Vec::new();
474        let base_time = 1609459200u64; // 2021-01-01 00:00:00 UTC
475
476        for i in 0..100 {
477            // Spread over 2 days (every ~30 minutes)
478            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        // 100 transactions over 2 days = 50 tx/day
499        assert!(analysis.velocity_score > 40.0); // High velocity
500        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(), // Failed transaction
515            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    // Helper function to create test transactions
528    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        // Mix of round numbers and non-round: 2 out of 5 rounds = not over half
643        let txs = vec![
644            create_test_tx("1609459200", "1.0"),   // round
645            create_test_tx("1609459300", "2.345"), // not round
646            create_test_tx("1609459400", "5.0"),   // round
647            create_test_tx("1609459500", "0.123"), // not round
648            create_test_tx("1609459600", "7.891"), // not round
649        ];
650        let analysis = analyze_patterns(&txs);
651        assert!(!analysis.round_number_pattern); // 2 out of 5 is not > half
652    }
653
654    #[test]
655    fn test_pattern_analysis_all_unusual_hours() {
656        // All transactions at 2 AM UTC (timestamp % 86400 / 3600 = 2)
657        // 1609459200 = 2021-01-01 00:00:00 UTC
658        // + 7200 = 2021-01-01 02:00:00 UTC
659        let txs = vec![
660            create_test_tx("1609466400", "1.0"), // 02:00 UTC
661            create_test_tx("1609470000", "2.0"), // 03:00 UTC
662            create_test_tx("1609473600", "3.0"), // 04:00 UTC
663        ];
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        // Etherscan rejects the V1 path ("deprecated V1 endpoint"), which
718        // left every compliance factor without data.
719        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        // "mainnet" should work as an alias for "ethereum"
856        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        // Note: get_internal_transactions does NOT check status != "1" unlike get_transactions
924        // It only unwraps result. So status 0 with null result returns empty vec.
925        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        // Transactions with invalid timestamps are filtered out by filter_map
932        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        // Only 1 valid timestamp, so velocity won't compute (needs 2+)
955        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        // amounts filter_map will yield None for unparseable value, so amounts is empty
988        assert!(!analysis.structuring_detected);
989        assert!(!analysis.round_number_pattern);
990    }
991}