1use serde::{Deserialize, Serialize};
7
8use crate::client::MempoolClient;
9use crate::error::Result;
10
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13pub struct MiningPoolStats {
14 pub pool: PoolInfo,
16 #[serde(default)]
18 pub block_count: u64,
19 #[serde(default, rename = "estimatedHashrate")]
21 pub estimated_hashrate: f64,
22 #[serde(default)]
24 pub share: f64,
25 #[serde(default, rename = "avgFeerate")]
27 pub avg_fee_rate: f64,
28 #[serde(default, rename = "avgBlockSize")]
30 pub avg_block_size: u64,
31 #[serde(default, rename = "totalReward")]
33 pub total_reward: u64,
34}
35
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38pub struct PoolInfo {
39 #[serde(default)]
41 pub id: u32,
42 #[serde(default)]
44 pub name: String,
45 #[serde(default)]
47 pub slug: String,
48 #[serde(default)]
50 pub link: String,
51}
52
53impl MiningPoolStats {
54 pub fn share_percent(&self) -> f64 {
56 self.share * 100.0
57 }
58
59 pub fn total_reward_btc(&self) -> f64 {
61 self.total_reward as f64 / 100_000_000.0
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67pub struct HashrateDistribution {
68 #[serde(default)]
70 pub pools: Vec<MiningPoolStats>,
71 #[serde(default, rename = "blockCount")]
73 pub block_count: u64,
74 #[serde(default, rename = "lastEstimatedHashrate")]
76 pub last_estimated_hashrate: f64,
77}
78
79impl HashrateDistribution {
80 pub fn get_pool(&self, name: &str) -> Option<&MiningPoolStats> {
82 self.pools.iter().find(|p| p.pool.name.eq_ignore_ascii_case(name))
83 }
84
85 pub fn top_pools(&self, n: usize) -> Vec<&MiningPoolStats> {
87 let mut sorted: Vec<_> = self.pools.iter().collect();
88 sorted.sort_by(|a, b| b.block_count.cmp(&a.block_count));
89 sorted.into_iter().take(n).collect()
90 }
91
92 pub fn total_hashrate(&self) -> f64 {
94 self.pools.iter().map(|p| p.estimated_hashrate).sum()
95 }
96}
97
98#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
100pub struct BlockRewardStats {
101 #[serde(default, rename = "avgReward")]
103 pub avg_reward: u64,
104 #[serde(default, rename = "avgFees")]
106 pub avg_fees: u64,
107 #[serde(default, rename = "avgSubsidy")]
109 pub avg_subsidy: u64,
110 #[serde(default, rename = "totalReward")]
112 pub total_reward: u64,
113 #[serde(default, rename = "totalFees")]
115 pub total_fees: u64,
116 #[serde(default, rename = "blockCount")]
118 pub block_count: u64,
119}
120
121impl BlockRewardStats {
122 pub fn avg_reward_btc(&self) -> f64 {
124 self.avg_reward as f64 / 100_000_000.0
125 }
126
127 pub fn avg_fees_btc(&self) -> f64 {
129 self.avg_fees as f64 / 100_000_000.0
130 }
131
132 pub fn fee_percentage(&self) -> f64 {
134 if self.avg_reward == 0 {
135 0.0
136 } else {
137 (self.avg_fees as f64 / self.avg_reward as f64) * 100.0
138 }
139 }
140}
141
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
144pub struct DifficultyAdjustment {
145 #[serde(default, rename = "difficultyChange")]
147 pub difficulty_change: f64,
148 #[serde(default, rename = "estimatedRetargetDate")]
150 pub estimated_retarget_date: u64,
151 #[serde(default, rename = "remainingBlocks")]
153 pub remaining_blocks: u64,
154 #[serde(default, rename = "remainingTime")]
156 pub remaining_time: u64,
157 #[serde(default, rename = "previousRetarget")]
159 pub previous_retarget: f64,
160 #[serde(default, rename = "previousTime")]
162 pub previous_time: u64,
163 #[serde(default, rename = "nextRetargetHeight")]
165 pub next_retarget_height: u64,
166 #[serde(default, rename = "timeAvg")]
168 pub time_avg: u64,
169 #[serde(default, rename = "timeOffset")]
171 pub time_offset: i64,
172}
173
174impl DifficultyAdjustment {
175 pub fn difficulty_change_percent(&self) -> f64 {
177 self.difficulty_change
178 }
179
180 pub fn remaining_hours(&self) -> f64 {
182 self.remaining_time as f64 / 3600.0
183 }
184
185 pub fn remaining_days(&self) -> f64 {
187 self.remaining_time as f64 / 86400.0
188 }
189
190 pub fn will_increase(&self) -> bool {
192 self.difficulty_change > 0.0
193 }
194}
195
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
198pub struct PoolBlock {
199 #[serde(default)]
201 pub height: u64,
202 #[serde(default)]
204 pub hash: String,
205 #[serde(default)]
207 pub timestamp: u64,
208 #[serde(default)]
210 pub size: u32,
211 #[serde(default)]
213 pub tx_count: u32,
214 #[serde(default)]
216 pub total_fees: u64,
217 #[serde(default)]
219 pub reward: u64,
220}
221
222impl PoolBlock {
223 pub fn reward_btc(&self) -> f64 {
225 self.reward as f64 / 100_000_000.0
226 }
227
228 pub fn fees_btc(&self) -> f64 {
230 self.total_fees as f64 / 100_000_000.0
231 }
232}
233
234impl MempoolClient {
236 pub async fn get_hashrate_distribution(&self, period: &str) -> Result<HashrateDistribution> {
241 self.get_mining(&format!("/v1/mining/hashrate/pools/{}", period)).await
242 }
243
244 pub async fn get_difficulty_adjustment(&self) -> Result<DifficultyAdjustment> {
246 self.get_mining("/v1/difficulty-adjustment").await
247 }
248
249 pub async fn get_mining_pool(&self, slug: &str) -> Result<MiningPoolStats> {
251 self.get_mining(&format!("/v1/mining/pool/{}", slug)).await
252 }
253
254 pub async fn get_pool_blocks(&self, slug: &str, block_height: Option<u64>) -> Result<Vec<PoolBlock>> {
260 let endpoint = match block_height {
261 Some(h) => format!("/v1/mining/pool/{}/blocks/{}", slug, h),
262 None => format!("/v1/mining/pool/{}/blocks", slug),
263 };
264 self.get_mining(&endpoint).await
265 }
266
267 pub async fn get_block_rewards(&self, period: &str) -> Result<BlockRewardStats> {
272 self.get_mining(&format!("/v1/mining/reward-stats/{}", period)).await
273 }
274
275 async fn get_mining<T: serde::de::DeserializeOwned>(&self, endpoint: &str) -> Result<T> {
277 let url = format!("{}{}", self.base_url(), endpoint);
278
279 let response = self.http_client().get(&url).send().await?;
280
281 let status = response.status();
282 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
283 return Err(crate::error::MempoolError::RateLimited);
284 }
285
286 if !status.is_success() {
287 let message = response.text().await.unwrap_or_default();
288 return Err(crate::error::MempoolError::ApiError {
289 status: status.as_u16(),
290 message,
291 });
292 }
293
294 response
295 .json()
296 .await
297 .map_err(|e| crate::error::MempoolError::ParseError(e.to_string()))
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 #[test]
306 fn test_mining_pool_stats() {
307 let stats = MiningPoolStats {
308 pool: PoolInfo {
309 id: 1,
310 name: "Foundry USA".to_string(),
311 slug: "foundryusa".to_string(),
312 link: "https://foundrydigital.com".to_string(),
313 },
314 block_count: 1000,
315 estimated_hashrate: 150.5,
316 share: 0.30,
317 avg_fee_rate: 25.5,
318 avg_block_size: 1500000,
319 total_reward: 625_000_000_000, };
321
322 assert_eq!(stats.share_percent(), 30.0);
323 assert_eq!(stats.total_reward_btc(), 6250.0);
324 }
325
326 #[test]
327 fn test_difficulty_adjustment() {
328 let adj = DifficultyAdjustment {
329 difficulty_change: 5.5,
330 estimated_retarget_date: 1234567890,
331 remaining_blocks: 500,
332 remaining_time: 86400 * 3, previous_retarget: 3.2,
334 previous_time: 1234000000,
335 next_retarget_height: 850000,
336 time_avg: 600,
337 time_offset: -30,
338 };
339
340 assert_eq!(adj.difficulty_change_percent(), 5.5);
341 assert!(adj.will_increase());
342 assert_eq!(adj.remaining_days(), 3.0);
343 }
344
345 #[test]
346 fn test_block_reward_stats() {
347 let stats = BlockRewardStats {
348 avg_reward: 650_000_000, avg_fees: 25_000_000, avg_subsidy: 625_000_000, total_reward: 65_000_000_000,
352 total_fees: 2_500_000_000,
353 block_count: 100,
354 };
355
356 assert_eq!(stats.avg_reward_btc(), 6.5);
357 assert_eq!(stats.avg_fees_btc(), 0.25);
358 assert!((stats.fee_percentage() - 3.846).abs() < 0.01);
360 }
361
362 #[test]
363 fn test_pool_block() {
364 let block = PoolBlock {
365 height: 800000,
366 hash: "abc123".to_string(),
367 timestamp: 1234567890,
368 size: 1500000,
369 tx_count: 3000,
370 total_fees: 25_000_000,
371 reward: 650_000_000,
372 };
373
374 assert_eq!(block.reward_btc(), 6.5);
375 assert_eq!(block.fees_btc(), 0.25);
376 }
377}