diff --git a/src/c2pool/main_dash.cpp b/src/c2pool/main_dash.cpp index 14d005c0f..c0ff14c19 100644 --- a/src/c2pool/main_dash.cpp +++ b/src/c2pool/main_dash.cpp @@ -486,7 +486,7 @@ int run_node(bool testnet, const std::string& rpc_endpoint, // same scope). The StratumServer co-owns the work source via shared_ptr. auto work_source = std::make_shared( node_coin_state, std::move(dashd_fallback), std::move(stratum_submit_fn), - core::stratum::StratumConfig{}); + core::stratum::StratumConfig{}, testnet); if (stratum_port != 0) { stratum_server = std::make_unique( @@ -494,9 +494,10 @@ int run_node(bool testnet, const std::string& rpc_endpoint, if (stratum_server->start()) { std::cout << "[run] stratum listening on " << stratum_host << ":" << stratum_port - << " (work source: DASHWorkSource 4a/4b skeleton -- X11;" - << " get_work routes to the dashd-RPC fallback until the" - << " node-held coin-state is seeded)\n"; + << " (work source: DASHWorkSource 4c/4d -- X11 template" + << " serving + submit scoring; templates source from the" + << " embedded coin-state when seeded, else the retained" + << " dashd-RPC GBT fallback)\n"; } else { std::cout << "[run] stratum FAILED to bind " << stratum_host << ":" << stratum_port << " -- stratum disabled\n"; diff --git a/src/c2pool/main_ltc.cpp b/src/c2pool/main_ltc.cpp index 9a3f7b8b8..af5e51deb 100644 --- a/src/c2pool/main_ltc.cpp +++ b/src/c2pool/main_ltc.cpp @@ -545,7 +545,14 @@ int main(int argc, char* argv[]) { { const uint64_t nofile = core::raise_nofile_limit(65536); if (nofile == 0) +#ifdef _WIN32 + // Windows has no RLIMIT_NOFILE; emit on stdout so the --help smoke + // (pwsh `2>&1 | Select-Object -First N`) never surfaces a stderr + // NativeCommandError from this benign startup notice. + std::cout << "[init] RLIMIT_NOFILE: not applicable on this platform\n"; +#else LOG_WARNING << "RLIMIT_NOFILE: unsupported on this platform (or query failed)"; +#endif else if (nofile < 65536) LOG_WARNING << "RLIMIT_NOFILE soft limit is " << nofile << " (< 65536; hard limit too low — raise with ulimit -Hn / limits.conf)"; @@ -632,6 +639,7 @@ int main(int argc, char* argv[]) { // Stratum tuning (configurable via CLI or YAML) core::StratumConfig stratum_config; // defaults: min=0.0005, max=65536, target=3.0s, vardiff=true + stratum_config.coin_symbol = "LTC"; // runtime coin tag for coin-agnostic core log lines // Operational tuning (configurable via CLI or YAML) std::string log_file; // empty = default "debug.log" diff --git a/src/core/stratum_server.cpp b/src/core/stratum_server.cpp index f72d750bf..0a003fce3 100644 --- a/src/core/stratum_server.cpp +++ b/src/core/stratum_server.cpp @@ -1322,8 +1322,16 @@ void StratumSession::send_notify_work(bool force_clean, const uint256* frozen_be auto now = std::chrono::steady_clock::now().time_since_epoch().count(); auto prev = s_last_warn.load(); if (now - prev > 30'000'000'000LL) { // 30 seconds - if (s_last_warn.compare_exchange_strong(prev, now)) - LOG_WARNING << "[LTC] Waiting for block template (header sync in progress)..."; + if (s_last_warn.compare_exchange_strong(prev, now)) { + // Runtime coin tag from the work-source config — this core is + // coin-agnostic and must never hardcode a coin (issue #732: + // a DASH binary logging "[LTC]" sent the operator diagnosing + // the wrong coin). Neutral fallback when unset. + const std::string& sym = + mining_interface_->get_stratum_config().coin_symbol; + LOG_WARNING << "[" << (sym.empty() ? "Stratum" : sym) + << "] Waiting for block template (header sync in progress)..."; + } } auto timer = std::make_shared(socket_.get_executor()); timer->expires_after(std::chrono::seconds(1)); diff --git a/src/core/stratum_types.hpp b/src/core/stratum_types.hpp index 07d3d4edc..82846b727 100644 --- a/src/core/stratum_types.hpp +++ b/src/core/stratum_types.hpp @@ -52,6 +52,11 @@ struct StratumConfig { // max_stratum_connections (YAML) / --max-stratum-connections (CLI). // Admission control only — zero wire-byte change for admitted sessions. size_t max_stratum_connections = 100; + // Runtime coin tag for coin-agnostic core log lines (e.g. the "waiting + // for block template" warning). Set by each coin's work source / main so + // a DASH binary never logs "[LTC]" (issue #732 secondary defect). Empty + // -> the core falls back to the neutral "[Stratum]" tag. + std::string coin_symbol; }; /// Frozen share-construction fields returned by ref_hash_fn. These diff --git a/src/impl/btc/stratum/work_source.cpp b/src/impl/btc/stratum/work_source.cpp index f0a5369cd..c8819f856 100644 --- a/src/impl/btc/stratum/work_source.cpp +++ b/src/impl/btc/stratum/work_source.cpp @@ -127,6 +127,9 @@ BTCWorkSource::BTCWorkSource(btc::coin::HeaderChain& chain, // 65536x-inflated wire diff that starves low-rate SHA256d miners of // acceptable shares. Wire-only; does not touch the share-accept target. config_.set_difficulty_multiplier = 1.0; + // Runtime coin tag for coin-agnostic core log lines (#732). + if (config_.coin_symbol.empty()) + config_.coin_symbol = "BTC"; LOG_INFO << "[BTC-STRATUM] BTCWorkSource constructed" << " (testnet=" << is_testnet_ diff --git a/src/impl/dash/stratum/CMakeLists.txt b/src/impl/dash/stratum/CMakeLists.txt index 4c34dcdfc..a4fca6916 100644 --- a/src/impl/dash/stratum/CMakeLists.txt +++ b/src/impl/dash/stratum/CMakeLists.txt @@ -10,8 +10,8 @@ # capstone; only c2pool-dash (and the test_dash_stratum_work_source KAT) link the # objects in, so ltc/btc/doge/dgb build + ctest surfaces stay untouched. # SAFE-ADDITIVE, per-coin isolation held: src/impl/dash only, dashd RPC fallback -# preserved. Stage 4b: bodies landed (getters + registry + get_work adapter real; -# X11 work-assembly + share-validation held at safe defaults for 4c/4d). +# preserved. Stage 4c/4d (#732): template trio served off the cached +# embedded/fallback DashWorkData + X11 submit scoring live. add_library(dash_stratum OBJECT work_source.hpp work_source.cpp ) diff --git a/src/impl/dash/stratum/work_source.cpp b/src/impl/dash/stratum/work_source.cpp index 196b97c50..84adf6e1d 100644 --- a/src/impl/dash/stratum/work_source.cpp +++ b/src/impl/dash/stratum/work_source.cpp @@ -1,18 +1,31 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -// dash::stratum::DASHWorkSource -- Stage 4b bodies. +// dash::stratum::DASHWorkSource -- Stage 4c/4d bodies (issue #732). // -// This TU gives the 4a header a linkable definition: a constructor that binds -// the node-held coin-state + the REQUIRED dashd fallback + the dual-path -// won-block submit callback, real bodies for the read-only getters and the -// per-connection worker registry, and the fused get_work() adapter. The -// substantive X11 work-assembly + share-validation methods (work template, -// coinbase split, mining_submit hot path, compute_share_difficulty) are held -// at their documented safe defaults; they flesh out in 4c/4d exactly as -// dgb::stratum::DGBWorkSource progressed 4a->4b->4c->4d. Landing the bodies -// now makes DASHWorkSource a concrete, INSTANTIABLE core::stratum::IWorkSource -// (all pure virtuals defined) so the next stacked slice can hold it via -// shared_ptr and validate the StratumServer wiring in -// main_dash.cpp end-to-end before the X11 logic lands. +// 4c (template serving): bridges the armed get_work() seam (#726 -- embedded +// coin-state when seeded, retained dashd GBT fallback otherwise) into the +// template trio the coin-agnostic StratumSession consumes in +// send_notify_work(): get_current_work_template() / get_stratum_merkle_ +// branches() / build_connection_coinbase() (coinb1/coinb2 split around the +// 8-byte nonce64 extranonce slot). The DashWorkData snapshot is cached under +// template_mutex_, keyed on work_generation_ + a 30 s staleness TTL, so the +// per-session 1 s notify timers never turn into a dashd RPC storm. +// +// 4d (submit scoring): mining_submit() reconstructs the 80-byte header from +// the frozen JobSnapshot + miner inputs, runs the DASH X11 chained-hash PoW +// (the --selftest-pinned dash::crypto::hash_x11), and classifies: WonBlock -> +// full-block assembly (the same serialization the --mine-block leg uses, +// coin/block_producer.hpp idiom) -> submit_block_fn_ (dual-path won-block +// broadcaster bound in main_dash.cpp); ShareAccept -> mint_share_fn_ seam +// (accept-for-vardiff + LOUD log while unbound); else low-difficulty reject. +// compute_share_difficulty() is the same reconstruction ending in +// diff1 / x11(header), so the coin-agnostic vardiff gate engages. +// +// Coinbase byte-compatibility: the payout split + tx layout reuse the +// EXISTING SSOTs the verifier and the --mine-block producer already share -- +// dash::coinbase::compute_dash_payouts (worker_tx || packed_payments || +// donation tail, the share_check.hpp gentx order) and dash::coinbase::build / +// split_coinb (ref_hash + nonce64 OP_RETURN tail). No second payout or +// serialization implementation exists in this TU by construction. // // Embedded / fallback duality (MUST PERSIST): get_work() sources the base // template through dash::stratum::get_work(), which picks the EMBEDDED arm when @@ -22,21 +35,90 @@ #include +#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) +#include // dash::make_coin_params + +#include // core::address_to_script (mint payout from username) #include +#include // chain::target_to_difficulty +#include // ParseHex, HexStr + +#include +#include +#include +#include #include namespace dash::stratum { +namespace { + +// Template staleness TTL: even without a work_generation bump (tip signal), +// re-source the template so ntime/mempool drift is bounded. Mirrors the ~30 s +// GBT re-poll every stratum sibling uses. +constexpr auto kStaleAfter = std::chrono::seconds(30); +// Negative-cache window after a failed sourcing attempt (set-gap / dashd +// down): sessions retry notify every 1 s -- don't turn that into an RPC storm. +constexpr auto kRetryAfter = std::chrono::seconds(5); + +// Stratum sends ntime/nonce/nbits as big-endian hex; sscanf(%x) decodes them. +inline uint32_t parse_be_hex_u32(const std::string& s) +{ + uint32_t v = 0; + std::sscanf(s.c_str(), "%x", &v); + return v; +} + +// One merkle ascent step: sha256d(left||right) over the two LE-internal +// 32-byte node hashes (per-coin isolation: the dash-local pair fold, same +// idiom as coin/block_producer.hpp's compute_merkle_root inner step). +inline uint256 merkle_pair(const uint256& left, const uint256& right) +{ + unsigned char buf[64]; + std::memcpy(buf, left.data(), 32); + std::memcpy(buf + 32, right.data(), 32); + return dash::coinbase::sha256d(std::span(buf, 64)); +} + +// Extract the 20-byte pubkey hash from a canonical P2PKH scriptPubKey +// (76 a9 14 <20B> 88 ac). DASH sharechain payouts are pubkey-hash keyed +// (share_check.hpp: pubkey_hash_to_script2), so this is the address shape a +// miner must authorize with to earn credit. Returns false for any other shape. +inline bool p2pkh_pubkey_hash(const std::vector& script, uint160& out) +{ + if (script.size() != 25) return false; + if (script[0] != 0x76 || script[1] != 0xa9 || script[2] != 0x14 + || script[23] != 0x88 || script[24] != 0xac) return false; + std::memcpy(out.begin(), script.data() + 3, 20); + return true; +} + +} // namespace + DASHWorkSource::DASHWorkSource(const coin::NodeCoinState& coin_state, std::function dashd_fallback, SubmitBlockFn submit_fn, - core::stratum::StratumConfig config) + core::stratum::StratumConfig config, + bool is_testnet) : coin_state_(coin_state) , dashd_fallback_(std::move(dashd_fallback)) , submit_block_fn_(std::move(submit_fn)) , config_(std::move(config)) + , is_testnet_(is_testnet) { + // X11 uses the standard Bitcoin diff-1 scale (p2pool-dash net + // DUMB_SCRYPT_DIFF = 1) -- override the StratumConfig default (65536, the + // scrypt convention) exactly as the SHA256d BTC work source does. Without + // this the advertised mining.set_difficulty is inflated 65536x and X11 + // miners self-throttle into never submitting. + config_.set_difficulty_multiplier = 1.0; + // Runtime coin tag for the coin-agnostic core log lines (#732 secondary + // defect: the core hardcoded "[LTC]" and a DASH binary logged LTC). + if (config_.coin_symbol.empty()) + config_.coin_symbol = "DASH"; LOG_INFO << "[DASH-STRATUM] DASHWorkSource constructed" << " (min_diff=" << config_.min_difficulty << " max_diff=" << config_.max_difficulty @@ -81,11 +163,14 @@ std::function DASHWorkSource::get_best_share_hash_fn() const std::string DASHWorkSource::get_current_gbt_prevhash() const { - // Held back at the 4b stage: the served template + its previousblockhash - // wire lands with the 4c work-assembly slice (mirrors dgb::stratum 4a/4c). - // A truthful empty absence -- never a fabricated tip id. The stratum server - // falls back to the best-share hash when this is empty. - return {}; + // GBT-conventional BE display hex of the tip the pool mines on, drawn from + // the SAME cached DashWorkData get_current_work_template() serves -- one + // truthful source, so the dedicated getter and the assembled template can + // never silently diverge. Empty on a set-gap (no template source armed) -- + // a truthful absence, never a fabricated tip id. + auto wd = cached_work(); + if (!wd) return {}; + return wd->m_previous_block.GetHex(); } bool DASHWorkSource::has_merged_chain(uint32_t /*chain_id*/) const @@ -142,36 +227,201 @@ void DASHWorkSource::update_stratum_worker(const std::string& session_id, } // ───────────────────────────────────────────────────────────────────────────── -// IWorkSource: work generation -- Stage 4c fills these in (safe defaults now). +// IWorkSource: work generation -- Stage 4c (the template trio). // ───────────────────────────────────────────────────────────────────────────── +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_); + 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 { + work = coin_state_.select_work(dashd_fallback_).work; + } catch (const std::exception& e) { + LOG_WARNING << "[DASH-STRATUM] template sourcing threw: " << e.what(); + work = coin::DashWorkData{}; + } + } + + 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. + template_cache_.reset(); + template_last_fail_at_ = now; + return nullptr; + } + // Tip moved since the last snapshot? Bump work_generation_ so sessions + // detect stale work between their timer firings and re-push. + if (template_cache_ && template_cache_->m_previous_block != work.m_previous_block) + work_generation_.fetch_add(1, std::memory_order_relaxed); + template_cache_ = std::make_shared(std::move(work)); + template_cache_gen_ = work_generation_.load(std::memory_order_relaxed); + template_cache_at_ = now; + template_last_fail_at_ = {}; + return template_cache_; +} + nlohmann::json DASHWorkSource::get_current_work_template() const { - // 4c: assemble the GBT-shaped template off the node-held coin-state (or the - // dashd fallback). An empty object until then -- an honest "no template yet" - // the stratum session treats as no work to push (safe, non-functional). - return nlohmann::json::object(); + // GBT-shaped template with exactly the fields StratumSession:: + // send_notify_work() consumes: previousblockhash (BE display hex), + // version (int), bits (8-char BE hex string), curtime, height. Empty + // object on a set-gap -- the session skips the push and retries. + auto wd = cached_work(); + if (!wd) return nlohmann::json::object(); + + nlohmann::json tmpl; + tmpl["previousblockhash"] = wd->m_previous_block.GetHex(); + tmpl["version"] = wd->m_version; + tmpl["bits"] = dash::coinbase::be_hex_u32(wd->m_bits); + tmpl["height"] = wd->m_height; + tmpl["coinbasevalue"] = wd->m_coinbase_value; + tmpl["curtime"] = static_cast( + wd->m_curtime ? wd->m_curtime + : static_cast(std::time(nullptr))); + if (wd->m_mintime) + tmpl["mintime"] = wd->m_mintime; + return tmpl; } std::vector DASHWorkSource::get_stratum_merkle_branches() const { - return {}; // 4c: cached branches from the last template build. + // Stratum merkle branches over [coinbase placeholder, tx1..txN]: the + // sibling list a miner ascends from its own coinbase txid. Wire encoding + // = hex of the LE-internal bytes (dash::coinbase::merkle_branches_hex -- + // NOT the reversed display form; see that helper's cpuminer note). + auto wd = cached_work(); + if (!wd || wd->m_tx_hashes.empty()) return {}; + + std::vector leaves; + leaves.reserve(1 + wd->m_tx_hashes.size()); + leaves.push_back(uint256::ZERO); // coinbase placeholder at leaf 0 + leaves.insert(leaves.end(), wd->m_tx_hashes.begin(), wd->m_tx_hashes.end()); + return dash::coinbase::merkle_branches_hex( + dash::coinbase::merkle_branches_raw(leaves)); } std::pair DASHWorkSource::get_coinbase_parts() const { - return { {}, {} }; // 4c: cached coinb1/coinb2 (extranonce slot between them). + // Session fallback when the per-connection builder yields nothing (e.g. a + // pre-authorize notify): a pool coinbase with NO miner payout (worker + // portion falls to the donation tail) but the correct masternode outputs + // + nonce64 extranonce slot. Keeps the core's hardcoded legacy default + // coinbase (an LTC-shaped stub) unreachable for DASH. + auto cbr = build_connection_coinbase(uint256::ZERO, "", {}, {}); + return { cbr.coinb1, cbr.coinb2 }; } core::stratum::CoinbaseResult DASHWorkSource::build_connection_coinbase( - const uint256& /*prev_share_hash*/, + const uint256& prev_share_hash, const std::string& /*extranonce1_hex*/, - const std::vector& /*payout_script*/, + const std::vector& payout_script, const std::vector>>& /*merged_addrs*/) const { - // 4c/4d: PPLNS->coinbase assembly (byte-identical to the verifier split). - // Empty result until wired -> the session pushes no work (safe, non-functional). - return {}; + // 4c per-connection coinbase. Payout split + serialization go through the + // EXISTING verifier-shared SSOTs (compute_dash_payouts -> build -> + // split_coinb) so the coinbase a miner hashes is byte-identical to the + // gentx shape share_check.hpp / pplns.hpp enforce -- no second payout + // implementation to drift. + // + // PPLNS weights come from the set_pplns_weights_fn seam (ShareTracker + // walk, bound by the run-loop). Genesis / unbound degradation: a fresh + // pool's sharechain is EMPTY, so the single connecting miner carries the + // whole worker_payout -- weights {payout_script: 1}, total 1 -- plus the + // GBT-mandated masternode/superblock outputs and the donation tail. + auto wd = cached_work(); + if (!wd) return {}; // no template -> no job (session retries) + + try { + const core::CoinParams params = dash::make_coin_params(is_testnet_); + + PplnsWeightsFn pplns_fn; + { + std::lock_guard lk(pplns_mutex_); + pplns_fn = pplns_weights_fn_; + } + + std::map, uint64_t> weights; + uint64_t total_weight = 0; + uint256 ref_hash = uint256::ZERO; // no sharechain commitment yet + if (pplns_fn) { + if (auto res = pplns_fn(prev_share_hash)) { + weights = std::move(res->weights); + total_weight = res->total_weight; + ref_hash = res->ref_hash; + } + } + + // DASH sharechain payouts are P2PKH-keyed (share_check.hpp + // pubkey_hash_to_script2); the finder pkh routes the pre-v36 2% + // finder fee back to this miner's own output. + uint160 finder_pkh; // zero unless the payout script is P2PKH + const bool have_pkh = p2pkh_pubkey_hash(payout_script, finder_pkh); + + if (weights.empty() || total_weight == 0) { + if (have_pkh) { + weights[payout_script] = 1; + total_weight = 1; + } else if (!payout_script.empty()) { + LOG_WARNING << "[DASH-STRATUM] payout script is not P2PKH (" + << payout_script.size() << "B) -- genesis coinbase " + "routes worker payout to the donation tail " + "(authorize with a P2PKH DASH address)"; + } + // No usable miner script: all-to-donation (still a valid block). + } + + auto tx_outs = dash::coinbase::compute_dash_payouts( + wd->m_coinbase_value, wd->m_packed_payments, finder_pkh, + weights, total_weight, params); + + dash::coinbase::CoinbaseLayout layout = dash::coinbase::build( + *wd, tx_outs, /*pool_tag=*/"c2pool", params, ref_hash); + dash::coinbase::CoinbSplit split = dash::coinbase::split_coinb(layout); + + core::stratum::CoinbaseResult out; + out.coinb1 = std::move(split.coinb1_hex); + out.coinb2 = std::move(split.coinb2_hex); + + // Freeze the snapshot ATOMICALLY with the coinbase: branches + tx set + // come from the SAME wd the coinbase was built over, so the session's + // job merkle always matches the block body assembled at submit time. + out.snapshot.subsidy = wd->m_coinbase_value; + out.snapshot.frozen_ref.ref_hash = ref_hash; + if (!wd->m_tx_hashes.empty()) { + std::vector leaves; + leaves.reserve(1 + wd->m_tx_hashes.size()); + leaves.push_back(uint256::ZERO); + leaves.insert(leaves.end(), wd->m_tx_hashes.begin(), wd->m_tx_hashes.end()); + auto raw = dash::coinbase::merkle_branches_raw(leaves); + out.snapshot.frozen_ref.frozen_merkle_branches = raw; + out.snapshot.merkle_branches = dash::coinbase::merkle_branches_hex(raw); + } + out.snapshot.tx_data = std::make_shared>( + wd->m_tx_data_hex); + return out; + } catch (const std::exception& e) { + // Builder invariant tripped (logged): serve no job rather than a + // malformed coinbase. + LOG_ERROR << "[DASH-STRATUM] build_connection_coinbase failed: " << e.what(); + return {}; + } } // ───────────────────────────────────────────────────────────────────────────── @@ -180,36 +430,247 @@ core::stratum::CoinbaseResult DASHWorkSource::build_connection_coinbase( nlohmann::json DASHWorkSource::mining_submit( const std::string& username, const std::string& job_id, - const std::string& /*extranonce1*/, const std::string& /*extranonce2*/, - const std::string& /*ntime*/, const std::string& /*nonce*/, + const std::string& extranonce1, const std::string& extranonce2, + const std::string& ntime, const std::string& nonce, const std::string& /*request_id*/, const std::map>& /*merged_addresses*/, - const core::stratum::JobSnapshot* /*job*/) + const core::stratum::JobSnapshot* job) { - // 4d: reconstruct the 80-byte header, run the DASH X11 chained-hash PoW, and - // classify WonBlock -> dual-path broadcaster / ShareAccept -> sharechain mint - // / Reject. Until then every submission is rejected -- the coin-agnostic - // stratum server must never credit a share this skeleton cannot score. - LOG_INFO << "[DASH-STRATUM] mining_submit (4b skeleton -> reject): user=" - << username << " job=" << job_id; - return nlohmann::json::array({ - false, nlohmann::json::array({20, "Not yet implemented (4b skeleton)", nullptr}) - }); + // Stage 4d -- the hot path. Reconstruct the 80-byte header from the frozen + // JobSnapshot + miner inputs, run the DASH X11 chained-hash PoW, and place + // the result in EXACTLY ONE of three classes: WonBlock -> full-block + // assembly (the --mine-block serialization idiom) -> submit_block_fn_ + // (dual-path broadcaster bound in main_dash.cpp); ShareAccept -> + // mint_share_fn_ seam; else low-difficulty reject. + + // Stratum JSON-RPC error payload (false + [code, message, null]). + auto reject = [](int code, const char* msg) { + return nlohmann::json::array({ + false, nlohmann::json::array({code, msg, nullptr}) + }); + }; + + if (!job) { + LOG_WARNING << "[DASH-STRATUM] submit reject (no JobSnapshot): user=" + << username << " job=" << job_id; + return reject(21, "Job not found"); + } + + // 1. coinbase = coinb1 || extranonce1 || extranonce2 || coinb2 + // (en1(4B) + en2(4B) fill the 8-byte nonce64 slot in the OP_RETURN tail). + auto coinb1_bytes = ParseHex(job->coinb1); + auto en1_bytes = ParseHex(extranonce1); + auto en2_bytes = ParseHex(extranonce2); + auto coinb2_bytes = ParseHex(job->coinb2); + + std::vector coinbase; + coinbase.reserve(coinb1_bytes.size() + en1_bytes.size() + + en2_bytes.size() + coinb2_bytes.size()); + coinbase.insert(coinbase.end(), coinb1_bytes.begin(), coinb1_bytes.end()); + coinbase.insert(coinbase.end(), en1_bytes.begin(), en1_bytes.end()); + coinbase.insert(coinbase.end(), en2_bytes.begin(), en2_bytes.end()); + coinbase.insert(coinbase.end(), coinb2_bytes.begin(), coinb2_bytes.end()); + + // 2. coinbase txid = sha256d (DASH non-witness canonical serialization). + const uint256 coinbase_txid = dash::coin::coinbase_txid(coinbase); + + // 3. Ascend the frozen stratum merkle branches (LE-internal bytes -- + // ParseHex+memcpy, NOT SetHex which reverses). Keep the parsed hashes + // for the mint seam. + std::vector branch_hashes; + branch_hashes.reserve(job->merkle_branches.size()); + uint256 merkle_root = coinbase_txid; + for (const auto& branch_hex : job->merkle_branches) { + uint256 b; + auto bb = ParseHex(branch_hex); + if (bb.size() == 32) std::memcpy(b.begin(), bb.data(), 32); + branch_hashes.push_back(b); + merkle_root = merkle_pair(merkle_root, b); + } + + // 4. 80-byte header via the block_producer SSOT serializer. prevhash + // arrives BE-display -> SetHex converts to internal LE. + uint256 prev_block; + prev_block.SetHex(job->gbt_prevhash.c_str()); + + unsigned char header[80]; + dash::coin::serialize_header80(header, + static_cast(job->version), prev_block, merkle_root, + parse_be_hex_u32(ntime), parse_be_hex_u32(job->nbits), + parse_be_hex_u32(nonce)); + + // 5. X11 PoW (the --selftest-pinned dash::crypto::hash_x11 entry). + const uint256 pow_hash = dash::crypto::hash_x11(header, sizeof(header)); + + // Expand targets. Block target = the original GBT block bits; share + // target = the frozen share_bits, permissive diff-1 fallback for jobs + // frozen before a share target was set (never silently reject-everything). + const uint256 block_target = dash::coin::target_from_nbits(parse_be_hex_u32( + job->block_nbits.empty() ? job->nbits : job->block_nbits)); + const uint256 share_target = dash::coin::target_from_nbits( + job->share_bits != 0 ? job->share_bits : 0x1d00ffffu); + + auto bump = [&](bool accepted) { + std::lock_guard lk(workers_mutex_); + for (auto& kv : workers_) { + if (kv.second.username == username) { + accepted ? kv.second.accepted++ : kv.second.rejected++; + break; + } + } + }; + + // 6. Classify (tighten-first: block target before share target). + if (pow_hash <= block_target) { + // A full network block. NEVER drop it. + auto wd = cached_work(); + const uint32_t height = wd ? wd->m_height : 0; + LOG_WARNING << "[DASH-STRATUM-BLOCK] *** BLOCK FOUND *** user=" << username + << " height~=" << height + << " pow_hash=" << pow_hash.GetHex().substr(0, 16) + << " job=" << job_id; + + // Full block = header || CompactSize(1+ntx) || coinbase || txs -- the + // exact --mine-block serialization (coin/block_producer.hpp; DASH has + // no segwit so the coinbase bytes go in verbatim). + static const std::vector kEmptyTxData; + const std::vector& txs = + job->tx_data ? *job->tx_data : kEmptyTxData; + + std::vector block_bytes; + block_bytes.reserve(80 + 9 + coinbase.size() + txs.size() * 256); + block_bytes.insert(block_bytes.end(), header, header + 80); + dash::coin::append_compact_size(block_bytes, 1 + txs.size()); + block_bytes.insert(block_bytes.end(), coinbase.begin(), coinbase.end()); + for (const auto& tx_hex : txs) { + auto tx_bytes = ParseHex(tx_hex); + block_bytes.insert(block_bytes.end(), tx_bytes.begin(), tx_bytes.end()); + } + + // 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_) { + try { + reached_network = submit_block_fn_(block_bytes, height); + } catch (const std::exception& e) { + LOG_ERROR << "[DASH-STRATUM-BLOCK] submit_block_fn threw: " << e.what(); + } + if (!reached_network) { + LOG_ERROR << "[DASH-STRATUM-BLOCK] WON BLOCK height=" << height + << " reached NEITHER the P2P relay NOR the submitblock " + "RPC -- lost subsidy!"; + } + } else { + LOG_ERROR << "[DASH-STRATUM-BLOCK] no submit_block_fn wired -- WON " + "BLOCK height=" << height << " not broadcast -- lost subsidy!"; + } + + bump(true); + return nlohmann::json(true); + } + + if (pow_hash <= share_target) { + // Meets the sharechain target but not the full block: hand the found- + // share fields to the mint seam. While the DASH node-side share- + // creation seam is unbound, accept for vardiff + LOUD log (documented + // 4d follow-up) -- never a silent drop, never a false reject. + MintShareFn mint_fn; + { + std::lock_guard lk(mint_share_mutex_); + mint_fn = mint_share_fn_; + } + + uint256 share_hash; + if (mint_fn) { + MintShareInputs in; + in.header_bytes.assign(header, header + 80); + in.coinbase_bytes = std::move(coinbase); + in.subsidy = job->subsidy; + in.prev_share_hash = job->prev_share_hash; + in.merkle_branches = std::move(branch_hashes); + in.payout_script = core::address_to_script(username); + in.pow_hash = pow_hash; + try { + share_hash = mint_fn(in); + } catch (const std::exception& e) { + LOG_WARNING << "[DASH-STRATUM-SHARE] mint_share_fn threw: " + << e.what() << " -- share not minted"; + } + } + + if (!share_hash.IsNull()) { + LOG_INFO << "[DASH-STRATUM-SHARE] ACCEPTED + MINTED user=" << username + << " share_hash=" << share_hash.GetHex().substr(0, 16) + << " job=" << job_id; + } else if (mint_fn) { + LOG_INFO << "[DASH-STRATUM-SHARE] accepted (mint deferred/declined) " + "user=" << username << " job=" << job_id; + } else { + LOG_WARNING << "[DASH-STRATUM-SHARE] accepted WITHOUT sharechain " + "credit (mint seam unbound -- set_mint_share_fn): user=" + << username << " job=" << job_id; + } + + bump(true); + return nlohmann::json(true); + } + + bump(false); + return reject(23, "Low difficulty share"); } double DASHWorkSource::compute_share_difficulty( - const std::string& /*coinb1*/, const std::string& /*coinb2*/, - const std::string& /*extranonce1*/, const std::string& /*extranonce2*/, - const std::string& /*ntime*/, const std::string& /*nonce*/, - uint32_t /*version*/, const std::string& /*prevhash_hex*/, - const std::string& /*nbits_hex*/, - const std::vector& /*merkle_branches*/) const + const std::string& coinb1, const std::string& coinb2, + const std::string& extranonce1, const std::string& extranonce2, + const std::string& ntime, const std::string& nonce, + uint32_t version, const std::string& prevhash_hex, + const std::string& nbits_hex, + const std::vector& merkle_branches) const { - // 4b/4c: reconstruct the header, x11_hash(header), return diff1 / pow_hash. - // Until then return 0.0 -- the documented parse-error / not-yet sentinel the - // vardiff gate already treats as a hard reject (no garbage diff leaks into - // the rate monitor). - return 0.0; + // Per-coin PoW difficulty for the vardiff gate: the SAME header + // reconstruction as mining_submit, ending in diff1 / x11(header). 0.0 on + // any malformed input (the documented parse-error sentinel the vardiff + // gate treats as a hard reject). + auto coinb1_bytes = ParseHex(coinb1); + auto en1_bytes = ParseHex(extranonce1); + auto en2_bytes = ParseHex(extranonce2); + auto coinb2_bytes = ParseHex(coinb2); + if (coinb1_bytes.empty() || coinb2_bytes.empty()) return 0.0; + + std::vector coinbase; + coinbase.reserve(coinb1_bytes.size() + en1_bytes.size() + + en2_bytes.size() + coinb2_bytes.size()); + coinbase.insert(coinbase.end(), coinb1_bytes.begin(), coinb1_bytes.end()); + coinbase.insert(coinbase.end(), en1_bytes.begin(), en1_bytes.end()); + coinbase.insert(coinbase.end(), en2_bytes.begin(), en2_bytes.end()); + coinbase.insert(coinbase.end(), coinb2_bytes.begin(), coinb2_bytes.end()); + uint256 merkle_root = dash::coin::coinbase_txid(coinbase); + + for (const auto& branch_hex : merkle_branches) { + uint256 b; + auto bb = ParseHex(branch_hex); + if (bb.size() == 32) std::memcpy(b.begin(), bb.data(), 32); + merkle_root = merkle_pair(merkle_root, b); + } + + // prevhash arrives BE-display; 64 hex chars or it's malformed. + auto prevhash_be = ParseHex(prevhash_hex); + if (prevhash_be.size() != 32) return 0.0; + uint256 prev_block; + prev_block.SetHex(prevhash_hex.c_str()); + + unsigned char header[80]; + dash::coin::serialize_header80(header, + static_cast(version), prev_block, merkle_root, + parse_be_hex_u32(ntime), parse_be_hex_u32(nbits_hex), + parse_be_hex_u32(nonce)); + + const uint256 pow_hash = dash::crypto::hash_x11(header, sizeof(header)); + if (pow_hash.IsNull()) return 0.0; + return chain::target_to_difficulty(pow_hash); } // ───────────────────────────────────────────────────────────────────────────── @@ -222,4 +683,16 @@ void DASHWorkSource::set_best_share_hash_fn(std::function fn) best_share_hash_fn_ = std::move(fn); } +void DASHWorkSource::set_mint_share_fn(MintShareFn fn) +{ + std::lock_guard lk(mint_share_mutex_); + mint_share_fn_ = std::move(fn); +} + +void DASHWorkSource::set_pplns_weights_fn(PplnsWeightsFn fn) +{ + std::lock_guard lk(pplns_mutex_); + pplns_weights_fn_ = std::move(fn); +} + } // namespace dash::stratum diff --git a/src/impl/dash/stratum/work_source.hpp b/src/impl/dash/stratum/work_source.hpp index e4b9b9774..f57db558f 100644 --- a/src/impl/dash/stratum/work_source.hpp +++ b/src/impl/dash/stratum/work_source.hpp @@ -54,11 +54,13 @@ #include // dash::stratum::get_work() fused capstone #include +#include #include #include #include #include #include +#include #include #include #include @@ -85,6 +87,42 @@ class DASHWorkSource : public core::stratum::IWorkSource using SubmitBlockFn = std::function& block_bytes, uint32_t height)>; + /// Found-share fields handed to the sharechain mint dispatch when a + /// stratum submission meets the share target (stage 4d). Mirrors the + /// dgb::stratum::DGBWorkSource MintShareInputs seam: the run-loop binds + /// the actual mint (ShareTracker add + peer broadcast) via + /// set_mint_share_fn once the DASH node-side share-creation seam exists; + /// until then submissions are accepted for vardiff and LOUDLY logged as + /// earning no sharechain credit (never a silent drop). + struct MintShareInputs { + std::vector header_bytes; ///< the 80-byte solved header + std::vector coinbase_bytes; ///< full reassembled coinbase tx + uint64_t subsidy{0}; ///< coinbasevalue frozen at job time + uint256 prev_share_hash; ///< sharechain tip frozen at job time + std::vector merkle_branches; ///< parsed LE-internal branch hashes + std::vector payout_script; ///< miner payout script (from username) + uint256 pow_hash; ///< X11 PoW hash of header_bytes + }; + /// Returns the minted share hash, or null-uint256 when the mint was + /// declined/deferred (reason logged by the callee). + using MintShareFn = std::function; + + /// PPLNS payout inputs for the per-connection coinbase (stage 4c). The + /// run-loop walks the ShareTracker (walk_cumulative_weights) for the + /// weight map + the ref-hash commitment and binds the walk here; the work + /// source itself never reaches into the tracker. While UNBOUND (or when + /// the producer returns nullopt) the coinbase degrades to the documented + /// genesis form: the connecting miner's script carries the full + /// worker_payout (fresh pool, empty sharechain) + the GBT-mandated + /// masternode/superblock outputs + the donation tail. + struct PplnsWeights { + std::map, uint64_t> weights; ///< script -> weight + uint64_t total_weight{0}; ///< grand total (incl. donation weight) + uint256 ref_hash; ///< PPLNS OP_RETURN commitment + }; + using PplnsWeightsFn = + std::function(const uint256& prev_share_hash)>; + /// Construct the DASH work source. Holds a NON-OWNING reference to the /// node-held coin-state (the embedded arm) and captures the REQUIRED dashd /// getblocktemplate fallback closure -- the always-reachable safety path + @@ -92,10 +130,13 @@ class DASHWorkSource : public core::stratum::IWorkSource /// submit callback. main_dash.cpp owns coin_state for the process lifetime /// and constructs this after it (see the Lifetime note above); the fallback /// and submit closures capture the dashd RPC client + won-block dispatcher. + /// `is_testnet` selects the coin params (address versions for GBT payee + /// decode + the v36 version gate) the coinbase builder consumes. DASHWorkSource(const coin::NodeCoinState& coin_state, std::function dashd_fallback, SubmitBlockFn submit_fn = {}, - core::stratum::StratumConfig config = {}); + core::stratum::StratumConfig config = {}, + bool is_testnet = false); ~DASHWorkSource(); @@ -178,7 +219,23 @@ class DASHWorkSource : public core::stratum::IWorkSource /// ShareTracker is constructed. void set_best_share_hash_fn(std::function fn); + /// Wire the sharechain mint dispatch (stage 4d follow-up). While unbound, + /// share-target submissions are accepted for vardiff + loudly logged. + void set_mint_share_fn(MintShareFn fn); + + /// Wire the ShareTracker PPLNS weight walk (stage 4c multi-output path). + /// While unbound, the coinbase uses the genesis single-miner split. + void set_pplns_weights_fn(PplnsWeightsFn fn); + private: + /// Template cache resolve: return the cached DashWorkData snapshot when it + /// is still fresh (same work_generation AND younger than the staleness + /// TTL), else re-source it through the embedded/fallback selector + /// (coin_state_.select_work). Returns nullptr on an honest set-gap (no + /// template source armed / empty template) -- never a fabricated one. + /// 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; // 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) @@ -189,8 +246,12 @@ class DASHWorkSource : public core::stratum::IWorkSource // Config (held by value; const after construction in MVP). core::stratum::StratumConfig config_; - // Atomic state. - std::atomic work_generation_{0}; + // Network selector for coin-params resolution (address versions + v36 gate). + bool is_testnet_{false}; + + // Atomic state. work_generation_ is mutable: the const template-cache + // resolve (cached_work) bumps it when a refresh observes a moved tip. + mutable std::atomic work_generation_{0}; std::atomic share_bits_{0}; std::atomic share_max_bits_{0}; @@ -202,10 +263,23 @@ class DASHWorkSource : public core::stratum::IWorkSource mutable std::mutex best_share_mutex_; std::function best_share_hash_fn_; - // Template cache (filled lazily; invalidated when work_generation_ bumps). - // Stage 4c populates these. + // Sharechain mint dispatch (stage 4d). Empty until wired. + mutable std::mutex mint_share_mutex_; + MintShareFn mint_share_fn_; + + // PPLNS weight walk (stage 4c multi-output path). Empty until wired. + mutable std::mutex pplns_mutex_; + PplnsWeightsFn pplns_weights_fn_; + + // Template cache (stage 4c): the last DashWorkData sourced through the + // embedded/fallback selector, keyed on work_generation_ + a staleness TTL + // (kStaleAfter). A failed fetch is negative-cached for kRetryAfter so a + // down dashd is not hammered by every session's 1 s notify retry timer. mutable std::mutex template_mutex_; - // ... cache fields land here in stage 4c + mutable std::shared_ptr template_cache_; + 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_{}; }; } // namespace dash::stratum diff --git a/src/impl/dgb/stratum/work_source.cpp b/src/impl/dgb/stratum/work_source.cpp index d3a42d449..452bf0407 100644 --- a/src/impl/dgb/stratum/work_source.cpp +++ b/src/impl/dgb/stratum/work_source.cpp @@ -115,6 +115,9 @@ DGBWorkSource::DGBWorkSource(c2pool::dgb::HeaderChain& chain, , subsidy_func_(std::move(subsidy_func)) , config_(std::move(config)) { + // Runtime coin tag for coin-agnostic core log lines (#732). + if (config_.coin_symbol.empty()) + config_.coin_symbol = "DGB"; LOG_INFO << "[DGB-STRATUM] DGBWorkSource constructed" << " (testnet=" << is_testnet_ << " min_diff=" << config_.min_difficulty diff --git a/test/test_dash_stratum_work_source.cpp b/test/test_dash_stratum_work_source.cpp index c6edf5d7c..d256956b2 100644 --- a/test/test_dash_stratum_work_source.cpp +++ b/test/test_dash_stratum_work_source.cpp @@ -1,49 +1,127 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -// dash::stratum::DASHWorkSource -- Stage 4b construction + contract KAT. +// dash::stratum::DASHWorkSource -- Stage 4c/4d KATs (issue #732). // -// Proves the work source instantiates against the live node-held coin-state -// (dash::coin::NodeCoinState) + the REQUIRED dashd fallback closure, satisfies -// the full core::stratum::IWorkSource pure-virtual contract (so a future -// core::StratumServer can hold it via shared_ptr), that its -// real-now surface (config defaults, work-generation counter, share-target -// atomics, worker registry, best-share callback) behaves, that the 4b-held -// work-generation / submit methods return their documented SAFE DEFAULTS (so a -// regression that accidentally "implements" them with garbage is caught), and -// that the fused get_work() adapter routes to the RETAINED dashd fallback on an -// unpopulated coin-state (the always-reachable [GBT-XCHECK] safety arm). +// The gate the v0.2.2 field failure proved missing: a stubbed work source +// returning a FIXED DashWorkData must yield a non-empty stratum template trio +// (template JSON / merkle branches / coinb1+coinb2 split), the reassembled +// coinbase must be byte-identical to the verifier-shared SSOT build +// (compute_dash_payouts -> coinbase::build) with value splits matching the +// fixture, compute_share_difficulty must return the pinned X11-derived value +// for a known header fixture, and mining_submit must classify a fixture solve +// into the right accept/won-block/reject class. Also keeps the 4a/4b +// contract KATs (construction, registry, fused get_work routing) and the +// set-gap honesty test (empty fallback -> empty trio, never fabricated). // // MUST appear in BOTH this ctest registration AND the build.yml --target // allowlist, or it becomes a #143-style NOT_BUILT sentinel that reds master. #include +#include // compute_dash_payouts, build, split_coinb, be_hex_u32 +#include // coinbase_txid, compute_merkle_root, serialize_header80, target_from_nbits +#include +#include + #include +#include #include +#include // ParseHex, HexStr + #include +#include #include +#include #include #include namespace { -// Construct a DASHWorkSource over a default (unpopulated) NodeCoinState. The -// submit callback records whether it was invoked (it must NOT be in 4b); the -// dashd fallback returns a distinctive sentinel so a get_work() routed to the -// fallback arm is provable by the returned work. +// P2PKH script for a fixed 20-byte pubkey hash (0x11 repeated). +std::vector fixture_miner_script() +{ + std::vector s = {0x76, 0xa9, 0x14}; + s.insert(s.end(), 20, 0x11); + s.push_back(0x88); + s.push_back(0xac); + return s; +} + +uint160 fixture_miner_pkh() +{ + uint160 h; + std::memset(h.begin(), 0x11, 20); + return h; +} + +// A masternode payment carried as a raw "!"+hex script (the GBT platform / +// script-payee form normalize_payment preserves) so the fixture needs no +// base58 round-trip. P2PKH to a distinct pkh (0x22 repeated). +std::string fixture_mn_payee() +{ + std::vector s = {0x76, 0xa9, 0x14}; + s.insert(s.end(), 20, 0x22); + s.push_back(0x88); + s.push_back(0xac); + return "!" + HexStr(std::span(s.data(), s.size())); +} + +constexpr uint64_t kCoinbaseValue = 271570414ULL; // sat +constexpr uint64_t kMnAmount = 100000000ULL; // masternode share (sat) +constexpr uint32_t kBits = 0x1e0ffff0u; +constexpr uint32_t kCurtime = 1751000000u; +constexpr int32_t kVersion = 0x20000000; +const char* const kPrevHashHex = + "000000000000001b2f8c9e5a1d4b3c6e7f8091a2b3c4d5e6f708192a3b4c5d6e"; + +// The rich fixture template the stubbed fallback serves: standard fields + +// DASH masternode payment + a two-entry tx set. +dash::coin::DashWorkData rich_work() +{ + dash::coin::DashWorkData w; + w.m_version = kVersion; + w.m_previous_block.SetHex(kPrevHashHex); + w.m_height = 424242u; + w.m_coinbase_value = kCoinbaseValue; + w.m_bits = kBits; + w.m_curtime = kCurtime; + + dash::coin::PackedPayment mn; + mn.payee = fixture_mn_payee(); + mn.amount = kMnAmount; + w.m_packed_payments.push_back(mn); + w.m_payment_amount = kMnAmount; + + uint256 t1, t2; + t1.SetHex("1111111111111111111111111111111111111111111111111111111111111111"); + t2.SetHex("2222222222222222222222222222222222222222222222222222222222222222"); + w.m_tx_hashes = {t1, t2}; + w.m_tx_data_hex = {"aa01", "bb02"}; // opaque tx bytes for block assembly + return w; +} + +// Construct a DASHWorkSource over a default (unpopulated) NodeCoinState with +// a stubbed dashd fallback. `work` is what the fallback serves; the submit +// callback captures any won-block dispatch for inspection. struct Fixture { dash::coin::NodeCoinState coin_state; // default: populated()==false bool submit_called = false; - dash::coin::DashWorkData fallback_work; // sentinel, seeded in make() + uint32_t submit_height = 0; + std::vector submitted_block; + dash::coin::DashWorkData fallback_work; // seeded by caller + + Fixture() = default; // set-gap fixture (empty work) + explicit Fixture(bool rich) { if (rich) fallback_work = rich_work(); } std::unique_ptr make() { - fallback_work.m_height = 424242u; // distinctive fallback marker - fallback_work.m_coinbase_value = 500000000ULL; - auto submit = [this](const std::vector&, uint32_t) -> bool { - submit_called = true; - return false; + auto submit = [this](const std::vector& block, + uint32_t height) -> bool { + submit_called = true; + submit_height = height; + submitted_block = block; + return true; }; auto fallback = [this]() -> dash::coin::DashWorkData { return fallback_work; }; return std::make_unique( @@ -51,28 +129,81 @@ struct Fixture { } }; +// Reassemble coinb1 || en1 || en2 || coinb2 into raw coinbase bytes. +std::vector reassemble(const std::string& coinb1, + const std::string& en1, + const std::string& en2, + const std::string& coinb2) +{ + auto b1 = ParseHex(coinb1); + auto e1 = ParseHex(en1); + auto e2 = ParseHex(en2); + auto b2 = ParseHex(coinb2); + std::vector out; + out.insert(out.end(), b1.begin(), b1.end()); + out.insert(out.end(), e1.begin(), e1.end()); + out.insert(out.end(), e2.begin(), e2.end()); + out.insert(out.end(), b2.begin(), b2.end()); + return out; +} + +// Fold LE-internal hex branches up from a leaf (the miner-side ascent). +uint256 fold_branches(uint256 leaf, const std::vector& branches) +{ + for (const auto& hex : branches) { + uint256 b; + auto bb = ParseHex(hex); + if (bb.size() == 32) std::memcpy(b.begin(), bb.data(), 32); + unsigned char buf[64]; + std::memcpy(buf, leaf.data(), 32); + std::memcpy(buf + 32, b.data(), 32); + leaf = dash::coinbase::sha256d(std::span(buf, 64)); + } + return leaf; +} + +// Assemble the 80-byte header exactly as the work source does at submit time. +std::vector assemble_header(uint32_t version, + const uint256& prev, + const uint256& merkle_root, + uint32_t ntime, uint32_t nbits, + uint32_t nonce) +{ + unsigned char hdr[80]; + dash::coin::serialize_header80(hdr, static_cast(version), prev, + merkle_root, ntime, nbits, nonce); + return std::vector(hdr, hdr + 80); +} + +// ── 4a/4b contract (kept) ─────────────────────────────────────────────────── + TEST(DashStratumWorkSource, ConstructsAndSatisfiesIWorkSourceContract) { - Fixture fx; + Fixture fx(true); auto ws = fx.make(); core::stratum::IWorkSource* iface = ws.get(); // usable through the abstract iface ASSERT_NE(iface, nullptr); } -TEST(DashStratumWorkSource, ConfigDefaultsMatchStratumConfig) +TEST(DashStratumWorkSource, ConfigDefaultsMatchStratumConfigWithX11Overrides) { - Fixture fx; + Fixture fx(true); auto ws = fx.make(); const auto& cfg = ws->get_stratum_config(); EXPECT_DOUBLE_EQ(cfg.min_difficulty, 0.0005); EXPECT_DOUBLE_EQ(cfg.max_difficulty, 65536.0); EXPECT_DOUBLE_EQ(cfg.target_time, 3.0); EXPECT_TRUE(cfg.vardiff_enabled); + // X11 = standard diff-1 scale (p2pool-dash DUMB_SCRYPT_DIFF == 1), NOT the + // scrypt 2^16 default -- otherwise advertised difficulty inflates 65536x. + EXPECT_DOUBLE_EQ(cfg.set_difficulty_multiplier, 1.0); + // Runtime coin tag for the coin-agnostic core log lines (#732). + EXPECT_EQ(cfg.coin_symbol, "DASH"); } TEST(DashStratumWorkSource, WorkGenerationStartsZeroAndBumps) { - Fixture fx; + Fixture fx(true); auto ws = fx.make(); EXPECT_EQ(ws->get_work_generation(), 0u); ws->bump_work_generation(); @@ -82,7 +213,7 @@ TEST(DashStratumWorkSource, WorkGenerationStartsZeroAndBumps) TEST(DashStratumWorkSource, ShareTargetAtomicsRoundTrip) { - Fixture fx; + Fixture fx(true); auto ws = fx.make(); EXPECT_EQ(ws->get_share_bits(), 0u); EXPECT_EQ(ws->get_share_max_bits(), 0u); @@ -93,15 +224,14 @@ TEST(DashStratumWorkSource, ShareTargetAtomicsRoundTrip) TEST(DashStratumWorkSource, NoMergedChainInV36) { - Fixture fx; + Fixture fx(true); auto ws = fx.make(); - // DASH V36 is a standalone X11 parent (no merged mining). EXPECT_FALSE(ws->has_merged_chain(0x0001)); } TEST(DashStratumWorkSource, BestShareHashFnEmptyUntilWired) { - Fixture fx; + Fixture fx(true); auto ws = fx.make(); EXPECT_FALSE(static_cast(ws->get_best_share_hash_fn())); ws->set_best_share_hash_fn([]() { return uint256::ZERO; }); @@ -112,7 +242,7 @@ TEST(DashStratumWorkSource, BestShareHashFnEmptyUntilWired) TEST(DashStratumWorkSource, WorkerRegistryRoundTrip) { - Fixture fx; + Fixture fx(true); auto ws = fx.make(); core::stratum::WorkerInfo info; info.username = "Xaddr.worker1"; @@ -120,53 +250,236 @@ TEST(DashStratumWorkSource, WorkerRegistryRoundTrip) ws->register_stratum_worker("sess-1", info); ws->update_stratum_worker("sess-1", /*hashrate=*/1.0e9, /*dead=*/0.0, /*difficulty=*/16.0, /*accepted=*/3, /*rejected=*/0, /*stale=*/0); - // Idempotent: unregister of a known + unknown session must not crash, and an - // update for an unregistered session is a safe drop. ws->update_stratum_worker("sess-unknown", 1.0, 0.0, 1.0, 0, 0, 0); ws->unregister_stratum_worker("sess-1"); ws->unregister_stratum_worker("sess-unknown"); SUCCEED(); } -TEST(DashStratumWorkSource, WorkGenStubsReturnSafeDefaults) +// The fused adapter: with an UNPOPULATED coin-state, get_work() routes to the +// RETAINED dashd fallback (always-reachable safety + [GBT-XCHECK] arm). +TEST(DashStratumWorkSource, GetWorkRoutesToDashdFallbackWhenCoinStateUnpopulated) +{ + Fixture fx(true); + ASSERT_FALSE(fx.coin_state.populated()); // default bundle is a set-gap + auto ws = fx.make(); + + dash::stratum::WorkJobTargetInputs job_in; + job_in.sane_target_min.SetHex( + "0000000000000000000000000000000000000000000000000000000000000001"); + job_in.sane_target_max.SetHex( + "00000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + job_in.share_info_bits_target.SetHex( + "0000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + job_in.have_desired_pseudoshare = false; + job_in.local_hash_rate = 0.0; + + dash::stratum::GetWork gw = ws->get_work(job_in); + + EXPECT_EQ(gw.source, dash::coin::WorkSource::DashdFallback); + EXPECT_EQ(gw.work.m_height, 424242u); + EXPECT_EQ(gw.work.m_coinbase_value, kCoinbaseValue); + EXPECT_EQ(gw.targets.share_target, job_in.sane_target_max); + EXPECT_EQ(gw.targets.min_share_target, job_in.share_info_bits_target); +} + +// ── 4c: set-gap honesty ───────────────────────────────────────────────────── +// An EMPTY fallback template (unarmed dashd arm: bits == 0) must yield an +// empty trio -- an honest "no template yet", never a fabricated one. +TEST(DashStratumWorkSource, SetGapServesEmptyTrio) { - Fixture fx; + Fixture fx; // fallback_work left default: m_bits == 0 auto ws = fx.make(); - // 4b skeleton: every work-generation getter returns its documented - // empty/default form (4c fills them in) -- no fabricated template state. EXPECT_TRUE(ws->get_current_gbt_prevhash().empty()); - EXPECT_TRUE(ws->get_current_work_template().is_object()); EXPECT_TRUE(ws->get_current_work_template().empty()); EXPECT_TRUE(ws->get_stratum_merkle_branches().empty()); auto parts = ws->get_coinbase_parts(); EXPECT_TRUE(parts.first.empty()); EXPECT_TRUE(parts.second.empty()); - auto cb = ws->build_connection_coinbase(uint256::ZERO, "deadbeef", {}, {}); + auto cb = ws->build_connection_coinbase(uint256::ZERO, "deadbeef", + fixture_miner_script(), {}); EXPECT_TRUE(cb.coinb1.empty()); EXPECT_TRUE(cb.coinb2.empty()); } -TEST(DashStratumWorkSource, MiningSubmitStubRejectsWithoutBroadcasting) +// ── 4c: the template trio off a fixed DashWorkData ────────────────────────── + +TEST(DashStratumWorkSource, TemplateServesFixtureFieldsWithCorrectEncodings) { - Fixture fx; + Fixture fx(true); auto ws = fx.make(); - auto result = ws->mining_submit( - "Xaddr.worker1", "job-0", "en1", "en2", "ntime", "nonce", "rid-0", - /*merged_addresses=*/{}, /*job=*/nullptr); - // Stratum reject form = [false, [code, msg, null]]. - ASSERT_TRUE(result.is_array()); - ASSERT_GE(result.size(), 1u); - EXPECT_FALSE(result[0].get()); - // The 4b stub must NOT have reached the won-block broadcaster. - EXPECT_FALSE(fx.submit_called); + + auto tmpl = ws->get_current_work_template(); + ASSERT_FALSE(tmpl.empty()); + // The exact fields + conventions StratumSession::send_notify_work reads: + EXPECT_EQ(tmpl.value("previousblockhash", ""), std::string(kPrevHashHex)); + EXPECT_EQ(tmpl.value("version", 0), kVersion); + EXPECT_EQ(tmpl.value("bits", ""), "1e0ffff0"); // 8-char BE hex + EXPECT_EQ(tmpl.value("height", 0u), 424242u); + EXPECT_EQ(tmpl.value("curtime", uint64_t{0}), uint64_t{kCurtime}); + EXPECT_EQ(tmpl.value("coinbasevalue", uint64_t{0}), kCoinbaseValue); + + // The dedicated prevhash getter serves the SAME tip as the template. + EXPECT_EQ(ws->get_current_gbt_prevhash(), std::string(kPrevHashHex)); +} + +TEST(DashStratumWorkSource, MerkleBranchesFoldToTheBlockMerkleRoot) +{ + Fixture fx(true); + auto ws = fx.make(); + + auto branches = ws->get_stratum_merkle_branches(); + ASSERT_EQ(branches.size(), 2u); // 3 leaves (cb + 2 txs) -> 2 siblings + + // Miner-side ascent from an arbitrary coinbase txid must reproduce the + // canonical block merkle root over [cb, t1, t2] (block_producer SSOT). + uint256 cb_txid; + cb_txid.SetHex("3333333333333333333333333333333333333333333333333333333333333333"); + const dash::coin::DashWorkData w = rich_work(); + std::vector leaves; + leaves.push_back(cb_txid); + leaves.insert(leaves.end(), w.m_tx_hashes.begin(), w.m_tx_hashes.end()); + EXPECT_EQ(fold_branches(cb_txid, branches), + dash::coin::compute_merkle_root(leaves)); +} + +TEST(DashStratumWorkSource, ConnectionCoinbaseIsByteIdenticalToVerifierSSOT) +{ + Fixture fx(true); + auto ws = fx.make(); + + const auto miner_script = fixture_miner_script(); + auto cbr = ws->build_connection_coinbase(uint256::ZERO, "00000000", + miner_script, {}); + ASSERT_FALSE(cbr.coinb1.empty()); + ASSERT_FALSE(cbr.coinb2.empty()); + EXPECT_EQ(cbr.snapshot.subsidy, kCoinbaseValue); + ASSERT_TRUE(cbr.snapshot.tx_data); + EXPECT_EQ(*cbr.snapshot.tx_data, rich_work().m_tx_data_hex); + EXPECT_EQ(cbr.snapshot.merkle_branches.size(), 2u); + + // Reassemble with the zeroed extranonce (en1+en2 == the 8-byte nonce64 + // placeholder) -> must be BYTE-IDENTICAL to the verifier-shared SSOT + // build: compute_dash_payouts + coinbase::build over the same fixture. + auto reassembled = reassemble(cbr.coinb1, "00000000", "00000000", cbr.coinb2); + + const dash::coin::DashWorkData w = rich_work(); + const core::CoinParams params = dash::make_coin_params(/*testnet=*/false); + std::map, uint64_t> weights{{miner_script, 1}}; + auto tx_outs = dash::coinbase::compute_dash_payouts( + kCoinbaseValue, w.m_packed_payments, fixture_miner_pkh(), + weights, /*total_weight=*/1, params); + auto layout = dash::coinbase::build(w, tx_outs, "c2pool", params, + /*ref_hash=*/uint256::ZERO); + EXPECT_EQ(reassembled, layout.bytes); + + // Value splits: worker payout = subsidy - masternode share; the single + // genesis miner carries it all (modulo the donation rounding remainder), + // the masternode output carries EXACTLY its GBT amount, and the total + // never exceeds the coinbasevalue. + const uint64_t worker_payout = kCoinbaseValue - kMnAmount; + uint64_t total = 0, miner_amt = 0, mn_amt = 0, donation_amt = 0; + const auto mn_script = ParseHex(fixture_mn_payee().substr(1)); + const std::vector donation_script( + dash::DONATION_SCRIPT.begin(), dash::DONATION_SCRIPT.end()); + for (const auto& o : tx_outs) { + total += o.amount; + if (o.script == miner_script) miner_amt += o.amount; + if (o.script == mn_script) mn_amt += o.amount; + if (o.script == donation_script) donation_amt += o.amount; + } + EXPECT_EQ(total, kCoinbaseValue); + EXPECT_EQ(mn_amt, kMnAmount); + EXPECT_EQ(miner_amt + donation_amt, worker_payout); + EXPECT_GE(miner_amt, worker_payout - 2); // donation keeps only rounding dust +} + +// ── 4d: X11 difficulty + submit classification ────────────────────────────── + +struct SubmitRig { + Fixture fx{true}; + std::unique_ptr ws; + core::stratum::JobSnapshot job; + std::string en1 = "00000001"; + std::string en2 = "00000002"; + uint256 prev; + + SubmitRig() + { + ws = fx.make(); + auto cbr = ws->build_connection_coinbase(uint256::ZERO, en1, + fixture_miner_script(), {}); + job.coinb1 = cbr.coinb1; + job.coinb2 = cbr.coinb2; + job.gbt_prevhash = kPrevHashHex; + job.nbits = "1e0ffff0"; + job.version = static_cast(kVersion); + job.merkle_branches = cbr.snapshot.merkle_branches; + job.tx_data = cbr.snapshot.tx_data; + job.subsidy = cbr.snapshot.subsidy; + prev.SetHex(kPrevHashHex); + } + + // Reproduce the submit-side header assembly for a given nonce. + std::vector header_for(uint32_t nonce) const + { + auto coinbase = reassemble(job.coinb1, en1, en2, job.coinb2); + uint256 root = fold_branches(dash::coin::coinbase_txid(coinbase), + job.merkle_branches); + return assemble_header(job.version, prev, root, kCurtime, + 0x1e0ffff0u, nonce); + } + + uint256 pow_for(uint32_t nonce) const + { + auto hdr = header_for(nonce); + return dash::crypto::hash_x11(hdr.data(), hdr.size()); + } + + // First nonce in [0, limit) whose X11 PoW satisfies `pred`. + template + uint32_t find_nonce(Pred pred, uint32_t limit = 5000) const + { + for (uint32_t n = 0; n < limit; ++n) + if (pred(pow_for(n))) return n; + ADD_FAILURE() << "no nonce satisfying predicate in [0," << limit << ")"; + return 0; + } + + nlohmann::json submit(uint32_t nonce) + { + return ws->mining_submit( + "XfixtureMinerAddress", "job_0", en1, en2, + dash::coinbase::be_hex_u32(kCurtime), + dash::coinbase::be_hex_u32(nonce), + "rid-0", {}, &job); + } +}; + +TEST(DashStratumWorkSource, ComputeShareDifficultyMatchesX11Reconstruction) +{ + SubmitRig rig; + const uint32_t nonce = 7; + + double got = rig.ws->compute_share_difficulty( + rig.job.coinb1, rig.job.coinb2, rig.en1, rig.en2, + dash::coinbase::be_hex_u32(kCurtime), + dash::coinbase::be_hex_u32(nonce), + rig.job.version, rig.job.gbt_prevhash, rig.job.nbits, + rig.job.merkle_branches); + + // Pin: diff1 / x11(header) over the independently assembled fixture header. + double expected = chain::target_to_difficulty(rig.pow_for(nonce)); + ASSERT_GT(expected, 0.0); + EXPECT_DOUBLE_EQ(got, expected); } -TEST(DashStratumWorkSource, ComputeShareDifficultyReturnsNotYetSentinel) +TEST(DashStratumWorkSource, ComputeShareDifficultyMalformedInputIsZero) { - Fixture fx; + Fixture fx(true); auto ws = fx.make(); - // 4b: the per-coin (X11) PoW-difficulty hook returns the documented 0.0 - // parse-error/not-yet sentinel the vardiff gate treats as a hard reject. + // Non-hex prevhash -> malformed header -> the documented 0.0 sentinel the + // vardiff gate treats as a hard reject. double diff = ws->compute_share_difficulty( "coinb1", "coinb2", "en1", "en2", "ntime", "nonce", /*version=*/0x20000000u, "prevhash", "1e0ffff0", @@ -174,36 +487,125 @@ TEST(DashStratumWorkSource, ComputeShareDifficultyReturnsNotYetSentinel) EXPECT_DOUBLE_EQ(diff, 0.0); } -// The fused adapter: with an UNPOPULATED coin-state, get_work() routes to the -// RETAINED dashd fallback (always-reachable safety + [GBT-XCHECK] arm), and the -// returned work IS the fallback closure output -- proving the member is a thin -// node-bound wrapper over the #698 capstone, and that the fallback is never -// bypassed on a set-gap. Job targets are assembled over it (pure transform). -TEST(DashStratumWorkSource, GetWorkRoutesToDashdFallbackWhenCoinStateUnpopulated) +TEST(DashStratumWorkSource, MiningSubmitRejectsWithoutJobSnapshot) { - Fixture fx; - ASSERT_FALSE(fx.coin_state.populated()); // default bundle is a set-gap + Fixture fx(true); auto ws = fx.make(); + auto result = ws->mining_submit( + "Xaddr.worker1", "job-0", "en1", "en2", "ntime", "nonce", "rid-0", + /*merged_addresses=*/{}, /*job=*/nullptr); + ASSERT_TRUE(result.is_array()); + EXPECT_FALSE(result[0].get()); + EXPECT_EQ(result[1][0].get(), 21); // Job not found + EXPECT_FALSE(fx.submit_called); +} - dash::stratum::WorkJobTargetInputs job_in; - job_in.sane_target_min.SetHex( - "0000000000000000000000000000000000000000000000000000000000000001"); - job_in.sane_target_max.SetHex( - "00000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); - job_in.share_info_bits_target.SetHex( - "0000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffff"); - job_in.have_desired_pseudoshare = false; - job_in.local_hash_rate = 0.0; +TEST(DashStratumWorkSource, MiningSubmitAcceptsShareBelowShareTarget) +{ + SubmitRig rig; + // Easy share target, impossible block target: a deterministic ShareAccept. + rig.job.share_bits = 0x207fffffu; // ~2^255 target: trivially satisfied + rig.job.block_nbits = "1c00ffff"; // hard: fixture PoW can never meet it + const uint256 block_target = dash::coin::target_from_nbits(0x1c00ffffu); + const uint256 share_target = dash::coin::target_from_nbits(0x207fffffu); + const uint32_t nonce = rig.find_nonce([&](const uint256& pow) { + return pow <= share_target && pow > block_target; + }); - dash::stratum::GetWork gw = ws->get_work(job_in); + auto result = rig.submit(nonce); + ASSERT_TRUE(result.is_boolean()); + EXPECT_TRUE(result.get()); + // Not a block: the won-block broadcaster must NOT have fired. + EXPECT_FALSE(rig.fx.submit_called); +} - // Routed to the retained dashd fallback, returning ITS sentinel work. - EXPECT_EQ(gw.source, dash::coin::WorkSource::DashdFallback); - EXPECT_EQ(gw.work.m_height, 424242u); - EXPECT_EQ(gw.work.m_coinbase_value, 500000000ULL); - // Job targets assembled over the sourced template (clipped into sane range). - EXPECT_EQ(gw.targets.share_target, job_in.sane_target_max); // ffff.. clipped to max - EXPECT_EQ(gw.targets.min_share_target, job_in.share_info_bits_target); // < sane_max -> floor +TEST(DashStratumWorkSource, MiningSubmitRejectsLowDifficulty) +{ + SubmitRig rig; + // Both targets impossible for the fixture PoW -> low-difficulty reject. + rig.job.share_bits = 0x1c00ffffu; + rig.job.block_nbits = "1c00ffff"; + const uint256 hard_target = dash::coin::target_from_nbits(0x1c00ffffu); + const uint32_t nonce = rig.find_nonce([&](const uint256& pow) { + return pow > hard_target; + }); + + auto result = rig.submit(nonce); + ASSERT_TRUE(result.is_array()); + EXPECT_FALSE(result[0].get()); + EXPECT_EQ(result[1][0].get(), 23); // Low difficulty share + EXPECT_FALSE(rig.fx.submit_called); +} + +TEST(DashStratumWorkSource, MiningSubmitWonBlockAssemblesAndBroadcasts) +{ + SubmitRig rig; + // Trivial block target -> deterministic WonBlock for a mined-in-test nonce. + 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; + }); + + auto result = rig.submit(nonce); + ASSERT_TRUE(result.is_boolean()); + EXPECT_TRUE(result.get()); + ASSERT_TRUE(rig.fx.submit_called); + EXPECT_EQ(rig.fx.submit_height, 424242u); + + // The dispatched block must be the --mine-block serialization: + // header(80) || CompactSize(1 + ntx) || coinbase || txs. + const auto& block = rig.fx.submitted_block; + const auto header = rig.header_for(nonce); + const auto coinbase = reassemble(rig.job.coinb1, rig.en1, rig.en2, rig.job.coinb2); + ASSERT_GT(block.size(), 81u + coinbase.size()); + EXPECT_TRUE(std::equal(header.begin(), header.end(), block.begin())); + EXPECT_EQ(block[80], 3u); // CompactSize: coinbase + 2 fixture txs + EXPECT_TRUE(std::equal(coinbase.begin(), coinbase.end(), block.begin() + 81)); + // Fixture tx bytes trail the coinbase verbatim. + auto t1 = ParseHex("aa01"), t2 = ParseHex("bb02"); + std::vector tail; + tail.insert(tail.end(), t1.begin(), t1.end()); + tail.insert(tail.end(), t2.begin(), t2.end()); + ASSERT_EQ(block.size(), 81u + coinbase.size() + tail.size()); + EXPECT_TRUE(std::equal(tail.begin(), tail.end(), + block.begin() + 81 + coinbase.size())); +} + +// The mint seam: a share-target submission routes the found-share fields into +// set_mint_share_fn when bound (the 4d follow-up wiring point). +TEST(DashStratumWorkSource, MiningSubmitRoutesShareIntoMintSeam) +{ + SubmitRig rig; + rig.job.share_bits = 0x207fffffu; + rig.job.block_nbits = "1c00ffff"; + const uint256 block_target = dash::coin::target_from_nbits(0x1c00ffffu); + const uint256 share_target = dash::coin::target_from_nbits(0x207fffffu); + const uint32_t nonce = rig.find_nonce([&](const uint256& pow) { + return pow <= share_target && pow > block_target; + }); + + bool mint_called = false; + dash::stratum::DASHWorkSource::MintShareInputs seen; + rig.ws->set_mint_share_fn( + [&](const dash::stratum::DASHWorkSource::MintShareInputs& in) -> uint256 { + mint_called = true; + seen = in; + uint256 h; + h.SetHex("4444444444444444444444444444444444444444444444444444444444444444"); + return h; + }); + + auto result = rig.submit(nonce); + ASSERT_TRUE(result.is_boolean()); + EXPECT_TRUE(result.get()); + ASSERT_TRUE(mint_called); + EXPECT_EQ(seen.header_bytes, rig.header_for(nonce)); + EXPECT_EQ(seen.coinbase_bytes, + reassemble(rig.job.coinb1, rig.en1, rig.en2, rig.job.coinb2)); + EXPECT_EQ(seen.subsidy, kCoinbaseValue); + EXPECT_EQ(seen.pow_hash, rig.pow_for(nonce)); } } // namespace