diff --git a/src/c2pool/main_dash.cpp b/src/c2pool/main_dash.cpp index 9ce61c345..50b350ece 100644 --- a/src/c2pool/main_dash.cpp +++ b/src/c2pool/main_dash.cpp @@ -839,6 +839,22 @@ int run_node(bool testnet, const std::string& rpc_endpoint, "walk bound)\n"; } + // Stale-payee fix (defect 3): bind the CoindRPC reconnect-churn observer. + // A "CoindRPC reconnecting" window means any cached template — and the + // masternode payee frozen inside it — may predate the reconnect; the + // observer drops the DASHWorkSource template cache and bumps the work + // generation so every stratum session re-pulls FRESH work instead of + // mining (and later submitting) a payee from before the churn. weak_ptr: + // rpc outlives work_source in this scope (declared earlier), so the + // callback must not extend or assume the work source's lifetime. + if (rpc) { + std::weak_ptr ws_weak = work_source; + rpc->set_on_reconnect([ws_weak]() { + if (auto ws = ws_weak.lock()) + ws->invalidate_template_cache("CoindRPC reconnect churn"); + }); + } + if (stratum_port != 0) { stratum_server = std::make_unique( ioc, stratum_host, stratum_port, work_source); diff --git a/src/core/stratum_server.cpp b/src/core/stratum_server.cpp index 0a003fce3..e9fe93b38 100644 --- a/src/core/stratum_server.cpp +++ b/src/core/stratum_server.cpp @@ -1451,13 +1451,82 @@ void StratumSession::send_notify_work(bool force_clean, const uint256* frozen_be } } + // ── ATOMIC SNAPSHOT: header fields (stale-payee fix) ── + // When the work source froze the header fields WITH the coinbase + // (snapshot.has_header — DASH does; the masternode payee rotates every + // block), they OVERRIDE the tmpl values fetched separately at the top of + // this function. tmpl and the coinbase are otherwise two independent + // fetches that can straddle a template refresh (TTL expiry / tip move / + // RPC-reconnect churn): the issued job would then carry the NEW tip's + // prevhash with a coinbase built for the OLD height's masternode payee — + // hex-confirmed on DASH testnet as a submit-time bad-cb-payee reject of a + // byte-correct-at-build coinbase (a lost block reward). With the override, + // header + coinbase + branches + tx set are ONE frozen template unit. + // Work sources that leave has_header false are byte-unchanged. + if (cbr.snapshot.has_header) { + gbt_prevhash = cbr.snapshot.gbt_prevhash; + prevhash = gbt_to_stratum_prevhash(gbt_prevhash); + version_u32 = cbr.snapshot.header_version; + { + std::ostringstream ss; + ss << std::hex << std::setw(8) << std::setfill('0') << version_u32; + version = ss.str(); + } + if (!cbr.snapshot.block_nbits_hex.empty()) { + gbt_block_nbits = cbr.snapshot.block_nbits_hex; + nbits = gbt_block_nbits; + } + if (cbr.snapshot.curtime) { + curtime = cbr.snapshot.curtime; + std::ostringstream ts; + ts << std::hex << std::setw(8) << std::setfill('0') << curtime; + ntime = ts.str(); + } + } + + // ── Unmineable-job gate (stale-payee fix, defect 2) ── + // Refuse to register/serve a job that is not real mineable work: + // (a) require_job_snapshot set (DASH) but the per-connection builder did + // NOT return an atomic snapshot — assembling a job from the separate + // tmpl fetch + the stub/global coinbase would be exactly the MIXED + // template unit the atomic binding above exists to prevent; + // (b) an empty / all-zero prevhash (the observed pre-auth zero-hash + // job_0 defect): a zeroed prev is not work on any chain. + // Same retry posture as the no-template path at the top: skip + 1 s timer. + if (mining_interface_->get_stratum_config().require_job_snapshot) { + const bool snapshot_missing = !cbr.snapshot.has_header; + const bool zero_prev = gbt_prevhash.empty() + || gbt_prevhash.find_first_not_of('0') == std::string::npos; + if (snapshot_missing || zero_prev) { + const std::string& sym = + mining_interface_->get_stratum_config().coin_symbol; + LOG_WARNING << "[" << (sym.empty() ? "Stratum" : sym) + << "] job suppressed (" + << (snapshot_missing ? "no atomic header+coinbase snapshot" + : "empty/zero prevhash") + << ") — not serving unmineable/mixed work; retrying in 1s"; + auto timer = std::make_shared(socket_.get_executor()); + timer->expires_after(std::chrono::seconds(1)); + timer->async_wait([weak_self = weak_from_this(), timer](boost::system::error_code ec) { + if (ec) return; + auto self = weak_self.lock(); + if (!self || !self->is_connected()) return; + self->send_notify_work(); + }); + return; + } + } + // ── ATOMIC SNAPSHOT: merkle branches ── // Override merkle branches with snapshot captured atomically with coinbase. // This ensures the merkle tree matches the witness commitment in coinb1. - // NOTE: Header fields (prevhash, version, bits, ntime) are NOT overridden — - // they come from the current daemon state and are independent of the - // coinbase/tx_data consistency. Overriding them caused GENTX validation - // failures on p2pool nodes (absheight, bits mismatch). + // NOTE: For work sources without has_header, the header fields (prevhash, + // version, bits, ntime) are NOT overridden — they come from the current + // daemon state and are independent of the coinbase/tx_data consistency. + // Overriding them from a DIFFERENT fetch caused GENTX validation failures + // on p2pool nodes (absheight, bits mismatch); the has_header override + // above is safe because its fields come from the SAME snapshot as the + // coinbase itself. if (!cbr.snapshot.merkle_branches.empty()) { merkle_branches_vec = std::move(cbr.snapshot.merkle_branches); merkle_branches = nlohmann::json::array(); diff --git a/src/core/stratum_types.hpp b/src/core/stratum_types.hpp index 82846b727..2b1eb2494 100644 --- a/src/core/stratum_types.hpp +++ b/src/core/stratum_types.hpp @@ -57,6 +57,15 @@ struct StratumConfig { // a DASH binary never logs "[LTC]" (issue #732 secondary defect). Empty // -> the core falls back to the neutral "[Stratum]" tag. std::string coin_symbol; + // Coins whose coinbase validity is bound to the template height (DASH: + // the masternode payee rotates EVERY block -> a coinbase built from one + // template combined with the header of another is rejected bad-cb-payee) + // set this true. The session then refuses to assemble a job unless + // build_connection_coinbase() returned an atomic header+coinbase snapshot + // (WorkSnapshot::has_header): no job is ever served from MIXED template + // fetches, and the legacy stub-coinbase fallback is unreachable. + // Default false: LTC/BTC/DGB behavior is byte-unchanged. + bool require_job_snapshot{false}; }; /// Frozen share-construction fields returned by ref_hash_fn. These @@ -125,6 +134,21 @@ struct WorkSnapshot { // prevent merkle root mismatch when refresh_work() updates the template. std::shared_ptr> tx_data; // raw tx hex from template (a1: shared/lazy) std::vector merkle_branches; // stratum merkle branches + // ── Atomic header binding (stale-payee fix) ────────────────────────── + // Header fields frozen from the SAME template snapshot the coinbase was + // built over. When has_header is true the stratum session OVERRIDES the + // header fields it fetched separately via get_current_work_template() + // with these, so the issued job (prevhash/version/nbits/ntime + coinbase + // + merkle branches + tx set) is ONE frozen unit from ONE template — a + // job can never carry the new tip's header with the old height's + // masternode payee (dashd bad-cb-payee, a lost block reward). Work + // sources that leave has_header false (LTC/BTC/DGB) are byte-unchanged. + bool has_header{false}; + std::string gbt_prevhash; // BE display hex (template previousblockhash) + uint32_t header_version{0}; // block header version + std::string block_nbits_hex; // 8-char BE hex GBT block bits + uint32_t curtime{0}; // template curtime (0 = leave session value) + uint32_t height{0}; // template height (diagnostics/logging) }; /// Result of build_connection_coinbase(): the two coinbase fragments diff --git a/src/impl/dash/coin/rpc.cpp b/src/impl/dash/coin/rpc.cpp index 48ff5c52a..83a28926e 100644 --- a/src/impl/dash/coin/rpc.cpp +++ b/src/impl/dash/coin/rpc.cpp @@ -115,12 +115,23 @@ void NodeRPC::reconnect() m_connected = false; LOG_WARNING << "RPC connection lost — reconnecting in 15 seconds..."; m_stream.close(); + // Stale-payee fix: the connection churned — anything cached from before + // this point (template / masternode payee) must not be served or + // submitted. Notify the observer (DASHWorkSource cache invalidation). + if (m_on_reconnect) + m_on_reconnect(); m_reconnect_timer = std::make_unique(m_context, false); m_reconnect_timer->start(15, [this]() { connect(m_address, m_userpass); }); } void NodeRPC::sync_reconnect() { + // Stale-payee fix: fire the churn observer FIRST — the caller (Send) is + // about to retry against a fresh connection; any template cached from the + // dead connection's era is suspect and must be invalidated. + if (m_on_reconnect) + m_on_reconnect(); + beast::error_code ec; m_stream.socket().shutdown(io::ip::tcp::socket::shutdown_both, ec); m_stream.close(); diff --git a/src/impl/dash/coin/rpc.hpp b/src/impl/dash/coin/rpc.hpp index bc4fb9305..ff807dde7 100644 --- a/src/impl/dash/coin/rpc.hpp +++ b/src/impl/dash/coin/rpc.hpp @@ -40,6 +40,7 @@ #include "rpc_data.hpp" #include "node_interface.hpp" +#include #include #include @@ -84,6 +85,13 @@ class NodeRPC : public jsonrpccxx::IClientConnector std::string m_userpass; bool m_connected = false; std::unique_ptr m_reconnect_timer; + // Reconnect-churn observer (stale-payee fix): fired whenever the RPC + // connection is torn down / re-established (reconnect(), sync_reconnect()). + // main_dash.cpp wires this to DASHWorkSource::invalidate_template_cache() + // so no stratum job is ever served or submitted from a template/masternode + // payee cached from BEFORE the churn window. Assigned once at startup, + // before the io loop runs. + std::function m_on_reconnect; std::string Send(const std::string &request) override; nlohmann::json CallAPIMethod(const std::string& method, const jsonrpccxx::positional_parameter& params = {}); @@ -95,6 +103,9 @@ class NodeRPC : public jsonrpccxx::IClientConnector void connect(NetService address, std::string userpass); void reconnect(); void sync_reconnect(); + /// Register the reconnect-churn observer (see m_on_reconnect). Call once + /// at startup before the io loop runs. + void set_on_reconnect(std::function fn) { m_on_reconnect = std::move(fn); } bool check(); bool check_blockheader(uint256 header); diff --git a/src/impl/dash/stratum/submit_payee_guard.hpp b/src/impl/dash/stratum/submit_payee_guard.hpp new file mode 100644 index 000000000..307b7e114 --- /dev/null +++ b/src/impl/dash/stratum/submit_payee_guard.hpp @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +#pragma once + +// dash::stratum submit-time masternode-payee guard (stale-payee fix, defect 4). +// +// DASH rotates the masternode payee EVERY block, so a coinbase is only valid +// at the exact height its template was fetched for. A won block whose header +// prev matches the CURRENT tip but whose coinbase carries a payee frozen from +// an OLDER template snapshot is deterministically rejected by dashd with +// bad-cb-payee — the block reward is lost. This was hex-confirmed live on +// testnet (.52) at h1517420: the reassembled stratum coinbase was byte-correct +// against the GBT at job-BUILD time, yet rejected at SUBMIT time because the +// payee had rotated in between (same staleness class as the h1517187 +// bad-chainlock sibling). +// +// check_submit_payee() is the last line of defense, called by +// DASHWorkSource::mining_submit() on the won-block path BEFORE dispatching to +// the network: it compares the job's reassembled coinbase outputs against the +// GBT-mandated payments of the CURRENT template. +// +// - TipMoved: the job's prev != current template prev. The block is +// internally consistent for ITS OWN height (its payee +// matches its own prev), so it is still submitted — it is +// an orphan-race candidate, not a doomed bad-cb-payee. +// - StalePayee: prev matches (same height) but a GBT-mandated payment +// (masternode / superblock / platform burn) is MISSING or +// amount-mismatched in the coinbase → dashd WILL reject +// bad-cb-payee. The caller must NOT submit (reject-loud — +// the steward-ruled posture; never silently submit a +// doomed block). +// - Unverifiable: the coinbase failed to deserialize. Never block a +// submission on a guard-side parse failure — submit, log. +// - Ok: every current GBT-mandated payment is present exactly. +// +// Pure + fenced: no sockets, no RPC, no locks — direct KAT surface +// (test_dash_stratum_work_source.cpp). The payee→script decode reuses the +// verifier-shared SSOT dash::decode_payee_script (share_check.hpp) so the +// guard can never disagree with the builder about script shapes. + +#include // DashWorkData, PackedPayment +#include // MutableTransaction (DASH tx codec) +#include // decode_payee_script (payee SSOT) + +#include +#include + +#include +#include +#include +#include + +namespace dash::stratum { + +enum class PayeeGuardVerdict { + Ok, ///< all current GBT-mandated payments present — submit + TipMoved, ///< job prev != current prev — orphan race, still submit + StalePayee, ///< same prev, payee missing/mismatched — DO NOT submit + Unverifiable, ///< coinbase would not parse — submit (never guard-block on parse) +}; + +struct PayeeGuardResult { + PayeeGuardVerdict verdict{PayeeGuardVerdict::Ok}; + std::string detail; ///< human-readable reason for the log line +}; + +inline PayeeGuardResult check_submit_payee( + const std::vector& coinbase_bytes, + const std::string& job_gbt_prevhash, + const dash::coin::DashWorkData& current, + const core::CoinParams& params) +{ + PayeeGuardResult r; + + // Height context: same prev == same height == same expected payee set. + // A differing prev means the tip moved after the job was issued; the + // block is self-consistent for its own height (orphan-race candidate). + if (current.m_previous_block.GetHex() != job_gbt_prevhash) { + r.verdict = PayeeGuardVerdict::TipMoved; + r.detail = "job prev=" + job_gbt_prevhash.substr(0, 16) + + " current prev=" + current.m_previous_block.GetHex().substr(0, 16) + + " (tip moved since job issue — orphan-race submit)"; + return r; + } + + // Deserialize the reassembled coinbase through the DASH tx codec (the + // same PackStream >> MutableTransaction path rpc.cpp uses for GBT txs). + dash::coin::MutableTransaction cb; + try { + PackStream ps(coinbase_bytes); + ps >> cb; + } catch (const std::exception& e) { + r.verdict = PayeeGuardVerdict::Unverifiable; + r.detail = std::string("coinbase deserialize failed: ") + e.what(); + return r; + } + + // Every GBT-mandated payment (masternode / superblock / platform burn) of + // the CURRENT template must appear as an exact (script, amount) output. + for (const auto& pay : current.m_packed_payments) { + if (pay.amount == 0) + continue; + const auto want_script = dash::decode_payee_script( + pay.payee, params.address_version, params.address_p2sh_version); + if (want_script.empty()) + continue; // undecodable payee — builder drops these too + bool found = false; + for (const auto& out : cb.vout) { + if (out.value == static_cast(pay.amount) + && out.scriptPubKey.m_data == want_script) { + found = true; + break; + } + } + if (!found) { + std::ostringstream ss; + ss << "GBT-mandated payment MISSING from coinbase: payee=" + << pay.payee << " amount=" << pay.amount + << " (height " << current.m_height + << " payee set != job's frozen payee set — dashd would reject" + " bad-cb-payee)"; + r.verdict = PayeeGuardVerdict::StalePayee; + r.detail = ss.str(); + return r; + } + } + + r.verdict = PayeeGuardVerdict::Ok; + r.detail = "all " + std::to_string(current.m_packed_payments.size()) + + " GBT-mandated payments present at current height"; + return r; +} + +} // namespace dash::stratum diff --git a/src/impl/dash/stratum/work_source.cpp b/src/impl/dash/stratum/work_source.cpp index 8a39f929e..662455a53 100644 --- a/src/impl/dash/stratum/work_source.cpp +++ b/src/impl/dash/stratum/work_source.cpp @@ -35,6 +35,7 @@ #include +#include // check_submit_payee (won-block stale-payee gate) #include // compute_dash_payouts, build, split_coinb, merkle helpers #include // compute_merkle_root, append_compact_size, target_from_nbits #include // dash::crypto::hash_x11 (X11 PoW SSOT) @@ -119,6 +120,13 @@ DASHWorkSource::DASHWorkSource(const coin::NodeCoinState& coin_state, // defect: the core hardcoded "[LTC]" and a DASH binary logged LTC). if (config_.coin_symbol.empty()) config_.coin_symbol = "DASH"; + // Stale-payee fix: DASH's coinbase validity is height-bound (the + // masternode payee rotates EVERY block), so the stratum session must + // NEVER assemble a job from mixed template fetches — it requires the + // atomic header+coinbase snapshot build_connection_coinbase() returns + // (WorkSnapshot::has_header) and suppresses the job otherwise. This also + // makes the core's legacy stub-coinbase fallback unreachable for DASH. + config_.require_job_snapshot = true; LOG_INFO << "[DASH-STRATUM] DASHWorkSource constructed" << " (min_diff=" << config_.min_difficulty << " max_diff=" << config_.max_difficulty @@ -259,9 +267,11 @@ std::shared_ptr DASHWorkSource::cached_work() const } std::lock_guard lk(template_mutex_); - if (work.m_bits == 0) { - // Set-gap (unarmed fallback / empty GBT): an honest absence. Keep any - // previous cache DROPPED -- serving a stale tip is worse than waiting. + if (work.m_bits == 0 || work.m_previous_block.IsNull()) { + // Set-gap (unarmed fallback / empty GBT) OR a zero-prev template (the + // pre-auth zero-hash job_0 defect: a zeroed prev is not mineable work + // on any chain): an honest absence. Keep any previous cache DROPPED -- + // serving a stale tip is worse than waiting. template_cache_.reset(); template_last_fail_at_ = now; return nullptr; @@ -353,6 +363,21 @@ core::stratum::CoinbaseResult DASHWorkSource::build_connection_coinbase( // block body assembled at submit time. Used by BOTH coinbase paths below. auto freeze_snapshot = [&](core::stratum::CoinbaseResult& out, const uint256& ref_hash) { + // Freeze the header fields ATOMICALLY with the coinbase: prevhash / + // version / nbits / ntime come from the SAME wd the coinbase was + // built over, so the session's issued job and the block body + // assembled at submit time can never mix template generations. This + // is the stale-payee root-cause fix: the masternode payee inside + // THIS coinbase is only valid at THIS wd's height — a job carrying a + // different fetch's prevhash with this coinbase is a guaranteed + // dashd bad-cb-payee reject (hex-confirmed @h1517420). + out.snapshot.has_header = true; + out.snapshot.gbt_prevhash = wd->m_previous_block.GetHex(); + out.snapshot.header_version = static_cast(wd->m_version); + out.snapshot.block_nbits_hex = dash::coinbase::be_hex_u32(wd->m_bits); + out.snapshot.curtime = wd->m_curtime + ? wd->m_curtime : static_cast(std::time(nullptr)); + out.snapshot.height = wd->m_height; out.snapshot.subsidy = wd->m_coinbase_value; out.snapshot.frozen_ref.ref_hash = ref_hash; if (!wd->m_tx_hashes.empty()) { @@ -473,7 +498,8 @@ core::stratum::CoinbaseResult DASHWorkSource::build_connection_coinbase( out.coinb1 = std::move(split.coinb1_hex); out.coinb2 = std::move(split.coinb2_hex); - // Freeze the snapshot ATOMICALLY with the coinbase (shared helper). + // Freeze the snapshot ATOMICALLY with the coinbase (shared helper: + // header fields + branches + tx set from the SAME wd). freeze_snapshot(out, ref_hash); return out; } catch (const std::exception& e) { @@ -607,12 +633,57 @@ nlohmann::json DASHWorkSource::mining_submit( block_bytes.insert(block_bytes.end(), tx_bytes.begin(), tx_bytes.end()); } + // ── SUBMIT-TIME PAYEE GUARD (stale-payee fix, defect 4) ────────── + // Last line of defense: validate the job's frozen coinbase against + // the CURRENT template's GBT-mandated payments before dispatch. A + // same-height payee mismatch is a deterministic dashd bad-cb-payee + // reject (the hex-confirmed h1517420 class) — reject LOUDLY here + // instead of submitting a doomed block (steward-ruled posture). A + // moved tip is an orphan-race candidate and still submits; a guard- + // side parse failure never blocks a submission. + bool payee_guard_reject = false; + if (wd) { + const auto guard = check_submit_payee( + coinbase, job->gbt_prevhash, *wd, + dash::make_coin_params(is_testnet_)); + switch (guard.verdict) { + case PayeeGuardVerdict::StalePayee: + payee_guard_reject = true; + LOG_ERROR << "[DASH-STRATUM-PAYEE-GUARD] WON BLOCK LOCALLY " + "REJECTED, NOT submitted: " << guard.detail + << " user=" << username << " job=" << job_id + << " -- a stale-payee coinbase is a guaranteed " + "bad-cb-payee network reject; the job/template " + "pipeline served stale work (investigate!)"; + break; + case PayeeGuardVerdict::TipMoved: + LOG_WARNING << "[DASH-STRATUM-PAYEE-GUARD] " << guard.detail + << " -- submitting anyway (block is self-" + "consistent for its own height)"; + break; + case PayeeGuardVerdict::Unverifiable: + LOG_WARNING << "[DASH-STRATUM-PAYEE-GUARD] guard could not " + "verify (" << guard.detail + << ") -- submitting unblocked"; + break; + case PayeeGuardVerdict::Ok: + LOG_INFO << "[DASH-STRATUM-PAYEE-GUARD] coinbase payee set " + "verified against current template (" + << guard.detail << ")"; + break; + } + } + // Dual-path won-block dispatch: submit_block_fn_ (bound in // main_dash.cpp) relays via the dashd submitblock RPC arm + the // embedded P2P relay leg when armed; true iff >=1 sink reached. A won // block reaching NEITHER is a lost subsidy -- scream, never drop. bool reached_network = false; - if (submit_block_fn_) { + if (payee_guard_reject) { + // Loud local reject: nothing dispatched. The share still counts + // for the miner below — the miner's work was honest; the stale + // template was ours. + } else if (submit_block_fn_) { try { reached_network = submit_block_fn_(block_bytes, height); } catch (const std::exception& e) { @@ -778,4 +849,24 @@ void DASHWorkSource::set_producer_job_fn(ProducerJobFn fn) producer_job_fn_ = std::move(fn); } +void DASHWorkSource::invalidate_template_cache(const char* reason) +{ + // Stale-payee fix (defect 3): a CoindRPC reconnect churn means the cached + // template — and the masternode payee frozen inside it — may predate the + // reconnect. Drop the cache (next cached_work() re-sources through the + // embedded/fallback selector) and bump work_generation so EVERY stratum + // session re-pushes fresh work on its next heartbeat instead of letting + // miners keep hashing jobs whose payee may already have rotated. + { + std::lock_guard lk(template_mutex_); + template_cache_.reset(); + template_last_fail_at_ = {}; // allow an immediate re-source + } + work_generation_.fetch_add(1, std::memory_order_relaxed); + LOG_WARNING << "[DASH-STRATUM] template cache INVALIDATED (" << reason + << ") -- cached GBT/masternode-payee snapshot dropped; all " + "sessions will re-pull fresh work (generation=" + << work_generation_.load(std::memory_order_relaxed) << ")"; +} + } // namespace dash::stratum diff --git a/src/impl/dash/stratum/work_source.hpp b/src/impl/dash/stratum/work_source.hpp index 36107fbd5..def29cf3a 100644 --- a/src/impl/dash/stratum/work_source.hpp +++ b/src/impl/dash/stratum/work_source.hpp @@ -269,6 +269,14 @@ class DASHWorkSource : public core::stratum::IWorkSource /// and share-target submissions cannot mint (loud fail-closed decline). void set_producer_job_fn(ProducerJobFn fn); + /// Drop the cached template snapshot + bump work_generation. Wired to the + /// NodeRPC reconnect hook in main_dash.cpp (stale-payee fix, defect 3): + /// a "CoindRPC reconnecting" churn means any cached template — and the + /// masternode payee frozen inside it — may predate the reconnect, so no + /// further job may be served from it. The generation bump makes every + /// stratum session re-pull a FRESH template on its next heartbeat. + void invalidate_template_cache(const char* reason = "reconnect"); + private: /// Template cache resolve: return the cached DashWorkData snapshot when it /// is still fresh (same work_generation AND younger than the staleness diff --git a/test/test_dash_stratum_work_source.cpp b/test/test_dash_stratum_work_source.cpp index d256956b2..de558a038 100644 --- a/test/test_dash_stratum_work_source.cpp +++ b/test/test_dash_stratum_work_source.cpp @@ -17,6 +17,7 @@ #include +#include // check_submit_payee (stale-payee fix) #include // compute_dash_payouts, build, split_coinb, be_hex_u32 #include // coinbase_txid, compute_merkle_root, serialize_header80, target_from_nbits #include @@ -608,4 +609,258 @@ TEST(DashStratumWorkSource, MiningSubmitRoutesShareIntoMintSeam) EXPECT_EQ(seen.pow_hash, rig.pow_for(nonce)); } +// ═════════════════════════════════════════════════════════════════════════════ +// Stale-payee fix KATs (bad-cb-payee root cause; hex-confirmed @h1517420). +// DASH rotates the masternode payee EVERY block: a job must be ONE frozen +// template unit (header + coinbase + branches + txs), the template cache must +// die on RPC-reconnect churn, and a won block whose coinbase no longer matches +// the current height's payee set must be rejected LOUDLY, never submitted. +// ═════════════════════════════════════════════════════════════════════════════ + +// A rotated sibling of rich_work(): the NEXT height, new prev, and — the DASH +// property under test — a DIFFERENT masternode payee (pkh 0x33, new amount). +const char* const kRotatedPrevHashHex = + "00000000000000c4d5e6f708192a3b4c5d6e2f8c9e5a1d4b3c6e7f8091a2b3c4"; + +std::string rotated_mn_payee() +{ + std::vector s = {0x76, 0xa9, 0x14}; + s.insert(s.end(), 20, 0x33); + s.push_back(0x88); + s.push_back(0xac); + return "!" + HexStr(std::span(s.data(), s.size())); +} + +dash::coin::DashWorkData rotated_work() +{ + dash::coin::DashWorkData w = rich_work(); + w.m_previous_block.SetHex(kRotatedPrevHashHex); + w.m_height += 1; + w.m_packed_payments.clear(); + dash::coin::PackedPayment mn; + mn.payee = rotated_mn_payee(); + mn.amount = kMnAmount + 12345; // rotation also changes the mandated amount + w.m_packed_payments.push_back(mn); + w.m_payment_amount = mn.amount; + return w; +} + +// Fix 1 pin: build_connection_coinbase freezes the header fields ATOMICALLY +// with the coinbase (WorkSnapshot::has_header) — the fields the stratum +// session must use INSTEAD of its separately-fetched template. +TEST(DashStratumWorkSource, SnapshotFreezesHeaderAtomicallyWithCoinbase) +{ + Fixture fx(true); + auto ws = fx.make(); + auto cbr = ws->build_connection_coinbase(uint256::ZERO, "00000000", + fixture_miner_script(), {}); + ASSERT_FALSE(cbr.coinb1.empty()); + EXPECT_TRUE(cbr.snapshot.has_header); + EXPECT_EQ(cbr.snapshot.gbt_prevhash, std::string(kPrevHashHex)); + EXPECT_EQ(cbr.snapshot.header_version, static_cast(kVersion)); + EXPECT_EQ(cbr.snapshot.block_nbits_hex, "1e0ffff0"); + EXPECT_EQ(cbr.snapshot.curtime, kCurtime); + EXPECT_EQ(cbr.snapshot.height, 424242u); + // require_job_snapshot is set: the core session refuses mixed jobs. + EXPECT_TRUE(ws->get_stratum_config().require_job_snapshot); +} + +// Fix 1 pin — THE double-fetch race. The core session fetches the template +// and the coinbase in two separate work-source calls; a rotation in between +// previously produced a MIXED job: tmpl A's header + a coinbase carrying +// height B's masternode payee -> deterministic dashd bad-cb-payee at submit. +// Pin (a) the hazard witness: the pre-rotation tmpl header differs from the +// post-rotation snapshot header, so a session composing tmpl(A)+coinbase(B) +// WOULD have mixed template generations; and (b) the fix invariant: the +// snapshot the coinbase ships with is self-consistent — its header AND its +// payee both come from the SAME (post-rotation) template. +TEST(DashStratumWorkSource, TemplateRotationBetweenFetchesYieldsSelfConsistentSnapshot) +{ + Fixture fx(true); + auto ws = fx.make(); + + // Fetch #1: the template, as StratumSession::send_notify_work does first. + auto tmpl_a = ws->get_current_work_template(); + ASSERT_EQ(tmpl_a.value("previousblockhash", ""), std::string(kPrevHashHex)); + + // The tip moves between the two fetches (new block -> payee ROTATES). + fx.fallback_work = rotated_work(); + ws->bump_work_generation(); // the tip signal that refreshes the cache + + // Fetch #2: the per-connection coinbase. + auto cbr = ws->build_connection_coinbase(uint256::ZERO, "00000000", + fixture_miner_script(), {}); + ASSERT_FALSE(cbr.coinb1.empty()); + ASSERT_TRUE(cbr.snapshot.has_header); + + // (a) hazard witness: tmpl(A) + this coinbase(B) would be a MIXED job. + EXPECT_NE(cbr.snapshot.gbt_prevhash, tmpl_a.value("previousblockhash", "")); + + // (b) fix invariant: snapshot header and coinbase payee are BOTH from the + // rotated template — the new payee script is in the coinbase, the old one + // is gone, and the frozen header names the rotated tip/height. + EXPECT_EQ(cbr.snapshot.gbt_prevhash, std::string(kRotatedPrevHashHex)); + EXPECT_EQ(cbr.snapshot.height, 424243u); + const std::string new_payee_hex = rotated_mn_payee().substr(1); + const std::string old_payee_hex = fixture_mn_payee().substr(1); + const std::string cb_hex = cbr.coinb1 + cbr.coinb2; + EXPECT_NE(cb_hex.find(new_payee_hex), std::string::npos); + EXPECT_EQ(cb_hex.find(old_payee_hex), std::string::npos); +} + +// Fix 2 pin: a CoindRPC reconnect (the churn window) invalidates the cached +// template + bumps the work generation, so no job is served from a payee +// snapshot predating the reconnect. +TEST(DashStratumWorkSource, InvalidateTemplateCacheForcesRefetchAndBumpsGeneration) +{ + dash::coin::NodeCoinState cs; // unpopulated -> fallback arm + int fallback_calls = 0; + auto ws = std::make_unique( + cs, + [&fallback_calls]() { + ++fallback_calls; + return rich_work(); + }); + + ASSERT_FALSE(ws->get_current_work_template().empty()); + EXPECT_EQ(fallback_calls, 1); + // Same generation, inside the TTL: served from cache, no re-fetch. + ASSERT_FALSE(ws->get_current_work_template().empty()); + EXPECT_EQ(fallback_calls, 1); + + const uint64_t gen_before = ws->get_work_generation(); + ws->invalidate_template_cache("kat: simulated CoindRPC reconnect"); + EXPECT_EQ(ws->get_work_generation(), gen_before + 1); + + // Next serve re-sources through the fallback — the stale snapshot is gone. + ASSERT_FALSE(ws->get_current_work_template().empty()); + EXPECT_EQ(fallback_calls, 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. +TEST(DashStratumWorkSource, ZeroPrevhashTemplateIsAnHonestSetGap) +{ + Fixture fx(true); + fx.fallback_work.m_previous_block = uint256::ZERO; // bits stay non-zero + auto ws = fx.make(); + EXPECT_TRUE(ws->get_current_work_template().empty()); + EXPECT_TRUE(ws->get_current_gbt_prevhash().empty()); + auto cbr = ws->build_connection_coinbase(uint256::ZERO, "00000000", + fixture_miner_script(), {}); + EXPECT_TRUE(cbr.coinb1.empty()); + EXPECT_FALSE(cbr.snapshot.has_header); +} + +// ── Fix 4: the submit-time payee guard (pure-function KATs) ───────────────── + +std::vector fixture_coinbase_bytes() +{ + Fixture fx(true); + auto ws = fx.make(); + auto cbr = ws->build_connection_coinbase(uint256::ZERO, "00000001", + fixture_miner_script(), {}); + return reassemble(cbr.coinb1, "00000001", "00000002", cbr.coinb2); +} + +TEST(DashSubmitPayeeGuard, OkWhenAllMandatedPaymentsPresent) +{ + const auto cb = fixture_coinbase_bytes(); + const auto r = dash::stratum::check_submit_payee( + cb, kPrevHashHex, rich_work(), dash::make_coin_params(false)); + EXPECT_EQ(r.verdict, dash::stratum::PayeeGuardVerdict::Ok); +} + +TEST(DashSubmitPayeeGuard, StalePayeeWhenPayeeRotatedAtSameHeight) +{ + // Same prev (same height context) but the current template now mandates a + // ROTATED masternode payee the frozen coinbase does not pay: the exact + // hex-confirmed bad-cb-payee class. The guard must forbid the submit. + const auto cb = fixture_coinbase_bytes(); + dash::coin::DashWorkData current = rotated_work(); + current.m_previous_block.SetHex(kPrevHashHex); // same height, new payee + const auto r = dash::stratum::check_submit_payee( + cb, kPrevHashHex, current, dash::make_coin_params(false)); + EXPECT_EQ(r.verdict, dash::stratum::PayeeGuardVerdict::StalePayee); + EXPECT_NE(r.detail.find("bad-cb-payee"), std::string::npos); +} + +TEST(DashSubmitPayeeGuard, StalePayeeWhenMandatedAmountChanged) +{ + const auto cb = fixture_coinbase_bytes(); + dash::coin::DashWorkData current = rich_work(); + current.m_packed_payments[0].amount += 1; // same script, wrong amount + const auto r = dash::stratum::check_submit_payee( + cb, kPrevHashHex, current, dash::make_coin_params(false)); + EXPECT_EQ(r.verdict, dash::stratum::PayeeGuardVerdict::StalePayee); +} + +TEST(DashSubmitPayeeGuard, TipMovedWhenPrevDiffers) +{ + // A moved tip is an orphan-race candidate, NOT a doomed bad-cb-payee: the + // block stays self-consistent for its own height and must still submit. + const auto cb = fixture_coinbase_bytes(); + const auto r = dash::stratum::check_submit_payee( + cb, kPrevHashHex, rotated_work(), dash::make_coin_params(false)); + EXPECT_EQ(r.verdict, dash::stratum::PayeeGuardVerdict::TipMoved); +} + +TEST(DashSubmitPayeeGuard, UnverifiableOnGarbageCoinbaseNeverBlocks) +{ + const std::vector garbage = {0x01, 0x02, 0x03}; + const auto r = dash::stratum::check_submit_payee( + garbage, kPrevHashHex, rich_work(), dash::make_coin_params(false)); + EXPECT_EQ(r.verdict, dash::stratum::PayeeGuardVerdict::Unverifiable); +} + +// ── Fix 4 wired into the hot path: mining_submit ──────────────────────────── + +// A won block whose payee rotated at the SAME height between job issue and +// submit is locally rejected LOUDLY — the broadcaster must NOT fire (never +// submit a doomed bad-cb-payee block; steward-ruled posture). +TEST(DashStratumWorkSource, WonBlockWithStalePayeeIsLocallyRejectedNotSubmitted) +{ + SubmitRig rig; + rig.job.share_bits = 0x207fffffu; + rig.job.block_nbits = "207fffff"; + const uint256 block_target = dash::coin::target_from_nbits(0x207fffffu); + const uint32_t nonce = rig.find_nonce([&](const uint256& pow) { + return pow <= block_target; + }); + + // Between job issue and submit: the payee ROTATES at the same height + // (the churn/staleness window). The re-sourced current template now + // mandates a payee the frozen job coinbase does not pay. + rig.fx.fallback_work = rotated_work(); + rig.fx.fallback_work.m_previous_block.SetHex(kPrevHashHex); // same prev + rig.ws->bump_work_generation(); // submit-side cached_work re-sources + + auto result = rig.submit(nonce); + ASSERT_TRUE(result.is_boolean()); + EXPECT_TRUE(result.get()); // the miner's work was honest + EXPECT_FALSE(rig.fx.submit_called); // ...but NOTHING was dispatched +} + +// A won block across a MOVED tip is an orphan-race candidate and still +// dispatches (self-consistent for its own height — never guard-blocked). +TEST(DashStratumWorkSource, WonBlockAcrossTipMoveStillSubmits) +{ + SubmitRig rig; + rig.job.share_bits = 0x207fffffu; + rig.job.block_nbits = "207fffff"; + const uint256 block_target = dash::coin::target_from_nbits(0x207fffffu); + const uint32_t nonce = rig.find_nonce([&](const uint256& pow) { + return pow <= block_target; + }); + + rig.fx.fallback_work = rotated_work(); // new prev AND new payee + rig.ws->bump_work_generation(); + + auto result = rig.submit(nonce); + ASSERT_TRUE(result.is_boolean()); + EXPECT_TRUE(result.get()); + EXPECT_TRUE(rig.fx.submit_called); // orphan race: still dispatched +} + } // namespace