From 37f16907be038c51d35ea618a78b95939504679f Mon Sep 17 00:00:00 2001 From: frstrtr Date: Sun, 5 Jul 2026 19:57:59 +0000 Subject: [PATCH] dash(#357): v36-native mint-side accept-floor auto-ratchet (FENCED/UNWIRED) Close the single gap the #357 AutoRatchet mint-gate weighting audit found: DASH defines the weighted desired-version tally (get_desired_version_weights) and the 60/95 accept gates consume it, but had NO mint-side consumer that lifts MINIMUM_PROTOCOL_VERSION off the same weighted window. Add src/impl/dash/auto_ratchet.hpp mirroring dgb::apply_min_protocol_ratchet_decision: ratchet_min_protocol_version (pure, floor-divided 95% work-weighted gate, oracle update_min_protocol_version dict-branch) + the full-window call-site guard (no lift until parent_height >= CHAIN_LENGTH over the same window the 60% switch gate reads). Purely additive, no node.cpp call site -- the live consumer is consensus-bearing and rides the S8 wire-up. KAT test_dash_auto_ratchet (14/14): floor-div boundary, empty-window dict-branch, consensus-magnitude no-overflow, full-window guard, and the gap-closing weighted-vs-plain-count divergence assert on a crafted window (v36 work-dominant but count-minority -> weighted ratchets, flat count does not). Registered in test/CMakeLists.txt and both build.yml --target allowlists. Fence: src/impl/dash/ + test/ only; no bitcoin_family/src/core/other-coin touch. --- .github/workflows/build.yml | 4 +- src/impl/dash/auto_ratchet.hpp | 137 ++++++++++++++++++++++++ test/CMakeLists.txt | 15 +++ test/test_dash_auto_ratchet.cpp | 178 ++++++++++++++++++++++++++++++++ 4 files changed, 332 insertions(+), 2 deletions(-) create mode 100644 src/impl/dash/auto_ratchet.hpp create mode 100644 test/test_dash_auto_ratchet.cpp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 421bd140..e023479d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -102,7 +102,7 @@ jobs: core_test sharechain_test share_test btc_share_test \ test_threading test_weights \ test_header_chain test_mempool test_template_builder \ - test_doge_chain test_compact_blocks test_dash_x11_kat test_dash_header_chain test_dash_block_replay test_dash_conformance test_dash_subsidy test_dash_mempool test_dash_simplifiedmns test_dash_quorum test_dash_quorum_root test_dash_mn_state test_dash_embedded_gbt test_dash_smldiff test_dash_p2p_messages test_dash_p2p_connection test_dash_p2p_node test_dash_node_interface test_dash_config test_dash_broadcaster test_dash_broadcaster_full test_dash_share_hash_link test_dash_block_relay test_dash_rpc_request test_dash_rpc_conf test_dash_block_producer test_dash_embedded_relay_e2e test_dash_block_relay_plan test_dash_version_activation_latch test_dash_block_relay_binding test_dash_block_relay_dual_arm test_dash_coinbase_parity test_dash_donation_combined test_dash_g3_assembled test_dash_work_target test_dash_work_job_targets test_dash_stratum_binding test_dash_job_notify_roundtrip test_dash_cb_payee test_dash_stratum_notify_roundtrip test_dash_poolnode_messages test_dash_peer test_dash_share_tracker test_dash_node test_dash_share_messages test_dash_stratum_extranonce_split test_dash_stratum_submit_reassembly test_dash_difficulty_parity \ + test_doge_chain test_compact_blocks test_dash_x11_kat test_dash_header_chain test_dash_block_replay test_dash_conformance test_dash_subsidy test_dash_mempool test_dash_simplifiedmns test_dash_quorum test_dash_quorum_root test_dash_mn_state test_dash_embedded_gbt test_dash_smldiff test_dash_p2p_messages test_dash_p2p_connection test_dash_p2p_node test_dash_node_interface test_dash_config test_dash_broadcaster test_dash_broadcaster_full test_dash_share_hash_link test_dash_block_relay test_dash_rpc_request test_dash_rpc_conf test_dash_block_producer test_dash_embedded_relay_e2e test_dash_block_relay_plan test_dash_version_activation_latch test_dash_block_relay_binding test_dash_block_relay_dual_arm test_dash_coinbase_parity test_dash_donation_combined test_dash_g3_assembled test_dash_work_target test_dash_work_job_targets test_dash_stratum_binding test_dash_job_notify_roundtrip test_dash_cb_payee test_dash_stratum_notify_roundtrip test_dash_poolnode_messages test_dash_peer test_dash_share_tracker test_dash_node test_dash_share_messages test_dash_stratum_extranonce_split test_dash_stratum_submit_reassembly test_dash_difficulty_parity test_dash_auto_ratchet \ test_multiaddress_pplns test_pplns_stress \ test_hash_link test_decay_pplns \ test_pplns_consensus \ @@ -252,7 +252,7 @@ jobs: test_threading test_weights \ test_header_chain test_mempool test_template_builder \ test_doge_chain test_compact_blocks test_dash_x11_kat \ - test_dash_header_chain test_dash_block_replay test_dash_conformance test_dash_subsidy test_dash_mempool test_dash_simplifiedmns test_dash_quorum test_dash_quorum_root test_dash_mn_state test_dash_embedded_gbt test_dash_smldiff test_dash_p2p_messages test_dash_p2p_connection test_dash_p2p_node test_dash_node_interface test_dash_config test_dash_broadcaster test_dash_broadcaster_full test_dash_share_hash_link test_dash_block_relay test_dash_rpc_request test_dash_rpc_conf test_dash_block_producer test_dash_embedded_relay_e2e test_dash_block_relay_plan test_dash_version_activation_latch test_dash_block_relay_binding test_dash_block_relay_dual_arm test_dash_coinbase_parity test_dash_donation_combined test_dash_g3_assembled test_dash_work_target test_dash_work_job_targets test_dash_stratum_binding test_dash_job_notify_roundtrip test_dash_cb_payee test_dash_stratum_notify_roundtrip test_dash_poolnode_messages test_dash_peer test_dash_share_tracker test_dash_node test_dash_share_messages test_dash_stratum_extranonce_split test_dash_stratum_submit_reassembly test_dash_difficulty_parity \ + test_dash_header_chain test_dash_block_replay test_dash_conformance test_dash_subsidy test_dash_mempool test_dash_simplifiedmns test_dash_quorum test_dash_quorum_root test_dash_mn_state test_dash_embedded_gbt test_dash_smldiff test_dash_p2p_messages test_dash_p2p_connection test_dash_p2p_node test_dash_node_interface test_dash_config test_dash_broadcaster test_dash_broadcaster_full test_dash_share_hash_link test_dash_block_relay test_dash_rpc_request test_dash_rpc_conf test_dash_block_producer test_dash_embedded_relay_e2e test_dash_block_relay_plan test_dash_version_activation_latch test_dash_block_relay_binding test_dash_block_relay_dual_arm test_dash_coinbase_parity test_dash_donation_combined test_dash_g3_assembled test_dash_work_target test_dash_work_job_targets test_dash_stratum_binding test_dash_job_notify_roundtrip test_dash_cb_payee test_dash_stratum_notify_roundtrip test_dash_poolnode_messages test_dash_peer test_dash_share_tracker test_dash_node test_dash_share_messages test_dash_stratum_extranonce_split test_dash_stratum_submit_reassembly test_dash_difficulty_parity test_dash_auto_ratchet \ test_hash_link test_decay_pplns \ test_pplns_consensus \ test_v36_script_sorting test_v36_cross_impl_refhash \ diff --git a/src/impl/dash/auto_ratchet.hpp b/src/impl/dash/auto_ratchet.hpp new file mode 100644 index 00000000..db5d9e38 --- /dev/null +++ b/src/impl/dash/auto_ratchet.hpp @@ -0,0 +1,137 @@ +#pragma once + +// DASH v36-native MINT-side auto-ratchet of the P2P accept floor. +// +// FENCED / ADDITIVE / UNWIRED. This header carries NO src/ call site — the live +// consumer (node.cpp, analogous to dgb::NodeImpl min-proto-ratchet) rides the S8 +// pool-node wire-up and is consensus-bearing, so it is surfaced separately for an +// integrator merge tap. Landing the skeleton here de-risks that wire-up and closes +// the single gap the #357 AutoRatchet mint-gate weighting audit found: DASH already +// defines the WEIGHTED desired-version tally (version_negotiation.hpp +// get_desired_version_weights) and the 60%/95% accept gates consume it, but there +// was NO mint-side consumer that lifts MINIMUM_PROTOCOL_VERSION off the same window. +// +// v36-STANDARDIZATION (operator 3-bucket rule, bucket 2 = v36-NATIVE SHARED +// STRUCTURE): the auto-ratchet is standardized cross-coin toward the v37 shape. It +// mirrors dgb::apply_min_protocol_ratchet_decision byte-for-byte in decision logic; +// only the floor constants differ per coin (the ISOLATION invariant stays per-coin). +// +// ORACLE NOTE (honest, surfaced to integrator): the DASH oracle frstrtr/p2pool-dash +// (older-than-v35) has NO update_min_protocol_version and NO NEW_MINIMUM_PROTOCOL_VERSION +// — MINIMUM_PROTOCOL_VERSION is the static 1700 net constant (dash.py:23). The +// dgb-scrypt oracle DOES ratchet (data.py:715 newminpver = NEW_MINIMUM_PROTOCOL_VERSION, +// 1400 -> 3500). So the DASH ratchet TARGET floor is a c2pool v36-native choice, not +// an oracle-derived number. The decision MATH below is oracle-faithful to the +// dgb-scrypt update_min_protocol_version (floor-divided 95% work-weighted gate); the +// concrete target value is a wire-up-time decision and is intentionally NOT baked in +// here — every function takes current_floor / target_floor as parameters. + +#include // uint288 + +#include +#include +#include + +namespace dash { + +// A single share's contribution to a desired-version tally: the version it desires +// and its work weight (target_to_average_attempts of its target, == ShareIndex::work, +// the same metric get_desired_version_weights caches -- version_negotiation.hpp D5). +struct VersionWork { + int64_t desired_version; + uint288 work; +}; + +// WORK-WEIGHTED accumulation: sum each version's work over the window. This is the +// map get_desired_version_weights produces from a real chain window; exposed here so +// the KAT can exercise the exact weighting divergence without a live ShareChain. +inline std::map +accumulate_version_weights(const std::vector& window) +{ + std::map res; + for (const auto& vw : window) + res[static_cast(vw.desired_version)] += vw.work; + return res; +} + +// FLAT (plain-count) accumulation: one vote per share, work ignored. This is the +// count map the ratchet must NOT consume -- it exists only to PROVE, in the KAT, +// that the weighted decision diverges from the plain-count decision on a crafted +// window (matching the btc/dgb work-weighted-not-flat-count divergence assert). +inline std::map +accumulate_version_counts(const std::vector& window) +{ + std::map res; + for (const auto& vw : window) + res[static_cast(vw.desired_version)] += uint288(1); + return res; +} + +// Pure ratchet -- mirrors the dgb-scrypt oracle update_min_protocol_version dict +// branch (data.py:715-719): +// minpver = getattr(share.net, 'MINIMUM_PROTOCOL_VERSION', 1700) +// newminpver = getattr(share.net, 'NEW_MINIMUM_PROTOCOL_VERSION', minpver) +// if (counts is not None) and (minpver < newminpver): +// if counts.get(share.VERSION, 0) >= sum(counts.itervalues())*95//100: +// share.net.MINIMUM_PROTOCOL_VERSION = newminpver +// +// counts is the WORK-WEIGHTED map (get_desired_version_weights), keyed by +// desired_version and looked up by the best share's VERSION. Pure: no I/O, inputs +// unmutated; returns the (possibly lifted) floor. +// +// Integer math is the oracle's exactly: ratchet iff +// best_weight >= floor(total_weight * 95 / 100). +// We floor-divide (total*95)/100 rather than the cross-multiplied +// best*100 >= total*95 idiom -- the two differ at the boundary when +// (total*95) % 100 != 0 (floor-div accepts best == floor(...), cross-mult rejects), +// and the oracle floor-divides. uint288 throughout: no IEEE-double, no overflow at +// consensus (2^256-scale) weights. +inline uint32_t ratchet_min_protocol_version( + const std::map& version_weights, + int64_t best_version, + uint32_t current_floor, + uint32_t target_floor) +{ + if (current_floor >= target_floor) // oracle: minpver < newminpver guard + return current_floor; + + uint288 best_weight; + uint288 total_weight; + for (const auto& [ver, w] : version_weights) { + total_weight = total_weight + w; + if (static_cast(ver) == best_version) + best_weight = best_weight + w; + } + // oracle: counts.get(share.VERSION, 0) >= sum(counts.itervalues())*95//100 + if (best_weight >= (total_weight * uint32_t(95)) / uint32_t(100)) + return target_floor; + return current_floor; +} + +// Runtime WIRING decision -- the call-site guard the pure ratchet deliberately omits. +// ratchet_min_protocol_version mirrors ONLY the oracle dict-branch: over an empty or +// partial window it ratchets (0 >= 0), because the oracle's full-window guard lives +// at the CALL SITE (main.py: only update when len(shares) > CHAIN_LENGTH, sampling +// get_desired_version over [nth_parent(prev, CHAIN_LENGTH*9/10), CHAIN_LENGTH/10] -- +// the SAME window the 60% version-switch gate reads, version_negotiation.hpp). Without +// this guard a fresh node (parent_height < CHAIN_LENGTH -> partial window) would +// spuriously lift the floor and reject every legitimate peer. This function is that +// guard: no lift until a FULL window exists behind the best share's parent, then +// delegate to the pure ratchet over the window the caller sampled. +inline uint32_t apply_min_protocol_ratchet_decision( + int32_t parent_height, + int32_t chain_length, + const std::map& window_weights, + int64_t best_version, + uint32_t current_floor, + uint32_t target_floor) +{ + if (current_floor >= target_floor) // oracle guard: minpver < newminpver + return current_floor; + if (parent_height < chain_length) // oracle: len(shares) > CHAIN_LENGTH + return current_floor; // no full window yet -> never lift + return ratchet_min_protocol_version( + window_weights, best_version, current_floor, target_floor); +} + +} // namespace dash diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index d6d9f93d..ea8e4fa9 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -572,6 +572,21 @@ if (BUILD_TESTING AND GTest_FOUND) target_link_libraries(test_dash_config PRIVATE c2pool_payout c2pool_hashrate c2pool_merged_mining) # OBJECT-lib SCC direct-naming (#22/#39) gtest_add_tests(test_dash_config "" AUTO) + # DASH v36-native MINT-side accept-floor auto-ratchet (#357 gap-closer, FENCED/ + # UNWIRED). Pins the pure dash::ratchet_min_protocol_version + the full-window + # apply_min_protocol_ratchet_decision guard, and the weighted-vs-plain-count + # divergence assert. Header-only over core (uint288); mirrors the test_dash_config + # link set. + add_executable(test_dash_auto_ratchet test_dash_auto_ratchet.cpp) + target_link_libraries(test_dash_auto_ratchet PRIVATE + GTest::gtest_main GTest::gtest + dash_x11 core + nlohmann_json::nlohmann_json + ${Boost_LIBRARIES} + ) + target_link_libraries(test_dash_auto_ratchet PRIVATE c2pool_payout c2pool_hashrate c2pool_merged_mining) # OBJECT-lib SCC direct-naming (#22/#39) + gtest_add_tests(test_dash_auto_ratchet "" AUTO) + add_executable(test_dash_peer test_dash_peer.cpp) target_link_libraries(test_dash_peer PRIVATE GTest::gtest_main GTest::gtest diff --git a/test/test_dash_auto_ratchet.cpp b/test/test_dash_auto_ratchet.cpp new file mode 100644 index 00000000..e0c35975 --- /dev/null +++ b/test/test_dash_auto_ratchet.cpp @@ -0,0 +1,178 @@ +// DASH v36-native MINT-side accept-floor auto-ratchet KAT. +// +// FENCED, additive (no production call site touched -- pins the pure free functions +// in src/impl/dash/auto_ratchet.hpp). Mirrors the dgb-scrypt oracle +// update_min_protocol_version (data.py:715-719): +// minpver = getattr(share.net, 'MINIMUM_PROTOCOL_VERSION', 1700) +// newminpver = getattr(share.net, 'NEW_MINIMUM_PROTOCOL_VERSION', minpver) +// if (counts is not None) and (minpver < newminpver): +// if counts.get(share.VERSION, 0) >= sum(counts.itervalues())*95//100: +// share.net.MINIMUM_PROTOCOL_VERSION = newminpver +// +// counts is the WORK-WEIGHTED get_desired_version_weights map -- each share weighted +// by target_to_average_attempts(target), keyed by desired_version, looked up by the +// best share VERSION. The cold floor 1700 is the DASH oracle net constant +// (frstrtr/p2pool-dash networks/dash.py:23, == config_pool.hpp MINIMUM_PROTOCOL_VERSION). +// +// TARGET below is a v36-successor floor STAND-IN: the DASH oracle has no +// NEW_MINIMUM_PROTOCOL_VERSION, so the concrete ratchet target is a c2pool v36-native +// choice made at the S8 wire-up (see auto_ratchet.hpp ORACLE NOTE). The KAT exercises +// the decision MATH, which is oracle-faithful independent of the target's value. +// +// The window the runtime samples is anchor = nth_parent(prev, CHAIN_LENGTH*9/10), +// size = CHAIN_LENGTH/10 -- the SAME window the 60% version-switch accept gate reads +// (version_negotiation.hpp). CL below is a stand-in CHAIN_LENGTH. +// +// Every expectation is hand-derived from the oracle formula. MUST appear in BOTH this +// dir CMakeLists.txt AND the build.yml --target allowlists (there are TWO), or it +// becomes a NOT_BUILT sentinel that silently passes. + +#include + +#include // uint288 + +#include + +namespace { + +uint288 u(uint64_t n) { return uint288(n); } + +// 2^k as a uint288 -- the scale of a real target_to_average_attempts (ttaa(2^k-1) +// = 2^(256-k)), so the boundary/overflow cases run at consensus magnitudes. +uint288 pow2(unsigned k) { + uint288 v = u(1); + for (unsigned i = 0; i < k; ++i) v = v * uint32_t(2); + return v; +} + +constexpr uint32_t COLD = 1700; // DASH oracle dash.py:23 MINIMUM_PROTOCOL_VERSION +constexpr uint32_t TARGET = 3600; // v36-successor floor STAND-IN (set at S8 wire-up) + +} // namespace + +// ============================================================================ +// Pure ratchet -- dash::ratchet_min_protocol_version (oracle dict-branch) +// ============================================================================ + +// --- guard: floor already at/above target -> no-op (minpver < newminpver false) +TEST(DashAutoRatchet, AlreadyAtTargetIsNoOp) { + EXPECT_EQ(dash::ratchet_min_protocol_version({{36, u(100)}}, 36, TARGET, TARGET), TARGET); + EXPECT_EQ(dash::ratchet_min_protocol_version({{36, u(100)}}, 36, TARGET + 1, TARGET), TARGET + 1); +} + +// --- unanimous support -> ratchet cold floor up to target ------------------- +TEST(DashAutoRatchet, UnanimousRatchets) { + EXPECT_EQ(dash::ratchet_min_protocol_version({{36, u(100)}}, 36, COLD, TARGET), TARGET); +} + +// --- exactly 95% (sum=100, best=95) -> 95 >= 100*95//100=95 -> ratchet ------- +TEST(DashAutoRatchet, Exactly95PercentRatchets) { + auto w = std::map{{36, u(95)}, {35, u(5)}}; + EXPECT_EQ(dash::ratchet_min_protocol_version(w, 36, COLD, TARGET), TARGET); +} + +// --- just below 95% (sum=100, best=94) -> 94 >= 95 false -> stays cold ------- +TEST(DashAutoRatchet, JustBelow95PercentStaysCold) { + auto w = std::map{{36, u(94)}, {35, u(6)}}; + EXPECT_EQ(dash::ratchet_min_protocol_version(w, 36, COLD, TARGET), COLD); +} + +// --- floor-div vs cross-multiply BOUNDARY: sum=101, best=95. +// oracle floor: 95 >= floor(101*95/100)=floor(95.95)=95 -> RATCHET. +// cross-mult (best*100 >= sum*95): 9500 >= 9595 false -> would NOT ratchet. +// Proves the function floor-divides like the oracle, not cross-mults. +TEST(DashAutoRatchet, FloorDivBoundaryRatchets) { + auto w = std::map{{36, u(95)}, {35, u(6)}}; // sum=101 + EXPECT_EQ(dash::ratchet_min_protocol_version(w, 36, COLD, TARGET), TARGET); +} + +// --- best version absent from the map -> best_weight 0 -> stays cold --------- +TEST(DashAutoRatchet, BestVersionAbsentStaysCold) { + EXPECT_EQ(dash::ratchet_min_protocol_version({{35, u(100)}}, 36, COLD, TARGET), COLD); +} + +// --- empty window: 0 >= 0*95//100=0 -> oracle ratchets (counts={} != None). +// The None-guard is the CALL-SITE job; the pure fn mirrors the dict-branch. +TEST(DashAutoRatchet, EmptyWindowMirrorsOracleDictBranch) { + EXPECT_EQ(dash::ratchet_min_protocol_version({}, 36, COLD, TARGET), TARGET); +} + +// --- consensus-magnitude weights (2^256-scale), no overflow in total*95 ------ +TEST(DashAutoRatchet, ConsensusMagnitudeNoOverflow) { + uint288 unit = pow2(256); + uint288 best = unit; for (int i = 1; i < 96; ++i) best = best + unit; // 96 * 2^256 + uint288 rest = unit; for (int i = 1; i < 4; ++i) rest = rest + unit; // 4 * 2^256 + auto w = std::map{{36, best}, {35, rest}}; + EXPECT_EQ(dash::ratchet_min_protocol_version(w, 36, COLD, TARGET), TARGET); +} + +// ============================================================================ +// THE GAP-CLOSING ASSERT (integrator #357 follow-up #2): the WEIGHTED decision +// must DIVERGE from the PLAIN-COUNT decision on a crafted window. If the ratchet +// ever regressed to consuming a flat count, this test flips red. +// ============================================================================ + +// Crafted window: v36 is a single HEAVY share (dominant WORK); v35 is ten TINY +// shares (dominant COUNT). Best share VERSION = 36. +// WEIGHTED: v36=1000, v35=10, total=1010 -> 1000 >= 1010*95//100=959 -> RATCHET. +// FLAT: v36=1, v35=10, total=11 -> 1 >= 11*95//100=10 -> stays COLD. +// Same window, same best_version, full window present -> opposite decisions. +TEST(DashAutoRatchet, WeightedDivergesFromPlainCount) { + std::vector window; + for (int i = 0; i < 10; ++i) window.push_back({35, u(1)}); // 10 tiny v35 shares + window.push_back({36, u(1000)}); // 1 heavy v36 share + + auto weighted = dash::accumulate_version_weights(window); + auto flat = dash::accumulate_version_counts(window); + + const int32_t CL = 100; // full window present (parent_height >= CL) for both arms + + const uint32_t weighted_decision = + dash::apply_min_protocol_ratchet_decision(CL, CL, weighted, 36, COLD, TARGET); + const uint32_t flat_decision = + dash::apply_min_protocol_ratchet_decision(CL, CL, flat, 36, COLD, TARGET); + + EXPECT_EQ(weighted_decision, TARGET); // work-weighted -> ratchets + EXPECT_EQ(flat_decision, COLD); // plain-count -> does NOT ratchet + EXPECT_NE(weighted_decision, flat_decision); // the divergence is the whole point +} + +// ============================================================================ +// apply_min_protocol_ratchet_decision -- RUNTIME WIRING full-window guard +// ============================================================================ + +constexpr int32_t CL = 100; // stand-in CHAIN_LENGTH for the window guard + +// --- FULL-WINDOW GUARD: parent_height < CHAIN_LENGTH -> NEVER lift, even when the +// partial window is unanimous. Fresh-node bug the wiring exists to prevent. +TEST(DashAutoRatchetWiring, ShortChainNeverLifts) { + auto w = std::map{{36, u(100)}}; // unanimous + EXPECT_EQ(dash::ratchet_min_protocol_version(w, 36, COLD, TARGET), TARGET); // pure fn would + EXPECT_EQ(dash::apply_min_protocol_ratchet_decision(CL - 1, CL, w, 36, COLD, TARGET), COLD); + EXPECT_EQ(dash::apply_min_protocol_ratchet_decision(0, CL, w, 36, COLD, TARGET), COLD); +} + +// --- empty window + short chain -> no lift. +TEST(DashAutoRatchetWiring, ShortChainEmptyWindowNoLift) { + EXPECT_EQ(dash::apply_min_protocol_ratchet_decision(CL - 1, CL, {}, 36, COLD, TARGET), COLD); +} + +// --- FULL WINDOW present (parent_height >= CHAIN_LENGTH) + unanimous -> lift. +TEST(DashAutoRatchetWiring, FullWindowUnanimousLifts) { + auto w = std::map{{36, u(100)}}; + EXPECT_EQ(dash::apply_min_protocol_ratchet_decision(CL, CL, w, 36, COLD, TARGET), TARGET); + EXPECT_EQ(dash::apply_min_protocol_ratchet_decision(CL * 5, CL, w, 36, COLD, TARGET), TARGET); +} + +// --- FULL WINDOW present but best < 95% -> stays cold (delegates to pure fn). +TEST(DashAutoRatchetWiring, FullWindowBelow95StaysCold) { + auto w = std::map{{36, u(94)}, {35, u(6)}}; // sum=100, 94<95 + EXPECT_EQ(dash::apply_min_protocol_ratchet_decision(CL, CL, w, 36, COLD, TARGET), COLD); +} + +// --- already at/above target short-circuits regardless of window/height (latch). +TEST(DashAutoRatchetWiring, AlreadyAtTargetShortCircuits) { + auto w = std::map{{36, u(100)}}; + EXPECT_EQ(dash::apply_min_protocol_ratchet_decision(CL, CL, w, 36, TARGET, TARGET), TARGET); + EXPECT_EQ(dash::apply_min_protocol_ratchet_decision(0, CL, w, 36, TARGET, TARGET), TARGET); +}