From 347953ab7cbb6df4aa1ebc511ca21efdcb2842cc Mon Sep 17 00:00:00 2001 From: frstrtr Date: Thu, 18 Jun 2026 05:23:33 +0000 Subject: [PATCH 1/4] dash(s6): reconcile payout dust_threshold to oracle 100000 (V36 Option-A) The DASH payout-dust floor was split across two wrong-semantic relay-policy values: params.hpp carried 5460 (dashd relay floor) and pplns compute_payouts defaulted to 54600. Neither matches the p2pool-dash oracle payout-dust semantic (PARENT.DUST_THRESHOLD = 0.001e8 = 100000 satoshi), which is the value the BTC/BCH/DGB siblings already conform to. Add dash::PoolConfig::DUST_THRESHOLD (100000) as the single SSOT and route both the CoinParams factory (params.hpp) and the PPLNS filter default (pplns.hpp) to PoolConfig::dust_threshold(). No behavior change for existing payouts (all worker splits exceed 100000; the unconditional donation line is unaffected). GATED: payout-filtering under the new 100000 floor is to be confirmed by the captured-corpus KAT before this lands; held for operator approval. --- src/impl/dash/config_pool.hpp | 11 +++++++++++ src/impl/dash/params.hpp | 5 ++++- src/impl/dash/pplns.hpp | 3 ++- 3 files changed, 17 insertions(+), 2 deletions(-) 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..fe40ae9d4 100644 --- a/src/impl/dash/pplns.hpp +++ b/src/impl/dash/pplns.hpp @@ -24,6 +24,7 @@ #include "share_chain.hpp" // dash::ShareChain, DashShare #include "share_check.hpp" // dash::pubkey_hash_to_script2 #include "coinbase_builder.hpp" // dash::coinbase::bits_to_difficulty +#include "config_pool.hpp" // dash::PoolConfig::dust_threshold (payout-dust SSOT) #include #include @@ -56,7 +57,7 @@ inline Result compute_payouts( uint64_t miner_value, const std::vector& fallback_script, size_t min_shares_for_pplns = 20, - uint64_t dust_threshold = 54600) // Dash dust sat + uint64_t dust_threshold = dash::PoolConfig::dust_threshold()) // payout-dust SSOT (config_pool.hpp) { Result r; if (miner_value == 0 || fallback_script.empty()) return r; From e658bf61c4b906f42954fc57eb1fcd60028e28d4 Mon Sep 17 00:00:00 2001 From: frstrtr Date: Thu, 18 Jun 2026 05:47:10 +0000 Subject: [PATCH 2/4] dash(s6): payout dust-threshold KAT A1-A3 vs p2pool-dash (A4 corpus-gated) Wire the dust-floor conformance KATs that gate the b9b3e384 reconciliation (payout dust SSOT -> PoolConfig::dust_threshold() == 100000, V36 Option-A). DashConformancePayoutDust, out-of-band oracle arithmetic mirroring p2pool-dash data.py get_expected_payouts (per-share work = diff-1, fractions exact): - A1 DustWorkerDroppedResidueToDonationLine: a worker whose floor(frac*value) falls below 100000 is dropped from the payout set. - A2 (same case): conservation holds and the dropped-dust residue accrues to the DONATION line (residue = miner_value - allocated), NOT to the largest remaining payout. Pins the actual code path; the pplns.hpp:7-8 header comment is stale. - A2b ThresholdBoundaryExactFloorRetained: amt == 100000 is retained (filter is strict <), guarding against an off-by-one to <=. - A3 DonationLineExemptFromDustFloor: the always-emitted donation line is not subject to the dust floor (sub-100000 residue still present). A4 (live captured-corpus round-trip through get_expected_payouts) is NOT wired: PR #144 @bddd38fb captured consensus-only vectors (X11 hash + DGW-v3 retarget), no payout/PPLNS vector and no sub-100000 proportional share. Not fabricated; held pending an oracle-cross-checked payout corpus. 3/3 new KATs green; full test_dash_conformance 35/35 across 11 suites on Linux x86_64. Held for operator approval; do not push. --- test/test_dash_conformance.cpp | 130 +++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/test/test_dash_conformance.cpp b/test/test_dash_conformance.cpp index be10b7173..4f138a4a4 100644 --- a/test/test_dash_conformance.cpp +++ b/test/test_dash_conformance.cpp @@ -537,6 +537,136 @@ TEST(DashConformancePplns, ColdChainFallsBackToSingleRecipient) { EXPECT_EQ(r2.payouts[0].amount, V); } +// ── S6 conformance: payout dust-threshold (A1–A4) vs frstrtr/p2pool-dash ────── +// +// V36 Option-A reconciliation (commit b9b3e384): the payout-dust floor is the +// single SSOT dash::PoolConfig::dust_threshold() == 100000 sat (oracle +// PARENT.DUST_THRESHOLD = 0.001e8), replacing the two wrong-semantic relay +// floors (params 5460 / pplns 54600). These KATs pin the FILTER behaviour the +// reconciliation must preserve. Amounts are OUT-OF-BAND oracle arithmetic +// mirroring p2pool-dash data.py get_expected_payouts (floor(frac*value); the +// unallocated remainder, INCLUDING dropped dust, accrues to the always-emitted +// donation line). Per-share work = diff-1 (BITS_DIFF1), so each share weighs +// exactly 1.0 and the fractions below are exact. +// +// RESIDUE-SINK PIN (integrator A2 ask): the dropped-dust residue flows to the +// DONATION line (pplns.hpp: amt `continue`, never added to +// `allocated`; residue = miner_value - allocated -> DONATION_SCRIPT line). It +// does NOT go onto the largest remaining payout — the pplns.hpp:7-8 header +// comment ("pushed onto the largest remaining payout") is stale w.r.t. the +// code; these KATs assert the code, not that comment. +// +// A4 (live captured-corpus round-trip through p2pool-dash get_expected_payouts) +// is NOT wired here: PR #144 @bddd38fb captured only consensus vectors (X11 +// block-hash + DGW-v3 retarget at testnet3 1497944) — it carries NO payout/ +// PPLNS vectors and no sub-100000 proportional share. Per integrator, a vector +// is NOT fabricated silently; A4 is held pending an oracle-cross-checked payout +// corpus. See [s=blocked] flag on the dust-conformance thread. + +// A1 + A2: a worker whose proportional split falls below the 100000 floor is +// dropped, and its value reappears on the DONATION line (conservation). A has +// 3 shares, B has 1 (weights 3.0 / 1.0); V = 200000. A = floor(0.75*200000) = +// 150000 (retained); B = floor(0.25*200000) = 50000 (< 100000, DROPPED); +// residue = 200000 - 150000 = 50000 -> donation. B absent; donation == 50000. +TEST(DashConformancePayoutDust, DustWorkerDroppedResidueToDonationLine) { + 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}, + {d_hex, 50000u}, + }; + EXPECT_EQ(payout_set(r), expected); + + // A1: dust worker B is gone from the set entirely. + EXPECT_EQ(payout_set(r).count(b_hex), 0u) << "sub-dust worker B must be dropped"; + + // A2: residue sink is the DONATION line (== dropped B value), NOT miner A. + EXPECT_EQ(payout_set(r).at(d_hex), 50000u) << "dropped dust must land on donation line"; + EXPECT_EQ(payout_set(r).at(a_hex), 150000u) << "retained worker must NOT absorb the dust"; + + // A2: conservation — every satoshi of miner_value accounted for. + uint64_t sum = 0; + for (const auto& p : r.payouts) sum += p.amount; + EXPECT_EQ(sum, V); +} + +// A2b boundary: a worker landing EXACTLY on the floor is RETAINED (the filter +// is strict `amt < threshold`). Same chain, V = 400000: B = floor(0.25*400000) +// = 100000 == threshold -> kept; A = 300000; residue 0 -> donation line at 0 +// (always emitted). Guards against an off-by-one slip to `<=`. +TEST(DashConformancePayoutDust, ThresholdBoundaryExactFloorRetained) { + 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}, // == floor, retained + {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-exemption — the property integrator most wants proven): the +// always-emitted donation line is NOT subject to the dust floor. In the A1 +// scenario the donation amount is 50000, strictly below dust_threshold() +// (100000), yet it is present. The dust `continue` lives only in the worker +// proportional loop; the donation line is appended afterwards, unconditionally. +TEST(DashConformancePayoutDust, DonationLineExemptFromDustFloor) { + 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 = 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); + const auto set = payout_set(r); + const std::string d_hex = script_hex(dash::DONATION_SCRIPT); + + ASSERT_EQ(set.count(d_hex), 1u) << "donation line must always be emitted"; + EXPECT_LT(set.at(d_hex), dash::PoolConfig::dust_threshold()) + << "this asserts the donation amount is genuinely sub-dust..."; + EXPECT_GT(set.at(d_hex), 0u) + << "...and still present despite being below the dust floor (exempt)"; +} + + // ── S6 conformance: share-version transition negotiation vs frstrtr/p2pool-dash ── // // The older-than-v35 -> v36 handshake. Two tallies are kept deliberately apart From a1e7f53d490cab3e6784aab607d8db35b26977a8 Mon Sep 17 00:00:00 2001 From: frstrtr Date: Thu, 18 Jun 2026 07:18:48 +0000 Subject: [PATCH 3/4] dash(s6): close payout dust-KAT A4 via generated boundary corpus A4 was held for a live captured sub-100000 payout corpus, but the p2pool-dash oracle (data.py:682 get_expected_payouts) is algorithmic: floor(weight_frac * value) with the unallocated remainder accruing to DONATION_SCRIPT. With equal-difficulty shares the weight fraction is n/T, so a 3:1 split (B = 1/4) reproduces any exact duff boundary node-free. A4a: V=399996 -> B=floor(99999) < 100000 -> dropped (residue to donation). A4b: V=400004 -> B=floor(100001) >= 100000 -> retained. Together with A2b (V=400000 -> 100000 retained) this pins the strict-< dust gate at single-duff granularity. DUST_THRESHOLD=100000 verified parity vs dash.py:34 / dash_testnet.py:28 / dash_regtest.py:28. ctest -R DashConformance 37/37 (was 35/35). --- test/test_dash_conformance.cpp | 83 +++++++++++++++++++++++++++++++--- 1 file changed, 77 insertions(+), 6 deletions(-) diff --git a/test/test_dash_conformance.cpp b/test/test_dash_conformance.cpp index 4f138a4a4..304fb1e56 100644 --- a/test/test_dash_conformance.cpp +++ b/test/test_dash_conformance.cpp @@ -556,12 +556,19 @@ TEST(DashConformancePplns, ColdChainFallsBackToSingleRecipient) { // comment ("pushed onto the largest remaining payout") is stale w.r.t. the // code; these KATs assert the code, not that comment. // -// A4 (live captured-corpus round-trip through p2pool-dash get_expected_payouts) -// is NOT wired here: PR #144 @bddd38fb captured only consensus vectors (X11 -// block-hash + DGW-v3 retarget at testnet3 1497944) — it carries NO payout/ -// PPLNS vectors and no sub-100000 proportional share. Per integrator, a vector -// is NOT fabricated silently; A4 is held pending an oracle-cross-checked payout -// corpus. See [s=blocked] flag on the dust-conformance thread. +// A4 (generated boundary corpus through the p2pool-dash get_expected_payouts +// algorithm) -- CLOSED node-free, per integrator (corpus is algorithmic, not a +// liftable test_data.py file). data.py:682 get_expected_payouts floors each +// proportional split (subsidy*weight//total_weight) and accrues the unallocated +// remainder (incl. dropped dust) to DONATION_SCRIPT; with equal-difficulty +// shares the weight fraction is n/T, so floor(n/T * V) reproduces any exact duff +// boundary WITHOUT a live node (PR #144 @bddd38fb carries only X11/DGW-v3 +// consensus vectors, never a sub-100000 PPLNS share). A 3:1 split (B = 1/4) +// places B at floor(V/4): V=399996 -> 99999 (just-below, DROPPED, A4a below), +// V=400000 -> 100000 (at floor, RETAINED, A2b above), V=400004 -> 100001 +// (just-above, RETAINED, A4b below). DUST_THRESHOLD=100000 independently matches +// dash.py:34 / dash_testnet.py:28 / dash_regtest.py:28 -- a verified-parity +// point, not a fix. // A1 + A2: a worker whose proportional split falls below the 100000 floor is // dropped, and its value reappears on the DONATION line (conservation). A has @@ -667,6 +674,70 @@ TEST(DashConformancePayoutDust, DonationLineExemptFromDustFloor) { } +// A4a (generated boundary, just-BELOW): 3:1 split, V=399996. B = floor(399996/4) +// = 99999 < 100000 -> DROPPED; A = floor(3*399996/4) = 299997; residue 99999 -> +// donation line. One duff under the floor must drop. +TEST(DashConformancePayoutDust, GeneratedBoundaryJustBelowDropped) { + 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}, + {d_hex, 99999u}, + }; + EXPECT_EQ(payout_set(r), expected); + EXPECT_EQ(payout_set(r).count(b_hex), 0u) + << "99999 (one duff under floor) must drop"; + + uint64_t sum = 0; + for (const auto& p : r.payouts) sum += p.amount; + EXPECT_EQ(sum, V); +} + +// A4b (generated boundary, just-ABOVE): same split, V=400004. B = floor(400004/4) +// = 100001 >= 100000 -> RETAINED; A = floor(3*400004/4) = 300003; residue 0. +// One duff over the floor must survive. +TEST(DashConformancePayoutDust, GeneratedBoundaryJustAboveRetained) { + 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); +} + + // ── S6 conformance: share-version transition negotiation vs frstrtr/p2pool-dash ── // // The older-than-v35 -> v36 handshake. Two tallies are kept deliberately apart From dc45cd85eb787e394fe5416081df9b018f0c3c14 Mon Sep 17 00:00:00 2001 From: frstrtr Date: Thu, 18 Jun 2026 10:49:50 +0000 Subject: [PATCH 4/4] =?UTF-8?q?dash(s6):=20conform=20payout=20to=20oracle?= =?UTF-8?q?=20=E2=80=94=20pay=20nonzero=20sub-dust,=20drop=20only=20zero?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator 2026-06-18 CONFORM-TO-ORACLE (option a). Remove the payout dust floor from pplns.hpp: every NONZERO worker allocation floor(weight_frac * miner_value) is paid to its own script, matching p2pool-dash data.py get_expected_payouts. Only EXACTLY-zero outputs are dropped (oracle if amounts[script]); the donation line carries only the rounding remainder. PoolConfig::dust_threshold() (100000) is retained as the vardiff / share-difficulty floor (c2pool_refactored.cpp mirror of work.py:326) and is no longer consulted in the payout path; the now-dead parameter and its include are removed. Dust KATs A1-A4 rewritten to assert oracle truth (nonzero sub-dust PAID, e.g. 99999 boundary now paid) plus a new A5 pinning the surviving exactly-zero filter. --- src/impl/dash/pplns.hpp | 14 ++- test/test_dash_conformance.cpp | 166 +++++++++++++++++++-------------- 2 files changed, 103 insertions(+), 77 deletions(-) diff --git a/src/impl/dash/pplns.hpp b/src/impl/dash/pplns.hpp index fe40ae9d4..3fa1cd017 100644 --- a/src/impl/dash/pplns.hpp +++ b/src/impl/dash/pplns.hpp @@ -24,7 +24,6 @@ #include "share_chain.hpp" // dash::ShareChain, DashShare #include "share_check.hpp" // dash::pubkey_hash_to_script2 #include "coinbase_builder.hpp" // dash::coinbase::bits_to_difficulty -#include "config_pool.hpp" // dash::PoolConfig::dust_threshold (payout-dust SSOT) #include #include @@ -56,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 = dash::PoolConfig::dust_threshold()) // payout-dust SSOT (config_pool.hpp) + size_t min_shares_for_pplns = 20) { Result r; if (miner_value == 0 || fallback_script.empty()) return r; @@ -137,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 304fb1e56..a8b4b986c 100644 --- a/test/test_dash_conformance.cpp +++ b/test/test_dash_conformance.cpp @@ -539,43 +539,27 @@ TEST(DashConformancePplns, ColdChainFallsBackToSingleRecipient) { // ── S6 conformance: payout dust-threshold (A1–A4) vs frstrtr/p2pool-dash ────── // -// V36 Option-A reconciliation (commit b9b3e384): the payout-dust floor is the -// single SSOT dash::PoolConfig::dust_threshold() == 100000 sat (oracle -// PARENT.DUST_THRESHOLD = 0.001e8), replacing the two wrong-semantic relay -// floors (params 5460 / pplns 54600). These KATs pin the FILTER behaviour the -// reconciliation must preserve. Amounts are OUT-OF-BAND oracle arithmetic -// mirroring p2pool-dash data.py get_expected_payouts (floor(frac*value); the -// unallocated remainder, INCLUDING dropped dust, accrues to the always-emitted -// donation line). Per-share work = diff-1 (BITS_DIFF1), so each share weighs -// exactly 1.0 and the fractions below are exact. -// -// RESIDUE-SINK PIN (integrator A2 ask): the dropped-dust residue flows to the -// DONATION line (pplns.hpp: amt `continue`, never added to -// `allocated`; residue = miner_value - allocated -> DONATION_SCRIPT line). It -// does NOT go onto the largest remaining payout — the pplns.hpp:7-8 header -// comment ("pushed onto the largest remaining payout") is stale w.r.t. the -// code; these KATs assert the code, not that comment. +// 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. // -// A4 (generated boundary corpus through the p2pool-dash get_expected_payouts -// algorithm) -- CLOSED node-free, per integrator (corpus is algorithmic, not a -// liftable test_data.py file). data.py:682 get_expected_payouts floors each -// proportional split (subsidy*weight//total_weight) and accrues the unallocated -// remainder (incl. dropped dust) to DONATION_SCRIPT; with equal-difficulty -// shares the weight fraction is n/T, so floor(n/T * V) reproduces any exact duff -// boundary WITHOUT a live node (PR #144 @bddd38fb carries only X11/DGW-v3 -// consensus vectors, never a sub-100000 PPLNS share). A 3:1 split (B = 1/4) -// places B at floor(V/4): V=399996 -> 99999 (just-below, DROPPED, A4a below), -// V=400000 -> 100000 (at floor, RETAINED, A2b above), V=400004 -> 100001 -// (just-above, RETAINED, A4b below). DUST_THRESHOLD=100000 independently matches -// dash.py:34 / dash_testnet.py:28 / dash_regtest.py:28 -- a verified-parity -// point, not a fix. - -// A1 + A2: a worker whose proportional split falls below the 100000 floor is -// dropped, and its value reappears on the DONATION line (conservation). A has -// 3 shares, B has 1 (weights 3.0 / 1.0); V = 200000. A = floor(0.75*200000) = -// 150000 (retained); B = floor(0.25*200000) = 50000 (< 100000, DROPPED); -// residue = 200000 - 150000 = 50000 -> donation. B absent; donation == 50000. -TEST(DashConformancePayoutDust, DustWorkerDroppedResidueToDonationLine) { +// 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) @@ -597,28 +581,28 @@ TEST(DashConformancePayoutDust, DustWorkerDroppedResidueToDonationLine) { const std::map expected = { {a_hex, 150000u}, - {d_hex, 50000u}, + {b_hex, 50000u}, + {d_hex, 0u}, }; EXPECT_EQ(payout_set(r), expected); - // A1: dust worker B is gone from the set entirely. - EXPECT_EQ(payout_set(r).count(b_hex), 0u) << "sub-dust worker B must be dropped"; + // 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: residue sink is the DONATION line (== dropped B value), NOT miner A. - EXPECT_EQ(payout_set(r).at(d_hex), 50000u) << "dropped dust must land on donation line"; - EXPECT_EQ(payout_set(r).at(a_hex), 150000u) << "retained worker must NOT absorb the dust"; + // 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 satoshi of miner_value accounted for. + // 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 boundary: a worker landing EXACTLY on the floor is RETAINED (the filter -// is strict `amt < threshold`). Same chain, V = 400000: B = floor(0.25*400000) -// = 100000 == threshold -> kept; A = 300000; residue 0 -> donation line at 0 -// (always emitted). Guards against an off-by-one slip to `<=`. -TEST(DashConformancePayoutDust, ThresholdBoundaryExactFloorRetained) { +// 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); @@ -634,7 +618,7 @@ TEST(DashConformancePayoutDust, ThresholdBoundaryExactFloorRetained) { 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}, // == floor, retained + {script_hex(dash::pubkey_hash_to_script2(B)), 100000u}, {script_hex(dash::DONATION_SCRIPT), 0u}, }; EXPECT_EQ(payout_set(r), expected); @@ -644,12 +628,13 @@ TEST(DashConformancePayoutDust, ThresholdBoundaryExactFloorRetained) { EXPECT_EQ(sum, V); } -// A3 (donation-exemption — the property integrator most wants proven): the -// always-emitted donation line is NOT subject to the dust floor. In the A1 -// scenario the donation amount is 50000, strictly below dust_threshold() -// (100000), yet it is present. The dust `continue` lives only in the worker -// proportional loop; the donation line is appended afterwards, unconditionally. -TEST(DashConformancePayoutDust, DonationLineExemptFromDustFloor) { +// 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); @@ -657,27 +642,32 @@ TEST(DashConformancePayoutDust, DonationLineExemptFromDustFloor) { uint256 s2 = sc.add(0x6a, s1, 0, A); uint256 tip = sc.add(0x6b, s2, 0, A); - const uint64_t V = 200000; + 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_LT(set.at(d_hex), dash::PoolConfig::dust_threshold()) - << "this asserts the donation amount is genuinely sub-dust..."; - EXPECT_GT(set.at(d_hex), 0u) - << "...and still present despite being below the dust floor (exempt)"; -} + 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): 3:1 split, V=399996. B = floor(399996/4) -// = 99999 < 100000 -> DROPPED; A = floor(3*399996/4) = 299997; residue 99999 -> -// donation line. One duff under the floor must drop. -TEST(DashConformancePayoutDust, GeneratedBoundaryJustBelowDropped) { +// 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); @@ -697,21 +687,22 @@ TEST(DashConformancePayoutDust, GeneratedBoundaryJustBelowDropped) { const std::map expected = { {a_hex, 299997u}, - {d_hex, 99999u}, + {b_hex, 99999u}, + {d_hex, 0u}, }; EXPECT_EQ(payout_set(r), expected); - EXPECT_EQ(payout_set(r).count(b_hex), 0u) - << "99999 (one duff under floor) must drop"; + 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): same split, V=400004. B = floor(400004/4) -// = 100001 >= 100000 -> RETAINED; A = floor(3*400004/4) = 300003; residue 0. -// One duff over the floor must survive. -TEST(DashConformancePayoutDust, GeneratedBoundaryJustAboveRetained) { +// 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); @@ -737,6 +728,37 @@ TEST(DashConformancePayoutDust, GeneratedBoundaryJustAboveRetained) { 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 ── //