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