use std::sync::Arc;
use serde::Deserialize;
const ETHERSCAN_API_BASE: &str = "https://api.etherscan.io";
#[derive(Debug, Clone)]
pub struct DataSources {
etherscan_api_key: String,
}
impl DataSources {
pub fn new(etherscan_api_key: String) -> Self {
Self { etherscan_api_key }
}
pub fn etherscan_key(&self) -> &str {
&self.etherscan_api_key
}
}
#[derive(Debug, Clone, Default, Deserialize, serde::Serialize)]
#[serde(default)]
pub struct EtherscanTransaction {
#[serde(rename(deserialize = "blockNumber"))]
pub block_number: String,
#[serde(rename(deserialize = "timeStamp"))]
pub timestamp: String,
pub hash: String,
pub from: String,
pub to: String,
pub value: String,
pub gas: String,
#[serde(rename(deserialize = "gasPrice"))]
pub gas_price: String,
#[serde(rename(deserialize = "isError"))]
pub is_error: String,
pub txreceipt_status: String,
pub input: String,
#[serde(rename(deserialize = "contractAddress"))]
pub contract_address: String,
#[serde(rename(deserialize = "cumulativeGasUsed"))]
pub cumulative_gas_used: String,
#[serde(rename(deserialize = "gasUsed"))]
pub gas_used: String,
pub confirmations: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct EtherscanResponse<T> {
pub status: String,
pub message: String,
pub result: Option<T>,
}
#[derive(Clone)]
pub struct BlockchainDataClient {
sources: DataSources,
http_client: Arc<dyn crate::http::HttpClient>,
base_url: String,
}
impl std::fmt::Debug for BlockchainDataClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BlockchainDataClient")
.field("sources", &self.sources)
.field("http_client", &"<dyn HttpClient>")
.finish()
}
}
impl BlockchainDataClient {
#[deprecated(
since = "0.8.0",
note = "pass the transport: use `BlockchainDataClient::new_with_http`"
)]
pub fn new(sources: DataSources) -> Self {
Self::new_with_http(sources, crate::http::shared::fallback())
}
pub fn new_with_http(sources: DataSources, http: Arc<dyn crate::http::HttpClient>) -> Self {
Self {
sources,
http_client: http,
base_url: ETHERSCAN_API_BASE.to_string(),
}
}
pub fn with_base_url(sources: DataSources, base_url: &str) -> Self {
Self {
sources,
http_client: crate::http::shared::fallback(),
base_url: base_url.to_string(),
}
}
#[deprecated(
since = "0.8.0",
note = "pass the transport: use `BlockchainDataClient::from_env_opt_with_http`"
)]
pub fn from_env_opt() -> Option<Self> {
Self::from_env_opt_with_http(crate::http::shared::fallback())
}
pub fn from_env_opt_with_http(http: Arc<dyn crate::http::HttpClient>) -> Option<Self> {
super::etherscan::etherscan_key()
.map(|key| Self::new_with_http(DataSources::new(key), http))
}
pub async fn get_transactions(
&self,
address: &str,
chain: &str,
) -> anyhow::Result<Vec<EtherscanTransaction>> {
match chain {
"ethereum" | "mainnet" => self.get_etherscan_transactions(address).await,
_ => anyhow::bail!(
"Chain '{}' not yet supported for transaction fetching",
chain
),
}
}
async fn get_etherscan_transactions(
&self,
address: &str,
) -> anyhow::Result<Vec<EtherscanTransaction>> {
let url = format!(
"{}/v2/api?chainid=1&module=account&action=txlist&address={}&startblock=0&endblock=99999999&sort=asc&apikey={}",
self.base_url,
address,
self.sources.etherscan_key()
);
let response = self
.http_client
.send(crate::http::Request::get(&url))
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
let data: EtherscanResponse<Vec<EtherscanTransaction>> =
response.json().map_err(|e| anyhow::anyhow!("{}", e))?;
if data.status != "1" {
anyhow::bail!("Etherscan API error: {}", data.message);
}
Ok(data.result.unwrap_or_default())
}
pub async fn get_internal_transactions(
&self,
address: &str,
) -> anyhow::Result<Vec<EtherscanTransaction>> {
let url = format!(
"{}/v2/api?chainid=1&module=account&action=txlistinternal&address={}&startblock=0&endblock=99999999&sort=asc&apikey={}",
self.base_url,
address,
self.sources.etherscan_key()
);
let response = self
.http_client
.send(crate::http::Request::get(&url))
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
let data: EtherscanResponse<Vec<EtherscanTransaction>> =
response.json().map_err(|e| anyhow::anyhow!("{}", e))?;
Ok(data.result.unwrap_or_default())
}
pub async fn get_token_transfers(
&self,
address: &str,
) -> anyhow::Result<Vec<EtherscanTransaction>> {
let url = format!(
"{}/v2/api?chainid=1&module=account&action=tokentx&address={}&startblock=0&endblock=99999999&sort=asc&apikey={}",
self.base_url,
address,
self.sources.etherscan_key()
);
let response = self
.http_client
.send(crate::http::Request::get(&url))
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
let data: EtherscanResponse<Vec<EtherscanTransaction>> =
response.json().map_err(|e| anyhow::anyhow!("{}", e))?;
Ok(data.result.unwrap_or_default())
}
pub async fn trace_transaction(
&self,
tx_hash: &str,
_depth: u32,
) -> anyhow::Result<TransactionTrace> {
let mut trace = TransactionTrace {
root_hash: tx_hash.to_string(),
hops: Vec::new(),
};
let url = format!(
"{}/v2/api?chainid=1&module=proxy&action=eth_getTransactionByHash&txhash={}&apikey={}",
self.base_url,
tx_hash,
self.sources.etherscan_key()
);
let _response = self
.http_client
.send(crate::http::Request::get(&url))
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
trace.hops.push(TraceHop {
depth: 0,
address: "unknown".to_string(),
amount: "0".to_string(),
timestamp: 0,
});
Ok(trace)
}
}
#[derive(Debug, Clone)]
pub struct TransactionTrace {
pub root_hash: String,
pub hops: Vec<TraceHop>,
}
#[derive(Debug, Clone)]
pub struct TraceHop {
pub depth: u32,
pub address: String,
pub amount: String,
pub timestamp: u64,
}
pub fn analyze_patterns(transactions: &[EtherscanTransaction]) -> PatternAnalysis {
let mut analysis = PatternAnalysis {
total_transactions: transactions.len(),
velocity_score: 0.0,
structuring_detected: false,
round_number_pattern: false,
time_clustering: false,
unusual_hours: 0,
};
if transactions.is_empty() {
return analysis;
}
let timestamps: Vec<u64> = transactions
.iter()
.filter_map(|tx| tx.timestamp.parse::<u64>().ok())
.collect();
if timestamps.len() >= 2 {
let min_ts = *timestamps.iter().min().unwrap();
let max_ts = *timestamps.iter().max().unwrap();
let days = (max_ts - min_ts) / 86400;
if days > 0 {
analysis.velocity_score = transactions.len() as f32 / days as f32;
}
}
let amounts: Vec<f64> = transactions
.iter()
.filter_map(|tx| {
let value_eth = tx.value.parse::<f64>().ok()? / 1e18;
Some(value_eth)
})
.collect();
let structuring_count = amounts
.iter()
.filter(|&&amt| {
let fractional = amt.fract();
fractional > 0.9 && fractional < 1.0
})
.count();
analysis.structuring_detected = structuring_count > amounts.len() / 3;
let round_count = amounts
.iter()
.filter(|&&amt| amt.fract() == 0.0 || amt.fract() < 0.01)
.count();
analysis.round_number_pattern = round_count > amounts.len() / 2;
let unusual_hour_count = timestamps
.iter()
.filter(|&&ts| {
let hour = (ts % 86400) / 3600;
!(6..=23).contains(&hour)
})
.count();
analysis.unusual_hours = unusual_hour_count;
analysis
}
#[derive(Debug, Clone, Default)]
pub struct PatternAnalysis {
pub total_transactions: usize,
pub velocity_score: f32,
pub structuring_detected: bool,
pub round_number_pattern: bool,
pub time_clustering: bool,
pub unusual_hours: usize,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_an_etherscan_txlist_entry_parses() {
let json = r#"{"blockNumber":"18000000","timeStamp":"1700000000","hash":"0xabc",
"from":"0x1","to":"0x2","value":"1","gasPrice":"5","isError":"0",
"gasUsed":"21000","input":"0x"}"#;
let tx: EtherscanTransaction = serde_json::from_str(json).unwrap();
assert_eq!(tx.block_number, "18000000");
assert_eq!(tx.timestamp, "1700000000");
assert_eq!(tx.gas_used, "21000");
assert_eq!(tx.contract_address, "", "a left-out field is empty");
}
#[test]
fn test_pattern_analysis_velocity() {
let txs = vec![
EtherscanTransaction {
block_number: "1".to_string(),
timestamp: "1609459200".to_string(), hash: "0x1".to_string(),
from: "0xa".to_string(),
to: "0xb".to_string(),
value: "1000000000000000000".to_string(),
gas: "21000".to_string(),
gas_price: "20000000000".to_string(),
is_error: "0".to_string(),
txreceipt_status: "1".to_string(),
input: "0x".to_string(),
contract_address: "".to_string(),
cumulative_gas_used: "21000".to_string(),
gas_used: "21000".to_string(),
confirmations: "100".to_string(),
},
EtherscanTransaction {
block_number: "2".to_string(),
timestamp: "1609545600".to_string(), hash: "0x2".to_string(),
from: "0xb".to_string(),
to: "0xc".to_string(),
value: "2000000000000000000".to_string(),
gas: "21000".to_string(),
gas_price: "20000000000".to_string(),
is_error: "0".to_string(),
txreceipt_status: "1".to_string(),
input: "0x".to_string(),
contract_address: "".to_string(),
cumulative_gas_used: "21000".to_string(),
gas_used: "21000".to_string(),
confirmations: "100".to_string(),
},
];
let analysis = analyze_patterns(&txs);
assert_eq!(analysis.total_transactions, 2);
assert_eq!(analysis.velocity_score, 2.0); }
#[test]
fn test_pattern_analysis_empty() {
let analysis = analyze_patterns(&[]);
assert_eq!(analysis.total_transactions, 0);
assert_eq!(analysis.velocity_score, 0.0);
assert!(!analysis.structuring_detected);
assert!(!analysis.round_number_pattern);
assert!(!analysis.time_clustering);
assert_eq!(analysis.unusual_hours, 0);
}
#[test]
fn test_pattern_analysis_structuring_detection() {
let txs = vec![
create_test_tx("1609459200", "0.99"),
create_test_tx("1609459300", "0.95"),
create_test_tx("1609459400", "0.98"),
];
let analysis = analyze_patterns(&txs);
assert!(analysis.structuring_detected);
}
#[test]
fn test_pattern_analysis_round_numbers() {
let txs = vec![
create_test_tx("1609459200", "1.0"),
create_test_tx("1609459300", "2.0"),
create_test_tx("1609459400", "5.0"),
];
let analysis = analyze_patterns(&txs);
assert!(analysis.round_number_pattern);
}
#[test]
fn test_pattern_analysis_unusual_hours() {
let txs = vec![EtherscanTransaction {
block_number: "1".to_string(),
timestamp: "1609470000".to_string(),
hash: "0x1".to_string(),
from: "0xa".to_string(),
to: "0xb".to_string(),
value: "1000000000000000000".to_string(),
gas: "21000".to_string(),
gas_price: "20000000000".to_string(),
is_error: "0".to_string(),
txreceipt_status: "1".to_string(),
input: "0x".to_string(),
contract_address: "".to_string(),
cumulative_gas_used: "21000".to_string(),
gas_used: "21000".to_string(),
confirmations: "100".to_string(),
}];
let analysis = analyze_patterns(&txs);
assert!(analysis.unusual_hours > 0);
}
#[test]
fn test_data_sources_creation() {
let sources = DataSources::new("test_api_key".to_string());
assert_eq!(sources.etherscan_key(), "test_api_key");
}
#[test]
fn test_pattern_analysis_high_velocity() {
let mut txs = Vec::new();
let base_time = 1609459200u64;
for i in 0..100 {
txs.push(EtherscanTransaction {
block_number: i.to_string(),
timestamp: (base_time + i * 1800).to_string(),
hash: format!("0x{}", i),
from: "0xa".to_string(),
to: "0xb".to_string(),
value: "1000000000000000000".to_string(),
gas: "21000".to_string(),
gas_price: "20000000000".to_string(),
is_error: "0".to_string(),
txreceipt_status: "1".to_string(),
input: "0x".to_string(),
contract_address: "".to_string(),
cumulative_gas_used: "21000".to_string(),
gas_used: "21000".to_string(),
confirmations: "100".to_string(),
});
}
let analysis = analyze_patterns(&txs);
assert!(analysis.velocity_score > 40.0); assert_eq!(analysis.total_transactions, 100);
}
#[test]
fn test_pattern_analysis_failed_transactions() {
let txs = vec![EtherscanTransaction {
block_number: "1".to_string(),
timestamp: "1609459200".to_string(),
hash: "0x1".to_string(),
from: "0xa".to_string(),
to: "0xb".to_string(),
value: "1000000000000000000".to_string(),
gas: "21000".to_string(),
gas_price: "20000000000".to_string(),
is_error: "1".to_string(), txreceipt_status: "0".to_string(),
input: "0x".to_string(),
contract_address: "".to_string(),
cumulative_gas_used: "21000".to_string(),
gas_used: "21000".to_string(),
confirmations: "100".to_string(),
}];
let analysis = analyze_patterns(&txs);
assert_eq!(analysis.total_transactions, 1);
}
fn create_test_tx(timestamp: &str, value_eth: &str) -> EtherscanTransaction {
let value_wei = (value_eth.parse::<f64>().unwrap() * 1e18) as u64;
EtherscanTransaction {
block_number: "1".to_string(),
timestamp: timestamp.to_string(),
hash: "0x1".to_string(),
from: "0xa".to_string(),
to: "0xb".to_string(),
value: value_wei.to_string(),
gas: "21000".to_string(),
gas_price: "20000000000".to_string(),
is_error: "0".to_string(),
txreceipt_status: "1".to_string(),
input: "0x".to_string(),
contract_address: "".to_string(),
cumulative_gas_used: "21000".to_string(),
gas_used: "21000".to_string(),
confirmations: "100".to_string(),
}
}
#[test]
fn test_data_sources_clone() {
let sources = DataSources::new("key1".to_string());
let cloned = sources.clone();
assert_eq!(cloned.etherscan_key(), "key1");
}
#[test]
fn test_data_sources_debug() {
let sources = DataSources::new("secret_key".to_string());
let debug = format!("{:?}", sources);
assert!(debug.contains("DataSources"));
}
#[test]
fn test_blockchain_data_client_creation() {
let sources = DataSources::new("test_key".to_string());
let client = BlockchainDataClient::new_with_http(sources, crate::http::test_transport());
let debug = format!("{:?}", client);
assert!(debug.contains("BlockchainDataClient"));
assert!(debug.contains("<dyn HttpClient>"));
}
#[test]
fn test_etherscan_response_deserialization() {
let json = r#"{"status":"1","message":"OK","result":null}"#;
let response: EtherscanResponse<Vec<EtherscanTransaction>> =
serde_json::from_str(json).unwrap();
assert_eq!(response.status, "1");
assert_eq!(response.message, "OK");
assert!(response.result.is_none());
}
#[test]
fn test_etherscan_response_with_result() {
let json = r#"{"status":"1","message":"OK","result":[]}"#;
let response: EtherscanResponse<Vec<EtherscanTransaction>> =
serde_json::from_str(json).unwrap();
assert_eq!(response.status, "1");
assert!(response.result.is_some());
assert!(response.result.unwrap().is_empty());
}
#[test]
fn test_transaction_trace_creation() {
let trace = TransactionTrace {
root_hash: "0xabc".to_string(),
hops: vec![
TraceHop {
depth: 0,
address: "0x111".to_string(),
amount: "1.5".to_string(),
timestamp: 1700000000,
},
TraceHop {
depth: 1,
address: "0x222".to_string(),
amount: "0.8".to_string(),
timestamp: 1700003600,
},
],
};
assert_eq!(trace.root_hash, "0xabc");
assert_eq!(trace.hops.len(), 2);
assert_eq!(trace.hops[0].depth, 0);
assert_eq!(trace.hops[1].address, "0x222");
}
#[test]
fn test_trace_hop_debug_and_clone() {
let hop = TraceHop {
depth: 2,
address: "0x333".to_string(),
amount: "3.14".to_string(),
timestamp: 1700000000,
};
let cloned = hop.clone();
assert_eq!(cloned.depth, 2);
let debug = format!("{:?}", hop);
assert!(debug.contains("TraceHop"));
}
#[test]
fn test_pattern_analysis_debug_and_clone() {
let analysis = analyze_patterns(&[]);
let cloned = analysis.clone();
assert_eq!(cloned.total_transactions, 0);
let debug = format!("{:?}", analysis);
assert!(debug.contains("PatternAnalysis"));
}
#[test]
fn test_pattern_analysis_mixed_patterns() {
let txs = vec![
create_test_tx("1609459200", "1.0"), create_test_tx("1609459300", "2.345"), create_test_tx("1609459400", "5.0"), create_test_tx("1609459500", "0.123"), create_test_tx("1609459600", "7.891"), ];
let analysis = analyze_patterns(&txs);
assert!(!analysis.round_number_pattern); }
#[test]
fn test_pattern_analysis_all_unusual_hours() {
let txs = vec![
create_test_tx("1609466400", "1.0"), create_test_tx("1609470000", "2.0"), create_test_tx("1609473600", "3.0"), ];
let analysis = analyze_patterns(&txs);
assert_eq!(analysis.unusual_hours, 3);
}
#[test]
fn test_etherscan_transaction_clone_and_debug() {
let tx = create_test_tx("1609459200", "1.0");
let cloned = tx.clone();
assert_eq!(cloned.hash, "0x1");
let debug = format!("{:?}", tx);
assert!(debug.contains("EtherscanTransaction"));
}
#[tokio::test]
async fn test_get_transactions_unsupported_chain() {
let sources = DataSources::new("test_key".to_string());
let client = BlockchainDataClient::new_with_http(sources, crate::http::test_transport());
let result = client.get_transactions("0xabc", "polygon").await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("not yet supported")
);
}
#[test]
fn test_data_sources_etherscan_key() {
let sources = DataSources::new("my_key_123".to_string());
assert_eq!(sources.etherscan_key(), "my_key_123");
}
#[test]
fn test_pattern_analysis_default() {
let analysis = PatternAnalysis::default();
assert_eq!(analysis.total_transactions, 0);
assert_eq!(analysis.velocity_score, 0.0);
assert!(!analysis.structuring_detected);
assert!(!analysis.round_number_pattern);
assert_eq!(analysis.unusual_hours, 0);
}
fn mock_etherscan_tx_response(txs: &[EtherscanTransaction]) -> String {
let result_json = serde_json::to_string(txs).unwrap();
format!(
r#"{{"status":"1","message":"OK","result":{}}}"#,
result_json
)
}
#[tokio::test]
async fn test_requests_use_the_etherscan_v2_api_for_mainnet() {
let mut server = mockito::Server::new_async().await;
let body = mock_etherscan_tx_response(&[create_test_tx("1700000000", "1.0")]);
let _mock = server
.mock("GET", "/v2/api")
.match_query(mockito::Matcher::UrlEncoded("chainid".into(), "1".into()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(&body)
.create_async()
.await;
let sources = DataSources::new("test_key".to_string());
let client = BlockchainDataClient::with_base_url(sources, &server.url());
assert_eq!(
client
.get_transactions("0xabc", "ethereum")
.await
.unwrap()
.len(),
1
);
}
#[tokio::test]
async fn test_get_etherscan_transactions_success() {
let mut server = mockito::Server::new_async().await;
let tx = create_test_tx("1700000000", "1.0");
let body = mock_etherscan_tx_response(&[tx]);
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(&body)
.create_async()
.await;
let sources = DataSources::new("test_key".to_string());
let client = BlockchainDataClient::with_base_url(sources, &server.url());
let txs = client.get_transactions("0xabc", "ethereum").await.unwrap();
assert_eq!(txs.len(), 1);
assert_eq!(txs[0].hash, "0x1");
}
#[tokio::test]
async fn test_get_etherscan_transactions_api_error() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"0","message":"NOTOK","result":null}"#)
.create_async()
.await;
let sources = DataSources::new("test_key".to_string());
let client = BlockchainDataClient::with_base_url(sources, &server.url());
let result = client.get_transactions("0xabc", "ethereum").await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Etherscan API error")
);
}
#[tokio::test]
async fn test_get_internal_transactions() {
let mut server = mockito::Server::new_async().await;
let tx = create_test_tx("1700000000", "0.5");
let body = mock_etherscan_tx_response(&[tx]);
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(&body)
.create_async()
.await;
let sources = DataSources::new("test_key".to_string());
let client = BlockchainDataClient::with_base_url(sources, &server.url());
let txs = client.get_internal_transactions("0xabc").await.unwrap();
assert_eq!(txs.len(), 1);
}
#[tokio::test]
async fn test_get_token_transfers() {
let mut server = mockito::Server::new_async().await;
let body = mock_etherscan_tx_response(&[]);
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(&body)
.create_async()
.await;
let sources = DataSources::new("test_key".to_string());
let client = BlockchainDataClient::with_base_url(sources, &server.url());
let txs = client.get_token_transfers("0xabc").await.unwrap();
assert!(txs.is_empty());
}
#[tokio::test]
async fn test_trace_transaction() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","result":{"hash":"0xabc"}}"#)
.create_async()
.await;
let sources = DataSources::new("test_key".to_string());
let client = BlockchainDataClient::with_base_url(sources, &server.url());
let trace = client.trace_transaction("0xabc123", 3).await.unwrap();
assert_eq!(trace.root_hash, "0xabc123");
assert_eq!(trace.hops.len(), 1);
assert_eq!(trace.hops[0].depth, 0);
}
#[tokio::test]
async fn test_get_transactions_mainnet_alias() {
let mut server = mockito::Server::new_async().await;
let body = mock_etherscan_tx_response(&[]);
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(&body)
.create_async()
.await;
let sources = DataSources::new("test_key".to_string());
let client = BlockchainDataClient::with_base_url(sources, &server.url());
let txs = client.get_transactions("0xabc", "mainnet").await.unwrap();
assert!(txs.is_empty());
}
#[tokio::test]
async fn test_get_etherscan_transactions_http_500() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(500)
.with_body("Internal Server Error")
.create_async()
.await;
let sources = DataSources::new("test_key".to_string());
let client = BlockchainDataClient::with_base_url(sources, &server.url());
let result = client.get_transactions("0xabc", "ethereum").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_etherscan_transactions_malformed_json() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"1","message":"OK","result":not valid json}"#)
.create_async()
.await;
let sources = DataSources::new("test_key".to_string());
let client = BlockchainDataClient::with_base_url(sources, &server.url());
let result = client.get_transactions("0xabc", "ethereum").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_internal_transactions_null_result() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"1","message":"OK","result":null}"#)
.create_async()
.await;
let sources = DataSources::new("test_key".to_string());
let client = BlockchainDataClient::with_base_url(sources, &server.url());
let txs = client.get_internal_transactions("0xabc").await.unwrap();
assert!(txs.is_empty());
}
#[tokio::test]
async fn test_get_internal_transactions_api_error() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"0","message":"Rate limit exceeded","result":null}"#)
.create_async()
.await;
let sources = DataSources::new("test_key".to_string());
let client = BlockchainDataClient::with_base_url(sources, &server.url());
let txs = client.get_internal_transactions("0xabc").await.unwrap();
assert!(txs.is_empty());
}
#[test]
fn test_analyze_patterns_invalid_timestamps_skipped() {
let txs = vec![
EtherscanTransaction {
block_number: "1".to_string(),
timestamp: "invalid".to_string(),
hash: "0x1".to_string(),
from: "0xa".to_string(),
to: "0xb".to_string(),
value: "1000000000000000000".to_string(),
gas: "21000".to_string(),
gas_price: "20000000000".to_string(),
is_error: "0".to_string(),
txreceipt_status: "1".to_string(),
input: "0x".to_string(),
contract_address: "".to_string(),
cumulative_gas_used: "21000".to_string(),
gas_used: "21000".to_string(),
confirmations: "100".to_string(),
},
create_test_tx("1609459200", "1.0"),
];
let analysis = analyze_patterns(&txs);
assert_eq!(analysis.total_transactions, 2);
assert_eq!(analysis.velocity_score, 0.0);
}
#[test]
fn test_analyze_patterns_single_transaction_no_velocity() {
let txs = vec![create_test_tx("1609459200", "1.0")];
let analysis = analyze_patterns(&txs);
assert_eq!(analysis.total_transactions, 1);
assert_eq!(analysis.velocity_score, 0.0);
}
#[test]
fn test_analyze_patterns_invalid_value_skipped() {
let txs = vec![EtherscanTransaction {
block_number: "1".to_string(),
timestamp: "1609459200".to_string(),
hash: "0x1".to_string(),
from: "0xa".to_string(),
to: "0xb".to_string(),
value: "not_a_number".to_string(),
gas: "21000".to_string(),
gas_price: "20000000000".to_string(),
is_error: "0".to_string(),
txreceipt_status: "1".to_string(),
input: "0x".to_string(),
contract_address: "".to_string(),
cumulative_gas_used: "21000".to_string(),
gas_used: "21000".to_string(),
confirmations: "100".to_string(),
}];
let analysis = analyze_patterns(&txs);
assert_eq!(analysis.total_transactions, 1);
assert!(!analysis.structuring_detected);
assert!(!analysis.round_number_pattern);
}
}