diff --git a/src/core/test/CMakeLists.txt b/src/core/test/CMakeLists.txt index fdde56e6e..2bce14e47 100644 --- a/src/core/test/CMakeLists.txt +++ b/src/core/test/CMakeLists.txt @@ -1,5 +1,5 @@ if (BUILD_TESTING AND (GTest_FOUND OR GTEST_FOUND)) - add_executable(core_test pack_test.cpp events_test.cpp chain_test.cpp packet_test.cpp block_broadcast_test.cpp broadcast_convergence_matrix_test.cpp core_merkle_branches_test.cpp) + add_executable(core_test pack_test.cpp events_test.cpp chain_test.cpp packet_test.cpp block_broadcast_test.cpp broadcast_convergence_matrix_test.cpp core_merkle_branches_test.cpp version_gate_test.cpp) target_link_libraries(core_test PRIVATE GTest::gtest_main core c2pool_merged_mining GTest::gtest) target_link_libraries(core_test PRIVATE c2pool_payout c2pool_hashrate ltc_coin) # OBJECT-lib SCC direct-naming (#22/#39) diff --git a/src/core/test/version_gate_test.cpp b/src/core/test/version_gate_test.cpp new file mode 100644 index 000000000..c43673a75 --- /dev/null +++ b/src/core/test/version_gate_test.cpp @@ -0,0 +1,109 @@ +#include + +#include +#include +#include +#include + +#include +#include + +using core::version_gate::verify_version_transition; + +// KAT for the cross-coin v36-native share-version-transition rule SSOT. +// Weight type is uint288, matching get_desired_version_weights' tally type. + +namespace { + +// Build a weighted desired-version tally: { version -> weight }. +std::map tally(std::initializer_list> entries) +{ + std::map m; + for (const auto& [ver, w] : entries) + m[ver] = uint288(w); + return m; +} + +} // namespace + +TEST(VersionGateTransition, SameVersionAdmitted) +{ + // parent=36, share=36: never throws (correct when minted), with or w/o history. + auto w = tally({{36, 100}}); + EXPECT_NO_THROW(verify_version_transition(36, 36, w, /*have_history=*/true)); + EXPECT_NO_THROW(verify_version_transition(36, 36, w, /*have_history=*/false)); +} + +TEST(VersionGateTransition, UpgradeWithMajoritySupportAdmitted) +{ + // +1 upgrade, new version holds 70% of weighted support, history present -> ok. + auto w = tally({{36, 70}, {35, 30}}); + EXPECT_NO_THROW(verify_version_transition(35, 36, w, /*have_history=*/true)); +} + +TEST(VersionGateTransition, UpgradeWithoutMajoritySupportRejected) +{ + // +1 upgrade, new version only 50% of weighted support -> reject. + auto w = tally({{36, 50}, {35, 50}}); + try + { + verify_version_transition(35, 36, w, /*have_history=*/true); + FAIL() << "expected throw"; + } + catch (const std::invalid_argument& e) + { + EXPECT_STREQ(e.what(), "switch without enough hash power upgraded"); + } +} + +TEST(VersionGateTransition, UpgradeWithoutHistoryRejected) +{ + // +1 upgrade, no CHAIN_LENGTH history -> reject with the history message. + auto w = tally({{36, 100}}); + try + { + verify_version_transition(35, 36, w, /*have_history=*/false); + FAIL() << "expected throw"; + } + catch (const std::invalid_argument& e) + { + EXPECT_STREQ(e.what(), "switch without enough history"); + } +} + +TEST(VersionGateTransition, DowngradeByOneAdmitted) +{ + // -1 downgrade (parent=36, share=35: AutoRatchet deactivation) with history -> ok. + auto w = tally({{36, 100}}); + EXPECT_NO_THROW(verify_version_transition(36, 35, w, /*have_history=*/true)); +} + +TEST(VersionGateTransition, MultiVersionJumpWithHistoryRejected) +{ + // parent=34, share=36 (jump of 2) with history -> invalid version jump. + auto w = tally({{36, 100}}); + try + { + verify_version_transition(34, 36, w, /*have_history=*/true); + FAIL() << "expected throw"; + } + catch (const std::invalid_argument& e) + { + EXPECT_STREQ(e.what(), "invalid version jump from 34 to 36"); + } +} + +TEST(VersionGateTransition, MultiVersionJumpWithoutHistoryAdmitted) +{ + // parent=34, share=36 with no history: only +1 upgrades are gated -> admitted. + auto w = tally({{36, 100}}); + EXPECT_NO_THROW(verify_version_transition(34, 36, w, /*have_history=*/false)); +} + +TEST(VersionGateTransition, ExactSixtyPercentBoundaryAdmitted) +{ + // new version == exactly 60% of total. Rule is `new*100 < total*60`: + // 60*100 == 100*60, NOT strictly less -> 60% PASSES (no throw). + auto w = tally({{36, 60}, {35, 40}}); // total 100, new=60 -> exactly 60% + EXPECT_NO_THROW(verify_version_transition(35, 36, w, /*have_history=*/true)); +} diff --git a/src/core/version_gate.hpp b/src/core/version_gate.hpp index ad5f12e6d..f1b262b82 100644 --- a/src/core/version_gate.hpp +++ b/src/core/version_gate.hpp @@ -32,6 +32,9 @@ // #include +#include +#include +#include namespace core::version_gate { @@ -47,4 +50,73 @@ constexpr bool is_v36_active(uint64_t version) return version >= V36_ACTIVATION_VERSION; } + +// Canonical v36-native share-version-transition rule (cross-coin SSOT). +// Applies the boundary admit/reject decision to an ALREADY-EXTRACTED +// (parent_version, share_version) pair using a PRECOMPUTED PPLNS-WEIGHTED +// desired-version tally for the [CHAIN_LENGTH*9/10, CHAIN_LENGTH] window behind +// the parent, plus a flag for whether CHAIN_LENGTH history exists behind the +// parent. Throws std::invalid_argument on a disallowed switch; returns on an +// admissible one. +// +// Rule (p2pool data.py check()): same-version always valid; +1 upgrade needs +// >= 60% PPLNS-WEIGHTED desired-version support for the new version in the +// window AND CHAIN_LENGTH history; -1 (AutoRatchet deactivation) valid; any +// other jump throws when history exists. With insufficient history only a +1 +// upgrade is rejected ("switch without enough history") and other shapes pass, +// matching the BTC inline gate exactly. +// +// `floor` is the per-coin v36 activation version (e.g. BTC 35->36, DASH 16->36). +// It is bucket-3 transition-compat carried as a PARAMETER (never hardcoded) so a +// coin whose seam also runs an obsolescence branch (DASH) can thread its own +// floor when it migrates onto this SSOT; the canonical boundary rule itself does +// not reference it. Defaulted to V36_ACTIVATION_VERSION. +template +inline void verify_version_transition( + int64_t parent_version, + int64_t share_version, + const std::map& window_weights, + bool have_history, + uint64_t floor = V36_ACTIVATION_VERSION) +{ + (void)floor; + // same version — always valid (it was correct when minted), regardless of history. + if (share_version == parent_version) + return; + + if (have_history) + { + if (share_version == parent_version + 1) + { + // Upgrade by one: needs >= 60% weighted support for the new version. + WeightT new_ver_weight{}; + WeightT total_weight{}; + for (const auto& [ver, w] : window_weights) + { + total_weight = total_weight + w; + if (static_cast(ver) == share_version) + new_ver_weight = new_ver_weight + w; + } + // canonical: counts.get(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_version + 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_version)); + } + } + else if (share_version == parent_version + 1) + { + // Not enough history for an upgrade boundary. + throw std::invalid_argument("switch without enough history"); + } + // else (downgrade / multi-jump with insufficient history): admitted, matching BTC. +} + } // namespace core::version_gate