From 8494c3f00560b2d9a4d3d165710f44cb90a9a2f9 Mon Sep 17 00:00:00 2001 From: frstrtr Date: Sun, 21 Jun 2026 09:43:15 +0000 Subject: [PATCH] dgb: AutoRatchet mint-side share-version ratchet (Phase B pool/share) Port p2pool-v36 data.py AutoRatchet (mirrors src/impl/ltc/auto_ratchet.hpp) as the DGB mint side that pairs with the already work-weighted accept gate (#249/#289). Bucket-2 v36-native shared structure, standardized cross-coin toward the v37 unified form; VOTING tail guard is work-weighted so an activation can never outrun the 60%-by-work accept gate. DGB divergence from LTC: the VOTING-state minted version is a constructor parameter (base_version_), NOT the ltc hardcode target-1. Per operator re-scope, DGBs live pre-v36 baseline conforms to frstrtr/p2pool-dgb-scrypt and is OLDER than ltc v35; the exact value is a decision-needed for the run-loop wire-in. Module is surface-for-tap (unwired) until that lands. KAT (in dgb_share_test): pins thresholds 95/50/2x/60, proves base_version is parameterized (not hardcoded 35), bootstrap mints baseline while voting target, and JSON state persists across restart. 4/4 new, 20/20 suite green. --- src/impl/dgb/auto_ratchet.hpp | 359 +++++++++++++++++++++++++++++++ src/impl/dgb/test/share_test.cpp | 75 +++++++ 2 files changed, 434 insertions(+) create mode 100644 src/impl/dgb/auto_ratchet.hpp diff --git a/src/impl/dgb/auto_ratchet.hpp b/src/impl/dgb/auto_ratchet.hpp new file mode 100644 index 000000000..ad14fa1e1 --- /dev/null +++ b/src/impl/dgb/auto_ratchet.hpp @@ -0,0 +1,359 @@ +#pragma once + +// AutoRatchet: autonomous share version transition state machine (DGB port). +// +// Manages DGB's -> V36 share format transition without manual +// operator coordination. Persists state to JSON so restarts don't regress +// once the network has confirmed a new version. +// +// State machine (identical shape to ltc::AutoRatchet — bucket-2 v36-native +// shared structure, standardized cross-coin toward the v37 unified form): +// +// VOTING -------(95% desired_version >= target)------> ACTIVATED +// ^ | +// |---(<50% desired_version >= target)---< | +// | +// (sustained 2*CHAIN_LENGTH at 95% new-format shares) | +// v +// VOTING <--(follows old network, keeps voting)------- CONFIRMED +// ^ | +// |---(<50% votes, network genuinely old)---< | +// (permanent on restart) | +// CONFIRMED <--------------------------+ +// +// Port of p2pool-v36 data.py AutoRatchet (lines 2109-2344), mirroring +// src/impl/ltc/auto_ratchet.hpp. +// +// DGB DIVERGENCE FROM LTC (per operator 2026-06-17 re-scope): DGB's live +// pre-v36 baseline is NOT necessarily V35 — it conforms to the DGB oracle +// frstrtr/p2pool-dgb-scrypt, which runs an OLDER share version than LTC's +// v35. The VOTING-state output version is therefore a CONSTRUCTOR PARAMETER +// (base_version_), NOT the ltc hardcode `target_version_ - 1`. The exact live +// value is a [decision-needed] for the wiring step; the default below keeps +// the module compilable and KAT-exercisable but MUST be overridden at the +// run-loop wire-in once confirmed against the oracle. Until then this module +// is surface-for-tap (unwired). + +#include "config_pool.hpp" +#include "share_tracker.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace dgb +{ + +enum class RatchetState : uint8_t +{ + VOTING = 0, // producing old-format shares, voting for upgrade + ACTIVATED = 1, // producing new-format shares, monitoring support + CONFIRMED = 2 // permanent: new format confirmed, survives restart +}; + +inline const char* ratchet_state_str(RatchetState s) +{ + switch (s) { + case RatchetState::VOTING: return "VOTING"; + case RatchetState::ACTIVATED: return "ACTIVATED"; + case RatchetState::CONFIRMED: return "CONFIRMED"; + } + return "UNKNOWN"; +} + +class AutoRatchet +{ +public: + // Thresholds (matching Python reference — bucket-2 standardized). + static constexpr int ACTIVATION_THRESHOLD = 95; // % votes to activate + static constexpr int DEACTIVATION_THRESHOLD = 50; // % below which to revert + static constexpr int CONFIRMATION_MULTIPLIER = 2; // confirm after 2x CHAIN_LENGTH + static constexpr int SWITCH_THRESHOLD = 60; // % required for format switch in validation + + // base_version: the share version DGB mints while VOTING. DGB-specific — + // see file header. Defaults to target_version-1 only as a compile default. + explicit AutoRatchet(const std::string& state_file_path = "", + int64_t target_version = 36, + int64_t base_version = -1) + : state_file_(state_file_path) + , target_version_(target_version) + , base_version_(base_version >= 0 ? base_version : target_version - 1) + { + load(); + } + + RatchetState state() const { return state_; } + int64_t target_version() const { return target_version_; } + int64_t base_version() const { return base_version_; } + + /// Determine which share version to produce based on network state. + /// Returns (share_version, desired_version_to_vote). + std::pair get_share_version( + ShareTracker& tracker, + const uint256& best_share_hash) + { + const int64_t current_version = base_version_; // DGB: oracle baseline, NOT target-1 + const uint32_t chain_length = PoolConfig::chain_length(); + const uint32_t confirmation_window = chain_length * CONFIRMATION_MULTIPLIER; + + // No chain — use persisted state for bootstrap + if (best_share_hash.IsNull() || + !tracker.chain.contains(best_share_hash) || + tracker.chain.get_height(best_share_hash) < 1) + { + if (state_ == RatchetState::CONFIRMED) + return {target_version_, target_version_}; + return {current_version, target_version_}; + } + + // Count votes and actual new-format shares in window. + // p2pool uses tracker.get_height() (chain depth) for both sampling + // and confirmation counting. This matches data.py:2488,2576. + int32_t height = tracker.chain.get_height(best_share_hash); + int32_t sample = std::min(height, static_cast(chain_length)); + + int32_t target_votes = 0; // shares voting desired_version >= target + int32_t target_shares = 0; // shares actually IN target format + int32_t total = 0; + + auto chain_view = tracker.chain.get_chain(best_share_hash, sample); + for (auto [hash, data] : chain_view) + { + ++total; + data.share.invoke([&](auto* obj) { + if (static_cast(obj->m_desired_version) >= target_version_) + ++target_votes; + if (static_cast(std::remove_pointer_t::version) >= target_version_) + ++target_shares; + }); + } + + if (total == 0) + { + if (state_ == RatchetState::CONFIRMED) + return {target_version_, target_version_}; + return {current_version, target_version_}; + } + + int vote_pct = (target_votes * 100) / total; + int share_pct = (target_shares * 100) / total; + bool full_window = (total >= static_cast(chain_length)); + + // --- State transitions --- + auto old_state = state_; + + if (state_ == RatchetState::VOTING) + { + if (full_window && vote_pct >= ACTIVATION_THRESHOLD) + { + // WORK-WEIGHTED tail guard (mint<->accept coupling). The + // consensus accept gate (share_check step 2 / p2pool check() + // data.py:1399) keys the 60% switch rule off + // get_desired_version_counts, which in canonical p2pool + // (data.py:2651) weights each share by + // target_to_average_attempts(target) -- i.e. WORK, not a flat + // head-count. AutoRatchet must evaluate the SAME work-weighted + // 60% rule over the SAME [9/10*CL, CL] window before it + // activates; otherwise a 95%-by-COUNT activation can outrun the + // 60%-by-WORK accept gate, the node mints a V36 boundary share + // every peer rejects, and the crossing wedges. (DGB accept gate + // already work-weighted: #249/#289.) + uint32_t tail_start = (chain_length * 9) / 10; + uint32_t tail_size = chain_length / 10; + auto tail_ancestor = tracker.chain.get_nth_parent_key(best_share_hash, tail_start); + auto tail_weights = tracker.get_desired_version_weights(tail_ancestor, tail_size); + + // mapped_type is the work-weight accumulator (uint288); default 0. + decltype(tail_weights)::mapped_type tail_target{}, tail_total{}; + for (auto& [ver, w] : tail_weights) { + tail_total = tail_total + w; + if (static_cast(ver) >= target_version_) + tail_target = tail_target + w; + } + // Canonical: counts.get(VERSION,0) < sum(counts)*60//100 + bool tail_ok = !(tail_target * uint32_t(100) < tail_total * uint32_t(SWITCH_THRESHOLD)); + + if (!tail_ok) { + static int tail_log = 0; + if (tail_log++ % 20 == 0) + LOG_INFO << "[AutoRatchet] VOTING: full window " << vote_pct + << "% >= " << ACTIVATION_THRESHOLD << "% but oldest 10% work-weighted V" + << target_version_ << " desire < " << SWITCH_THRESHOLD << "%) — waiting"; + // Don't transition yet + } + else + { + state_ = RatchetState::ACTIVATED; + activated_at_ = now_seconds(); + activated_height_ = height; + // Credit retroactive shares for late-joining nodes + // p2pool data.py:2535: retroactive = max(0, height - net.CHAIN_LENGTH) + int32_t retroactive = std::max(0, height - static_cast(chain_length)); + confirm_count_ = retroactive; + last_seen_height_ = height; + + LOG_INFO << "[AutoRatchet] VOTING -> ACTIVATED (" + << vote_pct << "% of " << total << " shares vote V" + << target_version_ << ", window=" << chain_length + << ", retroactive=" << retroactive << ")"; + + // Skip to CONFIRMED if chain is already deep enough + if (retroactive >= static_cast(confirmation_window) && + share_pct >= ACTIVATION_THRESHOLD) + { + state_ = RatchetState::CONFIRMED; + confirmed_at_ = now_seconds(); + LOG_INFO << "[AutoRatchet] VOTING -> CONFIRMED (retroactive: " + << retroactive << " >= " << confirmation_window << ")"; + } + save(); + } // else (tail guard passed) + } + } + else if (state_ == RatchetState::ACTIVATED) + { + if (full_window && vote_pct < DEACTIVATION_THRESHOLD) + { + // Network genuinely reverted + state_ = RatchetState::VOTING; + activated_at_ = 0; + activated_height_ = 0; + confirm_count_ = 0; + last_seen_height_ = 0; + LOG_INFO << "[AutoRatchet] ACTIVATED -> VOTING (" + << vote_pct << "% < " << DEACTIVATION_THRESHOLD << "% threshold)"; + save(); + } + else if (activated_height_ > 0) + { + // Track cumulative height increases using chain depth. + // p2pool data.py:2576: uses tracker.get_height() (chain depth). + if (last_seen_height_ > 0 && height > last_seen_height_) + confirm_count_ += (height - last_seen_height_); + last_seen_height_ = height; + + { + static int ac_log = 0; + if (ac_log++ % 20 == 0) + LOG_INFO << "[AutoRatchet] ACTIVATED: vote=" << vote_pct + << "% share=" << share_pct << "% full=" << (full_window ? "True" : "False") + << " height=" << height + << " confirm=" << confirm_count_ << "/" << confirmation_window; + } + + if (confirm_count_ >= static_cast(confirmation_window) && + share_pct >= ACTIVATION_THRESHOLD) + { + state_ = RatchetState::CONFIRMED; + confirmed_at_ = now_seconds(); + LOG_INFO << "[AutoRatchet] ACTIVATED -> CONFIRMED (" + << confirm_count_ << " cumulative shares, " + << share_pct << "% V" << target_version_ << ")"; + save(); + } + } + } + else if (state_ == RatchetState::CONFIRMED) + { + // CONFIRMED is permanent, but respect network consensus + if (full_window && vote_pct < DEACTIVATION_THRESHOLD) + { + LOG_WARNING << "[AutoRatchet] CONFIRMED but network is " + << (100 - vote_pct) << "% old version — following consensus"; + return {current_version, target_version_}; + } + } + + if (old_state != state_) + { + LOG_INFO << "[AutoRatchet] State: " << ratchet_state_str(old_state) + << " -> " << ratchet_state_str(state_); + } + + // Output + if (state_ == RatchetState::ACTIVATED || state_ == RatchetState::CONFIRMED) + return {target_version_, target_version_}; + else + return {current_version, target_version_}; + } + + // F10 (per ltc): validate_version_switch is intentionally absent — the + // single source of truth for the version-switch gate is 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 work-weighted (mint<->accept coupling). + +private: + std::string state_file_; + int64_t target_version_; + int64_t base_version_; + RatchetState state_ = RatchetState::VOTING; + int64_t activated_at_ = 0; + int32_t activated_height_ = 0; + int64_t confirmed_at_ = 0; + int32_t confirm_count_ = 0; + int32_t last_seen_height_ = 0; + + static int64_t now_seconds() + { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + } + + void load() + { + if (state_file_.empty()) return; + try { + std::ifstream f(state_file_); + if (!f.is_open()) return; + nlohmann::json j; + f >> j; + std::string s = j.value("state", "voting"); + if (s == "activated") state_ = RatchetState::ACTIVATED; + else if (s == "confirmed") state_ = RatchetState::CONFIRMED; + else state_ = RatchetState::VOTING; + activated_at_ = j.value("activated_at", int64_t(0)); + activated_height_ = j.value("activated_height", int32_t(0)); + confirmed_at_ = j.value("confirmed_at", int64_t(0)); + confirm_count_ = j.value("confirm_count", int32_t(0)); + LOG_INFO << "[AutoRatchet] Loaded state: " << ratchet_state_str(state_) + << " (target=V" << target_version_ + << " base=V" << base_version_ + << " confirmed_at=" << confirmed_at_ + << " confirm_count=" << confirm_count_ << ")"; + } catch (const std::exception& e) { + LOG_WARNING << "[AutoRatchet] Failed to load state: " << e.what(); + } + } + + void save() + { + if (state_file_.empty()) return; + try { + nlohmann::json j; + switch (state_) { + case RatchetState::VOTING: j["state"] = "voting"; break; + case RatchetState::ACTIVATED: j["state"] = "activated"; break; + case RatchetState::CONFIRMED: j["state"] = "confirmed"; break; + } + j["activated_at"] = activated_at_; + j["activated_height"] = activated_height_; + j["confirmed_at"] = confirmed_at_; + j["confirm_count"] = confirm_count_; + j["target_version"] = target_version_; + j["base_version"] = base_version_; + + std::ofstream f(state_file_); + f << j.dump(2); + } catch (const std::exception& e) { + LOG_WARNING << "[AutoRatchet] Failed to save state: " << e.what(); + } + } +}; + +} // namespace dgb diff --git a/src/impl/dgb/test/share_test.cpp b/src/impl/dgb/test/share_test.cpp index 30e845dbd..776367633 100644 --- a/src/impl/dgb/test/share_test.cpp +++ b/src/impl/dgb/test/share_test.cpp @@ -20,6 +20,8 @@ #include // DensePPLNSWindow — V36 decayed-PPLNS SSOT #include // make_coin_params — assembled CoinParams SSOT #include // #82 external-daemon RPC creds (digibyte.conf) +#include // Phase B: mint-side share-version ratchet +#include // getpid (AutoRatchet KAT temp state file) #include #include @@ -359,3 +361,76 @@ TEST(DGB_rpc_conf, EndpointOverrideCarriesNoSecret) EXPECT_EQ(c.host, "example.host"); EXPECT_EQ(c.userpass(), "u:p"); } + +// ---------------------------------------------------------------------------- +// AutoRatchet (mint-side share-version ratchet) — Phase B pool/share. +// Bucket-2 v36-native shared structure (standardized cross-coin toward v37). +// These cases pin the threshold constants + bootstrap/persistence semantics +// and, critically, prove the DGB VOTING-output version is the oracle baseline +// parameter (base_version_), NOT the ltc hardcode target-1. The live baseline +// value itself is a [decision-needed] vs frstrtr/p2pool-dgb-scrypt; the module +// stays unwired (surface-for-tap) until that lands. +// ---------------------------------------------------------------------------- + +TEST(DGB_share_test, AutoRatchetThresholdsMatchCanonical) +{ + EXPECT_EQ(dgb::AutoRatchet::ACTIVATION_THRESHOLD, 95); + EXPECT_EQ(dgb::AutoRatchet::DEACTIVATION_THRESHOLD, 50); + EXPECT_EQ(dgb::AutoRatchet::CONFIRMATION_MULTIPLIER, 2); + EXPECT_EQ(dgb::AutoRatchet::SWITCH_THRESHOLD, 60); +} + +// The DGB divergence: base_version must be honored as a parameter, never the +// ltc target-1 hardcode. Compile default keeps the module buildable. +TEST(DGB_share_test, AutoRatchetBaseVersionParameterized) +{ + dgb::AutoRatchet def("", 36); // default: target-1 + EXPECT_EQ(def.target_version(), 36); + EXPECT_EQ(def.base_version(), 35); + + dgb::AutoRatchet older("", 36, 33); // DGB oracle baseline override + EXPECT_EQ(older.target_version(), 36); + EXPECT_EQ(older.base_version(), 33); + EXPECT_EQ(older.state(), dgb::RatchetState::VOTING); +} + +// Bootstrap with no chain: VOTING node votes target but still MINTS the +// baseline version (an empty/just-started tracker must not skip ahead). +TEST(DGB_share_test, AutoRatchetBootstrapMintsBaselineWhileVoting) +{ + dgb::AutoRatchet ar("", 36, 33); + dgb::ShareTracker tracker; + auto [mint, vote] = ar.get_share_version(tracker, uint256{}); // null best hash + EXPECT_EQ(mint, 33); // baseline, NOT 35, NOT 36 + EXPECT_EQ(vote, 36); // always vote for target + EXPECT_EQ(ar.state(), dgb::RatchetState::VOTING); +} + +// State persists across restart via the JSON state file. +TEST(DGB_share_test, AutoRatchetStatePersistsAcrossRestart) +{ + std::string path = std::string("/tmp/dgb_autoratchet_kat_") + + std::to_string(::getpid()) + ".json"; + std::remove(path.c_str()); + { + // Seed a CONFIRMED state file. + nlohmann::json j; + j["state"] = "confirmed"; + j["activated_at"] = 1; + j["activated_height"] = 2; + j["confirmed_at"] = 3; + j["confirm_count"] = 4; + j["target_version"] = 36; + j["base_version"] = 33; + std::ofstream f(path); + f << j.dump(2); + } + dgb::AutoRatchet ar(path, 36, 33); + EXPECT_EQ(ar.state(), dgb::RatchetState::CONFIRMED); + // CONFIRMED bootstrap (no chain) mints the target version. + dgb::ShareTracker tracker; + auto [mint, vote] = ar.get_share_version(tracker, uint256{}); + EXPECT_EQ(mint, 36); + EXPECT_EQ(vote, 36); + std::remove(path.c_str()); +}