Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/impl/dash/config_pool.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/impl/dash/params.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand Down
13 changes: 9 additions & 4 deletions src/impl/dash/pplns.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,7 @@ inline Result compute_payouts(
size_t window_size,
uint64_t miner_value,
const std::vector<unsigned char>& 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;
Expand Down Expand Up @@ -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<Payout> tentative;
uint64_t allocated = 0;
for (auto& [key, e] : by_key) {
double frac = e.weight / total_weight;
uint64_t amt = static_cast<uint64_t>(frac * static_cast<double>(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
Expand Down
223 changes: 223 additions & 0 deletions test/test_dash_conformance.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::string, uint64_t> 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<std::string, uint64_t> 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<std::string, uint64_t> 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<std::string, uint64_t> 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
Expand Down
Loading