From 08f269026e98b1c9f1127633d0272aee2f41a953 Mon Sep 17 00:00:00 2001 From: frstrtr Date: Fri, 12 Jun 2026 03:23:30 +0000 Subject: [PATCH 1/4] ltc(v36): unify merged-mining PPLNS weight delta into one get_delta (F2) Collapse the three divergent V36 merged-weight delta implementations -- the compute_merged_payout_hash inline walk, ensure_v36_skiplist, and the per-chain ensure_merged_skiplist -- into a single merged_weights_delta() template plus a merged_weights_delta_for_hash() wrapper, mirroring p2pool MergedWeightsSkipList.get_delta (data.py:1864 @ 42ccca53). chain_id == nullopt drives the hash/v36 path (no merged-address resolution); a chain_id value drives the per-chain path (Tier 1 explicit merged_addresses, Tier 1.5 retroactive miner->merged lookup with normalize as a probe only, else the default key). Behavioral convergence: the hash-path variant previously set total_weight/ donation before the convertibility check, inflating the denominator for unconvertible scripts; it now follows the canonical (1,{},0,0) shape like the other two -- totals are set only once a weight key resolves. Drops the non-consensus [DOGE-TIER] per-share debug log. Default key remains normalize_script_for_merged() here; F1 flips it to the raw parent script (p2pool address_key = share.new_script). Part of the V36 transition/PPLNS conformance stack (F2 -> F10 -> F11 -> F1). --- src/impl/ltc/share_tracker.hpp | 243 ++++++++++++++------------------- 1 file changed, 99 insertions(+), 144 deletions(-) diff --git a/src/impl/ltc/share_tracker.hpp b/src/impl/ltc/share_tracker.hpp index 37fd87b28..045e32433 100644 --- a/src/impl/ltc/share_tracker.hpp +++ b/src/impl/ltc/share_tracker.hpp @@ -2197,6 +2197,99 @@ class ShareTracker return {std::move(result.weights), result.total_weight, result.total_donation_weight}; } + // -- merged_weights_delta (F2: single source of truth) -- + // Unified per-share merged-mining PPLNS weight delta. Replaces the three + // formerly-divergent variants (the compute_merged_payout_hash inline walk, + // ensure_v36_skiplist, and per-chain ensure_merged_skiplist). Mirrors + // p2pool MergedWeightsSkipList.get_delta (data.py:1864). + // + // chain_id == nullopt : hash / v36 path — no merged-address resolution. + // chain_id has value : per-chain path — Tier 1 explicit merged_addresses, + // Tier 1.5 retroactive miner→merged lookup (normalize + // used only as a lookup PROBE), else the default key. + // + // Pre-V36 shares count toward window sizing but contribute zero weight + // (p2pool returns (1, {}, 0, 0)); total_weight/donation are only set once a + // weight key resolves, so unconvertible scripts never inflate the denominator. + template + chain::WeightsDelta merged_weights_delta(ShareT* obj, std::optional chain_id) + { + chain::WeightsDelta delta; + delta.share_count = 1; + if (obj->m_desired_version < 36) + return delta; + + auto target = chain::bits_to_target(obj->m_bits); + auto att = chain::target_to_average_attempts(target); + auto parent_script = get_share_script(obj); + if (parent_script.empty()) + return delta; + + std::vector weight_key; + // Tier 1 / 1.5 merged-address resolution — only for chain-keyed skiplists + // whose share type carries explicit merged_addresses. + if (chain_id.has_value()) + { + if constexpr (requires { obj->m_merged_addresses; }) + { + // Tier 1: explicit merged_addresses for this chain. + for (const auto& entry : obj->m_merged_addresses) + { + if (entry.m_chain_id == *chain_id) + { + weight_key = make_merged_key(entry.m_script.m_data); + break; + } + } + // Tier 1.5: retroactive lookup of the same miner's explicit merged + // address from their other shares. Try the raw parent script, then + // its normalized P2PKH form (normalize is a lookup PROBE only here). + if (weight_key.empty()) + { + auto table_it = m_miner_merged_addr.find(*chain_id); + if (table_it != m_miner_merged_addr.end()) + { + auto miner_it = table_it->second.find(parent_script); + if (miner_it == table_it->second.end()) + { + auto norm = normalize_script_for_merged(parent_script); + if (!norm.empty() && norm != parent_script) + miner_it = table_it->second.find(norm); + } + if (miner_it != table_it->second.end()) + weight_key = make_merged_key(miner_it->second); + } + } + } + } + + // Default key: parent script normalized for merged-chain compatibility. + // (F1 will change this to the RAW parent script to match p2pool's + // address_key = share.new_script; normalize stays the Tier-1.5 probe only.) + if (weight_key.empty()) + weight_key = normalize_script_for_merged(parent_script); + if (weight_key.empty()) + return delta; + + delta.total_weight = att * 65535; + delta.total_donation_weight = att * static_cast(obj->m_donation); + delta.weights[weight_key] = att * static_cast(65535 - obj->m_donation); + return delta; + } + + // Per-hash wrapper: the boilerplate shared by every merged-weight skiplist + // lambda — bounds-check the raw chain, then dispatch to merged_weights_delta. + chain::WeightsDelta merged_weights_delta_for_hash( + const uint256& hash, std::optional chain_id) + { + chain::WeightsDelta delta; + if (!chain.contains(hash)) return delta; + chain.get_share(hash).invoke([&](auto* obj) { + delta = merged_weights_delta(obj, chain_id); + }); + return delta; + } + // -- compute_merged_payout_hash -- // Deterministic hash of V36-only PPLNS weight distribution. // Committed into V36 shares so peers can verify that the share creator's @@ -2244,24 +2337,8 @@ class ShareTracker }; chain::WeightsSkipList raw_sl( [this](const uint256& hash) -> chain::WeightsDelta { - chain::WeightsDelta delta; - if (!chain.contains(hash)) return delta; - delta.share_count = 1; - chain.get_share(hash).invoke([&](auto* obj) { - if (obj->m_desired_version < 36) return; - auto target = chain::bits_to_target(obj->m_bits); - auto att = chain::target_to_average_attempts(target); - delta.total_weight = att * 65535; - delta.total_donation_weight = att * static_cast(obj->m_donation); - auto raw_script = get_share_script(obj); - if (raw_script.empty()) return; - // Normalize P2WPKH→P2PKH for merged chain compatibility. - // P2TR/P2WSH → empty → skipped (unconvertible). - auto script = normalize_script_for_merged(raw_script); - if (script.empty()) return; - delta.weights[script] = att * static_cast(65535 - obj->m_donation); - }); - return delta; + // F2: unified merged-weight delta, hash/v36 path (no chain_id). + return merged_weights_delta_for_hash(hash, std::nullopt); }, std::move(raw_prev_fn) ); @@ -2640,26 +2717,8 @@ class ShareTracker return; m_v36_weights_skiplist.emplace( [this](const uint256& hash) -> chain::WeightsDelta { - chain::WeightsDelta delta; - if (!chain.contains(hash)) return delta; - delta.share_count = 1; - chain.get_share(hash).invoke([&](auto* obj) { - if (obj->m_desired_version < 36) return; - auto target = chain::bits_to_target(obj->m_bits); - auto att = chain::target_to_average_attempts(target); - auto raw_script = get_share_script(obj); - if (raw_script.empty()) return; - // Normalize P2WPKH→P2PKH for merged chain compatibility. - // P2TR/P2WSH → empty → skipped (unconvertible). - auto script = normalize_script_for_merged(raw_script); - if (script.empty()) return; - // Only set total_weight/donation for convertible scripts - // (matching p2pool: unconvertible → (1, {}, 0, 0)) - delta.total_weight = att * 65535; - delta.total_donation_weight = att * static_cast(obj->m_donation); - delta.weights[script] = att * static_cast(65535 - obj->m_donation); - }); - return delta; + // F2: unified merged-weight delta, hash/v36 path (no chain_id). + return merged_weights_delta_for_hash(hash, std::nullopt); }, make_previous_fn() ); @@ -2675,112 +2734,8 @@ class ShareTracker chain_id, chain::WeightsSkipList( [this, chain_id](const uint256& hash) -> chain::WeightsDelta { - chain::WeightsDelta delta; - if (!chain.contains(hash)) return delta; - delta.share_count = 1; - chain.get_share(hash).invoke([&](auto* obj) { - if (obj->m_desired_version < 36) return; - auto target = chain::bits_to_target(obj->m_bits); - auto att = chain::target_to_average_attempts(target); - // NOTE: total_weight and donation_weight are set AFTER - // convertibility check below. p2pool returns (1, {}, 0, 0) - // for unconvertible scripts — zero total, zero donation. - // Setting them here would inflate the denominator. - - std::vector weight_key; - const char* tier_name = "raw:v35"; - // p2pool gates Tier 1 + 1.5 on share.VERSION >= 36. - // V35-format shares always use raw parent script as key, - // even if desired_version >= 36. This keeps V35 and V36 - // weight entries separate per miner during the mixed window. - if constexpr (requires { obj->m_merged_addresses; }) - { - tier_name = "T3:skip"; - // Tier 1: explicit merged_addresses for this chain - for (const auto& entry : obj->m_merged_addresses) - { - if (entry.m_chain_id == chain_id) - { - weight_key = make_merged_key(entry.m_script.m_data); - tier_name = "T1:explicit"; - break; - } - } - // Tier 1.5: retroactive lookup — same miner's - // explicit merged address from their other shares. - // p2pool v0.14.5: try raw, then normalized P2PKH form. - if (weight_key.empty()) - { - auto parent_script = get_share_script(obj); - auto table_it = m_miner_merged_addr.find(chain_id); - if (table_it != m_miner_merged_addr.end()) - { - auto miner_it = table_it->second.find(parent_script); - if (miner_it == table_it->second.end()) { - auto norm = normalize_script_for_merged(parent_script); - if (!norm.empty() && norm != parent_script) - miner_it = table_it->second.find(norm); - } - if (miner_it != table_it->second.end()) { - weight_key = make_merged_key(miner_it->second); - tier_name = "T1.5:retro"; - } - } - // Tier 2: normalize P2WPKH→P2PKH for merged - // chain compatibility. P2TR/P2WSH → empty → skipped. - if (weight_key.empty()) - weight_key = normalize_script_for_merged(parent_script); - } - } - else - { - // V35-format share: normalize P2WPKH→P2PKH for merged - // chain compatibility. P2TR/P2WSH → empty → skipped. - weight_key = normalize_script_for_merged(get_share_script(obj)); - } - // Per-share tier diagnostic - // Log every T1/T1.5/MERGED hit and every 200th otherwise - { - static int s_tier_log_ctr = 0; - bool is_merged = is_merged_key(weight_key); - bool is_p2wpkh = !is_merged && weight_key.size() == 22 && - weight_key[0] == 0x00 && weight_key[1] == 0x14; - if (is_merged || is_p2wpkh || s_tier_log_ctr++ % 200 == 0) { - auto to_hex_short = [](const std::vector& s, size_t n = 20) { - static const char* H = "0123456789abcdef"; - std::string r; for (size_t i = 0; i < std::min(s.size(), n); ++i) { r += H[s[i]>>4]; r += H[s[i]&0xf]; } - return r; - }; - auto parent_script = get_share_script(obj); - LOG_INFO << "[DOGE-TIER] " << tier_name - << " chain_id=" << chain_id - << " ver=" << obj->version - << " dv=" << obj->m_desired_version - << " parent=" << to_hex_short(parent_script) - << " key=" << to_hex_short(weight_key) - << " bits=0x" << std::hex << obj->m_bits << std::dec; - // For V36 shares, log merged_addresses status - if constexpr (requires { obj->m_merged_addresses; }) { - LOG_INFO << "[DOGE-TIER] merged_addrs=" << obj->m_merged_addresses.size() - << " ver=" << obj->version; - for (const auto& entry : obj->m_merged_addresses) { - LOG_INFO << "[DOGE-TIER] chain_id=" << entry.m_chain_id - << " script_len=" << entry.m_script.m_data.size() - << " script=" << to_hex_short(entry.m_script.m_data); - } - } - } - } - // Tier 3: unconvertible — skip, weight redistributed - // p2pool: return (1, {}, 0, 0) — share counts but zero weight - if (weight_key.empty()) return; - // Only set total_weight/donation for convertible scripts - // (matching p2pool MergedWeightsSkipList.get_delta) - delta.total_weight = att * 65535; - delta.total_donation_weight = att * static_cast(obj->m_donation); - delta.weights[weight_key] = att * static_cast(65535 - obj->m_donation); - }); - return delta; + // F2: unified merged-weight delta, per-chain path. + return merged_weights_delta_for_hash(hash, chain_id); }, make_previous_fn() ) From 685669e9027b81c7b91c97c5c40d3b3224b2631d Mon Sep 17 00:00:00 2001 From: frstrtr Date: Fri, 12 Jun 2026 04:55:11 +0000 Subject: [PATCH 2/4] ltc(v36): canonical 60% weighted version-switch gate; drop non-canonical 95% punish (F10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - share_check step 2: replace retroactive 95%-obsolescence check with the canonical p2pool check() 60% PPLNS-weighted switch rule (data.py:1396-1414), evaluated only at version boundaries vs the parent. - ShareTracker::get_desired_version_weights: new uint288 PPLNS-weighted variant (weight = ShareIndex::work) for the consensus gate; flat-count get_desired_version_counts retained for AutoRatchet + tail guard (count-based, finding #1 — weighting them would shift activation timing). - Drop should_punish_version (95% obsolescence) and its Phase-4 head-scoring input (share_tracker.hpp:1148); head-scoring now punishes only naughty heads. - Remove dead validate_version_switch (0 callers). Restores #81 zero-divergence parity vs p2pool-merged-v36: no reject/score-down of shares canonical accepts. Linux x86_64 ctest 590/590. --- src/impl/ltc/auto_ratchet.hpp | 61 ++++------------------------ src/impl/ltc/share_check.hpp | 71 +++++++++++++++++++++++++-------- src/impl/ltc/share_tracker.hpp | 72 ++++++++++++++++++++-------------- 3 files changed, 104 insertions(+), 100 deletions(-) diff --git a/src/impl/ltc/auto_ratchet.hpp b/src/impl/ltc/auto_ratchet.hpp index 5284d92d2..bd235ca3a 100644 --- a/src/impl/ltc/auto_ratchet.hpp +++ b/src/impl/ltc/auto_ratchet.hpp @@ -257,60 +257,13 @@ class AutoRatchet return {current_version, target_version_}; } - /// Validate a version switch between consecutive shares. - /// Returns empty string if valid, error message if invalid. - /// Implements the 60% switch rule from Python check() method. - static std::string validate_version_switch( - int64_t share_version, int64_t prev_version, - ShareTracker& tracker, const uint256& prev_hash) - { - // Same version — always ok - if (share_version == prev_version) - return {}; - - int32_t height = tracker.chain.get_height(prev_hash); - uint32_t chain_length = PoolConfig::chain_length(); - - if (height < static_cast(chain_length)) - { - // Not enough history for version switch - if (share_version > prev_version) - return "version switch without enough history"; - return {}; // downgrade ok without history - } - - // Upgrade: requires 60% in sampling window [CHAIN_LENGTH*9/10, CHAIN_LENGTH] - if (share_version == prev_version + 1) - { - uint32_t window_start = (chain_length * 9) / 10; - uint32_t window_size = chain_length / 10; - auto ancestor = tracker.chain.get_nth_parent_key(prev_hash, window_start); - auto counts = tracker.get_desired_version_counts(ancestor, window_size); - - int64_t new_ver_count = 0; - int64_t total_count = 0; - for (auto& [ver, cnt] : counts) - { - total_count += cnt; - if (ver >= share_version) - new_ver_count += cnt; - } - - if (total_count > 0 && new_ver_count * 100 < total_count * SWITCH_THRESHOLD) - return "version switch without enough hash power upgraded (" - + std::to_string(new_ver_count * 100 / total_count) - + "% < " + std::to_string(SWITCH_THRESHOLD) + "%)"; - return {}; - } - - // Downgrade by 1 (AutoRatchet deactivation): allowed - if (share_version == prev_version - 1) - return {}; - - // Multi-version jump: not allowed - return "invalid version jump from " + std::to_string(prev_version) - + " to " + std::to_string(share_version); - } + // F10: validate_version_switch was removed — it was dead (0 callers) and + // diverged from canonical p2pool (it weighted by flat COUNT with a `>=` + // sum, vs canonical's PPLNS-weighted EXACT-version 60% rule). The single + // source of truth for the version-switch gate is now share_check step 2, + // which calls ShareTracker::get_desired_version_weights and matches + // p2pool check() (data.py:1396-1414) exactly. The VOTING tail guard above + // stays inline and count-based (finding #1). private: std::string state_file_; diff --git a/src/impl/ltc/share_check.hpp b/src/impl/ltc/share_check.hpp index a6d84eb3a..539eb2dc1 100644 --- a/src/impl/ltc/share_check.hpp +++ b/src/impl/ltc/share_check.hpp @@ -1704,31 +1704,70 @@ bool share_check(const ShareT& share, if (share.m_timestamp > now + 600) throw std::invalid_argument("share timestamp is too far in the future"); - // 2. Version counting — AutoRatchet upgrade enforcement - // p2pool data.py:1400-1414: version switch validation only at BOUNDARIES - // (when share.VERSION != parent.VERSION). Shares that match their parent's - // version are always valid — they were correct when created. - // Previous code rejected ALL v35 shares retroactively after 95% activation. - { - auto lookbehind = static_cast(PoolConfig::chain_length()); - if (tracker.chain.contains(share.m_hash) && - !share.m_prev_hash.IsNull() && tracker.chain.contains(share.m_prev_hash)) + // 2. Version switch enforcement — canonical 60% weighted switch rule. + // p2pool data.py check() (lines 1396-1414): a version BOUNDARY + // (share.version != parent.version) is only valid when, in the sampling + // window [CHAIN_LENGTH*9/10, CHAIN_LENGTH] behind the PARENT, the new + // version holds >= 60% of the PPLNS-WEIGHTED desired-version counts. + // The weight is target_to_average_attempts per share — get_desired_version_weights, + // matching canonical get_desired_version_counts (data.py:2651) — NOT a flat count. + // F10/(b): the prior non-canonical 95%-obsolescence punish is removed; the + // canonical 60% switch rule is now the ONLY version gate. AutoRatchet stays + // count-based and does not gate peer shares here. + { + auto chain_length = static_cast(PoolConfig::chain_length()); + if (!share.m_prev_hash.IsNull() && tracker.chain.contains(share.m_prev_hash)) { - // Get parent's share version + // Parent's share version (the type the incoming share must legally follow) int64_t parent_version = 0; tracker.chain.get_share(share.m_prev_hash).invoke([&](auto* obj) { parent_version = std::remove_pointer_t::version; }); + int64_t share_ver = share.version; - // Only enforce version obsolescence at version BOUNDARIES - if (share.version != parent_version) + auto prev_height = tracker.chain.get_height(share.m_prev_hash); + if (prev_height >= chain_length) { - auto height = tracker.chain.get_height(share.m_hash); - if (height >= lookbehind) + if (share_ver == parent_version) { - if (tracker.should_punish_version(share.m_hash, share.version, lookbehind)) - throw std::invalid_argument("share version too old — newer version has 95%+ activation"); + // same version — always valid (correct when created) } + else if (share_ver == parent_version + 1) + { + // Upgrade by one version: requires >= 60% weighted support in + // window [CHAIN_LENGTH*9/10, CHAIN_LENGTH] behind the parent. + uint32_t window_start = (static_cast(chain_length) * 9) / 10; + uint32_t window_size = static_cast(chain_length) / 10; + auto ancestor = tracker.chain.get_nth_parent_key(share.m_prev_hash, window_start); + auto weights = tracker.get_desired_version_weights( + ancestor, static_cast(window_size)); + + uint288 new_ver_weight; // weight of shares desiring exactly share_ver + uint288 total_weight; + for (auto& [ver, w] : weights) + { + total_weight = total_weight + w; + if (static_cast(ver) == share_ver) + new_ver_weight = new_ver_weight + w; + } + // Canonical: counts.get(self.VERSION,0) < sum(counts)*60//100 + if (new_ver_weight * uint32_t(100) < total_weight * uint32_t(60)) + throw std::invalid_argument("switch without enough hash power upgraded"); + } + else if (parent_version == share_ver + 1) + { + // Downgrade by one (AutoRatchet deactivation: V35 may follow V36) + } + else + { + throw std::invalid_argument("invalid version jump from " + + std::to_string(parent_version) + " to " + std::to_string(share_ver)); + } + } + else if (share_ver == parent_version + 1) + { + // Not enough history for an upgrade boundary + throw std::invalid_argument("switch without enough history"); } } } diff --git a/src/impl/ltc/share_tracker.hpp b/src/impl/ltc/share_tracker.hpp index 045e32433..7e58074db 100644 --- a/src/impl/ltc/share_tracker.hpp +++ b/src/impl/ltc/share_tracker.hpp @@ -1140,16 +1140,16 @@ class ShareTracker if (!head_idx) continue; int64_t ts = head_idx->time_seen; - // Punish heads: version obsolescence OR naughty (invalid block) + // Punish heads: naughty (invalid block) only. + // F10/(b): the non-canonical 95%-version-obsolescence punish + // is dropped — canonical Phase-4 head-scoring punishes only + // naughty heads. The version gate is the check() 60% weighted + // switch rule (share_check), not head-scoring. int32_t reason = 0; { - auto share_version = chain.get_share(hh).version(); - auto lookbehind = static_cast(PoolConfig::chain_length()); - if (should_punish_version(hh, share_version, lookbehind)) - reason = 1; auto* idx = chain.get_index(hh); if (idx && idx->naughty > 0) - reason = std::max(reason, idx->naughty); + reason = idx->naughty; } // p2pool: sort key = (work - min(punish,1)*ata(target), -reason, -time_seen) @@ -2109,6 +2109,36 @@ class ShareTracker return counts; } + // -- PPLNS-weighted version counting for the consensus 60% switch rule -- + // Canonical p2pool get_desired_version_counts (data.py:2651) weights each + // share by target_to_average_attempts(share.target) — NOT a flat count. + // The check()-phase 60% switch rule (share_check step 2) is a consensus gate + // and MUST use these weights to stay byte-identical with p2pool. AutoRatchet + // and its tail guard deliberately keep the FLAT-COUNT variant above + // (count-based per F10 finding #1 — weighting them would shift activation + // timing across the soak). Weight = ShareIndex::work (share.hpp). + std::map get_desired_version_weights(const uint256& share_hash, int32_t lookbehind) + { + std::map weights; + if (!chain.contains(share_hash)) + return weights; + auto height = chain.get_height(share_hash); + auto actual = std::min(lookbehind, height); + if (actual <= 0) + return weights; + + auto view = chain.get_chain(share_hash, actual); + for (auto [hash, data] : view) + { + uint64_t dv = 0; + data.share.invoke([&](auto* obj) { dv = obj->m_desired_version; }); + auto* idx = chain.get_index(hash); + if (idx) + weights[dv] = weights[dv] + idx->work; + } + return weights; + } + // -- Merged mining: per-chain PPLNS weights -- // For a specific aux chain_id, walk the share chain and accumulate PPLNS // weights for V36-signaling shares. Uses O(log n) skip list. @@ -2577,30 +2607,12 @@ class ShareTracker return result; } - // Returns true if shares at `share_version` should be punished because - // a newer version has reached the 95% activation threshold. - // Python ref: share.check() version_after_check logic - bool should_punish_version(const uint256& share_hash, int64_t share_version, int32_t lookbehind) - { - if (!chain.contains(share_hash)) - return false; - auto counts = get_desired_version_counts(share_hash, lookbehind); - auto height = chain.get_height(share_hash); - auto actual = std::min(lookbehind, height); - if (actual <= 0) - return false; - - // Check if any version higher than share_version has >= 95% support - for (auto& [ver, count] : counts) - { - if (static_cast(ver) > share_version) - { - if (count * 100 >= actual * 95) // 95% threshold - return true; - } - } - return false; - } + // F10/(b): should_punish_version (the 95%-obsolescence punish) was removed. + // It was non-canonical — canonical p2pool check() has no 95% obsolescence + // rule; the count-based AutoRatchet plus the 60% weighted switch rule + // (share_check step 2) are the only version gates. Keeping it would + // punish/score-down shares canonical accepts, breaking the #81 zero- + // divergence gate. Head-scoring (Phase 4) now punishes only naughty heads. private: std::vector m_stale_callbacks; From 18dd9457c60d01fc558039adf655f0b36aa414de Mon Sep 17 00:00:00 2001 From: frstrtr Date: Fri, 12 Jun 2026 06:36:44 +0000 Subject: [PATCH 3/4] ltc(v36): canonical exclude-then-append donation handling in payout sort (F11) Per-miner dests exclude BOTH donation scripts; COMBINED_DONATION_SCRIPT-keyed weight folds into the single donation-last output, DONATION_SCRIPT-keyed weight dropped. Matches p2pool data.py generate_transaction. 3 LTC writers. --- src/impl/ltc/share_check.hpp | 57 ++++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/src/impl/ltc/share_check.hpp b/src/impl/ltc/share_check.hpp index 539eb2dc1..ef45b861f 100644 --- a/src/impl/ltc/share_check.hpp +++ b/src/impl/ltc/share_check.hpp @@ -1120,8 +1120,23 @@ uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker, bool 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()); + // F11: canonical exclude-then-append donation handling (p2pool data.py + // generate_transaction). The per-miner dests list excludes BOTH donation + // scripts; a COMBINED_DONATION_SCRIPT-keyed weight folds into the single + // donation-last output, and any DONATION_SCRIPT-keyed weight is dropped. + const std::vector combined_donation_script( + PoolConfig::COMBINED_DONATION_SCRIPT.begin(), PoolConfig::COMBINED_DONATION_SCRIPT.end()); + const std::vector p2pk_donation_script( + PoolConfig::DONATION_SCRIPT.begin(), PoolConfig::DONATION_SCRIPT.end()); + if (auto _dit = amounts.find(combined_donation_script); _dit != amounts.end()) + donation_amount += _dit->second; + std::vector, uint64_t>> payout_outputs; + payout_outputs.reserve(amounts.size()); + for (auto& _kv : amounts) { + if (_kv.first == combined_donation_script || _kv.first == p2pk_donation_script) + continue; + payout_outputs.emplace_back(_kv.first, _kv.second); + } 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 @@ -2391,8 +2406,23 @@ uint256 create_local_share_v35( // V35: no minimum donation enforcement (unlike v36) uint64_t donation_amount = (subsidy > sum_amounts) ? (subsidy - sum_amounts) : 0; - std::vector, uint64_t>> payout_outputs( - amounts.begin(), amounts.end()); + // F11: canonical exclude-then-append donation handling (p2pool data.py + // generate_transaction). The per-miner dests list excludes BOTH donation + // scripts; a COMBINED_DONATION_SCRIPT-keyed weight folds into the single + // donation-last output, and any DONATION_SCRIPT-keyed weight is dropped. + const std::vector combined_donation_script( + PoolConfig::COMBINED_DONATION_SCRIPT.begin(), PoolConfig::COMBINED_DONATION_SCRIPT.end()); + const std::vector p2pk_donation_script( + PoolConfig::DONATION_SCRIPT.begin(), PoolConfig::DONATION_SCRIPT.end()); + if (auto _dit = amounts.find(combined_donation_script); _dit != amounts.end()) + donation_amount += _dit->second; + std::vector, uint64_t>> payout_outputs; + payout_outputs.reserve(amounts.size()); + for (auto& _kv : amounts) { + if (_kv.first == combined_donation_script || _kv.first == p2pk_donation_script) + continue; + payout_outputs.emplace_back(_kv.first, _kv.second); + } std::sort(payout_outputs.begin(), payout_outputs.end(), [](const auto& a, const auto& b) { if (a.second != b.second) return a.second < b.second; @@ -2933,8 +2963,23 @@ uint256 create_local_share( } } - std::vector, uint64_t>> payout_outputs( - amounts.begin(), amounts.end()); + // F11: canonical exclude-then-append donation handling (p2pool data.py + // generate_transaction). The per-miner dests list excludes BOTH donation + // scripts; a COMBINED_DONATION_SCRIPT-keyed weight folds into the single + // donation-last output, and any DONATION_SCRIPT-keyed weight is dropped. + const std::vector combined_donation_script( + PoolConfig::COMBINED_DONATION_SCRIPT.begin(), PoolConfig::COMBINED_DONATION_SCRIPT.end()); + const std::vector p2pk_donation_script( + PoolConfig::DONATION_SCRIPT.begin(), PoolConfig::DONATION_SCRIPT.end()); + if (auto _dit = amounts.find(combined_donation_script); _dit != amounts.end()) + donation_amount += _dit->second; + std::vector, uint64_t>> payout_outputs; + payout_outputs.reserve(amounts.size()); + for (auto& _kv : amounts) { + if (_kv.first == combined_donation_script || _kv.first == p2pk_donation_script) + continue; + payout_outputs.emplace_back(_kv.first, _kv.second); + } std::sort(payout_outputs.begin(), payout_outputs.end(), [](const auto& a, const auto& b) { if (a.second != b.second) return a.second < b.second; From 8ca179540d5d7d1d03c7ea7a677bc5f8904cb431 Mon Sep 17 00:00:00 2001 From: frstrtr Date: Fri, 12 Jun 2026 06:39:49 +0000 Subject: [PATCH 4/4] ltc(v36): raw parent-script merged-weight key, matching p2pool address_key (F1) Default merged-weight emission key now uses the RAW parent script (== p2pool address_key = share.new_script) instead of the normalized P2PKH form. normalize_script_for_merged retained as the Tier-1.5 lookup probe only. Fixes the merged-payout accounting divergence (weight keyed 76a914 while payout used raw 0014). Option A. --- src/impl/ltc/share_tracker.hpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/impl/ltc/share_tracker.hpp b/src/impl/ltc/share_tracker.hpp index 7e58074db..eb88d8dcc 100644 --- a/src/impl/ltc/share_tracker.hpp +++ b/src/impl/ltc/share_tracker.hpp @@ -2293,11 +2293,12 @@ class ShareTracker } } - // Default key: parent script normalized for merged-chain compatibility. - // (F1 will change this to the RAW parent script to match p2pool's - // address_key = share.new_script; normalize stays the Tier-1.5 probe only.) + // Default key: RAW parent script, matching p2pool's + // address_key = share.new_script. normalize_script_for_merged stays a + // Tier-1.5 lookup PROBE only (above); keying emission by the normalized + // P2PKH form was the merged-payout accounting divergence (F1, Option A). if (weight_key.empty()) - weight_key = normalize_script_for_merged(parent_script); + weight_key = parent_script; if (weight_key.empty()) return delta;