diff --git a/src/impl/dash/version_negotiation.hpp b/src/impl/dash/version_negotiation.hpp index f7807f69f..d08c5c6d5 100644 --- a/src/impl/dash/version_negotiation.hpp +++ b/src/impl/dash/version_negotiation.hpp @@ -8,17 +8,19 @@ // // * get_desired_version_counts() — a PLAIN tally (one vote per share) over a // chain window. Reference: p2pool-dash data.py get_desired_version_counts -// as consumed by Share.check()'s confirmed-state guard and by the -// AutoRatchet desired-version selection. It is NOT weighted in place. +// as consumed by the AutoRatchet desired-version selection and the VOTING +// tail guard (F10 keeps these count-based). It is NOT weighted in place. // // * get_desired_version_weights() — the SEPARATE WEIGHTED variant -// (weight = target_to_average_attempts(target), i.e. expected hashes) the -// v36 activation gate consumes. Reference: p2pool-merged-v36 work.py -// v36_active = weight[36] / Sum(weight) >= 0.95. +// (weight = target_to_average_attempts(target), i.e. expected hashes) that +// BOTH consensus gates consume: the 60% SUCCESSOR switch gate (D1) and the +// 95% v36 activation gate. Reference: p2pool data.py:1396-1414 (60% switch, +// PPLNS-weighted) + p2pool-merged-v36 work.py v36_active = weight[36] / +// Sum(weight) >= 0.95. // // The confirmed-state guard (Share.check): a SUCCESSOR-version share may follow -// its predecessor only if the new version already holds >= 60% of the PLAIN -// votes in the [9/10 .. 10/10] tail of the CHAIN_LENGTH window; a switch with +// its predecessor only if the new version already holds >= 60% of the WEIGHTED +// desired-version tally in the [9/10 .. 10/10] tail of the CHAIN_LENGTH window; a switch with // fewer than CHAIN_LENGTH ancestors is rejected ("without enough history"). #include "share_chain.hpp" // dash::ShareChain, dash::DashShare @@ -69,33 +71,40 @@ get_desired_version_weights(ShareChain& chain, const uint256& start_hash, uint64 static_cast(chain.get_height(start_hash))); for (auto&& [h, data] : chain.get_chain(start_hash, n)) { (void)h; + // D5: consume the cached per-share work (ShareIndex::work, set at add() + // to target_to_average_attempts(bits_to_target(m_bits)) -- share_chain.hpp:227) + // instead of recomputing it live. The two are equal by construction; sourcing + // the cached accessor removes recompute-drift risk and standardizes on the + // shared F10 work metric (divergence-map row D5). + const uint288 w = data.index->work; data.share.invoke([&](auto* obj) { using S = std::remove_pointer_t; - if constexpr (std::is_same_v) { - uint288 w = chain::target_to_average_attempts( - chain::bits_to_target(obj->m_bits)); + if constexpr (std::is_same_v) res[obj->m_desired_version] += w; - } }); } return res; } -// 60% confirmed-state guard (p2pool-dash data.py Share.check). A SUCCESSOR -// share is accepted only when its PLAIN vote count reaches floor(total*60/100). -// The floor matches the oracle's integer `sum*60//100` exactly (e.g. 4/7 votes -// clears thr=4, 3/7 does not). +// 60% switch gate (canonical v36-native -- F10 685669e9 share_check.hpp step 2; +// p2pool data.py:1396-1414). A SUCCESSOR-version share is accepted only when the +// new version holds >= 60% of the PPLNS-WEIGHTED desired-version tally (weight = +// target_to_average_attempts per share), via the exact rational +// new_ver_weight*100 >= total_weight*60 -- integer uint288, no IEEE-double and no +// floor. D1 standardization: the weighted tally (get_desired_version_weights) +// feeds this consensus gate; the PLAIN count (get_desired_version_counts) is +// retained for AutoRatchet + the VOTING tail guard only (D4). F10 deleted the +// old flat-count validate_version_switch precisely because flat-count diverges. inline bool -successor_switch_allowed(const std::map& plain_counts, +successor_switch_allowed(const std::map& weights, uint64_t successor_version) { - uint64_t total = 0; - for (const auto& [v, c] : plain_counts) total += c; - if (total == 0) return false; - const uint64_t threshold = (total * 60) / 100; // floor, as in the oracle - auto it = plain_counts.find(successor_version); - const uint64_t have = (it == plain_counts.end()) ? 0 : it->second; - return have >= threshold; + uint288 total(0); + for (const auto& [v, w] : weights) total += w; + if (total == uint288(0)) return false; + auto it = weights.find(successor_version); + const uint288 have = (it == weights.end()) ? uint288(0) : it->second; + return have * uint288(100) >= total * uint288(60); } // v36 activation gate (p2pool-merged-v36 work.py): v36 is active once its @@ -127,4 +136,63 @@ negotiation_window(ShareChain& chain, const uint256& prev_hash, uint64_t chain_l return Window{start, dist}; } +// ── D2: unified 5-case version-switch classifier (F10 share_check.hpp step 2) ─ +// successor_switch_allowed() answers ONLY the 60% upgrade threshold. The +// canonical v36-native shape (F10 share_check step 2; p2pool-dash data.py +// Share.check) classifies a proposed predecessor->desired version transition +// into five cases -- the threshold gate is just the body of ONE of them: +// +// Same desired == prev -> always allowed (continuation) +// SuccessorGated desired == prev + 1 -> allowed IFF the 60% weighted +// gate (successor_switch_allowed) +// clears over the window +// PredecessorAllowed desired == prev - 1 -> always allowed (downgrade-by-one +// rollback; needs no support window) +// InvalidJump |desired - prev| > 1 -> rejected (no version skipping) +// NoHistory a +1 switch is proposed -> rejected ("switch without enough +// with < CHAIN_LENGTH history"): the support window +// ancestors cannot be evaluated +// +// no-history applies ONLY to the +1 successor case -- it is the one transition +// that needs the support window; Same/Predecessor need no window, an InvalidJump +// is rejected on structure regardless of history. DASH previously expressed only +// negotiation_window() + the threshold; this folds them into the canonical shape. +// Pure + scaffolding-only (no live share_check caller) -- zero consensus risk. +enum class SwitchClass : uint8_t { + Same = 0, // desired == prev -- allow + SuccessorGated = 1, // desired == prev + 1 -- allow iff 60% gate clears + PredecessorAllowed = 2, // desired == prev - 1 -- allow (rollback) + InvalidJump = 3, // |desired - prev| > 1 -- reject + NoHistory = 4, // +1 switch, < CHAIN_LENGTH anc -- reject (no support window) +}; + +// Classify the predecessor->desired transition. `has_history` is whether the +// support window exists (>= CHAIN_LENGTH ancestors), i.e. negotiation_window() +// returned a value; it only changes the verdict for the +1 successor case. +inline SwitchClass +classify_switch(uint64_t prev_version, uint64_t desired_version, bool has_history) +{ + if (desired_version == prev_version) return SwitchClass::Same; + if (desired_version == prev_version - 1) return SwitchClass::PredecessorAllowed; + if (desired_version == prev_version + 1) + return has_history ? SwitchClass::SuccessorGated : SwitchClass::NoHistory; + return SwitchClass::InvalidJump; // |delta| > 1 +} + +// Final accept decision for a classified transition. SuccessorGated defers to the +// 60% weighted gate result (`gate_cleared` = successor_switch_allowed over the +// window); every other case is decided structurally. Mirrors F10 step 2 exactly. +inline bool +switch_accepted(SwitchClass cls, bool gate_cleared) +{ + switch (cls) { + case SwitchClass::Same: + case SwitchClass::PredecessorAllowed: return true; + case SwitchClass::SuccessorGated: return gate_cleared; + case SwitchClass::InvalidJump: + case SwitchClass::NoHistory: return false; + } + return false; +} + } // namespace dash::version_negotiation diff --git a/test/test_dash_conformance.cpp b/test/test_dash_conformance.cpp index fa67dfbda..5303b58c4 100644 --- a/test/test_dash_conformance.cpp +++ b/test/test_dash_conformance.cpp @@ -409,12 +409,18 @@ struct SyntheticChain { std::vector pool; // chain (ShareVariants::destroy) owns the shares; raw avoids double-free uint256 add(uint8_t tag, const uint256& prev, uint16_t donation, - const uint160& pkh) { + const uint160& pkh, + uint32_t bits = BITS_DIFF1, uint64_t version = 0) { auto* s = new dash::DashShare(); s->m_hash = pplns_share_hash(tag); s->m_prev_hash = prev; - s->m_bits = BITS_DIFF1; - s->m_max_bits = BITS_DIFF1; // ShareIndex ctor reads m_max_bits + // Final fields set BEFORE chain.add(): ShareIndex caches + // work=target_to_average_attempts(bits_to_target(m_bits)) at insertion, + // and a production share has immutable bits once it enters the chain. + // (m_max_bits stays at diff1 -- min_work basis unchanged.) + s->m_bits = bits; + s->m_max_bits = BITS_DIFF1; + s->m_desired_version = version; s->m_donation = donation; s->m_pubkey_hash = pkh; const uint256 h = s->m_hash; @@ -549,7 +555,7 @@ TEST(DashConformancePplns, ColdChainFallsBackToSingleRecipient) { // weight[36]/Sum >= 0.95). All thresholds and weights below are KAT vectors // computed OUT-OF-BAND in CPython from the exact integer formulas // target_to_average_attempts = 2**256 // (target + 1) -// tail-guard : count >= (total*60)//100 (floor) +// switch gate : have_w*100 >= total_w*60 (weighted, exact rational) // v36 gate : w36*100 >= total*95 (exact rational) // so the pins are not circular with the C++ path. @@ -565,11 +571,8 @@ namespace { // obj->m_bits live, so post-add patching is what the production walk observes. uint256 add_versioned(SyntheticChain& sc, uint8_t tag, const uint256& prev, uint64_t version, uint32_t bits, const uint160& pkh) { - uint256 h = sc.add(tag, prev, /*donation*/ 0, pkh); - auto* s = sc.pool.back(); - s->m_desired_version = version; - s->m_bits = bits; - return h; + // Bits/version set pre-insertion so the cached ShareIndex::work matches. + return sc.add(tag, prev, /*donation*/ 0, pkh, bits, version); } } // namespace @@ -595,18 +598,19 @@ TEST(DashConformanceVersionNeg, PlainCountAndWeightVsOutOfBandKat) { << "v36 weight != CPython KAT (3x diff1 work)"; } -// 60% SUCCESSOR tail-guard, floor semantics. KAT booleans from CPython -// count >= (total*60)//100. The 4/7 case (57% but thr=floor(4.2)=4) and 3/7 -// case lock the floor exactly as the oracle's `sum*60//100`. -TEST(DashConformanceVersionNeg, SuccessorGuard60PercentFloorKat) { +// 60% SUCCESSOR switch gate, PPLNS-WEIGHTED exact-rational semantics (D1 / +// F10 685669e9). KAT booleans from CPython have_w*100 >= total_w*60 (no floor). +// The 4/7 case now REJECTS (400 < 420) where the prior plain-floor gate cleared +// it (thr=floor(4.2)=4) -- the exact divergence D1 standardizes away. +TEST(DashConformanceVersionNeg, SuccessorGuard60PercentWeightedKat) { using dash::version_negotiation::successor_switch_allowed; - EXPECT_TRUE (successor_switch_allowed({{36u, 6u}, {16u, 4u}}, 36u)); // 60% - EXPECT_FALSE(successor_switch_allowed({{36u, 5u}, {16u, 5u}}, 36u)); // 50% - EXPECT_TRUE (successor_switch_allowed({{36u, 60u}, {16u, 40u}}, 36u)); // 60% - EXPECT_FALSE(successor_switch_allowed({{36u, 59u}, {16u, 41u}}, 36u)); // 59% - EXPECT_TRUE (successor_switch_allowed({{36u, 4u}, {16u, 3u}}, 36u)); // 4/7, thr=4 - EXPECT_FALSE(successor_switch_allowed({{36u, 3u}, {16u, 4u}}, 36u)); // 3/7, thr=4 - EXPECT_FALSE(successor_switch_allowed({}, 36u)); // empty + EXPECT_TRUE (successor_switch_allowed({{36u, uint288(6u)}, {16u, uint288(4u)}}, 36u)); // 6/10 = 60% + EXPECT_FALSE(successor_switch_allowed({{36u, uint288(5u)}, {16u, uint288(5u)}}, 36u)); // 5/10 = 50% + EXPECT_TRUE (successor_switch_allowed({{36u, uint288(60u)}, {16u, uint288(40u)}}, 36u)); // 60/100 = 60% + EXPECT_FALSE(successor_switch_allowed({{36u, uint288(59u)}, {16u, uint288(41u)}}, 36u)); // 59/100 = 59% + EXPECT_FALSE(successor_switch_allowed({{36u, uint288(4u)}, {16u, uint288(3u)}}, 36u)); // 4/7: 400 < 420 -> reject + EXPECT_FALSE(successor_switch_allowed({{36u, uint288(3u)}, {16u, uint288(4u)}}, 36u)); // 3/7: 300 < 420 -> reject + EXPECT_FALSE(successor_switch_allowed({}, 36u)); // empty } // v36 activation gate, exact-rational 95% on the work-weighted tally. KAT @@ -626,10 +630,11 @@ TEST(DashConformanceVersionNeg, V36GateWeighted95PercentKat) { EXPECT_FALSE(v36_active({{36u, big * uint288(94u)}, {16u, big * uint288(6u)}})); } -// The two tallies must diverge: on the 3x-v36(diff1)+1x-v16(heavy) chain the -// PLAIN guard clears (v36 holds 3/4 of votes >= 60%) but the WEIGHTED gate -// denies (v36 holds only ~60% of work < 95%). Proves the gate consumes the -// weighted variant, not the plain count (F10 separation). +// D1 + F10 separation: the 60% SUCCESSOR gate consumes the WEIGHTED tally, not +// the plain count. On the 3x-v36(diff1)+1x-v16(heavy) chain the two disagree at +// the 60% boundary: plain v36 = 3/4 = 75% (a plain-count gate WOULD clear), but +// weighted v36 = 3*W_DIFF1 / (3*W_DIFF1 + W_HEAVY) = 59.9996% < 60%, so the +// canonical weighted gate DENIES. The 95% activation gate likewise denies. TEST(DashConformanceVersionNeg, GateUsesWeightNotPlainCount) { SyntheticChain sc; const uint160 A = miner_h160(0x02); @@ -641,12 +646,56 @@ TEST(DashConformanceVersionNeg, GateUsesWeightNotPlainCount) { auto plain = dash::version_negotiation::get_desired_version_counts(sc.chain, tip, 4); auto weights = dash::version_negotiation::get_desired_version_weights(sc.chain, tip, 4); - // Plain: v36 = 3/4 of votes -> SUCCESSOR switch permitted. - EXPECT_TRUE(dash::version_negotiation::successor_switch_allowed(plain, 36u)); - // Weighted: v36 work share ~60% (3*W_DIFF1 / (3*W_DIFF1 + W_HEAVY)) < 95%. + // Plain count: v36 holds 3/4 = 75% of votes -- a plain-count 60% gate WOULD clear. + const std::map plain_expected = {{16u, 1u}, {36u, 3u}}; + EXPECT_EQ(plain, plain_expected); + // Weighted 60% SUCCESSOR gate (D1): v36 work ~59.9996% < 60% -> DENY. + EXPECT_FALSE(dash::version_negotiation::successor_switch_allowed(weights, 36u)); + // Weighted 95% activation gate: v36 work ~60% < 95% -> not active. EXPECT_FALSE(dash::version_negotiation::v36_active(weights)); } +// D2: unified 5-case version-switch classifier (F10 share_check.hpp step 2). +// Pins classify_switch() over every transition shape and the switch_accepted() +// decision table, then cross-checks the SuccessorGated body against the live D1 +// 60% weighted gate so the classifier can never diverge from successor_switch_allowed. +TEST(DashConformanceVersionNeg, SwitchClassifier5CaseKat) { + using namespace dash::version_negotiation; + using C = dash::version_negotiation::SwitchClass; + + // classify_switch: structural transition classification (prev, desired, has_history). + EXPECT_EQ(classify_switch(36u, 36u, true), C::Same); // continuation + EXPECT_EQ(classify_switch(36u, 36u, false), C::Same); // history irrelevant + EXPECT_EQ(classify_switch(35u, 34u, true), C::PredecessorAllowed); // downgrade-by-one + EXPECT_EQ(classify_switch(35u, 34u, false), C::PredecessorAllowed); // needs no window + EXPECT_EQ(classify_switch(35u, 36u, true), C::SuccessorGated); // +1 with support window + EXPECT_EQ(classify_switch(35u, 36u, false), C::NoHistory); // +1, window absent + EXPECT_EQ(classify_switch(35u, 37u, true), C::InvalidJump); // +2 skip + EXPECT_EQ(classify_switch(35u, 33u, true), C::InvalidJump); // -2 skip + EXPECT_EQ(classify_switch(35u, 40u, false), C::InvalidJump); // +5, structural reject + + // switch_accepted: final verdict. SuccessorGated defers to the 60% gate; + // every other case is decided structurally (gate_cleared is then ignored). + EXPECT_TRUE (switch_accepted(C::Same, false)); // continuation always ok + EXPECT_TRUE (switch_accepted(C::PredecessorAllowed, false)); // rollback always ok + EXPECT_TRUE (switch_accepted(C::SuccessorGated, true)); // +1, gate cleared + EXPECT_FALSE(switch_accepted(C::SuccessorGated, false)); // +1, gate denied + EXPECT_FALSE(switch_accepted(C::InvalidJump, true)); // skip never ok + EXPECT_FALSE(switch_accepted(C::NoHistory, true)); // no support window + + // Cross-check the gated body against the live D1 weighted gate: a +1 switch + // to v36 with history is accepted exactly when successor_switch_allowed clears. + const std::map clears = {{36u, uint288(6u)}, {16u, uint288(4u)}}; // 60% + const std::map denies = {{36u, uint288(5u)}, {16u, uint288(5u)}}; // 50% + EXPECT_TRUE (switch_accepted(classify_switch(35u, 36u, true), + successor_switch_allowed(clears, 36u))); + EXPECT_FALSE(switch_accepted(classify_switch(35u, 36u, true), + successor_switch_allowed(denies, 36u))); + // Same chain, but window absent -> NoHistory short-circuits the gate entirely. + EXPECT_FALSE(switch_accepted(classify_switch(35u, 36u, false), + successor_switch_allowed(clears, 36u))); +} + // ── DIP4 special-tx coinbase payload (CCbTx) wire-encoding conformance ────── // DASH coinbase transactions carry a DIP-0004 "special transaction" extra // payload: the CCbTx. Its merkleRootMNList / merkleRootQuorums fields are what