diff --git a/src/impl/dgb/coin/desired_version_tally.hpp b/src/impl/dgb/coin/desired_version_tally.hpp index d79b7aec4..4fd6d6753 100644 --- a/src/impl/dgb/coin/desired_version_tally.hpp +++ b/src/impl/dgb/coin/desired_version_tally.hpp @@ -128,4 +128,33 @@ inline uint32_t ratchet_min_protocol_version( return current_floor; } +// Runtime WIRING decision -- the call-site guard the pure ratchet above deliberately +// omits. ratchet_min_protocol_version mirrors ONLY the oracle dict-branch +// (data.py:861): over an empty/partial window it ratchets (0 >= 0), because the +// oracle's None / full-window guard lives at the CALL SITE (main.py:212): +// if len(shares) > CHAIN_LENGTH: +// counts = get_desired_version_counts(tracker, +// get_nth_parent_hash(previous_share.hash, CHAIN_LENGTH*9//10), CHAIN_LENGTH//10) +// update_min_protocol_version(counts, best_share) +// Without this guard a fresh node (parent_height < CHAIN_LENGTH -> empty window) would +// spuriously lift the floor to 3500 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 [CHAIN_LENGTH*9/10, CHAIN_LENGTH] window the +// caller sampled (the SAME window as the 60% version-switch gate, share_check step 2). +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 main.py:212: 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 dgb diff --git a/src/impl/dgb/node.cpp b/src/impl/dgb/node.cpp index a5d111266..3636fc381 100644 --- a/src/impl/dgb/node.cpp +++ b/src/impl/dgb/node.cpp @@ -309,12 +309,17 @@ std::optional NodeImpl::handle_version(std::unique_ptr peer->write(std::move(getaddrs_msg)); } - // Reject peers running too-old protocol - if (msg->m_version < m_tracker.m_params->minimum_protocol_version) + // Reject peers running too-old protocol. The floor is the RUNTIME + // ratcheted value (cold 1400, lifted to 3500 once >=95% of window work + // desires the best share's version -- apply_min_protocol_ratchet), NOT the + // immutable config floor. Read lock-free; compute thread publishes via store. + const uint32_t min_proto = + m_runtime_min_protocol_version.load(std::memory_order_relaxed); + if (msg->m_version < min_proto) { LOG_WARNING << "Peer " << msg->m_addr_from.m_endpoint.to_string() << " protocol " << msg->m_version - << " < minimum " << m_tracker.m_params->minimum_protocol_version + << " < minimum " << min_proto << ", disconnecting"; throw std::runtime_error("peer protocol too old"); } @@ -806,6 +811,58 @@ void NodeImpl::notify_local_share(const uint256& share_hash) run_think(); } +void NodeImpl::apply_min_protocol_ratchet() +{ + // Runtime wiring of the accept-floor ratchet #602 landed as a pure function. + // Mirrors oracle main.py:213-216 (run on each best-share advance) + data.py:857 + // update_min_protocol_version: over the window [CHAIN_LENGTH*9/10, CHAIN_LENGTH] + // behind the best share's PARENT, lift the inbound P2P accept-floor 1400 -> 3500 + // once the best share's VERSION holds >=95% of the work-weighted desired-version + // tally. Runtime sibling of the 60% version-switch gate (share_check.hpp step 2), + // reading the SAME window. MUST be called under exclusive m_tracker_mutex. + const uint32_t target = PoolConfig::SHARE_MINIMUM_PROTOCOL_VERSION; // 3500 + const uint32_t current = + m_runtime_min_protocol_version.load(std::memory_order_relaxed); + if (current >= target) // already ratcheted -> latched, no-op + return; + if (m_best_share_hash.IsNull() || !m_tracker.chain.contains(m_best_share_hash)) + return; + + // best share's VERSION (share TYPE version) + its parent hash + // (oracle: previous_share = shares[best_share.previous_share_hash]). + int64_t best_version = 0; + uint256 prev_hash; + m_tracker.chain.get_share(m_best_share_hash).invoke([&](auto* obj) { + best_version = std::remove_pointer_t::version; + prev_hash = obj->m_prev_hash; + }); + if (prev_hash.IsNull() || !m_tracker.chain.contains(prev_hash)) + return; + + const int32_t CL = static_cast(m_tracker.m_params->chain_length); + const int32_t parent_height = m_tracker.chain.get_height(prev_hash); + + // Sample the SAME window the 60% switch gate reads (share_check.hpp:1610): + // anchor = nth_parent(parent, CHAIN_LENGTH*9/10), size = CHAIN_LENGTH/10. + const uint32_t window_start = (static_cast(CL) * 9) / 10; + const uint32_t window_size = static_cast(CL) / 10; + auto anchor = m_tracker.chain.get_nth_parent_key(prev_hash, window_start); + auto weights = m_tracker.get_desired_version_weights( + anchor, static_cast(window_size)); + + // apply_min_protocol_ratchet_decision applies the main.py:212 full-window + // guard (parent_height >= CHAIN_LENGTH) the pure ratchet omits, then delegates. + const uint32_t lifted = dgb::apply_min_protocol_ratchet_decision( + parent_height, CL, weights, best_version, current, target); + if (lifted != current) { + m_runtime_min_protocol_version.store(lifted, std::memory_order_relaxed); + LOG_INFO << "[min-proto-ratchet] MINIMUM_PROTOCOL_VERSION " << current + << " -> " << lifted << " (>=95% window work desires v" + << best_version << ", best=" + << m_best_share_hash.GetHex().substr(0, 16) << ")"; + } +} + uint256 NodeImpl::best_share_hash() { // p2pool's think() returns the best VERIFIED head — not the raw chain tip. @@ -1465,6 +1522,7 @@ void NodeImpl::run_think() if (!result.best.IsNull()) { best_changed = (m_best_share_hash != result.best); m_best_share_hash = result.best; + apply_min_protocol_ratchet(); // oracle main.py:216 (per best-share advance) } // Phase 1c: drive the whale-departure detector with the fresh best @@ -1843,6 +1901,7 @@ void NodeImpl::clean_tracker() if (!result.best.IsNull()) { m_best_share_hash = result.best; + apply_min_protocol_ratchet(); // oracle main.py:216 (per best-share advance) } flush_verified_to_leveldb(); @@ -2014,6 +2073,7 @@ void NodeImpl::clean_tracker() if (!result.best.IsNull()) { clean_best_changed = (m_best_share_hash != result.best); m_best_share_hash = result.best; + apply_min_protocol_ratchet(); // oracle main.py:216 (per best-share advance) } publish_snapshot(); flush_verified_to_leveldb(); diff --git a/src/impl/dgb/node.hpp b/src/impl/dgb/node.hpp index d1db5a9c8..d8aa896d9 100644 --- a/src/impl/dgb/node.hpp +++ b/src/impl/dgb/node.hpp @@ -63,6 +63,21 @@ class NodeImpl : public pool::BaseNode WhaleDepartureDetector m_whale_departure; // Lock-free publication of the detector verdict (see local_desired_target). std::atomic m_whale_departure_active{false}; + + // Runtime P2P accept-floor, ratcheted 1400 -> 3500 by the desired-version + // tally (oracle net.MINIMUM_PROTOCOL_VERSION, mutated at main.py:216). Seeded + // from the cold config floor (PoolConfig::MINIMUM_PROTOCOL_VERSION) and lifted + // by apply_min_protocol_ratchet() on the compute thread under m_tracker_mutex; + // read lock-free by handle_version() on the IO thread. Latching (never lowers). + std::atomic m_runtime_min_protocol_version{ + PoolConfig::MINIMUM_PROTOCOL_VERSION}; + + // Runtime sibling of the 60% version-switch gate (share_check step 2): on each + // best-share advance, lift m_runtime_min_protocol_version once >=95% of the + // window work behind the best share's parent desires the best share's VERSION. + // Mirrors oracle main.py:213-216 + data.py:857. MUST be called under the + // exclusive m_tracker_mutex (reads tracker chain + desired-version weights). + void apply_min_protocol_ratchet(); // Phase 3L non-consensus log-based pool monitor (attack/anomaly detection). // Driven from run_think() under the exclusive tracker lock on a ~30s cadence; // emits [MONITOR-*] log lines only — no consensus / sharechain effect. diff --git a/src/impl/dgb/test/min_protocol_ratchet_test.cpp b/src/impl/dgb/test/min_protocol_ratchet_test.cpp index 80f3c2d81..117a21b1c 100644 --- a/src/impl/dgb/test/min_protocol_ratchet_test.cpp +++ b/src/impl/dgb/test/min_protocol_ratchet_test.cpp @@ -110,3 +110,55 @@ TEST(DgbMinProtocolRatchet, ConsensusMagnitudeNoOverflow) { auto w = std::map{{36, best}, {35, rest}}; EXPECT_EQ(dgb::ratchet_min_protocol_version(w, 36, COLD, TARGET), TARGET); } + +// ============================================================================ +// apply_min_protocol_ratchet_decision -- the RUNTIME WIRING guard (follow-on to +// the pure ratchet above). Adds the oracle main.py:212 full-window guard +// (len(shares) > CHAIN_LENGTH) the pure function deliberately omits. CL below is +// a stand-in CHAIN_LENGTH; parent_height is the height of the best share's parent. +// ============================================================================ + +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. This is the fresh-node bug the wiring exists to +// prevent: without it a young chain would lock the floor to 3500 and reject every +// legitimate peer. Contrast EmptyWindowMirrorsOracleDictBranch: the PURE fn +// ratchets on the same weights; the DECISION fn must NOT (no full window yet). +TEST(DgbMinProtocolRatchetWiring, ShortChainNeverLifts) { + auto w = std::map{{36, u(100)}}; // unanimous + // pure fn would ratchet... + EXPECT_EQ(dgb::ratchet_min_protocol_version(w, 36, COLD, TARGET), TARGET); + // ...but the decision guard holds it cold while parent_height < CL. + EXPECT_EQ(dgb::apply_min_protocol_ratchet_decision(CL - 1, CL, w, 36, COLD, TARGET), COLD); + // exactly one short of a full window still holds. + EXPECT_EQ(dgb::apply_min_protocol_ratchet_decision(0, CL, w, 36, COLD, TARGET), COLD); +} + +// --- empty window + short chain -> no lift (guards the EmptyWindowMirrorsOracle +// dict-branch behaviour behind the full-window precondition). +TEST(DgbMinProtocolRatchetWiring, ShortChainEmptyWindowNoLift) { + EXPECT_EQ(dgb::apply_min_protocol_ratchet_decision(CL - 1, CL, {}, 36, COLD, TARGET), COLD); +} + +// --- FULL WINDOW present (parent_height >= CHAIN_LENGTH) + unanimous -> lift. +TEST(DgbMinProtocolRatchetWiring, FullWindowUnanimousLifts) { + auto w = std::map{{36, u(100)}}; + EXPECT_EQ(dgb::apply_min_protocol_ratchet_decision(CL, CL, w, 36, COLD, TARGET), TARGET); + EXPECT_EQ(dgb::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(DgbMinProtocolRatchetWiring, FullWindowBelow95StaysCold) { + auto w = std::map{{36, u(94)}, {35, u(6)}}; // sum=100, 94<95 + EXPECT_EQ(dgb::apply_min_protocol_ratchet_decision(CL, CL, w, 36, COLD, TARGET), COLD); +} + +// --- already at/above target short-circuits regardless of window/height. +TEST(DgbMinProtocolRatchetWiring, AlreadyAtTargetShortCircuits) { + auto w = std::map{{36, u(100)}}; + EXPECT_EQ(dgb::apply_min_protocol_ratchet_decision(CL, CL, w, 36, TARGET, TARGET), TARGET); + // even with a short chain the latch holds at target (never lowers). + EXPECT_EQ(dgb::apply_min_protocol_ratchet_decision(0, CL, w, 36, TARGET, TARGET), TARGET); +} +