From 346b9c8e82ddd3a2bd55f88ac61ee454cb55747c Mon Sep 17 00:00:00 2001 From: btc-pool Date: Sun, 4 Oct 2026 03:49:30 +0000 Subject: [PATCH] Deferred PPLNS payouts with minimum payout and output cap --- p2poolv2_config/src/lib.rs | 16 + .../payout/simple_pplns/deferred.rs | 633 ++++++++++++++++++ .../src/accounting/payout/simple_pplns/mod.rs | 1 + .../accounting/payout/simple_pplns/payout.rs | 31 +- .../src/stratum/message_handlers/submit.rs | 37 +- p2poolv2_lib/src/stratum/validate_username.rs | 25 +- p2poolv2_lib/src/stratum/work/notify.rs | 2 + p2poolv2_node/src/main.rs | 28 + 8 files changed, 765 insertions(+), 8 deletions(-) create mode 100644 p2poolv2_lib/src/accounting/payout/simple_pplns/deferred.rs diff --git a/p2poolv2_config/src/lib.rs b/p2poolv2_config/src/lib.rs index 20f2b57..ef56672 100644 --- a/p2poolv2_config/src/lib.rs +++ b/p2poolv2_config/src/lib.rs @@ -129,6 +129,16 @@ pub struct StratumConfig { /// Pool operating mode. Defaults to P2Poolv2 when not specified. #[serde(default)] pub mode: PoolMode, + /// Deferred payouts (hydrapool mode only). When set, the payout + /// ledger is stored in this directory and small or excess payouts + /// are carried over to later blocks instead of being dropped. + pub payout_ledger_dir: Option, + /// Minimum amount in satoshis for an address to be paid in a block + /// coinbase when deferred payouts are enabled. Default: 10000. + pub min_payout_sats: Option, + /// Maximum number of miner outputs in a block coinbase when deferred + /// payouts are enabled. Default: 250. + pub max_payout_outputs: Option, // Parsed addresses - only available when State = Parsed #[serde(skip)] @@ -200,6 +210,9 @@ impl StratumConfig { max_connections: self.max_connections, wait_for_chain_sync: self.wait_for_chain_sync, mode: self.mode, + payout_ledger_dir: self.payout_ledger_dir, + min_payout_sats: self.min_payout_sats, + max_payout_outputs: self.max_payout_outputs, bootstrap_address_parsed: Some(bootstrap_address_parsed), donation_address_parsed, fee_address_parsed, @@ -253,6 +266,9 @@ impl StratumConfig { max_connections: None, wait_for_chain_sync: true, mode: PoolMode::default(), + payout_ledger_dir: None, + min_payout_sats: None, + max_payout_outputs: None, bootstrap_address_parsed: None, donation_address_parsed: None, fee_address_parsed: None, diff --git a/p2poolv2_lib/src/accounting/payout/simple_pplns/deferred.rs b/p2poolv2_lib/src/accounting/payout/simple_pplns/deferred.rs new file mode 100644 index 0000000..a294037 --- /dev/null +++ b/p2poolv2_lib/src/accounting/payout/simple_pplns/deferred.rs @@ -0,0 +1,633 @@ +// Copyright (C) 2024-2026 P2Poolv2 Developers (see AUTHORS) +// +// This file is part of P2Poolv2 +// +// P2Poolv2 is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// P2Poolv2 is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with +// P2Poolv2. If not, see . + +//! Deferred payouts for simple PPLNS (hydrapool mode). +//! +//! A block coinbase can only hold a limited number of outputs, and tiny +//! outputs are not worth paying. With deferred payouts enabled, an +//! address that is not paid in a block keeps a credit that is paid in a +//! later block, once its credit plus new earnings reach the minimum. +//! +//! For every found block the ledger records the entitlement of each +//! address (its proportional PPLNS share of the miner reward when the +//! block was found) and the amount actually paid to it in the coinbase. +//! The balance of an address is entitlement minus paid, summed over all +//! recorded blocks not listed as orphaned. Balances sum to zero: +//! positive balances are credits owed to miners that were not paid, +//! negative balances are the extra received by paid miners, recovered +//! from their later entitlements. +//! +//! The ledger is an append-only JSON lines file. Orphaned blocks are +//! listed, one block hash per line, in a separate file maintained by an +//! external checker that queries bitcoind. + +use crate::accounting::OutputPair; +use crate::accounting::payout::payout_distribution::append_proportional_distribution; +use crate::accounting::payout::simple_pplns::payout::Payout; +#[cfg(test)] +#[mockall_double::double] +use crate::shares::chain::chain_store_handle::ChainStoreHandle; +#[cfg(not(test))] +use crate::shares::chain::chain_store_handle::ChainStoreHandle; +use crate::stratum::work::block_template::BlockTemplate; +use bitcoin::address::NetworkUnchecked; +use bitcoin::{Address, Amount, Block, Network}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::error::Error; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; +use tracing::{error, info, warn}; + +pub const DEFAULT_MIN_PAYOUT_SATS: u64 = 10_000; +pub const DEFAULT_MAX_PAYOUT_OUTPUTS: usize = 250; +pub const LEDGER_FILE: &str = "ledger.jsonl"; +pub const ORPHANS_FILE: &str = "orphans.txt"; +const STEP_SIZE_SECONDS: u64 = 86_400; +const BASIS_POINT_FACTOR: u64 = 10_000; + +type BoxError = Box; + +#[derive(Debug, Clone)] +pub struct DeferredSettings { + pub min_payout_sats: u64, + pub max_payout_outputs: usize, + pub ledger_dir: PathBuf, + pub network: Network, + pub difficulty_multiplier: f64, + pub donation_address: Option
, + pub donation: Option, + pub fee_address: Option
, + pub fee: Option, +} + +impl DeferredSettings { + fn ledger_path(&self) -> PathBuf { + self.ledger_dir.join(LEDGER_FILE) + } + fn orphans_path(&self) -> PathBuf { + self.ledger_dir.join(ORPHANS_FILE) + } +} + +static SETTINGS: OnceLock = OnceLock::new(); + +/// Enable deferred payouts for this process. Fails if the ledger cannot +/// be read, so a corrupt ledger stops the pool instead of mispaying. +pub fn init(settings: DeferredSettings) -> Result<(), BoxError> { + if settings.max_payout_outputs == 0 { + return Err("max_payout_outputs must be at least 1".into()); + } + fs::create_dir_all(&settings.ledger_dir)?; + let balances = current_balances(&settings)?; + info!( + "Deferred payouts enabled: min {} sats, max {} outputs, ledger {}, {} addresses with a balance", + settings.min_payout_sats, + settings.max_payout_outputs, + settings.ledger_path().display(), + balances.values().filter(|b| **b != 0).count() + ); + SETTINGS + .set(settings) + .map_err(|_| "Deferred payouts already initialised".into()) +} + +/// Settings when deferred payouts are enabled. +pub fn settings() -> Option<&'static DeferredSettings> { + SETTINGS.get() +} + +/// One found block in the ledger. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LedgerEntry { + pub block_hash: String, + pub height: u32, + pub recorded_at: u64, + pub miner_reward_sats: u64, + pub entitlement: BTreeMap, + pub paid: BTreeMap, +} + +/// Sum entitlement minus paid per address, ignoring orphaned blocks and +/// duplicate records of the same block. +pub fn balances_from(entries: &[LedgerEntry], orphans: &HashSet) -> HashMap { + let mut seen = HashSet::new(); + let mut balances: HashMap = HashMap::new(); + for entry in entries { + if orphans.contains(&entry.block_hash) || !seen.insert(entry.block_hash.clone()) { + continue; + } + for (address, amount) in &entry.entitlement { + *balances.entry(address.clone()).or_insert(0) += *amount as i128; + } + for (address, amount) in &entry.paid { + *balances.entry(address.clone()).or_insert(0) -= *amount as i128; + } + } + balances.retain(|_, balance| *balance != 0); + balances +} + +fn read_entries(path: &Path) -> Result, BoxError> { + let content = match fs::read_to_string(path) { + Ok(content) => content, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(format!("Cannot read payout ledger {}: {e}", path.display()).into()), + }; + content + .lines() + .enumerate() + .filter(|(_, line)| !line.trim().is_empty()) + .map(|(index, line)| { + serde_json::from_str(line).map_err(|e| { + format!( + "Corrupt payout ledger {} line {}: {e}", + path.display(), + index + 1 + ) + .into() + }) + }) + .collect() +} + +fn read_orphans(path: &Path) -> Result, BoxError> { + match fs::read_to_string(path) { + Ok(content) => Ok(content + .lines() + .map(|line| line.trim().to_string()) + .filter(|line| !line.is_empty()) + .collect()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(HashSet::new()), + Err(e) => Err(format!("Cannot read orphan list {}: {e}", path.display()).into()), + } +} + +type FileKey = Option<(u64, SystemTime)>; + +fn file_key(path: &Path) -> FileKey { + fs::metadata(path) + .ok() + .and_then(|m| m.modified().ok().map(|t| (m.len(), t))) +} + +struct BalanceCache { + key: (FileKey, FileKey), + balances: HashMap, +} + +static CACHE: Mutex> = Mutex::new(None); + +/// Current balances, re-read only when the ledger or orphan list changed. +pub fn current_balances(settings: &DeferredSettings) -> Result, BoxError> { + let ledger_path = settings.ledger_path(); + let orphans_path = settings.orphans_path(); + let key = (file_key(&ledger_path), file_key(&orphans_path)); + let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(cached) = cache.as_ref() + && cached.key == key + { + return Ok(cached.balances.clone()); + } + let balances = balances_from(&read_entries(&ledger_path)?, &read_orphans(&orphans_path)?); + *cache = Some(BalanceCache { + key, + balances: balances.clone(), + }); + Ok(balances) +} + +/// Choose who is paid in this block and how much. +/// +/// Each address gets a target of its current entitlement plus its ledger +/// balance. Addresses whose target reaches `min_payout_sats` are paid, +/// largest targets first, up to `max_outputs`. If no address reaches the +/// minimum, the largest targets are paid anyway so the reward always goes +/// to miners. The miner reward is split among paid addresses in +/// proportion to their targets, so the unpaid targets are carried over in +/// the ledger and paid in a later block. +pub fn select_payouts( + entitlement: &[OutputPair], + balances: &HashMap, + miner_reward: Amount, + min_payout_sats: u64, + max_outputs: usize, + network: Network, +) -> Result, BoxError> { + if max_outputs == 0 { + return Err("max_payout_outputs must be at least 1".into()); + } + let mut targets: HashMap = HashMap::new(); + for pair in entitlement { + let key = pair.address.to_string(); + let balance = balances.get(&key).copied().unwrap_or(0); + let entry = targets + .entry(key) + .or_insert_with(|| (pair.address.clone(), balance)); + entry.1 += pair.amount.to_sat() as i128; + } + for (key, balance) in balances { + if *balance <= 0 || targets.contains_key(key) { + continue; + } + match key + .parse::>() + .ok() + .and_then(|a| a.require_network(network).ok()) + { + Some(address) => { + targets.insert(key.clone(), (address, *balance)); + } + None => warn!("Ignoring ledger credit for invalid address {key}"), + } + } + + let mut ranked: Vec<(String, Address, u128)> = targets + .into_iter() + .filter(|(_, (_, target))| *target > 0) + .map(|(key, (address, target))| (key, address, target as u128)) + .collect(); + if ranked.is_empty() { + return Ok(entitlement.to_vec()); + } + ranked.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0))); + + let mut payees: Vec<(String, Address, u128)> = ranked + .iter() + .filter(|(_, _, target)| *target >= min_payout_sats as u128) + .take(max_outputs) + .cloned() + .collect(); + if payees.is_empty() { + payees = ranked.into_iter().take(max_outputs).collect(); + } + payees.sort_by(|a, b| a.0.cmp(&b.0)); + + let total_target: u128 = payees.iter().map(|(_, _, target)| *target).sum(); + let reward = miner_reward.to_sat() as u128; + let mut distributed: u128 = 0; + let count = payees.len(); + let mut result = Vec::with_capacity(count); + for (index, (_, address, target)) in payees.into_iter().enumerate() { + let amount = if index == count - 1 { + reward - distributed + } else { + reward * target / total_target + }; + distributed += amount; + if amount > 0 { + result.push(OutputPair { + address, + amount: Amount::from_sat(amount as u64), + }); + } + } + Ok(result) +} + +/// Cut computed exactly like include_address_and_cut. +fn cut(amount: u64, basis_points: Option, address: &Option
) -> u64 { + match (address, basis_points.filter(|bp| *bp > 0)) { + (Some(_), Some(bp)) => amount + .checked_mul(bp as u64) + .map(|v| v / BASIS_POINT_FACTOR) + .unwrap_or(0), + _ => 0, + } +} + +/// Build the ledger entry for a found block from its coinbase and the +/// entitlement computed when it was found. +pub fn build_entry( + settings: &DeferredSettings, + block: &Block, + height: u32, + coinbasevalue: u64, + entitlement: &[OutputPair], +) -> Result { + let coinbase = block.txdata.first().ok_or("Block without coinbase")?; + let donation_cut = cut(coinbasevalue, settings.donation, &settings.donation_address); + let after_donation = coinbasevalue - donation_cut; + let fee_cut = cut(after_donation, settings.fee, &settings.fee_address); + let miner_reward = after_donation - fee_cut; + + let mut paid: BTreeMap = BTreeMap::new(); + for output in &coinbase.output { + if output.script_pubkey.is_op_return() || output.value == Amount::ZERO { + continue; + } + let address = Address::from_script(&output.script_pubkey, settings.network) + .map_err(|e| format!("Coinbase output without address: {e}"))?; + *paid.entry(address.to_string()).or_insert(0) += output.value.to_sat(); + } + for (address, amount) in [ + (&settings.donation_address, donation_cut), + (&settings.fee_address, fee_cut), + ] { + if let Some(address) = address + && amount > 0 + { + let key = address.to_string(); + let value = paid.get(&key).copied().unwrap_or(0); + if value < amount { + return Err(format!("Coinbase pays {key} less than its cut").into()); + } + if value == amount { + paid.remove(&key); + } else { + paid.insert(key, value - amount); + } + } + } + + let mut entitlement_map: BTreeMap = BTreeMap::new(); + for pair in entitlement { + *entitlement_map.entry(pair.address.to_string()).or_insert(0) += pair.amount.to_sat(); + } + + let paid_total: u64 = paid.values().sum(); + let entitled_total: u64 = entitlement_map.values().sum(); + if paid_total != miner_reward || entitled_total != miner_reward { + warn!( + "Ledger totals differ for block {}: reward {miner_reward}, paid {paid_total}, entitled {entitled_total}", + block.block_hash() + ); + } + + Ok(LedgerEntry { + block_hash: block.block_hash().to_string(), + height, + recorded_at: SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + miner_reward_sats: miner_reward, + entitlement: entitlement_map, + paid, + }) +} + +/// Append an entry to the ledger with a single write, then fsync. +pub fn append_entry(settings: &DeferredSettings, entry: &LedgerEntry) -> Result<(), BoxError> { + let mut line = serde_json::to_string(entry)?; + line.push('\n'); + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(settings.ledger_path())?; + file.write_all(line.as_bytes())?; + file.sync_all()?; + Ok(()) +} + +/// Record a block accepted by bitcoind. The entitlement is the PPLNS +/// split of the miner reward over the shares in the window now. +pub fn record_found_block( + settings: &DeferredSettings, + block: &Block, + template: &BlockTemplate, + store: &ChainStoreHandle, +) { + let result = (|| -> Result, BoxError> { + let compact = bitcoin::pow::CompactTarget::from_unprefixed_hex(&template.bits)?; + let total_difficulty = bitcoin::Target::from_compact(compact) + .difficulty(settings.network) + // At least 1, so the window is never empty on test networks + .max(1) + .saturating_mul(settings.difficulty_multiplier as u128); + let difficulty_map = Payout::new(STEP_SIZE_SECONDS) + .accumulate_difficulty_by_address(store, total_difficulty)?; + if difficulty_map.is_empty() { + return Ok(None); + } + let donation_cut = cut( + template.coinbasevalue, + settings.donation, + &settings.donation_address, + ); + let after_donation = template.coinbasevalue - donation_cut; + let miner_reward = + after_donation - cut(after_donation, settings.fee, &settings.fee_address); + let mut entitlement = Vec::with_capacity(difficulty_map.len()); + append_proportional_distribution( + &difficulty_map, + Amount::from_sat(miner_reward), + &mut entitlement, + )?; + let entry = build_entry( + settings, + block, + template.height, + template.coinbasevalue, + &entitlement, + )?; + append_entry(settings, &entry)?; + Ok(Some(entry)) + })(); + match result { + Ok(Some(entry)) => info!( + "Recorded block {} in payout ledger: {} entitled, {} paid", + entry.block_hash, + entry.entitlement.len(), + entry.paid.len() + ), + Ok(None) => warn!( + "Block {} not recorded in payout ledger: no PPLNS shares", + block.block_hash() + ), + Err(e) => error!( + "FAILED to record block {} in payout ledger: {e}. Add it manually before the next block.", + block.block_hash() + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::parse_address_from_string; + + const A: &str = "tb1qyazxde6558qj6z3d9np5e6msmrspwpf6k0qggk"; + const B: &str = "tb1qj08dejavm4ceu7l9qr64r4xewp0qrrzuz8ngaj"; + const C: &str = "tb1q3udk7r26qs32ltf9nmqrjaaa7tr55qmkk30q5d"; + + fn pair(address: &str, sats: u64) -> OutputPair { + OutputPair { + address: parse_address_from_string(address), + amount: Amount::from_sat(sats), + } + } + + fn amounts(pairs: &[OutputPair]) -> BTreeMap { + pairs + .iter() + .map(|p| (p.address.to_string(), p.amount.to_sat())) + .collect() + } + + fn entry(hash: &str, entitled: &[OutputPair], paid: &[OutputPair]) -> LedgerEntry { + LedgerEntry { + block_hash: hash.to_string(), + height: 1, + recorded_at: 0, + miner_reward_sats: entitled.iter().map(|p| p.amount.to_sat()).sum(), + entitlement: amounts(entitled), + paid: amounts(paid), + } + } + + #[test] + fn small_miner_is_deferred_then_paid() { + let reward = Amount::from_sat(100_000); + let block1_entitlement = vec![pair(A, 95_000), pair(B, 5_000)]; + let paid1 = select_payouts( + &block1_entitlement, + &HashMap::new(), + reward, + 10_000, + 10, + Network::Testnet4, + ) + .unwrap(); + assert_eq!(amounts(&paid1), amounts(&[pair(A, 100_000)])); + + let ledger = vec![entry("h1", &block1_entitlement, &paid1)]; + let balances = balances_from(&ledger, &HashSet::new()); + assert_eq!(balances.get(B), Some(&5_000)); + assert_eq!(balances.get(A), Some(&-5_000)); + assert_eq!(balances.values().sum::(), 0); + + // Second block: B's credit plus new share reaches the minimum. + let block2_entitlement = vec![pair(A, 94_000), pair(B, 6_000)]; + let paid2 = select_payouts( + &block2_entitlement, + &balances, + reward, + 10_000, + 10, + Network::Testnet4, + ) + .unwrap(); + assert_eq!( + amounts(&paid2), + amounts(&[pair(A, 89_000), pair(B, 11_000)]) + ); + + let mut ledger = ledger; + ledger.push(entry("h2", &block2_entitlement, &paid2)); + let balances = balances_from(&ledger, &HashSet::new()); + assert!(balances.is_empty(), "everyone paid exactly: {balances:?}"); + } + + #[test] + fn credit_is_paid_even_when_miner_stopped() { + let balances = HashMap::from([(C.to_string(), 20_000i128), (A.to_string(), -20_000)]); + let paid = select_payouts( + &[pair(A, 100_000)], + &balances, + Amount::from_sat(100_000), + 10_000, + 10, + Network::Testnet4, + ) + .unwrap(); + assert_eq!(amounts(&paid), amounts(&[pair(A, 80_000), pair(C, 20_000)])); + } + + #[test] + fn output_cap_keeps_largest_and_defers_rest() { + let entitlement = vec![pair(A, 50_000), pair(B, 30_000), pair(C, 20_000)]; + let paid = select_payouts( + &entitlement, + &HashMap::new(), + Amount::from_sat(100_000), + 1, + 2, + Network::Testnet4, + ) + .unwrap(); + assert_eq!(amounts(&paid), amounts(&[pair(A, 62_500), pair(B, 37_500)])); + let balances = balances_from(&[entry("h", &entitlement, &paid)], &HashSet::new()); + assert_eq!(balances.get(C), Some(&20_000)); + } + + #[test] + fn everyone_below_minimum_still_paid() { + let entitlement = vec![pair(A, 600), pair(B, 400)]; + let paid = select_payouts( + &entitlement, + &HashMap::new(), + Amount::from_sat(1_000), + 10_000, + 10, + Network::Testnet4, + ) + .unwrap(); + assert_eq!(amounts(&paid), amounts(&entitlement)); + } + + #[test] + fn total_is_always_the_miner_reward() { + let entitlement = vec![pair(A, 33_333), pair(B, 33_333), pair(C, 33_334)]; + let balances = HashMap::from([(B.to_string(), 777i128), (A.to_string(), -777)]); + let paid = select_payouts( + &entitlement, + &balances, + Amount::from_sat(100_000), + 1, + 10, + Network::Testnet4, + ) + .unwrap(); + assert_eq!(paid.iter().map(|p| p.amount.to_sat()).sum::(), 100_000); + } + + #[test] + fn orphaned_and_duplicate_blocks_ignored() { + let e = entry("h1", &[pair(A, 10)], &[pair(B, 10)]); + let ledger = vec![e.clone(), e.clone()]; + assert_eq!(balances_from(&ledger, &HashSet::new()).get(A), Some(&10)); + assert!(balances_from(&ledger, &HashSet::from(["h1".to_string()])).is_empty()); + } + + #[test] + fn ledger_round_trip_and_corruption_detected() { + let dir = tempfile::tempdir().unwrap(); + let settings = DeferredSettings { + min_payout_sats: 1, + max_payout_outputs: 1, + ledger_dir: dir.path().to_path_buf(), + network: Network::Testnet4, + difficulty_multiplier: 1.0, + donation_address: None, + donation: None, + fee_address: None, + fee: None, + }; + let e = entry("h1", &[pair(A, 10)], &[pair(B, 10)]); + append_entry(&settings, &e).unwrap(); + append_entry(&settings, &e).unwrap(); + assert_eq!( + read_entries(&settings.ledger_path()).unwrap(), + vec![e.clone(), e] + ); + fs::write(settings.ledger_path(), "{not json\n").unwrap(); + assert!(read_entries(&settings.ledger_path()).is_err()); + } +} diff --git a/p2poolv2_lib/src/accounting/payout/simple_pplns/mod.rs b/p2poolv2_lib/src/accounting/payout/simple_pplns/mod.rs index 1cc36bb..9ca1c9b 100644 --- a/p2poolv2_lib/src/accounting/payout/simple_pplns/mod.rs +++ b/p2poolv2_lib/src/accounting/payout/simple_pplns/mod.rs @@ -17,6 +17,7 @@ use bitcoin::consensus::{Decodable, Encodable}; use serde::{Deserialize, Serialize}; +pub mod deferred; pub mod payout; /// PPLNS share representation diff --git a/p2poolv2_lib/src/accounting/payout/simple_pplns/payout.rs b/p2poolv2_lib/src/accounting/payout/simple_pplns/payout.rs index 67fab27..fabd245 100644 --- a/p2poolv2_lib/src/accounting/payout/simple_pplns/payout.rs +++ b/p2poolv2_lib/src/accounting/payout/simple_pplns/payout.rs @@ -18,6 +18,7 @@ use crate::accounting::OutputPair; use crate::accounting::payout::payout_distribution::{ PayoutDistribution, append_proportional_distribution, }; +use crate::accounting::payout::simple_pplns::deferred; #[cfg(test)] #[mockall_double::double] use crate::shares::chain::chain_store_handle::ChainStoreHandle; @@ -52,7 +53,33 @@ impl PayoutDistribution for Payout { let address_difficulty_map = self.accumulate_difficulty_by_address(chain_store_handle, total_difficulty)?; - if address_difficulty_map.is_empty() { + if let Some(settings) = deferred::settings() { + let mut entitlement = Vec::with_capacity(address_difficulty_map.len()); + if !address_difficulty_map.is_empty() { + append_proportional_distribution( + &address_difficulty_map, + remaining_total_amount, + &mut entitlement, + )?; + } + let balances = deferred::current_balances(settings)?; + let selected = deferred::select_payouts( + &entitlement, + &balances, + remaining_total_amount, + settings.min_payout_sats, + settings.max_payout_outputs, + settings.network, + )?; + if selected.is_empty() { + *distribution = vec![OutputPair { + address: bootstrap_address, + amount: total_amount, + }]; + } else { + distribution.extend(selected); + } + } else if address_difficulty_map.is_empty() { *distribution = vec![OutputPair { address: bootstrap_address, amount: total_amount, @@ -90,7 +117,7 @@ impl Payout { /// /// Continues querying additional time windows if total difficulty hasn't /// been reached. - fn accumulate_difficulty_by_address( + pub(crate) fn accumulate_difficulty_by_address( &self, store: &ChainStoreHandle, total_difficulty: u128, diff --git a/p2poolv2_lib/src/stratum/message_handlers/submit.rs b/p2poolv2_lib/src/stratum/message_handlers/submit.rs index 525e589..52176e4 100644 --- a/p2poolv2_lib/src/stratum/message_handlers/submit.rs +++ b/p2poolv2_lib/src/stratum/message_handlers/submit.rs @@ -15,6 +15,8 @@ // P2Poolv2. If not, see . use crate::accounting::payout::simple_pplns::SimplePplnsShare; +#[cfg(not(test))] +use crate::accounting::payout::simple_pplns::deferred; use crate::config::PoolMode; use crate::shares::extranonce::Extranonce; use crate::stratum::{ @@ -125,7 +127,20 @@ pub(crate) async fn handle_submit<'a, D: DifficultyAdjusterTrait>( validation_result.coinbase.clone(), &job.blocktemplate, ); - submit_block(&block, &stratum_context.bitcoindrpc_client).await; + let accepted = submit_block(&block, &stratum_context.bitcoindrpc_client).await; + // Payout ledger types use the mocked chain store under test, and + // deferred payouts are never enabled in unit tests. + #[cfg(test)] + let _ = accepted; + #[cfg(not(test))] + if accepted && let Some(settings) = deferred::settings() { + deferred::record_found_block( + settings, + &block, + &job.blocktemplate, + &stratum_context.chain_store_handle, + ); + } } // In P2Poolv2 mode, reject shares that do not meet the pool difficulty target. @@ -231,14 +246,28 @@ pub(crate) async fn handle_submit<'a, D: DifficultyAdjusterTrait>( } /// Submit block to bitcoind using the shared RPC client. -pub async fn submit_block(block: &Block, bitcoindrpc_client: &BitcoindRpcClient) { +/// +/// Returns true when bitcoind accepted the block (or already had it). +pub async fn submit_block(block: &Block, bitcoindrpc_client: &BitcoindRpcClient) -> bool { tracing::info!( "Submitting block to bitcoind: {:?}", block.header.block_hash() ); match bitcoindrpc_client.submit_block(block).await { - Ok(_) => info!("Block submitted successfully"), - Err(e) => error!("Failed to submit block: {}", e), + Ok(result) + if result == "null" || result == "\"duplicate\"" || result == "\"inconclusive\"" => + { + info!("Block submitted successfully: {result}"); + true + } + Ok(result) => { + error!("Block rejected by bitcoind: {result}"); + false + } + Err(e) => { + error!("Failed to submit block: {}", e); + false + } } } diff --git a/p2poolv2_lib/src/stratum/validate_username.rs b/p2poolv2_lib/src/stratum/validate_username.rs index 3e3b445..38121c1 100644 --- a/p2poolv2_lib/src/stratum/validate_username.rs +++ b/p2poolv2_lib/src/stratum/validate_username.rs @@ -86,6 +86,14 @@ pub fn validate( "Expected an address for network {network}", )) })?; + // Legacy P2PKH outputs add signature operations to the coinbase, + // which can make a block with many payouts invalid. + if checked.address_type() == Some(bitcoin::AddressType::P2pkh) { + return Err(UsernameValidationError::InvalidAddress( + "Legacy addresses starting with 1 or m/n are not supported, use a bc1 address" + .to_string(), + )); + } Some(checked) } else { None @@ -157,6 +165,19 @@ mod tests { )); } + #[test] + fn test_legacy_p2pkh_address_rejected() { + let result = validate( + "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa.worker", + true, + Network::Bitcoin, + ); + assert!(matches!( + result.unwrap_err(), + UsernameValidationError::InvalidAddress(_) + )); + } + #[test] fn test_worker_name_too_long() { let mainnet_address = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"; @@ -171,7 +192,7 @@ mod tests { #[test] fn test_worker_name_max_length() { - let mainnet_address = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"; + let mainnet_address = "bc1qj08dejavm4ceu7l9qr64r4xewp0qrrzugpgmxp"; let max_worker = format!("{}.{}", mainnet_address, "a".repeat(29)); let result = validate(&max_worker, true, Network::Bitcoin); assert!(result.is_ok()); @@ -179,7 +200,7 @@ mod tests { #[test] fn test_multiple_dots_in_username() { - let mainnet_address = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"; + let mainnet_address = "bc1qj08dejavm4ceu7l9qr64r4xewp0qrrzugpgmxp"; let multiple_dots = format!("{}.worker.with.dots", mainnet_address); let result = validate(&multiple_dots, true, Network::Bitcoin); assert!(result.is_ok()); diff --git a/p2poolv2_lib/src/stratum/work/notify.rs b/p2poolv2_lib/src/stratum/work/notify.rs index 32ef935..7f17531 100644 --- a/p2poolv2_lib/src/stratum/work/notify.rs +++ b/p2poolv2_lib/src/stratum/work/notify.rs @@ -58,6 +58,8 @@ fn build_output_distribution( let total_difficulty = required_target .difficulty(context.config.network) + // At least 1, so the window is never empty on test networks + .max(1) .saturating_mul(context.config.difficulty_multiplier as u128); match context.payout.get_output_distribution( diff --git a/p2poolv2_node/src/main.rs b/p2poolv2_node/src/main.rs index fe89cf8..60a369a 100644 --- a/p2poolv2_node/src/main.rs +++ b/p2poolv2_node/src/main.rs @@ -17,8 +17,10 @@ use clap::Parser; use p2poolv2_api::start_api_server; use p2poolv2_lib::accounting::payout::build_payout_for_mode; +use p2poolv2_lib::accounting::payout::simple_pplns::deferred::{self, DeferredSettings}; use p2poolv2_lib::accounting::stats::metrics; use p2poolv2_lib::config::Config; +use p2poolv2_lib::config::PoolMode; use p2poolv2_lib::logging::setup_logging; use p2poolv2_lib::node::actor::NodeHandle; use p2poolv2_lib::pool_difficulty::PoolDifficulty; @@ -206,6 +208,32 @@ async fn main() -> ExitCode { let pool_difficulty_for_notify = PoolDifficulty::build(&chain_store_handle).expect("Failed to build pool difficulty"); + if let Some(ledger_dir) = &stratum_config.payout_ledger_dir { + if stratum_config.mode != PoolMode::Hydrapool { + error!("payout_ledger_dir is only supported in hydrapool mode"); + return ExitCode::FAILURE; + } + let settings = DeferredSettings { + min_payout_sats: stratum_config + .min_payout_sats + .unwrap_or(deferred::DEFAULT_MIN_PAYOUT_SATS), + max_payout_outputs: stratum_config + .max_payout_outputs + .unwrap_or(deferred::DEFAULT_MAX_PAYOUT_OUTPUTS), + ledger_dir: ledger_dir.into(), + network: stratum_config.network, + difficulty_multiplier: stratum_config.difficulty_multiplier, + donation_address: stratum_config.donation_address_parsed.clone(), + donation: stratum_config.donation, + fee_address: stratum_config.fee_address_parsed.clone(), + fee: stratum_config.fee, + }; + if let Err(e) = deferred::init(settings) { + error!("Failed to initialise deferred payouts: {e}"); + return ExitCode::FAILURE; + } + } + let cloned_stratum_config = stratum_config.clone(); let (payout, shared_pplns_window) = build_payout_for_mode(stratum_config.mode, cloned_stratum_config.network); -- 2.43.0