172 lines
6.7 KiB
Rust
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);
|
|
}
|
|
}
|