diff --git a/src/impl/dash/config_pool.hpp b/src/impl/dash/config_pool.hpp index b87be24ae..25570b4b1 100644 --- a/src/impl/dash/config_pool.hpp +++ b/src/impl/dash/config_pool.hpp @@ -63,6 +63,17 @@ struct PoolConfig static const std::string& identifier_hex() { return is_testnet ? TESTNET_IDENTIFIER_HEX : IDENTIFIER_HEX; } static const std::string& prefix_hex() { return is_testnet ? TESTNET_PREFIX_HEX : PREFIX_HEX; } + // ---- Dust threshold (payout-dust semantic) ----------------------------- + // DUST_THRESHOLD: minimum per-recipient payout to justify a coinbase output. + // SOURCE: p2pool-dash oracle DUST_THRESHOLD = 0.001e8 = 100000 satoshi + // (PARENT.DUST_THRESHOLD). This is the PAYOUT-dust floor, NOT the dashd relay + // policy floor (5460/54600) which is wrong-semantic for the PPLNS path. + // V36 Option-A conform-to-p2pool: 100000 is the V36-correct value, matching + // the BTC/BCH/DGB sibling payout-dust semantic. + static constexpr uint64_t DUST_THRESHOLD = 100000; // satoshi (mainnet) + static constexpr uint64_t TESTNET_DUST_THRESHOLD = 100000; // satoshi (testnet: oracle carries no separate floor) + static uint64_t dust_threshold() { return is_testnet ? TESTNET_DUST_THRESHOLD : DUST_THRESHOLD; } + // MAX_TARGET: easiest allowed share difficulty (share-diff floor). // mainnet : 0xFFFF * 2**208 (standard bdiff difficulty-1 target) // testnet : 2**256 // 2**20 - 1 diff --git a/src/impl/dash/params.hpp b/src/impl/dash/params.hpp index ecf3b4a45..1994a65ff 100644 --- a/src/impl/dash/params.hpp +++ b/src/impl/dash/params.hpp @@ -83,7 +83,10 @@ inline core::CoinParams make_coin_params(bool testnet, const PoolOverrides& over return subsidy; }; - p.dust_threshold = 5460; // [confirm-vs-oracle] legacy DASH dust floor (relay policy, not consensus) + // Dust: payout-dust floor from the SSOT (config_pool.hpp). Was 5460 (dashd + // relay-policy floor, wrong semantic for the PPLNS payout path) -- reconciled + // to the oracle payout-dust value 100000 per V36 Option-A conform-to-p2pool. + p.dust_threshold = PoolConfig::dust_threshold(); // Softforks: DASH older baseline has NO segwit (segwit_activation_version=0). p.softforks_required = {}; diff --git a/src/impl/dash/pplns.hpp b/src/impl/dash/pplns.hpp index ef1da564c..3fa1cd017 100644 --- a/src/impl/dash/pplns.hpp +++ b/src/impl/dash/pplns.hpp @@ -55,8 +55,7 @@ inline Result compute_payouts( size_t window_size, uint64_t miner_value, const std::vector& fallback_script, - size_t min_shares_for_pplns = 20, - uint64_t dust_threshold = 54600) // Dash dust sat + size_t min_shares_for_pplns = 20) { Result r; if (miner_value == 0 || fallback_script.empty()) return r; @@ -136,13 +135,19 @@ inline Result compute_payouts( return r; } - // Proportional split with dust handling. + // Proportional split — oracle conformance (p2pool-dash data.py + // get_expected_payouts): every NONZERO worker allocation is paid to its + // own script; only EXACTLY-zero outputs are dropped (oracle `if + // amounts[script]`). There is NO payout dust floor — a sub-dust but + // nonzero worker is paid, not swept to donation. (PoolConfig:: + // dust_threshold() survives only as the vardiff / share-difficulty floor + // in c2pool_refactored.cpp; it is not consulted here.) std::vector tentative; uint64_t allocated = 0; for (auto& [key, e] : by_key) { double frac = e.weight / total_weight; uint64_t amt = static_cast(frac * static_cast(miner_value)); - if (amt < dust_threshold) continue; + if (amt == 0) continue; // oracle drops only zero-value outputs tentative.push_back({ dash::pubkey_hash_to_script2(e.pubkey_hash), amt diff --git a/test/test_dash_conformance.cpp b/test/test_dash_conformance.cpp index be10b7173..a8b4b986c 100644 --- a/test/test_dash_conformance.cpp +++ b/test/test_dash_conformance.cpp @@ -537,6 +537,229 @@ TEST(DashConformancePplns, ColdChainFallsBackToSingleRecipient) { EXPECT_EQ(r2.payouts[0].amount, V); } +// ── S6 conformance: payout dust-threshold (A1–A4) vs frstrtr/p2pool-dash ────── +// +// V36 oracle-conformance (operator 2026-06-18 "CONFORM TO ORACLE", option a): +// the PAYOUT path carries NO dust floor. Every worker whose proportional split +// floor(weight_frac * miner_value) is NONZERO is paid that amount to its own +// script, exactly as p2pool-dash data.py get_expected_payouts does +// (amounts[script] = subsidy*weight//total_weight). Only EXACTLY-zero outputs +// are dropped (oracle `if amounts[script]`). The always-emitted DONATION line +// carries ONLY the rounding remainder (miner_value - Σworkers), which may be 0. +// +// dash::PoolConfig::dust_threshold() (100000 duff) survives as the vardiff / +// share-difficulty floor (c2pool_refactored.cpp mirror of work.py:326); it is +// NO LONGER consulted in the payout path. These KATs pin the oracle behaviour: +// a sub-dust-but-nonzero worker is PAID, not swept to donation. Amounts are +// OUT-OF-BAND oracle arithmetic; per-share work = diff-1 so each share weighs +// exactly 1.0 and the fractions below are exact. + +// A1 + A2: a worker whose proportional split is nonzero but below the old +// 100000 floor is PAID to its own script (oracle pays every nonzero output); +// the donation line carries only the rounding remainder. A has 3 shares, B has +// 1 (weights 3.0 / 1.0); V = 200000. A = floor(0.75*200000) = 150000; B = +// floor(0.25*200000) = 50000 (sub-dust, PAID); residue = 0 -> donation = 0. +TEST(DashConformancePayoutDust, SubDustNonzeroWorkerPaidNotSwept) { + SyntheticChain sc; + const uint160 A = miner_h160(0x07), B = miner_h160(0x08); + uint256 g = sc.add(0x60, uint256(), 0, B); // genesis: miner B (1 share) + uint256 s1 = sc.add(0x61, g, 0, A); // miner A + uint256 s2 = sc.add(0x62, s1, 0, A); // miner A + uint256 tip = sc.add(0x63, s2, 0, A); // tip: miner A (A has 3) + + const uint64_t V = 200000; + auto fallback = dash::pubkey_hash_to_script2(miner_h160(0xef)); + auto r = dash::pplns::compute_payouts(sc.chain, tip, /*window*/ 10, V, + fallback, /*min_shares*/ 2); + + ASSERT_FALSE(r.used_fallback); + EXPECT_EQ(r.shares_used, 4u); + + const std::string a_hex = script_hex(dash::pubkey_hash_to_script2(A)); + const std::string b_hex = script_hex(dash::pubkey_hash_to_script2(B)); + const std::string d_hex = script_hex(dash::DONATION_SCRIPT); + + const std::map expected = { + {a_hex, 150000u}, + {b_hex, 50000u}, + {d_hex, 0u}, + }; + EXPECT_EQ(payout_set(r), expected); + + // A1: sub-dust worker B is PAID its own output (oracle pays all nonzero). + EXPECT_EQ(payout_set(r).at(b_hex), 50000u) << "sub-dust nonzero worker B must be paid"; + + // A2: donation carries only the rounding remainder (here 0), NOT B's value. + EXPECT_EQ(payout_set(r).at(d_hex), 0u) << "donation is the rounding remainder only"; + + // A2: conservation — every duff of miner_value accounted for. + uint64_t sum = 0; + for (const auto& p : r.payouts) sum += p.amount; + EXPECT_EQ(sum, V); +} + +// A2b: every nonzero worker near the old threshold is paid. Same chain, +// V = 400000: B = floor(0.25*400000) = 100000 (paid), A = 300000, residue 0 -> +// donation 0. Under the oracle there is no `<`/`<=` boundary at all — any +// nonzero output is kept; this guards that the region at the old floor is paid. +TEST(DashConformancePayoutDust, ThresholdRegionNonzeroWorkersAllPaid) { + SyntheticChain sc; + const uint160 A = miner_h160(0x07), B = miner_h160(0x08); + uint256 g = sc.add(0x64, uint256(), 0, B); + uint256 s1 = sc.add(0x65, g, 0, A); + uint256 s2 = sc.add(0x66, s1, 0, A); + uint256 tip = sc.add(0x67, s2, 0, A); + + const uint64_t V = 400000; + auto fallback = dash::pubkey_hash_to_script2(miner_h160(0xef)); + auto r = dash::pplns::compute_payouts(sc.chain, tip, /*window*/ 10, V, + fallback, /*min_shares*/ 2); + + ASSERT_FALSE(r.used_fallback); + const std::map expected = { + {script_hex(dash::pubkey_hash_to_script2(A)), 300000u}, + {script_hex(dash::pubkey_hash_to_script2(B)), 100000u}, + {script_hex(dash::DONATION_SCRIPT), 0u}, + }; + EXPECT_EQ(payout_set(r), expected); + + uint64_t sum = 0; + for (const auto& p : r.payouts) sum += p.amount; + EXPECT_EQ(sum, V); +} + +// A3 (donation = rounding remainder only): the always-emitted donation line +// carries exactly miner_value - Σworkers, nothing else. A has 3 shares, B has 1; +// V = 200002 (not divisible by the weight total 4). A = floor(0.75*200002) = +// 150001; B = floor(0.25*200002) = 50000 (sub-dust, PAID); allocated = 200001; +// residue = 1 -> donation = 1. Proves the donation line is the remainder sink, +// not a dust sink, and that a sub-dust worker is paid alongside it. +TEST(DashConformancePayoutDust, DonationLineIsRoundingRemainderOnly) { + SyntheticChain sc; + const uint160 A = miner_h160(0x07), B = miner_h160(0x08); + uint256 g = sc.add(0x68, uint256(), 0, B); + uint256 s1 = sc.add(0x69, g, 0, A); + uint256 s2 = sc.add(0x6a, s1, 0, A); + uint256 tip = sc.add(0x6b, s2, 0, A); + + const uint64_t V = 200002; + auto fallback = dash::pubkey_hash_to_script2(miner_h160(0xef)); + auto r = dash::pplns::compute_payouts(sc.chain, tip, /*window*/ 10, V, + fallback, /*min_shares*/ 2); + + ASSERT_FALSE(r.used_fallback); + const auto set = payout_set(r); + const std::string a_hex = script_hex(dash::pubkey_hash_to_script2(A)); + const std::string b_hex = script_hex(dash::pubkey_hash_to_script2(B)); + const std::string d_hex = script_hex(dash::DONATION_SCRIPT); + + EXPECT_EQ(set.at(a_hex), 150001u); + EXPECT_EQ(set.at(b_hex), 50000u) << "sub-dust nonzero worker still paid"; + ASSERT_EQ(set.count(d_hex), 1u) << "donation line must always be emitted"; + EXPECT_EQ(set.at(d_hex), 1u) << "donation == miner_value - sum(workers) (rounding remainder)"; + + uint64_t sum = 0; + for (const auto& p : r.payouts) sum += p.amount; + EXPECT_EQ(sum, V); +} + +// A4a (generated boundary, just-BELOW the old floor): 3:1 split, V = 399996. +// B = floor(399996/4) = 99999 -> under the OLD floor it was dropped; under the +// oracle it is PAID. A = floor(3*399996/4) = 299997; residue 0 -> donation 0. +// One duff under the old floor must now be paid to the worker. +TEST(DashConformancePayoutDust, GeneratedBoundaryJustBelowNowPaid) { + SyntheticChain sc; + const uint160 A = miner_h160(0x07), B = miner_h160(0x08); + uint256 g = sc.add(0x70, uint256(), 0, B); + uint256 s1 = sc.add(0x71, g, 0, A); + uint256 s2 = sc.add(0x72, s1, 0, A); + uint256 tip = sc.add(0x73, s2, 0, A); + + const uint64_t V = 399996; + auto fallback = dash::pubkey_hash_to_script2(miner_h160(0xef)); + auto r = dash::pplns::compute_payouts(sc.chain, tip, /*window*/ 10, V, + fallback, /*min_shares*/ 2); + + ASSERT_FALSE(r.used_fallback); + const std::string a_hex = script_hex(dash::pubkey_hash_to_script2(A)); + const std::string b_hex = script_hex(dash::pubkey_hash_to_script2(B)); + const std::string d_hex = script_hex(dash::DONATION_SCRIPT); + + const std::map expected = { + {a_hex, 299997u}, + {b_hex, 99999u}, + {d_hex, 0u}, + }; + EXPECT_EQ(payout_set(r), expected); + EXPECT_EQ(payout_set(r).at(b_hex), 99999u) + << "99999 (one duff under the old floor) must now be paid"; + + uint64_t sum = 0; + for (const auto& p : r.payouts) sum += p.amount; + EXPECT_EQ(sum, V); +} + +// A4b (generated boundary, just-ABOVE the old floor): same split, V = 400004. +// B = floor(400004/4) = 100001 -> paid (as before); A = floor(3*400004/4) = +// 300003; residue 0 -> donation 0. Symmetric partner to A4a. +TEST(DashConformancePayoutDust, GeneratedBoundaryJustAbovePaid) { + SyntheticChain sc; + const uint160 A = miner_h160(0x07), B = miner_h160(0x08); + uint256 g = sc.add(0x74, uint256(), 0, B); + uint256 s1 = sc.add(0x75, g, 0, A); + uint256 s2 = sc.add(0x76, s1, 0, A); + uint256 tip = sc.add(0x77, s2, 0, A); + + const uint64_t V = 400004; + auto fallback = dash::pubkey_hash_to_script2(miner_h160(0xef)); + auto r = dash::pplns::compute_payouts(sc.chain, tip, /*window*/ 10, V, + fallback, /*min_shares*/ 2); + + ASSERT_FALSE(r.used_fallback); + const std::map expected = { + {script_hex(dash::pubkey_hash_to_script2(A)), 300003u}, + {script_hex(dash::pubkey_hash_to_script2(B)), 100001u}, + {script_hex(dash::DONATION_SCRIPT), 0u}, + }; + EXPECT_EQ(payout_set(r), expected); + + uint64_t sum = 0; + for (const auto& p : r.payouts) sum += p.amount; + EXPECT_EQ(sum, V); +} + +// A5 (surviving zero-filter): an EXACTLY-zero proportional split is the only +// thing still dropped (oracle `if amounts[script]`). 3:1 split, V = 3: +// B = floor(0.25*3) = 0 -> DROPPED (zero output); A = floor(0.75*3) = 2; +// residue = 1 -> donation = 1. Pins that the zero filter survives the floor's +// removal. +TEST(DashConformancePayoutDust, ExactlyZeroWorkerOutputDropped) { + SyntheticChain sc; + const uint160 A = miner_h160(0x07), B = miner_h160(0x08); + uint256 g = sc.add(0x78, uint256(), 0, B); + uint256 s1 = sc.add(0x79, g, 0, A); + uint256 s2 = sc.add(0x7a, s1, 0, A); + uint256 tip = sc.add(0x7b, s2, 0, A); + + const uint64_t V = 3; + auto fallback = dash::pubkey_hash_to_script2(miner_h160(0xef)); + auto r = dash::pplns::compute_payouts(sc.chain, tip, /*window*/ 10, V, + fallback, /*min_shares*/ 2); + + ASSERT_FALSE(r.used_fallback); + const std::string a_hex = script_hex(dash::pubkey_hash_to_script2(A)); + const std::string b_hex = script_hex(dash::pubkey_hash_to_script2(B)); + const std::string d_hex = script_hex(dash::DONATION_SCRIPT); + + EXPECT_EQ(payout_set(r).count(b_hex), 0u) << "exactly-zero worker output must be dropped"; + EXPECT_EQ(payout_set(r).at(a_hex), 2u); + EXPECT_EQ(payout_set(r).at(d_hex), 1u); + + uint64_t sum = 0; + for (const auto& p : r.payouts) sum += p.amount; + EXPECT_EQ(sum, V); +} + // ── S6 conformance: share-version transition negotiation vs frstrtr/p2pool-dash ── // // The older-than-v35 -> v36 handshake. Two tallies are kept deliberately apart