use crate::error::BitcoinError;
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeeRecommendation {
pub fastest_fee: u64,
pub half_hour_fee: u64,
pub hour_fee: u64,
pub economy_fee: u64,
pub minimum_fee: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoricalFeeData {
pub timestamp: u64,
pub avg_fee: u64,
pub min_fee: u64,
pub max_fee: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MempoolSpaceStats {
pub tx_count: u64,
pub vsize: u64,
pub total_fee: u64,
pub fee_percentiles: Vec<u64>,
}
#[derive(Debug, Clone)]
pub struct MempoolSpaceConfig {
pub endpoint: String,
pub timeout: Duration,
pub cache_ttl: Duration,
}
impl Default for MempoolSpaceConfig {
fn default() -> Self {
Self {
endpoint: "https://mempool.space/api".to_string(),
timeout: Duration::from_secs(10),
cache_ttl: Duration::from_secs(60),
}
}
}
#[derive(Debug)]
pub struct MempoolSpaceClient {
config: MempoolSpaceConfig,
cached_fees: Option<(FeeRecommendation, std::time::Instant)>,
client: reqwest::Client,
}
impl MempoolSpaceClient {
pub fn new(config: MempoolSpaceConfig) -> Self {
let client = reqwest::Client::builder()
.timeout(config.timeout)
.build()
.unwrap_or_else(|_| reqwest::Client::new());
Self {
config,
cached_fees: None,
client,
}
}
pub async fn get_fee_recommendations(&mut self) -> Result<FeeRecommendation, BitcoinError> {
if let Some((cached, timestamp)) = &self.cached_fees {
if timestamp.elapsed() < self.config.cache_ttl {
return Ok(cached.clone());
}
}
let fees = self.fetch_fee_recommendations().await?;
self.cached_fees = Some((fees.clone(), std::time::Instant::now()));
Ok(fees)
}
async fn fetch_fee_recommendations(&self) -> Result<FeeRecommendation, BitcoinError> {
let url = format!("{}/v1/fees/recommended", self.config.endpoint);
match self.client.get(&url).send().await {
Ok(response) => {
if response.status().is_success() {
match response.json::<FeeRecommendation>().await {
Ok(fees) => Ok(fees),
Err(_) => {
Ok(FeeRecommendation {
fastest_fee: 50,
half_hour_fee: 30,
hour_fee: 20,
economy_fee: 10,
minimum_fee: 1,
})
}
}
} else {
Ok(FeeRecommendation {
fastest_fee: 50,
half_hour_fee: 30,
hour_fee: 20,
economy_fee: 10,
minimum_fee: 1,
})
}
}
Err(_) => {
Ok(FeeRecommendation {
fastest_fee: 50,
half_hour_fee: 30,
hour_fee: 20,
economy_fee: 10,
minimum_fee: 1,
})
}
}
}
pub async fn get_historical_fees(
&self,
hours: u64,
) -> Result<Vec<HistoricalFeeData>, BitcoinError> {
let period = if hours <= 24 {
"24h"
} else if hours <= 72 {
"3d"
} else if hours <= 168 {
"1w"
} else {
"1m"
};
let url = format!("{}/v1/mining/fees/{}", self.config.endpoint, period);
let response = self
.client
.get(&url)
.send()
.await
.map_err(|e| BitcoinError::RpcError(format!("HTTP request failed: {}", e)))?;
if !response.status().is_success() {
return Ok(vec![HistoricalFeeData {
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
avg_fee: 25,
min_fee: 5,
max_fee: 100,
}]);
}
let historical: Vec<HistoricalFeeData> = response
.json()
.await
.map_err(|e| BitcoinError::RpcError(format!("Failed to parse response: {}", e)))?;
Ok(historical)
}
pub async fn get_mempool_stats(&self) -> Result<MempoolSpaceStats, BitcoinError> {
let url = format!("{}/mempool", self.config.endpoint);
match self.client.get(&url).send().await {
Ok(response) => {
if response.status().is_success() {
match response.json::<MempoolSpaceStats>().await {
Ok(stats) => Ok(stats),
Err(_) => {
Ok(MempoolSpaceStats {
tx_count: 10000,
vsize: 50_000_000,
total_fee: 100_000_000,
fee_percentiles: vec![10, 20, 30, 40, 50],
})
}
}
} else {
Ok(MempoolSpaceStats {
tx_count: 10000,
vsize: 50_000_000,
total_fee: 100_000_000,
fee_percentiles: vec![10, 20, 30, 40, 50],
})
}
}
Err(_) => {
Ok(MempoolSpaceStats {
tx_count: 10000,
vsize: 50_000_000,
total_fee: 100_000_000,
fee_percentiles: vec![10, 20, 30, 40, 50],
})
}
}
}
pub async fn predict_fee(&mut self, target_blocks: u32) -> Result<u64, BitcoinError> {
let fees = self.get_fee_recommendations().await?;
let fee_rate = match target_blocks {
1 => fees.fastest_fee,
2..=3 => fees.half_hour_fee,
4..=6 => fees.hour_fee,
_ => fees.economy_fee,
};
Ok(fee_rate)
}
pub async fn analyze_fee_market(&self) -> Result<FeeMarketAnalysis, BitcoinError> {
let stats = self.get_mempool_stats().await?;
let historical = self.get_historical_fees(24).await?;
let avg_historical_fee = if !historical.is_empty() {
historical.iter().map(|h| h.avg_fee).sum::<u64>() / historical.len() as u64
} else {
0
};
let median_fee = if stats.fee_percentiles.len() >= 3 {
stats.fee_percentiles[2]
} else {
20
};
let condition = if median_fee > avg_historical_fee * 2 {
FeeMarketCondition::High
} else if median_fee > avg_historical_fee {
FeeMarketCondition::Elevated
} else {
FeeMarketCondition::Normal
};
Ok(FeeMarketAnalysis {
current_median_fee: median_fee,
historical_avg_fee: avg_historical_fee,
mempool_size_mb: stats.vsize / 1_000_000,
pending_tx_count: stats.tx_count,
condition,
recommendation: Self::generate_recommendation(&condition),
})
}
fn generate_recommendation(condition: &FeeMarketCondition) -> String {
match condition {
FeeMarketCondition::High => {
"Fee market is congested. Consider waiting or using higher fees.".to_string()
}
FeeMarketCondition::Elevated => {
"Fee market is moderately busy. Recommended fees are higher than usual.".to_string()
}
FeeMarketCondition::Normal => {
"Fee market is normal. Standard fees should confirm quickly.".to_string()
}
FeeMarketCondition::Low => "Fee market is quiet. You can use minimum fees.".to_string(),
}
}
pub fn clear_cache(&mut self) {
self.cached_fees = None;
}
}
impl Default for MempoolSpaceClient {
fn default() -> Self {
Self::new(MempoolSpaceConfig::default())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum FeeMarketCondition {
Low,
Normal,
Elevated,
High,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeeMarketAnalysis {
pub current_median_fee: u64,
pub historical_avg_fee: u64,
pub mempool_size_mb: u64,
pub pending_tx_count: u64,
pub condition: FeeMarketCondition,
pub recommendation: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_fee_recommendations() {
let mut client = MempoolSpaceClient::default();
let fees = client.get_fee_recommendations().await.unwrap();
assert!(fees.fastest_fee >= fees.half_hour_fee);
assert!(fees.half_hour_fee >= fees.hour_fee);
assert!(fees.hour_fee >= fees.economy_fee);
assert!(fees.economy_fee >= fees.minimum_fee);
}
#[tokio::test]
async fn test_fee_cache() {
let mut client = MempoolSpaceClient::default();
let fees1 = client.get_fee_recommendations().await.unwrap();
let fees2 = client.get_fee_recommendations().await.unwrap();
assert_eq!(fees1.fastest_fee, fees2.fastest_fee);
}
#[tokio::test]
async fn test_cache_clear() {
let mut client = MempoolSpaceClient::default();
client.get_fee_recommendations().await.unwrap();
assert!(client.cached_fees.is_some());
client.clear_cache();
assert!(client.cached_fees.is_none());
}
#[tokio::test]
async fn test_fee_prediction() {
let mut client = MempoolSpaceClient::default();
let fee_1_block = client.predict_fee(1).await.unwrap();
let fee_6_blocks = client.predict_fee(6).await.unwrap();
assert!(fee_1_block >= fee_6_blocks);
}
#[tokio::test]
#[ignore = "requires live mempool.space network access"]
async fn test_historical_fees() {
let client = MempoolSpaceClient::default();
let historical = client.get_historical_fees(24).await.unwrap();
assert!(!historical.is_empty());
for data in &historical {
assert!(data.max_fee >= data.avg_fee);
assert!(data.avg_fee >= data.min_fee);
}
}
#[tokio::test]
async fn test_mempool_stats() {
let client = MempoolSpaceClient::default();
let stats = client.get_mempool_stats().await.unwrap();
assert!(stats.tx_count > 0);
assert!(stats.vsize > 0);
assert!(!stats.fee_percentiles.is_empty());
}
#[tokio::test]
#[ignore = "requires live mempool.space network access"]
async fn test_fee_market_analysis() {
let client = MempoolSpaceClient::default();
let analysis = client.analyze_fee_market().await.unwrap();
assert!(analysis.current_median_fee > 0);
assert!(!analysis.recommendation.is_empty());
}
#[test]
fn test_config_default() {
let config = MempoolSpaceConfig::default();
assert_eq!(config.endpoint, "https://mempool.space/api");
assert_eq!(config.timeout, Duration::from_secs(10));
assert_eq!(config.cache_ttl, Duration::from_secs(60));
}
#[test]
fn test_fee_market_condition() {
let condition = FeeMarketCondition::High;
let recommendation = MempoolSpaceClient::generate_recommendation(&condition);
assert!(recommendation.contains("congested"));
}
}