diff --git a/README.md b/README.md index a1ff8bba3..ea9b2b156 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,7 @@ Development is supported by Anthropic's [Claude for Open Source](https://claude. > > **Recent Bitcoin Block Mined by P2pool** (2025-03-07 06:08:22 UTC) BTC [#886688](https://blockchair.com/bitcoin/block/886688) > -> **First DASH block, c2pool** (2026-07-20 01:15:15 UTC) DASH [#2507753](https://blockchair.com/dash/block/2507753) — a solo X11 block. The DIP4 coinbase pays the masternode the network requires at that height, and dashd accepted it. The payee is checked against the template before the block is broadcast; work built on a stale template is discarded, not mined. -> -> **DASH block at a full-payment height** (2026-07-20 23:25:32 UTC) DASH [#2508254](https://blockchair.com/dash/block/2508254) — six transactions, three consensus-mandated payments in the coinbase. The full payee set was assembled and verified against the template before submission, and dashd accepted the block. The demanding case takes the same path as the trivial one. +> **First DASH Block Mined by c2pool** (2026-07-20 01:15:15 UTC) DASH [#2507753](https://blockchair.com/dash/block/2507753) — solo X11 block, DIP4 coinbase reward-safe with the correct masternode payee, accepted by dashd --- diff --git a/src/c2pool/CMakeLists.txt b/src/c2pool/CMakeLists.txt index 2c59d1c19..219c99885 100644 --- a/src/c2pool/CMakeLists.txt +++ b/src/c2pool/CMakeLists.txt @@ -88,9 +88,12 @@ target_link_libraries(c2pool_node_enhanced INTERFACE nlohmann_json::nlohmann_json ) -# Inject version from git at build time +# Inject version from git at build time. --dirty so a tag-exact but locally- +# modified rebuild does not share a version string with the clean tag build +# (the embedded oracle-shadow ledger keys its clean streak on this — a dirty +# dev build must not inherit a tag-exact binary's graduation streak). execute_process( - COMMAND git describe --tags --always + COMMAND git describe --tags --always --dirty WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} OUTPUT_VARIABLE C2POOL_GIT_VERSION OUTPUT_STRIP_TRAILING_WHITESPACE diff --git a/src/c2pool/hashrate/tracker.cpp b/src/c2pool/hashrate/tracker.cpp index 7a0ea303f..e49ca5cc8 100644 --- a/src/c2pool/hashrate/tracker.cpp +++ b/src/c2pool/hashrate/tracker.cpp @@ -182,58 +182,30 @@ void HashrateTracker::set_difficulty_from_hashrate(double now) { if (ewma_share_count_ < static_cast(vardiff_warmup_shares_)) return; // keep seed diff double h = get_recent_hashrate(now); if (h <= 0.0) return; - // Un-quantized ideal difficulty from the smoothed hashrate. The [min,max] - // clamp is part of the ideal (a rig outside the bounds should sit at the - // bound), so hysteresis below operates on this clamped-but-unquantized value. - double d_ideal = h * target_time_per_mining_share_ / 4294967296.0; - d_ideal = std::max(min_difficulty_, std::min(max_difficulty_, d_ideal)); + double d = h * target_time_per_mining_share_ / 4294967296.0; + d = std::max(min_difficulty_, std::min(max_difficulty_, d)); // Firmware-grid fix: many ASIC firmwares round the advertised pool difficulty // DOWN to a power-of-two grid, then mine that easier target and submit shares // the pool's exact (higher) required difficulty rejects. Advertise only // power-of-two difficulties so advertised == applied == required. Round DOWN so // accepted-share cadence never drops below target. + d = std::exp2(std::floor(std::log2(d))); // Quantize the floor too so a floor-pinned/warm-up advertise is still a // power of two. min_difficulty_ (e.g. 0.0005) is not itself on the grid, so // re-flooring at it would re-open the firmware reject gap at the floor. // Advertise at most one grid step below the configured floor (0.000488 vs // 0.0005), preserving the round-DOWN cadence invariant. - double d_q = std::max(std::exp2(std::floor(std::log2(min_difficulty_))), - std::exp2(std::floor(std::log2(d_ideal)))); - - // Hysteresis around the current power-of-two bucket. After the firmware-grid - // fix quantizes advertised diff to powers of two, both d_q and - // current_difficulty_ live on the 2^n grid, so a dead-band on the QUANTIZED - // ratio is inert (ratios are only 1, 2, 0.5). A rig whose un-quantized ideal - // D sits near a 2^n boundary would then flap its advertised bucket - // 2^n <-> 2^(n+1) on estimator noise (~+/-24% at tau=90s), doubling - // set_difficulty churn and jittering rig-side hashrate graphs. - // - // Fix: hold the current bucket [C, 2C) and only re-quantize when the - // UN-QUANTIZED ideal D leaves it DECISIVELY -- at/above 2C by the dead-band - // margin (step up) or below C by the dead-band margin (step down). Values in - // the band [C*(1-deadband), 2C*(1+deadband)) keep the current advertisement, - // so boundary noise no longer churns. This only widens the transition: it - // never advertises above ideal D by more than the margin, and never below the - // (quantized) floor. Round-DOWN cadence and warm-up/clamp are preserved. + d = std::max(std::exp2(std::floor(std::log2(min_difficulty_))), + std::exp2(std::floor(std::log2(d)))); if (current_difficulty_ > 0.0) { - const double C = current_difficulty_; - // Only hold when the current advertisement is itself on the 2^n grid - // (a value we previously advertised). A non-grid seed/hint (e.g. the raw - // min_difficulty_ or an operator hint) must be corrected onto the grid - // immediately, not frozen in place by the hysteresis band. - const bool current_on_grid = (C == std::exp2(std::floor(std::log2(C)))); - if (current_on_grid) { - const double up_threshold = 2.0 * C * (1.0 + vardiff_deadband_); - const double down_threshold = C * (1.0 - vardiff_deadband_); - if (d_ideal >= down_threshold && d_ideal < up_threshold) - return; // inside the hysteresis band -- keep the current bucket - } + double ratio = d / current_difficulty_; + // Dead-band: absorb estimator noise, no needless set_difficulty churn. + if (ratio < 1.0 + vardiff_deadband_ && ratio > 1.0 / (1.0 + vardiff_deadband_)) + return; } - - if (d_q == current_difficulty_) return; // decisive move re-adopts same grid step - LOG_INFO << "[Stratum] VARDIFF(hashrate): " << current_difficulty_ << " -> " << d_q + LOG_INFO << "[Stratum] VARDIFF(hashrate): " << current_difficulty_ << " -> " << d << " (H_est=" << h << " H/s, target=" << target_time_per_mining_share_ << "s)"; - current_difficulty_ = d_q; // downstream notify + stratum 1% resend guard unchanged + current_difficulty_ = d; // downstream notify + stratum 1% resend guard unchanged } void HashrateTracker::adjust_difficulty() { diff --git a/src/c2pool/main_dash.cpp b/src/c2pool/main_dash.cpp index b9390a67b..ee3137b5c 100644 --- a/src/c2pool/main_dash.cpp +++ b/src/c2pool/main_dash.cpp @@ -63,6 +63,7 @@ #include // E2a live-feed bridge (raw wire events -> derived ingest events) #include // wire_mempool_ingest (leg 1) #include // wire_tip_ingest (leg 2) +#include // dash::coin::EmbeddedOracleShadow — per-block dashd cross-check (OBSERVE-only) #include // wire_block_connect_ingest (leg 3) #include // wire_mn_list_ingest (leg 4) #include // E2c: RPC protx-list MN-set seed (parse_protx_list_seed) @@ -92,8 +93,6 @@ #include #include -#include // io-thread-decouple: background RPC pool -#include #include #include // std::getenv @@ -194,7 +193,8 @@ void print_banner(const char* argv0) << " [--listen [HOST:]PORT] [--addnode HOST:PORT]... [--connect HOST:PORT]...\n" << " [--stratum [HOST:]PORT] [--coin-p2p-connect HOST:PORT]...\n" << " [--web-port PORT] [--web-host ADDR] [--dashboard-dir PATH]\n" - << " [--embedded-utxo]\n" + << " [--embedded-utxo] [--embedded-oracle-shadow]\n" + << " [--oracle-graduation-blocks N] [--oracle-class-coverage K]\n" << " [--give-author PCT] [-f|--fee PCT] [--node-owner-address ADDR]\n" << " [--redistribute pplns|fee|boost|donate]\n" << " " << argv0 << " --mine-block [--coin-rpc H:P] [--coin-rpc-auth PATH]\n" @@ -214,6 +214,12 @@ void print_banner(const char* argv0) << " --web-port PORT (alias --http-port, default 8080) serves the FULL\n" << " c2pool web dashboard + JSON API on --web-host (default 0.0.0.0)\n" << " from --dashboard-dir (default web-static); --web-port 0 disables.\n" + << " --embedded-oracle-shadow runs the OBSERVE-only per-block dashd\n" + << " cross-check: dashd getblocktemplate{mode:proposal} is the VERDICT,\n" + << " regime-aware field-compare is the DIAGNOSIS; a persisted graduation\n" + << " ledger + /embedded_oracle verdict signal when the embedded arm is\n" + << " proven equivalent (safe to disable dashd, served domain). Needs the\n" + << " dashd RPC arm; never changes serving. N/K tune the graduation gate.\n" << " Live sharechain tip/stats, pool hashrate and per-share difficulty\n" << " are bound to the REAL DASH tracker; local hashrate comes from the\n" << " DASH stratum acceptor. If stratum and web ports collide the web\n" @@ -382,7 +388,10 @@ int run_node(bool testnet, const std::string& rpc_endpoint, double dev_donation, double node_owner_fee, const std::string& node_owner_address, const std::string& redistribute_mode, - bool no_p2p_relay) + bool no_p2p_relay, + bool embedded_oracle_shadow = false, + uint64_t oracle_grad_blocks = 5000, + uint64_t oracle_class_coverage = 20) { namespace io = boost::asio; @@ -538,6 +547,12 @@ int run_node(bool testnet, const std::string& rpc_endpoint, // serve X11 miners from the LTC work source. The dashboard is told the // miner-facing port through mining_interface->set_worker_port() (display) // and fed real stratum rates from the DASH acceptor after it starts. + // Embedded ORACLE-SHADOW validator (--embedded-oracle-shadow). Declared here + // (before web_server) so the /embedded_oracle endpoint closure can read it; + // constructed + subscribed to the tip event later in the coin_p2p block, once + // node_coin_state exists. OBSERVE-only: stats_json() touches no serving state. + std::shared_ptr oracle_shadow; + std::unique_ptr web_server; auto enhanced_node = std::make_shared(testnet); if (web_port != 0) { @@ -570,6 +585,16 @@ int run_node(bool testnet, const std::string& rpc_endpoint, // resolved), not an ICoinNode, so tell the dashboard RPC is present. mi->set_coin_rpc_available(static_cast(rpc)); + // ── /embedded_oracle stats endpoint (OBSERVE-only) ──────────────── + // JSON coverage ledger + objective GRADUATION verdict (safe-to-disable- + // dashd gate). Reads the shadow validator's own structures; never the + // serving path. Empty/disabled shape when --embedded-oracle-shadow off. + mi->set_embedded_oracle_fn([&oracle_shadow]() -> nlohmann::json { + if (oracle_shadow) return oracle_shadow->stats_json(); + return nlohmann::json{{"mode", "disabled"}, + {"note", "run with --embedded-oracle-shadow to enable"}}; + }); + web_server->set_dashboard_dir(dashboard_dir); // Explicitly DISABLE the WebServer's own stratum acceptor. Its ctor // defaults stratum_port_ to (web_port + 10) (web_server.cpp:8330/8344/ @@ -1280,6 +1305,116 @@ int run_node(bool testnet, const std::string& rpc_endpoint, // header chain), NOT the raw wire. coin_feed_subs.push_back( c2pool::dash::wire_tip_ingest(coin_state, *maintainer)); + + // ── Embedded ORACLE-SHADOW: per-block dashd cross-check (OBSERVE-only) ─ + // Subscribed to the SAME new_tip event AFTER wire_tip_ingest, so the + // maintainer has already republished the embedded bundle for this tip + // before the shadow reads it. The shadow builds the embedded template + // via node_coin_state.select_work (the SAME build path the serve arm + // uses) and cross-checks it against a fresh dashd getblocktemplate, + // gating divergence/graduation on the DETERMINISTIC field set only (the + // two nodes have intentionally different mempools/peers). It NEVER + // changes the serving decision. Requires the alongside dashd RPC arm. + if (embedded_oracle_shadow) { + if (!rpc) { + std::cout << "[run] --embedded-oracle-shadow given but the dashd " + "RPC arm is UNARMED (need dash.conf creds / --coin-rpc); " + "the shadow has no oracle to cross-check against -- " + "disabled.\n"; + } else { + dash::coin::EmbeddedOracleShadow::Config oc; + oc.testnet = testnet; + oc.grad.consecutive_clean_target = oracle_grad_blocks; + oc.grad.per_class_coverage_target = oracle_class_coverage; +#ifdef C2POOL_VERSION + oc.c2pool_commit = C2POOL_VERSION; +#endif + try { oc.dashd_version = + rpc->getnetworkinfo().value("subversion", std::string{}); } + catch (...) { /* best-effort ledger identity */ } + const auto oracle_dir = + (core::filesystem::config_path() / net_subdir).string(); + oc.divergence_ledger_path = + oracle_dir + "/embedded_oracle_divergence.jsonl"; + oc.graduation_state_path = + oracle_dir + "/embedded_oracle_graduation.json"; + + // Proposal VERDICT leg: assemble the embedded block (pool-only + // coinbase, no miner payout -- consensus-irrelevant to dashd + // TestBlockValidity) with the SAME SSOTs the --mine-block path + // uses, and submit getblocktemplate{mode:proposal}. "" = dashd + // ACCEPTED; else the reject reason. This is the authoritative, + // mempool-independent per-height verdict (§6 condition 2). + auto proposal_fn = + [testnet, rp = rpc.get()](const dash::coin::DashWorkData& wd) + -> dash::coin::ProposalResult { + dash::coin::ProposalResult r; + r.attempted = true; + try { + const core::CoinParams params = dash::make_coin_params(testnet); + std::map, uint64_t> empty_weights; + uint160 zero_pkh; // pool-only coinbase (no finder) + auto tx_outs = dash::coinbase::compute_dash_payouts( + wd.m_coinbase_value, wd.m_packed_payments, zero_pkh, + empty_weights, /*total_weight=*/0, params); + auto layout = dash::coinbase::build( + wd, tx_outs, /*pool_tag=*/"c2pool", params, + /*ref_hash=*/uint256::ZERO); + // nonce 0 + curtime: proposal mode skips PoW; validity is + // structure + payee + CbTx + tx-set (TestBlockValidity). + auto block = dash::coin::serialize_full_block( + wd, layout.bytes, /*nonce=*/0, + wd.m_curtime ? wd.m_curtime + : static_cast(std::time(nullptr))); + const std::string reason = rp->propose_block_hex(HexStr(block)); + r.accepted = reason.empty(); + r.reason = reason; + } catch (const std::exception& e) { + r.accepted = false; r.reason = std::string("assemble:") + e.what(); + } + return r; + }; + + // creditPool INVARIANT base = the CONNECTED block N-1's committed + // creditPoolBalance (dashd getblock verbosity 2 -> tx[0].cbTx), + // not dashd's previous *template* projection (review nit d). + auto base_cp_fn = + [rp = rpc.get()](const uint256& prev_hash) + -> std::optional { + try { + auto blk = rp->getblock(prev_hash, /*verbosity=*/2); + if (blk.contains("tx") && blk["tx"].is_array() + && !blk["tx"].empty()) { + const auto& cb = blk["tx"][0]; + if (cb.contains("cbTx") + && cb["cbTx"].contains("creditPoolBalance")) + return cb["cbTx"]["creditPoolBalance"].get(); + } + } catch (...) { /* nullopt -> fall back to template base */ } + return std::nullopt; + }; + + oracle_shadow = std::make_shared( + node_coin_state, + [rp = rpc.get()]() { return rp->getwork(); }, + std::move(proposal_fn), + std::move(oc), + std::move(base_cp_fn)); + auto* shadow = oracle_shadow.get(); + coin_feed_subs.push_back( + coin_state.new_tip.subscribe( + [shadow](const ::dash::interfaces::TipAdvance& t) { + shadow->on_new_tip(t.prev_height + 1, t.prev_hash); + })); + std::cout << "[run] --embedded-oracle-shadow ARMED: per-block dashd " + "PROPOSAL verdict + regime-aware field diagnosis (N=" + << oracle_grad_blocks << " K=" << oracle_class_coverage + << "); ledger at " << oracle_dir + << "/embedded_oracle_*.{jsonl,json}; verdict at " + "/embedded_oracle\n"; + } + } + // Leg 3 (block connect): Node::block_connected -> maintainer // .on_block_connected (MnStateMachine::apply_block, folds DIP3 special // txs into the DMN set). block_connected is fired by the live-feed @@ -1497,94 +1632,49 @@ int run_node(bool testnet, const std::string& rpc_endpoint, // trivial RPC; failures are swallowed so a daemon hiccup never crashes the // run-loop (retry next tick). Reuses the existing NodeRPC client — no new // dependency, no dashd config change, no new notify mechanism. - // io-thread-decouple: dedicated single-thread pool for the fallback arm's - // BLOCKING dashd RPC (getbestblockhash tip probe + the background template - // re-source). Mirrors main_ltc.cpp hdr_pool ("keeps scrypt off io_context"): - // synchronous beast I/O runs HERE, never on the stratum io_context, so 60+ - // sessions never starve while dashd is queried (or wedged -- the NodeRPC - // socket timeout + m_rpc_mutex bound this thread). Declared here (after rpc / - // work_source / stratum_server) so its explicit stop()+join() after the run - // loop -- and its destructor -- happen BEFORE those objects unwind: no - // background probe is ever mid-flight against freed state. Only created on - // the fallback arm; null on the embedded arm (legacy inline path unchanged). - std::shared_ptr rpc_pool; if (!coin_p2p && rpc && stratum_server) { - rpc_pool = std::make_shared(1); - - // Non-blocking template re-source: cached_work() hands the blocking - // select_work()/GBT to rpc_pool as a single-flight background job - // instead of blocking the io thread on every stale/generation miss (the - // per-share ~15-30 s GBT block). The io thread serves the cached template - // immediately; the pool updates it and the next notify picks it up. - work_source->set_refresh_executor( - [rpc_pool](std::function job) { - boost::asio::post(*rpc_pool, std::move(job)); - }); - auto tip_timer = std::make_shared(ioc); auto last_tip = std::make_shared(); auto tip_tick = std::make_shared< std::function>(); - // tip_tick runs ON ioc when the 3 s timer fires. It does NOT call the - // blocking RPC itself: it hands getbestblockhash to rpc_pool and posts - // the tip-change follow-up (invalidate + bump + notify) + the timer - // re-arm BACK onto ioc (ws/ss/tip_timer are io-thread-confined), exactly - // like main_ltc.cpp's post-to-pool -> post-back-to-ioc pattern. The - // timer is re-armed only AFTER the RPC completes, so a slow dashd cannot - // pile up overlapping polls. Lost-block-prevention is preserved: a real - // tip change still fires invalidate + notify -- only WHERE the probe runs - // has moved off the stratum io thread. *tip_tick = [rpc = rpc.get(), ws = work_source.get(), - ss = stratum_server.get(), tip_timer, last_tip, tip_tick, - rpc_pool, &ioc](const boost::system::error_code& ec) { + ss = stratum_server.get(), tip_timer, last_tip, tip_tick]( + const boost::system::error_code& ec) { if (ec) return; // cancelled at shutdown - boost::asio::post(*rpc_pool, - [rpc, ws, ss, tip_timer, last_tip, tip_tick, &ioc]() { - std::string tip; - bool ok = false; - try { - tip = rpc->getbestblockhash(); // BLOCKING -- BACKGROUND THREAD - ok = true; - } catch (const std::exception& e) { - LOG_WARNING << "[Stratum] tip-poll getbestblockhash failed " - "(non-fatal, retry next tick): " << e.what(); - } catch (...) { - // swallow — never crash on a tip probe + try { + const std::string tip = rpc->getbestblockhash(); + if (!tip.empty() && *last_tip != tip) { + const bool first_seen = last_tip->empty(); + *last_tip = tip; + // Skip the notify on the very first observation (baseline); + // only a genuine tip CHANGE forces a refresh. + if (!first_seen) { + ws->invalidate_template_cache( + "tip-poll: dashd best-block changed"); + ws->bump_work_generation(); + ss->notify_all(); + LOG_INFO << "[Stratum] tip-poll: new tip " + << tip.substr(0, 16) + << ", forcing template refresh + notify"; + std::cout << "[Stratum] tip-poll: new tip " + << tip.substr(0, 16) + << ", forcing template refresh + notify\n"; } - // Follow-up + timer re-arm run BACK ON ioc (io-thread-confined - // state). If ioc is already stopped (shutdown) this handler - // simply never runs -> the poll stops cleanly. - boost::asio::post(ioc, - [ok, tip = std::move(tip), ws, ss, tip_timer, last_tip, tip_tick]() { - if (ok && !tip.empty() && *last_tip != tip) { - const bool first_seen = last_tip->empty(); - *last_tip = tip; - // Skip the notify on the very first observation - // (baseline); only a genuine tip CHANGE refreshes. - if (!first_seen) { - ws->invalidate_template_cache( - "tip-poll: dashd best-block changed"); - ws->bump_work_generation(); - ss->notify_all(); - LOG_INFO << "[Stratum] tip-poll: new tip " - << tip.substr(0, 16) - << ", forcing template refresh + notify"; - std::cout << "[Stratum] tip-poll: new tip " - << tip.substr(0, 16) - << ", forcing template refresh + notify\n"; - } - } - tip_timer->expires_after(std::chrono::seconds(3)); - tip_timer->async_wait(*tip_tick); - }); - }); + } + } catch (const std::exception& e) { + LOG_WARNING << "[Stratum] tip-poll getbestblockhash failed " + "(non-fatal, retry next tick): " << e.what(); + } catch (...) { + // swallow — never crash the run-loop on a tip probe + } + tip_timer->expires_after(std::chrono::seconds(3)); + tip_timer->async_wait(*tip_tick); }; tip_timer->expires_after(std::chrono::seconds(3)); tip_timer->async_wait(*tip_tick); std::cout << "[run] fallback-arm tip-poll ARMED (dashd getbestblockhash " - "every 3 s on a dedicated RPC thread -> event-driven template " - "refresh + clean_jobs notify on tip change; io thread never " - "blocks on dashd)\n"; + "every 3 s -> event-driven template refresh + clean_jobs " + "notify on tip change)\n"; } std::cout << "[run] run-loop up (Ctrl-C to stop); won blocks relay DUAL-PATH:\n" @@ -1614,22 +1704,17 @@ int run_node(bool testnet, const std::string& rpc_endpoint, } } - // io-thread-decouple: join the background RPC pool FIRST, before any of the - // objects it dereferences (rpc / work_source / stratum_server) unwind. run() - // has returned (ioc stopped), so no NEW work is posted; stop()+join() waits - // out any in-flight getbestblockhash/GBT re-source (bounded by the NodeRPC - // socket timeout) so no pool thread ever touches freed state. Any post-back - // to the stopped ioc simply never executes. - if (rpc_pool) { - rpc_pool->stop(); - rpc_pool->join(); - } - // Tear the acceptor + sessions down while the work source and node_coin_state // it references are still alive -- explicit reset keeps destruction order safe // (stratum_server was declared before them, so it would otherwise outlive them). stratum_server.reset(); + // Join the oracle-shadow worker thread BEFORE node_coin_state / rpc unwind: + // the worker dereferences both (select_work + getwork/proposal), and + // oracle_shadow is declared earlier than node_coin_state so it would + // otherwise outlive it. ioc.run() has returned, so no further new_tip fires. + if (oracle_shadow) oracle_shadow.reset(); + // Stop the dashboard BEFORE p2p_node unwinds: its callbacks hold a raw // dash::Node* and the HTTP thread must be joined while that is still valid. if (web_server) { @@ -1800,6 +1885,9 @@ int main(int argc, char** argv) std::string stratum_host = "0.0.0.0"; // --stratum [HOST:]PORT bind interface (default all) uint16_t stratum_port = 0; // 0 disables the Stratum accept-loop; --stratum sets it bool embedded_utxo = false; // --embedded-utxo: arm the E2b UTXO/fee lane (opt-in) + bool embedded_oracle_shadow = false; // --embedded-oracle-shadow: per-block dashd cross-check (OBSERVE-only) + uint64_t oracle_grad_blocks = 5000; // --oracle-graduation-blocks N (consecutive clean) + uint64_t oracle_class_coverage = 20; // --oracle-class-coverage K (per height class) double dev_donation = 0.1; // --give-author (donation_percentage; README default 0.1%) double node_owner_fee = 0.0; // -f / --fee (node_owner_fee; default 0) std::string node_owner_address; // --node-owner-address (fee destination) @@ -1844,6 +1932,12 @@ int main(int argc, char** argv) no_p2p_relay = true; else if (std::strcmp(argv[i], "--embedded-utxo") == 0) embedded_utxo = true; + else if (std::strcmp(argv[i], "--embedded-oracle-shadow") == 0) + embedded_oracle_shadow = true; + else if (std::strcmp(argv[i], "--oracle-graduation-blocks") == 0 && i + 1 < argc) + oracle_grad_blocks = std::strtoull(argv[++i], nullptr, 10); + else if (std::strcmp(argv[i], "--oracle-class-coverage") == 0 && i + 1 < argc) + oracle_class_coverage = std::strtoull(argv[++i], nullptr, 10); else if ((std::strcmp(argv[i], "--give-author") == 0 || std::strcmp(argv[i], "--dev-donation") == 0) && i + 1 < argc) dev_donation = std::strtod(argv[++i], nullptr); @@ -1944,7 +2038,9 @@ int main(int argc, char** argv) stratum_host, stratum_port, web_host, web_port, dashboard_dir, coin_p2p_targets, embedded_utxo, dev_donation, node_owner_fee, - node_owner_address, redistribute_mode, no_p2p_relay); + node_owner_address, redistribute_mode, no_p2p_relay, + embedded_oracle_shadow, oracle_grad_blocks, + oracle_class_coverage); } return run_selftest(); } \ No newline at end of file diff --git a/src/core/stratum_server.cpp b/src/core/stratum_server.cpp index 2cdea2042..bd0d45e95 100644 --- a/src/core/stratum_server.cpp +++ b/src/core/stratum_server.cpp @@ -92,7 +92,6 @@ StratumServer::StratumServer(net::io_context& ioc, const std::string& address, u , bind_address_(address) , port_(port) , running_(false) - , idle_reap_timer_(ioc) { } @@ -114,8 +113,7 @@ bool StratumServer::start() running_ = true; accept_connections(); - start_idle_reaper(); // no-op unless session_idle_timeout_sec > 0 - + LOG_INFO << "StratumServer started on " << bind_address_ << ":" << port_; return true; @@ -135,7 +133,6 @@ void StratumServer::stop() boost::system::error_code ec; acceptor_.cancel(ec); acceptor_.close(ec); - idle_reap_timer_.cancel(); // stop the zombie-session reaper running_ = false; // Snapshot + clear the live-sessions set under the mutex, then close @@ -386,58 +383,6 @@ void StratumServer::notify_all() } } -void StratumServer::start_idle_reaper() -{ - // Zombie-session reaper (belt-and-suspenders alongside OS TCP keepalive): - // periodically close sessions that have sent no inbound line past the idle - // timeout. A live miner submits shares far inside the window; a half-open - // NAT-dead peer sends nothing. No-op (never re-armed) unless the DASH-set - // StratumConfig::session_idle_timeout_sec > 0 -> other coins unchanged. - const auto& cfg0 = mining_interface_ ? mining_interface_->get_stratum_config() - : core::stratum::StratumConfig{}; - const uint32_t timeout = cfg0.session_idle_timeout_sec; - const bool keepalive_on = cfg0.tcp_keepalive_enabled; - if (timeout == 0 || !running_) return; - - // Sweep at half the timeout, bounded to [15 s, 60 s], so a dead session is - // reclaimed within ~1.5x the timeout. - const uint32_t period = std::max(15, std::min(60, timeout / 2)); - idle_reap_timer_.expires_after(std::chrono::seconds(period)); - idle_reap_timer_.async_wait([this, timeout, keepalive_on](const boost::system::error_code& ec) { - if (ec) return; // cancelled at shutdown - if (!running_) return; - std::vector> snapshot; - { - std::lock_guard lk(sessions_mutex_); - snapshot.assign(sessions_.begin(), sessions_.end()); - } - size_t reaped = 0; - for (auto& s : snapshot) { - if (!s->is_connected()) - continue; // socket already dead -> notify_all prunes it - if (s->seconds_since_activity() <= timeout) - continue; // recently active - // KEEPALIVE-AWARE: when OS TCP keepalive is enabled it is the - // liveness authority -- a still-open socket means keepalive has NOT - // declared the peer dead (it force-closes dead NAT paths, which the - // is_connected() guard above then catches). Do NOT reap an idle-but- - // keepalive-validated AUTHORIZED rig: a high fixed-diff suffix - // (ADDR+N) can legitimately exceed the idle window between submits, - // and reaping it would drop a LIVE, paying miner. Idle-time reaping - // is the FALLBACK only where keepalive is unavailable; unauthorized - // sessions (never completed handshake) are always fair game. - if (keepalive_on && s->is_authorized()) - continue; - s->drop_stale("idle timeout (no inbound share/line)"); - ++reaped; - } - if (reaped) - LOG_INFO << "[Stratum] idle-reaper closed " << reaped - << " stale session(s) (> " << timeout << "s idle)"; - start_idle_reaper(); // re-arm - }); -} - /// StratumSession Implementation StratumSession::StratumSession(tcp::socket socket, std::shared_ptr mining_interface, StratumServer* server) @@ -445,9 +390,7 @@ StratumSession::StratumSession(tcp::socket socket, std::shared_ptr , mining_interface_(mining_interface) , server_(server) , connected_at_(std::chrono::steady_clock::now()) - , last_activity_(connected_at_) , work_push_timer_(socket_.get_executor()) - , handshake_timer_(socket_.get_executor()) { subscription_id_ = generate_subscription_id(); extranonce1_ = generate_extranonce1(); @@ -468,8 +411,6 @@ void StratumSession::start() auto ep = socket_.remote_endpoint(ec); if (ec) return; // socket closed before start() was dispatched LOG_INFO << "StratumSession started for client: " << ep; - apply_socket_keepalive(); // OS keepalive (no-op unless enabled) -- reaps NAT-dead peers - arm_handshake_timer(); // authorize deadline (no-op unless configured) read_message(); } @@ -504,13 +445,10 @@ void StratumSession::read_message() void StratumSession::process_message(std::size_t bytes_read) { try { - // Zombie-session reaper: a received line is proof of a live peer. - last_activity_ = std::chrono::steady_clock::now(); - std::istream is(&buffer_); std::string line; std::getline(is, line); - + if (!line.empty() && line.back() == '\r') { line.pop_back(); // Remove \r if present } @@ -593,8 +531,6 @@ nlohmann::json StratumSession::handle_authorize(const nlohmann::json& params, co if (params.size() >= 1 && params[0].is_string()) { username_ = params[0]; authorized_ = true; - // Handshake completed -> disarm the authorize deadline (zombie-session fix). - handshake_timer_.cancel(); // ─── Step 1: Strip fixed difficulty suffix (+N) before any parsing ─── // Format: "ADDRESS+1024" or "ADDR,ADDR+512" → suggested_difficulty_=N @@ -1353,20 +1289,6 @@ nlohmann::json StratumSession::handle_submit(const nlohmann::json& params, const void StratumSession::send_response(const nlohmann::json& response) { try { - // Write-queue backlog cap (zombie-session fix): a dead peer whose kernel - // send buffer has filled never drains the async_write, so the queue - // grows unbounded. Past the configured depth, drop the session rather - // than accumulate. 0 = unlimited (legacy; other coins unchanged). - const size_t cap = mining_interface_ - ? mining_interface_->get_stratum_config().max_write_queue_depth - : 0; - if (cap > 0 && write_queue_.size() >= cap) { - LOG_WARNING << "[Stratum] session " << session_id_ - << " write backlog " << write_queue_.size() - << " >= cap " << cap << " -- dropping stuck session"; - drop_stale("write-queue backlog exceeded"); - return; - } std::string message = response.dump() + "\n"; write_queue_.push_back(std::move(message)); if (!writing_) @@ -1868,82 +1790,12 @@ void StratumSession::cancel_timers() // Cancel pending timer — callback fires with ec=operation_aborted → returns. // Timer is a member, so it outlives all its callbacks (no use-after-free). work_push_timer_.cancel(); - handshake_timer_.cancel(); // Close socket so is_connected() returns false for any already-dequeued // callbacks that fire with ec=success after cancel(). boost::system::error_code ec; socket_.close(ec); } -// ── Zombie-session fixes (transport/liveness only; consensus-neutral) ──────── - -void StratumSession::apply_socket_keepalive() -{ - // Enable OS TCP keepalive so the kernel actively probes an idle peer and - // errors the pending async_read when a NAT path has gone dead (the peer - // never sent FIN/RST). This is THE root fix for immortal subscribed - // sessions -- socket_.is_open() alone never falsifies for a half-open NAT - // drop. No-op unless the (DASH-set) StratumConfig knob is enabled, so - // LTC/BTC/DGB behaviour is unchanged. - const auto& cfg = mining_interface_->get_stratum_config(); - if (!cfg.tcp_keepalive_enabled) return; - - boost::system::error_code ec; - socket_.set_option(boost::asio::socket_base::keep_alive(true), ec); // portable (asio) - if (ec) { - LOG_WARNING << "[Stratum] SO_KEEPALIVE set failed: " << ec.message(); - return; - } - // Per-probe tuning (idle/interval/count) is Linux-specific -- Windows tunes - // keepalive via WSAIoctl(SIO_KEEPALIVE_VALS) and macOS via TCP_KEEPALIVE, so - // guard exactly like sample_tcp_rtt_ms() above (#ifdef __linux__). Off-Linux - // the portable keep_alive(true) above still arms keepalive with OS defaults; - // the DASH deploy target is Linux, where the 60/10/3 tuning (~90 s detect) - // applies. This keeps core building on the Windows/macOS CI lanes. -#ifdef __linux__ - const int fd = socket_.native_handle(); - int idle = static_cast(cfg.tcp_keepalive_idle_sec); - int intvl = static_cast(cfg.tcp_keepalive_interval_sec); - int cnt = static_cast(cfg.tcp_keepalive_count); - ::setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle)); - ::setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &intvl, sizeof(intvl)); - ::setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &cnt, sizeof(cnt)); - LOG_TRACE << "[Stratum] TCP keepalive armed (idle=" << idle - << "s interval=" << intvl << "s count=" << cnt << ")"; -#else - LOG_TRACE << "[Stratum] TCP keepalive armed (OS-default probe timing; " - "per-probe tuning is Linux-only)"; -#endif -} - -void StratumSession::arm_handshake_timer() -{ - // Drop a session that never completes mining.authorize within the deadline - // (a live rig authorizes in << 1 s; a half-open probe never does). Disarmed - // in handle_authorize(). No-op unless configured. Member timer + weak self - // keepalive so a fired-after-close callback is a safe no-op. - const auto& cfg = mining_interface_->get_stratum_config(); - if (cfg.handshake_timeout_sec == 0) return; - handshake_timer_.expires_after(std::chrono::seconds(cfg.handshake_timeout_sec)); - std::weak_ptr weak = shared_from_this(); - handshake_timer_.async_wait([weak](const boost::system::error_code& ec) { - if (ec) return; // cancelled (authorized, or shutdown) - auto self = weak.lock(); - if (!self) return; - if (!self->authorized_ && self->is_connected()) - self->drop_stale("handshake (authorize) deadline"); - }); -} - -void StratumSession::drop_stale(const char* reason) -{ - LOG_INFO << "[Stratum] dropping stale session " << session_id_ - << " (" << reason << ")"; - // cancel_timers() closes the socket; the pending async_read then fails and - // runs the standard disconnect path (unregister worker, log). Idempotent. - cancel_timers(); -} - // Parse multi-chain addresses from username string. // Tries multiple separator formats for maximum miner compatibility: // 1. Slash+colon: "LTC_ADDR/98:DOGE_ADDR" (explicit chain ID) diff --git a/src/core/stratum_server.hpp b/src/core/stratum_server.hpp index 3ff20c581..5cbc8bac7 100644 --- a/src/core/stratum_server.hpp +++ b/src/core/stratum_server.hpp @@ -190,10 +190,6 @@ class StratumSession : public std::enable_shared_from_this uint64_t stale_shares_ = 0; uint64_t doa_shares_ = 0; // block-template changed at submit (live-vs-frozen prevhash mismatch); accounting only, share still broadcasts std::chrono::steady_clock::time_point connected_at_; - // Last inbound-line timestamp (zombie-session reaper, StratumConfig:: - // session_idle_timeout_sec). Updated on every received stratum line; a - // half-open NAT-dead peer never advances it. Init to connected_at_. - std::chrono::steady_clock::time_point last_activity_; std::string session_id_; // Reject-reason diagnostics throttle: emit the low-diff (P5) and @@ -222,11 +218,6 @@ class StratumSession : public std::enable_shared_from_this // where the transport/protocol object owns its timers. boost::asio::steady_timer work_push_timer_; - // Handshake deadline (zombie-session fix, StratumConfig::handshake_timeout_ - // sec): armed at start(), disarmed once authorize completes. A session that - // never authorizes (half-open / probe) is dropped when it fires. - boost::asio::steady_timer handshake_timer_; - public: explicit StratumSession(tcp::socket socket, std::shared_ptr mining_interface, StratumServer* server = nullptr); @@ -234,15 +225,6 @@ class StratumSession : public std::enable_shared_from_this bool is_connected() const { return socket_.is_open(); } bool is_subscribed() const { return subscribed_; } - bool is_authorized() const { return authorized_; } - // Seconds since the last inbound stratum line (zombie-session reaper). - double seconds_since_activity() const { - return std::chrono::duration( - std::chrono::steady_clock::now() - last_activity_).count(); - } - // Reap a stale/zombie session: cancel timers + close the socket. The pending - // async_read then fails and runs the normal disconnect path. Idempotent. - void drop_stale(const char* reason); // Diagnostics / test introspection (read-only; call quiesced or from the // session's io_context thread — active_jobs_ mutates on notify/submit). size_t active_job_count() const { return active_jobs_.size(); } @@ -285,10 +267,6 @@ class StratumSession : public std::enable_shared_from_this void start_periodic_work_push(); void schedule_work_push_timer(); void cancel_timers(); - // Zombie-session fixes (all no-op unless the DASH-set StratumConfig knobs - // enable them): apply OS TCP keepalive, arm the authorize deadline. - void apply_socket_keepalive(); - void arm_handshake_timer(); std::string generate_extranonce1(); void parse_address_separators(std::string& username, std::string& merged_addr_raw); @@ -325,13 +303,6 @@ class StratumServer mutable double addr_rates_cache_ts_ = 0.0; mutable std::mutex cache_mutex_; - // Zombie-session idle reaper (StratumConfig::session_idle_timeout_sec). - // Periodically closes sessions with no inbound activity past the timeout so - // a NAT-dead peer that OS keepalive somehow missed is still reclaimed. - // Disarmed (never scheduled) when the timeout is 0 -> legacy no-op. - boost::asio::steady_timer idle_reap_timer_; - void start_idle_reaper(); - public: StratumServer(net::io_context& ioc, const std::string& address, uint16_t port, std::shared_ptr mining_interface); ~StratumServer(); diff --git a/src/core/stratum_types.hpp b/src/core/stratum_types.hpp index 3cbf47424..4764f1b55 100644 --- a/src/core/stratum_types.hpp +++ b/src/core/stratum_types.hpp @@ -67,36 +67,6 @@ struct StratumConfig { // fetches, and the legacy stub-coinbase fallback is unreachable. // Default false: LTC/BTC/DGB behavior is byte-unchanged. bool require_job_snapshot{false}; - - // ── Live-session hygiene (mining-hotel ZOMBIE-SESSION LEAK fix) ────────── - // A NAT-dropped miner's TCP connection is frequently never FIN/RST'd, so - // StratumSession::is_connected() (socket_.is_open()) stays true FOREVER and - // the session is never reaped -- every failed rig retry then mints an - // immortal subscribed session that keeps drawing full per-notify job builds - // (observed: 66 sockets for ~23 rigs). At the default cap these zombies also - // lock real rigs out (admission control). These knobs bound that leak. ALL - // default OFF/neutral so LTC/BTC/DGB behaviour is BYTE-UNCHANGED; DASH opts - // in (impl/dash/stratum/work_source.cpp ctor). Transport/liveness only -- - // zero wire-byte change and consensus-neutral. - // - // (a) OS TCP keepalive: the kernel probes an idle peer and errors the - // pending async_read on a dead NAT path, which runs the normal - // disconnect+prune. THE root fix for the immortal-session class. - bool tcp_keepalive_enabled = false; - uint32_t tcp_keepalive_idle_sec = 60; // begin probing after 60 s idle - uint32_t tcp_keepalive_interval_sec = 10; // 10 s between probes - uint32_t tcp_keepalive_count = 3; // drop after 3 failed probes - // (b) Handshake deadline: drop a session that has not completed - // mining.authorize within N s (a live rig authorizes in << 1 s). 0=off. - uint32_t handshake_timeout_sec = 0; - // (c) Application idle reaper (belt-and-suspenders for keepalive): reap a - // session that has sent NO inbound line for N s. A live miner submits - // shares far inside this window; a half-open zombie sends nothing. 0=off. - uint32_t session_idle_timeout_sec = 0; - // (d) Write-queue backlog cap: drop a session whose un-acked write backlog - // exceeds this many frames (a dead peer whose kernel send buffer filled - // accumulates unbounded). 0=unlimited (legacy). - size_t max_write_queue_depth = 0; }; /// Frozen share-construction fields returned by ref_hash_fn. These diff --git a/src/core/web_server.cpp b/src/core/web_server.cpp index 2eb2796f0..c69509cb4 100644 --- a/src/core/web_server.cpp +++ b/src/core/web_server.cpp @@ -383,6 +383,8 @@ void HttpSession::process_request() rest_result = mining_interface_->rest_node_info(); else if (target == "/api/node_topology") rest_result = mining_interface_->rest_node_topology(); + else if (target == "/embedded_oracle") + rest_result = mining_interface_->rest_embedded_oracle(); else if (target == "/luck_stats") rest_result = mining_interface_->rest_luck_stats(); else if (target == "/ban_stats") @@ -5272,6 +5274,21 @@ void MiningInterface::auto_detect_external_info() // (LTC/DOGE/BTC/DGB/DASH/BCH) runs a different coin set + embedded daemons; // the dashboard must reflect THAT node's REAL shape, auto-detected from live // state -- never a hardcoded LTC+DOGE shape. Web/diagnostic layer only. +// Embedded ORACLE-SHADOW validator stats (/embedded_oracle). Returns the +// per-block dashd cross-check coverage ledger + objective GRADUATION verdict +// (the safe-to-disable-dashd gate) when the wiring layer feeds the hook; +// otherwise a {mode:disabled} shape. OBSERVE-only reporting. +nlohmann::json MiningInterface::rest_embedded_oracle() +{ + if (m_embedded_oracle_fn) { + auto j = m_embedded_oracle_fn(); + if (!j.is_null()) + return j; + } + return nlohmann::json{{"mode", "disabled"}, + {"note", "embedded-oracle-shadow not wired on this node"}}; +} + nlohmann::json MiningInterface::rest_node_topology() { // D0.3 seam: prefer the per-coin StatsProvider hook when the wiring layer diff --git a/src/core/web_server.hpp b/src/core/web_server.hpp index a296f3fa5..0f2fab5e8 100644 --- a/src/core/web_server.hpp +++ b/src/core/web_server.hpp @@ -488,6 +488,14 @@ class MiningInterface : public jsonrpccxx::JsonRpc2Server, void set_node_topology_fn(node_topology_fn_t fn) { m_node_topology_fn = std::move(fn); } nlohmann::json rest_node_topology(); // /api/node_topology + // Embedded ORACLE-SHADOW validator stats seam (/embedded_oracle). When set + // by a wiring layer (main_dash.cpp), returns the per-block dashd cross-check + // coverage ledger + objective GRADUATION verdict. OBSERVE-only reporting; + // touches no serving/consensus state. Unset -> a {mode:disabled} shape. + using embedded_oracle_fn_t = std::function; + void set_embedded_oracle_fn(embedded_oracle_fn_t fn) { m_embedded_oracle_fn = thread_safe_wrap(std::move(fn)); } + nlohmann::json rest_embedded_oracle(); // /embedded_oracle + // Sharechain stats callback — returns live tracker data for the /sharechain/stats endpoint using sharechain_stats_fn_t = std::function; void set_sharechain_stats_fn(sharechain_stats_fn_t fn) { m_sharechain_stats_fn = thread_safe_wrap(std::move(fn)); } @@ -1035,6 +1043,7 @@ class MiningInterface : public jsonrpccxx::JsonRpc2Server, spv_progress_fn_t m_spv_progress_fn; coin_peers_fn_t m_coin_peers_fn; node_topology_fn_t m_node_topology_fn; // D0.3 per-coin stats provider (optional) + embedded_oracle_fn_t m_embedded_oracle_fn; // /embedded_oracle shadow-validator stats (optional) // Rate limiter for /api/coin_peers: IP → last request time std::map m_coin_peers_rate_limit; sharechain_window_fn_t m_sharechain_window_fn; diff --git a/src/impl/dash/coin/embedded_oracle_shadow.hpp b/src/impl/dash/coin/embedded_oracle_shadow.hpp new file mode 100644 index 000000000..5c366b6e8 --- /dev/null +++ b/src/impl/dash/coin/embedded_oracle_shadow.hpp @@ -0,0 +1,938 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +#pragma once + +/// DASH Embedded ORACLE-SHADOW VALIDATOR (--embedded-oracle-shadow). +/// +/// GOAL: run c2pool ALONGSIDE a Dash Core daemon and use dashd as a CONTINUOUS +/// REALTIME ORACLE. On every new tip the DAEMONLESS embedded arm builds a DIP4 +/// CbTx/template from its own SPV coin-state; we then (1) submit the fully- +/// assembled embedded block to dashd `getblocktemplate{mode:proposal}` — dashd's +/// TestBlockValidity is the AUTHORITATIVE per-height VERDICT (accepted/rejected), +/// mempool-independent — and (2) field-compare the embedded template against +/// dashd's own getblocktemplate as the DIAGNOSIS (which field diverged). A +/// persisted graduation ledger then objectively signals when it is SAFE to +/// disable dashd over the SERVED domain. +/// +/// This design implements the five §6 conditions of the adversarial critique +/// (docs/c2pool-dash-oracle-shadow-adversarial-critique.md): +/// +/// 1. FROZEN DETERMINISM TABLE (3-regime field model). Every compared field +/// carries a regime; there is no "expected mismatch at INFO" bucket (that +/// classification ate a real bug class once): +/// - ALIGNMENT KEY (previousblockhash): a mismatch VOIDs the sample. +/// - EQUALITY (cross-node must match: nHeight, CbTx nVersion, bits, +/// mintime, payee IDENTITY, platform-burn amount, normalized subsidy, +/// merkleRootMNList/Quorums *conditionally* — see below). +/// - INVARIANT (checked against the template's OWN tx set, NEVER cross- +/// node: creditPoolBalance normalized-base, coinbasevalue − ownfees). +/// - CONSTRAINT (range, never strict equality: bestCL* — the committed +/// CL must be >= the previous block's committed CL; the 96-byte sig +/// must match ONLY when the two nodes' bestCL heights coincide). +/// - NOT-COMPARABLE (excluded: curtime wall clock, tx set / tx merkle). +/// - DERIVED (secondary diagnostic: full CbTx bytes, bestCL masked). +/// 2. dashd as JUDGE not just reference: per-height mode=proposal of the +/// assembled embedded block is the VERDICT; field-compare is diagnosis. +/// Until proposal is in-node the Python soak is NOT superseded — so a +/// served height counts CLEAN only when the proposal is ACCEPTED. +/// 3. SELF-CONSISTENCY the field-compare cannot see: the embedded selector +/// must EXCLUDE special-tx types (1-4, 6, 8, 9) until their state machines +/// exist (§1.1 latent bug — a selected ProTx/asset-lock makes the CbTx +/// roots/pool contradict the arm's OWN tx set, invisible to field-compare; +/// dashd proposal catches it). NonEmptyTemplate is a required coverage +/// class. [Selector exclusion + nonzero-fee pinning are follow-up slices; +/// this validator MEASURES both and gates graduation on them.] +/// 4. ALIGNMENT + SETTLE: samples keyed on (height, prev_hash); reorg tracked +/// by prev-hash lineage, not height ordering; VOID-rate + serve-rate +/// surfaced with a serve-rate floor in the gate; ledger HARD-KEYED to +/// {c2pool_commit, comparator_version, dashd_version, net} — a new binary +/// does NOT inherit an old binary's streak. +/// 5. GRADUATION is scoped + REVOCABLE. GRADUATED == daemonless over the +/// SERVED domain with quantified fail-closed idle at the classes the arm +/// does not serve; the verdict declares proposal-accepted-when-served vs +/// still-fall-closed classes. Chain-drift sentinels (unknown CbTx version, +/// quorum-tail parse failure) auto-REVOKE to fail-closed. +/// +/// ══ WHAT THIS ORACLE GRADUATES (scope) ══════════════════════════════════════ +/// It proves CHAIN-STATE / CbTx-VALIDITY EQUIVALENCE over the served domain: +/// the embedded arm computes the same consensus-deterministic template dashd +/// would AND dashd's TestBlockValidity accepts the block. That is what reward- +/// safe DAEMONLESS BLOCK CONSTRUCTION needs. Its soundness basis is NOT peer +/// diversity: DASH master has NO peer-scoring (the LTC/p2pool-v36 CoinPeerManager +/// was deliberately NOT ported — isolation fence); the arm dials a single static +/// --coin-p2p-connect target (usually the co-running dashd). What makes the +/// cross-check meaningful is that the embedded arm INDEPENDENTLY DERIVES chain +/// state through its OWN SPV / mnlistdiff / quorum / credit-pool pipeline. +/// +/// NOT covered (a SEPARATE, still-open gate): NETWORK-LAYER independence — +/// independent mempool / relay / block-propagation / peer connectivity. That +/// does not exist today (single peer, usually dashd). Truly disabling dashd +/// also needs the CoinPeerManager ported to DASH. GRADUATED here is a NECESSARY +/// but not SUFFICIENT condition for full network-standalone daemonless operation. +/// +/// OBSERVE-ONLY (operator rule, consensus-neutral): NEVER changes the serving +/// decision. Reads the SAME embedded build path (NodeCoinState::select_work) + +/// the SAME dashd RPC closure the serve arm holds, on its OWN cadence into its +/// OWN structures. The serve path is untouched; dashd stays authoritative for +/// actual serving while shadowing. +/// +/// STRICTLY single-coin: src/impl/dash/coin/ only. Header-only so the pure +/// classify/compare/graduation logic is KAT-pinnable without a live node. + +#include // NodeCoinState, DashWorkData, WorkSelection, WorkSource +#include // DashWorkData, PackedPayment +#include // vendor::CCbTx, vendor::parse_cbtx + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dash { +namespace coin { + +// Bump on ANY change to the field-comparison / graduation semantics so a new +// comparator does NOT inherit an old comparator's clean streak (§6 cond. 4). +inline constexpr uint32_t kOracleComparatorVersion = 1; +// Highest CbTx payload version this comparator models. A connected/observed +// CbTx with a higher nVersion is a chain-drift sentinel (§6 cond. 5). +inline constexpr uint16_t kMaxKnownCbTxVersion = vendor::CCbTx::VERSION_CLSIG_AND_BALANCE; + +// ───────────────────────────────────────────────────────────────────────────── +// Height-class taxonomy. A single height can belong to MULTIPLE classes; +// coverage counts EACH (served + clean). +// ───────────────────────────────────────────────────────────────────────────── +enum class HeightClass { + Normal, DkgWindow, Superblock, PostRestartCold, + QuorumRotation, CreditPoolTransition, NonEmptyTemplate, Reorg, +}; +inline const char* height_class_name(HeightClass c) { + switch (c) { + case HeightClass::Normal: return "normal"; + case HeightClass::DkgWindow: return "dkg_window"; + case HeightClass::Superblock: return "superblock"; + case HeightClass::PostRestartCold: return "post_restart_cold"; + case HeightClass::QuorumRotation: return "quorum_rotation"; + case HeightClass::CreditPoolTransition: return "credit_pool_transition"; + case HeightClass::NonEmptyTemplate: return "non_empty_template"; + case HeightClass::Reorg: return "reorg"; + } + return "normal"; +} +inline const std::vector& graduation_required_classes() { + static const std::vector v = { + HeightClass::DkgWindow, HeightClass::Superblock, + HeightClass::PostRestartCold, HeightClass::QuorumRotation, + HeightClass::CreditPoolTransition, HeightClass::NonEmptyTemplate, + }; + return v; +} + +// ── DKG-window / superblock periodicity (net-dependent) ───────────────────── +struct NetPeriodicity { + std::vector> dkg_windows; // (interval, start, end) + uint32_t superblock_cycle{0}; + static NetPeriodicity for_net(bool testnet) { + NetPeriodicity p; + if (testnet) { + p.dkg_windows = {{{24, 10, 18}}, {{288, 20, 28}}, + {{576, 20, 48}}, {{288, 42, 50}}}; + p.superblock_cycle = 24; + } else { + p.dkg_windows = {{{288, 24, 32}}}; // headline; full table = follow-up + p.superblock_cycle = 16616; + } + return p; + } + bool is_dkg_window(uint32_t h) const { + for (const auto& w : dkg_windows) { + const uint32_t m = h % w[0]; + if (m >= w[1] && m <= w[2]) return true; + } + return false; + } + bool is_superblock(uint32_t h) const { + return superblock_cycle != 0 && (h % superblock_cycle) == 0; + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// The frozen determinism table, as code. Each compared field carries a REGIME. +// Only EQUALITY-failures and INVARIANT-violations count as divergences; CONSTRAINT +// range-fails count only in their strict sub-case; NOT-COMPARABLE/DERIVED never. +// ───────────────────────────────────────────────────────────────────────────── +enum class Regime { + Equality, // cross-node must match; mismatch = bug (counts) + Invariant, // per-side own-tx-set check; violation = bug (counts) + Constraint, // range; only the strict sub-case counts + Informational, // recorded, never counts (NOT-COMPARABLE / DERIVED) +}; +inline const char* regime_name(Regime r) { + switch (r) { + case Regime::Equality: return "equality"; + case Regime::Invariant: return "invariant"; + case Regime::Constraint: return "constraint"; + case Regime::Informational: return "informational"; + } + return "informational"; +} +struct FieldResult { + std::string field; + Regime regime; + bool counts; // this mismatch breaks graduation + std::string embedded; + std::string dashd; + bool match; +}; + +inline std::string hex_of(const std::vector& b) { + static const char* d = "0123456789abcdef"; + std::string s; s.reserve(b.size() * 2); + for (unsigned char c : b) { s.push_back(d[(c >> 4) & 0xF]); s.push_back(d[c & 0xF]); } + return s; +} +inline std::string hex_of(const std::array& b) { + return hex_of(std::vector(b.begin(), b.end())); +} +inline std::string payee_identity_str(const std::vector& pps) { + std::string s; for (const auto& p : pps) { s += p.payee; s += '|'; } return s; +} +inline std::string payee_amounts_str(const std::vector& pps) { + std::string s; + for (const auto& p : pps) { s += p.payee; s += ':'; s += std::to_string(p.amount); s += '|'; } + return s; +} +inline uint64_t platform_burn_amount(const std::vector& pps) { + for (const auto& p : pps) if (p.payee == "!6a") return p.amount; + return 0; +} +inline uint64_t sum_fees(const std::vector& fees) { + uint64_t s = 0; for (auto f : fees) s += f; return s; +} +/// bestCL bytes zeroed (the propagation-dependent field) so the whole-payload +/// DERIVED byte compare does not flap on legitimate CL-lag differences. +inline std::vector cbtx_bytes_bestcl_masked( + const std::vector& payload, const vendor::CCbTx& cbtx, bool ok) { + std::vector b(payload.begin(), payload.end()); + if (ok && cbtx.nVersion >= vendor::CCbTx::VERSION_CLSIG_AND_BALANCE) { + // Zero the trailing bestCLHeightDiff(varint)+sig(96)+creditPool(8) tail + // region conservatively: mask the last (96 + 8) bytes if present. + if (b.size() >= 104) std::fill(b.end() - 104, b.end(), 0); + } + return b; +} + +/// Per-side context the INVARIANT fields need (their own tx set / chain base). +struct SideContext { + const DashWorkData* wd{nullptr}; + const vendor::CCbTx* cbtx{nullptr}; + bool cbtx_ok{false}; + uint64_t own_fees{0}; // Σ selected tx fees this side + int64_t platform_reward{0}; // "!6a" burn (== platformReward(N)) + bool empty_template{false};// no selected txs (invariant collapses) +}; + +/// Compare with regime tags. `prev_committed_credit_pool` (optional) enables the +/// creditPool normalized-base INVARIANT; `dkg_window` voids the quorum-root +/// conditional-equality; `contiguous` (prev observed height == N-1) is required +/// for the creditPool base check (else it is VOIDed, not false-flagged). +inline std::vector compare_templates( + const SideContext& e, const SideContext& d, + std::optional prev_committed_credit_pool, + bool dkg_window, bool contiguous) +{ + std::vector out; + const auto& emb = *e.wd; const auto& dashd = *d.wd; + auto eq = [&](const char* f, Regime r, bool counts, std::string a, std::string b) { + out.push_back({f, r, counts && (a != b), a, b, a == b}); + }; + + // ── EQUALITY (cross-node) ─────────────────────────────────────────────── + eq("height", Regime::Equality, true, std::to_string(emb.m_height), std::to_string(dashd.m_height)); + eq("bits", Regime::Equality, true, std::to_string(emb.m_bits), std::to_string(dashd.m_bits)); + eq("mintime", Regime::Equality, true, std::to_string(emb.m_mintime),std::to_string(dashd.m_mintime)); + eq("payee_identities", Regime::Equality, true, + payee_identity_str(emb.m_packed_payments), payee_identity_str(dashd.m_packed_payments)); + eq("platform_burn_amount", Regime::Equality, true, + std::to_string(e.platform_reward), std::to_string(d.platform_reward)); + // Normalized subsidy core = coinbasevalue − own fees (fees stripped) — this + // is the EQUALITY core; raw coinbasevalue is INVARIANT (below). + eq("subsidy_core", Regime::Equality, true, + std::to_string(static_cast(emb.m_coinbase_value) - static_cast(e.own_fees)), + std::to_string(static_cast(dashd.m_coinbase_value) - static_cast(d.own_fees))); + + if (e.cbtx_ok && d.cbtx_ok) { + eq("cbtx_version", Regime::Equality, true, + std::to_string(e.cbtx->nVersion), std::to_string(d.cbtx->nVersion)); + eq("coinbase_nHeight", Regime::Equality, true, + std::to_string(e.cbtx->nHeight), std::to_string(d.cbtx->nHeight)); + // merkleRootMNList — EQUALITY conditional on no ProTx in either tx set. + // The embedded selector excludes special txs (§1.1 precondition), so + // over the served domain this is EQUALITY. (When that precondition is + // not yet enforced, a non-empty template downgrades to Informational to + // avoid a false flag the proposal leg would catch anyway.) + const bool roots_are_equality = e.empty_template && d.empty_template; + eq("merkleRootMNList", roots_are_equality ? Regime::Equality : Regime::Informational, + roots_are_equality, e.cbtx->merkleRootMNList.GetHex(), d.cbtx->merkleRootMNList.GetHex()); + // merkleRootQuorums — EQUALITY only OUTSIDE DKG windows (a DKG height + // mines a quorum-commitment tx → root legitimately differs). + const bool q_is_equality = roots_are_equality && !dkg_window; + eq("merkleRootQuorums", q_is_equality ? Regime::Equality : Regime::Informational, + q_is_equality, e.cbtx->merkleRootQuorums.GetHex(), d.cbtx->merkleRootQuorums.GetHex()); + + // ── CONSTRAINT: bestCL* is a RANGE, never strict equality. Strict sig + // equality is required ONLY when the two nodes' bestCL heights + // coincide (same bestCLHeightDiff at same block height ⇒ same CL). + const bool cl_heights_coincide = (e.cbtx->bestCLHeightDiff == d.cbtx->bestCLHeightDiff); + const bool sig_equal = (e.cbtx->bestCLSignature == d.cbtx->bestCLSignature); + out.push_back({"bestCLHeightDiff", Regime::Constraint, /*counts=*/false, + std::to_string(e.cbtx->bestCLHeightDiff), + std::to_string(d.cbtx->bestCLHeightDiff), cl_heights_coincide}); + out.push_back({"bestCLSignature", Regime::Constraint, + /*counts=*/(cl_heights_coincide && !sig_equal), + hex_of(e.cbtx->bestCLSignature), hex_of(d.cbtx->bestCLSignature), sig_equal}); + + // ── INVARIANT: creditPool normalized-base (the bug the soaks caught 3×). + // committed(N) − platformReward(N) − ownLockDelta == creditPool(N−1). + // Empty template ⇒ ownLockDelta 0. Requires a contiguous prev base; + // otherwise VOID (Informational), never a false flag. + if (prev_committed_credit_pool && contiguous) { + const int64_t base = *prev_committed_credit_pool; + const int64_t e_norm = e.cbtx->creditPoolBalance - e.platform_reward; // empty-template + const int64_t d_norm = d.cbtx->creditPoolBalance - d.platform_reward; + const bool e_ok = e.empty_template ? (e_norm == base) : true; // non-empty: proposal judges + const bool d_ok = d.empty_template ? (d_norm == base) : true; + out.push_back({"creditPool_invariant", Regime::Invariant, /*counts=*/!(e_ok && d_ok), + std::to_string(e_norm) + "==" + std::to_string(base) + "?" + (e_ok ? "ok" : "VIOL"), + std::to_string(d_norm) + "==" + std::to_string(base) + "?" + (d_ok ? "ok" : "VIOL"), + e_ok && d_ok}); + } else { + out.push_back({"creditPool_invariant", Regime::Informational, false, + std::to_string(e.cbtx->creditPoolBalance), + std::to_string(d.cbtx->creditPoolBalance), + e.cbtx->creditPoolBalance == d.cbtx->creditPoolBalance}); + } + } else { + eq("cbtx_parse", Regime::Equality, true, + e.cbtx_ok ? "ok" : "fail", d.cbtx_ok ? "ok" : "fail"); + } + + // ── INVARIANT: coinbasevalue − own fees == subsidy core (per side). Cross- + // checked as the EQUALITY subsidy_core above; the raw totals are info. + eq("coinbasevalue_raw", Regime::Informational, false, + std::to_string(emb.m_coinbase_value), std::to_string(dashd.m_coinbase_value)); + + // ── DERIVED / NOT-COMPARABLE (informational only) ─────────────────────── + eq("payee_amounts", Regime::Informational, false, + payee_amounts_str(emb.m_packed_payments), payee_amounts_str(dashd.m_packed_payments)); + eq("tx_count", Regime::Informational, false, + std::to_string(emb.m_tx_hashes.size()), std::to_string(dashd.m_tx_hashes.size())); + eq("cbtx_bytes_bestcl_masked", Regime::Informational, false, + hex_of(cbtx_bytes_bestcl_masked(emb.m_coinbase_payload, *e.cbtx, e.cbtx_ok)), + hex_of(cbtx_bytes_bestcl_masked(dashd.m_coinbase_payload, *d.cbtx, d.cbtx_ok))); + return out; +} + +// ── dashd proposal (mode=proposal) VERDICT (§6 cond. 2) ───────────────────── +struct ProposalResult { + bool attempted{false}; + bool accepted{false}; + std::string reason; // dashd's reject string on failure +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Graduation ledger. A SERVED height (embedded arm armed) counts CLEAN iff the +// dashd proposal ACCEPTED it AND no EQUALITY/INVARIANT/strict-CONSTRAINT field +// diverged. Fall-closed (dashd-fallback) heights are recorded per class so the +// verdict can DECLARE the residual serve-gaps. Keyed to {commit, comparator, +// dashd_version, net}; a key change wipes the streak (§6 cond. 4). +// ───────────────────────────────────────────────────────────────────────────── +struct GraduationConfig { + uint64_t consecutive_clean_target{5000}; // N (served + clean) + uint64_t per_class_coverage_target{20}; // K (served + clean, per class) + double serve_rate_floor{0.5}; // arm must SERVE >= this of eligible normals +}; + +class GraduationLedger { +public: + std::map coverage; // class -> served+clean heights + std::map fall_closed_by_class; // class -> heights the arm did NOT serve + std::map divergences_by_class; + std::map divergences_by_field; + uint64_t total_compared{0}; // served heights that reached the compare + uint64_t total_clean{0}; + uint64_t total_divergent{0}; + uint64_t proposal_accepted{0}; + uint64_t proposal_rejected{0}; + uint64_t consecutive_clean{0}; + uint64_t reorg_covered{0}; + uint64_t served_normal{0}; // normal-class heights the arm served + uint64_t eligible_normal{0}; // normal-class heights observed (served + fall-closed) + uint64_t void_samples{0}; + uint32_t last_height{0}; + bool revoked{false}; + std::string revoked_reason; + // Ledger identity (streak-invalidating on change). + std::string key_commit, key_dashd_version, key_net; + uint32_t key_comparator{0}; + + void note_fall_closed(uint32_t height, const std::set& classes) { + last_height = height; + for (auto c : classes) fall_closed_by_class[height_class_name(c)]++; + if (classes.count(HeightClass::Normal)) eligible_normal++; + } + void note_void(uint32_t height) { void_samples++; last_height = height; } + + /// Record a SERVED + COMPARED height. `counted_fields` are the graduation- + /// breaking divergences; `proposal_ok`==true only when dashd ACCEPTED. + void record_served(uint32_t height, const std::set& classes, + bool proposal_ok, + const std::vector& counted_fields) { + total_compared++; + last_height = height; + proposal_ok ? proposal_accepted++ : proposal_rejected++; + if (classes.count(HeightClass::Normal)) { eligible_normal++; served_normal++; } + const bool clean = proposal_ok && counted_fields.empty(); + if (clean) { + total_clean++; + consecutive_clean++; + for (auto c : classes) { + coverage[height_class_name(c)]++; + if (c == HeightClass::Reorg) reorg_covered++; + } + } else { + total_divergent++; + consecutive_clean = 0; + for (auto c : classes) divergences_by_class[height_class_name(c)]++; + for (const auto& f : counted_fields) divergences_by_field[f]++; + if (!proposal_ok) divergences_by_field["dashd_proposal_rejected"]++; + } + } + + double serve_rate() const { + return eligible_normal ? static_cast(served_normal) / eligible_normal : 0.0; + } + + bool is_graduated(const GraduationConfig& cfg) const { + if (revoked) return false; + if (consecutive_clean < cfg.consecutive_clean_target) return false; + if (serve_rate() < cfg.serve_rate_floor) return false; + for (auto c : graduation_required_classes()) { + auto it = coverage.find(height_class_name(c)); + if (it == coverage.end() || it->second < cfg.per_class_coverage_target) + return false; + } + return reorg_covered >= 1; + } + + nlohmann::json verdict_json(const GraduationConfig& cfg) const { + nlohmann::json j; + j["verdict"] = is_graduated(cfg) ? "GRADUATED" : "NOT-GRADUATED"; + if (revoked) { j["revoked"] = true; j["revoked_reason"] = revoked_reason; } + j["scope"] = "chain-state equivalence over the SERVED domain; network-" + "layer independence (CoinPeerManager) is a SEPARATE gate"; + j["consecutive_clean"] = consecutive_clean; + j["consecutive_clean_target"] = cfg.consecutive_clean_target; + j["serve_rate"] = serve_rate(); + j["serve_rate_floor"] = cfg.serve_rate_floor; + nlohmann::json need = nlohmann::json::object(); + if (consecutive_clean < cfg.consecutive_clean_target) + need["consecutive_clean"] = cfg.consecutive_clean_target - consecutive_clean; + if (serve_rate() < cfg.serve_rate_floor) need["serve_rate"] = cfg.serve_rate_floor; + // Per-class served coverage still-needed + explicit fall-closed declaration. + nlohmann::json served_gaps = nlohmann::json::object(); + for (auto c : graduation_required_classes()) { + const std::string n = height_class_name(c); + uint64_t have = 0; auto it = coverage.find(n); + if (it != coverage.end()) have = it->second; + if (have < cfg.per_class_coverage_target) + need[n] = cfg.per_class_coverage_target - have; + uint64_t fc = 0; auto fit = fall_closed_by_class.find(n); + if (fit != fall_closed_by_class.end()) fc = fit->second; + if (have == 0 && fc > 0) + served_gaps[n] = "NEVER served daemonlessly (" + std::to_string(fc) + + " fall-closed) — residual serve-gap; disabling dashd " + "leaves this class with no fallback"; + } + if (reorg_covered < 1) need["reorg"] = 1; + j["still_needed"] = need; + j["residual_serve_gaps"] = served_gaps; + return j; + } + + nlohmann::json to_json() const { + nlohmann::json j; + j["total_compared"] = total_compared; j["total_clean"] = total_clean; + j["total_divergent"] = total_divergent; + j["proposal_accepted"] = proposal_accepted; j["proposal_rejected"] = proposal_rejected; + j["consecutive_clean"] = consecutive_clean; j["reorg_covered"] = reorg_covered; + j["served_normal"] = served_normal; j["eligible_normal"] = eligible_normal; + j["void_samples"] = void_samples; j["last_height"] = last_height; + j["revoked"] = revoked; j["revoked_reason"] = revoked_reason; + j["coverage"] = coverage; j["fall_closed_by_class"] = fall_closed_by_class; + j["divergences_by_class"] = divergences_by_class; + j["divergences_by_field"] = divergences_by_field; + j["key"] = {{"commit", key_commit}, {"comparator", key_comparator}, + {"dashd_version", key_dashd_version}, {"net", key_net}}; + return j; + } + void from_json(const nlohmann::json& j) { + auto g = [&](const char* k, uint64_t& v) { + if (j.contains(k) && j[k].is_number_unsigned()) v = j[k].get(); }; + g("total_compared", total_compared); g("total_clean", total_clean); + g("total_divergent", total_divergent); g("proposal_accepted", proposal_accepted); + g("proposal_rejected", proposal_rejected); g("consecutive_clean", consecutive_clean); + g("reorg_covered", reorg_covered); g("served_normal", served_normal); + g("eligible_normal", eligible_normal); g("void_samples", void_samples); + if (j.contains("last_height") && j["last_height"].is_number_unsigned()) + last_height = j["last_height"].get(); + if (j.contains("revoked")) revoked = j["revoked"].get(); + if (j.contains("revoked_reason")) revoked_reason = j["revoked_reason"].get(); + auto lm = [&](const char* k, std::map& m) { + if (j.contains(k) && j[k].is_object()) + for (auto it = j[k].begin(); it != j[k].end(); ++it) + m[it.key()] = it.value().get(); }; + lm("coverage", coverage); lm("fall_closed_by_class", fall_closed_by_class); + lm("divergences_by_class", divergences_by_class); + lm("divergences_by_field", divergences_by_field); + if (j.contains("key")) { + const auto& k = j["key"]; + if (k.contains("commit")) key_commit = k["commit"].get(); + if (k.contains("comparator")) key_comparator = k["comparator"].get(); + if (k.contains("dashd_version")) key_dashd_version = k["dashd_version"].get(); + if (k.contains("net")) key_net = k["net"].get(); + } + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Runtime driver. +// ───────────────────────────────────────────────────────────────────────────── +class EmbeddedOracleShadow { +public: + struct Config { + bool testnet{true}; + GraduationConfig grad; + std::string divergence_ledger_path; // JSONL append log + std::string graduation_state_path; // JSON snapshot (load on start) + uint32_t post_restart_cold_window{2}; + std::string c2pool_commit; // ledger identity + std::string dashd_version; // ledger identity (best-effort) + }; + // Assembles + proposes the embedded block to dashd; bound in main_dash.cpp + // (has the coinbase builders + RPC). Unbound ⇒ proposal leg unavailable ⇒ + // NO served height can be CLEAN (condition 2: proposal is required). + using ProposalFn = std::function; + // Optional: fetch the CONNECTED block N-1's committed creditPoolBalance + // (dashd getblock verbosity 2 -> tx[0].cbTx.creditPoolBalance) to use as the + // creditPool INVARIANT base, instead of the previous *template's* projected + // value (review nit d — the actually-connected block can differ from the + // template on real asset-lock chains -> fewer false VIOLs). Bound in + // main_dash.cpp; when unbound the invariant falls back to the previous + // compared template value with contiguity. Returns nullopt on any failure. + using BaseCreditPoolFn = std::function(const uint256& prev_hash)>; + + EmbeddedOracleShadow(const NodeCoinState& coin_state, + std::function dashd_gbt, + ProposalFn proposal_fn, Config cfg, + BaseCreditPoolFn base_credit_pool_fn = {}) + : coin_state_(coin_state) + , dashd_gbt_(std::move(dashd_gbt)) + , proposal_fn_(std::move(proposal_fn)) + , base_credit_pool_fn_(std::move(base_credit_pool_fn)) + , cfg_(std::move(cfg)) + , periodicity_(NetPeriodicity::for_net(cfg_.testnet)) + { + load_graduation_state(); + // A fallback/dev/no-git commit string (empty, "dev", or the CMake + // "0.1.0-alpha" default) can be shared across DIFFERENT binaries, so it + // must NEVER inherit a prior streak (review nit c). `--dirty` in the git + // describe (CMakeLists) covers tag-exact dirty rebuilds; this covers the + // no-git fallbacks. + const bool fallback_commit = cfg_.c2pool_commit.empty() + || cfg_.c2pool_commit == "dev" || cfg_.c2pool_commit == "0.1.0-alpha"; + // Streak-invalidation on identity change (§6 cond. 4). + if (fallback_commit + || ledger_.key_comparator != kOracleComparatorVersion + || ledger_.key_commit != cfg_.c2pool_commit + || ledger_.key_dashd_version != cfg_.dashd_version + || ledger_.key_net != (cfg_.testnet ? "testnet" : "mainnet")) { + if (ledger_.total_compared != 0) + LOG_WARNING << "[EMB-ORACLE] ledger identity changed (commit/comparator/" + "dashd/net) — RESETTING clean streak (a new binary must " + "not inherit an old streak)"; + GraduationLedger fresh; + fresh.key_commit = cfg_.c2pool_commit; + fresh.key_comparator = kOracleComparatorVersion; + fresh.key_dashd_version = cfg_.dashd_version; + fresh.key_net = cfg_.testnet ? "testnet" : "mainnet"; + ledger_ = std::move(fresh); + } + LOG_INFO << "[EMB-ORACLE] shadow constructed (net=" + << (cfg_.testnet ? "testnet" : "mainnet") + << " N=" << cfg_.grad.consecutive_clean_target + << " K=" << cfg_.grad.per_class_coverage_target + << " proposal=" << (proposal_fn_ ? "wired(VERDICT)" : "UNSET(field-compare only)") + << " comparator_v=" << kOracleComparatorVersion << ")"; + // Async worker: the per-block compare runs 2-3 dashd RPCs + file writes; + // running it INLINE on the tip-dispatch thread would let a slow/hung + // dashd stall the tip event chain (review nit e). The tip thread only + // ENQUEUES (coalescing to the latest tip); this thread does the work. + worker_ = std::thread([this] { worker_loop(); }); + } + + ~EmbeddedOracleShadow() { + { std::lock_guard lk(q_mu_); stop_ = true; } + q_cv_.notify_all(); + if (worker_.joinable()) worker_.join(); + } + + EmbeddedOracleShadow(const EmbeddedOracleShadow&) = delete; + EmbeddedOracleShadow& operator=(const EmbeddedOracleShadow&) = delete; + + /// Tip-thread entry: ENQUEUE ONLY (never blocks the caller). The worker + /// thread runs the compare so a hung dashd cannot perturb tip processing. + /// Coalescing: if a tip is already pending unprocessed, it is replaced by + /// the newest (a shadow samples tips; a skipped intermediate is a coverage + /// gap, never a wrong count — and on the ~2.6 min interval the worker keeps + /// up with room to spare). + void on_new_tip(uint32_t next_height, const uint256& next_prev_hash) { + { + std::lock_guard lk(q_mu_); + pending_ = std::make_pair(next_height, next_prev_hash); + } + q_cv_.notify_one(); + } + + /// The actual per-block shadow-compare (worker thread). OBSERVE-ONLY. All + /// heavy RPCs run here BEFORE mu_ is taken, so /embedded_oracle (stats_json) + /// never blocks on a dashd RPC. + void process_tip(uint32_t next_height, const uint256& next_prev_hash) { + if (!dashd_gbt_) { LOG_WARNING << "[EMB-ORACLE] no dashd oracle bound"; return; } + + // ── Phase 1: ALL heavy RPCs, NO lock ──────────────────────────────── + // Runs on the worker thread; taking mu_ here would make /embedded_oracle + // (stats_json) block on a dashd RPC. So gather everything unlocked, then + // take mu_ only for the fast analysis + ledger phase. + WorkSelection sel = coin_state_.select_work(dashd_gbt_); + const bool is_embedded = (sel.source == WorkSource::Embedded); + DashWorkData emb, dashd; + bool dashd_rpc_ok = false, aligned = false; + std::optional base_cp; // creditPool(N-1) from the CONNECTED block + bool base_from_connected = false; + ProposalResult pr; + + if (is_embedded) { + emb = std::move(sel.work); + try { dashd = dashd_gbt_(); dashd_rpc_ok = true; } + catch (const std::exception& ex) { + LOG_WARNING << "[EMB-ORACLE] dashd oracle threw: " << ex.what() << " — void"; + } + if (dashd_rpc_ok) { + aligned = (emb.m_height == dashd.m_height + && emb.m_previous_block == dashd.m_previous_block); + if (aligned) { + // Fix (d): base the creditPool invariant on the ACTUALLY- + // connected block N-1's committed value, not dashd's previous + // *template* projection (which can differ on real asset-lock + // chains -> false VIOLs). Unlocked RPC; nullopt on failure. + if (base_credit_pool_fn_) { + try { base_cp = base_credit_pool_fn_(emb.m_previous_block); } + catch (...) { base_cp.reset(); } + base_from_connected = base_cp.has_value(); + } + // Proposal VERDICT (unlocked RPC). + if (proposal_fn_) { + try { pr = proposal_fn_(emb); } + catch (const std::exception& ex) { + pr.attempted = true; pr.accepted = false; + pr.reason = std::string("throw:") + ex.what(); + } + } + } + } + } + + // ── Phase 2: analysis + ledger, UNDER mu_ (pure CPU + brief file I/O) ─ + std::lock_guard lk(mu_); + const bool reorg = detect_reorg(next_height, next_prev_hash); + + if (!is_embedded) { + embedded_armed_ = false; + ledger_.note_fall_closed(next_height, classify(next_height, {}, false, reorg, false)); + LOG_INFO << "[EMB-ORACLE] h=" << next_height + << " FALL-CLOSED (dashd-fallback) — recorded serve-gap"; + save_graduation_state(); + remember_tip(next_height, next_prev_hash); + return; + } + if (!dashd_rpc_ok) { + ledger_.note_void(next_height); remember_tip(next_height, next_prev_hash); return; + } + if (!aligned) { + LOG_INFO << "[EMB-ORACLE] tip-skew emb(h=" << emb.m_height << ",prev=" + << emb.m_previous_block.GetHex().substr(0, 12) << ") dashd(h=" + << dashd.m_height << ",prev=" << dashd.m_previous_block.GetHex().substr(0, 12) + << ") — VOID"; + ledger_.note_void(next_height); remember_tip(next_height, next_prev_hash); return; + } + + vendor::CCbTx ec, dc; + const bool eok = vendor::parse_cbtx(emb.m_coinbase_payload, ec); + const bool dok = vendor::parse_cbtx(dashd.m_coinbase_payload, dc); + + // Chain-drift sentinel (§6 cond. 5): an unknown CbTx version from dashd + // (or a quorum-tail parse failure) auto-REVOKES graduation. + if (dok && dc.nVersion > kMaxKnownCbTxVersion) revoke( + "unknown dashd CbTx nVersion=" + std::to_string(dc.nVersion)); + if (!dok && !dashd.m_coinbase_payload.empty()) revoke("dashd CbTx payload parse failure"); + + SideContext e; e.wd = &emb; e.cbtx = &ec; e.cbtx_ok = eok; + e.own_fees = sum_fees(emb.m_tx_fees); e.platform_reward = platform_burn_amount(emb.m_packed_payments); + e.empty_template = emb.m_tx_hashes.empty(); + SideContext d; d.wd = &dashd; d.cbtx = &dc; d.cbtx_ok = dok; + d.own_fees = sum_fees(dashd.m_tx_fees); d.platform_reward = platform_burn_amount(dashd.m_packed_payments); + d.empty_template = dashd.m_tx_hashes.empty(); + + const bool dkg = periodicity_.is_dkg_window(emb.m_height); + // Prefer the connected-block base (contiguous by construction); else the + // previous compared template value with the height-contiguity guard. + std::optional prev_cp; + bool contiguous; + if (base_from_connected) { prev_cp = base_cp; contiguous = true; } + else { contiguous = (have_prev_ && prev_compared_height_ + 1 == emb.m_height); + if (have_prev_) prev_cp = prev_credit_pool_; } + + // Fix (1): NonEmptyTemplate is the class that must force-sample the + // EMBEDDED arm's tx-selection / fee-split / special-tx path. Classify it + // on the EMBEDDED side ONLY — dashd's near-always-non-empty template + // would otherwise tick the class while the embedded arm serves empty + // templates, accruing coverage without ever exercising the path the + // class exists to force (false-graduation vector). + std::set classes = classify(emb.m_height, dc, dok, reorg, + !e.empty_template); + + auto fields = compare_templates(e, d, prev_cp, dkg, contiguous); + + // Without a proposal leg, condition 2 forbids certifying — treat as + // not-accepted so the height cannot count clean. + const bool proposal_ok = pr.attempted && pr.accepted; + + // Fixes (a)+(b): a proposal reason that is a DASHD-SIDE RPC blip + // ("rpc-error:") or a proposal-mode TIP RACE ("inconclusive" / + // "not-best-prevblk" / "stale") is NOT an embedded consensus fault — + // VOID the sample (don't count, don't reset the streak) rather than + // false-REJECT. (The bounded settle-resample is a scoped follow-up.) + if (pr.attempted && !pr.accepted && is_void_proposal_reason(pr.reason)) { + LOG_INFO << "[EMB-ORACLE] h=" << emb.m_height + << " proposal VOID (dashd-side/tip-race): " << pr.reason; + ledger_.note_void(emb.m_height); + // still advance the credit-pool / quorum trackers for continuity + if (dok) prev_credit_pool_ = dc.creditPoolBalance; + prev_compared_height_ = emb.m_height; have_prev_ = dok; + remember_tip(next_height, next_prev_hash); + return; + } + + std::vector counted; + for (const auto& f : fields) if (f.counts) counted.push_back(f.field); + + const HeightClass primary = primary_class(classes); + if (!proposal_ok) { + if (pr.attempted) + LOG_WARNING << "[EMB-ORACLE] DIVERGENCE height=" << emb.m_height + << " class=" << height_class_name(primary) + << " field=dashd_proposal verdict=REJECTED reason=" << pr.reason; + else + LOG_WARNING << "[EMB-ORACLE] height=" << emb.m_height + << " proposal leg UNAVAILABLE — cannot certify this height " + "(field-compare is diagnosis only)"; + } + for (const auto& f : fields) { + if (f.match || !f.counts) continue; + LOG_WARNING << "[EMB-ORACLE] DIVERGENCE height=" << emb.m_height + << " class=" << height_class_name(primary) + << " field=" << f.field << " regime=" << regime_name(f.regime) + << " embedded=" << f.embedded << " dashd=" << f.dashd; + } + if (proposal_ok && counted.empty()) + LOG_INFO << "[EMB-ORACLE] AGREE height=" << emb.m_height + << " class=" << height_class_name(primary) + << " (proposal ACCEPTED; all equality/invariant fields match; base_cp=" + << (base_from_connected ? "connected" : "template") << ")"; + + ledger_.record_served(emb.m_height, classes, proposal_ok, counted); + append_divergence_ledger(emb.m_height, classes, primary, fields, pr, counted); + save_graduation_state(); + + // Advance trackers. + if (!embedded_armed_) { embedded_armed_ = true; cold_heights_seen_ = 0; } + cold_heights_seen_++; + if (dok) { prev_credit_pool_ = dc.creditPoolBalance; } + prev_compared_height_ = emb.m_height; have_prev_ = dok; + remember_tip(next_height, next_prev_hash); + } + + nlohmann::json stats_json() const { + std::lock_guard lk(mu_); + nlohmann::json j = ledger_.to_json(); + j["graduation"] = ledger_.verdict_json(cfg_.grad); + j["net"] = cfg_.testnet ? "testnet" : "mainnet"; + j["mode"] = "embedded-oracle-shadow"; + j["proposal_leg"] = proposal_fn_ ? "wired" : "unset"; + return j; + } + +private: + // Worker loop: drains the coalescing tip queue so the compare (2-3 dashd + // RPCs + file writes) never runs on the tip-dispatch thread. + void worker_loop() { + for (;;) { + std::pair job; + { + std::unique_lock lk(q_mu_); + q_cv_.wait(lk, [this] { return stop_ || pending_.has_value(); }); + if (stop_ && !pending_.has_value()) return; + job = *pending_; + pending_.reset(); + } + try { process_tip(job.first, job.second); } + catch (const std::exception& e) { + LOG_WARNING << "[EMB-ORACLE] worker exception: " << e.what(); + } + } + } + + // A proposal reject reason that is a dashd-side RPC blip or a proposal-mode + // TIP RACE — not an embedded consensus fault. VOID it (don't count / reset). + static bool is_void_proposal_reason(const std::string& r) { + auto has = [&](const char* s) { return r.find(s) != std::string::npos; }; + return has("rpc-error:") || has("inconclusive") + || has("prevblk") || has("not-best") || has("stale"); + } + + void revoke(const std::string& reason) { + if (ledger_.revoked) return; + ledger_.revoked = true; ledger_.revoked_reason = reason; + ledger_.consecutive_clean = 0; + LOG_WARNING << "[EMB-ORACLE] GRADUATION REVOKED (chain-drift sentinel): " + << reason << " — arm demoted to fail-closed; re-shadow required"; + } + void remember_tip(uint32_t h, const uint256& prev) { last_tip_height_ = h; last_tip_prev_ = prev; } + bool detect_reorg(uint32_t next_height, const uint256& next_prev_hash) const { + if (last_tip_height_ == 0) return false; + // Linear extension: this tip builds on the last observed tip's block. + // A prev-hash that is NOT the last tip's own hash lineage, or a non- + // increasing height, is a reorg/re-tip. (We approximate lineage by the + // recorded prev-hash; a same-height re-tip has next_height<=last.) + if (next_height <= last_tip_height_) return true; + (void)next_prev_hash; + return false; + } + std::set classify(uint32_t height, const vendor::CCbTx& dcbtx, + bool dok, bool reorg, bool non_empty) { + std::set c; + if (periodicity_.is_superblock(height)) c.insert(HeightClass::Superblock); + if (periodicity_.is_dkg_window(height)) c.insert(HeightClass::DkgWindow); + if (reorg) c.insert(HeightClass::Reorg); + if (non_empty) c.insert(HeightClass::NonEmptyTemplate); + if (!embedded_armed_) c.insert(HeightClass::PostRestartCold); + else if (cold_heights_seen_ < cfg_.post_restart_cold_window) + c.insert(HeightClass::PostRestartCold); + if (dok && have_prev_ && dcbtx.creditPoolBalance != prev_credit_pool_) + c.insert(HeightClass::CreditPoolTransition); + if (dok && have_prev_ && dcbtx.merkleRootQuorums != prev_quorum_root_) + c.insert(HeightClass::QuorumRotation); + if (dok) prev_quorum_root_ = dcbtx.merkleRootQuorums; + if (c.empty()) c.insert(HeightClass::Normal); + return c; + } + static HeightClass primary_class(const std::set& classes) { + for (auto c : {HeightClass::Reorg, HeightClass::Superblock, + HeightClass::CreditPoolTransition, HeightClass::QuorumRotation, + HeightClass::DkgWindow, HeightClass::NonEmptyTemplate, + HeightClass::PostRestartCold}) + if (classes.count(c)) return c; + return HeightClass::Normal; + } + void append_divergence_ledger(uint32_t height, const std::set& classes, + HeightClass primary, const std::vector& fields, + const ProposalResult& pr, const std::vector& counted) { + if (cfg_.divergence_ledger_path.empty()) return; + nlohmann::json row; + row["ts"] = static_cast(std::time(nullptr)); + row["height"] = height; row["primary_class"] = height_class_name(primary); + nlohmann::json cls = nlohmann::json::array(); + for (auto c : classes) cls.push_back(height_class_name(c)); + row["classes"] = cls; + row["proposal"] = {{"attempted", pr.attempted}, {"accepted", pr.accepted}, + {"reason", pr.reason}}; + row["clean"] = pr.attempted && pr.accepted && counted.empty(); + row["counted_divergences"] = counted; + nlohmann::json mm = nlohmann::json::array(); + for (const auto& f : fields) if (!f.match) + mm.push_back({{"field", f.field}, {"regime", regime_name(f.regime)}, + {"counts", f.counts}, {"embedded", f.embedded}, {"dashd", f.dashd}}); + if (!mm.empty()) row["mismatched_fields"] = mm; + std::ofstream f(cfg_.divergence_ledger_path, std::ios::app); + if (f) f << row.dump() << "\n"; + } + void save_graduation_state() { + if (cfg_.graduation_state_path.empty()) return; + std::ofstream f(cfg_.graduation_state_path, std::ios::trunc); + if (f) f << ledger_.to_json().dump(2) << "\n"; + } + void load_graduation_state() { + if (cfg_.graduation_state_path.empty()) return; + std::ifstream f(cfg_.graduation_state_path); + if (!f) return; + try { nlohmann::json j; f >> j; ledger_.from_json(j); } + catch (const std::exception& e) { + LOG_WARNING << "[EMB-ORACLE] could not load graduation state: " << e.what(); + } + } + + const NodeCoinState& coin_state_; + std::function dashd_gbt_; + ProposalFn proposal_fn_; + BaseCreditPoolFn base_credit_pool_fn_; + Config cfg_; + NetPeriodicity periodicity_; + + mutable std::mutex mu_; // guards ledger_ (worker writes, web thread reads) + GraduationLedger ledger_; + + // Coalescing tip queue + worker thread (decouple compare from tip thread). + std::thread worker_; + std::mutex q_mu_; + std::condition_variable q_cv_; + std::optional> pending_; + bool stop_{false}; + + // Trackers below are WORKER-THREAD-EXCLUSIVE (process_tip only) — no lock. + bool embedded_armed_{false}; + uint32_t cold_heights_seen_{0}; + bool have_prev_{false}; + int64_t prev_credit_pool_{0}; + uint256 prev_quorum_root_; + uint32_t prev_compared_height_{0}; + uint32_t last_tip_height_{0}; + uint256 last_tip_prev_; +}; + +} // namespace coin +} // namespace dash diff --git a/src/impl/dash/coin/rpc.cpp b/src/impl/dash/coin/rpc.cpp index 04a415f58..e1920a7c5 100644 --- a/src/impl/dash/coin/rpc.cpp +++ b/src/impl/dash/coin/rpc.cpp @@ -9,11 +9,6 @@ #include // ParseHex / HexStr -#ifndef _WIN32 -#include // setsockopt SO_SND/RCVTIMEO (Send() deadline) -#include // struct timeval -#endif - namespace dash { @@ -80,10 +75,6 @@ void NodeRPC::connect(NetService address, std::string userpass) { try { - // Bound the synchronous Send() I/O BEFORE check() - // runs its first RPC, so a wedged dashd cannot hang - // the caller (io thread or background rpc_pool). - apply_socket_timeouts(); if (check()) { m_connected = true; @@ -156,59 +147,11 @@ void NodeRPC::sync_reconnect() LOG_WARNING << "CoindRPC sync_reconnect connect failed: " << ec.message(); return; } - apply_socket_timeouts(); // re-arm the Send() deadline on the fresh socket LOG_INFO << "CoindRPC reconnected (sync)"; } -void NodeRPC::close_stream() -{ - // Same teardown idiom as sync_reconnect()/the destructor. m_connected is - // cleared so the next Send() write-fails into a clean sync_reconnect(). - beast::error_code ec; - m_stream.socket().shutdown(io::ip::tcp::socket::shutdown_both, ec); - m_stream.close(); - m_connected = false; -} - -void NodeRPC::apply_socket_timeouts() -{ - // Force the socket back to BLOCKING mode, then set kernel send/receive - // timeouts. async_connect (connect()) leaves asio's non_blocking flag set; - // under it a synchronous recv returns EAGAIN immediately and SO_RCVTIMEO is - // a no-op. In blocking mode the sync http::write/http::read inside Send() - // do true blocking send()/recv() which the kernel bounds by SO_SND/RCVTIMEO, - // returning an error after RPC_IO_TIMEOUT_SECONDS instead of hanging on a - // wedged dashd. (beast tcp_stream::expires_after cannot be used: it only - // governs ASYNC ops -- the sync path calls socket.read_some() directly.) - // Per-operation inactivity timeout: a large-but-steadily-arriving GBT does - // NOT trip it; only a genuine stall does. POSIX (SO_*TIMEO takes a timeval); - // guarded off Windows (no /timeval, and Winsock SO_RCVTIMEO takes - // a DWORD) -- the DASH deploy target is Linux, and macOS keeps the real - // deadline. On Windows this is a no-op (pre-PR behaviour: no deadline). -#ifndef _WIN32 - if (!m_stream.socket().is_open()) - return; - boost::system::error_code ec; - m_stream.socket().non_blocking(false, ec); // guarantee SO_*TIMEO applies - if (ec) - LOG_WARNING << "CoindRPC: could not set blocking mode: " << ec.message(); - struct timeval tv; - tv.tv_sec = RPC_IO_TIMEOUT_SECONDS; - tv.tv_usec = 0; - const int fd = m_stream.socket().native_handle(); - ::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); - ::setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); -#endif -} - std::string NodeRPC::Send(const std::string &request) { - // io-thread-decouple / LTC parity (impl/ltc/coin/rpc.cpp:136): serialize the - // whole write+read+reconnect. getwork()/submit_block_hex (io thread) and the - // tip-poll's getbestblockhash (background rpc_pool thread) both drive this - // single shared beast client; m_http_request/m_stream are not thread-safe. - // The socket deadline (apply_socket_timeouts) bounds how long the lock is held. - std::lock_guard _rpc_lock(m_rpc_mutex); // Retry once after synchronous reconnect on write/read failure for (int attempt = 0; attempt < 2; ++attempt) { @@ -226,10 +169,6 @@ std::string NodeRPC::Send(const std::string &request) sync_reconnect(); continue; } - // Deadline-desync guard (see the read-fail path below): tear the - // socket down before giving up so a partially-written request can - // never be answered into a LATER Send() on a reused connection. - close_stream(); return {}; } @@ -248,17 +187,6 @@ std::string NodeRPC::Send(const std::string &request) sync_reconnect(); continue; } - // Deadline-desync guard (SO_RCVTIMEO deadline hazard): this final - // attempt has already WRITTEN the request into dashd but its response - // is unread (read timed out). jsonrpccxx reuses the constant id - // "curltest" and never validates response ids, so reusing this open - // connection would make the NEXT Send() read THIS request's late - // response -> permanent off-by-one desync (a GBT would parse a - // getbestblockhash string -> zeroed work -> a stuck "honest set-gap" - // outage until the connection churns). Tear the socket down so the - // next Send() write-fails into a clean sync_reconnect(). Already - // under m_rpc_mutex. - close_stream(); return {}; } @@ -501,6 +429,27 @@ nlohmann::json NodeRPC::getblocktemplate(std::vector rules) return CallAPIMethod("getblocktemplate", {make_gbt_request(rules)}); } +std::string NodeRPC::propose_block_hex(const std::string& block_hex) +{ + // getblocktemplate {mode:proposal, data:} runs dashd's + // TestBlockValidity on the exact block and returns null on ACCEPT or a + // reject-reason string. This is the ORACLE-SHADOW authoritative VERDICT + // (mempool-independent). Never throws to the caller: an RPC-layer error is + // surfaced as a reason string so a transient RPC blip is not read as a + // consensus rejection. + try { + nlohmann::json req; + req["mode"] = "proposal"; + req["data"] = block_hex; + auto res = CallAPIMethod("getblocktemplate", {req}); + if (res.is_null()) return {}; // ACCEPTED + if (res.is_string()) return res.get(); + return res.dump(); + } catch (const std::exception& e) { + return std::string("rpc-error:") + e.what(); + } +} + nlohmann::json NodeRPC::getnetworkinfo() { return CallAPIMethod("getnetworkinfo"); diff --git a/src/impl/dash/coin/rpc.hpp b/src/impl/dash/coin/rpc.hpp index f1d2742f1..348b617cf 100644 --- a/src/impl/dash/coin/rpc.hpp +++ b/src/impl/dash/coin/rpc.hpp @@ -42,7 +42,6 @@ #include #include -#include #include #include @@ -77,17 +76,6 @@ class NodeRPC : public jsonrpccxx::IClientConnector beast::tcp_stream m_stream; boost::asio::ip::tcp::resolver m_resolver; http::request m_http_request; - // Serializes Send() (verbatim LTC parity, impl/ltc/coin/rpc.hpp:43-47). - // io-thread-decouple drives this ONE shared client from TWO threads: the - // stratum io_context thread (getwork() during a template re-source, and ARM - // B submit_block_hex on a won block) AND the background rpc_pool thread (the - // tip poll's getbestblockhash + the single-flight GBT refresh). m_http_request - // + m_stream are NOT thread-safe: without this lock one thread's - // prepare_payload() frees the Content-Length field element mid-write on the - // other -> UAF / interleaved HTTP frames / cross-wired responses (a garbled - // submitblock = lost block on the paying node). The lock also serializes the - // sync_reconnect() retry path, which is only ever entered from inside Send(). - std::mutex m_rpc_mutex; std::unique_ptr m_auth; jsonrpccxx::JsonRpcClient m_client; @@ -108,28 +96,6 @@ class NodeRPC : public jsonrpccxx::IClientConnector std::string Send(const std::string &request) override; nlohmann::json CallAPIMethod(const std::string& method, const jsonrpccxx::positional_parameter& params = {}); - // io-thread-decouple: a REAL deadline on the synchronous Send() I/O so a - // wedged-but-connected dashd (socket open, no bytes) cannot hang the caller - // forever -- which on the background rpc_pool thread would wedge - // template_refresh_inflight_ true (permanent set-gap after the next tip - // change), stop the tip-poll re-arming, AND block rpc_pool->join() at - // shutdown (SIGKILL needed). beast's SYNCHRONOUS read_some/write_some call - // socket.read_some() directly and do NOT honour tcp_stream::expires_after - // (that timer only fires for ASYNC ops -- basic_stream.hpp), so the robust - // bound is a kernel SO_RCV/SNDTIMEO on the socket forced back to BLOCKING - // mode (async_connect leaves asio's non_blocking flag set, under which - // SO_*TIMEO is a no-op). apply_socket_timeouts() does both; it is called - // after every successful (re)connect. Linux release target. - static constexpr int RPC_IO_TIMEOUT_SECONDS = 12; - void apply_socket_timeouts(); - // Tear down m_stream (sync_reconnect idiom). Called on a FINAL-attempt Send() - // failure so a half-written / unread request can never be answered into a - // later Send() on a reused connection -- the deadline-desync guard (the - // constant "curltest" id + jsonrpccxx not validating response ids makes any - // reused-connection off-by-one a permanent set-gap outage). Caller holds - // m_rpc_mutex. - void close_stream(); - public: NodeRPC(io::io_context* context, dash::interfaces::Node* coin, bool testnet); ~NodeRPC(); @@ -157,6 +123,11 @@ class NodeRPC : public jsonrpccxx::IClientConnector // DASH: X11, no segwit -> rules are passed through plainly; no separate algo // param (the DGB Scrypt divergence does NOT apply). See rpc_request.hpp. nlohmann::json getblocktemplate(std::vector rules = {}); + + // ORACLE-SHADOW verdict: getblocktemplate{mode:proposal} of a fully- + // assembled block. Returns "" on ACCEPT (dashd TestBlockValidity passed) or + // the reject reason. Mempool-independent; never throws (RPC blip -> reason). + std::string propose_block_hex(const std::string& block_hex); nlohmann::json getnetworkinfo(); nlohmann::json getblockchaininfo(); nlohmann::json getmininginfo(); diff --git a/src/impl/dash/share.hpp b/src/impl/dash/share.hpp index 760d72448..30f1e6470 100644 --- a/src/impl/dash/share.hpp +++ b/src/impl/dash/share.hpp @@ -70,95 +70,4 @@ struct DashShare : chain::BaseShare NetService peer_addr; }; -// ═══════════════════════════════════════════════════════════════════════════ -// DASH v36 share (wire-type 36) — PHASE A: DEFINED + PARSEABLE, DORMANT. -// -// Byte-standardized to the cross-coin v36 share shape (the v36-standardize-for- -// v37 goal: uniform structs). The STANDARDIZED PREFIX (min_header .. message_data) -// is byte-for-byte the NON-SEGWIT v36 layout — i.e. the BCH v36 MergedMiningShare -// shape (src/impl/bch/share.hpp), which is the LTC/DGB v36 layout MINUS the -// Optional(segwit_data) slot. BCH is the reference here (not LTC/DGB directly) -// because DASH, like BCH, is non-segwit: BCH's SEGWIT_ACTIVATION_VERSION==0 makes -// is_segwit_activated(36) false, so a non-segwit v36 coin OMITS segwit_data. This -// is the ESTABLISHED cross-coin convention, NOT a DASH divergence. -// -// Field categories (operator 3-bucket / v36-standardize model): -// * SHARED / STANDARDIZED (Bucket-2, byte-identical cross-coin): min_header, -// prev_hash, coinbase, nonce, pubkey_hash + pubkey_type, subsidy(VarInt), -// donation, stale_info, desired_version(VarInt) [THE version-vote], the merged -// fields (merged_addresses / merged_coinbase_info / merged_payout_hash — held -// EMPTY/INERT since DASH is standalone X11 with no AuxPoW child, exactly as a -// non-merged ltc/dgb/bch coin populates them), far_share_hash, max_bits, bits, -// timestamp, absheight, abswork(AbsworkV36Format), ref_merkle_link, -// last_txout_nonce, hash_link(V36HashLinkType), merkle_link, message_data. -// * DASH-SPECIFIC SUFFIX (Bucket-3 / coin-specific, the FLAGGED divergence): -// coinbase_payload, payment_amount, packed_payments, coinbase_payload_outer. -// DASH masternode / DIP4-CbTx consensus data with NO cross-coin equivalent; -// required so Phase C can reconstruct+verify a real DASH coinbase from the -// share (a non-merged coin has no analogue). Appended AFTER the standardized -// message_data so the ENTIRE standardized prefix stays byte-identical to -// bch/ltc/dgb. See the design note + PR body for the review decision. -// -// The version-vote (m_desired_version) is what #774's AutoRatchet reads to -// activate; Phase A only DEFINES this type (dormant, unminted). current_share_version -// stays 16, so nothing mints a v36 share and the live 1700 accept path is -// byte-unchanged (Phase C flips activation via the ratchet, NOT here). -struct DashV36Share : chain::BaseShare -{ - // ── min_header (SmallBlockHeaderType — coin block header, standardized) ── - bitcoin_family::coin::SmallBlockHeaderType m_min_header; - - // ── share_data (STANDARDIZED v36) ── - // NOTE: m_prev_hash is inherited from chain::BaseShare. - BaseScript m_coinbase; // coinbase scriptSig (VarStr) - uint32_t m_nonce{0}; // share nonce - uint160 m_pubkey_hash; // v36 address (IntType(160)) - uint8_t m_pubkey_type{0}; // v36: 0=P2PKH, 1=P2WPKH, 2=P2SH (DASH always 0/P2PKH) - uint64_t m_subsidy{0}; // v36: VarInt-encoded - uint16_t m_donation{0}; // donation (bps) - StaleInfo m_stale_info{StaleInfo::none}; - uint64_t m_desired_version{36}; // THE version-vote (VarInt) - - // v36 merged-mining address entries — EMPTY/INERT on DASH (no AuxPoW child). - std::vector m_merged_addresses; - - // ── share_info (non-share_data, STANDARDIZED v36) ── - uint256 m_far_share_hash; - uint32_t m_max_bits{0}; - uint32_t m_bits{0}; - uint32_t m_timestamp{0}; - uint32_t m_absheight{0}; - uint128 m_abswork; // v36: AbsworkV36Format (VarInt low64) on the wire - - // v36 merged-mining coinbase verification — EMPTY/INERT on DASH. - std::vector m_merged_coinbase_info; - uint256 m_merged_payout_hash; // zero = none (INERT on DASH) - - // ── remaining STANDARDIZED v36 share fields ── - v36::MerkleLink m_ref_merkle_link; - uint64_t m_last_txout_nonce{0}; - v36::V36HashLinkType m_hash_link; // v36: state + extra_data(VarStr) + length(VarInt) - v36::MerkleLink m_merkle_link; - BaseScript m_message_data; // v36 messaging hook — EMPTY on DASH in Phase A (Phase B) - - // ── DASH-SPECIFIC SUFFIX (FLAGGED divergence — see struct header) ── - BaseScript m_coinbase_payload; // DIP3/DIP4 CBTX inner (PossiblyNone '', VarStr) - uint64_t m_payment_amount{0}; // total masternode payment amount - std::vector m_packed_payments; // masternode/superblock/platform - BaseScript m_coinbase_payload_outer; // outer coinbase_payload_data (appended to hash_link) - - // ── carried-but-UNSERIALIZED members (v36 wire omits tx_info; kept so a - // future promotion into the live variant matches DashShare's field - // surface for the shared generic-invoke call sites in node.cpp) ── - std::vector m_new_transaction_hashes; - std::vector m_transaction_hash_refs; - - // Ingestion metadata — not on the wire (parity with DashShare::peer_addr). - NetService peer_addr; - - DashV36Share() {} - DashV36Share(const uint256& hash, const uint256& prev_hash) - : chain::BaseShare(hash, prev_hash) {} -}; - } // namespace dash \ No newline at end of file diff --git a/src/impl/dash/share_chain.hpp b/src/impl/dash/share_chain.hpp index 33b225f75..d090b9459 100644 --- a/src/impl/dash/share_chain.hpp +++ b/src/impl/dash/share_chain.hpp @@ -11,7 +11,6 @@ #include #include #include -#include // SSOT: core::version_gate::is_v36_active #include @@ -58,17 +57,6 @@ struct DashFormatter template static void Read(StreamType& is, ShareT* share) { - // V36 shares (wire-type 36, dash::DashV36Share) use the standardized - // cross-coin v36 layout. Compile-time dispatch: the v16 body below is - // instantiated ONLY for DashShare (version 16), so it stays byte- - // unchanged; the v36 body is instantiated ONLY for DashV36Share. - if constexpr (core::version_gate::is_v36_active(ShareT::version)) - { - ReadV36(is, share); - return; - } - else - { // ── min_header ── is >> share->m_min_header; @@ -148,19 +136,11 @@ struct DashFormatter // ── coinbase_payload (outer) ── is >> share->m_coinbase_payload_outer; // PossiblyNone('', VarStr) - } // else (v16) } template static void Write(StreamType& os, const ShareT* share) { - if constexpr (core::version_gate::is_v36_active(ShareT::version)) - { - WriteV36(os, share); - return; - } - else - { os << share->m_min_header; os << share->m_prev_hash; os << share->m_coinbase; @@ -200,124 +180,6 @@ struct DashFormatter os << share->m_hash_link; os << share->m_merkle_link.m_branch; os << share->m_coinbase_payload_outer; - } // else (v16) - } - - // ═══════════════════════════════════════════════════════════════════════ - // V36 (wire-type 36) read/write — STANDARDIZED cross-coin non-segwit v36 - // layout (the bch/ltc/dgb MergedMiningShare shape MINUS segwit_data) plus - // the DASH-specific suffix. Field ORDER and ENCODINGS mirror the BCH v36 - // Formatter (src/impl/bch/share.hpp) byte-for-byte for the standardized - // prefix; the suffix (coinbase_payload / payment_amount / packed_payments / - // coinbase_payload_outer) rides AFTER message_data. Pinned by the byte- - // parity + round-trip KATs in test/test_dash_v36_share.cpp. - // ═══════════════════════════════════════════════════════════════════════ - template - static void ReadV36(StreamType& is, ShareT* share) - { - // ── min_header ── - is >> share->m_min_header; - - // ── share_data (standardized v36) ── - is >> share->m_prev_hash; // PossiblyNone(0, IntType(256)) - is >> share->m_coinbase; // VarStr - is >> share->m_nonce; // uint32 - is >> share->m_pubkey_hash; // v36 address: IntType(160) - is >> share->m_pubkey_type; // v36: IntType(8) - { uint64_t sub; ::Unserialize(is, VarInt(sub)); share->m_subsidy = sub; } // v36: VarInt - is >> share->m_donation; // uint16 - { uint8_t si; is >> si; share->m_stale_info = static_cast(si); } - { uint64_t dv; ::Unserialize(is, VarInt(dv)); share->m_desired_version = dv; } // version-vote - - // NO segwit_data (non-segwit coin — BCH convention). - - // v36 merged_addresses (empty/inert on DASH). - is >> share->m_merged_addresses; - - // NO tx_info (only serialized for version < 34). - - is >> share->m_far_share_hash; // PossiblyNone(0, IntType(256)) - is >> share->m_max_bits; // uint32 - is >> share->m_bits; // uint32 - is >> share->m_timestamp; // uint32 - is >> share->m_absheight; // uint32 - ::Unserialize(is, Using(share->m_abswork)); // v36: VarInt low64 - - // v36 merged_coinbase_info + merged_payout_hash (empty/zero-inert on DASH). - is >> share->m_merged_coinbase_info; - is >> share->m_merged_payout_hash; - - // ref_merkle_link (MERKLE_LINK_SMALL — index omitted). - { ParamPackStream ps{v36::MERKLE_LINK_SMALL, is}; ::Unserialize(ps, share->m_ref_merkle_link); } - - is >> share->m_last_txout_nonce; // uint64 - is >> share->m_hash_link; // V36HashLinkType - - { ParamPackStream ps{v36::MERKLE_LINK_SMALL, is}; ::Unserialize(ps, share->m_merkle_link); } - - is >> share->m_message_data; // v36 messaging hook (empty on DASH Phase A) - - // ── DASH-specific suffix (FLAGGED divergence) ── - is >> share->m_coinbase_payload; // PossiblyNone('', VarStr) - is >> share->m_payment_amount; // uint64 - { - uint64_t count; - ::Unserialize(is, VarInt(count)); - if (count > MAX_PAYMENTS_PER_SHARE) - throw std::ios_base::failure("packed_payments count exceeds cap"); - share->m_packed_payments.resize(count); - for (uint64_t i = 0; i < count; ++i) { - BaseScript payee_bs; - is >> payee_bs; - share->m_packed_payments[i].m_payee.assign( - payee_bs.m_data.begin(), payee_bs.m_data.end()); - is >> share->m_packed_payments[i].m_amount; - } - } - is >> share->m_coinbase_payload_outer; // PossiblyNone('', VarStr) - } - - template - static void WriteV36(StreamType& os, const ShareT* share) - { - os << share->m_min_header; - os << share->m_prev_hash; - os << share->m_coinbase; - os << share->m_nonce; - os << share->m_pubkey_hash; - os << share->m_pubkey_type; - ::Serialize(os, VarInt(share->m_subsidy)); - os << share->m_donation; - { uint8_t si = static_cast(share->m_stale_info); os << si; } - ::Serialize(os, VarInt(share->m_desired_version)); - os << share->m_merged_addresses; - os << share->m_far_share_hash; - os << share->m_max_bits; - os << share->m_bits; - os << share->m_timestamp; - os << share->m_absheight; - ::Serialize(os, Using(share->m_abswork)); - os << share->m_merged_coinbase_info; - os << share->m_merged_payout_hash; - { ParamPackStream ps{v36::MERKLE_LINK_SMALL, os}; ::Serialize(ps, share->m_ref_merkle_link); } - os << share->m_last_txout_nonce; - os << share->m_hash_link; - { ParamPackStream ps{v36::MERKLE_LINK_SMALL, os}; ::Serialize(ps, share->m_merkle_link); } - os << share->m_message_data; - // ── DASH-specific suffix (FLAGGED divergence) ── - os << share->m_coinbase_payload; - os << share->m_payment_amount; - { - uint64_t count = share->m_packed_payments.size(); - ::Serialize(os, VarInt(count)); - for (auto& pay : share->m_packed_payments) { - BaseScript bs; - bs.m_data.assign(pay.m_payee.begin(), pay.m_payee.end()); - os << bs; - os << pay.m_amount; - } - } - os << share->m_coinbase_payload_outer; } }; @@ -326,22 +188,6 @@ struct DashFormatter using ShareType = chain::ShareVariants; -// ── PHASE A: v36 share-type dispatch (DORMANT) ─────────────────────────────── -// The live `ShareType` above is DELIBERATELY left as {DashShare} only, so the -// live accept / mint / store / send paths (node.cpp) stay byte-unchanged and -// keep compiling — several consume the share via generic-invoke lambdas that -// call DashShare-concrete helpers (e.g. share_init_verify(const DashShare&) at -// node.cpp:64). Promoting DashV36Share into the live variant requires guarding -// those call sites with `if constexpr (std::is_same_v)`, and -// is the FIRST wiring step of Phase B/C — NOT Phase A. -// -// This SEPARATE variant proves the v36 wire type round-trips through the REAL -// chain::ShareVariants dispatch machinery (load map keyed by version 36 -> -// DashV36Share, DashFormatter::ReadV36/WriteV36). It is what the round-trip KAT -// exercises. Nothing mints a v36 share (current_share_version stays 16), so the -// type is parseable/mintable-in-principle yet dormant until Phase-C activation. -using V36ShareType = chain::ShareVariants; - // ── Load share from wire format ────────────────────────────────────────────── inline ShareType load_share(chain::RawShare& rshare, NetService peer_addr) @@ -354,16 +200,6 @@ inline ShareType load_share(chain::RawShare& rshare, NetService peer_addr) return share; } -// PHASE A v36 load helper (dispatch-by-version-36). Mirrors load_share but over -// the dormant V36ShareType variant; used by the round-trip KAT. -inline V36ShareType load_v36_share(chain::RawShare& rshare, NetService peer_addr) -{ - auto stream = rshare.contents.as_stream(); - auto share = V36ShareType::load(rshare.type, stream); - share.ACTION({ obj->peer_addr = peer_addr; }); - return share; -} - // ── ShareHasher ────────────────────────────────────────────────────────────── struct ShareHasher diff --git a/src/impl/dash/share_types.hpp b/src/impl/dash/share_types.hpp index 382844b87..aa6214412 100644 --- a/src/impl/dash/share_types.hpp +++ b/src/impl/dash/share_types.hpp @@ -65,152 +65,4 @@ struct PackedPayment } }; -// ═══════════════════════════════════════════════════════════════════════════ -// V36 SHARED (Bucket-2 / category-2) serialization types. -// -// These are BYTE-FOR-BYTE COPIES of the cross-coin v36 share-format types that -// already live, duplicated per-coin, in src/impl/{ltc,dgb,bch,btc}/share_types.hpp -// (V36HashLinkType, MergedAddressEntry, MergedCoinbaseEntry, AbsworkV36Format, -// the param-based MerkleLink + MERKLE_LINK_SMALL). The v36 share revision has NO -// single shared header — every coin carries its own byte-identical copy and the -// ONLY shared symbol is core::version_gate::is_v36_active. DASH conforms to that -// established pattern here: the copies below MUST stay identical to the other -// coins (this is the whole v36-standardize-for-v37 goal — uniform structs). Any -// drift from the ltc/dgb/bch shape is a cross-coin consensus divergence. -// -// Kept in a nested `dash::v36` namespace so the param-based v36 MerkleLink does -// NOT collide with the pre-existing branch-only `dash::MerkleLink` used by the -// live v16 DashShare (which stays byte-unchanged). The two MerkleLink encodings -// are byte-equivalent for the share-level links (both emit just the branch -// vector: dash::MerkleLink never serializes index; dash::v36::MerkleLink under -// MERKLE_LINK_SMALL has allow_index=false, so index is likewise omitted). -// -// DASH is standalone X11 with NO AuxPoW child, so the MERGED-mining types -// (MergedAddressEntry / MergedCoinbaseEntry) are carried for STRUCTURAL -// uniformity only and are always populated EMPTY on a DASH v36 share — exactly -// how a non-merged ltc/dgb/bch coin populates them (empty vector => VarInt(0)). -namespace v36 -{ - -struct MerkleLinkParams -{ - const bool allow_index; - - SER_PARAMS_OPFUNC -}; - -constexpr static MerkleLinkParams MERKLE_LINK_SMALL {.allow_index = false}; -constexpr static MerkleLinkParams MERKLE_LINK_FULL {.allow_index = true}; - -// Param-based MerkleLink (ltc/dgb/bch v36 shape). Under MERKLE_LINK_SMALL the -// index is NOT serialized (allow_index=false) — byte-identical to the branch- -// only encoding the live v16 dash::MerkleLink uses for the share-level links. -struct MerkleLink -{ - std::vector m_branch; - uint32_t m_index{0}; - - MerkleLink() { } - - template - void UnserializeMerkleLink(StreamType& s, const MerkleLinkParams& params) - { - s >> m_branch; - if (params.allow_index) - s >> m_index; - } - - template - void SerializeMerkleLink(StreamType& s, const MerkleLinkParams& params) const - { - s << m_branch; - if (params.allow_index) - s << m_index; - } - - template - inline void Serialize(StreamType& os) const - { - SerializeMerkleLink(os, os.GetParams()); - } - - template - inline void Unserialize(StreamType& is) - { - UnserializeMerkleLink(is, is.GetParams()); - } -}; - -// V36 hash link — extra_data becomes VarStr (was FixedStr(0) pre-V36). -struct V36HashLinkType -{ - FixedStrType<32> m_state; - BaseScript m_extra_data; // VarStr in V36 - uint64_t m_length; - - C2POOL_SERIALIZE_METHODS(V36HashLinkType) { READWRITE(obj.m_state, obj.m_extra_data, VarInt(obj.m_length)); } -}; - -// V36 merged mining: per-chain address entry. -struct MergedAddressEntry -{ - uint32_t m_chain_id; - BaseScript m_script; - - C2POOL_SERIALIZE_METHODS(MergedAddressEntry) { READWRITE(obj.m_chain_id, obj.m_script); } -}; - -// V36 merged mining: per-chain coinbase verification entry. -struct MergedCoinbaseEntry -{ - uint32_t m_chain_id; - uint64_t m_coinbase_value; - uint32_t m_block_height; - FixedStrType<80> m_block_header; - MerkleLink m_coinbase_merkle_link; - BaseScript m_coinbase_script; // V36: actual scriptSig (allows custom tags + THE state_root) - - template - void Serialize(StreamType& os) const - { - ::Serialize(os, m_chain_id); - ::Serialize(os, Using(m_coinbase_value)); - ::Serialize(os, Using(m_block_height)); - ::Serialize(os, m_block_header); - ParamPackStream pstream{MERKLE_LINK_SMALL, os}; - ::Serialize(pstream, m_coinbase_merkle_link); - ::Serialize(os, m_coinbase_script); - } - - template - void Unserialize(StreamType& is) - { - ::Unserialize(is, m_chain_id); - ::Unserialize(is, Using(m_coinbase_value)); - ::Unserialize(is, Using(m_block_height)); - ::Unserialize(is, m_block_header); - ParamPackStream pstream{MERKLE_LINK_SMALL, is}; - ::Unserialize(pstream, m_coinbase_merkle_link); - ::Unserialize(is, m_coinbase_script); - } -}; - -// V36: abswork is VarInt-encoded on the wire but stored as uint128. -struct AbsworkV36Format -{ - template - static void Write(StreamType& os, const uint128& value) - { - WriteCompactSize(os, value.GetLow64()); - } - - template - static void Read(StreamType& is, uint128& value) - { - value = uint128(ReadCompactSize(is, false)); - } -}; - -} // namespace v36 - } // namespace dash \ No newline at end of file diff --git a/src/impl/dash/stratum/work_source.cpp b/src/impl/dash/stratum/work_source.cpp index ff951fa56..21af1548c 100644 --- a/src/impl/dash/stratum/work_source.cpp +++ b/src/impl/dash/stratum/work_source.cpp @@ -142,25 +142,6 @@ DASHWorkSource::DASHWorkSource(const coin::NodeCoinState& coin_state, // Derived directly from a smoothed hashrate, so it cannot oscillate. DASH-only; // other coins keep the legacy ratio path (use_hashrate_vardiff=false). config_.use_hashrate_vardiff = true; - // ── Zombie-session leak fix (mining-hotel): OPT DASH IN to the live-session - // hygiene knobs (default-off in StratumConfig so other coins are unchanged). - // A NAT-dropped rig's TCP session is never FIN/RST'd, so socket_.is_open() - // stays true forever and the session is never reaped -- every failed retry - // minted an immortal subscribed session drawing full per-notify job builds - // (66 sockets for ~23 rigs). Transport/liveness only; consensus-neutral. - config_.tcp_keepalive_enabled = true; // kernel probes dead NAT paths (root fix) - config_.tcp_keepalive_idle_sec = 60; - config_.tcp_keepalive_interval_sec = 10; - config_.tcp_keepalive_count = 3; // ~90 s to detect a dead peer - config_.handshake_timeout_sec = 30; // drop never-authorize probes - // Idle reaper is a BACKSTOP to TCP keepalive (the real liveness authority), - // not the primary zombie killer. 1800 s + the keepalive-aware skip in - // start_idle_reaper() means an authorized rig on a high fixed-diff suffix - // (e.g. ADDR+4096 at modest X11 hashrate -> multi-minute share intervals) is - // NEVER reaped for idleness while its socket is keepalive-validated; only - // genuinely dead / never-authorized sessions are reclaimed. - config_.session_idle_timeout_sec = 1800; - config_.max_write_queue_depth = 256; // drop a stuck-write dead peer LOG_INFO << "[DASH-STRATUM] DASHWorkSource constructed" << " (min_diff=" << config_.min_difficulty << " max_diff=" << config_.max_difficulty @@ -285,14 +266,24 @@ std::shared_ptr DASHWorkSource::peek_template() const // IWorkSource: work generation -- Stage 4c (the template trio). // ───────────────────────────────────────────────────────────────────────────── -void DASHWorkSource::resource_template_now() const +std::shared_ptr DASHWorkSource::cached_work() const { - // The blocking select_work()/dashd-GBT re-source + cache update. Runs either - // inline on the caller (legacy blocking path) OR on the background rpc_pool - // thread (io-thread-decouple, via refresh_executor_). select_work() is done - // OUTSIDE the lock (the fallback arm is a blocking dashd RPC); the cache is - // then updated under template_mutex_. Byte-for-byte the SAME template the - // old inline path produced -- transport/threading change only. + const uint64_t gen = work_generation_.load(std::memory_order_relaxed); + const auto now = std::chrono::steady_clock::now(); + { + std::lock_guard lk(template_mutex_); + if (template_cache_ && template_cache_gen_ == gen + && now - template_cache_at_ < kStaleAfter) + return template_cache_; + // Negative cache: a recent failed sourcing attempt -> don't re-poll yet. + if (!template_cache_ && template_last_fail_at_.time_since_epoch().count() != 0 + && now - template_last_fail_at_ < kRetryAfter) + return nullptr; + } + + // Re-source OUTSIDE the lock (the fallback arm is a blocking dashd RPC). + // Embedded arm when the node-held bundle is populated, retained dashd GBT + // fallback otherwise (the never-removed [GBT-XCHECK] safety path). coin::DashWorkData work; if (coin_state_.populated() || dashd_fallback_) { try { @@ -317,7 +308,6 @@ void DASHWorkSource::resource_template_now() const } } - const auto now = std::chrono::steady_clock::now(); std::lock_guard lk(template_mutex_); if (work.m_bits == 0 || work.m_previous_block.IsNull()) { // Set-gap (unarmed fallback / empty GBT) OR a zero-prev template (the @@ -326,7 +316,7 @@ void DASHWorkSource::resource_template_now() const // serving a stale tip is worse than waiting. template_cache_.reset(); template_last_fail_at_ = now; - return; + return nullptr; } // Tip moved since the last snapshot? Bump work_generation_ so sessions // detect stale work between their timer firings and re-push. @@ -336,58 +326,6 @@ void DASHWorkSource::resource_template_now() const template_cache_gen_ = work_generation_.load(std::memory_order_relaxed); template_cache_at_ = now; template_last_fail_at_ = {}; -} - -std::shared_ptr DASHWorkSource::cached_work() const -{ - const uint64_t gen = work_generation_.load(std::memory_order_relaxed); - const auto now = std::chrono::steady_clock::now(); - { - std::lock_guard lk(template_mutex_); - // FRESH cache (same generation, inside the TTL) -> serve it. The - // generation key is load-bearing: bump_work_generation() is the "the tip - // may have rotated, re-source" signal (double-fetch-race fix), so it must - // still trigger a refresh -- but io-thread-decouple moves WHERE that - // refresh runs off the stratum io thread (below). - if (template_cache_ && template_cache_gen_ == gen - && now - template_cache_at_ < kStaleAfter) - return template_cache_; - - if (refresh_executor_) { - // NON-BLOCKING scale path (the fix for 60+ sessions freezing on each - // re-source): the io thread NEVER runs the blocking select_work()/ - // dashd-GBT. Hand it to the background rpc_pool as a SINGLE-FLIGHT - // job and serve the CURRENT cache (possibly stale, or null on a - // set-gap) immediately. A minted share bumps the generation ~every - // 15-30 s; before, that blocked the io thread on a full GBT -- now it - // costs one background fetch, coalesced. A stale serve is bounded by - // the bg refresh latency AND the 3 s tip-poll invalidation (which - // RESETS the cache on a real tip change, so a rotated tip is served - // as a set-gap until the fresh template lands, never as stale work). - const bool recently_failed = - !template_cache_ - && template_last_fail_at_.time_since_epoch().count() != 0 - && now - template_last_fail_at_ < kRetryAfter; - if (!recently_failed && !template_refresh_inflight_.exchange(true)) { - refresh_executor_([this]() { - resource_template_now(); - template_refresh_inflight_.store(false); - }); - } - return template_cache_; // stale-or-null; null == honest set-gap - } - - // Legacy inline-blocking path (executor not wired: embedded/tests) -- - // BYTE-IDENTICAL to the pre-decouple behaviour. Negative cache: a recent - // failed sourcing attempt -> don't re-poll yet. - if (!template_cache_ && template_last_fail_at_.time_since_epoch().count() != 0 - && now - template_last_fail_at_ < kRetryAfter) - return nullptr; - } - - // Legacy path only: re-source inline (blocks the caller) then serve. - resource_template_now(); - std::lock_guard lk(template_mutex_); return template_cache_; } diff --git a/src/impl/dash/stratum/work_source.hpp b/src/impl/dash/stratum/work_source.hpp index 62b7a80c2..eac5e4f09 100644 --- a/src/impl/dash/stratum/work_source.hpp +++ b/src/impl/dash/stratum/work_source.hpp @@ -305,23 +305,6 @@ class DASHWorkSource : public core::stratum::IWorkSource /// Bumps work_generation_ when a refresh observes a moved coin tip so /// stratum sessions re-push work on their next heartbeat. std::shared_ptr cached_work() const; - // io-thread-decouple: the blocking select_work()/dashd-GBT re-source, factored - // out so it runs either inline (legacy blocking path, no executor wired) OR - // on the background rpc_pool thread (via refresh_executor_). Updates the - // template cache under template_mutex_. - void resource_template_now() const; - -public: - // io-thread-decouple: wire a background executor (main_dash.cpp posts onto - // the dedicated rpc_pool). When set, cached_work() NEVER blocks the caller - // on a dashd GBT: it serves the (possibly stale) cache immediately and hands - // the blocking re-source to this executor as a SINGLE-FLIGHT background job. - // Set once at startup before the io loop runs. Only wired on the dashd- - // fallback arm; unset -> the legacy inline-blocking path (embedded/tests). - void set_refresh_executor(std::function)> fn) - { refresh_executor_ = std::move(fn); } - -private: // External dependencies (non-owning references) -- see Lifetime note. const coin::NodeCoinState& coin_state_; ///< embedded work arm (populated -> Embedded) std::function dashd_fallback_; ///< always-reachable dashd GBT RPC arm (never removed) @@ -370,13 +353,6 @@ class DASHWorkSource : public core::stratum::IWorkSource mutable uint64_t template_cache_gen_{0}; mutable std::chrono::steady_clock::time_point template_cache_at_{}; mutable std::chrono::steady_clock::time_point template_last_fail_at_{}; - - // io-thread-decouple: background single-flight template refresh. - // refresh_executor_ posts the blocking re-source onto the rpc_pool thread - // (set_refresh_executor); template_refresh_inflight_ collapses concurrent - // refreshes to one. Empty executor -> legacy inline-blocking cached_work(). - std::function)> refresh_executor_; - mutable std::atomic template_refresh_inflight_{false}; }; } // namespace dash::stratum diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index f6df776d0..a92c30b28 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -391,18 +391,12 @@ if (BUILD_TESTING AND GTest_FOUND) # sharereply's shares (RawShare round-trip, child->parent order) insert + # verify and ROOT a previously-unrooted live-pushed tip. Same allowlisted # target (proven pattern), no build.yml change. - # test_dash_v36_share.cpp (Phase A DASH v36 share-type): byte-parity of the - # standardized cross-coin v36 prefix vs the non-segwit (BCH) v36 shape, - # round-trip through the version-36 ShareVariants dispatch, merged-fields- - # inert convention, and v16 backward-compat. Same allowlisted target (proven - # pattern), no build.yml change (avoids the #769 new-target/allowlist trap). add_executable(test_dash_share_hash_link test_dash_share_hash_link.cpp test_dash_share_producer.cpp test_dash_share_producer_bind.cpp test_dash_mint_runloop.cpp - test_dash_share_download.cpp - test_dash_v36_share.cpp) + test_dash_share_download.cpp) target_link_libraries(test_dash_share_hash_link PRIVATE GTest::gtest_main GTest::gtest dash_x11 core @@ -520,7 +514,16 @@ if (BUILD_TESTING AND GTest_FOUND) # frstrtr/p2pool-dash getwork (pre-v35). Header-only over the dash leaves + # core (links core for script_to_address in address_utils.cpp). Last S7 # capstone; node-backed end-to-end parity deferred to a live-harness leaf. - add_executable(test_dash_embedded_gbt test_dash_embedded_gbt.cpp) + # The Embedded ORACLE-SHADOW VALIDATOR KATs (test_dash_embedded_oracle_shadow + # .cpp) are FOLDED into this already-CI-allowlisted target rather than a new + # executable: the bot that pushes the branch lacks `workflow` scope, so it + # cannot add a new --target to build.yml (which would otherwise leave the KAT + # a NOT_BUILT sentinel). Both files are hermetic gtest TUs over the same link + # set (dash_x11 + core + the payout/hashrate/merged-mining OBJECT libs); the + # oracle-shadow header pulls node_coin_state -> work_source + vendor/cbtx. + add_executable(test_dash_embedded_gbt + test_dash_embedded_gbt.cpp + test_dash_embedded_oracle_shadow.cpp) target_link_libraries(test_dash_embedded_gbt PRIVATE GTest::gtest_main GTest::gtest dash_x11 core diff --git a/test/test_dash_embedded_oracle_shadow.cpp b/test/test_dash_embedded_oracle_shadow.cpp new file mode 100644 index 000000000..d2ba45cf2 --- /dev/null +++ b/test/test_dash_embedded_oracle_shadow.cpp @@ -0,0 +1,291 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +/// DASH Embedded ORACLE-SHADOW VALIDATOR — hermetic KATs for the pure +/// regime-aware compare + graduation logic (embedded_oracle_shadow.hpp). +/// +/// The runtime driver (on_new_tip) and the dashd PROPOSAL verdict need a live +/// node + dashd and are exercised by the testnet shadow soak. These KATs pin the +/// DECISION LOGIC the runtime relies on, per the adversarial-critique §6 model: +/// +/// (1) EQUALITY fields (nHeight, cbtx version, bits, mintime, payee identity, +/// platform burn, normalized subsidy, roots when empty) — a mismatch +/// COUNTS as a divergence. +/// (2) A pure mempool/fee difference (raw coinbasevalue, tx set, payee +/// amounts, MN amount) produces ZERO counted divergences — the false +/// positive the separate-mempool reality would otherwise cause. The +/// EQUALITY subsidy core (coinbasevalue − Σfees) still matches. +/// (3) creditPool is an INVARIANT (normalized base), not cross-node equality: +/// a stale embedded seed VIOLATES its own base invariant and COUNTS; a +/// higher-but-consistent dashd pool does NOT. +/// (4) bestCL* is a CONSTRAINT: differing CL heights do NOT count; equal CL +/// height with differing sig DOES. +/// (5) GraduationLedger: served-clean requires proposal ACCEPTED; N + K + reorg +/// + serve-rate floor; fall-closed classes are declared as serve-gaps; +/// a chain-drift revoke withholds graduation. +/// (6) NetPeriodicity height classification. + +#include + +#include +#include +#include + +#include +#include + +#include +#include +#include + +using namespace dash::coin; + +namespace { + +std::vector encode(const vendor::CCbTx& c) { + auto stream = ::pack(c); + auto sp = stream.get_span(); + return std::vector( + reinterpret_cast(sp.data()), + reinterpret_cast(sp.data()) + sp.size()); +} +uint256 hashn(uint8_t seed) { + uint256 h; // begin() is uint32_t* (8 words); write the 32 raw bytes. + auto* b = reinterpret_cast(h.begin()); + for (int i = 0; i < 32; ++i) b[i] = static_cast(seed + i); + return h; +} +vendor::CCbTx make_cbtx(uint32_t height, int64_t credit_pool, + uint8_t mn_seed = 1, uint8_t q_seed = 100, + uint32_t cl_diff = 0) { + vendor::CCbTx c; + c.nVersion = vendor::CCbTx::VERSION_CLSIG_AND_BALANCE; + c.nHeight = static_cast(height); + c.merkleRootMNList = hashn(mn_seed); + c.merkleRootQuorums = hashn(q_seed); + c.bestCLHeightDiff = cl_diff; + c.bestCLSignature = {}; + c.creditPoolBalance = credit_pool; + return c; +} +std::vector payees(uint64_t platform, uint64_t mn_amount, + const std::string& mn = "yMNaddr111111111111111111111") { + return { {"!6a", platform}, {mn, mn_amount} }; +} +DashWorkData make_wd(uint32_t height, const vendor::CCbTx& cbtx, + const std::vector& p, + uint64_t coinbasevalue, std::vector fees) { + DashWorkData w; + w.m_height = height; + w.m_previous_block = hashn(200); + w.m_bits = 0x1e0ffff0u; + w.m_mintime = 1700000000u; + w.m_coinbase_value = coinbasevalue; + w.m_packed_payments = p; + w.m_coinbase_payload = encode(cbtx); + w.m_tx_fees = fees; + w.m_tx_hashes.assign(fees.size(), hashn(50)); + return w; +} +SideContext side(const DashWorkData& wd, const vendor::CCbTx& cb) { + SideContext s; s.wd = &wd; s.cbtx = &cb; s.cbtx_ok = true; + s.own_fees = sum_fees(wd.m_tx_fees); + s.platform_reward = platform_burn_amount(wd.m_packed_payments); + s.empty_template = wd.m_tx_hashes.empty(); + return s; +} +const FieldResult* find(const std::vector& fr, const std::string& f) { + for (const auto& x : fr) if (x.field == f) return &x; + return nullptr; +} +size_t counted(const std::vector& fr) { + size_t n = 0; for (const auto& f : fr) if (f.counts) ++n; return n; +} + +} // namespace + +// ── (1) identical empty templates -> zero counted divergences ──────────────── +TEST(OracleShadow, IdenticalEmptyTemplatesAgree) { + auto cb = make_cbtx(1000, 5000000); + auto e = make_wd(1000, cb, payees(1000, 90000), 500000000, {}); + auto d = make_wd(1000, cb, payees(1000, 90000), 500000000, {}); + auto fr = compare_templates(side(e, cb), side(d, cb), + std::optional(4999000), /*dkg=*/false, /*contig=*/true); + EXPECT_EQ(counted(fr), 0u); +} + +// ── (2) mempool/fee-only difference -> ZERO counted (the keystone) ─────────── +TEST(OracleShadow, MempoolFeeDifferenceIsNotCounted) { + auto cb = make_cbtx(1000, 5000000); // same chain state -> same CbTx + // Embedded: 0 fees; dashd: 30000 sat of fees over 5 txs. Same subsidy core. + auto e = make_wd(1000, cb, payees(1000, 90000), 500000000, {}); + auto d = make_wd(1000, cb, payees(1000, 91777), 500030000, {6000,6000,6000,6000,6000}); + // Non-empty on the dashd side -> roots downgrade to informational, and the + // creditPool invariant tolerates dashd's own-tx delta. + auto fr = compare_templates(side(e, cb), side(d, cb), + std::optional(4999000), false, true); + EXPECT_EQ(counted(fr), 0u) << "fee/mempool differences must not count"; + EXPECT_TRUE(find(fr, "coinbasevalue_raw") && !find(fr, "coinbasevalue_raw")->match); + EXPECT_TRUE(find(fr, "subsidy_core") && find(fr, "subsidy_core")->match); // core matches + EXPECT_TRUE(find(fr, "payee_amounts") && !find(fr, "payee_amounts")->match); + EXPECT_TRUE(find(fr, "payee_identities") && find(fr, "payee_identities")->match); + EXPECT_TRUE(find(fr, "platform_burn_amount") && find(fr, "platform_burn_amount")->match); +} + +// ── (3) stale embedded creditPool seed -> INVARIANT violation, counts ──────── +TEST(OracleShadow, StaleCreditPoolSeedIsCountedInvariant) { + // base creditPool(N-1) = 4_999_000; platformReward(N) = 1000. + // A correct committed pool = 4_999_000 + 1000 = 5_000_000. + auto good = make_cbtx(1000, 5000000); + auto stale = make_cbtx(1000, 4000000); // stale seed -> base check fails + auto e = make_wd(1000, stale, payees(1000, 90000), 500000000, {}); // embedded stale + auto d = make_wd(1000, good, payees(1000, 90000), 500000000, {}); // dashd correct + auto fr = compare_templates(side(e, stale), side(d, good), + std::optional(4999000), false, /*contig=*/true); + const auto* inv = find(fr, "creditPool_invariant"); + ASSERT_TRUE(inv); + EXPECT_EQ(inv->regime, Regime::Invariant); + EXPECT_TRUE(inv->counts); + EXPECT_FALSE(inv->match); +} + +// ── (3b) higher-but-consistent dashd pool (no delta) -> NOT counted ────────── +TEST(OracleShadow, ConsistentCreditPoolNotCounted) { + // Both sides committed = base + platformReward = 4_999_000 + 1000. + auto cb = make_cbtx(1000, 5000000); + auto e = make_wd(1000, cb, payees(1000, 90000), 500000000, {}); + auto d = make_wd(1000, cb, payees(1000, 90000), 500000000, {}); + auto fr = compare_templates(side(e, cb), side(d, cb), + std::optional(4999000), false, true); + const auto* inv = find(fr, "creditPool_invariant"); + ASSERT_TRUE(inv); + EXPECT_FALSE(inv->counts); + EXPECT_TRUE(inv->match); +} + +// ── (4) bestCL CONSTRAINT: differing CL heights don't count; equal-height +// differing sig does ────────────────────────────────────────────────── +TEST(OracleShadow, BestCLIsRangeConstraint) { + // Different bestCLHeightDiff (propagation lag) -> NOT counted. + auto ce = make_cbtx(1000, 5000000, 1, 100, /*cl_diff=*/2); + auto cd = make_cbtx(1000, 5000000, 1, 100, /*cl_diff=*/0); + auto e = make_wd(1000, ce, payees(1000, 90000), 500000000, {}); + auto d = make_wd(1000, cd, payees(1000, 90000), 500000000, {}); + auto fr = compare_templates(side(e, ce), side(d, cd), + std::optional(4999000), false, true); + const auto* hd = find(fr, "bestCLHeightDiff"); + ASSERT_TRUE(hd); + EXPECT_EQ(hd->regime, Regime::Constraint); + EXPECT_FALSE(hd->counts); // differing heights are propagation lag, not a bug + + // Same CL height, differing sig -> counts (same height => same CL => same sig). + auto ce2 = make_cbtx(1000, 5000000, 1, 100, 0); ce2.bestCLSignature.fill(0xAB); + auto cd2 = make_cbtx(1000, 5000000, 1, 100, 0); cd2.bestCLSignature.fill(0xCD); + auto e2 = make_wd(1000, ce2, payees(1000, 90000), 500000000, {}); + auto d2 = make_wd(1000, cd2, payees(1000, 90000), 500000000, {}); + auto fr2 = compare_templates(side(e2, ce2), side(d2, cd2), + std::optional(4999000), false, true); + const auto* sig = find(fr2, "bestCLSignature"); + ASSERT_TRUE(sig); + EXPECT_TRUE(sig->counts); + EXPECT_FALSE(sig->match); +} + +// ── (5) wrong MN root on empty templates -> EQUALITY, counts ───────────────── +TEST(OracleShadow, WrongMNRootEqualityCounts) { + auto ce = make_cbtx(1000, 5000000, /*mn_seed=*/1); + auto cd = make_cbtx(1000, 5000000, /*mn_seed=*/9); + auto e = make_wd(1000, ce, payees(1000, 90000), 500000000, {}); + auto d = make_wd(1000, cd, payees(1000, 90000), 500000000, {}); + auto fr = compare_templates(side(e, ce), side(d, cd), + std::optional(4999000), false, true); + const auto* r = find(fr, "merkleRootMNList"); + ASSERT_TRUE(r); + EXPECT_EQ(r->regime, Regime::Equality); + EXPECT_TRUE(r->counts); +} + +// ── (6) GraduationLedger: proposal-gated clean + N/K/reorg/serve-rate ──────── +TEST(OracleShadow, GraduationPredicate) { + GraduationLedger led; + GraduationConfig cfg; + cfg.consecutive_clean_target = 12; + cfg.per_class_coverage_target = 2; + cfg.serve_rate_floor = 0.5; + + // A proposal-REJECTED served height never counts clean. + led.record_served(1, {HeightClass::Normal}, /*proposal_ok=*/false, {}); + EXPECT_EQ(led.consecutive_clean, 0u); + EXPECT_EQ(led.proposal_rejected, 1u); + + // Feed clean served heights covering each required class + a reorg. + for (uint32_t h = 2; h <= 13; ++h) { + std::set cl{HeightClass::Normal}; + if (h <= 3) cl.insert(HeightClass::DkgWindow); + if (h >= 4 && h <= 5) cl.insert(HeightClass::Superblock); + if (h >= 6 && h <= 7) cl.insert(HeightClass::PostRestartCold); + if (h >= 8 && h <= 9) cl.insert(HeightClass::QuorumRotation); + if (h >= 10 && h <= 11) cl.insert(HeightClass::CreditPoolTransition); + if (h >= 12 && h <= 13) cl.insert(HeightClass::NonEmptyTemplate); + if (h == 7) cl.insert(HeightClass::Reorg); + led.record_served(h, cl, /*proposal_ok=*/true, {}); + } + EXPECT_GE(led.consecutive_clean, 12u); + EXPECT_GE(led.reorg_covered, 1u); + EXPECT_GT(led.serve_rate(), 0.5); + EXPECT_TRUE(led.is_graduated(cfg)) << led.verdict_json(cfg).dump(2); + + // A counted divergence resets the streak. + led.record_served(14, {HeightClass::Normal}, true, {"merkleRootMNList"}); + EXPECT_EQ(led.consecutive_clean, 0u); + EXPECT_FALSE(led.is_graduated(cfg)); + EXPECT_EQ(led.divergences_by_field["merkleRootMNList"], 1u); +} + +// ── (6b) fall-closed classes are declared as residual serve-gaps ───────────── +TEST(OracleShadow, FallClosedDeclaredAsServeGap) { + GraduationLedger led; + GraduationConfig cfg; + // Superblock only ever fall-closed (never served) -> declared serve-gap. + led.note_fall_closed(100, {HeightClass::Superblock, HeightClass::Normal}); + auto v = led.verdict_json(cfg); + EXPECT_EQ(v["verdict"], "NOT-GRADUATED"); + ASSERT_TRUE(v["residual_serve_gaps"].contains("superblock")); +} + +// ── (6c) revocation withholds graduation ───────────────────────────────────── +TEST(OracleShadow, RevocationWithholdsGraduation) { + GraduationLedger led; + GraduationConfig cfg; + cfg.consecutive_clean_target = 1; + cfg.per_class_coverage_target = 0; + led.record_served(1, {HeightClass::Reorg}, true, {}); + // Even fully-covered, a revoke blocks graduation. + led.revoked = true; led.revoked_reason = "unknown CbTx version"; + EXPECT_FALSE(led.is_graduated(cfg)); + EXPECT_EQ(led.verdict_json(cfg)["revoked"], true); +} + +// ── (7) serve-rate floor gates a cherry-picking arm ────────────────────────── +TEST(OracleShadow, ServeRateFloorGates) { + GraduationLedger led; + GraduationConfig cfg; + cfg.consecutive_clean_target = 1; + cfg.per_class_coverage_target = 0; + cfg.serve_rate_floor = 0.5; + // 1 served normal + 9 fall-closed normals -> serve_rate 0.1 < 0.5. + led.record_served(1, {HeightClass::Normal, HeightClass::Reorg}, true, {}); + for (uint32_t h = 2; h <= 10; ++h) led.note_fall_closed(h, {HeightClass::Normal}); + EXPECT_LT(led.serve_rate(), 0.5); + EXPECT_FALSE(led.is_graduated(cfg)); +} + +// ── (8) NetPeriodicity height classification ───────────────────────────────── +TEST(OracleShadow, HeightClassification) { + auto t = NetPeriodicity::for_net(true); + EXPECT_TRUE(t.is_superblock(24)); + EXPECT_FALSE(t.is_superblock(25)); + EXPECT_TRUE(t.is_dkg_window(24 * 5 + 12)); + EXPECT_FALSE(t.is_dkg_window(24 * 5 + 1)); + auto m = NetPeriodicity::for_net(false); + EXPECT_TRUE(m.is_superblock(16616)); + EXPECT_FALSE(m.is_superblock(16617)); +} diff --git a/test/test_dash_stratum_work_source.cpp b/test/test_dash_stratum_work_source.cpp index b30906fb2..7f4f36b2c 100644 --- a/test/test_dash_stratum_work_source.cpp +++ b/test/test_dash_stratum_work_source.cpp @@ -32,14 +32,10 @@ #include -#include #include -#include #include -#include #include #include -#include #include namespace { @@ -795,87 +791,6 @@ TEST(DashStratumWorkSource, FallbackArmTipChangeRefreshesTemplateAndBumpsGenerat EXPECT_EQ(tmpl_after.value("height", 0u), 424243u); } -// ── io-thread-decouple KAT (mining-hotel stratum-stall fix, v0.2.3.8) ──────── -// The stall fix: the stratum io_context thread must NEVER block on the dashd -// fallback getblocktemplate. cached_work() re-sources through a background -// executor (the dedicated rpc_pool in main_dash.cpp) as a SINGLE-FLIGHT job and -// serves the cached template immediately, so a generation bump (a minted share, -// ~every 15-30 s) no longer freezes 60+ sessions on a blocking GBT. Pins: -// (a) with an executor wired, a cache-miss / gen-bump does NOT call the (slow) -// fallback on the calling (io) thread -- it schedules ONE background job -// and serves the current cache immediately; -// (b) the re-source runs on the EXECUTOR's thread, not the caller's; -// (c) single-flight: concurrent misses schedule at most one refresh; -// (d) the gen-key freshness contract (double-fetch-race fix) is preserved -- -// a bump still triggers a refresh, only OFF the io thread. -TEST(DashStratumWorkSource, IoThreadDecoupleServesCachedAndRefreshesOffThread) -{ - dash::coin::NodeCoinState cs; // unpopulated -> dashd-fallback arm - std::atomic fallback_calls{0}; - std::atomic fallback_on_caller{false}; - const auto caller_tid = std::this_thread::get_id(); - - auto ws = std::make_unique( - cs, - [&]() -> dash::coin::DashWorkData { - fallback_calls.fetch_add(1); - if (std::this_thread::get_id() == caller_tid) - fallback_on_caller.store(true); - return rich_work(); - }); - - // Controllable executor: capture jobs so the test drives WHEN the background - // re-source runs. Single-flight is enforced inside the work source. - std::mutex jm; - std::vector> jobs; - ws->set_refresh_executor([&](std::function job) { - std::lock_guard l(jm); - jobs.push_back(std::move(job)); - }); - - // (1) First request: cache empty. The io thread does NOT call the fallback -- - // it schedules ONE background job and serves an empty set-gap template. - auto t0 = ws->get_current_work_template(); - EXPECT_TRUE(t0.empty()); - EXPECT_EQ(fallback_calls.load(), 0); // io thread never blocked on dashd - { std::lock_guard l(jm); ASSERT_EQ(jobs.size(), 1u); } - - // (1b) Single-flight: another request while a refresh is in flight schedules - // no additional job. - (void)ws->get_current_work_template(); - { std::lock_guard l(jm); EXPECT_EQ(jobs.size(), 1u); } - - // (2) Run the background job on a DIFFERENT thread (the rpc_pool stand-in). - // The blocking fallback runs THERE; the cache is populated. - std::function job0; - { std::lock_guard l(jm); job0 = jobs[0]; jobs.clear(); } - std::thread(job0).join(); - EXPECT_EQ(fallback_calls.load(), 1); - EXPECT_FALSE(fallback_on_caller.load()); // re-source ran OFF the caller thread - - // (3) Now cached + fresh: served immediately, no new fallback call, no job. - auto t1 = ws->get_current_work_template(); - EXPECT_FALSE(t1.empty()); - EXPECT_EQ(t1.value("previousblockhash", ""), std::string(kPrevHashHex)); - EXPECT_EQ(fallback_calls.load(), 1); - { std::lock_guard l(jm); EXPECT_TRUE(jobs.empty()); } - - // (4) A generation bump (a minted best-share) breaks freshness, but the io - // thread STILL does not block: it serves the cached template and - // schedules a single background refresh. - ws->bump_work_generation(); - auto t2 = ws->get_current_work_template(); - EXPECT_FALSE(t2.empty()); // served cached template -- no stall - EXPECT_EQ(fallback_calls.load(), 1); // fallback NOT called on the io thread - { std::lock_guard l(jm); ASSERT_EQ(jobs.size(), 1u); } - - // Draining the scheduled refresh runs the fallback off-thread again. - std::function job1; - { std::lock_guard l(jm); job1 = jobs[0]; jobs.clear(); } - std::thread(job1).join(); - EXPECT_EQ(fallback_calls.load(), 2); -} - // Fix 3 pin (work-source side of the zero-hash pre-auth job_0 defect): a // template with a zeroed prev is NOT mineable work — honest set-gap, never a // zero-prev job. diff --git a/test/test_dash_v36_share.cpp b/test/test_dash_v36_share.cpp deleted file mode 100644 index e5e4d4512..000000000 --- a/test/test_dash_v36_share.cpp +++ /dev/null @@ -1,343 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// DASH v36 share-type KATs (Phase A: DEFINE + PARSE, dormant). -// -// CONSENSUS-BEARING. This pins the DASH v36 share wire format (dash::DashV36Share, -// wire-type 36). Four guarantees: -// -// 1. BYTE-PARITY of the STANDARDIZED prefix (min_header .. message_data) vs the -// cross-coin non-segwit v36 share shape. The reference is the BCH v36 -// MergedMiningShare Formatter (src/impl/bch/share.hpp:152-252), i.e. the -// LTC/DGB v36 layout MINUS the Optional(segwit_data) slot — BCH, like DASH, -// is non-segwit (SEGWIT_ACTIVATION_VERSION==0 => is_segwit_activated(36) -// false), so the segwit_data field is omitted. The expected prefix is -// rebuilt field-by-field in the BCH order using the SAME core pack -// primitives, then frozen as a golden hex regression sentinel — a field- -// order or encoding drift (missing pubkey_type, fixed-uint64 subsidy instead -// of VarInt, fixed-uint128 abswork instead of AbsworkV36Format, segwit slot -// leaking in, etc.) fails the test. -// -// 2. ROUND-TRIP through the REAL chain::ShareVariants dispatch: pack -> RawShare -// (type 36) -> dash::load_v36_share -> field equality -> re-pack byte- -// identical. Proves version-36 dispatch (LoadMethods[36] -> DashV36Share, -// DashFormatter::ReadV36/WriteV36) round-trips. -// -// 3. MERGED fields are INERT and follow the non-merged cross-coin convention: -// empty merged_addresses / merged_coinbase_info => a single VarInt(0) byte; -// zero merged_payout_hash => 32 zero bytes; empty message_data => VarInt(0). -// Identical to how a standalone (non-merged) ltc/dgb/bch coin populates them. -// -// 4. BACKWARD-COMPAT: the pre-existing v16 DashShare wire format is byte- -// UNCHANGED by the DashFormatter version-branch refactor (v16 round-trips -// through the live ShareType::load(16); v16 vs v36 of the same logical -// inputs are DISTINCT and keep the v16-specific ordering). - -#include - -#include // DashFormatter, ShareType, V36ShareType, load_share, load_v36_share -#include // dash::DashShare, dash::DashV36Share -#include // dash::v36::* shared types, dash::PackedPayment - -#include // chain::RawShare -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -namespace { - -std::string to_hex(const std::span bytes) -{ - static const char* h = "0123456789abcdef"; - std::string out; - out.reserve(bytes.size() * 2); - for (unsigned char b : bytes) { out.push_back(h[b >> 4]); out.push_back(h[b & 0xf]); } - return out; -} - -std::string pack_hex(const PackStream& ps) -{ - auto& m = const_cast(ps); - return to_hex(std::span( - reinterpret_cast(m.data()), m.size())); -} - -// A canonical, fully-populated DASH v36 share. Distinctive scalar values so a -// misordered/misencoded field is visible; merged fields left EMPTY (DASH is -// standalone X11 — no AuxPoW child), DASH suffix populated. -dash::DashV36Share make_canonical_v36() -{ - dash::DashV36Share s; - s.m_min_header.m_version = 2; - s.m_min_header.m_previous_block.SetHex( - "00000000000000000000000000000000000000000000000000000000000000aa"); - s.m_min_header.m_timestamp = 0x11223344; - s.m_min_header.m_bits = 0x1e0ffff0; - s.m_min_header.m_nonce = 0x55667788; - - s.m_prev_hash.SetHex( - "0000000000000000000000000000000000000000000000000000000000000001"); - s.m_coinbase = BaseScript(std::vector{0xab, 0xcd}); - s.m_nonce = 0x0a0b0c0d; - s.m_pubkey_hash.SetHex("00000000000000000000000000000000deadbeef"); - s.m_pubkey_type = 0; // DASH always P2PKH - s.m_subsidy = 5000000000ULL; // > 2^32 => 9-byte VarInt - s.m_donation = 0x1234; - s.m_stale_info = dash::StaleInfo::none; - s.m_desired_version = 36; // the version-vote - - // merged_addresses: EMPTY (inert) - s.m_far_share_hash.SetHex( - "0000000000000000000000000000000000000000000000000000000000000002"); - s.m_max_bits = 0x1e0fffff; - s.m_bits = 0x1d00ffff; - s.m_timestamp = 0x99aabbcc; - s.m_absheight = 0x00010203; - s.m_abswork = uint128(0x0102030405060708ULL); - - // merged_coinbase_info: EMPTY (inert); merged_payout_hash: zero (inert) - s.m_last_txout_nonce = 0xdeadbeefcafef00dULL; - s.m_hash_link.m_state.m_data.assign(32, 0x00); - for (int i = 0; i < 32; ++i) s.m_hash_link.m_state.m_data[i] = static_cast(i); - s.m_hash_link.m_extra_data = BaseScript(std::vector{0xaa, 0xbb, 0xcc}); - s.m_hash_link.m_length = 128; - // ref_merkle_link / merkle_link: empty branches - // message_data: EMPTY (Phase A — messaging is Phase B) - - // ── DASH suffix ── - s.m_coinbase_payload = BaseScript(std::vector{0x01, 0x02}); - s.m_payment_amount = 0x00000000abcdef01ULL; - s.m_packed_payments.push_back(dash::PackedPayment{"!6a0401020304", 12345}); - s.m_coinbase_payload_outer = BaseScript(std::vector{0x02, 0x01, 0x02}); - return s; -} - -// Independently rebuild the STANDARDIZED prefix (min_header .. message_data) in -// the BCH v36 Formatter field order, using the same core primitives. This is the -// byte-parity reference: if DashFormatter::WriteV36 drifts from the standardized -// order/encoding this reconstruction diverges. -PackStream expected_standardized_prefix(const dash::DashV36Share& s) -{ - PackStream os; - os << s.m_min_header; // bch:154 - os << s.m_prev_hash << s.m_coinbase << s.m_nonce; // bch:156-160 - os << s.m_pubkey_hash; // bch:165 (v36 address) - os << s.m_pubkey_type; // bch:166 (v36) - ::Serialize(os, VarInt(s.m_subsidy)); // bch:180 (v36 VarInt) - os << s.m_donation; // bch:188 - { uint8_t si = static_cast(s.m_stale_info); os << si; } // bch:189 EnumType> - ::Serialize(os, VarInt(s.m_desired_version)); // bch:190 (version-vote) - // NO segwit_data (non-segwit — bch:194 is_segwit_activated(36)==false) - os << s.m_merged_addresses; // bch:202 (v36) - // NO tx_info (version >= 34) - os << s.m_far_share_hash << s.m_max_bits << s.m_bits - << s.m_timestamp << s.m_absheight; // bch:210-215 - ::Serialize(os, Using(s.m_abswork)); // bch:221 (v36) - os << s.m_merged_coinbase_info; // bch:231 (v36) - os << s.m_merged_payout_hash; // bch:232 (v36) - { ParamPackStream ps{dash::v36::MERKLE_LINK_SMALL, os}; ::Serialize(ps, s.m_ref_merkle_link); } // bch:237 - os << s.m_last_txout_nonce; // bch:239 - os << s.m_hash_link; // bch:241 (V36HashLinkType) - { ParamPackStream ps{dash::v36::MERKLE_LINK_SMALL, os}; ::Serialize(ps, s.m_merkle_link); } // bch:244 - os << s.m_message_data; // bch:250 (v36) - return os; -} - -// Serialize the DASH suffix alone (everything WriteV36 emits after message_data). -PackStream expected_dash_suffix(const dash::DashV36Share& s) -{ - PackStream os; - os << s.m_coinbase_payload; - os << s.m_payment_amount; - { - uint64_t count = s.m_packed_payments.size(); - ::Serialize(os, VarInt(count)); - for (auto& pay : s.m_packed_payments) { - BaseScript bs; bs.m_data.assign(pay.m_payee.begin(), pay.m_payee.end()); - os << bs; os << pay.m_amount; - } - } - os << s.m_coinbase_payload_outer; - return os; -} - -// Full v36 wire bytes via the production formatter. -PackStream pack_v36(const dash::DashV36Share& s) -{ - PackStream os; - dash::DashFormatter::Write(os, &s); // version-branch -> WriteV36 - return os; -} - -} // namespace - -// ── 1. BYTE-PARITY: standardized prefix matches the BCH v36 field order ────── -TEST(DashV36ShareByteParity, StandardizedPrefixMatchesCrossCoinV36Shape) -{ - auto s = make_canonical_v36(); - - PackStream full = pack_v36(s); - PackStream expect_prefix = expected_standardized_prefix(s); - PackStream expect_suffix = expected_dash_suffix(s); - - const std::string full_hex = pack_hex(full); - const std::string prefix_hex = pack_hex(expect_prefix); - const std::string suffix_hex = pack_hex(expect_suffix); - - // The production wire = standardized prefix ++ DASH suffix, in that order. - ASSERT_EQ(full_hex, prefix_hex + suffix_hex) - << "DashFormatter::WriteV36 must emit the standardized cross-coin v36 " - "prefix (bch/ltc/dgb non-segwit shape) followed by the DASH suffix"; - - // The standardized prefix is the consensus-critical cross-coin part. - EXPECT_EQ(full_hex.substr(0, prefix_hex.size()), prefix_hex) - << "standardized v36 prefix byte-parity"; -} - -// FROZEN GOLDEN regression sentinel for the standardized prefix + full wire. -// Captured from DashFormatter::WriteV36 on make_canonical_v36(); any change to -// the v36 wire layout must update these deliberately (a consensus boundary). -TEST(DashV36ShareByteParity, FrozenGoldenWire) -{ - auto s = make_canonical_v36(); - const std::string prefix_hex = pack_hex(expected_standardized_prefix(s)); - const std::string full_hex = pack_hex(pack_v36(s)); - - EXPECT_EQ(prefix_hex, std::string( - "02aa0000000000000000000000000000000000000000000000000000000000000044332211f0ff0f1e88776655010000000000000000000000000000000000000000000000000000000000000002abcd0d0c0b0aefbeadde0000000000000000000000000000000000ff00f2052a0100000034120024000200000000000000000000000000000000000000000000000000000000000000ffff0f1effff001dccbbaa9903020100ff0807060504030201000000000000000000000000000000000000000000000000000000000000000000000df0fecaefbeadde000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f03aabbcc800000")) - << "standardized v36 prefix golden drift"; - EXPECT_EQ(full_hex, std::string( - "02aa0000000000000000000000000000000000000000000000000000000000000044332211f0ff0f1e88776655010000000000000000000000000000000000000000000000000000000000000002abcd0d0c0b0aefbeadde0000000000000000000000000000000000ff00f2052a0100000034120024000200000000000000000000000000000000000000000000000000000000000000ffff0f1effff001dccbbaa9903020100ff0807060504030201000000000000000000000000000000000000000000000000000000000000000000000df0fecaefbeadde000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f03aabbcc80000002010201efcdab00000000010d21366130343031303230333034393000000000000003020102")) - << "full v36 wire golden drift"; -} - -// ── 2. ROUND-TRIP through real version-36 dispatch ─────────────────────────── -TEST(DashV36ShareRoundTrip, ThroughVersion36Dispatch) -{ - auto s = make_canonical_v36(); - PackStream wire = pack_v36(s); - - // Wrap as a RawShare(type=36) and load via the real ShareVariants dispatch. - chain::RawShare rshare(uint64_t{36}, wire); - ASSERT_EQ(rshare.type, 36u); - auto loaded = dash::load_v36_share(rshare, NetService{"test", 0}); - - loaded.invoke([&](auto* obj) { - using T = std::remove_pointer_t; - static_assert(std::is_same_v, - "type-36 wire must dispatch to DashV36Share"); - EXPECT_EQ(obj->m_min_header.m_version, s.m_min_header.m_version); - EXPECT_EQ(obj->m_min_header.m_bits, s.m_min_header.m_bits); - EXPECT_EQ(obj->m_prev_hash, s.m_prev_hash); - EXPECT_EQ(obj->m_coinbase.m_data, s.m_coinbase.m_data); - EXPECT_EQ(obj->m_nonce, s.m_nonce); - EXPECT_EQ(obj->m_pubkey_hash, s.m_pubkey_hash); - EXPECT_EQ(obj->m_pubkey_type, s.m_pubkey_type); - EXPECT_EQ(obj->m_subsidy, s.m_subsidy); - EXPECT_EQ(obj->m_donation, s.m_donation); - EXPECT_EQ(obj->m_desired_version, s.m_desired_version); - EXPECT_EQ(obj->m_far_share_hash, s.m_far_share_hash); - EXPECT_EQ(obj->m_max_bits, s.m_max_bits); - EXPECT_EQ(obj->m_bits, s.m_bits); - EXPECT_EQ(obj->m_timestamp, s.m_timestamp); - EXPECT_EQ(obj->m_absheight, s.m_absheight); - EXPECT_EQ(obj->m_abswork.GetLow64(), s.m_abswork.GetLow64()); - EXPECT_TRUE(obj->m_merged_addresses.empty()); - EXPECT_TRUE(obj->m_merged_coinbase_info.empty()); - EXPECT_EQ(obj->m_merged_payout_hash, s.m_merged_payout_hash); - EXPECT_EQ(obj->m_last_txout_nonce, s.m_last_txout_nonce); - EXPECT_EQ(obj->m_hash_link.m_state.m_data, s.m_hash_link.m_state.m_data); - EXPECT_EQ(obj->m_hash_link.m_extra_data.m_data, s.m_hash_link.m_extra_data.m_data); - EXPECT_EQ(obj->m_hash_link.m_length, s.m_hash_link.m_length); - // DASH suffix - EXPECT_EQ(obj->m_coinbase_payload.m_data, s.m_coinbase_payload.m_data); - EXPECT_EQ(obj->m_payment_amount, s.m_payment_amount); - ASSERT_EQ(obj->m_packed_payments.size(), s.m_packed_payments.size()); - EXPECT_EQ(obj->m_packed_payments[0].m_payee, s.m_packed_payments[0].m_payee); - EXPECT_EQ(obj->m_packed_payments[0].m_amount, s.m_packed_payments[0].m_amount); - EXPECT_EQ(obj->m_coinbase_payload_outer.m_data, s.m_coinbase_payload_outer.m_data); - }); - - // Re-serialize the loaded share: must be byte-identical to the original wire. - PackStream re; - loaded.Serialize(re); - EXPECT_EQ(pack_hex(re), pack_hex(wire)) << "v36 re-serialization must round-trip"; - - // Dispatch reports version 36. - EXPECT_EQ(loaded.version(), 36); -} - -// ── 3. MERGED fields inert => non-merged cross-coin convention ─────────────── -TEST(DashV36ShareMergedInert, EmptyMergedFieldsFollowNonMergedConvention) -{ - auto s = make_canonical_v36(); - ASSERT_TRUE(s.m_merged_addresses.empty()); - ASSERT_TRUE(s.m_merged_coinbase_info.empty()); - ASSERT_TRUE(s.m_merged_payout_hash.IsNull()); - - // An empty vector serializes as a single VarInt(0) == 0x00 byte; a zero - // uint256 payout hash as 32 zero bytes. Same as a standalone ltc/dgb/bch coin. - PackStream a; a << s.m_merged_addresses; - EXPECT_EQ(pack_hex(a), "00"); - PackStream c; c << s.m_merged_coinbase_info; - EXPECT_EQ(pack_hex(c), "00"); - PackStream p; p << s.m_merged_payout_hash; - EXPECT_EQ(pack_hex(p), std::string(64, '0')); - PackStream m; m << s.m_message_data; - EXPECT_EQ(pack_hex(m), "00") << "empty message_data (Phase A) == VarInt(0)"; -} - -// ── 4. version-vote (desired_version) is a VarInt the ratchet can read ──────── -TEST(DashV36ShareVersionVote, DesiredVersionRidesAsVarInt) -{ - auto s = make_canonical_v36(); - s.m_desired_version = 36; - PackStream a; ::Serialize(a, VarInt(s.m_desired_version)); - EXPECT_EQ(pack_hex(a), "24"); // 36 == 0x24, single-byte CompactSize - - s.m_desired_version = 300; // multi-byte CompactSize boundary - PackStream b; ::Serialize(b, VarInt(s.m_desired_version)); - EXPECT_EQ(pack_hex(b), "fd2c01"); -} - -// ── 5. BACKWARD-COMPAT: v16 DashShare wire is UNCHANGED and DISTINCT ───────── -TEST(DashV16BackwardCompat, V16RoundTripsAndIsDistinctFromV36) -{ - // Minimal v16 share. - dash::DashShare v16; - v16.m_min_header.m_version = 2; - v16.m_min_header.m_bits = 0x1e0ffff0; - v16.m_prev_hash.SetHex( - "0000000000000000000000000000000000000000000000000000000000000001"); - v16.m_coinbase = BaseScript(std::vector{0xab, 0xcd}); - v16.m_nonce = 0x0a0b0c0d; - v16.m_pubkey_hash.SetHex("00000000000000000000000000000000deadbeef"); - v16.m_subsidy = 5000000000ULL; - v16.m_donation = 0x1234; - v16.m_desired_version = 16; - v16.m_bits = 0x1d00ffff; - v16.m_hash_link.m_state.m_data.assign(32, 0x00); - v16.m_hash_link.m_length = 0; - - // v16 packs via the live ShareType formatter (else-branch) and round-trips - // through the live LoadMethods[16] dispatch — byte-unchanged by the refactor. - PackStream w16; dash::DashFormatter::Write(w16, &v16); - chain::RawShare r16(uint64_t{16}, w16); - auto loaded16 = dash::load_share(r16, NetService{"test", 0}); - EXPECT_EQ(loaded16.version(), 16); - PackStream re16; loaded16.Serialize(re16); - EXPECT_EQ(pack_hex(re16), pack_hex(w16)) << "v16 wire must round-trip unchanged"; - - // v16-specific ordering pin: coinbase_payload is serialized IMMEDIATELY after - // coinbase (v16 share_data), a slot the v36 layout does NOT have there. Prove - // the two layouts are distinct for the same logical inputs. - auto v36 = make_canonical_v36(); - PackStream w36; dash::DashFormatter::Write(w36, &v36); - EXPECT_NE(pack_hex(w16), pack_hex(w36)) - << "v16 and v36 wire layouts must differ (distinct share formats)"; -} diff --git a/test/test_dash_work_source.cpp b/test/test_dash_work_source.cpp index c0cec8811..71915a609 100644 --- a/test/test_dash_work_source.cpp +++ b/test/test_dash_work_source.cpp @@ -241,98 +241,3 @@ TEST(HashrateVardiffQuantize, QuantizationFormulaIsFloorLog2) EXPECT_GT(2.0 * q, D); } } - -// Hysteresis KAT (F-2). After the F-1 fix quantizes advertised diff to powers of -// two, the advertised value and current_difficulty_ both live on the 2^n grid, so -// a dead-band on the QUANTIZED ratio is inert (ratios are only 1, 2, 0.5). A rig -// whose un-quantized ideal D sits near a 2^n boundary then flaps its advertised -// bucket 2^n <-> 2^(n+1) on estimator noise, doubling set_difficulty churn and -// jittering rig-side hashrate graphs. The fix holds the current power-of-two -// bucket [C, 2C) and only re-quantizes when the UN-QUANTIZED ideal D leaves it -// DECISIVELY: at/above 2C by the dead-band margin (step up) or below C by the -// margin (step down). These KATs would FAIL pre-fix (a bare round-DOWN quantize -// steps the instant D crosses the plain 2^n edge). deadband = 0.10 (default). -namespace { -constexpr double kHystTarget = 10.0; // set_target_time_per_mining_share -constexpr double kHystTau = 90.0; // vardiff_ewma_tau_ (fixed default) -constexpr double kDeadband = 0.10; // vardiff_deadband_ (default) - -// Configure a tracker seeded (via hint) at an on-grid power-of-two bucket C. -// Passed by reference: HashrateTracker holds a std::mutex and is non-copyable. -void setup_hyst_tracker(c2pool::hashrate::HashrateTracker& t, double seed_C) { - t.set_difficulty_bounds(0.0005, 1e9); // wide: exercise hysteresis, not clamp - t.set_target_time_per_mining_share(kHystTarget); - t.set_hashrate_vardiff(true); - t.enable_vardiff(true); - t.set_difficulty_hint(seed_C); // current bucket C (a power of two) -} -} // namespace - -// UP: an ideal D that rises PAST a 2^n boundary but stays within the hysteresis -// band does NOT flap the advertised bucket; a decisive move past 2C*(1+deadband) -// steps. Seeded bucket C = 8 (2^3); boundary of interest is the upper edge 16. -TEST(HashrateVardiffHysteresis, BoundaryNoiseHeldButDecisiveMoveStepsUp) -{ - // per-share issued diff => ideal D grows ~2.06/share; at 8 shares D ~ 16.5, - // i.e. just above the 2^n edge (16) yet below 2C*(1+deadband) = 17.6. - constexpr double kDiff = 3.70; - - c2pool::hashrate::HashrateTracker t; - setup_hyst_tracker(t, 8.0); - for (int i = 0; i < 8; ++i) - t.record_mining_share_submission(kDiff, /*accepted=*/true); - - const double D8 = ideal_D(8, kDiff, kHystTarget, kHystTau); - // Precondition: D has crossed the plain 2^n edge (a pre-fix quantize would - // already advertise 16 here) but sits inside the upper hysteresis band. - ASSERT_GT(D8, 16.0) << "test mis-scaled: D8=" << D8; - ASSERT_LT(D8, 2.0 * 8.0 * (1.0 + kDeadband)) << "test mis-scaled: D8=" << D8; - // No-flap: advertised bucket held at C = 8 despite D having crossed 16. - EXPECT_EQ(t.get_current_difficulty(), 8.0) - << "boundary noise flapped the advertised bucket (D8=" << D8 << ")"; - - // Decisive move up: push D well past 2C*(1+deadband)=17.6. - for (int i = 8; i < 13; ++i) - t.record_mining_share_submission(kDiff, /*accepted=*/true); - const double D13 = ideal_D(13, kDiff, kHystTarget, kHystTau); - ASSERT_GE(D13, 2.0 * 8.0 * (1.0 + kDeadband)) << "test mis-scaled: D13=" << D13; - // Steps to the largest power of two <= D (round-DOWN preserved): 16. - EXPECT_EQ(t.get_current_difficulty(), 16.0) - << "decisive move did not step the advertised bucket (D13=" << D13 << ")"; - EXPECT_LE(t.get_current_difficulty(), D13); // never advertise above ideal D -} - -// DOWN: an ideal D that dips BELOW the current bucket's lower edge but stays -// within the margin holds; a decisive drop below C*(1-deadband) steps down. -// Two trackers both seeded at C = 16 (2^4). -TEST(HashrateVardiffHysteresis, LowerMarginHeldButDecisiveDropStepsDown) -{ - // Hold case: at warm-up completion (4 shares) D ~ 15.2 -- below the lower - // edge 16 yet at/above C*(1-deadband) = 14.4. Pre-fix would step down to 8. - { - constexpr double kDiff = 6.815; // D(4) ~ 15.2 - c2pool::hashrate::HashrateTracker t; - setup_hyst_tracker(t, 16.0); - for (int i = 0; i < 4; ++i) // exactly warm-up: first adjust fires - t.record_mining_share_submission(kDiff, /*accepted=*/true); - const double D4 = ideal_D(4, kDiff, kHystTarget, kHystTau); - ASSERT_LT(D4, 16.0) << "test mis-scaled: D4=" << D4; - ASSERT_GE(D4, 16.0 * (1.0 - kDeadband)) << "test mis-scaled: D4=" << D4; - EXPECT_EQ(t.get_current_difficulty(), 16.0) - << "lower-margin noise flapped the advertised bucket down (D4=" << D4 << ")"; - } - // Decisive case: D(4) ~ 11.0, clearly below C*(1-deadband)=14.4 -> step down. - { - constexpr double kDiff = 4.932; // D(4) ~ 11.0 - c2pool::hashrate::HashrateTracker t; - setup_hyst_tracker(t, 16.0); - for (int i = 0; i < 4; ++i) - t.record_mining_share_submission(kDiff, /*accepted=*/true); - const double D4 = ideal_D(4, kDiff, kHystTarget, kHystTau); - ASSERT_LT(D4, 16.0 * (1.0 - kDeadband)) << "test mis-scaled: D4=" << D4; - // Steps to the largest power of two <= D (round-DOWN preserved): 8. - EXPECT_EQ(t.get_current_difficulty(), 8.0) - << "decisive drop did not step the advertised bucket (D4=" << D4 << ")"; - EXPECT_LE(t.get_current_difficulty(), D4); // never advertise above ideal D - } -}