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