Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/c2pool/main_dash.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<dash::stratum::DASHWorkSource> 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<core::StratumServer>(
ioc, stratum_host, stratum_port, work_source);
Expand Down
77 changes: 73 additions & 4 deletions src/core/stratum_server.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<boost::asio::steady_timer>(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();
Expand Down
24 changes: 24 additions & 0 deletions src/core/stratum_types.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -125,6 +134,21 @@ struct WorkSnapshot {
// prevent merkle root mismatch when refresh_work() updates the template.
std::shared_ptr<const std::vector<std::string>> tx_data; // raw tx hex from template (a1: shared/lazy)
std::vector<std::string> 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
Expand Down
11 changes: 11 additions & 0 deletions src/impl/dash/coin/rpc.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<core::Timer>(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();
Expand Down
11 changes: 11 additions & 0 deletions src/impl/dash/coin/rpc.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
#include "rpc_data.hpp"
#include "node_interface.hpp"

#include <functional>
#include <iostream>

#include <core/uint256.hpp>
Expand Down Expand Up @@ -84,6 +85,13 @@ class NodeRPC : public jsonrpccxx::IClientConnector
std::string m_userpass;
bool m_connected = false;
std::unique_ptr<core::Timer> 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<void()> m_on_reconnect;

std::string Send(const std::string &request) override;
nlohmann::json CallAPIMethod(const std::string& method, const jsonrpccxx::positional_parameter& params = {});
Expand All @@ -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<void()> fn) { m_on_reconnect = std::move(fn); }
bool check();
bool check_blockheader(uint256 header);

Expand Down
133 changes: 133 additions & 0 deletions src/impl/dash/stratum/submit_payee_guard.hpp
Original file line number Diff line number Diff line change
@@ -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 <impl/dash/coin/rpc_data.hpp> // DashWorkData, PackedPayment
#include <impl/dash/coin/transaction.hpp> // MutableTransaction (DASH tx codec)
#include <impl/dash/share_check.hpp> // decode_payee_script (payee SSOT)

#include <core/coin_params.hpp>
#include <core/pack.hpp>

#include <cstdint>
#include <sstream>
#include <string>
#include <vector>

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<unsigned char>& 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<int64_t>(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
Loading
Loading