From 2f1710f748f355e67c4bd7824e0c437eb10f0dbe Mon Sep 17 00:00:00 2001 From: frstrtr Date: Sun, 5 Jul 2026 21:09:34 +0000 Subject: [PATCH 1/5] feat(bch): wire ref_hash_fn + thread frozen_ref into local-share author (G2 peer-verify) The local-share author path emitted a bare coinbase (no p2pool OP_RETURN ref_hash) and ran create_local_share with has_frozen=false, so every share it produced was non-peer-verifiable: recomputed ref_hash != stored, 100% of the time (verify_share mismatch). Root cause was a missing set_ref_hash_fn call in the pool entrypoint -- the work-source scaffolding already gated the OP_RETURN emit and the frozen snapshot on ref_hash_fn being set. Wire set_ref_hash_fn to a lambda that walks the share tracker for the share target (compute_share_target), absheight, abswork and far_share_hash with a clipped timestamp, mirroring create_local_share_v35s chain-position math, and computes the ref_hash via compute_ref_hash_for_work. Thread the resulting frozen snapshot (job.frozen_ref) back into create_local_share with has_frozen=true so the create-side reconstruction is byte-exact by construction (frozen round-trip) and the frozen values are chain-correct. BCH is standalone SHA256d: no segwit, no merged dimension. Diff fenced to the BCH tree (pool entrypoint only). --- src/impl/bch/pool_entrypoint.hpp | 187 +++++++++++++++++++++++++++++-- 1 file changed, 177 insertions(+), 10 deletions(-) diff --git a/src/impl/bch/pool_entrypoint.hpp b/src/impl/bch/pool_entrypoint.hpp index 41766af4..c3b5a13a 100644 --- a/src/impl/bch/pool_entrypoint.hpp +++ b/src/impl/bch/pool_entrypoint.hpp @@ -167,6 +167,166 @@ inline void standup_pool_run(boost::asio::io_context& ioc, // follow-up slice as pplns_fn/ref_hash_fn. work_source->set_donation_script(PoolConfig::get_donation_script(35)); + // -- ref_hash_fn: peer-verifiable share commitment (G2 conform) -------- + // Without this the local-author coinbase carries NO p2pool OP_RETURN + // ref_hash (build_connection_coinbase gates the OP_RETURN on ref_hash_fn + // being set) and create_local_share runs has_frozen=false -- so every + // share we author is systematically non-peer-verifiable: the recomputed + // ref_hash never equals the stored one (100% verify_share mismatch). + // This lambda produces the ref_hash AND the chain-walked snapshot + // (bits/max_bits from compute_share_target, absheight/abswork/ + // far_share_hash off the prev walk, clipped timestamp) that populates + // snap.frozen_ref; mining_submit threads it back into create_local_share + // (has_frozen=true) so the create-side reconstruction is byte-exact. + // Mirrors main_btc.cpp:920 and the chain-position math of + // create_local_share_v35 (share_check.hpp:2724); BCH is standalone + // SHA256d -- no segwit, no merged dimension. + work_source->set_ref_hash_fn( + [&node](const uint256& prev_share_hash, + const std::vector& scriptSig, + const std::vector& payout_script, + uint64_t subsidy, uint32_t block_bits, uint32_t timestamp) + -> core::stratum::RefHashResult + { + core::stratum::RefHashResult result; + result.share_version = 35; + result.desired_version = 36; + + bch::RefHashParams p; + p.share_version = 35; + p.prev_share = prev_share_hash; + p.coinbase_scriptSig = scriptSig; + p.share_nonce = 0; + p.subsidy = subsidy; + p.donation = 50; // 0.5% finder fee (matches create side) + p.stale_info = 0; + p.desired_version = 36; + p.has_segwit = false; // BCH: never segwit + p.timestamp = timestamp; + + // Pubkey extract mirrors create_local_share_v35 (share_check.hpp + // ~2600): P2PKH / P2SH / P2WPKH, so p.pubkey_hash + p.pubkey_type + // feed the ref-stream identically on both sides. + if (payout_script.size() == 25 && payout_script[0] == 0x76 && + payout_script[1] == 0xa9 && payout_script[2] == 0x14 && + payout_script[23] == 0x88 && payout_script[24] == 0xac) { + std::memcpy(p.pubkey_hash.begin(), payout_script.data() + 3, 20); + p.pubkey_type = 0; + } else if (payout_script.size() == 23 && payout_script[0] == 0xa9 && + payout_script[1] == 0x14 && payout_script[22] == 0x87) { + std::memcpy(p.pubkey_hash.begin(), payout_script.data() + 2, 20); + p.pubkey_type = 2; + } else if (payout_script.size() == 22 && payout_script[0] == 0x00 && + payout_script[1] == 0x14) { + std::memcpy(p.pubkey_hash.begin(), payout_script.data() + 2, 20); + p.pubkey_type = 1; + } + + auto block_bits_fallback = [&] { + p.bits = block_bits; p.max_bits = block_bits; + }; + + // Read-only chain walk (shared lock; the compute thread holds the + // exclusive lock and never runs on this stratum thread). + { + std::shared_lock lk(node.tracker_mutex()); + auto& tracker = node.tracker(); + + const bool have_prev = !prev_share_hash.IsNull() && + tracker.chain.contains(prev_share_hash); + + // share_version off the prev tip (matches create_ver); cold + // start stays v35 voting v36. + if (have_prev) { + tracker.chain.get(prev_share_hash).share.invoke([&](auto* s) { + using ST = std::remove_pointer_t; + p.share_version = ST::version; + }); + } + + // absheight + clipped timestamp off prev. + if (have_prev) { + tracker.chain.get(prev_share_hash).share.invoke([&](auto* prev) { + p.absheight = prev->m_absheight + 1; + if (p.timestamp <= prev->m_timestamp) + p.timestamp = prev->m_timestamp + 1; + }); + } else { + p.absheight = 1; // genesis + } + + // share_target with the clipped timestamp (same call + // create_local_share_v35 makes). + try { + auto st = tracker.compute_share_target( + prev_share_hash, p.timestamp, + chain::bits_to_target(block_bits)); + p.bits = st.bits; p.max_bits = st.max_bits; + } catch (const std::exception&) { + block_bits_fallback(); + } + // Cold start: compute_share_target's genesis branch yields + // bits==0 while max_bits carries the MAX_TARGET floor. Pin + // p.bits to the floor so the ref_hash, the frozen field, and + // share_bits_ all agree (else recomputed != stored). + if (p.bits == 0 && p.max_bits != 0) + p.bits = p.max_bits; + + // abswork = prev_abswork + attempts(this share's bits). + { + auto att = chain::target_to_average_attempts( + chain::bits_to_target(p.bits)); + if (have_prev) { + uint128 prev_abswork; + tracker.chain.get(prev_share_hash).share.invoke( + [&](auto* prev) { prev_abswork = prev->m_abswork; }); + p.abswork = prev_abswork + uint128(att.GetLow64()); + } else { + p.abswork = uint128(att.GetLow64()); + } + } + + // far_share_hash: 99th ancestor, mirroring create_local_share + // _v35's get_height_and_last rule exactly. + if (have_prev) { + auto [prev_height, last] = + tracker.chain.get_height_and_last(prev_share_hash); + if (last.IsNull() && prev_height < 99) + p.far_share_hash = uint256(); + else + p.far_share_hash = + tracker.chain.get_nth_parent_key(prev_share_hash, 99); + } else { + p.far_share_hash = uint256(); + } + } + + // Mirror the walked values into the result for snap.frozen_ref. + result.share_version = p.share_version; + result.desired_version = p.desired_version; + result.bits = p.bits; + result.max_bits = p.max_bits; + result.timestamp = p.timestamp; + result.absheight = p.absheight; + result.abswork = p.abswork; + result.far_share_hash = p.far_share_hash; + + try { + auto [rh, nn] = bch::compute_ref_hash_for_work(p); + result.ref_hash = rh; + result.last_txout_nonce = nn; + } catch (const std::exception& e) { + LOG_WARNING << "[BCH-STRATUM] compute_ref_hash_for_work threw: " + << e.what() << " -- coinbase will lack OP_RETURN"; + } + return result; + }); + + LOG_INFO << "[BCH-STRATUM] ref_hash_fn wired (walks share tracker for" + << " share_target/absheight/abswork/far_share; emits p2pool" + << " OP_RETURN ref_hash + freezes the snapshot for byte-exact" + << " create-side reconstruction)."; + work_source->set_create_share_fn( [&node](const std::vector& full_coinbase, const std::vector& header_80b, @@ -217,8 +377,15 @@ inline void standup_pool_run(boost::asio::io_context& ioc, // Author at the current tip's version (35 on an empty chain), voting // desired=36. Same-version authoring never trips the 60%-by-work // accept gate; the ratchet-driven upgrade to v36 is a follow-up slice. + // Author at the frozen share_version the ref_hash was computed + // under (job.frozen_ref.share_version) so the create-side ref + // reconstruction is byte-exact; fall back to the prev-tip version + // (cold start: v35 voting v36) when ref_hash_fn produced no frozen + // data (bits == 0). int64_t create_ver = 35; - if (!job.prev_share_hash.IsNull() && + if (job.frozen_ref.bits != 0) { + create_ver = job.frozen_ref.share_version; + } else if (!job.prev_share_hash.IsNull() && node.tracker().chain.contains(job.prev_share_hash)) { node.tracker().chain.get_share(job.prev_share_hash).invoke( [&](auto* s) { @@ -244,15 +411,15 @@ inline void standup_pool_run(boost::asio::io_context& ioc, /* witness_root */ uint256(), /* override_max_bits */ job.share_max_bits, // pin share target to /* override_bits */ job.share_bits, // what the miner was issued - /* frozen_absheight */ 0, - /* frozen_abswork */ uint128(), - /* frozen_far_share_hash */ uint256(), - /* frozen_timestamp */ 0, - /* frozen_merged_payout */ uint256(), - /* has_frozen */ false, - /* frozen_merkle_branches*/ {}, - /* frozen_witness_root */ uint256(), - /* frozen_merged_cb_info */ {}, + /* frozen_absheight */ job.frozen_ref.absheight, + /* frozen_abswork */ job.frozen_ref.abswork, + /* frozen_far_share_hash */ job.frozen_ref.far_share_hash, + /* frozen_timestamp */ job.frozen_ref.timestamp, + /* frozen_merged_payout */ job.frozen_ref.merged_payout_hash, + /* has_frozen */ (job.frozen_ref.bits != 0), + /* frozen_merkle_branches*/ job.frozen_ref.frozen_merkle_branches, + /* frozen_witness_root */ uint256(), // BCH: never segwit + /* frozen_merged_cb_info */ {}, // BCH: standalone, no merged /* share_version */ create_ver, /* desired_version */ 36); } catch (const std::exception& e) { From f9c0d32d0a2a02ba6209512fe15df60cdf61146e Mon Sep 17 00:00:00 2001 From: frstrtr Date: Mon, 6 Jul 2026 11:17:16 +0000 Subject: [PATCH 2/5] feat(bch): mirror BCH_DEMO_SHARE_BITS floor into standup_pool_run so ref_hash/frozen/gate read one share target (G2 verify_share conform) Isolated-net demo knob (env-gated OFF on normal/mainnet). On a CPU-grind regtest the ref_hash previously read compute_share_target ~diff-1 floor while the core pool_difficulty gate read the BCH_DEMO_SHARE_BITS floor -- two share targets, so verify_share recompute could not match the stored share. Reading ONE target here eliminates the verify_share hash mismatch. BCH-local; fenced to src/impl/bch/. --- src/impl/bch/pool_entrypoint.hpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/impl/bch/pool_entrypoint.hpp b/src/impl/bch/pool_entrypoint.hpp index c3b5a13a..349cc03f 100644 --- a/src/impl/bch/pool_entrypoint.hpp +++ b/src/impl/bch/pool_entrypoint.hpp @@ -69,6 +69,7 @@ #include #include +#include // std::getenv (BCH_DEMO_SHARE_BITS demo floor) #include #include #include @@ -272,6 +273,20 @@ inline void standup_pool_run(boost::asio::io_context& ioc, if (p.bits == 0 && p.max_bits != 0) p.bits = p.max_bits; + // [ISOLATED-NET DEMO / G2] Mirror work_source's + // BCH_DEMO_SHARE_BITS floor here so the ref_hash, the frozen + // field, and the core pool_difficulty gate all read ONE share + // target on a CPU-grind isolated net. Without it ref_hash_fn + // reports compute_share_target's ~diff-1 floor -- unclearable + // by a CPU grinder -- so no submission is ever promoted to a + // STORED share and recomputed==stored can't be observed. + // OFF unless the env var is set; never active on normal or + // mainnet runs. BCH-local (fenced to the BCH tree). + if (const char* e = std::getenv("BCH_DEMO_SHARE_BITS"); e && *e) { + uint32_t demo = static_cast(std::strtoul(e, nullptr, 16)); + if (demo) { p.bits = demo; p.max_bits = demo; } + } + // abswork = prev_abswork + attempts(this share's bits). { auto att = chain::target_to_average_attempts( From 940f8aa85ab4b394f2100e900200728d77b9ce17 Mon Sep 17 00:00:00 2001 From: frstrtr Date: Mon, 6 Jul 2026 11:37:23 +0000 Subject: [PATCH 3/5] fix(bch): ring stratum notify_all after local-share author so work refreshes with new tip as prev_share (G2 chain-link) Every authored share landed height=1 prev=0000 as an orphan sibling because the miner kept grinding the job frozen at pool start (prev= genesis): notify_local_share updated the internal best/think() but never rang the stratum work-refresh. Core StratumServer::notify_all() already re-polls best_share_hash_fn and pushes a clean mining.notify with the new sharechain tip; this wires the local-share author path to it (BCH-side only, core unchanged). Restores chain growth past height 1. --- src/impl/bch/pool_entrypoint.hpp | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/impl/bch/pool_entrypoint.hpp b/src/impl/bch/pool_entrypoint.hpp index 349cc03f..57ad1d09 100644 --- a/src/impl/bch/pool_entrypoint.hpp +++ b/src/impl/bch/pool_entrypoint.hpp @@ -62,6 +62,7 @@ #include #include +#include #include // HexStr @@ -342,8 +343,17 @@ inline void standup_pool_run(boost::asio::io_context& ioc, << " OP_RETURN ref_hash + freezes the snapshot for byte-exact" << " create-side reconstruction)."; + // Local-share -> stratum work-refresh bridge (BCH-side wiring; the refresh + // mechanism itself lives unchanged in core StratumServer::notify_all(), + // which re-polls best_share_hash_fn and pushes a clean mining.notify with + // the NEW sharechain tip as prev_share). Without ringing it after each + // authored share, every miner keeps grinding the job frozen at pool start + // (prev=genesis), so shares bootstrap off 0000... as orphan siblings and + // the chain never links past height 1. + auto stratum_notify = std::make_shared>(); + work_source->set_create_share_fn( - [&node](const std::vector& full_coinbase, + [&node, stratum_notify](const std::vector& full_coinbase, const std::vector& header_80b, const core::stratum::JobSnapshot& job, const std::vector& payout_script) -> uint256 @@ -448,6 +458,9 @@ inline void standup_pool_run(boost::asio::io_context& ioc, if (!share_hash.IsNull()) { node.broadcast_share(share_hash); node.notify_local_share(share_hash); + // Ring the stratum work-refresh so the next mining.notify carries + // the new sharechain tip as prev_share (chain links past height 1). + if (*stratum_notify) (*stratum_notify)(); LOG_INFO << "[BCH-CREATE-SHARE] OK + broadcast v" << create_ver << " hash=" << share_hash.GetHex().substr(0, 16); } @@ -463,6 +476,8 @@ inline void standup_pool_run(boost::asio::io_context& ioc, stratum_server = std::make_unique( ioc, stratum_addr, stratum_port, work_source); if (stratum_server->start()) { + // Wire the local-share bridge to the now-constructed stratum server. + *stratum_notify = [srv = stratum_server.get()]() { srv->notify_all(); }; LOG_INFO << "[BCH-POOL] stratum listening on " << stratum_addr << ":" << stratum_port << " (BCHWorkSource: SHA256d, no-segwit," << " CashTokens transparent-carry; hit block routes the" From 5bcd8d2a7741349f4575cf05af0b776da4e4620c Mon Sep 17 00:00:00 2001 From: frstrtr Date: Mon, 6 Jul 2026 12:08:52 +0000 Subject: [PATCH 4/5] fix(bch): guard 99-deep far_share walk against short-chain throw (G2 chain-grow) The prev-link fix lets the sharechain grow past height 1, but two 99-deep get_nth_parent_key(prev,99) far_share walks -- the ref_hash_fn lambda in pool_entrypoint and the create_local_share author in share_check -- threw get_nth_parent_key: chain too short once the chain outgrew genesis yet was still shorter than the 99 lookback, aborting every share past height 2. The _v35 author already degrades to far=None via try/catch on this exact case; mirror that guard into both sites so far_share collapses to None on a short chain (matches p2pool get_nth_parent_hash(prev,99) semantics). Regtest grind now grows chain 1->12 with zero create-share throws. --- src/impl/bch/pool_entrypoint.hpp | 16 ++++++++++++---- src/impl/bch/share_check.hpp | 9 ++++++++- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/impl/bch/pool_entrypoint.hpp b/src/impl/bch/pool_entrypoint.hpp index 57ad1d09..984b83d8 100644 --- a/src/impl/bch/pool_entrypoint.hpp +++ b/src/impl/bch/pool_entrypoint.hpp @@ -307,11 +307,19 @@ inline void standup_pool_run(boost::asio::io_context& ioc, if (have_prev) { auto [prev_height, last] = tracker.chain.get_height_and_last(prev_share_hash); - if (last.IsNull() && prev_height < 99) + if (last.IsNull() && prev_height < 99) { p.far_share_hash = uint256(); - else - p.far_share_hash = - tracker.chain.get_nth_parent_key(prev_share_hash, 99); + } else { + try { + p.far_share_hash = + tracker.chain.get_nth_parent_key(prev_share_hash, 99); + } catch (const std::exception&) { + // Bootstrap short chain: degrade to far=None instead of + // throwing out of ref_hash_fn (which would zero frozen_ref + // and force the create-side into its own unguarded walk). + p.far_share_hash = uint256(); + } + } } else { p.far_share_hash = uint256(); } diff --git a/src/impl/bch/share_check.hpp b/src/impl/bch/share_check.hpp index db414901..6399c13c 100644 --- a/src/impl/bch/share_check.hpp +++ b/src/impl/bch/share_check.hpp @@ -2744,7 +2744,14 @@ uint256 create_local_share( // Chain is complete and shorter than 99 → None (zero) share.m_far_share_hash = uint256(); } else { - share.m_far_share_hash = tracker.chain.get_nth_parent_key(prev_share, 99); + try { + share.m_far_share_hash = tracker.chain.get_nth_parent_key(prev_share, 99); + } catch (const std::exception&) { + // Bootstrap: chain shorter than the 99-deep lookback even though + // get_height_and_last reported a non-null last → far=None, mirroring + // the guarded _v35 author and p2pool's get_nth_parent_hash(prev,99). + share.m_far_share_hash = uint256(); + } } } else { // Genesis: p2pool always does (prev_absheight + 1), (prev_abswork + aps) From 477c695434d3596c8c65f71949b9d6e818720c00 Mon Sep 17 00:00:00 2001 From: frstrtr Date: Mon, 6 Jul 2026 12:49:26 +0000 Subject: [PATCH 5/5] bch(g2): store coinbase input scriptSig in share m_coinbase, not full tx create_share_fn fed the entire serialized coinbase transaction into the share m_coinbase field; share_init_verify enforces the consensus 2..100 byte bound on the coinbase input scriptSig and threw "bad coinbase size", leaving every locally-authored G2 share unverified (verified=0). Parse the coinbase tx and extract only the input scriptSig; the full tx still rides via actual_coinbase_bytes for gentx byte-parity. --- src/impl/bch/pool_entrypoint.hpp | 41 ++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/src/impl/bch/pool_entrypoint.hpp b/src/impl/bch/pool_entrypoint.hpp index 984b83d8..b8554c74 100644 --- a/src/impl/bch/pool_entrypoint.hpp +++ b/src/impl/bch/pool_entrypoint.hpp @@ -385,8 +385,45 @@ inline void standup_pool_run(boost::asio::io_context& ioc, min_header.m_bits = read_le32(header_80b.data() + 72); min_header.m_nonce = read_le32(header_80b.data() + 76); - BaseScript coinbase_bs(std::vector( - full_coinbase.begin(), full_coinbase.end())); + // full_coinbase is the fully serialized coinbase TRANSACTION, but a + // share's m_coinbase field must be ONLY the coinbase INPUT scriptSig + // (BIP34 height + pool marker) -- share_init_verify enforces the + // consensus 2..100 byte bound. Feeding the whole tx tripped + // "bad coinbase size" and left every G2 share unverified. Extract the + // input scriptSig here; the full tx still rides via actual_coinbase_bytes + // for gentx byte-parity (extranonce lives in an OP_RETURN output, not + // the scriptSig, so this slice is complete on its own). + std::vector coinbase_scriptSig; + { + size_t p = 0; + bool ok = true; + auto need = [&](size_t n) { return p + n <= full_coinbase.size(); }; + auto rd_varint = [&](uint64_t& out) -> bool { + if (!need(1)) return false; + uint8_t ch = full_coinbase[p++]; + if (ch < 253) { out = ch; return true; } + size_t n = (ch == 253) ? 2 : (ch == 254) ? 4 : 8; + if (!need(n)) return false; + out = 0; + for (size_t i = 0; i < n; ++i) + out |= uint64_t(full_coinbase[p++]) << (8 * i); + return true; + }; + uint64_t vin = 0, slen = 0; + if (need(4)) p += 4; else ok = false; // version + ok = ok && rd_varint(vin) && vin >= 1; // tx_in count + if (ok && need(36)) p += 36; else ok = false; // prevout (32+4) + ok = ok && rd_varint(slen); // scriptSig length + if (ok && need(slen)) { + coinbase_scriptSig.assign(full_coinbase.begin() + p, + full_coinbase.begin() + p + slen); + } else { + LOG_WARNING << "[BCH-CREATE-SHARE] cannot parse coinbase scriptSig from " + << full_coinbase.size() << "B tx -- share skipped"; + return uint256::ZERO; + } + } + BaseScript coinbase_bs(coinbase_scriptSig); // Stratum branches are hex of LE-internal bytes -> ParseHex+memcpy // (SetHex would byte-reverse and break the merkle root the miner used).