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..a2ede0d48 100644 --- a/src/c2pool/main_ltc.cpp +++ b/src/c2pool/main_ltc.cpp @@ -632,6 +632,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/share.hpp b/src/impl/dash/share.hpp index 7f85ad1c7..30f1e6470 100644 --- a/src/impl/dash/share.hpp +++ b/src/impl/dash/share.hpp @@ -54,7 +54,11 @@ struct DashShare : chain::BaseShare uint64_t m_last_txout_nonce{0}; HashLinkType m_hash_link; MerkleLink m_merkle_link; - BaseScript m_coinbase_payload_outer; // PossiblyNone('', VarStr) — outer coinbase_payload + // Outer coinbase_payload — PossiblyNone('', VarStr) on the wire. The VALUE + // is the oracle's coinbase_payload_data = VarStrType().pack(raw_payload) + // (data.py:277-289), i.e. m_data holds [compactsize(len raw)][raw], NOT the + // bare payload; Share.__init__ appends it verbatim to the hash_link data. + BaseScript m_coinbase_payload_outer; // Identity hash (computed, not serialized) uint256 m_hash; diff --git a/src/impl/dash/share_check.hpp b/src/impl/dash/share_check.hpp index 54e5e8f47..e4f8f3420 100644 --- a/src/impl/dash/share_check.hpp +++ b/src/impl/dash/share_check.hpp @@ -276,23 +276,26 @@ inline uint256 share_init_verify(const DashShare& share, hash_link_data.insert(hash_link_data.end(), p, p + 4); } // Append outer coinbase_payload (coinbase_payload_data in Python). - // Oracle data.py:278,346-348: coinbase_payload_data = pack.VarStrType().pack(payload) - // = [compactsize(len)][payload]; check_hash_link appends THAT (or b"" when None). - // BaseScript C2POOL_SERIALIZE_METHODS does READWRITE(m_data) over a byte vector, - // which strips one VarStr layer on read (so m_data holds the RAW payload) and - // re-adds the compactsize prefix on write. Serialize via the stream rather than - // appending m_data raw — the raw append dropped the prefix, diverging gentx_hash - // from the oracle on any non-empty (DIP4 CbTx) payload. PRESERVE the empty branch: - // the oracle appends nothing (b"") when the payload is None/empty, NOT a 0x00. + // Oracle data.py:277-289: the outer contents['coinbase_payload'] VALUE is + // coinbase_payload_data = pack.VarStrType().pack(raw_payload) — i.e. the + // VarStr-PACKED payload ([compactsize(len)][payload]) — and Share.__init__ + // (data.py:346-348) appends that value VERBATIM to the check_hash_link + // data (b"" when None). On the wire the PossiblyNone(b'', VarStr) outer + // layer adds one MORE compactsize prefix around the value, so + // DashFormatter's single READWRITE(m_data) strip leaves m_data holding + // exactly the oracle field value: [compactsize(len raw)][raw]. Append it + // RAW. (#412 correctly established that the compactsize prefix belongs in + // this data — but re-serializing m_data as a VarStr added a SECOND prefix + // on top of the one m_data already carries, diverging gentx_hash from the + // oracle on every real DIP4 CbTx share. The share-producer slice pins the + // corrected framing: producer KATs in test_dash_share_producer.cpp, + // updated oracle anchors in test_dash_share_hash_link.cpp.) + // PRESERVE the empty branch: the oracle appends nothing (b"") when the + // payload is None/empty, NOT a 0x00. { auto& cpd = share.m_coinbase_payload_outer.m_data; if (!cpd.empty()) - { - PackStream cpd_stream; - cpd_stream << share.m_coinbase_payload_outer; // VarStr-pack: [compactsize][payload] - auto* cp = reinterpret_cast(cpd_stream.data()); - hash_link_data.insert(hash_link_data.end(), cp, cp + cpd_stream.size()); - } + hash_link_data.insert(hash_link_data.end(), cpd.begin(), cpd.end()); } auto gentx_before_refhash = compute_gentx_before_refhash(); diff --git a/src/impl/dash/share_producer.hpp b/src/impl/dash/share_producer.hpp new file mode 100644 index 000000000..e8f32d40d --- /dev/null +++ b/src/impl/dash/share_producer.hpp @@ -0,0 +1,989 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +#pragma once + +// ============================================================================ +// share_producer.hpp — DASH producer-side share construction (mint campaign +// slice 1/3): prospective share_info assembly, ref_hash / hash_link producers, +// oracle retarget, gentx byte assembly and the full DashShare build with a +// MANDATORY self-verify against the in-tree verifier (share_check.hpp). +// +// ORACLE: github.com/frstrtr/p2pool-dash (master, python2). Every function +// below is a hand-trace of the oracle source; citations are to p2pool/data.py +// (share machinery), p2pool/dash/data.py (coin serialization / FloatingInteger) +// and p2pool/networks/dash.py (consensus constants). The live network is the +// pre-v36 v16 lineage: a share whose bytes diverge from what the oracle's +// Share.__init__/check() reproduce is rejected — and a PoW-invalid +// reconstruction is a PeerMisbehavingError (BAN). Byte parity is therefore a +// HARD constraint on every function in this header. +// +// Structure mirrors the DGB mint campaign (src/impl/dgb/run_loop_mint.hpp + +// src/impl/dgb/coin/work_ref_hash.hpp): pure, tracker-free cores where +// possible; thin ChainT-templated wrappers for the chain-position walks. +// NOTHING here is wired into --run or stratum (slices 2-3 do the binding via +// DASHWorkSource::MintShareFn / PplnsWeightsFn). +// +// DELIBERATE divergences from EXISTING in-tree code (all oracle-conform, see +// the per-function notes; these are the review hotspots): +// 1. Retarget: ShareTracker::compute_share_target carries a v36 emergency +// time-decay (share_tracker.hpp "Step 3") that does NOT exist in the +// p2pool-dash oracle. The mint path must not use it — compute_share_target +// below is the oracle formula only (data.py:137-145). +// 2. PPLNS weights window: the oracle starts the cumulative-weights walk at +// previous_share.previous_share_hash (data.py:181), i.e. the GRANDPARENT +// of the share being built. share_check.hpp::generate_share_transaction +// walks from prev_hash itself — off by one vs the oracle. The producer +// implements the oracle window. +// 3. worker_payout: the oracle subtracts only payments actually EMITTED as +// outputs (valid payee, amount > 0 — data.py:191-218); the verifier +// subtracts every packed_payments amount unconditionally. The producer +// implements the oracle rule. +// ============================================================================ + +#include "share.hpp" +#include "share_types.hpp" +#include "share_chain.hpp" // dash::ShareHasher (tx-ref dedup map) +#include "share_check.hpp" // DONATION_SCRIPT, decode_payee_script, pubkey_hash_to_script2, + // check_merkle_link, check_hash_link, compute_gentx_before_refhash, + // share_init_verify + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dash::producer +{ + +// ── small helpers ──────────────────────────────────────────────────────────── + +// math.clip(x, (low, high)) — p2pool/util/math.py:40. Low bound wins first. +template +inline T clip(const T& x, const T& low, const T& high) +{ + if (x < low) return low; + if (x > high) return high; + return x; +} + +// uint288 -> uint128 truncation (mod 2^128), for the oracle's +// "% 2**128" abswork accumulation (data.py:245). Hex-based so attempts +// above 2^64 (unrealistically hard shares) still accumulate exactly. +inline uint128 low128(const uint288& v) +{ + const std::string hex = v.GetHex(); // 72 hex chars, big-endian + uint128 out; + out.SetHex(hex.substr(hex.size() - 32)); // low 128 bits + return out; +} + +// Append the raw bytes of a PackStream to a byte vector. +inline void append_stream(std::vector& out, PackStream& s) +{ + auto* p = reinterpret_cast(s.data()); + out.insert(out.end(), p, p + s.size()); +} + +// ── 1a. Oracle retarget (data.py:135-145 + networks/dash.py) ───────────────── +// +// if height < net.TARGET_LOOKBEHIND (100): +// pre_target3 = net.MAX_TARGET +// else: +// aps = get_pool_attempts_per_second(tracker, prev, 100, min_work=True, integer=True) +// pre_target = 2**256//(net.SHARE_PERIOD*aps) - 1 if aps else 2**256-1 +// pre_target2 = clip(pre_target, (prev.max_target*9//10, prev.max_target*11//10)) +// pre_target3 = clip(pre_target2, (net.MIN_TARGET (=0), net.MAX_TARGET)) +// max_bits = FloatingInteger.from_target_upper_bound(pre_target3) +// bits = FloatingInteger.from_target_upper_bound(clip(desired_target, +// (pre_target3//30, pre_target3))) +// +// NO emergency time-decay: the oracle has none (that step is a p2pool-v36-ism +// present in ShareTracker::compute_share_target and MUST NOT be used to mint). +// chain::target_to_bits_upper_bound is mantissa-TRUNCATING, matching the +// oracle's FloatingInteger.from_target_upper_bound (dash/data.py:44-50) — +// pinned by the DashShareProducerRetarget KATs. + +struct ShareTarget +{ + uint32_t max_bits{0}; + uint32_t bits{0}; +}; + +// Pure core over explicit inputs. have_lookbehind == (height(prev) >= +// TARGET_LOOKBEHIND); aps is the already-floored integer attempts/second on +// the min_work basis (integer=True). +inline ShareTarget compute_share_target_pure(bool have_lookbehind, + const uint288& aps, + const uint256& prev_max_target, + const uint256& desired_target, + uint32_t share_period, + const uint256& max_target) +{ + uint256 pre_target3; + + if (!have_lookbehind) + { + pre_target3 = max_target; + } + else + { + // pre_target — exact big-int: 2**256//(SHARE_PERIOD*aps) - 1 + uint288 pre_target; + if (aps.IsNull()) + { + // 2**256 - 1 + pre_target.SetHex("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + } + else + { + uint288 two_256; + two_256.SetHex("10000000000000000000000000000000000000000000000000000000000000000"); + uint288 divisor = aps * share_period; + pre_target = two_256 / divisor; + // Python: q - 1. q == 0 gives -1 there; the subsequent lower clip + // to prev.max_target*9//10 maps both -1 and 0 to the same result + // (the clip low bound), so saturating at 0 is value-identical. + if (!pre_target.IsNull()) + pre_target = pre_target - uint288(1); + } + + // pre_target2 = clip(pre_target, prev.max_target*9//10, *11//10) + uint288 lo, hi; + { + uint288 pm; + pm.SetHex(prev_max_target.GetHex()); + lo = pm * 9; lo = lo / 10; + hi = pm * 11; hi = hi / 10; + } + uint288 pre_target2 = clip(pre_target, lo, hi); + + // pre_target3 = clip(pre_target2, MIN_TARGET=0, MAX_TARGET) + uint288 max_288; + max_288.SetHex(max_target.GetHex()); + if (pre_target2 > max_288) + pre_target3 = max_target; + else + pre_target3.SetHex(pre_target2.GetHex()); + } + + ShareTarget out; + out.max_bits = chain::target_to_bits_upper_bound(pre_target3); + + // bits = from_target_upper_bound(clip(desired, pre_target3//30, pre_target3)) + // pre_target3 stays FULL precision here (not re-derived from the compact). + const uint256 bits_lo = pre_target3 / 30; + out.bits = chain::target_to_bits_upper_bound( + clip(desired_target, bits_lo, pre_target3)); + return out; +} + +// get_pool_attempts_per_second — data.py:643-653, min_work=True, integer=True. +// near = prev; far = nth_parent(prev, dist-1) +// attempts = delta(near, far).min_work; time = near.ts - far.ts (<=0 -> 1) +// return attempts // time +// Mirrors ShareTracker::get_pool_attempts_per_second but is the mint-path SSOT +// (tracker-free, no diagnostics); the time span is int64 — the oracle's exact +// unbounded-int subtraction — where the tracker copy clamps through int32. +template +inline uint288 pool_attempts_per_second(ChainT& chain, const uint256& prev_hash, + int32_t dist) +{ + if (dist < 2 || !chain.contains(prev_hash)) + return uint288(0); + // Boundary guard: the oracle only calls this with height >= dist (the + // TARGET_LOOKBEHIND gate). Enforce it HERE too so no future caller can + // receive a silently-wrong span from the skip list's exhaust fallback. + if (chain.get_acc_height(prev_hash) < dist) + return uint288(0); + const uint256 far_hash = chain.get_nth_parent_via_skip(prev_hash, dist - 1); + if (far_hash.IsNull() || !chain.contains(far_hash)) + return uint288(0); + + auto delta = chain.get_delta(prev_hash, far_hash); + const uint288 attempts = delta.min_work; + + uint32_t near_ts = 0, far_ts = 0; + chain.get_share(prev_hash).invoke([&](auto* obj) { near_ts = obj->m_timestamp; }); + chain.get_share(far_hash).invoke([&](auto* obj) { far_ts = obj->m_timestamp; }); + int64_t time_span = static_cast(near_ts) - static_cast(far_ts); + if (time_span <= 0) + time_span = 1; + return attempts / uint288(static_cast(time_span)); +} + +// Chain wrapper: resolve height / aps / prev max_target off the live chain. +template +inline ShareTarget compute_share_target(ChainT& chain, const uint256& prev_hash, + const uint256& desired_target, + const core::CoinParams& params) +{ + const uint256 max_target = params.max_target; + + if (prev_hash.IsNull() || !chain.contains(prev_hash)) + return compute_share_target_pure(false, uint288(0), uint256(), + desired_target, + static_cast(params.share_period), + max_target); + + const int32_t height = chain.get_acc_height(prev_hash); + if (height < static_cast(params.target_lookbehind)) + return compute_share_target_pure(false, uint288(0), uint256(), + desired_target, + static_cast(params.share_period), + max_target); + + const uint288 aps = pool_attempts_per_second( + chain, prev_hash, static_cast(params.target_lookbehind)); + + uint256 prev_max_target; + chain.get_share(prev_hash).invoke([&](auto* obj) { + prev_max_target = chain::bits_to_target(obj->m_max_bits); + }); + + return compute_share_target_pure(true, aps, prev_max_target, desired_target, + static_cast(params.share_period), + max_target); +} + +// ── 1b. Timestamp clip (data.py:238-241) ───────────────────────────────────── +// clip(desired, (prev.timestamp + 1, prev.timestamp + 2*SHARE_PERIOD - 1)); +// no previous share -> desired unchanged. +inline uint32_t clip_timestamp(uint32_t desired, bool has_prev, uint32_t prev_ts, + uint32_t share_period) +{ + if (!has_prev) + return desired; + // Widen to 64-bit and saturate at 2^32-1: the oracle computes the bounds + // with unbounded ints (a prev_ts near 2^32 could not serialize there + // either), so a wrapped uint32 window here would be a silent divergence. + const uint64_t lo64 = static_cast(prev_ts) + 1; + const uint64_t hi64 = static_cast(prev_ts) + 2ull * share_period - 1; + const uint64_t clipped = clip(desired, lo64, hi64); + return clipped > 0xffffffffull ? 0xffffffffu : static_cast(clipped); +} + +// ── 1c. far_share_hash (data.py:235) ───────────────────────────────────────── +// None if last is None and height < 99 else tracker.get_nth_parent_hash(prev, 99) +// Guarded manual walk: 99 prev-pointer hops from prev; falling off the tracked +// chain yields null — the value the oracle's PossiblyNone(0) packs for the +// genesis-parent (None) at exactly height == 99. +template +inline uint256 compute_far_share_hash(ChainT& chain, const uint256& prev_hash) +{ + if (prev_hash.IsNull() || !chain.contains(prev_hash)) + return uint256(); + if (chain.get_acc_height(prev_hash) < 99) + return uint256(); + uint256 cur = prev_hash; + for (int i = 0; i < 99; ++i) + { + auto* idx = chain.get_index(cur); + if (!idx) + return uint256(); + cur = idx->tail; + if (cur.IsNull() || !chain.contains(cur)) + return uint256(); // walked past the tracked tail -> oracle None + } + return cur; +} + +// ── 1d. Transaction forwarding refs (data.py:147-170) ──────────────────────── +// +// past_shares = get_chain(prev, min(height, 100)) # prev at i == 0 +// tx_hash_to_this[tx_hash] = [1+i, j] (first-seen wins) # share_count, tx_count +// for tx_hash in desired: known -> that pair; else new -> [0, len(new)-1] +// +// share_count is the distance from the share being BUILT (itself 0), hence +// the 1+i offset. The oracle __init__ asserts share_count < 110 on receive +// (data.py:337); walking min(height, 100) parents keeps 1+i <= 100. +struct TxRefs +{ + std::vector new_transaction_hashes; + std::vector transaction_hash_refs; // flattened [share_count, tx_count] pairs + std::vector other_transaction_hashes; // the job's tx set, template order +}; + +template +inline TxRefs assemble_tx_refs(ChainT& chain, const uint256& prev_hash, + const std::vector& desired_tx_hashes) +{ + TxRefs out; + + std::unordered_map, ShareHasher> tx_hash_to_this; + if (!prev_hash.IsNull() && chain.contains(prev_hash)) + { + const int32_t height = chain.get_acc_height(prev_hash); + const uint64_t count = static_cast(std::min(height, 100)); + uint64_t i = 0; + for (auto [hash, data] : chain.get_chain(prev_hash, count)) + { + data.share.invoke([&](auto* obj) { + for (uint64_t j = 0; j < obj->m_new_transaction_hashes.size(); ++j) + { + const uint256& h = obj->m_new_transaction_hashes[j]; + tx_hash_to_this.try_emplace(h, 1 + i, j); // first-seen wins + } + }); + ++i; + } + } + + for (const auto& tx_hash : desired_tx_hashes) + { + auto it = tx_hash_to_this.find(tx_hash); + if (it != tx_hash_to_this.end()) + { + out.transaction_hash_refs.push_back(it->second.first); + out.transaction_hash_refs.push_back(it->second.second); + } + else + { + out.new_transaction_hashes.push_back(tx_hash); + out.transaction_hash_refs.push_back(0); + out.transaction_hash_refs.push_back(out.new_transaction_hashes.size() - 1); + } + out.other_transaction_hashes.push_back(tx_hash); + } + return out; +} + +// ── 1e. Cumulative PPLNS weights, ORACLE window (data.py:181-184) ──────────── +// +// weights, total, donation = tracker.get_cumulative_weights( +// previous_share.previous_share_hash, # the GRANDPARENT +// max(0, min(height, REAL_CHAIN_LENGTH) - 1), +// 65535*SPREAD*target_to_average_attempts(block_target)) +// +// Linear walk equivalent of the oracle WeightsSkipList (data.py:456-491): +// per share delta {script: att*(65535-donation)}, att*65535 toward the grand +// total, att*donation toward the donation pool; the overshooting share is +// included PARTIALLY with the oracle's exact integer division order +// (data.py:478-481). NOTE the window START — the in-tree verifier +// (generate_share_transaction) walks from prev itself; the oracle walks from +// prev.prev. The producer implements the oracle. +struct CumulativeWeights +{ + std::map, uint288> weights; + uint288 total_weight; // grand total INCLUDING donation weight + uint288 donation_weight; +}; + +template +inline CumulativeWeights get_cumulative_weights(ChainT& chain, + const uint256& start_hash, + int32_t max_shares, + const uint288& desired_weight) +{ + CumulativeWeights out; + if (start_hash.IsNull() || !chain.contains(start_hash) || max_shares <= 0) + return out; + + const int32_t height = chain.get_acc_height(start_hash); + const uint64_t count = + static_cast(std::min(max_shares, height)); + + for (auto [hash, data] : chain.get_chain(start_hash, count)) + { + uint288 att; + uint32_t don = 0; + std::vector script; + data.share.invoke([&](auto* obj) { + att = chain::target_to_average_attempts(chain::bits_to_target(obj->m_bits)); + don = obj->m_donation; + script = pubkey_hash_to_script2(obj->m_pubkey_hash); + }); + + const uint288 share_total = att * 65535u; // toward grand total + const uint288 share_addr_w = att * static_cast(65535 - don); + const uint288 share_don_w = att * don; + + if (out.total_weight + share_total > desired_weight) + { + // Partial inclusion — oracle apply_delta (data.py:478-481): + // new_w = (desired-total1)//65535 * w2 // (total2//65535) + const uint288 remaining = desired_weight - out.total_weight; + uint288 partial_addr, partial_don; + if (!share_total.IsNull()) + { + partial_addr = remaining / 65535u * share_addr_w / (share_total / 65535u); + partial_don = remaining / 65535u * share_don_w / (share_total / 65535u); + } + out.weights[script] += partial_addr; + out.donation_weight += partial_don; + out.total_weight = desired_weight; + break; + } + + out.weights[script] += share_addr_w; + out.total_weight += share_total; + out.donation_weight += share_don_w; + } + return out; +} + +// ── Prospective share_info ─────────────────────────────────────────────────── + +// Job-time inputs the caller resolves from (live tip, miner identity, GBT +// work template DashWorkData, vardiff). All values explicit — no tracker +// access happens beyond what generate_prospective_share_info walks itself. +struct ProducerJobInputs +{ + uint256 prev_share_hash; // live sharechain tip (null for genesis) + std::vector coinbase_scriptSig; // share_data['coinbase'], 2..100 bytes + std::vector coinbase_payload; // RAW DIP4 CbTx extra payload ('' -> none) + uint32_t share_nonce{0}; // share_data['nonce'] + uint160 pubkey_hash; // miner identity (P2PKH basis) + uint64_t subsidy{0}; // GBT coinbasevalue (all fees known) + uint16_t donation{0}; // share_data['donation'] (per-miner --give-author) + StaleInfo stale_info{StaleInfo::none}; + uint64_t desired_version{16}; + uint64_t payment_amount{0}; // GBT masternode/superblock total + std::vector packed_payments; // GBT payment list (template order) + std::vector desired_tx_hashes; // GBT tx set (template order) + uint32_t desired_timestamp{0}; + uint256 desired_target; // vardiff target (pre-clip) +}; + +// The assembled share_info + the job tx set the merkle_link derives from. +struct ProspectiveShareInfo +{ + // share_data + uint256 prev_hash; + std::vector coinbase; + std::vector coinbase_payload; // RAW payload + uint32_t nonce{0}; + uint160 pubkey_hash; + uint64_t subsidy{0}; + uint16_t donation{0}; + StaleInfo stale_info{StaleInfo::none}; + uint64_t desired_version{16}; + uint64_t payment_amount{0}; + std::vector packed_payments; + + // share_info proper + std::vector new_transaction_hashes; + std::vector transaction_hash_refs; + uint256 far_share_hash; + uint32_t max_bits{0}; + uint32_t bits{0}; + uint32_t timestamp{0}; + uint32_t absheight{0}; + uint128 abswork; + + // job context (NOT serialized into the ref preimage) + std::vector other_transaction_hashes; +}; + +// Full prospective assembly at job time — the producer half of the oracle's +// generate_transaction() (data.py:132-246) up to and including share_info. +template +inline ProspectiveShareInfo generate_prospective_share_info( + ChainT& chain, const core::CoinParams& params, const ProducerJobInputs& in) +{ + ProspectiveShareInfo info; + + info.prev_hash = in.prev_share_hash; + info.coinbase = in.coinbase_scriptSig; + info.coinbase_payload = in.coinbase_payload; + info.nonce = in.share_nonce; + info.pubkey_hash = in.pubkey_hash; + info.subsidy = in.subsidy; // all template fees known -> unchanged (data.py:172-179) + info.donation = in.donation; + info.stale_info = in.stale_info; + info.desired_version = in.desired_version; + info.payment_amount = in.payment_amount; + info.packed_payments = in.packed_payments; + + // Retarget (oracle formula — see compute_share_target notes). + const ShareTarget st = + compute_share_target(chain, in.prev_share_hash, in.desired_target, params); + info.max_bits = st.max_bits; + info.bits = st.bits; + + // Tx forwarding refs over the past min(height, 100) shares. + TxRefs refs = assemble_tx_refs(chain, in.prev_share_hash, in.desired_tx_hashes); + info.new_transaction_hashes = std::move(refs.new_transaction_hashes); + info.transaction_hash_refs = std::move(refs.transaction_hash_refs); + info.other_transaction_hashes = std::move(refs.other_transaction_hashes); + + // far_share_hash ancestor walk. + info.far_share_hash = compute_far_share_hash(chain, in.prev_share_hash); + + // timestamp clip + absheight/abswork accumulation off the previous share. + bool has_prev = !in.prev_share_hash.IsNull() && chain.contains(in.prev_share_hash); + uint32_t prev_ts = 0, prev_absheight = 0; + uint128 prev_abswork; + if (has_prev) + { + chain.get_share(in.prev_share_hash).invoke([&](auto* obj) { + prev_ts = obj->m_timestamp; + prev_absheight = obj->m_absheight; + prev_abswork = obj->m_abswork; + }); + } + info.timestamp = clip_timestamp(in.desired_timestamp, has_prev, prev_ts, + static_cast(params.share_period)); + + // absheight = (prev + 1) % 2^32; abswork = (prev + ata(bits.target)) % 2^128 + // (data.py:244-245). base_uint addition already wraps at the type width. + info.absheight = prev_absheight + 1; + info.abswork = prev_abswork + + low128(chain::target_to_average_attempts(chain::bits_to_target(info.bits))); + + return info; +} + +// ── 2. ref_hash producer ───────────────────────────────────────────────────── + +// Serialize share_info exactly as the v16 ref preimage expects it — field for +// field the layout share_init_verify / DashFormatter consume (share_info_type, +// data.py:81-106). MUST stay byte-identical to the verifier's ref_stream +// assembly in share_check.hpp (KAT: ProducerRefStreamEqualsVerifierMirror). +inline void serialize_share_info(PackStream& os, const ProspectiveShareInfo& info) +{ + os << info.prev_hash; // PossiblyNone(0, IntType(256)) + { BaseScript bs; bs.m_data = info.coinbase; os << bs; } // VarStr + { BaseScript bs; bs.m_data = info.coinbase_payload; os << bs; } // PossiblyNone('', VarStr) + os << info.nonce; + os << info.pubkey_hash; + os << info.subsidy; + os << info.donation; + { uint8_t si = static_cast(info.stale_info); os << si; } + { uint64_t dv = info.desired_version; ::Serialize(os, VarInt(dv)); } + os << info.payment_amount; + { + uint64_t count = info.packed_payments.size(); + ::Serialize(os, VarInt(count)); + for (const auto& pay : info.packed_payments) + { + BaseScript bs; + bs.m_data.assign(pay.m_payee.begin(), pay.m_payee.end()); + os << bs; + os << pay.m_amount; + } + } + os << info.new_transaction_hashes; // ListType(IntType(256)) + { + // ListType(VarIntType(), 2): PAIR count, then all elements + uint64_t pair_count = info.transaction_hash_refs.size() / 2; + ::Serialize(os, VarInt(pair_count)); + for (uint64_t v : info.transaction_hash_refs) + ::Serialize(os, VarInt(v)); + } + os << info.far_share_hash; // PossiblyNone(0, IntType(256)) + os << info.max_bits; + os << info.bits; + os << info.timestamp; + os << info.absheight; + os << info.abswork; +} + +// get_ref_hash (data.py:296-301): sha256d(ref_type.pack({identifier, +// share_info})) folded through the ref merkle link. The producer uses an +// EMPTY ref_merkle_link (data.py:285: dict(branch=[], index=0)). +inline uint256 compute_ref_hash(const core::CoinParams& params, + const ProspectiveShareInfo& info, + const MerkleLink& ref_merkle_link = MerkleLink{}) +{ + PackStream ref_stream; + { + const std::string hex = params.active_identifier_hex(); + for (size_t i = 0; i + 1 < hex.size(); i += 2) + { + unsigned char byte = static_cast( + std::stoul(hex.substr(i, 2), nullptr, 16)); + ref_stream.write(std::span( + reinterpret_cast(&byte), 1)); + } + } + serialize_share_info(ref_stream, info); + + auto sp = std::span( + reinterpret_cast(ref_stream.data()), ref_stream.size()); + return check_merkle_link(Hash(sp), ref_merkle_link); +} + +// ── gentx byte assembly (data.py:187-268 + dash/data.py tx_type) ───────────── + +struct GentxResult +{ + std::vector bytes; // full serialized coinbase tx + size_t prefix_len{0}; // hash_link prefix cut (data.py:280) + uint256 txid; // sha256d(bytes) +}; + +// Build the FULL oracle coinbase for the prospective share. +// Output order: worker_tx (sorted, no donation) || payments_tx (template +// order, valid payees, amount>0) || donation_tx || OP_RETURN(ref_hash || +// last_txout_nonce). +// worker_payout = subsidy - Σ(EMITTED payments) (oracle rule, data.py:187-218) +// amounts[s] = worker_payout*49*weight // (50*total_weight) +// amounts[finder] += worker_payout // 50 +// amounts[DONATION] = existing + worker_payout - Σ(amounts) (data.py:220-223) +// tx serialization is the oracle dash tx_type: version int16 + type int16, +// coinbase input, outputs, lock_time, then VarStr(extra_payload) iff +// version>=3 and type!=0 (dash/data.py:96-114). +inline GentxResult build_gentx(const ProspectiveShareInfo& info, + const CumulativeWeights& w, + const uint256& ref_hash, + uint64_t last_txout_nonce, + const core::CoinParams& params) +{ + using Script = std::vector; + const Script donation_script(DONATION_SCRIPT.begin(), DONATION_SCRIPT.end()); + + // payments_tx — template order; skip empty/'script:'/undecodable payees and + // zero amounts; ONLY emitted payments reduce worker_payout (oracle rule). + std::vector> payments_tx; + uint64_t emitted_payments = 0; + for (const auto& pay : info.packed_payments) + { + if (pay.m_amount == 0 || pay.m_payee.empty()) + continue; + if (pay.m_payee.rfind("script:", 0) == 0) + continue; // old-protocol form, skipped (data.py:204-206) + Script s = decode_payee_script(pay.m_payee, params.address_version, + params.address_p2sh_version); + if (s.empty()) + continue; // invalid address, skipped (data.py:209-214) + payments_tx.emplace_back(std::move(s), pay.m_amount); + emitted_payments += pay.m_amount; + } + const uint64_t worker_payout = + (info.subsidy > emitted_payments) ? (info.subsidy - emitted_payments) : 0; + + // PPLNS amounts (exact integer division order; uint288 intermediates). + std::map amounts; + if (!w.total_weight.IsNull()) + { + for (const auto& [script, weight] : w.weights) + { + const uint288 num = uint288(worker_payout) * (weight * 49u); + const uint288 den = w.total_weight * 50u; + const uint64_t amount = (num / den).GetLow64(); + amounts[script] = amount; // 0-amounts dropped at emit + } + } + const Script finder_script = pubkey_hash_to_script2(info.pubkey_hash); + amounts[finder_script] += worker_payout / 50; // 2% block finder + + // amounts[DONATION] = existing + worker_payout - Σ(all amounts) — the + // existing weight-derived donation entry is PRESERVED (oracle + // amounts.get(DONATION_SCRIPT, 0) + ...), unlike the verifier's rebuild. + { + uint64_t sum = 0; + for (const auto& [s, a] : amounts) sum += a; + if (sum > worker_payout) + throw std::runtime_error("build_gentx: amounts exceed worker_payout"); + amounts[donation_script] += (worker_payout - sum); + } + + // worker_tx: sorted scripts (std::map order == python sorted(str)), + // excluding DONATION, dropping zero amounts (data.py:228-229). + std::vector> worker_tx; + for (const auto& [script, amount] : amounts) + { + if (script == donation_script || amount == 0) + continue; + worker_tx.emplace_back(script, amount); + } + + // ── serialize (oracle dash tx_type layout) ── + GentxResult out; + auto& b = out.bytes; + auto put_le = [&](uint64_t v, int n) { + for (int i = 0; i < n; ++i) + b.push_back(static_cast((v >> (8 * i)) & 0xff)); + }; + auto put_varint = [&](uint64_t v) { + PackStream ps; ::Serialize(ps, VarInt(v)); append_stream(b, ps); + }; + auto put_varstr = [&](const Script& s) { + put_varint(s.size()); + b.insert(b.end(), s.begin(), s.end()); + }; + auto put_txout = [&](uint64_t value, const Script& script) { + put_le(value, 8); + put_varstr(script); + }; + + const bool has_cbtx = !info.coinbase_payload.empty(); + // version int16 + type int16 (dash/data.py:97-98): {3,5} for DIP4 CbTx. + put_le(has_cbtx ? 3u : 1u, 2); + put_le(has_cbtx ? 5u : 0u, 2); + + // tx_ins: one coinbase input (data.py:251-255). + put_varint(1); + for (int i = 0; i < 32; ++i) b.push_back(0x00); // previous_output None -> hash 0 + put_le(0xffffffffu, 4); // -> index 2^32-1 + put_varstr(info.coinbase); // scriptSig + put_le(0xffffffffu, 4); // sequence None -> 2^32-1 + + // tx_outs. + put_varint(worker_tx.size() + payments_tx.size() + 1 /*donation*/ + 1 /*OP_RETURN*/); + for (const auto& [script, amount] : worker_tx) + put_txout(amount, script); + for (const auto& [script, amount] : payments_tx) + put_txout(amount, script); + put_txout(amounts[donation_script], donation_script); + { + Script op_ret; + op_ret.reserve(2 + 32 + 8); + op_ret.push_back(0x6a); + op_ret.push_back(0x28); + op_ret.insert(op_ret.end(), ref_hash.data(), ref_hash.data() + 32); + for (int i = 0; i < 8; ++i) + op_ret.push_back(static_cast((last_txout_nonce >> (8 * i)) & 0xff)); + put_txout(0, op_ret); + } + + put_le(0, 4); // lock_time + + size_t payload_varstr_size = 0; + if (has_cbtx) + { + const size_t before = b.size(); + put_varstr(info.coinbase_payload); // extra_payload VarStr + payload_varstr_size = b.size() - before; + } + + // prefix = packed_gentx[:-payload_varstr_size - 32 - 8 - 4] (data.py:280) + if (b.size() < payload_varstr_size + 44) + throw std::runtime_error("build_gentx: tx shorter than known tail"); + out.prefix_len = b.size() - payload_varstr_size - 44; + + auto sp = std::span(b.data(), b.size()); + out.txid = Hash(sp); + return out; +} + +// ── 3. hash_link midstate producer (data.py:34-37) ─────────────────────────── +// +// def prefix_to_hash_link(prefix, const_ending=''): +// assert prefix.endswith(const_ending) +// x = sha256(prefix) +// return dict(state=x.state, extra_data=x.buf[:max(0,len(x.buf)-len(const_ending))], +// length=x.length//8) +// +// Inverse of dash::check_hash_link: captures the SHA256 midstate after the +// gentx prefix so a submitted solve folds into the gentx txid. Mirrors +// dgb::prefix_to_hash_link (share_check.hpp) on the dash HashLinkType. +inline HashLinkType prefix_to_hash_link(const std::vector& prefix, + const std::vector& const_ending) +{ + // Oracle asserts prefix.endswith(const_ending) — a producer bug otherwise. + if (prefix.size() < const_ending.size() || + !std::equal(const_ending.rbegin(), const_ending.rend(), prefix.rbegin())) + throw std::runtime_error("prefix_to_hash_link: prefix does not end with const_ending"); + + CSHA256 hasher; + hasher.Write(prefix.data(), prefix.size()); + + HashLinkType out; + out.m_state.m_data.resize(32); + for (int i = 0; i < 8; ++i) + WriteBE32(out.m_state.m_data.data() + i * 4, hasher.s[i]); + + const size_t bufsize = hasher.bytes % 64; + const size_t extra_len = + (bufsize > const_ending.size()) ? (bufsize - const_ending.size()) : 0; + out.m_extra_data.m_data.assign(hasher.buf, hasher.buf + extra_len); + out.m_length = hasher.bytes; + return out; +} + +// ── merkle link for the share (data.py:288) ────────────────────────────────── +// calculate_merkle_link([None] + other_transaction_hashes, 0): the branch for +// the coinbase slot. Siblings along the index-0 path never involve slot 0 +// itself, so a zero placeholder is byte-safe. Odd layers duplicate their last +// element (dash/data.py:189-207). +// Same reduction as dash::coinbase::merkle_branches_raw (coinbase_builder.hpp) +// — kept local so this consensus header does not pull the coinbase builder's +// transaction/GBT-JSON dependency tree; equality between the two walks is +// drift-fenced by the MerkleLinkMatchesCoinbaseBuilder KAT. +inline MerkleLink calculate_merkle_link_index0(const std::vector& other_tx_hashes) +{ + MerkleLink link; + link.m_index = 0; + if (other_tx_hashes.empty()) + return link; + + std::vector layer; + layer.reserve(1 + other_tx_hashes.size()); + layer.emplace_back(); // coinbase placeholder (never a sibling) + layer.insert(layer.end(), other_tx_hashes.begin(), other_tx_hashes.end()); + + while (layer.size() > 1) + { + link.m_branch.push_back(layer[1]); + if (layer.size() % 2 == 1) + layer.push_back(layer.back()); + std::vector next; + next.reserve(layer.size() / 2); + for (size_t i = 0; i + 1 < layer.size(); i += 2) + { + std::vector buf(64); + std::memcpy(buf.data(), layer[i].data(), 32); + std::memcpy(buf.data() + 32, layer[i + 1].data(), 32); + auto sp = std::span(buf.data(), buf.size()); + next.push_back(Hash(sp)); + } + layer.swap(next); + } + return link; +} + +// ── 4. DashShare build + MANDATORY self-verify ─────────────────────────────── +// +// The oracle get_share closure (data.py:270-292): min_header (solved header +// sans merkle_root) + share_info + empty ref_merkle_link + last_txout_nonce + +// hash_link over the gentx prefix + merkle_link over the job tx set + the +// outer coinbase_payload. +// +// OUTER coinbase_payload semantics (BYTE-PARITY CRITICAL): the oracle stores +// pack.VarStrType().pack(raw_payload) — the VarStr-PACKED payload — as the +// field VALUE (data.py:277-289), then appends that value VERBATIM to the +// hash_link data (data.py:346-348). On the wire the PossiblyNone(b'',VarStr) +// outer layer adds one more length prefix. m_coinbase_payload_outer.m_data +// therefore carries the VarStr-packed payload (compactsize || raw), matching +// what DashFormatter reads off a live oracle share, and share_init_verify +// appends it verbatim (share_check.hpp). +// +// The self-verify is NOT optional: the built share is pushed through the +// in-tree verifier (share_init_verify — ref_hash, hash_link fold, merkle, +// header reconstruction, X11) and the producer-side gentx txid is +// cross-checked against the verifier's check_hash_link fold. Any mismatch +// throws — a share that fails here must never reach the wire. +// SCOPE of the guarantee: these checks prove serialization SELF-CONSISTENCY +// (the committed hash_link/ref_hash/merkle fold reproduces exactly what a +// receiving verifier recomputes from the share bytes). They cannot prove the +// PPLNS amounts/window are oracle-correct — no in-tree component reconstructs +// the coinbase from chain state on the accept path (generate_share_transaction +// is currently uncalled and carries known divergences from the oracle; see the +// header notes). Amount-level oracle parity is carried by the KAT layer +// (test_dash_share_producer.cpp) and the oracle-replay confirmation of the +// PROVISIONAL goldens. +struct BuiltShare +{ + DashShare share; // fully populated, m_hash set to the X11 share hash + uint256 gentx_hash; // sha256d of the produced coinbase + uint256 ref_hash; // PPLNS OP_RETURN commitment +}; + +template +inline BuiltShare build_share(ChainT& chain, + const core::CoinParams& params, + const ProspectiveShareInfo& info, + const bitcoin_family::coin::SmallBlockHeaderType& min_header, + uint64_t last_txout_nonce, + bool check_pow = true) +{ + BuiltShare out; + + // Oracle weights window: start at prev.prev with max(0, min(height, RCL)-1) + // shares, capped at 65535*SPREAD*ata(block_target) (data.py:181-184). + CumulativeWeights weights; + if (!info.prev_hash.IsNull() && chain.contains(info.prev_hash)) + { + uint256 grandparent; + chain.get_share(info.prev_hash).invoke([&](auto* obj) { + grandparent = obj->m_prev_hash; + }); + const int32_t height = chain.get_acc_height(info.prev_hash); + const int32_t max_shares = std::max( + 0, std::min(height, static_cast(params.real_chain_length)) - 1); + const uint256 block_target = chain::bits_to_target(min_header.m_bits); + const uint288 desired_weight = + chain::target_to_average_attempts(block_target) * params.spread * 65535u; + weights = get_cumulative_weights(chain, grandparent, max_shares, desired_weight); + } + + out.ref_hash = compute_ref_hash(params, info); + GentxResult gentx = build_gentx(info, weights, out.ref_hash, last_txout_nonce, params); + out.gentx_hash = gentx.txid; + + DashShare& s = out.share; + s.m_min_header = min_header; + + s.m_prev_hash = info.prev_hash; + s.m_coinbase = BaseScript(info.coinbase); + s.m_coinbase_payload = BaseScript(info.coinbase_payload); + s.m_nonce = info.nonce; + s.m_pubkey_hash = info.pubkey_hash; + s.m_subsidy = info.subsidy; + s.m_donation = info.donation; + s.m_stale_info = info.stale_info; + s.m_desired_version = info.desired_version; + s.m_payment_amount = info.payment_amount; + s.m_packed_payments = info.packed_payments; + + s.m_new_transaction_hashes = info.new_transaction_hashes; + s.m_transaction_hash_refs = info.transaction_hash_refs; + s.m_far_share_hash = info.far_share_hash; + s.m_max_bits = info.max_bits; + s.m_bits = info.bits; + s.m_timestamp = info.timestamp; + s.m_absheight = info.absheight; + s.m_abswork = info.abswork; + + s.m_ref_merkle_link = MerkleLink{}; // data.py:285 + s.m_last_txout_nonce = last_txout_nonce; + { + std::vector prefix( + gentx.bytes.begin(), gentx.bytes.begin() + gentx.prefix_len); + s.m_hash_link = prefix_to_hash_link(prefix, compute_gentx_before_refhash()); + } + s.m_merkle_link = calculate_merkle_link_index0(info.other_transaction_hashes); + + // Outer payload: the VarStr-PACKED raw payload, or empty for none + // (data.py:275-279 — see the semantics note above). + if (!info.coinbase_payload.empty()) + { + PackStream ps; + BaseScript raw; raw.m_data = info.coinbase_payload; + ps << raw; // [compactsize][payload] + s.m_coinbase_payload_outer.m_data.assign( + reinterpret_cast(ps.data()), + reinterpret_cast(ps.data()) + ps.size()); + } + + // ── MANDATORY self-verify ── + // (1) The verifier's hash_link fold over our hash_link must reproduce the + // produced gentx txid exactly (producer/checker cross-check). + { + std::vector hld; + hld.insert(hld.end(), out.ref_hash.data(), out.ref_hash.data() + 32); + for (int i = 0; i < 8; ++i) + hld.push_back(static_cast((last_txout_nonce >> (8 * i)) & 0xff)); + for (int i = 0; i < 4; ++i) + hld.push_back(0x00); + hld.insert(hld.end(), s.m_coinbase_payload_outer.m_data.begin(), + s.m_coinbase_payload_outer.m_data.end()); + const uint256 folded = + check_hash_link(s.m_hash_link, hld, compute_gentx_before_refhash()); + if (folded != gentx.txid) + throw std::runtime_error( + "build_share: hash_link fold does not reproduce gentx txid"); + } + // (2) Full in-tree verifier pass (ref_hash recompute, merkle, header + // reconstruction, share-target validity, X11 — and the PoW check when + // the caller submits a real solve). + s.m_hash = share_init_verify(s, params, check_pow); + + return out; +} + +} // namespace dash::producer 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/CMakeLists.txt b/test/CMakeLists.txt index 9c1954d57..43dff7c09 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -376,7 +376,13 @@ if (BUILD_TESTING AND GTest_FOUND) # share_init_verify gentx hash_link byte-parity vs oracle (coinbase_payload # VarStr prefix). Header-only over the dash_x11 + core link set; mirrors the # test_dash_conformance link set. - add_executable(test_dash_share_hash_link test_dash_share_hash_link.cpp) + # test_dash_share_producer.cpp (mint campaign slice 1): producer-side + # prospective share_info assembly, ref_hash/hash_link producers, oracle + # retarget + gentx byte goldens. Compiled into this EXISTING allowlisted + # target so the CI build.yml allowlist stays untouched (proven pattern). + add_executable(test_dash_share_hash_link + test_dash_share_hash_link.cpp + test_dash_share_producer.cpp) target_link_libraries(test_dash_share_hash_link PRIVATE GTest::gtest_main GTest::gtest dash_x11 core diff --git a/test/test_dash_share_hash_link.cpp b/test/test_dash_share_hash_link.cpp index bfa1cda28..b7daf4a7a 100644 --- a/test/test_dash_share_hash_link.cpp +++ b/test/test_dash_share_hash_link.cpp @@ -17,12 +17,17 @@ // CPython hashlib over a fully-known byte vector (ref || nonce || 0 || // VarStr(payload)). Also asserts the buggy raw-append form yields a // DIFFERENT, separately-pinned digest, proving the prefix is load-bearing. -// 2. ShareInitVerifyAppendsVarStrPayload — exercises the production +// 2. ShareInitVerifyAppendsOuterValueVerbatim — exercises the production // share_init_verify() end-to-end on a DashShare with a non-empty outer // payload. With empty merkle/ref links the block merkle_root equals the // gentx_hash, so X11(reconstructed header) is a faithful proxy for the -// gentx_hash. The expected value is rebuilt with the oracle VarStr form; -// the raw-append form is asserted to differ, so the test is discriminating. +// gentx_hash. Oracle framing (data.py:277-289, 346-348): the outer field +// VALUE m_data is the VarStr-PACKED payload ([compactsize][raw]) and is +// appended VERBATIM — so the check data carries exactly ONE compactsize +// prefix. The double-prefix form (re-VarStr'ing m_data, the pre-producer +// -slice defect) and the bare-raw form (no prefix at all, the pre-#412 +// defect) are both asserted to differ, so the test is discriminating in +// both directions. #include @@ -170,12 +175,18 @@ TEST(DashShareHashLink, RealTestnet3SpecialTxVarStrParity) { } // (2) End-to-end through the production share_init_verify(). -TEST(DashShareHashLink, ShareInitVerifyAppendsVarStrPayload) { +TEST(DashShareHashLink, ShareInitVerifyAppendsOuterValueVerbatim) { const core::CoinParams params = dash::make_coin_params(/*testnet=*/false); + // Oracle field value: VarStr-packed payload ([compactsize][raw]) — what + // DashFormatter's single strip leaves in m_data for a live oracle share. + std::vector outer_value; + outer_value.push_back(static_cast(kPayload.size())); + outer_value.insert(outer_value.end(), kPayload.begin(), kPayload.end()); + dash::DashShare share; share.m_coinbase = BaseScript(std::vector{0x00, 0x00}); // 2 bytes (valid 2..100) - share.m_coinbase_payload_outer.m_data = kPayload; // NON-empty (under test) + share.m_coinbase_payload_outer.m_data = outer_value; // NON-empty (under test) share.m_desired_version = 16; share.m_bits = 0x1d00ffffu; // bits_to_target == mainnet max_target (passes validity guard) share.m_max_bits = 0x1d00ffffu; @@ -218,12 +229,15 @@ TEST(DashShareHashLink, ShareInitVerifyAppendsVarStrPayload) { reinterpret_cast(ref_stream.data()), ref_stream.size()); uint256 ref_hash = dash::check_merkle_link(Hash(ref_span), share.m_ref_merkle_link); - // expected gentx with the ORACLE (VarStr) payload form - auto build_gentx = [&](bool varstr) { + // expected gentx: 0 = ORACLE (append field value verbatim = ONE prefix); + // 1 = double-prefix defect (re-VarStr the value); 2 = bare-raw defect. + auto build_gentx = [&](int form) { std::vector hd(ref_hash.data(), ref_hash.data() + 32); put_le(hd, share.m_last_txout_nonce, 8); put_le(hd, 0, 4); - if (varstr) { + if (form == 0) { + hd.insert(hd.end(), outer_value.begin(), outer_value.end()); + } else if (form == 1) { PackStream ps; ps << share.m_coinbase_payload_outer; auto* cp = reinterpret_cast(ps.data()); hd.insert(hd.end(), cp, cp + ps.size()); @@ -247,12 +261,16 @@ TEST(DashShareHashLink, ShareInitVerifyAppendsVarStrPayload) { return params.pow_func(hsp); }; - uint256 expected = share_hash_for(build_gentx(/*varstr=*/true)); - uint256 buggy = share_hash_for(build_gentx(/*varstr=*/false)); - ASSERT_NE(hex_of(expected), hex_of(buggy)) << "test must discriminate the two encodings"; + uint256 expected = share_hash_for(build_gentx(0)); + uint256 double_prefix = share_hash_for(build_gentx(1)); + uint256 bare_raw = share_hash_for(build_gentx(2)); + ASSERT_NE(hex_of(expected), hex_of(double_prefix)) << "test must discriminate"; + ASSERT_NE(hex_of(expected), hex_of(bare_raw)) << "test must discriminate"; uint256 actual = dash::share_init_verify(share, params, /*check_pow=*/false); EXPECT_EQ(hex_of(actual), hex_of(expected)) - << "share_init_verify must append VarStr(coinbase_payload), not raw bytes"; - EXPECT_NE(hex_of(actual), hex_of(buggy)); + << "share_init_verify must append the outer field value verbatim " + "(it already carries the oracle's single compactsize prefix)"; + EXPECT_NE(hex_of(actual), hex_of(double_prefix)); + EXPECT_NE(hex_of(actual), hex_of(bare_raw)); } \ No newline at end of file diff --git a/test/test_dash_share_producer.cpp b/test/test_dash_share_producer.cpp new file mode 100644 index 000000000..4fd579c2b --- /dev/null +++ b/test/test_dash_share_producer.cpp @@ -0,0 +1,767 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// DASH share-producer KATs (mint campaign slice 1/3). +// +// Oracle: github.com/frstrtr/p2pool-dash (master, python2) — p2pool/data.py +// generate_transaction / get_share / get_ref_hash / prefix_to_hash_link, +// p2pool/dash/data.py FloatingInteger + tx_type, p2pool/networks/dash.py +// consensus constants. All pinned goldens below were derived by HAND-TRACING +// the oracle source with exact big-integer arithmetic (CPython ints / +// hashlib), NOT by running the oracle process — provisional values are marked +// [PROVISIONAL] and listed in the PR body for oracle-replay confirmation. +// +// Coverage (KAT plan a-e): +// (a) producer share_info -> ref stream / ref_hash goldens + the built share +// passing the in-tree verifier (share_init_verify / verify_share); +// (b) built share round-trips the wire codec byte-identically (DashFormatter +// + RawShare + load_share), with the oracle outer-payload framing pinned; +// (c) retarget KATs at several chain positions (genesis, short, deep-uniform, +// fast-pool low clamp, slow-pool high clamp); +// (d) absheight/abswork accumulation incl. the mod 2^32 / 2^128 wrap; +// (e) hash_link producer -> checker round trip across buffer boundaries. + +#include + +#include +#include +#include +#include +#include +#include // coinbase::merkle_branches_raw (drift fence) + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +using dash::producer::ProducerJobInputs; +using dash::producer::ProspectiveShareInfo; + +std::string hex_of(const uint256& h) { + auto c = h.GetChars(); + return HexStr(std::span(c.data(), c.size())); +} + +std::string hex_of_bytes(const std::vector& v) { + return HexStr(std::span(v.data(), v.size())); +} + +uint256 h256_tag(uint8_t tag) { + std::vector v(32, 0x00); + v[0] = tag; v[31] = 0xa5; + return uint256(v); +} + +uint160 h160_uniform(uint8_t byte) { + return uint160(std::vector(20, byte)); +} + +// Compact bits for the DASH mainnet MAX_TARGET (0xFFFF * 2^208, bdiff 1.0). +constexpr uint32_t BITS_DIFF1 = 0x1d00ffffu; + +// ata(bits_to_target(0x1d00ffff)) = 2^256 // (0xFFFF*2^208 + 1) = 0x100010001. +const char* ATA_DIFF1_HEX = "100010001"; + +// Synthetic back-linked chain owning DashShares (test_dash_conformance idiom). +struct SyntheticChain { + dash::ShareChain chain; + + uint256 add(uint8_t tag, const uint256& prev, uint32_t bits, uint32_t max_bits, + uint32_t timestamp, const uint160& pkh, uint16_t donation = 0, + uint32_t absheight = 0, uint128 abswork = uint128(), + std::vector new_tx_hashes = {}) { + auto* s = new dash::DashShare(); + s->m_hash = h256_tag(tag); + s->m_prev_hash = prev; + s->m_bits = bits; + s->m_max_bits = max_bits; + s->m_timestamp = timestamp; + s->m_pubkey_hash = pkh; + s->m_donation = donation; + s->m_desired_version = 16; + s->m_absheight = absheight; + s->m_abswork = abswork; + s->m_new_transaction_hashes = std::move(new_tx_hashes); + const uint256 h = s->m_hash; + chain.add(s); + return h; + } +}; + +// Build a uniform chain of `n` shares: fixed bits/max_bits, timestamps +// t0, t0+spacing, ...; returns the tip hash. Tags 1..n (n <= 250). +uint256 build_uniform_chain(SyntheticChain& sc, int n, uint32_t bits, + uint32_t t0, uint32_t spacing, + const uint160& pkh) { + uint256 prev; + for (int i = 0; i < n; ++i) + prev = sc.add(static_cast(1 + i), prev, bits, bits, + t0 + static_cast(i) * spacing, pkh); + return prev; +} + +uint288 u288_hex(const char* h) { uint288 v; v.SetHex(h); return v; } +uint128 u128_hex(const char* h) { uint128 v; v.SetHex(h); return v; } + +} // namespace + +// ═════════════════════════════════════════════════════════════════════════════ +// (c) Retarget — oracle data.py:135-145, networks/dash.py MAX_TARGET/SHARE_PERIOD +// /TARGET_LOOKBEHIND. Goldens: exact big-int trace of the oracle formula. +// ═════════════════════════════════════════════════════════════════════════════ + +TEST(DashShareProducerRetarget, GenesisUsesMaxTarget) { + const auto params = dash::make_coin_params(false); + dash::ShareChain chain; + + // desired = MAX -> bits = max_bits = compact(MAX_TARGET) = 0x1d00ffff. + auto st = dash::producer::compute_share_target( + chain, uint256(), params.max_target, params); + EXPECT_EQ(st.max_bits, BITS_DIFF1); + EXPECT_EQ(st.bits, BITS_DIFF1); + + // desired = 1 -> clipped to pre_target3//30 = MAX//30 -> compact 0x1c088880 + // (oracle trace: 0xFFFF*2^208 // 30 = 0x08888000..., truncating compact). + auto st2 = dash::producer::compute_share_target( + chain, uint256(), uint256(1), params); + EXPECT_EQ(st2.max_bits, BITS_DIFF1); + EXPECT_EQ(st2.bits, 0x1c088880u); +} + +TEST(DashShareProducerRetarget, ShortChainUsesMaxTarget) { + const auto params = dash::make_coin_params(false); + SyntheticChain sc; + // 50 shares < TARGET_LOOKBEHIND (100) -> pre_target3 = MAX_TARGET. + uint256 tip = build_uniform_chain(sc, 50, BITS_DIFF1, 1000000, 20, h160_uniform(0x11)); + + auto st = dash::producer::compute_share_target(sc.chain, tip, uint256(1), params); + EXPECT_EQ(st.max_bits, BITS_DIFF1); + EXPECT_EQ(st.bits, 0x1c088880u); +} + +TEST(DashShareProducerRetarget, DeepUniformDiff1ClampsToMax) { + const auto params = dash::make_coin_params(false); + SyntheticChain sc; + // 100 diff-1 shares at perfect 20 s spacing. Oracle trace: + // attempts = 99*ata(diff1); time = 1980; aps = attempts//1980 + // pre_target = 2^256//(20*aps)-1 = 0xffff000cffe7... > MAX (aps floor) + // pre2 within ±10% of prev max; pre3 = clip(.., MAX) = MAX -> 0x1d00ffff. + uint256 tip = build_uniform_chain(sc, 100, BITS_DIFF1, 1000000, 20, h160_uniform(0x11)); + ASSERT_EQ(sc.chain.get_acc_height(tip), 100); + + auto st = dash::producer::compute_share_target(sc.chain, tip, params.max_target, params); + EXPECT_EQ(st.max_bits, BITS_DIFF1); + EXPECT_EQ(st.bits, BITS_DIFF1); +} + +TEST(DashShareProducerRetarget, FastPoolClampsToNineTenths) { + const auto params = dash::make_coin_params(false); + SyntheticChain sc; + // 100 shares at bits 0x1c1fffff, 2 s spacing (shares 10x too fast). + // Oracle trace: pre_target < prev_max*9//10 -> clipped low. + // prev_max*9//10 = 0x1ccccbe666... -> TRUNCATING compact 0x1c1ccccb + // (a rounding-up implementation would emit 0x1c1ccccc — discriminating). + // desired=1 -> bits = compact(pre_target3//30 = 0x00f5c287ae...) = 0x1c00f5c2. + uint256 tip = build_uniform_chain(sc, 100, 0x1c1fffffu, 1000000, 2, h160_uniform(0x11)); + + auto st = dash::producer::compute_share_target(sc.chain, tip, uint256(1), params); + EXPECT_EQ(st.max_bits, 0x1c1ccccbu); + EXPECT_EQ(st.bits, 0x1c00f5c2u); +} + +TEST(DashShareProducerRetarget, SlowPoolClampsToElevenTenths) { + const auto params = dash::make_coin_params(false); + SyntheticChain sc; + // 100 shares at bits 0x1c1fffff, 200 s spacing (10x too slow). + // Oracle trace: pre_target > prev_max*11//10 -> clipped high. + // prev_max*11//10 = 0x2333321999... -> compact 0x1c233332. + // desired = old target (within [pre3//30, pre3]) -> unchanged 0x1c1fffff. + uint256 tip = build_uniform_chain(sc, 100, 0x1c1fffffu, 1000000, 200, h160_uniform(0x11)); + + auto st = dash::producer::compute_share_target( + sc.chain, tip, chain::bits_to_target(0x1c1fffffu), params); + EXPECT_EQ(st.max_bits, 0x1c233332u); + EXPECT_EQ(st.bits, 0x1c1fffffu); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// share_info scalar mechanics: timestamp clip, far_share_hash, tx refs, +// (d) absheight/abswork accumulation. +// ═════════════════════════════════════════════════════════════════════════════ + +TEST(DashShareProducerInfo, TimestampClip) { + using dash::producer::clip_timestamp; + // clip(desired, (prev+1, prev+2*SHARE_PERIOD-1)) — data.py:238-241, SP=20. + EXPECT_EQ(clip_timestamp(900, true, 1000, 20), 1001u); + EXPECT_EQ(clip_timestamp(1500, true, 1000, 20), 1039u); + EXPECT_EQ(clip_timestamp(1010, true, 1000, 20), 1010u); + EXPECT_EQ(clip_timestamp(777, false, 0, 20), 777u); // no previous share +} + +TEST(DashShareProducerInfo, FarShareHash) { + SyntheticChain sc; + std::vector hashes; + uint256 prev; + for (int i = 0; i < 100; ++i) { + prev = sc.add(static_cast(1 + i), prev, BITS_DIFF1, BITS_DIFF1, + 1000000 + i * 20, h160_uniform(0x11)); + hashes.push_back(prev); + } + // height(prev)=100: 99 hops from tip -> shares[0] (data.py:235). + EXPECT_EQ(dash::producer::compute_far_share_hash(sc.chain, hashes[99]), hashes[0]); + // height 98 (< 99) -> None. + EXPECT_TRUE(dash::producer::compute_far_share_hash(sc.chain, hashes[97]).IsNull()); + // height exactly 99: the walk lands on the genesis' previous pointer + // (None) -> null. [PROVISIONAL] — oracle's DistanceSkipList at this exact + // boundary hand-traced to the genesis prev (None); confirm on replay. + EXPECT_TRUE(dash::producer::compute_far_share_hash(sc.chain, hashes[98]).IsNull()); +} + +TEST(DashShareProducerInfo, TxForwardingRefs) { + SyntheticChain sc; + const uint256 A = h256_tag(0xa1), B = h256_tag(0xb2), C = h256_tag(0xc3); + // Past share (the tip, i == 0 in the walk) carries new hashes [A, B]. + uint256 g = sc.add(0x01, uint256(), BITS_DIFF1, BITS_DIFF1, 1000000, h160_uniform(0x11)); + uint256 tip = sc.add(0x02, g, BITS_DIFF1, BITS_DIFF1, 1000020, h160_uniform(0x11), + 0, 0, uint128(), {A, B}); + + // desired [B, C, A]: B -> [1,1] (share_count 1+i=1, tx_count 1); + // C unseen -> new [0,0]; A -> [1,0]. (data.py:147-170) + auto refs = dash::producer::assemble_tx_refs(sc.chain, tip, {B, C, A}); + ASSERT_EQ(refs.new_transaction_hashes.size(), 1u); + EXPECT_EQ(refs.new_transaction_hashes[0], C); + EXPECT_EQ(refs.transaction_hash_refs, + (std::vector{1, 1, 0, 0, 1, 0})); + EXPECT_EQ(refs.other_transaction_hashes, (std::vector{B, C, A})); +} + +TEST(DashShareProducerInfo, AbsAccumulationAndWrap) { + const auto params = dash::make_coin_params(false); + SyntheticChain sc; + // Previous share at the wrap boundary: absheight 2^32-1, abswork 2^128-1. + uint128 max128 = u128_hex("ffffffffffffffffffffffffffffffff"); + uint256 tip = sc.add(0x01, uint256(), BITS_DIFF1, BITS_DIFF1, 1000000, + h160_uniform(0x11), 0, 0xffffffffu, max128); + + ProducerJobInputs in; + in.prev_share_hash = tip; + in.coinbase_scriptSig = {0x51, 0x52}; + in.pubkey_hash = h160_uniform(0x22); + in.subsidy = 500000000; + in.desired_timestamp = 1000010; + in.desired_target = params.max_target; + + auto info = dash::producer::generate_prospective_share_info(sc.chain, params, in); + // (prev + 1) % 2^32 = 0; (prev + ata(diff1)) % 2^128 = ata(diff1) - 1. + EXPECT_EQ(info.absheight, 0u); + EXPECT_EQ(info.abswork, u128_hex("100010000")); + EXPECT_EQ(info.bits, BITS_DIFF1); // height 1 < lookbehind + EXPECT_EQ(info.timestamp, 1000010u); // inside the clip window + EXPECT_TRUE(info.far_share_hash.IsNull()); + + // Genesis accumulation: absheight 0+1, abswork 0+ata(bits). + ProducerJobInputs gin = in; + gin.prev_share_hash = uint256(); + auto ginfo = dash::producer::generate_prospective_share_info(sc.chain, params, gin); + EXPECT_EQ(ginfo.absheight, 1u); + EXPECT_EQ(ginfo.abswork, u128_hex(ATA_DIFF1_HEX)); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// PPLNS weights — ORACLE window (data.py:181-184) + partial inclusion +// (data.py:473-481). The window starts at the GRANDPARENT of the share being +// built; the tip (previous share) itself is EXCLUDED. NOTE: the in-tree +// verifier generate_share_transaction walks from the tip — this KAT pins the +// oracle semantics for the producer (review hotspot, see PR body). +// ═════════════════════════════════════════════════════════════════════════════ + +TEST(DashShareProducerWeights, OracleWindowStartsAtGrandparent) { + SyntheticChain sc; + const uint160 A = h160_uniform(0xaa), B = h160_uniform(0xbb), C = h160_uniform(0xcc); + uint256 g = sc.add(0x01, uint256(), BITS_DIFF1, BITS_DIFF1, 1000000, A); + uint256 s1 = sc.add(0x02, g, BITS_DIFF1, BITS_DIFF1, 1000020, B); + uint256 s2 = sc.add(0x03, s1, BITS_DIFF1, BITS_DIFF1, 1000040, C); // tip = prev of new share + (void)s2; + + // Building on s2: start = s2.prev = s1; max_shares = min(3, RCL) - 1 = 2. + // Window = [s1 (B), g (A)] — C is NOT in its own window. + uint288 huge = u288_hex("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + auto w = dash::producer::get_cumulative_weights(sc.chain, s1, 2, huge); + + const uint288 a1_65535 = u288_hex(ATA_DIFF1_HEX) * 65535u; + ASSERT_EQ(w.weights.size(), 2u); + EXPECT_EQ(w.weights.at(dash::pubkey_hash_to_script2(A)), a1_65535); + EXPECT_EQ(w.weights.at(dash::pubkey_hash_to_script2(B)), a1_65535); + EXPECT_EQ(w.weights.count(dash::pubkey_hash_to_script2(C)), 0u); + EXPECT_EQ(w.total_weight, a1_65535 + a1_65535); + EXPECT_TRUE(w.donation_weight.IsNull()); +} + +TEST(DashShareProducerWeights, PartialInclusionMatchesOracleDivision) { + SyntheticChain sc; + const uint160 A = h160_uniform(0xaa), B = h160_uniform(0xbb); + // Window walk order: [s1 (B, don 0), g (A, don 6553)]. + uint256 g = sc.add(0x01, uint256(), BITS_DIFF1, BITS_DIFF1, 1000000, A, 6553); + uint256 s1 = sc.add(0x02, g, BITS_DIFF1, BITS_DIFF1, 1000020, B, 0); + + // desired = 65535*a1 + 65535*(a1//2): s1 fully included, g partially. + // Oracle apply_delta trace (exact ints): + // remaining//65535 = a1//2 = 0x80008000 + // partial_addr = 0x80008000 * (a1*58982) // a1 = 0x733373330000 + // partial_don = 0x80008000 * (a1*6553) // a1 = 0x0ccc8ccc8000 + auto w = dash::producer::get_cumulative_weights( + sc.chain, s1, 2, u288_hex("17fffffff7fff")); + + EXPECT_EQ(w.weights.at(dash::pubkey_hash_to_script2(B)), + u288_hex(ATA_DIFF1_HEX) * 65535u); + EXPECT_EQ(w.weights.at(dash::pubkey_hash_to_script2(A)), + u288_hex("733373330000")); + EXPECT_EQ(w.donation_weight, u288_hex("ccc8ccc8000")); + EXPECT_EQ(w.total_weight, u288_hex("17fffffff7fff")); // capped at desired +} + +// ═════════════════════════════════════════════════════════════════════════════ +// (a) ref stream / ref_hash goldens — fixture F1 (genesis mint, no payload, +// one '!' script payment). Byte layout hand-assembled from share_info_type +// (data.py:81-106) + ref_type (data.py:124-127); digests via CPython hashlib. +// ═════════════════════════════════════════════════════════════════════════════ + +namespace { + +ProspectiveShareInfo fixture_f1_info() { + ProspectiveShareInfo info; + info.prev_hash = uint256(); + info.coinbase = {0x03, 0x01, 0x02, 0x03}; + info.coinbase_payload = {}; + info.nonce = 0x01020304; + info.pubkey_hash = h160_uniform(0x11); + info.subsidy = 500000000; + info.donation = 200; + info.stale_info = dash::StaleInfo::none; + info.desired_version = 16; + info.payment_amount = 100000000; + { + dash::PackedPayment pp; + pp.m_payee = "!6a04deadbeef"; + pp.m_amount = 100000000; + info.packed_payments.push_back(pp); + } + info.far_share_hash = uint256(); + info.max_bits = BITS_DIFF1; + info.bits = BITS_DIFF1; + info.timestamp = 1700000000; + info.absheight = 1; + info.abswork = u128_hex(ATA_DIFF1_HEX); + return info; +} + +const char* F1_REF_STREAM_HEX = + "7242ef345e1bed6b" // IDENTIFIER + "0000000000000000000000000000000000000000000000000000000000000000" // prev None + "0403010203" // coinbase VarStr + "00" // inner payload None + "04030201" // nonce LE + "1111111111111111111111111111111111111111" // pubkey_hash + "0065cd1d00000000" // subsidy LE + "c800" // donation LE + "00" // stale_info + "10" // desired_version VarInt + "00e1f50500000000" // payment_amount LE + "010d213661303464656164626565660" "0e1f50500000000" // packed_payments[1] + "00" // new_tx_hashes + "00" // tx_hash_ref pairs + "0000000000000000000000000000000000000000000000000000000000000000" // far None + "ffff001d" "ffff001d" // max_bits, bits + "00f15365" // timestamp LE + "01000000" // absheight LE + "01000100010000000000000000000000"; // abswork LE (128) + +const char* F1_REF_HASH_HEX = + "ae9fd236e3de3647ce76bf2eb3172ad0ec51d3edba4b231b4b1e2d204771880b"; + +const char* F1_GENTX_HEX = + "01000000" // version 1, type 0 + "010000000000000000000000000000000000000000000000000000000000000000" + "ffffffff" "0403010203" "ffffffff" // coinbase input + "04" // 4 outputs + "00127a0000000000" "1976a914111111111111111111111111111111111111111188ac" // finder 8000000 + "00e1f50500000000" "066a04deadbeef" // payment 100000000 + "00725d1700000000" "1976a91420cb5c22b1e4d5947e5c112c7696b51ad9af3c6188ac" // donation 392000000 + "0000000000000000" "2a6a28" + "ae9fd236e3de3647ce76bf2eb3172ad0ec51d3edba4b231b4b1e2d204771880b" // ref_hash + "0102030405060708" // last_txout_nonce LE + "00000000"; // locktime + +const char* F1_TXID_HEX = + "4cf27aa8726d1df1af549cf905e1b747288dcca9a418f15d052ad45cc0d0b9a0"; + +constexpr uint64_t F1_NONCE64 = 0x0807060504030201ull; + +} // namespace + +TEST(DashShareProducerRefHash, F1StreamAndRefHashGolden) { + const auto params = dash::make_coin_params(false); + const auto info = fixture_f1_info(); + + // Full ref preimage bytes (identifier || share_info) — pinned. + PackStream s; + { + const std::string hex = params.active_identifier_hex(); + for (size_t i = 0; i + 1 < hex.size(); i += 2) { + unsigned char b = static_cast( + std::stoul(hex.substr(i, 2), nullptr, 16)); + s.write(std::span(reinterpret_cast(&b), 1)); + } + } + dash::producer::serialize_share_info(s, info); + std::vector bytes( + reinterpret_cast(s.data()), + reinterpret_cast(s.data()) + s.size()); + EXPECT_EQ(hex_of_bytes(bytes), F1_REF_STREAM_HEX); + EXPECT_EQ(bytes.size(), 179u); + + // ref_hash = sha256d(stream) folded through the (empty) ref merkle link. + EXPECT_EQ(hex_of(dash::producer::compute_ref_hash(params, info)), F1_REF_HASH_HEX); +} + +TEST(DashShareProducerGentx, F1BytesGolden) { + const auto params = dash::make_coin_params(false); + const auto info = fixture_f1_info(); + + uint256 ref_hash; + { + std::vector raw; + const std::string h = F1_REF_HASH_HEX; + for (size_t i = 0; i + 1 < h.size(); i += 2) + raw.push_back(static_cast(std::stoul(h.substr(i, 2), nullptr, 16))); + std::memcpy(ref_hash.data(), raw.data(), 32); + } + + // Genesis: empty weights -> finder 2% + donation remainder only. + auto gentx = dash::producer::build_gentx( + info, dash::producer::CumulativeWeights{}, ref_hash, F1_NONCE64, params); + + EXPECT_EQ(hex_of_bytes(gentx.bytes), F1_GENTX_HEX); + EXPECT_EQ(gentx.bytes.size(), 189u); + EXPECT_EQ(gentx.prefix_len, 145u); // len - 0 (no payload) - 32 - 8 - 4 + EXPECT_EQ(hex_of(gentx.txid), F1_TXID_HEX); +} + +// F2: same fixture with a DIP4 CbTx payload (0xdeadbeef). version|type flip to +// {3,5}, VarStr(extra_payload) tail, prefix cut excludes it. Goldens: CPython. +TEST(DashShareProducerGentx, F2PayloadGolden) { + const auto params = dash::make_coin_params(false); + auto info = fixture_f1_info(); + info.coinbase_payload = {0xde, 0xad, 0xbe, 0xef}; + + const uint256 ref_hash = dash::producer::compute_ref_hash(params, info); + EXPECT_EQ(hex_of(ref_hash), + "e5efc58b08be49d7bc03b6f1731114d84068346817017465ce07e46c516196e1"); + + auto gentx = dash::producer::build_gentx( + info, dash::producer::CumulativeWeights{}, ref_hash, F1_NONCE64, params); + EXPECT_EQ(gentx.bytes.size(), 194u); + EXPECT_EQ(gentx.prefix_len, 145u); // len - 5 (VarStr payload) - 44 + EXPECT_EQ(hex_of(gentx.txid), + "6835d59cfe4c564b06dc55b3d235fe201869a3aca3c1368eb295a831e8e36239"); + // version int16 / type int16 (dash/data.py:97-98) + payload tail. + ASSERT_GE(gentx.bytes.size(), 5u); + EXPECT_EQ(gentx.bytes[0], 0x03); EXPECT_EQ(gentx.bytes[1], 0x00); + EXPECT_EQ(gentx.bytes[2], 0x05); EXPECT_EQ(gentx.bytes[3], 0x00); + const auto tail = std::vector(gentx.bytes.end() - 5, gentx.bytes.end()); + EXPECT_EQ(hex_of_bytes(tail), "04deadbeef"); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// (e) hash_link producer -> checker round trip. +// ═════════════════════════════════════════════════════════════════════════════ + +TEST(DashShareProducerHashLink, RoundTripAcrossBufferBoundaries) { + const auto ce = dash::compute_gentx_before_refhash(); // 37 bytes + ASSERT_EQ(ce.size(), 37u); + + // Sweep prefix lengths across several SHA256 block boundaries: prefix = + // [pattern...] || const_ending; resumed fold must equal fresh SHA256d. + for (size_t total = ce.size(); total <= ce.size() + 130; ++total) { + std::vector prefix(total - ce.size()); + for (size_t i = 0; i < prefix.size(); ++i) + prefix[i] = static_cast(i * 7 + 3); + prefix.insert(prefix.end(), ce.begin(), ce.end()); + + std::vector data; + for (int i = 0; i < 44; ++i) data.push_back(static_cast(0xf0 - i)); + + auto hl = dash::producer::prefix_to_hash_link(prefix, ce); + uint256 folded = dash::check_hash_link(hl, data, ce); + + std::vector whole(prefix); + whole.insert(whole.end(), data.begin(), data.end()); + uint256 direct = Hash(std::span(whole.data(), whole.size())); + ASSERT_EQ(hex_of(folded), hex_of(direct)) << "prefix len " << total; + } +} + +TEST(DashShareProducerHashLink, RejectsPrefixWithoutConstEnding) { + const auto ce = dash::compute_gentx_before_refhash(); + std::vector bad(50, 0xab); + EXPECT_THROW(dash::producer::prefix_to_hash_link(bad, ce), std::runtime_error); +} + +// Non-circular pin: length-0 midstate == SHA-256 IV; extra_data empty. +// (CPython: hashlib state of b'' is the IV by definition.) +TEST(DashShareProducerHashLink, EmptyPrefixIsIV) { + auto hl = dash::producer::prefix_to_hash_link({}, {}); + EXPECT_EQ(hex_of_bytes(hl.m_state.m_data), + "6a09e667bb67ae853c6ef372a54ff53a510e527f9b05688c1f83d9ab5be0cd19"); + EXPECT_TRUE(hl.m_extra_data.m_data.empty()); + EXPECT_EQ(hl.m_length, 0u); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// (a)+(b) Full DashShare build: mandatory self-verify against the in-tree +// verifier, wire-codec round trip, oracle outer-payload framing. +// ═════════════════════════════════════════════════════════════════════════════ + +namespace { + +bitcoin_family::coin::SmallBlockHeaderType fixture_min_header() { + bitcoin_family::coin::SmallBlockHeaderType h; + h.m_version = 536870912; // BIP9 base version + h.m_previous_block = h256_tag(0x77); + h.m_timestamp = 1700000005; + h.m_bits = 0x1b00ffffu; // block target (much harder than share) + h.m_nonce = 0xdeadbeefu; + return h; +} + +} // namespace + +TEST(DashShareProducerBuild, F1GenesisSelfVerifies) { + const auto params = dash::make_coin_params(false); + dash::ShareChain chain; + + auto built = dash::producer::build_share( + chain, params, fixture_f1_info(), fixture_min_header(), F1_NONCE64, + /*check_pow=*/false); + + EXPECT_EQ(hex_of(built.ref_hash), F1_REF_HASH_HEX); + EXPECT_EQ(hex_of(built.gentx_hash), F1_TXID_HEX); + EXPECT_FALSE(built.share.m_hash.IsNull()); + EXPECT_TRUE(built.share.m_coinbase_payload_outer.m_data.empty()); + // prefix_len 145: buf tail 145 % 64 = 17 bytes, fully covered by the + // 37-byte const_ending -> extra_data empty (oracle data.py:37). + EXPECT_EQ(built.share.m_hash_link.m_length, 145u); + EXPECT_TRUE(built.share.m_hash_link.m_extra_data.m_data.empty()); + + // Independent re-run of the verifier reproduces the same share hash. + EXPECT_EQ(hex_of(dash::share_init_verify(built.share, params, false)), + hex_of(built.share.m_hash)); +} + +TEST(DashShareProducerBuild, F2PayloadOuterFramingAndWireRoundTrip) { + const auto params = dash::make_coin_params(false); + dash::ShareChain chain; + + auto info = fixture_f1_info(); + info.coinbase_payload = {0xde, 0xad, 0xbe, 0xef}; + + auto built = dash::producer::build_share( + chain, params, info, fixture_min_header(), F1_NONCE64, /*check_pow=*/false); + + // Oracle outer field VALUE = VarStr(raw payload) (data.py:277-289). + EXPECT_EQ(hex_of_bytes(built.share.m_coinbase_payload_outer.m_data), "04deadbeef"); + + // Wire codec round trip (DashFormatter): serialize -> parse -> serialize + // must be byte-identical, and the wire tail must carry the DOUBLE length + // prefix the oracle's PossiblyNone(b'', VarStr) outer layer produces: + // [merkle branch count 00] [05] [04 de ad be ef]. + PackStream w1; + dash::DashFormatter::Write(w1, &built.share); + std::vector wire1( + reinterpret_cast(w1.data()), + reinterpret_cast(w1.data()) + w1.size()); + ASSERT_GE(wire1.size(), 7u); + EXPECT_EQ(hex_of_bytes(std::vector(wire1.end() - 7, wire1.end())), + "00050" "4deadbeef"); + + // Parse back through the full load_share path (RawShare, type 16). + chain::RawShare raw(16, PackStream(wire1)); + auto loaded = dash::load_share(raw, NetService()); + PackStream w2; + loaded.invoke([&](auto* obj) { + dash::DashFormatter::Write(w2, obj); + // The loaded share must also pass the verifier with the same hash. + EXPECT_EQ(hex_of(dash::share_init_verify(*obj, params, false)), + hex_of(built.share.m_hash)); + }); + std::vector wire2( + reinterpret_cast(w2.data()), + reinterpret_cast(w2.data()) + w2.size()); + EXPECT_EQ(hex_of_bytes(wire1), hex_of_bytes(wire2)); + loaded.destroy(); + + // RawShare framing round trip (message_shares element). + PackStream m1; m1 << raw; + chain::RawShare raw2; PackStream m1copy(std::vector( + reinterpret_cast(m1.data()), + reinterpret_cast(m1.data()) + m1.size())); + m1copy >> raw2; + EXPECT_EQ(raw2.type, 16u); + PackStream m2; m2 << raw2; + EXPECT_EQ(m1.size(), m2.size()); + EXPECT_EQ(std::memcmp(m1.data(), m2.data(), m1.size()), 0); +} + +TEST(DashShareProducerBuild, ChainMintPaysWindowAndPassesVerifier) { + const auto params = dash::make_coin_params(false); + SyntheticChain sc; + const uint160 A = h160_uniform(0xaa), B = h160_uniform(0xbb), C = h160_uniform(0xcc); + // g(A) <- s1(B) <- s2(C, tip). abswork/absheight tracked for accumulation. + uint256 g = sc.add(0x01, uint256(), BITS_DIFF1, BITS_DIFF1, 1699999900, A, 0, + 1, u128_hex(ATA_DIFF1_HEX)); + uint256 s1 = sc.add(0x02, g, BITS_DIFF1, BITS_DIFF1, 1699999920, B, 0, + 2, u128_hex(ATA_DIFF1_HEX) * 2u); + uint256 s2 = sc.add(0x03, s1, BITS_DIFF1, BITS_DIFF1, 1699999940, C, 0, + 3, u128_hex(ATA_DIFF1_HEX) * 3u); + + ProducerJobInputs in; + in.prev_share_hash = s2; + in.coinbase_scriptSig = {0x03, 0x01, 0x02, 0x03}; + in.share_nonce = 7; + in.pubkey_hash = C; // miner C mints again + in.subsidy = 500000000; + in.donation = 0; + in.desired_version = 16; + in.desired_timestamp = 1700000000; // clipped to prev+39 = 1699999979 + in.desired_target = params.max_target; + + auto info = dash::producer::generate_prospective_share_info(sc.chain, params, in); + EXPECT_EQ(info.timestamp, 1699999979u); + EXPECT_EQ(info.absheight, 4u); + EXPECT_EQ(info.abswork, u128_hex(ATA_DIFF1_HEX) * 4u); + EXPECT_EQ(info.bits, BITS_DIFF1); // height 3 < lookbehind + EXPECT_TRUE(info.far_share_hash.IsNull()); + + auto built = dash::producer::build_share( + sc.chain, params, info, fixture_min_header(), F1_NONCE64, /*check_pow=*/false); + + // Parse the produced gentx outputs: PPLNS window = [s1(B), g(A)] (oracle + // grandparent window), each 49/100 of the payout; finder fee to C; + // donation output ALWAYS present (amount 0 here). + // worker_payout = 5e8; A = B = 5e8*49*w//(50*2w) = 245000000; + // C = 5e8//50 = 10000000; donation = 0. + struct Out { uint64_t value; std::vector script; }; + std::vector outs; + { + // Minimal reader over the produced bytes (layout pinned by F1 golden). + auto gentx = dash::producer::build_gentx( + info, + [&] { + uint256 grandparent = s1; + const uint288 desired = chain::target_to_average_attempts( + chain::bits_to_target(fixture_min_header().m_bits)) * params.spread * 65535u; + return dash::producer::get_cumulative_weights(sc.chain, grandparent, 2, desired); + }(), + built.ref_hash, F1_NONCE64, params); + // Same inputs -> the exact gentx build_share committed to (txid match). + EXPECT_EQ(hex_of(gentx.txid), hex_of(built.gentx_hash)); + const auto& v = gentx.bytes; + size_t p = 4 /*ver+type*/ + 1 /*vin cnt*/ + 32 + 4; + p += 1 + v[p]; // scriptSig VarStr (short) + p += 4; // sequence + size_t n = v[p++]; // vout count (short) + for (size_t i = 0; i < n; ++i) { + Out o; o.value = 0; + for (int k = 0; k < 8; ++k) o.value |= static_cast(v[p + k]) << (8 * k); + p += 8; + size_t sl = v[p++]; + o.script.assign(v.begin() + p, v.begin() + p + sl); + p += sl; + outs.push_back(std::move(o)); + } + } + ASSERT_EQ(outs.size(), 5u); // A, B, finder C, donation, OP_RETURN + EXPECT_EQ(outs[0].script, dash::pubkey_hash_to_script2(A)); + EXPECT_EQ(outs[0].value, 245000000u); + EXPECT_EQ(outs[1].script, dash::pubkey_hash_to_script2(B)); + EXPECT_EQ(outs[1].value, 245000000u); + EXPECT_EQ(outs[2].script, dash::pubkey_hash_to_script2(C)); + EXPECT_EQ(outs[2].value, 10000000u); + EXPECT_EQ(outs[3].value, 0u); // donation output, always emitted + EXPECT_EQ(outs[3].script, + std::vector(dash::DONATION_SCRIPT.begin(), + dash::DONATION_SCRIPT.end())); + EXPECT_EQ(outs[4].value, 0u); // OP_RETURN ref_hash commitment + ASSERT_EQ(outs[4].script.size(), 42u); + EXPECT_EQ(outs[4].script[0], 0x6a); + EXPECT_EQ(outs[4].script[1], 0x28); + + // Full accept-path verifier (Phase 1 + version-transition gate). + EXPECT_NO_THROW(dash::verify_share(built.share, sc.chain, + params.chain_length, params, + /*verify_init=*/true, /*check_pow=*/false)); +} + +// Merkle link over a non-empty job tx set: branch = [h1, sha256d(h2||h2)] +// (odd layer duplicates its last element — dash/data.py:189-207), and the +// built share still passes the verifier (merkle fold consistency). +TEST(DashShareProducerBuild, MerkleLinkWithTransactions) { + const auto params = dash::make_coin_params(false); + dash::ShareChain chain; + + const uint256 h1 = h256_tag(0x41), h2 = h256_tag(0x42); + auto info = fixture_f1_info(); + info.new_transaction_hashes = {h1, h2}; + info.transaction_hash_refs = {0, 0, 0, 1}; + info.other_transaction_hashes = {h1, h2}; + + auto link = dash::producer::calculate_merkle_link_index0({h1, h2}); + ASSERT_EQ(link.m_branch.size(), 2u); + EXPECT_EQ(link.m_branch[0], h1); + { + std::vector buf(64); + std::memcpy(buf.data(), h2.data(), 32); + std::memcpy(buf.data() + 32, h2.data(), 32); + EXPECT_EQ(link.m_branch[1], + Hash(std::span(buf.data(), buf.size()))); + } + + auto built = dash::producer::build_share( + chain, params, info, fixture_min_header(), F1_NONCE64, /*check_pow=*/false); + EXPECT_EQ(built.share.m_merkle_link.m_branch.size(), 2u); + EXPECT_EQ(hex_of(dash::share_init_verify(built.share, params, false)), + hex_of(built.share.m_hash)); +} + +// Drift fence: the producer's local merkle walk must stay byte-identical to +// the stratum-side dash::coinbase::merkle_branches_raw over the same tx set +// (both implement dash/data.py calculate_merkle_link for index 0). Swept over +// several set sizes incl. the odd-layer duplication cases. +TEST(DashShareProducerBuild, MerkleLinkMatchesCoinbaseBuilder) { + for (size_t n = 0; n <= 9; ++n) { + std::vector txs; + for (size_t i = 0; i < n; ++i) + txs.push_back(h256_tag(static_cast(0x50 + i))); + + auto link = dash::producer::calculate_merkle_link_index0(txs); + + std::vector with_placeholder; + with_placeholder.emplace_back(); + with_placeholder.insert(with_placeholder.end(), txs.begin(), txs.end()); + auto branches = dash::coinbase::merkle_branches_raw(with_placeholder); + + ASSERT_EQ(link.m_branch.size(), branches.size()) << "n=" << n; + for (size_t i = 0; i < branches.size(); ++i) + EXPECT_EQ(link.m_branch[i], branches[i]) << "n=" << n << " i=" << i; + EXPECT_EQ(link.m_index, 0u); + } +} 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