diff --git a/src/impl/bch/pool_entrypoint.hpp b/src/impl/bch/pool_entrypoint.hpp index 41766af4d..82e15f1dd 100644 --- a/src/impl/bch/pool_entrypoint.hpp +++ b/src/impl/bch/pool_entrypoint.hpp @@ -62,6 +62,7 @@ #include #include +#include #include // HexStr @@ -69,6 +70,7 @@ #include #include +#include // std::getenv (BCH_DEMO_SHARE_BITS demo floor) #include #include #include @@ -167,8 +169,252 @@ 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; + + // [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( + 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 { + 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(); + } + } + + // 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)."; + + // 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>(); + + // --- Version-aware PPLNS payout + author-version wiring (finder-fee gate) --- + // The height-2 verified-tip stall was a share-version vs coinbase-author + // mismatch: shares stamp at the tip version (35 through a short grind, the + // ratchet never flips) while the coinbase author produced a v36-pure, + // finder-fee-less gentx -- so generate_share_transaction (v35 verify path) + // rejected every non-genesis share. Fix: select the PPLNS shape at the tip + // version and (in build_connection_coinbase) apply the sub-36 finder fee. + // The v35 walk (flat weights, grandparent start, 199/200) and the v36 walk + // (decayed, exponential window, pure) yield DIFFERENT integer amounts + // (division order), so shape must be chosen at weight->amount time, never + // haircut afterward. Ref: share_check.hpp use_v36_pplns. + auto derive_author_version = [&node]() -> int64_t { + int64_t ver = 35; + uint256 tip = node.best_share_hash(); + std::shared_lock lk(node.tracker_mutex()); + if (!tip.IsNull() && node.tracker().chain.contains(tip)) { + node.tracker().chain.get_share(tip).invoke( + [&](auto* s) { + using ST = std::remove_pointer_t; + ver = ST::version; + }); + } + return ver; + }; + work_source->set_author_version_fn(derive_author_version); + + work_source->set_pplns_fn( + [&node](const uint256& best_share_hash, + const uint256& block_target, + uint64_t subsidy, + const std::vector& donation_script) + -> std::map, double> + { + // Single read guard: derive the tip version AND walk under the same + // lock (do NOT call derive_author_version here -- that would take a + // second shared_lock on the same thread == UB under a waiting writer). + std::shared_lock lk(node.tracker_mutex()); + int64_t ver = 35; + uint256 tip = node.best_share_hash(); + if (!tip.IsNull() && node.tracker().chain.contains(tip)) { + node.tracker().chain.get_share(tip).invoke( + [&](auto* s) { + using ST = std::remove_pointer_t; + ver = ST::version; + }); + } + if (ver < 36) + return node.tracker().get_v35_expected_payouts( + best_share_hash, block_target, subsidy, donation_script); + return node.tracker().get_expected_payouts( + best_share_hash, block_target, subsidy, donation_script); + }); + 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 @@ -192,8 +438,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). @@ -217,8 +500,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 +534,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) { @@ -266,6 +556,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); } @@ -281,6 +574,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" diff --git a/src/impl/bch/share_check.hpp b/src/impl/bch/share_check.hpp index db414901d..f4e4403f7 100644 --- a/src/impl/bch/share_check.hpp +++ b/src/impl/bch/share_check.hpp @@ -710,6 +710,26 @@ uint256 share_init_verify(const ShareT& share, bool check_pow = true) reinterpret_cast(header_stream.data()), header_stream.size()); uint256 share_hash = Hash(hdr_span); + // [verify-preimage-diag] Non-fatal author-vs-verify divergence localiser. + // When a locally-stored identity is present and the recomputed hash differs, + // dump the preimage components. The header scalars are carried verbatim, so a + // mismatch here isolates to gentx_hash / merkle_root (coinbase+PPLNS author + // path). The chain keys off share.m_hash, so this is diagnostic only. + if (!share.m_hash.IsNull() && share_hash != share.m_hash) { + static int preimage_diag = 0; + if (preimage_diag++ < 10) { + LOG_WARNING << "[verify-preimage] recomputed=" << share_hash.GetHex().substr(0, 16) + << " stored=" << share.m_hash.GetHex().substr(0, 16); + LOG_WARNING << "[verify-preimage] gentx_hash=" << gentx_hash.GetHex() + << " merkle_root=" << merkle_root.GetHex(); + LOG_WARNING << "[verify-preimage] hdr ver=" << share.m_min_header.m_version + << " prev=" << share.m_min_header.m_previous_block.GetHex().substr(0, 16) + << " time=" << share.m_min_header.m_timestamp + << " bits=0x" << std::hex << share.m_min_header.m_bits << std::dec + << " nonce=" << share.m_min_header.m_nonce; + } + } + // --- PoW check (SHA256d) --- // For Bitcoin POW_FUNC is SHA256d, identical to the block identity hash. // (LTC was scrypt(1024,1,1,256); BTC's pow_hash == share_hash via Hash(hdr_span).) @@ -2744,7 +2764,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) diff --git a/src/impl/bch/stratum/coinbase_outputs.hpp b/src/impl/bch/stratum/coinbase_outputs.hpp index 87d15d770..dbe0f7b25 100644 --- a/src/impl/bch/stratum/coinbase_outputs.hpp +++ b/src/impl/bch/stratum/coinbase_outputs.hpp @@ -94,4 +94,82 @@ assemble_v36_coinbase_outputs( return outputs; } +// apply_v35_finder_fee: pre-v36 (sub-36) finder fee. generate_share_transaction +// (share_check.hpp, use_v36_pplns==false branch) credits subsidy/200 to the +// share creator\x27s own script and shrinks the donation residual by the same +// amount. For a LOCALLY-authored share creator==finder==this connection\x27s +// payout_script, so mirror it exactly here: move subsidy/200 from the donation +// entry (get_v35_expected_payouts folds the ~0.5% into donation) onto +// payout_script. Total stays == subsidy. No-op when `payouts` is empty (cold +// start -> degraded single-output fallback owns that) or the fee rounds to 0. +// Pure/header-only so the production TU and the fenced dual-version KAT consume +// the IDENTICAL implementation. sv>=36 authors never call this (pure PPLNS). +// canonical_finder_script: reconstruct the finder's coinbase script EXACTLY as +// the verifier's generate_share_transaction sees it, i.e. get_share_script() = +// pubkey_hash_to_script(m_pubkey_hash, m_pubkey_type), where create_local_share +// derives (m_pubkey_hash, m_pubkey_type) from payout_script. Standard P2PKH / +// P2SH / P2WPKH scripts are returned verbatim (identity -- no behaviour change). +// A payout_script the share author cannot store as a 20-byte pubkey_hash maps as +// create_local_share does: size >= 20 -> P2PKH of the first 20 bytes; size < 20 +// (incl. EMPTY) -> P2PKH(all-zeros) -- the SAME degenerate creator script the +// stored share carries. This keeps the sub-36 finder-fee target byte-identical +// author<->verifier so the gentx hash matches. Without it, an author whose miner +// authorised with a non-decodable address paid NO finder fee while the verifier +// credited subsidy/200 to P2PKH(zeros): a one-output GENTX-MISMATCH on every +// non-genesis share (the height>=2 verified-tip stall). +inline std::vector canonical_finder_script( + const std::vector& payout_script) +{ + // P2PKH: 76 a9 14 <20> 88 ac (identity) + 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) + return payout_script; + // P2SH: a9 14 <20> 87 (identity) + if (payout_script.size() == 23 && payout_script[0] == 0xa9 && + payout_script[1] == 0x14 && payout_script[22] == 0x87) + return payout_script; + // P2WPKH: 00 14 <20> (identity -- pubkey_hash_to_script(type=1) is the same) + if (payout_script.size() == 22 && payout_script[0] == 0x00 && + payout_script[1] == 0x14) + return payout_script; + // Non-standard: mirror create_local_share's m_pubkey_hash derivation, which + // only writes the hash when payout_script.size() >= 20 (else it stays zero). + unsigned char h[20] = {0}; + if (payout_script.size() >= 20) + for (size_t i = 0; i < 20; ++i) h[i] = payout_script[i]; + std::vector script; + script.reserve(25); + script.push_back(0x76); + script.push_back(0xa9); + script.push_back(0x14); + script.insert(script.end(), h, h + 20); + script.push_back(0x88); + script.push_back(0xac); + return script; +} + +inline void apply_v35_finder_fee( + std::map, double>& payouts, + const std::vector& payout_script, + const std::vector& donation_script, + uint64_t subsidy) +{ + if (payouts.empty()) return; + const double finder_fee = static_cast(subsidy / 200); + if (finder_fee <= 0.0) return; + // Credit the finder fee to the CANONICAL creator script the verifier + // reconstructs from the stored share, NOT the raw connection payout_script. + // These differ only when payout_script is non-standard/empty -- exactly the + // case that left author (no fee) and verifier (fee to P2PKH-zeros) one + // output apart. See canonical_finder_script(). + const auto finder_script = canonical_finder_script(payout_script); + payouts[finder_script] += finder_fee; + if (!donation_script.empty()) { + auto dit = payouts.find(donation_script); + if (dit != payouts.end() && dit->second >= finder_fee) + dit->second -= finder_fee; + } +} + } // namespace bch::stratum diff --git a/src/impl/bch/stratum/work_source.cpp b/src/impl/bch/stratum/work_source.cpp index 7592cce03..1c5627ef2 100644 --- a/src/impl/bch/stratum/work_source.cpp +++ b/src/impl/bch/stratum/work_source.cpp @@ -334,6 +334,12 @@ void BCHWorkSource::set_pplns_fn(PplnsFn fn) pplns_fn_ = std::move(fn); } +void BCHWorkSource::set_author_version_fn(std::function fn) +{ + std::lock_guard lk(callback_mutex_); + author_version_fn_ = std::move(fn); +} + void BCHWorkSource::set_ref_hash_fn(RefHashFn fn) { std::lock_guard lk(callback_mutex_); @@ -384,10 +390,16 @@ core::stratum::CoinbaseResult BCHWorkSource::build_connection_coinbase( PplnsFn pplns_fn; RefHashFn ref_hash_fn; std::vector donation_script; + std::function author_version_fn; { std::lock_guard lk(callback_mutex_); pplns_fn = pplns_fn_; ref_hash_fn = ref_hash_fn_; donation_script = donation_script_; + author_version_fn = author_version_fn_; } + // Author version gates the pre-v36 finder-fee shape (applied below, needs + // this connection\x27s payout_script). Unset => 35 (safe: fresh chains author + // at v35). sv>=36 => pure PPLNS, no finder fee. + const int64_t author_version = author_version_fn ? author_version_fn() : 35; // scriptSig (deterministic): BIP34 height + pool tag. auto bip34 = bip34_height_push(height); @@ -425,6 +437,13 @@ core::stratum::CoinbaseResult BCHWorkSource::build_connection_coinbase( payouts[donation_script] += static_cast(dropped_value); } + // Pre-v36 finder fee: sub-36 authors owe generate_share_transaction\x27s v35 + // credit of subsidy/200 to the (creator==finder==) miner\x27s own payout_script, + // deducted from the donation residual. Header-only apply_v35_finder_fee keeps + // this byte-identical to the KAT-pinned math; sv>=36 skips it entirely. + if (author_version < 36) + apply_v35_finder_fee(payouts, payout_script, donation_script, coinbasevalue); + // V36 removes the finder fee -- pure PPLNS accounting. The oracle // (p2pool-merged-v36 data.py ~945) fires the subsidy/200 finder fee ONLY in // the `not v36_active` branch; the v36 gentx pays no finder fee. Byte-parity diff --git a/src/impl/bch/stratum/work_source.hpp b/src/impl/bch/stratum/work_source.hpp index dd2aeaed0..bd506a291 100644 --- a/src/impl/bch/stratum/work_source.hpp +++ b/src/impl/bch/stratum/work_source.hpp @@ -234,6 +234,12 @@ class BCHWorkSource : public core::stratum::IWorkSource /// appears as an output. void set_donation_script(std::vector script); + /// Wire the author-version producer: returns the sharechain-tip version the + /// next locally-authored share is stamped at (mirrors pool_entrypoint\x27s + /// create_ver derivation off the tip). build_connection_coinbase uses it to + /// gate the pre-v36 finder-fee shape. Left unset => v36-pure (no finder fee). + void set_author_version_fn(std::function fn); + private: // External dependencies (non-owning references) bch::coin::HeaderChain& chain_; @@ -267,6 +273,7 @@ class BCHWorkSource : public core::stratum::IWorkSource RefHashFn ref_hash_fn_; CreateShareFn create_share_fn_; std::vector donation_script_; + std::function author_version_fn_; // Template cache (filled lazily; invalidated when work_generation_ bumps) // Stage c populates these. diff --git a/src/impl/bch/test/coinbase_author_kat_test.cpp b/src/impl/bch/test/coinbase_author_kat_test.cpp index 26181c367..485376946 100644 --- a/src/impl/bch/test/coinbase_author_kat_test.cpp +++ b/src/impl/bch/test/coinbase_author_kat_test.cpp @@ -9,7 +9,7 @@ // ORACLE packer (scripts/gen_g2_oracle.py, a verbatim transcription of the // data.py v36 amounts/dests/payouts block), NOT authored against this builder. // -// Three cases: +// Four cases (dual-version finder-fee bar in 3+4): // CASE 1 -- ORDERING: four PPLNS dests with an amount TIE (B==C) exercises the // (amount asc, THEN script asc) sort; donation marker forced LAST; // the marker <1-sat rule decrements the largest (D) by one satoshi. @@ -41,6 +41,7 @@ using Script = std::vector; using Outputs = std::vector>; using bch::stratum::assemble_v36_coinbase_outputs; +using bch::stratum::apply_v35_finder_fee; static const uint64_t SUBSIDY = 1000000000ULL; @@ -112,6 +113,26 @@ static std::map pplns(std::vector> wei return amounts; } +// V35 pre-marker PPLNS amounts = subsidy*199*weight//(200*total_weight) (the +// 199/200 haircut get_v35_expected_payouts emits), donation entry = subsidy - +// sum(pplns) BEFORE the finder fee is carved out. apply_v35_finder_fee then +// moves subsidy/200 from DON onto the creator==finder script. Mirrors +// share_tracker.hpp get_v35_expected_payouts + share_check.hpp v35 verify. +static std::map pplns_v35(std::vector> weights, + uint64_t donation_weight) { + uint64_t total_weight = donation_weight; + for (auto& [s,w] : weights) total_weight += w; + std::map amounts; + uint64_t sum = 0; + for (auto& [s,w] : weights) { + uint64_t amt = SUBSIDY * 199 * w / (200 * total_weight); + amounts[s] = static_cast(amt); + sum += amt; + } + amounts[DON] = static_cast(SUBSIDY - sum); // pre-finder donation + return amounts; +} + int main() { // ----- CASE 1: ordering + amount-tie script tiebreak + marker decrement --- { @@ -176,6 +197,29 @@ int main() { std::cout << "[KAT] case3 finder-fee-removal byte-diff -- PASS\n"; } + // ----- CASE 4: sub-36 AUTHOR path == fee-present oracle vector ------------ + // v35 author path: get_v35 199/200 shape -> apply_v35_finder_fee -> assemble. + // Same {A:60,B:40} inputs as CASE 3 but authored at sv<36: subsidy/200 moves + // from the donation residual onto the creator==finder script (A). Assembled + // bytes MUST equal CASE 3's ORACLE_WITH_FINDERFEE -- the very vector a v36 + // author is forbidden to emit. Dual-version bar: v36 => fee-LESS, v35 => + // fee-PRESENT, from the SAME production helpers. + { + auto payouts = pplns_v35({{A,60},{B,40}}, 0); + apply_v35_finder_fee(payouts, /*creator==finder==*/A, DON, SUBSIDY); + auto outs = assemble_v36_coinbase_outputs(payouts, DON, SUBSIDY); + assert(outs.size() == 3); + assert(outs[0].first == B && outs[0].second == 398000000ULL); + assert(outs[1].first == A && outs[1].second == 601999999ULL); // 199/200 + fee, -1 marker + assert(outs[2].first == DON && outs[2].second == 1ULL); + const std::string ORACLE_WITH_FINDERFEE = + "80ffb817000000001976a914bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb88ac" + "7fcae123000000001976a914aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa88ac" + "010000000000000017a9148c6272621d89e8fa526dd86acff60c7136be8e8587"; + assert(serialize_outsection(outs) == ORACLE_WITH_FINDERFEE); + std::cout << "[KAT] case4 sub-36 finder-fee author byte-parity -- PASS\n"; + } + std::cout << "[KAT] bch G2 coinbase-author KAT -- ALL CASES PASS\n"; return 0; }