Skip to main content

pot_o_extensions/
pool_strategy.rs

1//! Pool strategy: solo and proportional/PPLNS pool info and reward distribution.
2
3use pot_o_core::TribeResult;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7// ---------------------------------------------------------------------------
8// Types
9// ---------------------------------------------------------------------------
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12pub enum PoolType {
13    Solo,
14    Proportional,
15    PPLNS,
16    Custom,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct MinerShare {
21    pub miner_pubkey: String,
22    pub share_pct: f64,
23    pub reward_amount: u64,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct MinerInfo {
28    pub pubkey: String,
29    pub stake: u64,
30    pub proofs_submitted: u64,
31    pub reputation: f64,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct ProofRecord {
36    pub miner_pubkey: String,
37    pub challenge_id: String,
38    pub reward: u64,
39    pub timestamp: chrono::DateTime<chrono::Utc>,
40}
41
42#[derive(Debug, Clone, Default, Serialize, Deserialize)]
43pub struct PoolInfo {
44    pub pool_type: String,
45    pub total_miners: usize,
46    pub total_stake: u64,
47    pub minimum_stake: u64,
48}
49
50// ---------------------------------------------------------------------------
51// Trait
52// ---------------------------------------------------------------------------
53
54/// How mining rewards are distributed among participants.
55pub trait PoolStrategy: Send + Sync {
56    fn pool_type(&self) -> PoolType;
57    fn calculate_shares(&self, proofs: &[ProofRecord], reward: u64)
58        -> TribeResult<Vec<MinerShare>>;
59    fn minimum_stake(&self) -> u64;
60    fn accept_miner(&self, miner: &MinerInfo) -> TribeResult<bool>;
61    fn pool_info(&self, miners: usize, total_stake: u64) -> PoolInfo;
62}
63
64// ---------------------------------------------------------------------------
65// SoloStrategy (implemented now)
66// ---------------------------------------------------------------------------
67
68pub struct SoloStrategy;
69
70impl PoolStrategy for SoloStrategy {
71    fn pool_type(&self) -> PoolType {
72        PoolType::Solo
73    }
74
75    fn calculate_shares(
76        &self,
77        proofs: &[ProofRecord],
78        reward: u64,
79    ) -> TribeResult<Vec<MinerShare>> {
80        // 100% to the miner who submitted the proof
81        Ok(proofs
82            .last()
83            .map(|p| {
84                vec![MinerShare {
85                    miner_pubkey: p.miner_pubkey.clone(),
86                    share_pct: 100.0,
87                    reward_amount: reward,
88                }]
89            })
90            .unwrap_or_default())
91    }
92
93    fn minimum_stake(&self) -> u64 {
94        0 // No stake required for solo
95    }
96
97    fn accept_miner(&self, _miner: &MinerInfo) -> TribeResult<bool> {
98        Ok(true)
99    }
100
101    fn pool_info(&self, miners: usize, total_stake: u64) -> PoolInfo {
102        PoolInfo {
103            pool_type: "solo".into(),
104            total_miners: miners,
105            total_stake,
106            minimum_stake: 0,
107        }
108    }
109}
110
111// ---------------------------------------------------------------------------
112// Helper function for fair reward distribution with proper rounding
113// ---------------------------------------------------------------------------
114
115fn distribute_rewards(
116    miner_counts: HashMap<String, u64>,
117    total_proofs: u64,
118    total_reward: u64,
119) -> Vec<MinerShare> {
120    let mut shares = Vec::new();
121
122    // First pass: calculate rewards (truncating)
123    let mut calculated_rewards: Vec<(String, u64, f64)> = miner_counts
124        .into_iter()
125        .map(|(miner_pubkey, count)| {
126            let share_pct = (count as f64 / total_proofs as f64) * 100.0;
127            let reward_amount = (count as f64 / total_proofs as f64 * total_reward as f64) as u64;
128            (miner_pubkey, reward_amount, share_pct)
129        })
130        .collect();
131
132    // Calculate total distributed and remainder
133    let total_distributed: u64 = calculated_rewards.iter().map(|r| r.1).sum();
134    let remainder = total_reward - total_distributed;
135
136    // Give remainder to the last miner (ensures total == reward)
137    if let Some(last) = calculated_rewards.last_mut() {
138        last.1 += remainder;
139    }
140
141    // Convert to MinerShare
142    for (miner_pubkey, reward_amount, share_pct) in calculated_rewards {
143        shares.push(MinerShare {
144            miner_pubkey,
145            share_pct,
146            reward_amount,
147        });
148    }
149
150    shares
151}
152
153// ---------------------------------------------------------------------------
154// ProportionalPool (implemented)
155// ---------------------------------------------------------------------------
156
157pub struct ProportionalPool {
158    pub min_stake: u64,
159}
160
161impl PoolStrategy for ProportionalPool {
162    fn pool_type(&self) -> PoolType {
163        PoolType::Proportional
164    }
165
166    fn calculate_shares(
167        &self,
168        proofs: &[ProofRecord],
169        reward: u64,
170    ) -> TribeResult<Vec<MinerShare>> {
171        if proofs.is_empty() {
172            return Ok(Vec::new());
173        }
174
175        // Count proofs per miner
176        let mut miner_counts: HashMap<String, u64> = HashMap::new();
177        for proof in proofs {
178            *miner_counts.entry(proof.miner_pubkey.clone()).or_insert(0) += 1;
179        }
180
181        let total_proofs = proofs.len() as u64;
182        let shares = distribute_rewards(miner_counts, total_proofs, reward);
183
184        Ok(shares)
185    }
186
187    fn minimum_stake(&self) -> u64 {
188        self.min_stake
189    }
190
191    fn accept_miner(&self, miner: &MinerInfo) -> TribeResult<bool> {
192        Ok(miner.stake >= self.min_stake)
193    }
194
195    fn pool_info(&self, miners: usize, total_stake: u64) -> PoolInfo {
196        PoolInfo {
197            pool_type: "proportional".into(),
198            total_miners: miners,
199            total_stake,
200            minimum_stake: self.min_stake,
201        }
202    }
203}
204
205// ---------------------------------------------------------------------------
206// PPLNSPool (stubbed)
207// ---------------------------------------------------------------------------
208
209pub struct PPLNSPool {
210    pub window_size: usize,
211    pub min_stake: u64,
212}
213
214impl PoolStrategy for PPLNSPool {
215    fn pool_type(&self) -> PoolType {
216        PoolType::PPLNS
217    }
218
219    fn calculate_shares(
220        &self,
221        proofs: &[ProofRecord],
222        reward: u64,
223    ) -> TribeResult<Vec<MinerShare>> {
224        if proofs.is_empty() {
225            return Ok(Vec::new());
226        }
227
228        // Take only the last window_size proofs
229        let start_idx = if proofs.len() > self.window_size {
230            proofs.len() - self.window_size
231        } else {
232            0
233        };
234        let window_proofs = &proofs[start_idx..];
235
236        // Count proofs per miner in the window
237        let mut miner_counts: HashMap<String, u64> = HashMap::new();
238        for proof in window_proofs {
239            *miner_counts.entry(proof.miner_pubkey.clone()).or_insert(0) += 1;
240        }
241
242        let total_proofs = window_proofs.len() as u64;
243        let shares = distribute_rewards(miner_counts, total_proofs, reward);
244
245        Ok(shares)
246    }
247
248    fn minimum_stake(&self) -> u64 {
249        self.min_stake
250    }
251
252    fn accept_miner(&self, miner: &MinerInfo) -> TribeResult<bool> {
253        Ok(miner.stake >= self.min_stake)
254    }
255
256    fn pool_info(&self, miners: usize, total_stake: u64) -> PoolInfo {
257        PoolInfo {
258            pool_type: "pplns".into(),
259            total_miners: miners,
260            total_stake,
261            minimum_stake: self.min_stake,
262        }
263    }
264}
265
266// ---------------------------------------------------------------------------
267// Tests
268// ---------------------------------------------------------------------------
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use chrono::Utc;
274
275    fn create_proof(miner_pubkey: &str) -> ProofRecord {
276        ProofRecord {
277            miner_pubkey: miner_pubkey.to_string(),
278            challenge_id: "challenge1".to_string(),
279            reward: 0,
280            timestamp: Utc::now(),
281        }
282    }
283
284    fn create_miner(pubkey: &str, stake: u64) -> MinerInfo {
285        MinerInfo {
286            pubkey: pubkey.to_string(),
287            stake,
288            proofs_submitted: 0,
289            reputation: 1.0,
290        }
291    }
292
293    // =========================================================================
294    // ProportionalPool Tests
295    // =========================================================================
296
297    #[test]
298    fn test_proportional_single_miner() {
299        let strategy = ProportionalPool { min_stake: 0 };
300        let proofs = vec![
301            create_proof("miner_a"),
302            create_proof("miner_a"),
303            create_proof("miner_a"),
304        ];
305
306        let shares = strategy.calculate_shares(&proofs, 1000).unwrap();
307
308        assert_eq!(shares.len(), 1);
309        assert_eq!(shares[0].miner_pubkey, "miner_a");
310        assert_eq!(shares[0].share_pct, 100.0);
311        assert_eq!(shares[0].reward_amount, 1000);
312    }
313
314    #[test]
315    fn test_proportional_equal_shares() {
316        let strategy = ProportionalPool { min_stake: 0 };
317        let proofs = vec![
318            create_proof("miner_a"),
319            create_proof("miner_b"),
320            create_proof("miner_a"),
321            create_proof("miner_b"),
322        ];
323
324        let shares = strategy.calculate_shares(&proofs, 1000).unwrap();
325
326        assert_eq!(shares.len(), 2);
327
328        // Find miner_a
329        let miner_a = shares.iter().find(|s| s.miner_pubkey == "miner_a").unwrap();
330        assert_eq!(miner_a.share_pct, 50.0);
331        assert_eq!(miner_a.reward_amount, 500);
332
333        // Find miner_b
334        let miner_b = shares.iter().find(|s| s.miner_pubkey == "miner_b").unwrap();
335        assert_eq!(miner_b.share_pct, 50.0);
336        assert_eq!(miner_b.reward_amount, 500);
337    }
338
339    #[test]
340    fn test_proportional_unequal_shares() {
341        let strategy = ProportionalPool { min_stake: 0 };
342        let proofs = vec![
343            create_proof("miner_a"),
344            create_proof("miner_b"),
345            create_proof("miner_a"),
346            create_proof("miner_c"),
347            create_proof("miner_a"),
348        ];
349
350        let shares = strategy.calculate_shares(&proofs, 1000).unwrap();
351
352        assert_eq!(shares.len(), 3);
353
354        // miner_a: 3/5 = 60%
355        let miner_a = shares.iter().find(|s| s.miner_pubkey == "miner_a").unwrap();
356        assert_eq!(miner_a.share_pct, 60.0);
357        assert_eq!(miner_a.reward_amount, 600);
358
359        // miner_b: 1/5 = 20%
360        let miner_b = shares.iter().find(|s| s.miner_pubkey == "miner_b").unwrap();
361        assert_eq!(miner_b.share_pct, 20.0);
362        assert_eq!(miner_b.reward_amount, 200);
363
364        // miner_c: 1/5 = 20%
365        let miner_c = shares.iter().find(|s| s.miner_pubkey == "miner_c").unwrap();
366        assert_eq!(miner_c.share_pct, 20.0);
367        assert_eq!(miner_c.reward_amount, 200);
368    }
369
370    #[test]
371    fn test_proportional_empty_proofs() {
372        let strategy = ProportionalPool { min_stake: 0 };
373        let proofs = vec![];
374
375        let shares = strategy.calculate_shares(&proofs, 1000).unwrap();
376
377        assert_eq!(shares.len(), 0);
378    }
379
380    #[test]
381    fn test_proportional_fractional_rounding() {
382        let strategy = ProportionalPool { min_stake: 0 };
383        let proofs = vec![
384            create_proof("miner_a"),
385            create_proof("miner_b"),
386            create_proof("miner_c"),
387        ];
388
389        let shares = strategy.calculate_shares(&proofs, 1000).unwrap();
390
391        assert_eq!(shares.len(), 3);
392
393        // Each miner should get approximately 333.33
394        let total: u64 = shares.iter().map(|s| s.reward_amount).sum();
395        assert_eq!(total, 1000); // Total must equal original reward
396
397        // Percentages should sum to 100.0
398        let pct_sum: f64 = shares.iter().map(|s| s.share_pct).sum();
399        assert!((pct_sum - 100.0).abs() < 0.01);
400    }
401
402    // =========================================================================
403    // PPLNSPool Tests
404    // =========================================================================
405
406    #[test]
407    fn test_pplns_window_excludes_old() {
408        let strategy = PPLNSPool {
409            window_size: 3,
410            min_stake: 0,
411        };
412        let proofs = vec![
413            create_proof("miner_old1"),
414            create_proof("miner_old2"),
415            create_proof("miner_a"),
416            create_proof("miner_b"),
417            create_proof("miner_a"),
418        ];
419
420        let shares = strategy.calculate_shares(&proofs, 1000).unwrap();
421
422        // Should only include last 3: [miner_a, miner_b, miner_a]
423        // miner_old1 and miner_old2 should not be in results
424        let has_old1 = shares.iter().any(|s| s.miner_pubkey == "miner_old1");
425        let has_old2 = shares.iter().any(|s| s.miner_pubkey == "miner_old2");
426
427        assert!(!has_old1);
428        assert!(!has_old2);
429
430        // Check total rewards equal the input reward
431        let total: u64 = shares.iter().map(|s| s.reward_amount).sum();
432        assert_eq!(total, 1000);
433
434        // miner_a: 2/3 = ~666.67%
435        let miner_a = shares.iter().find(|s| s.miner_pubkey == "miner_a").unwrap();
436        assert!((miner_a.share_pct - 66.67).abs() < 0.1);
437        assert!(miner_a.reward_amount >= 666 && miner_a.reward_amount <= 667);
438
439        // miner_b: 1/3 = ~333.33%
440        let miner_b = shares.iter().find(|s| s.miner_pubkey == "miner_b").unwrap();
441        assert!((miner_b.share_pct - 33.33).abs() < 0.1);
442        assert!(miner_b.reward_amount >= 333 && miner_b.reward_amount <= 334);
443    }
444
445    #[test]
446    fn test_pplns_single_miner_in_window() {
447        let strategy = PPLNSPool {
448            window_size: 5,
449            min_stake: 0,
450        };
451        let proofs = vec![
452            create_proof("miner_a"),
453            create_proof("miner_a"),
454            create_proof("miner_a"),
455        ];
456
457        let shares = strategy.calculate_shares(&proofs, 1000).unwrap();
458
459        assert_eq!(shares.len(), 1);
460        assert_eq!(shares[0].miner_pubkey, "miner_a");
461        assert_eq!(shares[0].share_pct, 100.0);
462        assert_eq!(shares[0].reward_amount, 1000);
463    }
464
465    #[test]
466    fn test_pplns_shared_window() {
467        let strategy = PPLNSPool {
468            window_size: 4,
469            min_stake: 0,
470        };
471        let proofs = vec![
472            create_proof("miner_a"),
473            create_proof("miner_a"),
474            create_proof("miner_b"),
475            create_proof("miner_c"),
476            create_proof("miner_b"),
477        ];
478
479        let shares = strategy.calculate_shares(&proofs, 1000).unwrap();
480
481        // Last 4 proofs: [miner_a, miner_b, miner_c, miner_b]
482        // miner_a: 1/4 = 25%
483        // miner_b: 2/4 = 50%
484        // miner_c: 1/4 = 25%
485
486        assert_eq!(shares.len(), 3);
487
488        let miner_a = shares.iter().find(|s| s.miner_pubkey == "miner_a").unwrap();
489        assert_eq!(miner_a.share_pct, 25.0);
490        assert_eq!(miner_a.reward_amount, 250);
491
492        let miner_b = shares.iter().find(|s| s.miner_pubkey == "miner_b").unwrap();
493        assert_eq!(miner_b.share_pct, 50.0);
494        assert_eq!(miner_b.reward_amount, 500);
495
496        let miner_c = shares.iter().find(|s| s.miner_pubkey == "miner_c").unwrap();
497        assert_eq!(miner_c.share_pct, 25.0);
498        assert_eq!(miner_c.reward_amount, 250);
499    }
500
501    #[test]
502    fn test_pplns_empty_proofs() {
503        let strategy = PPLNSPool {
504            window_size: 3,
505            min_stake: 0,
506        };
507        let proofs = vec![];
508
509        let shares = strategy.calculate_shares(&proofs, 1000).unwrap();
510
511        assert_eq!(shares.len(), 0);
512    }
513
514    #[test]
515    fn test_pplns_window_larger_than_proofs() {
516        let strategy = PPLNSPool {
517            window_size: 10,
518            min_stake: 0,
519        };
520        let proofs = vec![
521            create_proof("miner_a"),
522            create_proof("miner_b"),
523            create_proof("miner_a"),
524        ];
525
526        let shares = strategy.calculate_shares(&proofs, 1000).unwrap();
527
528        // Window size larger than proofs, use all proofs
529        // miner_a: 2/3 = ~66.67%
530        // miner_b: 1/3 = ~33.33%
531
532        assert_eq!(shares.len(), 2);
533
534        // Total must equal reward
535        let total: u64 = shares.iter().map(|s| s.reward_amount).sum();
536        assert_eq!(total, 1000);
537
538        let miner_a = shares.iter().find(|s| s.miner_pubkey == "miner_a").unwrap();
539        assert!((miner_a.share_pct - 66.67).abs() < 0.1);
540        assert!(miner_a.reward_amount >= 666 && miner_a.reward_amount <= 667);
541
542        let miner_b = shares.iter().find(|s| s.miner_pubkey == "miner_b").unwrap();
543        assert!((miner_b.share_pct - 33.33).abs() < 0.1);
544        assert!(miner_b.reward_amount >= 333 && miner_b.reward_amount <= 334);
545    }
546
547    // =========================================================================
548    // Miner Acceptance Tests
549    // =========================================================================
550
551    #[test]
552    fn test_proportional_accept_miner_with_sufficient_stake() {
553        let strategy = ProportionalPool { min_stake: 100 };
554        let miner = create_miner("miner_a", 150);
555
556        let accepted = strategy.accept_miner(&miner).unwrap();
557
558        assert!(accepted);
559    }
560
561    #[test]
562    fn test_proportional_reject_miner_with_insufficient_stake() {
563        let strategy = ProportionalPool { min_stake: 100 };
564        let miner = create_miner("miner_a", 50);
565
566        let accepted = strategy.accept_miner(&miner).unwrap();
567
568        assert!(!accepted);
569    }
570
571    #[test]
572    fn test_pplns_accept_miner_with_sufficient_stake() {
573        let strategy = PPLNSPool {
574            window_size: 5,
575            min_stake: 200,
576        };
577        let miner = create_miner("miner_a", 300);
578
579        let accepted = strategy.accept_miner(&miner).unwrap();
580
581        assert!(accepted);
582    }
583
584    #[test]
585    fn test_pplns_reject_miner_with_insufficient_stake() {
586        let strategy = PPLNSPool {
587            window_size: 5,
588            min_stake: 200,
589        };
590        let miner = create_miner("miner_a", 100);
591
592        let accepted = strategy.accept_miner(&miner).unwrap();
593
594        assert!(!accepted);
595    }
596
597    // =========================================================================
598    // Pool Info Tests
599    // =========================================================================
600
601    #[test]
602    fn test_proportional_pool_info() {
603        let strategy = ProportionalPool { min_stake: 50 };
604
605        let info = strategy.pool_info(5, 500);
606
607        assert_eq!(info.pool_type, "proportional");
608        assert_eq!(info.total_miners, 5);
609        assert_eq!(info.total_stake, 500);
610        assert_eq!(info.minimum_stake, 50);
611    }
612
613    #[test]
614    fn test_pplns_pool_info() {
615        let strategy = PPLNSPool {
616            window_size: 10,
617            min_stake: 100,
618        };
619
620        let info = strategy.pool_info(3, 300);
621
622        assert_eq!(info.pool_type, "pplns");
623        assert_eq!(info.total_miners, 3);
624        assert_eq!(info.total_stake, 300);
625        assert_eq!(info.minimum_stake, 100);
626    }
627
628    // =========================================================================
629    // SoloStrategy Tests (regression)
630    // =========================================================================
631
632    #[test]
633    fn test_solo_pool_type() {
634        let strategy = SoloStrategy;
635        assert_eq!(strategy.pool_type(), PoolType::Solo);
636    }
637
638    #[test]
639    fn test_solo_minimum_stake() {
640        let strategy = SoloStrategy;
641        assert_eq!(strategy.minimum_stake(), 0);
642    }
643
644    #[test]
645    fn test_solo_accept_miner() {
646        let strategy = SoloStrategy;
647        let miner = create_miner("miner_a", 0);
648        assert!(strategy.accept_miner(&miner).unwrap());
649    }
650
651    #[test]
652    fn test_solo_calculate_shares() {
653        let strategy = SoloStrategy;
654        let proofs = vec![
655            create_proof("miner_a"),
656            create_proof("miner_b"),
657            create_proof("miner_c"),
658        ];
659
660        let shares = strategy.calculate_shares(&proofs, 1000).unwrap();
661
662        // Solo strategy gives all to the last proof submitter
663        assert_eq!(shares.len(), 1);
664        assert_eq!(shares[0].miner_pubkey, "miner_c");
665        assert_eq!(shares[0].reward_amount, 1000);
666    }
667
668    #[test]
669    fn test_solo_empty_proofs() {
670        let strategy = SoloStrategy;
671        let proofs = vec![];
672
673        let shares = strategy.calculate_shares(&proofs, 1000).unwrap();
674
675        assert_eq!(shares.len(), 0);
676    }
677}