use serde::{Deserialize, Serialize};
use crate::client::MempoolClient;
use crate::error::Result;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MiningPoolStats {
pub pool: PoolInfo,
#[serde(default)]
pub block_count: u64,
#[serde(default, rename = "estimatedHashrate")]
pub estimated_hashrate: f64,
#[serde(default)]
pub share: f64,
#[serde(default, rename = "avgFeerate")]
pub avg_fee_rate: f64,
#[serde(default, rename = "avgBlockSize")]
pub avg_block_size: u64,
#[serde(default, rename = "totalReward")]
pub total_reward: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PoolInfo {
#[serde(default)]
pub id: u32,
#[serde(default)]
pub name: String,
#[serde(default)]
pub slug: String,
#[serde(default)]
pub link: String,
}
impl MiningPoolStats {
pub fn share_percent(&self) -> f64 {
self.share * 100.0
}
pub fn total_reward_btc(&self) -> f64 {
self.total_reward as f64 / 100_000_000.0
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HashrateDistribution {
#[serde(default)]
pub pools: Vec<MiningPoolStats>,
#[serde(default, rename = "blockCount")]
pub block_count: u64,
#[serde(default, rename = "lastEstimatedHashrate")]
pub last_estimated_hashrate: f64,
}
impl HashrateDistribution {
pub fn get_pool(&self, name: &str) -> Option<&MiningPoolStats> {
self.pools.iter().find(|p| p.pool.name.eq_ignore_ascii_case(name))
}
pub fn top_pools(&self, n: usize) -> Vec<&MiningPoolStats> {
let mut sorted: Vec<_> = self.pools.iter().collect();
sorted.sort_by(|a, b| b.block_count.cmp(&a.block_count));
sorted.into_iter().take(n).collect()
}
pub fn total_hashrate(&self) -> f64 {
self.pools.iter().map(|p| p.estimated_hashrate).sum()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockRewardStats {
#[serde(default, rename = "avgReward")]
pub avg_reward: u64,
#[serde(default, rename = "avgFees")]
pub avg_fees: u64,
#[serde(default, rename = "avgSubsidy")]
pub avg_subsidy: u64,
#[serde(default, rename = "totalReward")]
pub total_reward: u64,
#[serde(default, rename = "totalFees")]
pub total_fees: u64,
#[serde(default, rename = "blockCount")]
pub block_count: u64,
}
impl BlockRewardStats {
pub fn avg_reward_btc(&self) -> f64 {
self.avg_reward as f64 / 100_000_000.0
}
pub fn avg_fees_btc(&self) -> f64 {
self.avg_fees as f64 / 100_000_000.0
}
pub fn fee_percentage(&self) -> f64 {
if self.avg_reward == 0 {
0.0
} else {
(self.avg_fees as f64 / self.avg_reward as f64) * 100.0
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DifficultyAdjustment {
#[serde(default, rename = "difficultyChange")]
pub difficulty_change: f64,
#[serde(default, rename = "estimatedRetargetDate")]
pub estimated_retarget_date: u64,
#[serde(default, rename = "remainingBlocks")]
pub remaining_blocks: u64,
#[serde(default, rename = "remainingTime")]
pub remaining_time: u64,
#[serde(default, rename = "previousRetarget")]
pub previous_retarget: f64,
#[serde(default, rename = "previousTime")]
pub previous_time: u64,
#[serde(default, rename = "nextRetargetHeight")]
pub next_retarget_height: u64,
#[serde(default, rename = "timeAvg")]
pub time_avg: u64,
#[serde(default, rename = "timeOffset")]
pub time_offset: i64,
}
impl DifficultyAdjustment {
pub fn difficulty_change_percent(&self) -> f64 {
self.difficulty_change
}
pub fn remaining_hours(&self) -> f64 {
self.remaining_time as f64 / 3600.0
}
pub fn remaining_days(&self) -> f64 {
self.remaining_time as f64 / 86400.0
}
pub fn will_increase(&self) -> bool {
self.difficulty_change > 0.0
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PoolBlock {
#[serde(default)]
pub height: u64,
#[serde(default)]
pub hash: String,
#[serde(default)]
pub timestamp: u64,
#[serde(default)]
pub size: u32,
#[serde(default)]
pub tx_count: u32,
#[serde(default)]
pub total_fees: u64,
#[serde(default)]
pub reward: u64,
}
impl PoolBlock {
pub fn reward_btc(&self) -> f64 {
self.reward as f64 / 100_000_000.0
}
pub fn fees_btc(&self) -> f64 {
self.total_fees as f64 / 100_000_000.0
}
}
impl MempoolClient {
pub async fn get_hashrate_distribution(&self, period: &str) -> Result<HashrateDistribution> {
self.get_mining(&format!("/v1/mining/hashrate/pools/{}", period)).await
}
pub async fn get_difficulty_adjustment(&self) -> Result<DifficultyAdjustment> {
self.get_mining("/v1/difficulty-adjustment").await
}
pub async fn get_mining_pool(&self, slug: &str) -> Result<MiningPoolStats> {
self.get_mining(&format!("/v1/mining/pool/{}", slug)).await
}
pub async fn get_pool_blocks(&self, slug: &str, block_height: Option<u64>) -> Result<Vec<PoolBlock>> {
let endpoint = match block_height {
Some(h) => format!("/v1/mining/pool/{}/blocks/{}", slug, h),
None => format!("/v1/mining/pool/{}/blocks", slug),
};
self.get_mining(&endpoint).await
}
pub async fn get_block_rewards(&self, period: &str) -> Result<BlockRewardStats> {
self.get_mining(&format!("/v1/mining/reward-stats/{}", period)).await
}
async fn get_mining<T: serde::de::DeserializeOwned>(&self, endpoint: &str) -> Result<T> {
let url = format!("{}{}", self.base_url(), endpoint);
let response = self.http_client().get(&url).send().await?;
let status = response.status();
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
return Err(crate::error::MempoolError::RateLimited);
}
if !status.is_success() {
let message = response.text().await.unwrap_or_default();
return Err(crate::error::MempoolError::ApiError {
status: status.as_u16(),
message,
});
}
response
.json()
.await
.map_err(|e| crate::error::MempoolError::ParseError(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mining_pool_stats() {
let stats = MiningPoolStats {
pool: PoolInfo {
id: 1,
name: "Foundry USA".to_string(),
slug: "foundryusa".to_string(),
link: "https://foundrydigital.com".to_string(),
},
block_count: 1000,
estimated_hashrate: 150.5,
share: 0.30,
avg_fee_rate: 25.5,
avg_block_size: 1500000,
total_reward: 625_000_000_000, };
assert_eq!(stats.share_percent(), 30.0);
assert_eq!(stats.total_reward_btc(), 6250.0);
}
#[test]
fn test_difficulty_adjustment() {
let adj = DifficultyAdjustment {
difficulty_change: 5.5,
estimated_retarget_date: 1234567890,
remaining_blocks: 500,
remaining_time: 86400 * 3, previous_retarget: 3.2,
previous_time: 1234000000,
next_retarget_height: 850000,
time_avg: 600,
time_offset: -30,
};
assert_eq!(adj.difficulty_change_percent(), 5.5);
assert!(adj.will_increase());
assert_eq!(adj.remaining_days(), 3.0);
}
#[test]
fn test_block_reward_stats() {
let stats = BlockRewardStats {
avg_reward: 650_000_000, avg_fees: 25_000_000, avg_subsidy: 625_000_000, total_reward: 65_000_000_000,
total_fees: 2_500_000_000,
block_count: 100,
};
assert_eq!(stats.avg_reward_btc(), 6.5);
assert_eq!(stats.avg_fees_btc(), 0.25);
assert!((stats.fee_percentage() - 3.846).abs() < 0.01);
}
#[test]
fn test_pool_block() {
let block = PoolBlock {
height: 800000,
hash: "abc123".to_string(),
timestamp: 1234567890,
size: 1500000,
tx_count: 3000,
total_fees: 25_000_000,
reward: 650_000_000,
};
assert_eq!(block.reward_btc(), 6.5);
assert_eq!(block.fees_btc(), 0.25);
}
}