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