diff --git a/src/impl/dgb/coin/connection_coinbase.hpp b/src/impl/dgb/coin/connection_coinbase.hpp index b6071bad1..481a097d8 100644 --- a/src/impl/dgb/coin/connection_coinbase.hpp +++ b/src/impl/dgb/coin/connection_coinbase.hpp @@ -21,12 +21,14 @@ // ============================================================================ #include "gentx_coinbase.hpp" +#include "pplns_payout_split.hpp" // compute_pplns_payout_split SSOT #include #include // HexStr #include // Span #include +#include #include #include #include @@ -103,4 +105,50 @@ inline ConnCoinbaseParts build_connection_coinbase_parts(const ConnCoinbaseInput return out; } + +// ============================================================================ +// PPLNS-sourced per-connection coinbase (the #328/#329 SSOT bridge). +// +// Instead of pre-resolved payout_outputs/donation_amount, the caller passes the +// raw PPLNS weight map + subsidy and we delegate the amount split to +// compute_pplns_payout_split() -- the SAME helper share_check.hpp +// generate_share_transaction() (the verification path) calls (see #329). The +// per-connection coinbase a miner hashes and the coinbase the share check +// enforces are therefore byte-identical on every payout satoshi BY +// CONSTRUCTION: there is exactly one payout implementation, not two that must +// be kept in agreement. No payout arithmetic lives here -- pure delegation + +// field forwarding into build_connection_coinbase_parts(). +// ============================================================================ +struct ConnCoinbasePplnsInputs +{ + std::vector coinbase_script; // scriptSig (BIP34 height + tag) + std::optional> segwit_commitment_script; + // PPLNS weight map + total, exactly as produced by the ShareTracker + // (share_tracker.hpp get_v36_decayed_cumulative_weights / get_cumulative_weights). + std::map, uint288> weights; + uint288 total_weight; + uint64_t subsidy{0}; // block subsidy + fees to split + bool use_v36_pplns{true}; // V36 (no finder fee) vs pre-V36 + std::vector finder_script; // pre-V36 0.5% finder-fee target + std::vector donation_script; + uint256 ref_hash; // p2pool ref_hash (32B) + uint64_t last_txout_nonce{0}; // OP_RETURN nonce (extranonce slot) +}; + +inline ConnCoinbaseParts build_connection_coinbase_from_pplns(const ConnCoinbasePplnsInputs& in) +{ + PplnsPayoutSplit split = compute_pplns_payout_split( + in.weights, in.total_weight, in.subsidy, in.use_v36_pplns, in.finder_script); + + ConnCoinbaseInputs ci; + ci.coinbase_script = in.coinbase_script; + ci.segwit_commitment_script = in.segwit_commitment_script; + ci.payout_outputs = std::move(split.payout_outputs); + ci.donation_amount = split.donation_amount; + ci.donation_script = in.donation_script; + ci.ref_hash = in.ref_hash; + ci.last_txout_nonce = in.last_txout_nonce; + return build_connection_coinbase_parts(ci); +} + } // namespace dgb::coin diff --git a/src/impl/dgb/coin/pplns_weight_walk.hpp b/src/impl/dgb/coin/pplns_weight_walk.hpp new file mode 100644 index 000000000..4c2c0d084 --- /dev/null +++ b/src/impl/dgb/coin/pplns_weight_walk.hpp @@ -0,0 +1,112 @@ +#pragma once +// ───────────────────────────────────────────────────────────────────────────── +// SSOT: PPLNS weight walk — step 1 of generate_share_transaction(), lifted so the +// share-VERIFICATION path (share_check.hpp generate_share_transaction) and the +// per-connection Stratum coinbase EMISSION path (work_source.cpp +// build_connection_coinbase producer seam, bound in main_dgb.cpp) draw ONE +// tracker-walk implementation — no second copy to drift a payout satoshi. +// +// Counterpart of the steps 2-3 lift in pplns_payout_split.hpp (#328): together +// compute_pplns_weight_walk() + compute_pplns_payout_split() are the full +// former-inline body of generate_share_transaction()'s PPLNS computation. +// Verbatim lift — exact V36 (exponential depth-decay) vs pre-V36 (flat +// cumulative, grandparent start) branch, the data.py:762-764 insufficient-depth +// guard, the block-target / spread / 65535 max_weight cap, and the +// unlimited-weight V36 sentinel. +// Reference: frstrtr/p2pool-merged-v36 data.py:879/884-885, work.py:759. +// +// NOTE: the result type is DEDUCED from the tracker (dgb::CumulativeWeights) via +// decltype rather than named/included directly — share_tracker.hpp includes +// share_check.hpp, which includes this header, so naming the type here (or +// including share_tracker.hpp) would form a parse-time include cycle when +// share_tracker.hpp is the outer include. The dependent return type resolves +// only at instantiation, where TrackerT is complete. +// ───────────────────────────────────────────────────────────────────────────── + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace dgb::coin { + +// Walk the sharechain from `prev_hash` (the new share's parent / the current +// tip when emitting work) backward and accumulate the PPLNS weight map exactly +// as the verifier does. `block_bits` is the block-header nBits feeding the +// pre-V36 max_weight cap (share.m_min_header.m_bits on the verify path). +// +// * prev_hash null or absent from the chain -> empty weights (caller treats +// as a safe coinbase-only / empty job — the pre-wire behavior). +// * chain shorter than real_chain_length -> throws std::invalid_argument, +// the SAME boundary generate_share_transaction() rejects, so emission and +// verification refuse identical insufficient-depth states. +template +inline auto compute_pplns_weight_walk( + TrackerT& tracker, + const uint256& prev_hash, + uint32_t block_bits, + const core::CoinParams& params, + bool use_v36_pplns) + -> decltype(tracker.get_v36_decayed_cumulative_weights( + prev_hash, std::int32_t{0}, std::declval())) +{ + using Result = decltype(tracker.get_v36_decayed_cumulative_weights( + prev_hash, std::int32_t{0}, std::declval())); + Result out{}; + + if (prev_hash.IsNull() || !tracker.chain.contains(prev_hash)) + return out; // no parent in chain -> empty (safe coinbase-only job). + + // p2pool data.py:762-764 — refuse to compute PPLNS with insufficient depth. + // Without this guard, attempt_verify() (which allows CHAIN_LENGTH+1) can + // trigger a PPLNS walk that terminates early, producing wrong coinbase + // amounts and causing persistent GENTX-MISMATCH during bootstrap. + auto chain_len = static_cast(params.real_chain_length); + { + auto pplns_height = tracker.chain.get_height(prev_hash); + auto pplns_last = tracker.chain.get_last(prev_hash); + if (!(pplns_height >= chain_len || pplns_last.IsNull())) + throw std::invalid_argument( + "share chain not long enough for PPLNS verification (height=" + + std::to_string(pplns_height) + " need=" + + std::to_string(chain_len) + ")"); + } + + // block_target from block header bits (matches Python: self.header['bits'].target) + auto block_target = chain::bits_to_target(block_bits); + auto max_weight = chain::target_to_average_attempts(block_target) + * params.spread * 65535; + + // PPLNS formula selected by runtime v36_active (AutoRatchet state), not + // compile-time share version. Ref: p2pool data.py:879, work.py:759. + if (use_v36_pplns) { + // V36 PPLNS: exponential depth-decay, walk from parent. + uint288 unlimited_weight; + unlimited_weight.SetHex("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + out = tracker.get_v36_decayed_cumulative_weights(prev_hash, chain_len, unlimited_weight); + } else { + // Pre-V36 PPLNS: flat cumulative weights (no decay). CRITICAL: walk from + // GRANDPARENT for HEIGHT-1 shares. p2pool data.py:884-885: + // _pplns_start = previous_share.share_data['previous_share_hash'] + // _pplns_max_shares = max(0, min(height, REAL_CHAIN_LENGTH) - 1) + uint256 pplns_start; + tracker.chain.get(prev_hash).share.invoke([&](auto* s) { + pplns_start = s->m_prev_hash; // grandparent + }); + auto available = tracker.chain.get_height(prev_hash); + auto walk_count = static_cast( + std::max(0, std::min(chain_len, available) - 1)); + + if (!pplns_start.IsNull() && tracker.chain.contains(pplns_start) && walk_count > 0) { + out = tracker.get_cumulative_weights(pplns_start, walk_count, max_weight); + } + } + return out; +} + +} // namespace dgb::coin diff --git a/src/impl/dgb/share_check.hpp b/src/impl/dgb/share_check.hpp index 971ab3fc4..ee2f47e75 100644 --- a/src/impl/dgb/share_check.hpp +++ b/src/impl/dgb/share_check.hpp @@ -9,6 +9,8 @@ #include "share_types.hpp" #include +#include +#include // SSOT: PPLNS step-1 tracker walk (shared w/ emission) #include #include @@ -966,59 +968,17 @@ uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker, const uint288 total_weight; uint288 total_donation_weight; - if (!prev_hash.IsNull() && tracker.chain.contains(prev_hash)) + // Step 1 of the PPLNS computation now lives in the shared SSOT + // dgb::coin::compute_pplns_weight_walk() so the per-connection Stratum + // coinbase EMISSION path walks the tracker through the SAME code this + // VERIFICATION path does — byte-identical weights and identical insufficient- + // depth guard, no second walk to drift. Verbatim lift; see pplns_weight_walk.hpp. { - // p2pool data.py:762-764 — refuse to compute PPLNS with insufficient depth. - // Without this guard, attempt_verify() (which allows CHAIN_LENGTH+1) can - // trigger a PPLNS walk that terminates early, producing wrong coinbase - // amounts and causing persistent GENTX-MISMATCH during bootstrap. - auto chain_len = static_cast(params.real_chain_length); - { - auto pplns_height = tracker.chain.get_height(prev_hash); - auto pplns_last = tracker.chain.get_last(prev_hash); - if (!(pplns_height >= chain_len || pplns_last.IsNull())) - throw std::invalid_argument( - "share chain not long enough for PPLNS verification (height=" - + std::to_string(pplns_height) + " need=" - + std::to_string(chain_len) + ")"); - } - - // block_target from block header bits (matches Python: self.header['bits'].target) - auto block_target = chain::bits_to_target(share.m_min_header.m_bits); - auto max_weight = chain::target_to_average_attempts(block_target) - * params.spread * 65535; - - // PPLNS formula selected by runtime v36_active (AutoRatchet state), - // not compile-time share version. Ref: p2pool data.py:879, work.py:759. - if (use_v36_pplns) { - // V36 PPLNS: exponential depth-decay, walk from parent - uint288 unlimited_weight; - unlimited_weight.SetHex("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); - auto result = tracker.get_v36_decayed_cumulative_weights(prev_hash, chain_len, unlimited_weight); - weights = std::move(result.weights); - total_weight = result.total_weight; - total_donation_weight = result.total_donation_weight; - } else { - // Pre-V36 PPLNS: flat cumulative weights (no decay) - // CRITICAL: Walk from GRANDPARENT for HEIGHT-1 shares. - // p2pool data.py:884-885: - // _pplns_start = previous_share.share_data['previous_share_hash'] - // _pplns_max_shares = max(0, min(height, REAL_CHAIN_LENGTH) - 1) - uint256 pplns_start; - tracker.chain.get(prev_hash).share.invoke([&](auto* s) { - pplns_start = s->m_prev_hash; // grandparent - }); - auto available = tracker.chain.get_height(prev_hash); - auto walk_count = static_cast( - std::max(0, std::min(chain_len, available) - 1)); - - if (!pplns_start.IsNull() && tracker.chain.contains(pplns_start) && walk_count > 0) { - auto result = tracker.get_cumulative_weights(pplns_start, walk_count, max_weight); - weights = std::move(result.weights); - total_weight = result.total_weight; - total_donation_weight = result.total_donation_weight; - } - } + auto cw = dgb::coin::compute_pplns_weight_walk( + tracker, prev_hash, share.m_min_header.m_bits, params, use_v36_pplns); + weights = std::move(cw.weights); + total_weight = cw.total_weight; + total_donation_weight = cw.total_donation_weight; } // --- 2. Convert weights to exact integer payout amounts --- @@ -1029,7 +989,6 @@ uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker, const // donation = subsidy - sum(amounts) auto gst_t1 = std::chrono::steady_clock::now(); // after PPLNS walk - std::map, uint64_t> amounts; // Periodic dump of PPLNS weights for cross-impl comparison { @@ -1051,89 +1010,36 @@ uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker, const } } - if (!total_weight.IsNull()) - { - for (auto& [script, weight] : weights) - { - uint64_t amount; - if (use_v36_pplns) - { - // V36: amounts[script] = subsidy * weight / total_weight - uint288 num = uint288(subsidy) * weight; - amount = (num / total_weight).GetLow64(); - } - else - { - // Pre-V36: amounts[script] = subsidy * (199 * weight) / (200 * total_weight) - uint288 num = uint288(subsidy) * (weight * 199); - uint288 den = total_weight * 200; - amount = (num / den).GetLow64(); - } - if (amount > 0) - amounts[script] = amount; - } - } - - // Pre-V36: add 0.5% finder fee to share creator - if (!use_v36_pplns) - { - auto finder_script = get_share_script(&share); - amounts[finder_script] += subsidy / 200; - } + // --- 2-3. PPLNS amount math + consensus output ordering (SSOT) ---------- + // Steps 2-3 are now the single tracker-free helper compute_pplns_payout_split(). + // The per-connection Stratum coinbase assembler draws the SAME split, so + // emission and this verification path cannot diverge on a payout satoshi. + // The helper is a verbatim lift of the former inline math (exact V36 / + // pre-V36 formulae, 0.5% finder fee, >=1-sat V36 donation floor with the + // (amount, script) tiebreak, and the ascending sort truncated to [-4000:]). + // Reference: frstrtr/p2pool-merged-v36 data.py generate_transaction(). + auto finder_script = get_share_script(&share); + auto pplns_split = dgb::coin::compute_pplns_payout_split( + weights, total_weight, subsidy, use_v36_pplns, finder_script); + auto& payout_outputs = pplns_split.payout_outputs; + uint64_t donation_amount = pplns_split.donation_amount; - // Donation output = subsidy minus sum of all payout amounts - uint64_t sum_amounts = 0; - for (auto& [s, a] : amounts) - sum_amounts += a; - uint64_t donation_amount = (subsidy > sum_amounts) ? (subsidy - sum_amounts) : 0; + auto gst_t2 = std::chrono::steady_clock::now(); // after amounts+ordering - // Dump amounts for cross-impl debugging + // Dump payouts for cross-impl debugging if (dump_diag) { - LOG_DEBUG_DIAG << "[GST-AMOUNTS] subsidy=" << subsidy << " addrs=" << amounts.size() + uint64_t sum_amounts = 0; + for (auto& [s, a] : payout_outputs) sum_amounts += a; + LOG_DEBUG_DIAG << "[GST-AMOUNTS] subsidy=" << subsidy << " addrs=" << payout_outputs.size() << " sum=" << sum_amounts << " donation=" << donation_amount << " prev=" << prev_hash.GetHex().substr(0,16); - for (auto& [s, a] : amounts) { + for (auto& [s, a] : payout_outputs) { static const char* HX = "0123456789abcdef"; std::string sh; for (size_t i = 0; i < std::min(s.size(), size_t(10)); ++i) { sh += HX[s[i]>>4]; sh += HX[s[i]&0xf]; } LOG_DEBUG_DIAG << "[GST-AMOUNTS] " << sh << "... = " << a; } } - // V36 consensus: donation output must carry >= 1 satoshi (a60f7f7f) - if (use_v36_pplns) { - if (donation_amount < 1 && subsidy > 0 && !amounts.empty()) { - // Deduct 1 sat from the largest miner payout - // Deterministic tiebreak: (amount, script) — largest script wins when equal - auto largest = std::max_element(amounts.begin(), amounts.end(), - [](const auto& a, const auto& b) { - if (a.second != b.second) return a.second < b.second; - return a.first < b.first; - }); - if (largest != amounts.end() && largest->second > 0) { - largest->second -= 1; - sum_amounts -= 1; - donation_amount = subsidy - sum_amounts; - } - } - } - - // --- 3. Build sorted output list --- - auto gst_t2 = std::chrono::steady_clock::now(); // after amounts - // Python: sorted(dests, key=lambda a: (amounts[a], a))[-4000:] - // = ascending by (amount, script), keep last 4000 (highest amounts) - std::vector, uint64_t>> payout_outputs( - amounts.begin(), amounts.end()); - std::sort(payout_outputs.begin(), payout_outputs.end(), - [](const auto& a, const auto& b) { - if (a.second != b.second) return a.second < b.second; // asc by amount - return a.first < b.first; // asc by script for tie-breaking - }); - - // Keep last MAX_OUTPUTS (highest amounts), matching Python's [-4000:] - constexpr size_t MAX_OUTPUTS = 4000; - if (payout_outputs.size() > MAX_OUTPUTS) - payout_outputs.erase(payout_outputs.begin(), payout_outputs.end() - MAX_OUTPUTS); - // --- 4. Serialise the coinbase transaction --- // Wire layout (version|vin|vouts|locktime) and txid are produced by the // SSOT assembler dgb::coin::assemble_gentx_coinbase() so that emission and diff --git a/src/impl/dgb/stratum/work_source.cpp b/src/impl/dgb/stratum/work_source.cpp index af4465f79..2aa5b009c 100644 --- a/src/impl/dgb/stratum/work_source.cpp +++ b/src/impl/dgb/stratum/work_source.cpp @@ -25,6 +25,7 @@ #include #include // scrypt_pow_hash (DGB-Scrypt PoW SSOT) #include // classify_submission (Stage-4d decision SSOT) +#include // build_connection_coinbase_from_pplns SSOT #include // compact_to_target (compact bits -> u256) #include @@ -333,16 +334,49 @@ std::pair DGBWorkSource::get_coinbase_parts() const } core::stratum::CoinbaseResult DGBWorkSource::build_connection_coinbase( - const uint256& /*prev_share_hash*/, - const std::string& /*extranonce1_hex*/, - const std::vector& /*payout_script*/, - const std::vector>>& /*merged_addrs*/) const + const uint256& prev_share_hash, + const std::string& extranonce1_hex, + const std::vector& payout_script, + const std::vector>>& merged_addrs) const { - // Stage 4c: build per-connection coinbase using the SSOT gentx assembler - // (coin/gentx_coinbase.hpp) + ShareTracker ref_hash + PPLNS payout map. - // For now return an empty result; sessions calling this will get an empty - // job and skip pushing work, which is safe but non-functional. - return {}; + // Phase B live-wire: delegate to the PPLNS->coinbase SSOT + // (build_connection_coinbase_from_pplns), which itself routes through the + // single compute_pplns_payout_split() the verifier uses -- so the coinbase a + // miner hashes here is byte-identical to the one generate_share_transaction() + // enforces, by construction (no second payout implementation to keep in sync). + // + // The PPLNS inputs (weight map walked from the ShareTracker, ref_hash, + // subsidy, donation script) are produced by the seam bound in main_dgb.cpp + // -- the tracker walk lives there, not in the work source. While the seam is + // UNBOUND (or returns nullopt) this is byte-identical to the pre-wire stub: + // an empty result, so the session pushes no work (safe, non-functional). + PplnsInputsFn fn; + { + std::lock_guard lk(pplns_inputs_mutex_); + fn = pplns_inputs_fn_; // copy under lock; a concurrent set_*_fn() cannot + // tear it out mid-call. + } + if (!fn) + return {}; // unbound: pre-wire behavior (empty job). + + std::optional inputs = + fn(prev_share_hash, extranonce1_hex, payout_script, merged_addrs); + if (!inputs) + return {}; // producer declined (e.g. tip not yet known) -> safe empty job. + + dgb::coin::ConnCoinbaseParts parts = + dgb::coin::build_connection_coinbase_from_pplns(*inputs); + + core::stratum::CoinbaseResult out; + out.coinb1 = std::move(parts.coinb1); + out.coinb2 = std::move(parts.coinb2); + // Freeze the consensus-bearing ref fields the submit path (mining_submit) + // re-derives the won-block reconstruct from; the remaining snapshot fields + // (merkle_branches / segwit) are populated by the template-cache follow-on. + out.snapshot.subsidy = inputs->subsidy; + out.snapshot.frozen_ref.ref_hash = inputs->ref_hash; + out.snapshot.frozen_ref.last_txout_nonce = inputs->last_txout_nonce; + return out; } // ───────────────────────────────────────────────────────────────────────────── @@ -610,6 +644,12 @@ void DGBWorkSource::set_fallback_payout_fn(FallbackPayoutFn fn) fallback_payout_fn_ = std::move(fn); } +void DGBWorkSource::set_pplns_inputs_fn(PplnsInputsFn fn) +{ + std::lock_guard lk(pplns_inputs_mutex_); + pplns_inputs_fn_ = std::move(fn); +} + uint256 DGBWorkSource::try_mint_share(const MintShareInputs& in) const { MintShareFn fn; diff --git a/src/impl/dgb/stratum/work_source.hpp b/src/impl/dgb/stratum/work_source.hpp index c4a592d9b..c73228ef3 100644 --- a/src/impl/dgb/stratum/work_source.hpp +++ b/src/impl/dgb/stratum/work_source.hpp @@ -42,6 +42,7 @@ #include #include #include // core::SubsidyFunc — embedded coinbasevalue SSOT feed +#include // ConnCoinbasePplnsInputs (PPLNS->coinbase SSOT) #include #include @@ -209,6 +210,26 @@ class DGBWorkSource : public core::stratum::IWorkSource using FallbackPayoutFn = std::function()>; void set_fallback_payout_fn(FallbackPayoutFn fn); + /// Producer seam for the per-connection coinbase (Phase B live-wire). + /// Given the share-chain tip a miner builds ON TOP OF, plus this session's + /// extranonce1 and resolved payout/merged scripts, returns the fully + /// populated PPLNS inputs (weight map sourced from the ShareTracker, ref_hash, + /// subsidy, donation script). Bound once at startup in main_dgb.cpp where the + /// ShareTracker + CoinParams are in scope; the tracker walk lives THERE so no + /// sharechain logic leaks into the work source. While UNBOUND, + /// build_connection_coinbase() returns an empty result (byte-identical to the + /// pre-wire stub -- no behavior change until the producer is bound). Returning + /// std::nullopt (e.g. tip not yet known) also yields an empty, safe job. The + /// emitted coinbase is byte-identical to the verifier's + /// generate_share_transaction() BY CONSTRUCTION: both delegate to the single + /// compute_pplns_payout_split() SSOT via build_connection_coinbase_from_pplns. + using PplnsInputsFn = std::function( + const uint256& prev_share_hash, + const std::string& extranonce1_hex, + const std::vector& payout_script, + const std::vector>>& merged_addrs)>; + void set_pplns_inputs_fn(PplnsInputsFn fn); + /// Dispatch one share-difficulty submission to the bound mint callback. /// The stage-4d mining_submit classify branch calls this on the /// "pow_hash <= share target" outcome. Returns the minted share hash, or a @@ -269,6 +290,12 @@ class DGBWorkSource : public core::stratum::IWorkSource mutable std::mutex fallback_payout_mutex_; FallbackPayoutFn fallback_payout_fn_; + // Per-connection coinbase PPLNS-inputs producer (#327/#330 live-wire). + // Empty until set_pplns_inputs_fn() bound in main_dgb; while empty the + // per-connection coinbase path returns an empty job (pre-wire behavior). + mutable std::mutex pplns_inputs_mutex_; + PplnsInputsFn pplns_inputs_fn_; + // Template cache (filled lazily; invalidated when work_generation_ bumps) // Stage 4c populates these. mutable std::mutex template_mutex_; diff --git a/src/impl/dgb/test/connection_coinbase_test.cpp b/src/impl/dgb/test/connection_coinbase_test.cpp index 7c2146baf..430556bdc 100644 --- a/src/impl/dgb/test/connection_coinbase_test.cpp +++ b/src/impl/dgb/test/connection_coinbase_test.cpp @@ -18,6 +18,7 @@ #include #include +#include #include #include #include @@ -101,4 +102,106 @@ TEST(ConnCoinbase, RefOpReturnLayout) { EXPECT_EQ(tohex(op), std::string("6a28") + ref32 + "0807060504030201"); } + +// ============================================================================ +// (5)-(8) PPLNS SSOT wiring: build_connection_coinbase_from_pplns delegates the +// payout split to compute_pplns_payout_split (the #329 verification SSOT) and +// forwards every non-payout field unchanged into build_connection_coinbase_parts. +// +// These are DELEGATION-IDENTITY tests, not a second payout implementation: each +// asserts the PPLNS-sourced path produces bytes identical to the manual path +// fed by the SAME SSOT split. Reintroducing inline payout math, dropping the +// v36 floor, or mis-forwarding a field would diverge them. The split MATH +// itself is pinned independently in pplns_payout_split_test.cpp. + +using Script = std::vector; + +// Build the manual reference: run the SSOT split, hand it to the parts builder. +dgb::coin::ConnCoinbaseParts manual_from_split( + const std::map& w, const uint288& total, uint64_t subsidy, + bool v36, const Script& finder, + const std::optional