rustywallet_mempool/
mining.rs

1//! Mining pool statistics from mempool.space.
2//!
3//! This module provides access to mining pool statistics
4//! including hashrate distribution, block rewards, and pool rankings.
5
6use serde::{Deserialize, Serialize};
7
8use crate::client::MempoolClient;
9use crate::error::Result;
10
11/// Mining pool statistics.
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13pub struct MiningPoolStats {
14    /// Pool name/slug
15    pub pool: PoolInfo,
16    /// Number of blocks mined
17    #[serde(default)]
18    pub block_count: u64,
19    /// Estimated hashrate (EH/s)
20    #[serde(default, rename = "estimatedHashrate")]
21    pub estimated_hashrate: f64,
22    /// Share of total hashrate (0-1)
23    #[serde(default)]
24    pub share: f64,
25    /// Average fee rate of blocks
26    #[serde(default, rename = "avgFeerate")]
27    pub avg_fee_rate: f64,
28    /// Average block size
29    #[serde(default, rename = "avgBlockSize")]
30    pub avg_block_size: u64,
31    /// Total rewards earned (satoshis)
32    #[serde(default, rename = "totalReward")]
33    pub total_reward: u64,
34}
35
36/// Pool identification info.
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38pub struct PoolInfo {
39    /// Pool ID/slug
40    #[serde(default)]
41    pub id: u32,
42    /// Pool name
43    #[serde(default)]
44    pub name: String,
45    /// Pool slug (URL-friendly name)
46    #[serde(default)]
47    pub slug: String,
48    /// Pool link
49    #[serde(default)]
50    pub link: String,
51}
52
53impl MiningPoolStats {
54    /// Get share as percentage.
55    pub fn share_percent(&self) -> f64 {
56        self.share * 100.0
57    }
58
59    /// Get total reward in BTC.
60    pub fn total_reward_btc(&self) -> f64 {
61        self.total_reward as f64 / 100_000_000.0
62    }
63}
64
65/// Hashrate distribution across pools.
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67pub struct HashrateDistribution {
68    /// Pool statistics
69    #[serde(default)]
70    pub pools: Vec<MiningPoolStats>,
71    /// Total block count in period
72    #[serde(default, rename = "blockCount")]
73    pub block_count: u64,
74    /// Time period in seconds
75    #[serde(default, rename = "lastEstimatedHashrate")]
76    pub last_estimated_hashrate: f64,
77}
78
79impl HashrateDistribution {
80    /// Get pool by name.
81    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    /// Get top N pools by block count.
86    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    /// Get total hashrate (sum of all pools).
93    pub fn total_hashrate(&self) -> f64 {
94        self.pools.iter().map(|p| p.estimated_hashrate).sum()
95    }
96}
97
98/// Block reward statistics.
99#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
100pub struct BlockRewardStats {
101    /// Average block reward (satoshis)
102    #[serde(default, rename = "avgReward")]
103    pub avg_reward: u64,
104    /// Average fees per block (satoshis)
105    #[serde(default, rename = "avgFees")]
106    pub avg_fees: u64,
107    /// Average subsidy per block (satoshis)
108    #[serde(default, rename = "avgSubsidy")]
109    pub avg_subsidy: u64,
110    /// Total rewards in period (satoshis)
111    #[serde(default, rename = "totalReward")]
112    pub total_reward: u64,
113    /// Total fees in period (satoshis)
114    #[serde(default, rename = "totalFees")]
115    pub total_fees: u64,
116    /// Block count in period
117    #[serde(default, rename = "blockCount")]
118    pub block_count: u64,
119}
120
121impl BlockRewardStats {
122    /// Get average reward in BTC.
123    pub fn avg_reward_btc(&self) -> f64 {
124        self.avg_reward as f64 / 100_000_000.0
125    }
126
127    /// Get average fees in BTC.
128    pub fn avg_fees_btc(&self) -> f64 {
129        self.avg_fees as f64 / 100_000_000.0
130    }
131
132    /// Get fee percentage of total reward.
133    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/// Difficulty adjustment information.
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
144pub struct DifficultyAdjustment {
145    /// Current difficulty
146    #[serde(default, rename = "difficultyChange")]
147    pub difficulty_change: f64,
148    /// Estimated seconds until next adjustment
149    #[serde(default, rename = "estimatedRetargetDate")]
150    pub estimated_retarget_date: u64,
151    /// Remaining blocks until adjustment
152    #[serde(default, rename = "remainingBlocks")]
153    pub remaining_blocks: u64,
154    /// Remaining time in seconds
155    #[serde(default, rename = "remainingTime")]
156    pub remaining_time: u64,
157    /// Previous difficulty
158    #[serde(default, rename = "previousRetarget")]
159    pub previous_retarget: f64,
160    /// Previous time
161    #[serde(default, rename = "previousTime")]
162    pub previous_time: u64,
163    /// Next retarget height
164    #[serde(default, rename = "nextRetargetHeight")]
165    pub next_retarget_height: u64,
166    /// Time average in seconds
167    #[serde(default, rename = "timeAvg")]
168    pub time_avg: u64,
169    /// Time offset
170    #[serde(default, rename = "timeOffset")]
171    pub time_offset: i64,
172}
173
174impl DifficultyAdjustment {
175    /// Get difficulty change as percentage.
176    pub fn difficulty_change_percent(&self) -> f64 {
177        self.difficulty_change
178    }
179
180    /// Get remaining time in hours.
181    pub fn remaining_hours(&self) -> f64 {
182        self.remaining_time as f64 / 3600.0
183    }
184
185    /// Get remaining time in days.
186    pub fn remaining_days(&self) -> f64 {
187        self.remaining_time as f64 / 86400.0
188    }
189
190    /// Check if difficulty will increase.
191    pub fn will_increase(&self) -> bool {
192        self.difficulty_change > 0.0
193    }
194}
195
196/// Mining pool block.
197#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
198pub struct PoolBlock {
199    /// Block height
200    #[serde(default)]
201    pub height: u64,
202    /// Block hash
203    #[serde(default)]
204    pub hash: String,
205    /// Block timestamp
206    #[serde(default)]
207    pub timestamp: u64,
208    /// Block size
209    #[serde(default)]
210    pub size: u32,
211    /// Transaction count
212    #[serde(default)]
213    pub tx_count: u32,
214    /// Total fees (satoshis)
215    #[serde(default)]
216    pub total_fees: u64,
217    /// Block reward (satoshis)
218    #[serde(default)]
219    pub reward: u64,
220}
221
222impl PoolBlock {
223    /// Get reward in BTC.
224    pub fn reward_btc(&self) -> f64 {
225        self.reward as f64 / 100_000_000.0
226    }
227
228    /// Get fees in BTC.
229    pub fn fees_btc(&self) -> f64 {
230        self.total_fees as f64 / 100_000_000.0
231    }
232}
233
234/// Mining client extension for MempoolClient.
235impl MempoolClient {
236    /// Get hashrate distribution across mining pools.
237    ///
238    /// # Arguments
239    /// * `period` - Time period: "24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"
240    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    /// Get difficulty adjustment information.
245    pub async fn get_difficulty_adjustment(&self) -> Result<DifficultyAdjustment> {
246        self.get_mining("/v1/difficulty-adjustment").await
247    }
248
249    /// Get mining pool information by slug.
250    pub async fn get_mining_pool(&self, slug: &str) -> Result<MiningPoolStats> {
251        self.get_mining(&format!("/v1/mining/pool/{}", slug)).await
252    }
253
254    /// Get blocks mined by a pool.
255    ///
256    /// # Arguments
257    /// * `slug` - Pool slug (e.g., "foundryusa", "antpool")
258    /// * `block_height` - Optional starting block height
259    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    /// Get block reward statistics.
268    ///
269    /// # Arguments
270    /// * `period` - Time period: "24h", "3d", "1w", "1m", "3m", "6m", "1y", "2y", "3y", "all"
271    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    /// Internal GET method for mining endpoints.
276    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, // 6250 BTC
320        };
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, // 3 days
333            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, // 6.5 BTC
349            avg_fees: 25_000_000, // 0.25 BTC
350            avg_subsidy: 625_000_000, // 6.25 BTC
351            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        // Fee percentage: 0.25 / 6.5 * 100 ≈ 3.85%
359        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}