igneum/pool/src/pplns.rs

172 lines
6.7 KiB
Rust

//! PPLNS: pay per last N shares, with N measured in block weight (a share at shift `s` weighs `2^-s` of a block,
//! spec 09 section 9.8 item 3). The window holds the newest shares whose weights sum to `window_blocks` blocks of
//! expected work; when a block is found, the reward after the pool fee is split across the window in proportion to
//! weight. The split is snapshotted when the block is found and credited when the block is confirmed blue, so an
//! orphan pays nobody and a late joiner is paid for the share of the window it earned.
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, VecDeque};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ShareRec {
pub address: String,
pub worker: String,
pub weight: f64,
pub at_ms: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Pplns {
pub window_blocks: f64,
pub shares: VecDeque<ShareRec>,
pub total_weight: f64,
/// Weight accumulated since the last block, for the effort figure
pub since_block: f64,
}
/// One payee's part of a block: `fraction` of the reward after the fee.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Payee {
pub address: String,
pub fraction: f64,
}
impl Pplns {
pub fn new(window_blocks: f64) -> Self {
Self { window_blocks, shares: VecDeque::new(), total_weight: 0.0, since_block: 0.0 }
}
pub fn push(&mut self, s: ShareRec) {
self.total_weight += s.weight;
self.since_block += s.weight;
self.shares.push_back(s);
while self.shares.len() > 1 && self.total_weight - self.shares.front().map(|s| s.weight).unwrap_or(0.0) >= self.window_blocks {
let front = self.shares.pop_front().unwrap();
self.total_weight -= front.weight;
}
if self.total_weight < 0.0 {
self.total_weight = 0.0;
}
}
/// The split of the window right now, by address, fractions summing to 1 (empty window: no payees).
pub fn snapshot(&self) -> Vec<Payee> {
if self.total_weight <= 0.0 {
return Vec::new();
}
let mut by: HashMap<&str, f64> = HashMap::new();
for s in &self.shares {
*by.entry(s.address.as_str()).or_insert(0.0) += s.weight;
}
let mut out: Vec<Payee> = by.into_iter().map(|(a, w)| Payee { address: a.to_string(), fraction: w / self.total_weight }).collect();
out.sort_by(|a, b| b.fraction.partial_cmp(&a.fraction).unwrap().then(a.address.cmp(&b.address)));
out
}
/// Effort of the block just found: window weight since the previous block over one block of expected work.
pub fn take_effort(&mut self) -> f64 {
let e = self.since_block;
self.since_block = 0.0;
e
}
}
/// Splits `reward_wei` across `payees` after a fee of `fee_percent`. Rounding dust stays with the pool (the sum of
/// the parts never exceeds the reward after the fee).
pub fn distribute(reward_wei: u128, fee_percent: f64, payees: &[Payee]) -> (Vec<(String, u128)>, u128) {
let fee = ((reward_wei as f64) * (fee_percent.clamp(0.0, 100.0) / 100.0)).floor() as u128;
let net = reward_wei.saturating_sub(fee);
let mut out = Vec::with_capacity(payees.len());
let mut given = 0u128;
for p in payees {
let part = ((net as f64) * p.fraction.clamp(0.0, 1.0)).floor() as u128;
let part = part.min(net - given);
given += part;
out.push((p.address.clone(), part));
}
(out, fee + (net - given))
}
#[cfg(test)]
mod tests {
use super::*;
fn share(a: &str, w: f64) -> ShareRec {
ShareRec { address: a.into(), worker: "w".into(), weight: w, at_ms: 0 }
}
#[test]
fn the_window_holds_the_newest_n_blocks_of_weight() {
let mut p = Pplns::new(2.0);
for _ in 0..10 {
p.push(share("a", 0.5)); // 5 blocks of weight pushed
}
assert!((p.total_weight - 2.0).abs() < 1e-9, "{}", p.total_weight);
assert_eq!(p.shares.len(), 4);
// mixed shifts: a 2^-1 and many 2^-4 shares
let mut p = Pplns::new(1.0);
p.push(share("a", 0.5));
for _ in 0..16 {
p.push(share("b", 1.0 / 16.0));
}
assert!((p.total_weight - 1.0).abs() < 1e-9);
// a's share fell out once b filled a whole block of weight
assert!(p.shares.iter().all(|s| s.address == "b"));
}
#[test]
fn the_split_is_by_weight_in_the_window_and_sums_to_one() {
let mut p = Pplns::new(4.0);
p.push(share("a", 1.0));
p.push(share("b", 0.5));
p.push(share("b", 0.5));
p.push(share("c", 2.0));
let snap = p.snapshot();
assert_eq!(snap[0], Payee { address: "c".into(), fraction: 0.5 });
assert_eq!(snap[1], Payee { address: "a".into(), fraction: 0.25 });
assert_eq!(snap[2], Payee { address: "b".into(), fraction: 0.25 });
let sum: f64 = snap.iter().map(|p| p.fraction).sum();
assert!((sum - 1.0).abs() < 1e-12);
assert!(Pplns::new(1.0).snapshot().is_empty());
}
#[test]
fn a_late_joiner_is_paid_only_for_its_part_of_the_window() {
let mut p = Pplns::new(2.0);
for _ in 0..8 {
p.push(share("old", 0.25));
}
p.push(share("new", 0.25)); // pushes the oldest 0.25 out
let snap = p.snapshot();
let new = snap.iter().find(|x| x.address == "new").unwrap();
assert!((new.fraction - 0.125).abs() < 1e-12, "1 of 8 shares in the window: {}", new.fraction);
}
#[test]
fn distribution_takes_the_fee_first_and_never_overpays() {
let payees = vec![Payee { address: "a".into(), fraction: 2.0 / 3.0 }, Payee { address: "b".into(), fraction: 1.0 / 3.0 }];
let reward = 1_000_000_000_000_000_000u128; // 1 IGN
let (parts, kept) = distribute(reward, 1.0, &payees);
let fee = reward / 100;
let paid: u128 = parts.iter().map(|p| p.1).sum();
assert!(paid <= reward - fee);
assert!(paid + kept == reward, "every wei is accounted for");
assert!(kept >= fee && kept < fee + 10, "fee plus at most rounding dust: {kept}");
assert_eq!(parts[0].0, "a");
assert!((parts[0].1 as f64 / (reward - fee) as f64 - 2.0 / 3.0).abs() < 1e-9);
// zero fee, one payee: everything
let (parts, kept) = distribute(1000, 0.0, &[Payee { address: "a".into(), fraction: 1.0 }]);
assert_eq!((parts[0].1, kept), (1000, 0));
// no payees: the pool keeps it
assert_eq!(distribute(1000, 1.0, &[]).1, 1000);
}
#[test]
fn effort_counts_weight_since_the_previous_block() {
let mut p = Pplns::new(10.0);
p.push(share("a", 0.5));
p.push(share("a", 0.75));
assert!((p.take_effort() - 1.25).abs() < 1e-12);
assert_eq!(p.take_effort(), 0.0);
}
}