|
| 1 | +#pragma once |
| 2 | + |
| 3 | +// AutoRatchet: autonomous share version transition state machine (DGB port). |
| 4 | +// |
| 5 | +// Manages DGB's <baseline> -> V36 share format transition without manual |
| 6 | +// operator coordination. Persists state to JSON so restarts don't regress |
| 7 | +// once the network has confirmed a new version. |
| 8 | +// |
| 9 | +// State machine (identical shape to ltc::AutoRatchet — bucket-2 v36-native |
| 10 | +// shared structure, standardized cross-coin toward the v37 unified form): |
| 11 | +// |
| 12 | +// VOTING -------(95% desired_version >= target)------> ACTIVATED |
| 13 | +// ^ | |
| 14 | +// |---(<50% desired_version >= target)---< | |
| 15 | +// | |
| 16 | +// (sustained 2*CHAIN_LENGTH at 95% new-format shares) | |
| 17 | +// v |
| 18 | +// VOTING <--(follows old network, keeps voting)------- CONFIRMED |
| 19 | +// ^ | |
| 20 | +// |---(<50% votes, network genuinely old)---< | |
| 21 | +// (permanent on restart) | |
| 22 | +// CONFIRMED <--------------------------+ |
| 23 | +// |
| 24 | +// Port of p2pool-v36 data.py AutoRatchet (lines 2109-2344), mirroring |
| 25 | +// src/impl/ltc/auto_ratchet.hpp. |
| 26 | +// |
| 27 | +// DGB DIVERGENCE FROM LTC (per operator 2026-06-17 re-scope): DGB's live |
| 28 | +// pre-v36 baseline is NOT necessarily V35 — it conforms to the DGB oracle |
| 29 | +// frstrtr/p2pool-dgb-scrypt, which runs an OLDER share version than LTC's |
| 30 | +// v35. The VOTING-state output version is therefore a CONSTRUCTOR PARAMETER |
| 31 | +// (base_version_), NOT the ltc hardcode `target_version_ - 1`. The exact live |
| 32 | +// value is a [decision-needed] for the wiring step; the default below keeps |
| 33 | +// the module compilable and KAT-exercisable but MUST be overridden at the |
| 34 | +// run-loop wire-in once confirmed against the oracle. Until then this module |
| 35 | +// is surface-for-tap (unwired). |
| 36 | + |
| 37 | +#include "config_pool.hpp" |
| 38 | +#include "share_tracker.hpp" |
| 39 | +#include <core/log.hpp> |
| 40 | + |
| 41 | +#include <nlohmann/json.hpp> |
| 42 | +#include <algorithm> |
| 43 | +#include <chrono> |
| 44 | +#include <cstdint> |
| 45 | +#include <fstream> |
| 46 | +#include <string> |
| 47 | +#include <utility> |
| 48 | + |
| 49 | +namespace dgb |
| 50 | +{ |
| 51 | + |
| 52 | +enum class RatchetState : uint8_t |
| 53 | +{ |
| 54 | + VOTING = 0, // producing old-format shares, voting for upgrade |
| 55 | + ACTIVATED = 1, // producing new-format shares, monitoring support |
| 56 | + CONFIRMED = 2 // permanent: new format confirmed, survives restart |
| 57 | +}; |
| 58 | + |
| 59 | +inline const char* ratchet_state_str(RatchetState s) |
| 60 | +{ |
| 61 | + switch (s) { |
| 62 | + case RatchetState::VOTING: return "VOTING"; |
| 63 | + case RatchetState::ACTIVATED: return "ACTIVATED"; |
| 64 | + case RatchetState::CONFIRMED: return "CONFIRMED"; |
| 65 | + } |
| 66 | + return "UNKNOWN"; |
| 67 | +} |
| 68 | + |
| 69 | +class AutoRatchet |
| 70 | +{ |
| 71 | +public: |
| 72 | + // Thresholds (matching Python reference — bucket-2 standardized). |
| 73 | + static constexpr int ACTIVATION_THRESHOLD = 95; // % votes to activate |
| 74 | + static constexpr int DEACTIVATION_THRESHOLD = 50; // % below which to revert |
| 75 | + static constexpr int CONFIRMATION_MULTIPLIER = 2; // confirm after 2x CHAIN_LENGTH |
| 76 | + static constexpr int SWITCH_THRESHOLD = 60; // % required for format switch in validation |
| 77 | + |
| 78 | + // base_version: the share version DGB mints while VOTING. DGB-specific — |
| 79 | + // see file header. Defaults to target_version-1 only as a compile default. |
| 80 | + explicit AutoRatchet(const std::string& state_file_path = "", |
| 81 | + int64_t target_version = 36, |
| 82 | + int64_t base_version = -1) |
| 83 | + : state_file_(state_file_path) |
| 84 | + , target_version_(target_version) |
| 85 | + , base_version_(base_version >= 0 ? base_version : target_version - 1) |
| 86 | + { |
| 87 | + load(); |
| 88 | + } |
| 89 | + |
| 90 | + RatchetState state() const { return state_; } |
| 91 | + int64_t target_version() const { return target_version_; } |
| 92 | + int64_t base_version() const { return base_version_; } |
| 93 | + |
| 94 | + /// Determine which share version to produce based on network state. |
| 95 | + /// Returns (share_version, desired_version_to_vote). |
| 96 | + std::pair<int64_t, int64_t> get_share_version( |
| 97 | + ShareTracker& tracker, |
| 98 | + const uint256& best_share_hash) |
| 99 | + { |
| 100 | + const int64_t current_version = base_version_; // DGB: oracle baseline, NOT target-1 |
| 101 | + const uint32_t chain_length = PoolConfig::chain_length(); |
| 102 | + const uint32_t confirmation_window = chain_length * CONFIRMATION_MULTIPLIER; |
| 103 | + |
| 104 | + // No chain — use persisted state for bootstrap |
| 105 | + if (best_share_hash.IsNull() || |
| 106 | + !tracker.chain.contains(best_share_hash) || |
| 107 | + tracker.chain.get_height(best_share_hash) < 1) |
| 108 | + { |
| 109 | + if (state_ == RatchetState::CONFIRMED) |
| 110 | + return {target_version_, target_version_}; |
| 111 | + return {current_version, target_version_}; |
| 112 | + } |
| 113 | + |
| 114 | + // Count votes and actual new-format shares in window. |
| 115 | + // p2pool uses tracker.get_height() (chain depth) for both sampling |
| 116 | + // and confirmation counting. This matches data.py:2488,2576. |
| 117 | + int32_t height = tracker.chain.get_height(best_share_hash); |
| 118 | + int32_t sample = std::min(height, static_cast<int32_t>(chain_length)); |
| 119 | + |
| 120 | + int32_t target_votes = 0; // shares voting desired_version >= target |
| 121 | + int32_t target_shares = 0; // shares actually IN target format |
| 122 | + int32_t total = 0; |
| 123 | + |
| 124 | + auto chain_view = tracker.chain.get_chain(best_share_hash, sample); |
| 125 | + for (auto [hash, data] : chain_view) |
| 126 | + { |
| 127 | + ++total; |
| 128 | + data.share.invoke([&](auto* obj) { |
| 129 | + if (static_cast<int64_t>(obj->m_desired_version) >= target_version_) |
| 130 | + ++target_votes; |
| 131 | + if (static_cast<int64_t>(std::remove_pointer_t<decltype(obj)>::version) >= target_version_) |
| 132 | + ++target_shares; |
| 133 | + }); |
| 134 | + } |
| 135 | + |
| 136 | + if (total == 0) |
| 137 | + { |
| 138 | + if (state_ == RatchetState::CONFIRMED) |
| 139 | + return {target_version_, target_version_}; |
| 140 | + return {current_version, target_version_}; |
| 141 | + } |
| 142 | + |
| 143 | + int vote_pct = (target_votes * 100) / total; |
| 144 | + int share_pct = (target_shares * 100) / total; |
| 145 | + bool full_window = (total >= static_cast<int32_t>(chain_length)); |
| 146 | + |
| 147 | + // --- State transitions --- |
| 148 | + auto old_state = state_; |
| 149 | + |
| 150 | + if (state_ == RatchetState::VOTING) |
| 151 | + { |
| 152 | + if (full_window && vote_pct >= ACTIVATION_THRESHOLD) |
| 153 | + { |
| 154 | + // WORK-WEIGHTED tail guard (mint<->accept coupling). The |
| 155 | + // consensus accept gate (share_check step 2 / p2pool check() |
| 156 | + // data.py:1399) keys the 60% switch rule off |
| 157 | + // get_desired_version_counts, which in canonical p2pool |
| 158 | + // (data.py:2651) weights each share by |
| 159 | + // target_to_average_attempts(target) -- i.e. WORK, not a flat |
| 160 | + // head-count. AutoRatchet must evaluate the SAME work-weighted |
| 161 | + // 60% rule over the SAME [9/10*CL, CL] window before it |
| 162 | + // activates; otherwise a 95%-by-COUNT activation can outrun the |
| 163 | + // 60%-by-WORK accept gate, the node mints a V36 boundary share |
| 164 | + // every peer rejects, and the crossing wedges. (DGB accept gate |
| 165 | + // already work-weighted: #249/#289.) |
| 166 | + uint32_t tail_start = (chain_length * 9) / 10; |
| 167 | + uint32_t tail_size = chain_length / 10; |
| 168 | + auto tail_ancestor = tracker.chain.get_nth_parent_key(best_share_hash, tail_start); |
| 169 | + auto tail_weights = tracker.get_desired_version_weights(tail_ancestor, tail_size); |
| 170 | + |
| 171 | + // mapped_type is the work-weight accumulator (uint288); default 0. |
| 172 | + decltype(tail_weights)::mapped_type tail_target{}, tail_total{}; |
| 173 | + for (auto& [ver, w] : tail_weights) { |
| 174 | + tail_total = tail_total + w; |
| 175 | + if (static_cast<int64_t>(ver) >= target_version_) |
| 176 | + tail_target = tail_target + w; |
| 177 | + } |
| 178 | + // Canonical: counts.get(VERSION,0) < sum(counts)*60//100 |
| 179 | + bool tail_ok = !(tail_target * uint32_t(100) < tail_total * uint32_t(SWITCH_THRESHOLD)); |
| 180 | + |
| 181 | + if (!tail_ok) { |
| 182 | + static int tail_log = 0; |
| 183 | + if (tail_log++ % 20 == 0) |
| 184 | + LOG_INFO << "[AutoRatchet] VOTING: full window " << vote_pct |
| 185 | + << "% >= " << ACTIVATION_THRESHOLD << "% but oldest 10% work-weighted V" |
| 186 | + << target_version_ << " desire < " << SWITCH_THRESHOLD << "%) — waiting"; |
| 187 | + // Don't transition yet |
| 188 | + } |
| 189 | + else |
| 190 | + { |
| 191 | + state_ = RatchetState::ACTIVATED; |
| 192 | + activated_at_ = now_seconds(); |
| 193 | + activated_height_ = height; |
| 194 | + // Credit retroactive shares for late-joining nodes |
| 195 | + // p2pool data.py:2535: retroactive = max(0, height - net.CHAIN_LENGTH) |
| 196 | + int32_t retroactive = std::max(0, height - static_cast<int32_t>(chain_length)); |
| 197 | + confirm_count_ = retroactive; |
| 198 | + last_seen_height_ = height; |
| 199 | + |
| 200 | + LOG_INFO << "[AutoRatchet] VOTING -> ACTIVATED (" |
| 201 | + << vote_pct << "% of " << total << " shares vote V" |
| 202 | + << target_version_ << ", window=" << chain_length |
| 203 | + << ", retroactive=" << retroactive << ")"; |
| 204 | + |
| 205 | + // Skip to CONFIRMED if chain is already deep enough |
| 206 | + if (retroactive >= static_cast<int32_t>(confirmation_window) && |
| 207 | + share_pct >= ACTIVATION_THRESHOLD) |
| 208 | + { |
| 209 | + state_ = RatchetState::CONFIRMED; |
| 210 | + confirmed_at_ = now_seconds(); |
| 211 | + LOG_INFO << "[AutoRatchet] VOTING -> CONFIRMED (retroactive: " |
| 212 | + << retroactive << " >= " << confirmation_window << ")"; |
| 213 | + } |
| 214 | + save(); |
| 215 | + } // else (tail guard passed) |
| 216 | + } |
| 217 | + } |
| 218 | + else if (state_ == RatchetState::ACTIVATED) |
| 219 | + { |
| 220 | + if (full_window && vote_pct < DEACTIVATION_THRESHOLD) |
| 221 | + { |
| 222 | + // Network genuinely reverted |
| 223 | + state_ = RatchetState::VOTING; |
| 224 | + activated_at_ = 0; |
| 225 | + activated_height_ = 0; |
| 226 | + confirm_count_ = 0; |
| 227 | + last_seen_height_ = 0; |
| 228 | + LOG_INFO << "[AutoRatchet] ACTIVATED -> VOTING (" |
| 229 | + << vote_pct << "% < " << DEACTIVATION_THRESHOLD << "% threshold)"; |
| 230 | + save(); |
| 231 | + } |
| 232 | + else if (activated_height_ > 0) |
| 233 | + { |
| 234 | + // Track cumulative height increases using chain depth. |
| 235 | + // p2pool data.py:2576: uses tracker.get_height() (chain depth). |
| 236 | + if (last_seen_height_ > 0 && height > last_seen_height_) |
| 237 | + confirm_count_ += (height - last_seen_height_); |
| 238 | + last_seen_height_ = height; |
| 239 | + |
| 240 | + { |
| 241 | + static int ac_log = 0; |
| 242 | + if (ac_log++ % 20 == 0) |
| 243 | + LOG_INFO << "[AutoRatchet] ACTIVATED: vote=" << vote_pct |
| 244 | + << "% share=" << share_pct << "% full=" << (full_window ? "True" : "False") |
| 245 | + << " height=" << height |
| 246 | + << " confirm=" << confirm_count_ << "/" << confirmation_window; |
| 247 | + } |
| 248 | + |
| 249 | + if (confirm_count_ >= static_cast<int32_t>(confirmation_window) && |
| 250 | + share_pct >= ACTIVATION_THRESHOLD) |
| 251 | + { |
| 252 | + state_ = RatchetState::CONFIRMED; |
| 253 | + confirmed_at_ = now_seconds(); |
| 254 | + LOG_INFO << "[AutoRatchet] ACTIVATED -> CONFIRMED (" |
| 255 | + << confirm_count_ << " cumulative shares, " |
| 256 | + << share_pct << "% V" << target_version_ << ")"; |
| 257 | + save(); |
| 258 | + } |
| 259 | + } |
| 260 | + } |
| 261 | + else if (state_ == RatchetState::CONFIRMED) |
| 262 | + { |
| 263 | + // CONFIRMED is permanent, but respect network consensus |
| 264 | + if (full_window && vote_pct < DEACTIVATION_THRESHOLD) |
| 265 | + { |
| 266 | + LOG_WARNING << "[AutoRatchet] CONFIRMED but network is " |
| 267 | + << (100 - vote_pct) << "% old version — following consensus"; |
| 268 | + return {current_version, target_version_}; |
| 269 | + } |
| 270 | + } |
| 271 | + |
| 272 | + if (old_state != state_) |
| 273 | + { |
| 274 | + LOG_INFO << "[AutoRatchet] State: " << ratchet_state_str(old_state) |
| 275 | + << " -> " << ratchet_state_str(state_); |
| 276 | + } |
| 277 | + |
| 278 | + // Output |
| 279 | + if (state_ == RatchetState::ACTIVATED || state_ == RatchetState::CONFIRMED) |
| 280 | + return {target_version_, target_version_}; |
| 281 | + else |
| 282 | + return {current_version, target_version_}; |
| 283 | + } |
| 284 | + |
| 285 | + // F10 (per ltc): validate_version_switch is intentionally absent — the |
| 286 | + // single source of truth for the version-switch gate is share_check step 2, |
| 287 | + // which calls ShareTracker::get_desired_version_weights and matches p2pool |
| 288 | + // check() (data.py:1396-1414) exactly. The VOTING tail guard above stays |
| 289 | + // inline and work-weighted (mint<->accept coupling). |
| 290 | + |
| 291 | +private: |
| 292 | + std::string state_file_; |
| 293 | + int64_t target_version_; |
| 294 | + int64_t base_version_; |
| 295 | + RatchetState state_ = RatchetState::VOTING; |
| 296 | + int64_t activated_at_ = 0; |
| 297 | + int32_t activated_height_ = 0; |
| 298 | + int64_t confirmed_at_ = 0; |
| 299 | + int32_t confirm_count_ = 0; |
| 300 | + int32_t last_seen_height_ = 0; |
| 301 | + |
| 302 | + static int64_t now_seconds() |
| 303 | + { |
| 304 | + return std::chrono::duration_cast<std::chrono::seconds>( |
| 305 | + std::chrono::system_clock::now().time_since_epoch()).count(); |
| 306 | + } |
| 307 | + |
| 308 | + void load() |
| 309 | + { |
| 310 | + if (state_file_.empty()) return; |
| 311 | + try { |
| 312 | + std::ifstream f(state_file_); |
| 313 | + if (!f.is_open()) return; |
| 314 | + nlohmann::json j; |
| 315 | + f >> j; |
| 316 | + std::string s = j.value("state", "voting"); |
| 317 | + if (s == "activated") state_ = RatchetState::ACTIVATED; |
| 318 | + else if (s == "confirmed") state_ = RatchetState::CONFIRMED; |
| 319 | + else state_ = RatchetState::VOTING; |
| 320 | + activated_at_ = j.value("activated_at", int64_t(0)); |
| 321 | + activated_height_ = j.value("activated_height", int32_t(0)); |
| 322 | + confirmed_at_ = j.value("confirmed_at", int64_t(0)); |
| 323 | + confirm_count_ = j.value("confirm_count", int32_t(0)); |
| 324 | + LOG_INFO << "[AutoRatchet] Loaded state: " << ratchet_state_str(state_) |
| 325 | + << " (target=V" << target_version_ |
| 326 | + << " base=V" << base_version_ |
| 327 | + << " confirmed_at=" << confirmed_at_ |
| 328 | + << " confirm_count=" << confirm_count_ << ")"; |
| 329 | + } catch (const std::exception& e) { |
| 330 | + LOG_WARNING << "[AutoRatchet] Failed to load state: " << e.what(); |
| 331 | + } |
| 332 | + } |
| 333 | + |
| 334 | + void save() |
| 335 | + { |
| 336 | + if (state_file_.empty()) return; |
| 337 | + try { |
| 338 | + nlohmann::json j; |
| 339 | + switch (state_) { |
| 340 | + case RatchetState::VOTING: j["state"] = "voting"; break; |
| 341 | + case RatchetState::ACTIVATED: j["state"] = "activated"; break; |
| 342 | + case RatchetState::CONFIRMED: j["state"] = "confirmed"; break; |
| 343 | + } |
| 344 | + j["activated_at"] = activated_at_; |
| 345 | + j["activated_height"] = activated_height_; |
| 346 | + j["confirmed_at"] = confirmed_at_; |
| 347 | + j["confirm_count"] = confirm_count_; |
| 348 | + j["target_version"] = target_version_; |
| 349 | + j["base_version"] = base_version_; |
| 350 | + |
| 351 | + std::ofstream f(state_file_); |
| 352 | + f << j.dump(2); |
| 353 | + } catch (const std::exception& e) { |
| 354 | + LOG_WARNING << "[AutoRatchet] Failed to save state: " << e.what(); |
| 355 | + } |
| 356 | + } |
| 357 | +}; |
| 358 | + |
| 359 | +} // namespace dgb |
0 commit comments