From 09997f1dad1f27ec5a6b665dcedb02d73f8816bd Mon Sep 17 00:00:00 2001 From: frstrtr Date: Fri, 26 Jun 2026 21:06:51 +0000 Subject: [PATCH 1/6] bch(g2): strip v36 finder fee from coinbase author -- conform to p2pool-merged-v36 The oracle (data.py ~945) fires the subsidy/200 finder fee only in the not-v36_active branch; the v36 gentx pays no finder fee (pure PPLNS accounting). The BCH v36 coinbase builder was deducting subsidy/200 from the donation output and crediting the miner -- a payout-value divergence from the canonical merged-v36 gentx. Byte-parity is the HARD invariant, so the sv>=36 path now deducts nothing. Pre-v36 (v35) finder fee remains in the legacy not-v36 path, untouched. Consensus-bearing (moves payout values) -- merge stays operator-gated. BCH-only scope: src/impl/bch/stratum/work_source.cpp. No core, no bitcoin_family. Syntax-clean. Branch-only on bch/g2-work-source. --- src/impl/bch/stratum/work_source.cpp | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/impl/bch/stratum/work_source.cpp b/src/impl/bch/stratum/work_source.cpp index c403eb2f6..364458973 100644 --- a/src/impl/bch/stratum/work_source.cpp +++ b/src/impl/bch/stratum/work_source.cpp @@ -366,17 +366,13 @@ core::stratum::CoinbaseResult BCHWorkSource::build_connection_coinbase( payouts[donation_script] += static_cast(dropped_value); } - // v36 finder fee: subsidy/200 to this miner, deducted from donation. - if (!payouts.empty() && coinbasevalue > 0 && !payout_script.empty()) { - const double finder_fee = static_cast(coinbasevalue) / 200.0; - if (!donation_script.empty()) { - auto it = payouts.find(donation_script); - if (it != payouts.end() && it->second >= finder_fee) { - it->second -= finder_fee; - payouts[payout_script] += finder_fee; - } - } - } + // 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 + // to the canonical merged-v36 gentx is the HARD invariant, so the sv>=36 + // coinbase author deducts NOTHING here (integrator conform ruling 2026-06-27, + // same shape as the DASH dust call). The pre-v36 (v35) finder fee lives in + // the legacy work source's not-v36 path, not in this v36-only builder. // Degraded fallback: full subsidy -> miner. if (payouts.empty() && !payout_script.empty()) From be96b17a545a9883824573995ab780a09b29a706 Mon Sep 17 00:00:00 2001 From: frstrtr Date: Fri, 26 Jun 2026 21:27:42 +0000 Subject: [PATCH 2/6] bch(g2): coinbase-author KAT -- v36 output-assembly byte-parity vs oracle Extract the oracle-conforming coinbase OUTPUT-ASSEMBLY (donation forced last, >=1-sat marker rule, amount-asc/script-asc sort, dests[-4000:] cap) out of BCHWorkSource::build_connection_coinbase into the pure header-only assemble_v36_coinbase_outputs(), so the byte-shape is pinned against the p2pool-merged-v36 oracle (data.py generate_transaction v36 branch) rather than asserted against the builder itself. coinbase_author_kat_test (3 cases, ground-truth from scripts/gen_g2_oracle.py, a verbatim transcription of the data.py v36 amounts/dests/payouts block): 1. ordering: amount-asc with a B==C tie exercising the script-asc tiebreak, donation marker forced last, <1-sat marker decrement of the largest. 2. donation forced last even when its amount ties the largest PPLNS payout. 3. finder-fee removal byte-diff: assembled bytes equal the oracle v36 gentx and differ from the pre-v36 199/200-haircut + subsidy/200 finder-fee control -- proving the fee was removed, not self-authored. Wired into the pure BCH_ABLA_TESTS CMake list and BOTH build.yml COIN_BCH allowlists (no NOT_BUILT green-by-omission). per-coin isolation: src/impl/bch only; zero p2pool-merged-v36 surface. --- .github/workflows/build.yml | 2 + scripts/gen_g2_oracle.py | 70 +++++++ src/impl/bch/stratum/coinbase_outputs.hpp | 97 ++++++++++ src/impl/bch/stratum/work_source.cpp | 58 +----- src/impl/bch/test/CMakeLists.txt | 1 + .../bch/test/coinbase_author_kat_test.cpp | 181 ++++++++++++++++++ 6 files changed, 359 insertions(+), 50 deletions(-) create mode 100644 scripts/gen_g2_oracle.py create mode 100644 src/impl/bch/stratum/coinbase_outputs.hpp create mode 100644 src/impl/bch/test/coinbase_author_kat_test.cpp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2dae9b6bb..6c02e6558 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -343,6 +343,7 @@ jobs: bch_genesis_conformance_test bch_abla_growth_soak_test \ bch_coinbase_kat_segwit_predicate_test \ bch_coinbase_kat_bytevector_test \ + bch_coinbase_author_kat_test \ bch_cashtokens_transparency_test \ bch_block_connector_test bch_block_ordering_test \ bch_utxo_connect_test bch_reorg_connect_test bch_reorg_rerequest_test \ @@ -436,6 +437,7 @@ jobs: bch_genesis_conformance_test bch_abla_growth_soak_test \ bch_coinbase_kat_segwit_predicate_test \ bch_coinbase_kat_bytevector_test \ + bch_coinbase_author_kat_test \ bch_cashtokens_transparency_test \ bch_block_connector_test bch_block_ordering_test \ bch_utxo_connect_test bch_reorg_connect_test bch_reorg_rerequest_test \ diff --git a/scripts/gen_g2_oracle.py b/scripts/gen_g2_oracle.py new file mode 100644 index 000000000..ac7baeaff --- /dev/null +++ b/scripts/gen_g2_oracle.py @@ -0,0 +1,70 @@ +# Oracle golden generator for BCH G2 coinbase-author KAT. +# Transcribes VERBATIM the v36 output-assembly from p2pool-merged-v36 +# p2pool/data.py:920-1085 (generate_transaction, v36_active branch): +# amounts = subsidy*weight//total_weight ; +# total_donation = subsidy - sum(amounts) ; +# if total_donation < 1 and subsidy>0: largest by (amount,script) -=1 ; recompute +# amounts[DON] += total_donation +# dests = sorted(scripts \ {DON}, key=(amounts[s], s))[-4000:] +# payouts = [{value:amounts[s], script:s} for s in dests if amounts[s]] + [DON last] +import struct + +COMBINED_DONATION_SCRIPT = bytes.fromhex('a9148c6272621d89e8fa526dd86acff60c7136be8e8587') +def p2pkh(b): return bytes.fromhex('76a914') + bytes([b])*20 + bytes.fromhex('88ac') +A=p2pkh(0xaa); B=p2pkh(0xbb); C=p2pkh(0xcc); D=p2pkh(0xdd) + +def assemble_v36(weights, donation_weight, subsidy): + total_weight = sum(weights.values()) + donation_weight + amounts = dict((s, subsidy*w//total_weight) for s,w in weights.items()) + total_donation = subsidy - sum(amounts.values()) + if total_donation < 1 and subsidy > 0: + largest = max(amounts, key=lambda k:(amounts[k], k)) + amounts[largest] -= 1 + total_donation = subsidy - sum(amounts.values()) + amounts[COMBINED_DONATION_SCRIPT] = amounts.get(COMBINED_DONATION_SCRIPT,0) + total_donation + excluded = {COMBINED_DONATION_SCRIPT} + dests = sorted([s for s in amounts if s not in excluded], key=lambda s:(amounts[s], s))[-4000:] + payouts = [(s, amounts[s]) for s in dests if amounts[s]] + payouts.append((COMBINED_DONATION_SCRIPT, amounts[COMBINED_DONATION_SCRIPT])) + return payouts + +def assemble_with_finderfee(weights, finder, donation_weight, subsidy): + # NEGATIVE control: pre-v36 math (199/200 haircut) + subsidy//200 finder fee. + total_weight = sum(weights.values()) + donation_weight + amounts = dict((s, subsidy*(199*w)//(200*total_weight)) for s,w in weights.items()) + amounts[finder] = amounts.get(finder,0) + subsidy//200 + total_donation = subsidy - sum(amounts.values()) + if total_donation < 1 and subsidy > 0: + largest = max(amounts, key=lambda k:(amounts[k], k)) + amounts[largest] -= 1 + total_donation = subsidy - sum(amounts.values()) + amounts[COMBINED_DONATION_SCRIPT] = amounts.get(COMBINED_DONATION_SCRIPT,0) + total_donation + dests = sorted([s for s in amounts if s != COMBINED_DONATION_SCRIPT], key=lambda s:(amounts[s], s))[-4000:] + payouts = [(s, amounts[s]) for s in dests if amounts[s]] + payouts.append((COMBINED_DONATION_SCRIPT, amounts[COMBINED_DONATION_SCRIPT])) + return payouts + +def varint(n): + if n < 0xfd: return bytes([n]) + if n <= 0xffff: return b'\xfd'+struct.pack(' 0, decrement the largest PPLNS payout by 1 satoshi +// (deterministic tiebreak on (amount, script)) and move that satoshi into +// the donation output, so the marker always carries >= 1 satoshi. +// 3. PPLNS dests drop zero-value entries, sort (amount asc, then script asc), +// and keep only the largest 4000 (oracle dests[-4000:]). +// 4. NO finder fee is deducted (V36 pure-PPLNS accounting); the input `payouts` +// already reflect the no-haircut, no-finder-fee v36 amounts. +// +// Per-coin isolation: src/impl/bch/ only. Header-only (no link surface) so both +// the production TU and the fenced KAT consume the identical implementation. + +#include +#include +#include +#include +#include + +namespace bch::stratum { + +// payouts: script -> amount (double, as emitted by the PPLNS callback), INCLUDING +// the donation entry keyed by donation_script (pre-marker amount). +// returns: ordered (script, satoshi-amount) outputs ready to serialize, with the +// donation/marker output appended LAST. +inline std::vector, uint64_t>> +assemble_v36_coinbase_outputs( + std::map, double> payouts, + const std::vector& donation_script, + uint64_t coinbasevalue) +{ + // Separate the donation/marker entry from the PPLNS payout set. + uint64_t donation_amount = 0; + bool have_donation = false; + if (!donation_script.empty()) { + auto dit = payouts.find(donation_script); + if (dit != payouts.end()) { + donation_amount = static_cast(dit->second); + have_donation = true; + payouts.erase(dit); + } + } + + // V36 CONSENSUS RULE (data.py: total_donation < 1 path): the donation/marker + // output must carry >= 1 satoshi. If it rounds to 0 while subsidy > 0, + // decrement the largest PPLNS payout by 1 sat (deterministic tiebreak on + // (amount, script)) and move that satoshi into the donation output. + if (have_donation && donation_amount < 1 && coinbasevalue > 0 && !payouts.empty()) { + auto largest = payouts.begin(); + for (auto it = payouts.begin(); it != payouts.end(); ++it) { + const uint64_t a = static_cast(it->second); + const uint64_t la = static_cast(largest->second); + if (a > la || (a == la && it->first > largest->first)) largest = it; + } + if (static_cast(largest->second) >= 1) { + largest->second -= 1.0; + donation_amount += 1; + } + } + + // PPLNS dests: drop zero-value entries, sort (amount asc, script asc), then + // keep only the largest 4000 (oracle dests[-4000:]). + std::vector, uint64_t>> outputs; + outputs.reserve(payouts.size() + 1); + for (const auto& [script, amount_d] : payouts) { + if (script.empty()) continue; + const uint64_t amount = static_cast(amount_d); + if (amount > 0) outputs.emplace_back(script, amount); + } + std::sort(outputs.begin(), outputs.end(), [](const auto& a, const auto& b) { + if (a.second != b.second) return a.second < b.second; + return a.first < b.first; + }); + if (outputs.size() > 4000) + outputs.erase(outputs.begin(), outputs.end() - 4000); + + // Donation/marker output appended LAST, before the OP_RETURN. It is NOT + // filtered by the amount>0 rule: a zero-value donation (subsidy==0) is still + // emitted to preserve gentx_before_refhash byte-parity with the oracle. + if (have_donation) + outputs.emplace_back(donation_script, donation_amount); + + return outputs; +} + +} // namespace bch::stratum diff --git a/src/impl/bch/stratum/work_source.cpp b/src/impl/bch/stratum/work_source.cpp index 364458973..27fa8870c 100644 --- a/src/impl/bch/stratum/work_source.cpp +++ b/src/impl/bch/stratum/work_source.cpp @@ -25,6 +25,8 @@ #include +#include // assemble_v36_coinbase_outputs + #include #include #include // merkle_hash_pair (CTOR SHA256d) @@ -384,56 +386,12 @@ core::stratum::CoinbaseResult BCHWorkSource::build_connection_coinbase( // V36 >= 1-satoshi consensus marker rule. PPLNS dests sort ascending and are // capped to the largest 4000 (oracle dests[-4000:]). - // Separate the donation/marker entry from the PPLNS payout set. - uint64_t donation_amount = 0; - bool have_donation = false; - if (!donation_script.empty()) { - auto dit = payouts.find(donation_script); - if (dit != payouts.end()) { - donation_amount = static_cast(dit->second); - have_donation = true; - payouts.erase(dit); - } - } - - // V36 CONSENSUS RULE (data.py: total_donation < 1 path): the donation/marker - // output must carry >= 1 satoshi. If it rounds to 0 while subsidy > 0, - // decrement the largest PPLNS payout by 1 sat (deterministic tiebreak on - // (amount, script)) and move that satoshi into the donation output. - if (have_donation && donation_amount < 1 && coinbasevalue > 0 && !payouts.empty()) { - auto largest = payouts.begin(); - for (auto it = payouts.begin(); it != payouts.end(); ++it) { - const uint64_t a = static_cast(it->second); - const uint64_t la = static_cast(largest->second); - if (a > la || (a == la && it->first > largest->first)) largest = it; - } - if (static_cast(largest->second) >= 1) { - largest->second -= 1.0; - donation_amount += 1; - } - } - - // PPLNS dests: drop zero-value entries, sort (amount asc, script asc), then - // keep only the largest 4000 (oracle dests[-4000:]). - std::vector, uint64_t>> outputs; - outputs.reserve(payouts.size() + 1); - for (const auto& [script, amount_d] : payouts) { - if (script.empty()) continue; - const uint64_t amount = static_cast(amount_d); - if (amount > 0) outputs.emplace_back(script, amount); - } - std::sort(outputs.begin(), outputs.end(), [](const auto& a, const auto& b) { - if (a.second != b.second) return a.second < b.second; - return a.first < b.first; - }); - if (outputs.size() > 4000) - outputs.erase(outputs.begin(), outputs.end() - 4000); - - // Donation/marker output appended LAST, before the OP_RETURN. It is NOT - // filtered by the amount>0 rule: a zero-value donation (subsidy==0) is still - // emitted to preserve gentx_before_refhash byte-parity with the oracle. - if (have_donation) - outputs.emplace_back(donation_script, donation_amount); + // The donation forced-LAST rule, the V36 >=1-sat marker rule, the + // (amount asc, script asc) sort and the dests[-4000:] cap all live in the + // pure, header-only assemble_v36_coinbase_outputs() so the byte-shape is + // pinned by coinbase_author_kat_test against the p2pool-merged-v36 oracle + // (data.py generate_transaction) rather than asserted against this builder. + auto outputs = assemble_v36_coinbase_outputs(std::move(payouts), donation_script, coinbasevalue); // ref_hash + frozen chain-walk values. core::stratum::RefHashResult rh_result; diff --git a/src/impl/bch/test/CMakeLists.txt b/src/impl/bch/test/CMakeLists.txt index 2696bc8e6..05c81eeea 100644 --- a/src/impl/bch/test/CMakeLists.txt +++ b/src/impl/bch/test/CMakeLists.txt @@ -23,6 +23,7 @@ if(BUILD_TESTING) g1_oracle_byte_parity_test # G1: oracle byte-parity vs p2pool-merged-v36 bitcoincash[_testnet].py (pure) g0_canonical_pin_test # G0: canonical-pin vs p2poolBCH @6603b79 baseline + v35->v36 transition rule (pure) g2_ratchet_gate_kat_test # G2: 60%-by-WORK version-switch accept gate + mint-cannot-outrun-accept (pure) + coinbase_author_kat_test # KAT G2: v36 coinbase output-assembly byte-parity vs p2pool-merged-v36 oracle (pure) ) foreach(t IN LISTS BCH_ABLA_TESTS) add_executable(bch_${t} ${t}.cpp) diff --git a/src/impl/bch/test/coinbase_author_kat_test.cpp b/src/impl/bch/test/coinbase_author_kat_test.cpp new file mode 100644 index 000000000..26181c367 --- /dev/null +++ b/src/impl/bch/test/coinbase_author_kat_test.cpp @@ -0,0 +1,181 @@ +// --------------------------------------------------------------------------- +// bch G2 coinbase-AUTHOR KAT -- v36 output-assembly byte-parity vs oracle +// +// Pins BCHWorkSource::build_connection_coinbase's output-assembly (extracted to +// the pure header bch/stratum/coinbase_outputs.hpp) against ground-truth emitted +// by the p2pool-merged-v36 oracle (p2pool/data.py generate_transaction, +// v36_active branch ~920-1085). This is the byte-diff bar that caught the DASH +// dust-payout false-positive: the expected vectors below were produced by the +// 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: +// 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. +// CASE 2 -- DONATION FORCED LAST regardless of amount: the donation output +// (400000000) ties the largest PPLNS payout yet must still appear +// LAST, after B and A, NOT sorted into amount order. +// CASE 3 -- FINDER-FEE REMOVAL byte-diff: the v36 builder pays NO finder fee. +// The positive vector is the oracle v36 gentx; the NEGATIVE control +// is the same inputs run through the pre-v36 199/200-haircut + +// subsidy/200 finder-fee path. The assembled bytes MUST equal the +// v36 vector and MUST DIFFER from the finder-fee vector -- proving +// the fee was removed rather than asserting self-authored output. +// +// p2pool-merged-v36 surface: NONE (pins an assembly invariant). per-coin +// isolation: src/impl/bch/ only. Over coinbase_outputs.hpp -- no peer/socket/ +// live coin lib, no work-source construction. +// --------------------------------------------------------------------------- + +#include +#include +#include +#include +#include +#include +#include + +#include + +using Script = std::vector; +using Outputs = std::vector>; +using bch::stratum::assemble_v36_coinbase_outputs; + +static const uint64_t SUBSIDY = 1000000000ULL; + +// Fixed deterministic scripts (match scripts/gen_g2_oracle.py). +static Script p2pkh(unsigned char b) { + Script s = {0x76, 0xa9, 0x14}; + s.insert(s.end(), 20, b); + s.push_back(0x88); s.push_back(0xac); + return s; +} +static Script from_hex(const std::string& h) { + Script out; out.reserve(h.size()/2); + for (std::size_t i = 0; i + 1 < h.size(); i += 2) + out.push_back(static_cast(std::stoul(h.substr(i,2), nullptr, 16))); + return out; +} + +static const Script A = p2pkh(0xaa); +static const Script B = p2pkh(0xbb); +static const Script C = p2pkh(0xcc); +static const Script D = p2pkh(0xdd); +// Real V36 COMBINED_DONATION_SCRIPT (P2SH) from data.py:131. +static const Script DON = from_hex("a9148c6272621d89e8fa526dd86acff60c7136be8e8587"); + +// Serialize the output section exactly as the coinbase author does: +// value (LE64) || varint(script_len) || script +static void push_varint(Script& v, uint64_t n) { + if (n < 0xfd) { v.push_back(static_cast(n)); } + else if (n <= 0xffff) { v.push_back(0xfd); v.push_back(n & 0xff); v.push_back((n>>8)&0xff); } + else if (n <= 0xffffffff) { + v.push_back(0xfe); + for (int i=0;i<4;++i) v.push_back((n>>(i*8))&0xff); + } else { + v.push_back(0xff); + for (int i=0;i<8;++i) v.push_back((n>>(i*8))&0xff); + } +} +static std::string to_hex(const Script& b) { + static const char* d = "0123456789abcdef"; + std::string s; s.reserve(b.size()*2); + for (unsigned char c : b) { s.push_back(d[c>>4]); s.push_back(d[c&0xf]); } + return s; +} +static std::string serialize_outsection(const Outputs& outs) { + Script v; + for (const auto& [script, value] : outs) { + for (int i=0;i<8;++i) v.push_back((value>>(i*8))&0xff); // LE64 + push_varint(v, script.size()); + v.insert(v.end(), script.begin(), script.end()); + } + return to_hex(v); +} + +// Pre-marker PPLNS amounts = subsidy*weight//total_weight, plus the donation +// entry keyed by DON = subsidy - sum(pplns) (what the PPLNS callback hands the +// builder). The marker <1-sat rule + ordering happen INSIDE the function. +static std::map pplns(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 a = SUBSIDY * w / total_weight; + amounts[s] = static_cast(a); + sum += a; + } + amounts[DON] = static_cast(SUBSIDY - sum); // pre-marker donation + return amounts; +} + +int main() { + // ----- CASE 1: ordering + amount-tie script tiebreak + marker decrement --- + { + auto outs = assemble_v36_coinbase_outputs( + pplns({{A,10},{B,20},{C,20},{D,50}}, 0), DON, SUBSIDY); + // Order: A(1e8) < B==C(2e8, script-asc bb script asc + assert(outs[3].first == D && outs[3].second == 499999999ULL); // marker -1 sat + assert(outs[4].first == DON && outs[4].second == 1ULL); // donation LAST + const std::string ORACLE = + "00e1f505000000001976a914aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa88ac" + "00c2eb0b000000001976a914bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb88ac" + "00c2eb0b000000001976a914cccccccccccccccccccccccccccccccccccccccc88ac" + "ff64cd1d000000001976a914dddddddddddddddddddddddddddddddddddddddd88ac" + "010000000000000017a9148c6272621d89e8fa526dd86acff60c7136be8e8587"; + assert(serialize_outsection(outs) == ORACLE); + std::cout << "[KAT] case1 ordering/tie/marker -- PASS\n"; + } + + // ----- CASE 2: donation forced LAST even when its amount ties the largest -- + { + auto outs = assemble_v36_coinbase_outputs( + pplns({{A,40},{B,20}}, 40), DON, SUBSIDY); + // PPLNS: B(2e8) < A(4e8); donation(4e8) ties A yet must be LAST. + assert(outs.size() == 3); + assert(outs[0].first == B && outs[0].second == 200000000ULL); + assert(outs[1].first == A && outs[1].second == 400000000ULL); + assert(outs[2].first == DON && outs[2].second == 400000000ULL); // forced last + const std::string ORACLE = + "00c2eb0b000000001976a914bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb88ac" + "0084d717000000001976a914aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa88ac" + "0084d7170000000017a9148c6272621d89e8fa526dd86acff60c7136be8e8587"; + assert(serialize_outsection(outs) == ORACLE); + std::cout << "[KAT] case2 donation-forced-last -- PASS\n"; + } + + // ----- CASE 3: finder-fee REMOVAL -- byte-diff vs the fee-bearing control -- + { + auto outs = assemble_v36_coinbase_outputs( + pplns({{A,60},{B,40}}, 0), DON, SUBSIDY); + assert(outs.size() == 3); + assert(outs[0].first == B && outs[0].second == 400000000ULL); + assert(outs[1].first == A && outs[1].second == 599999999ULL); // full weight, NO -fee + assert(outs[2].first == DON && outs[2].second == 1ULL); + const std::string ORACLE_V36 = + "0084d717000000001976a914bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb88ac" + "ff45c323000000001976a914aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa88ac" + "010000000000000017a9148c6272621d89e8fa526dd86acff60c7136be8e8587"; + // NEGATIVE control: oracle pre-v36 (199/200 haircut + subsidy/200 finder + // fee to A). A v36 builder that still paid the finder fee would emit this. + const std::string ORACLE_WITH_FINDERFEE = + "80ffb817000000001976a914bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb88ac" + "7fcae123000000001976a914aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa88ac" + "010000000000000017a9148c6272621d89e8fa526dd86acff60c7136be8e8587"; + const std::string got = serialize_outsection(outs); + assert(got == ORACLE_V36); // matches oracle v36 gentx + assert(got != ORACLE_WITH_FINDERFEE); // PROVES finder fee removed + assert(ORACLE_V36 != ORACLE_WITH_FINDERFEE); + std::cout << "[KAT] case3 finder-fee-removal byte-diff -- PASS\n"; + } + + std::cout << "[KAT] bch G2 coinbase-author KAT -- ALL CASES PASS\n"; + return 0; +} From 93ecd56b84d404943475ed394ee02cde8a922230 Mon Sep 17 00:00:00 2001 From: frstrtr Date: Fri, 26 Jun 2026 23:14:38 +0000 Subject: [PATCH 3/6] bch(g3b): wire BCHWorkSource + StratumServer into standup_pool_run Close the M5 served-IWorkSource gap (slice-b criterion 1): standup_pool_run now constructs bch::stratum::BCHWorkSource off the embedded daemon HeaderChain + Mempool and stands up core::StratumServer on the miner-facing bind. A mainnet-hit (share-author) block routes through the SAME dual-path broadcaster (embedded P2P primary + BCHN submitblock fallback) as the won-block sink, via daemon.broadcast_won_block(block_bytes, HexStr(block_bytes)) returning r.any() -- so the genuine share coinbase is what reaches the network, not a synthetic solo-cb leg-C block. stratum_port==0 disables the front-end (daemon-only run-loop preserved). Header syntax-clean. src/impl/bch only; p2pool-merged-v36 surface NONE (run-loop bring-up + block dispatch, not share/PPLNS/coinbase bytes). BCH = SHA256d standalone parent, no-segwit, CashTokens transparent-carry. --- src/impl/bch/pool_entrypoint.hpp | 85 ++++++++++++++++++++++++++++---- 1 file changed, 76 insertions(+), 9 deletions(-) diff --git a/src/impl/bch/pool_entrypoint.hpp b/src/impl/bch/pool_entrypoint.hpp index 4ce597f81..97cbaa21e 100644 --- a/src/impl/bch/pool_entrypoint.hpp +++ b/src/impl/bch/pool_entrypoint.hpp @@ -13,6 +13,17 @@ // run-loop accepts shares, or a verified block-meeting share would fire // m_on_block_found into a null sink and the win would be silently dropped. // +// G3-slice-b (integrator 2026-06-27): this entrypoint now also stands up the +// miner-facing IWorkSource + Stratum front-end, closing the M5 "no BCH +// IWorkSource served" gap. BCHWorkSource bridges the coin-agnostic +// core::StratumServer to BCH work generation off the embedded daemon's +// HeaderChain + Mempool, and routes a mainnet-hit (share-author) block down the +// SAME dual-path broadcaster the won-block sink uses (embedded P2P primary + +// BCHN submitblock fallback). So the coinbase a genuine share assembles is what +// reaches the network -- not a synthetic solo-cb leg-C block (the slice-a +// caveat). standup_pool_run is the production caller the work_source.hpp Stage-a +// TODO names ("wire StratumServer into standup_pool_run + main_bch.cpp --pool"). +// // Construction + wiring order (matches EmbeddedDaemon::run() contract): // 1. EmbeddedDaemon(ctx, config, anchor) -- cold-start floor-anchored ABLA. // 2. daemon.run() -- external BCHN-RPC fallback (init_rpc) + assemble() + @@ -25,12 +36,14 @@ // 5. ASSERT node.has_block_broadcaster() -- the sink is LIVE. This is the // structural criterion-C check; throwing here is correct (a missing sink // at the production entrypoint is a wiring bug, not a runtime condition). +// 6. BCHWorkSource + core::StratumServer -- miner-facing front-end; a hit +// block routes through the SAME daemon.broadcast_won_block dual path. // // The sink being live is NECESSARY but NOT SUFFICIENT to declare BCH // block-viable: reconstruct_won_block() still gates on full gentx (coinbase) -// reconstruction, and the dual paths must be proven to FIRE+ACCEPT against the -// live VM300 bchn-bch peer (192.168.86.110:8333) -- the behavioural half of -// criterion C, a separate read-only e2e slice (code-exists != fires). +// reconstruction, and the dual paths must be proven to FIRE+ACCEPT against a +// live BCHN peer -- the behavioural half of criterion C, a separate e2e slice +// (code-exists != fires). // // PER-COIN ISOLATION: src/impl/bch only. p2pool-merged-v36 surface: NONE // (block dispatch + run-loop bring-up, not share/PPLNS/coinbase/AuxPoW bytes). @@ -39,26 +52,40 @@ #include "node.hpp" #include "pool_standup.hpp" #include "coin/embedded_daemon.hpp" +#include "stratum/work_source.hpp" #include +#include + +#include // HexStr #include #include +#include #include +#include +#include namespace bch { // Stand up the BCH pool node + embedded coin daemon on a shared io_context, -// bind the won-block sink, assert it is live, then drive the run-loop. +// bind the won-block sink, assert it is live, stand up the miner-facing +// IWorkSource + Stratum front-end, then drive the run-loop. // `config` and `ioc` outlive this call (owned by the binary entrypoint); the -// daemon and node are owned here for the run-loop lifetime. `anchor_height` is -// the cold-start ABLA floor anchor (operator-approved VM300 record, floor- -// equivalent). Returns when the io_context stops. +// daemon, node, work source and stratum server are owned here for the run-loop +// lifetime. `anchor_height` is the cold-start ABLA floor anchor (operator- +// approved VM300 record, floor-equivalent). `stratum_addr`/`stratum_port` are +// the miner-facing bind (port 0 == stratum disabled); `is_testnet` selects the +// BCH network params the work source stamps onto generated work. Returns when +// the io_context stops. inline void standup_pool_run(boost::asio::io_context& ioc, Config& config, - uint32_t anchor_height) + uint32_t anchor_height, + const std::string& stratum_addr = "0.0.0.0", + uint16_t stratum_port = 0, + bool is_testnet = false) { // 1+2: embedded daemon up first -- it owns the work source + RPC fallback // the pool node consumes, and is the broadcast sink the node wires into. @@ -83,7 +110,47 @@ inline void standup_pool_run(boost::asio::io_context& ioc, << " submitblock fallback). Structural broadcaster-gate criterion-C" << " satisfied; live VM300 e2e is the behavioural half."; - // Drive the shared io_context: pool node + embedded daemon run together. + // 6: miner-facing IWorkSource + Stratum front-end. BCHWorkSource generates + // work off the embedded daemon's HeaderChain + Mempool (SHA256d, no-segwit, + // CashTokens transparent-carry), and routes a mainnet-hit block through the + // SAME dual-path broadcaster (embedded P2P primary + BCHN submitblock + // fallback) the won-block sink uses -- so the share-author coinbase reaches + // the network down both legs. Held here so the shared_ptr + server outlive + // ioc.run() alongside the daemon they reference. + auto work_source = std::make_shared( + daemon.chain(), + daemon.mempool(), + is_testnet, + [&daemon](const std::vector& block_bytes, + uint32_t /*height*/) -> bool { + // Genuine share-author block -> dual-path broadcaster. block_hex for + // the BCHN submitblock fallback is HexStr(block_bytes). True iff at + // least one sink accepted (embedded P2P relay OR external RPC). + coin::BlockBroadcast r = + daemon.broadcast_won_block(block_bytes, HexStr(block_bytes)); + return r.any(); + }); + + std::unique_ptr stratum_server; + if (stratum_port != 0) { + stratum_server = std::make_unique( + ioc, stratum_addr, stratum_port, work_source); + if (stratum_server->start()) { + LOG_INFO << "[BCH-POOL] stratum listening on " << stratum_addr << ":" + << stratum_port << " (BCHWorkSource: SHA256d, no-segwit," + << " CashTokens transparent-carry; hit block routes the" + << " dual-path broadcaster)."; + } else { + LOG_ERROR << "[BCH-POOL] stratum FAILED to bind " << stratum_addr << ":" + << stratum_port << " -- stratum disabled, daemon run-loop continues."; + stratum_server.reset(); + } + } else { + LOG_INFO << "[BCH-POOL] stratum disabled (no --stratum bind given);" + << " embedded daemon run-loop only."; + } + + // Drive the shared io_context: pool node + embedded daemon + stratum run together. ioc.run(); } From 0cbe1b00a32a08b9863df1c66b34f71bd2318d30 Mon Sep 17 00:00:00 2001 From: frstrtr Date: Fri, 26 Jun 2026 23:50:24 +0000 Subject: [PATCH 4/6] bch(g3b): main_bch --pool entrypoint -- bch::Config + --stratum dispatch First non-harness c2pool-bch entrypoint. --pool stands up the BCH pool node + embedded coin daemon via bch::standup_pool_run (the prior 0-caller production standup), binding the dual-path won-block sink and, with --stratum [HOST:]PORT, the miner-facing BCHWorkSource + StratumServer so a genuine share-author coinbase is what gets assembled and broadcast. Config built without a YAML load (matches --ibd harness): P2P magic (mainnet/testnet/regtest), BCHN peer, and sharechain PREFIX set by hand from chainparams. External BCHN-RPC fallback retained. --regtest/--anchor added; banner + arg dispatch wired. Structural slice (criterion 1): gives standup_pool_run its first caller. FOUND->ASSEMBLED->ACCEPTED e2e on fresh-genesis regtest is the next behavioural slice. bch-fenced (src only), p2pool-merged-v36 surface NONE. --- src/c2pool/main_bch.cpp | 91 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/src/c2pool/main_bch.cpp b/src/c2pool/main_bch.cpp index b87ca24ec..b26667250 100644 --- a/src/c2pool/main_bch.cpp +++ b/src/c2pool/main_bch.cpp @@ -34,6 +34,8 @@ #include #include #include +#include +#include // ParseHexBytes (sharechain prefix) #include @@ -71,7 +73,8 @@ void print_banner(const char* argv0) << " " << argv0 << " --ibd [--testnet] [--near-tip] [--auto-kick] [--peer HOST:PORT] [--max-seconds N]\n" << " " << argv0 << " --with-peer-verify [--testnet] [--peer HOST:PORT] [--max-seconds N]\n" << " " << argv0 << " --leg-c-capture [--rpc-conf PATH]\n" - << " " << argv0 << " --leg-c-capture-p2p [--rpc-conf PATH] [--p2p-port N]\n\n" + << " " << argv0 << " --leg-c-capture-p2p [--rpc-conf PATH] [--p2p-port N]\n" + << " " << argv0 << " --pool [--testnet|--regtest] [--stratum [HOST:]PORT] [--peer HOST:PORT] [--anchor N]\n\n" << "Status: M5 pool/sharechain + embedded-daemon assembly live.\n" << " The embedded daemon (coin/embedded_daemon.hpp) is the primary\n" << " work source; external BCHN-RPC stays as the fallback.\n" @@ -583,6 +586,70 @@ int run_leg_c_capture_p2p(const std::string& conf_path, uint16_t p2p_port) return confirmed ? 0 : 1; } + +// --pool: PRODUCTION pool run-loop -- the first non-harness c2pool-bch +// entrypoint. Stands up the BCH pool node + embedded coin daemon on one shared +// io_context via bch::standup_pool_run, with the won-block sink bound (dual +// path: embedded P2P primary + BCHN submitblock fallback) and, when --stratum +// is given, the miner-facing BCHWorkSource + core::StratumServer listening so a +// genuine share-author coinbase is what gets assembled and broadcast. The +// --ibd path is a read-only evidence harness; THIS path is the real node. +// +// Config is built WITHOUT a YAML load (matches run_ibd): no pool.yaml/coin.yaml +// is required for the slice. The two embedded-daemon wire fields (P2P magic + +// BCHN peer) and the sharechain PREFIX (bucket-1 isolation primitive, never +// standardized) are set by hand from BCH chainparams. The external BCHN-RPC +// fallback inside EmbeddedDaemon::run() is retained (external_fallback law). +// +// p2pool-merged-v36 surface: NONE -- run-loop bring-up + block dispatch, not +// share/PPLNS/coinbase/PoW bytes. PER-COIN ISOLATION: src/impl/bch only. +int run_pool(const std::string& peer_host, uint16_t peer_port, bool testnet, + bool regtest, uint32_t anchor_height, + const std::string& stratum_addr, uint16_t stratum_port) +{ + boost::asio::io_context ioc; + + bch::PoolConfig::is_testnet = testnet; + + bch::Config config("bch"); + // Skip Config::init() (no on-disk pool.yaml/coin.yaml); set only the fields + // the run-loop touches, from BCH chainparams. + config.coin()->m_testnet = testnet || regtest; + config.coin()->m_symbol = "BCH"; + config.coin()->m_p2p.address = NetService(peer_host, peer_port); + // BCH P2P network magic (pchMessageStart, BCHN chainparams.cpp): mainnet + // e3e1f3e8, testnet3 f4e5f3f4, regtest dab5bffa. Wrong magic == BCHN drops + // the peer with EOF right after connect. + config.coin()->m_p2p.prefix = regtest + ? std::vector{ std::byte{0xda}, std::byte{0xb5}, std::byte{0xbf}, std::byte{0xfa} } + : (testnet + ? std::vector{ std::byte{0xf4}, std::byte{0xe5}, std::byte{0xf3}, std::byte{0xf4} } + : std::vector{ std::byte{0xe3}, std::byte{0xe1}, std::byte{0xf3}, std::byte{0xe8} }); + // Sharechain identity: BCH p2pool PREFIX namespaces the sharechain P2P + // framing. standup_pool_run's Node reads pool()->m_prefix. + config.pool()->m_prefix = ParseHexBytes(bch::PoolConfig::prefix_hex()); + + std::cout + << "[pool] c2pool-bch pool run-loop" + << (regtest ? " (regtest)" : (testnet ? " (testnet)" : " (mainnet)")) + << " -- BCHN peer " << peer_host << ":" << peer_port + << ", cold-start anchor=" << anchor_height; + if (stratum_port) + std::cout << ", stratum " << stratum_addr << ":" << stratum_port; + else + std::cout << ", stratum DISABLED (no --stratum)"; + std::cout << "\n"; + + try { + bch::standup_pool_run(ioc, config, anchor_height, + stratum_addr, stratum_port, testnet || regtest); + } catch (const std::exception& e) { + std::cout << "[pool] FATAL: " << e.what() << "\n"; + return 1; + } + return 0; +} + } // namespace int main(int argc, char** argv) @@ -592,6 +659,11 @@ int main(int argc, char** argv) bool want_leg_c = false; bool want_leg_c_p2p = false; bool want_with_peer_verify = false; + bool want_pool = false; + bool regtest = false; + std::string stratum_addr = "0.0.0.0"; + uint16_t stratum_port = 0; // 0 disables stratum; --stratum sets it + uint32_t anchor_height = 0; // cold-start ABLA floor anchor uint16_t leg_c_p2p_port = 18444; // BCHN regtest P2P default std::string rpc_conf; bool testnet = false; @@ -609,6 +681,20 @@ int main(int argc, char** argv) if (std::strcmp(argv[i], "--help") == 0) want_help = true; if (std::strcmp(argv[i], "--ibd") == 0) want_ibd = true; if (std::strcmp(argv[i], "--with-peer-verify") == 0) want_with_peer_verify = true; + if (std::strcmp(argv[i], "--pool") == 0) want_pool = true; + if (std::strcmp(argv[i], "--regtest") == 0) { regtest = true; testnet = true; port = 18444; } + if (std::strcmp(argv[i], "--anchor") == 0 && i + 1 < argc) + anchor_height = static_cast(std::stoul(argv[++i])); + if (std::strcmp(argv[i], "--stratum") == 0 && i + 1 < argc) { + std::string sp = argv[++i]; + const auto c = sp.rfind(char(58)); // ASCII colon + if (c != std::string::npos) { + stratum_addr = sp.substr(0, c); + stratum_port = static_cast(std::stoul(sp.substr(c + 1))); + } else { + stratum_port = static_cast(std::stoul(sp)); + } + } if (std::strcmp(argv[i], "--leg-c-capture") == 0) want_leg_c = true; if (std::strcmp(argv[i], "--leg-c-capture-p2p") == 0) want_leg_c_p2p = true; if (std::strcmp(argv[i], "--p2p-port") == 0 && i + 1 < argc) @@ -651,6 +737,9 @@ int main(int argc, char** argv) return run_leg_c_capture(rpc_conf); } + if (want_pool) + return run_pool(host, port, testnet, regtest, anchor_height, stratum_addr, stratum_port); + if (want_with_peer_verify) return run_with_peer_verify(host, port, testnet, max_seconds); From c6606ec1ebd08c81bd2bea99edb9d5d14bbf987a Mon Sep 17 00:00:00 2001 From: frstrtr Date: Sat, 27 Jun 2026 08:12:39 +0000 Subject: [PATCH 5/6] bch(finding3): regtest as distinct third net -- serve diff-1 0x207fffff --pool --regtest previously collapsed to testnet params (anchor@955700, P2P->.110) and served unwinnable testnet difficulty, so a single CPU could never win a block. Thread regtest as a third net distinct from mainnet/testnet: - asert.hpp: asert_regtest() + bch_pow_limit_regtest() (fPowNoRetargeting, fixed target at powLimit nBits 0x207fffff; ASERT bypassed on regtest) - header_chain.hpp: BCHChainParams::regtest() (P2P 18444, cold-start tip seeded so served nbits == 0x207fffff before first P2P regtest block) - embedded_daemon.hpp / pool_entrypoint.hpp / main_bch.cpp: is_regtest dispatch threaded through the --pool entrypoint GBT-from-local-regtest-BCHN (regtest_block::build_from_gbt) deferred to a follow-on sub-slice. Consensus-neutral: regtest-only behavior, no mainnet path change. Fenced to src/impl/bch/ + main_bch.cpp dispatch. --- src/c2pool/main_bch.cpp | 2 +- src/impl/bch/coin/asert.hpp | 25 ++++++++++++++++++++++++ src/impl/bch/coin/embedded_daemon.hpp | 8 +++++--- src/impl/bch/coin/header_chain.hpp | 28 +++++++++++++++++++++++++++ src/impl/bch/pool_entrypoint.hpp | 5 +++-- 5 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/c2pool/main_bch.cpp b/src/c2pool/main_bch.cpp index b26667250..ddc91cc2a 100644 --- a/src/c2pool/main_bch.cpp +++ b/src/c2pool/main_bch.cpp @@ -642,7 +642,7 @@ int run_pool(const std::string& peer_host, uint16_t peer_port, bool testnet, try { bch::standup_pool_run(ioc, config, anchor_height, - stratum_addr, stratum_port, testnet || regtest); + stratum_addr, stratum_port, testnet || regtest, regtest); } catch (const std::exception& e) { std::cout << "[pool] FATAL: " << e.what() << "\n"; return 1; diff --git a/src/impl/bch/coin/asert.hpp b/src/impl/bch/coin/asert.hpp index 7aea68851..837ea129a 100644 --- a/src/impl/bch/coin/asert.hpp +++ b/src/impl/bch/coin/asert.hpp @@ -74,6 +74,31 @@ inline ASERTParams asert_testnet3() { }; } +inline uint256 bch_pow_limit_regtest() { + // BCH regtest powLimit (BCHN CRegTestParams). NOTE: top byte 0x7f does NOT + // satisfy CalculateASERT's 32-leading-zero-bits invariant -- which is why + // regtest runs with fPowNoRetargeting (ASERT is never invoked on regtest). + uint256 v; + v.SetHex("7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + return v; +} + +/// BCH regtest difficulty params (BCHN CRegTestParams). fPowNoRetargeting=true, +/// so the required target is fixed at the powLimit nBits (0x207fffff) for every +/// block -- ASERT is bypassed (header_chain honours BCHChainParams::no_retargeting). +/// The anchor here is nominal (genesis) and never feeds CalculateASERT. +/// allow_min_difficulty mirrors BCHN regtest. (FINDING3: regtest must be its own +/// net so --pool --regtest serves diff-1 0x207fffff, not the testnet anchor.) +inline ASERTParams asert_regtest() { + return ASERTParams{ + ASERTAnchor{0, 0x207fffff, 1296688602}, // genesis nBits/time (nominal) + 2 * 24 * 60 * 60, + BCH_TARGET_SPACING, + true, + bch_pow_limit_regtest(), + }; +} + inline ASERTParams asert_testnet4() { return ASERTParams{ ASERTAnchor{16844, 0x1d00ffff, 1605451779}, diff --git a/src/impl/bch/coin/embedded_daemon.hpp b/src/impl/bch/coin/embedded_daemon.hpp index 403d2b2f0..3cfa38fef 100644 --- a/src/impl/bch/coin/embedded_daemon.hpp +++ b/src/impl/bch/coin/embedded_daemon.hpp @@ -81,10 +81,12 @@ class EmbeddedDaemon { /// Cold-start ctor: floor-anchored ABLA at `anchor_height` (safe default /// when no VM300 BCHN pin is available yet). `context`/`config` outlive the /// daemon (owned by the binary entrypoint, same contract as coin::Node). - EmbeddedDaemon(auto* context, config_t* config, uint32_t anchor_height) + EmbeddedDaemon(auto* context, config_t* config, uint32_t anchor_height, + bool is_regtest = false) : m_config(config), - m_chain(config->m_testnet ? BCHChainParams::testnet() - : BCHChainParams::mainnet()), + m_chain(is_regtest ? BCHChainParams::regtest() + : (config->m_testnet ? BCHChainParams::testnet() + : BCHChainParams::mainnet())), m_pool(), m_embedded(m_chain, m_pool, config->m_testnet), m_node(context, config), diff --git a/src/impl/bch/coin/header_chain.hpp b/src/impl/bch/coin/header_chain.hpp index 830bac7b2..c363534ac 100644 --- a/src/impl/bch/coin/header_chain.hpp +++ b/src/impl/bch/coin/header_chain.hpp @@ -150,6 +150,7 @@ struct BCHChainParams { uint256 pow_limit; // == asert.pow_limit (mirror for check_pow) uint256 genesis_hash; // SHA256d genesis block hash (identification) bool allow_min_difficulty{false}; // == asert.allow_min_difficulty (testnet) + bool no_retargeting{false}; // == BCHN fPowNoRetargeting (regtest) // Fast-start checkpoint: skip syncing from genesis, start from a recent height. // The header chain seeds this checkpoint as if it were the genesis block. @@ -196,6 +197,25 @@ struct BCHChainParams { p.fast_start_checkpoint = Checkpoint{0, p.genesis_hash}; return p; } + + /// BCH regtest params (P2P port 18444). BCHN CRegTestParams: + /// fPowNoRetargeting + fPowAllowMinDifficultyBlocks -- difficulty is fixed at + /// the genesis nBits 0x207fffff (powLimit), so a single CPU can mine it. + /// Genesis is the shared Satoshi regtest genesis. Seeds the cold-start tip so + /// served work nbits == 0x207fffff BEFORE the first P2P regtest block arrives + /// (FINDING3: --pool --regtest previously collapsed to testnet params and + /// served unwinnable testnet difficulty). GBT-from-local-regtest-BCHN + /// (regtest_block::build_from_gbt) is a deferred follow-on slice. + static BCHChainParams regtest() { + BCHChainParams p; + p.asert = asert_regtest(); + p.pow_limit = p.asert.pow_limit; + p.allow_min_difficulty = p.asert.allow_min_difficulty; + p.no_retargeting = true; + p.genesis_hash.SetHex("0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206"); + p.fast_start_checkpoint = Checkpoint{0, p.genesis_hash}; + return p; + } }; // ─── PoW Functions ────────────────────────────────────────────────────────── @@ -705,6 +725,14 @@ class HeaderChain { bool validate_difficulty(const BlockHeaderType& header, uint32_t new_height) { if (new_height < 2) return true; // genesis + first block + // Regtest (BCHN fPowNoRetargeting): difficulty never adjusts -- every + // block carries the powLimit nBits (0x207fffff). ASERT is bypassed here: + // CalculateASERT would assert on the regtest powLimit (top byte 0x7f + // violates its 32-leading-zero-bits invariant). The bits/target are + // already gated against pow_limit by check_pow at the accept site, so + // accept. (FINDING3.) + if (m_params.no_retargeting) return true; + // Get tip (the block we're building on). auto prev_opt = lookup_header_internal(header.m_previous_block); if (!prev_opt) return false; diff --git a/src/impl/bch/pool_entrypoint.hpp b/src/impl/bch/pool_entrypoint.hpp index 97cbaa21e..0a9c6f7a1 100644 --- a/src/impl/bch/pool_entrypoint.hpp +++ b/src/impl/bch/pool_entrypoint.hpp @@ -85,11 +85,12 @@ inline void standup_pool_run(boost::asio::io_context& ioc, uint32_t anchor_height, const std::string& stratum_addr = "0.0.0.0", uint16_t stratum_port = 0, - bool is_testnet = false) + bool is_testnet = false, + bool is_regtest = false) { // 1+2: embedded daemon up first -- it owns the work source + RPC fallback // the pool node consumes, and is the broadcast sink the node wires into. - coin::EmbeddedDaemon daemon(&ioc, &config, anchor_height); + coin::EmbeddedDaemon daemon(&ioc, &config, anchor_height, is_regtest); daemon.run(); // 3: the pool node (sharechain, LevelDB, P2P, Stratum). From f7486db3e093afb17c0669789ed1b3167eef2447 Mon Sep 17 00:00:00 2001 From: frstrtr Date: Sat, 27 Jun 2026 12:21:59 +0000 Subject: [PATCH 6/6] bch(g3a): populate embedded mempool from BCHN GBT for populated won blocks The embedded daemon built coinbase-only templates (nTx=1) on regtest and any RPC-primary deploy: loose BCHN-mempool txs never reach the embedded P2P relay, so build_template selected from an empty m_pool. Two fenced fixes: 1. EmbeddedDaemon::sync_mempool_from_rpc() (run()-startup): feed the BCHN GBT tx set -- the same set NodeRPC::getwork() consumes -- into m_pool. No-op when no external RPC is bound; regtest skips the mainnet/testnet anchor (is_regtest stored as a member). One-shot known limitation; per-tip refresh is a tracked non-blocking follow-up. 2. Mempool::add_tx_with_known_fee() + GBT per-tx fee passthrough (fee_known=true) so get_sorted_txs_with_fees() selects GBT txs into the template and coinbasevalue stays accurate. Proven on live regtest BCHN: won block @h326 nTx=7 (6 mempool + coinbase), 1539-byte relayed body, both broadcaster legs fire (p2p sent, rpc ok, tip 325->326). Fence: src/impl/bch only; no p2pool-merged-v36 surface; external-RPC fallback preserved. --- src/impl/bch/coin/embedded_daemon.hpp | 56 ++++++++++++++++++++++++++- src/impl/bch/coin/mempool.hpp | 36 +++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/src/impl/bch/coin/embedded_daemon.hpp b/src/impl/bch/coin/embedded_daemon.hpp index 3cfa38fef..1d9d04765 100644 --- a/src/impl/bch/coin/embedded_daemon.hpp +++ b/src/impl/bch/coin/embedded_daemon.hpp @@ -91,7 +91,7 @@ class EmbeddedDaemon { m_embedded(m_chain, m_pool, config->m_testnet), m_node(context, config), m_abla(config->m_testnet, anchor_height, m_chain), - m_connector(m_chain, m_pool) {} + m_connector(m_chain, m_pool) { m_regtest = is_regtest; } EmbeddedDaemon(const EmbeddedDaemon&) = delete; EmbeddedDaemon& operator=(const EmbeddedDaemon&) = delete; @@ -107,12 +107,58 @@ class EmbeddedDaemon { wire_chain_ingest(); // new_headers --> HeaderChain (advances synced height) pin_cold_start_anchor(); // operator-APPROVED VM300 anchor (decisions@ 2026-06-18); floor-equivalent const bool p2p_up = maybe_start_p2p(); // arm won-block P2P relay leg + connector re-request sink (gated on configured peer) + sync_mempool_from_rpc(); // RPC-fallback: BCHN loose-mempool txs never reach embedded P2P -> fill m_pool so populated templates (nTx>1) are built LOG_INFO << "[EMB-BCH] embedded daemon up: embedded-primary work source," << " external BCHN-RPC fallback retained, ABLA loop closed" << " (cold-start anchor pinned @" << BchnAnchorRecord::height << ")," << " P2P relay leg " << (p2p_up ? "LIVE" : "OFF (no peer configured -> RPC-only)") << "."; } + /// Populate the embedded mempool from the external BCHN GBT (RPC-fallback + /// path). On regtest -- and any RPC-primary deploy -- loose transactions + /// live in BCHN's mempool and never reach the embedded P2P relay, so the + /// template builder selects from an empty m_pool and every won block is + /// coinbase-only (nTx=1). BCHN GBT already returns the CTOR-ordered, + /// fee-annotated, consensus-valid tx set -- the SAME set NodeRPC::getwork() + /// consumes for the external-RPC work path -- so we hand those to m_pool and + /// build_template selects them unchanged. No-op when no external RPC is bound + /// (a pure embedded-P2P deploy keeps its own relayed mempool). + /// KNOWN LIMITATION: one-shot at run()-startup; per-tip/periodic refresh is a + /// tracked production-standup follow-up (fenced, non-blocking for G3a). + /// p2pool-merged-v36 surface: NONE -- local mempool population only. + std::size_t sync_mempool_from_rpc() { + auto* r = m_node.rpc(); + if (!r) return 0; // pure embedded-P2P deploy -> no RPC fill + std::size_t added = 0; + try { + auto wd = r->getwork(); // GBT-derived; m_txs unpacked, witness-free + const auto& jtx = (wd.m_data.is_object() && wd.m_data.contains("transactions") + && wd.m_data["transactions"].is_array()) + ? wd.m_data["transactions"] + : nlohmann::json::array(); + for (std::size_t i = 0; i < wd.m_txs.size(); ++i) { + MutableTransaction mtx(wd.m_txs[i]); + bool ok = false; + if (i < jtx.size() && jtx[i].is_object() + && jtx[i].contains("fee") && jtx[i]["fee"].is_number()) { + // Authoritative per-tx fee from BCHN GBT -> fee_known=true so + // get_sorted_txs_with_fees() selects it into the template. + ok = m_pool.add_tx_with_known_fee( + mtx, static_cast(jtx[i]["fee"].template get())); + } else { + ok = m_pool.add_tx(mtx); // no fee field -> UTXO/feeless fallback + } + if (ok) ++added; + } + LOG_INFO << "[EMB-BCH] mempool RPC-sync: +" << added + << " txs from BCHN GBT (m_pool size=" << m_pool.size() << ")"; + } catch (const std::exception& e) { + LOG_WARNING << "[EMB-BCH] mempool RPC-sync skipped (RPC fallback): " + << e.what(); + } + return added; + } + /// READ-ONLY IBD evidence harness entry (drives the --ibd run-loop in /// main_bch.cpp). Brings up ONLY the network-free chain + the P2P front-end /// pointed at a single BCHN peer (VM300 bchn-bch .110:8333), with the @@ -369,6 +415,13 @@ class EmbeddedDaemon { /// p2pool-merged-v36 surface (ABLA is BCH embedded-internal). void pin_cold_start_anchor() { using Rec = BchnAnchorRecord; + if (m_regtest) { + // Regtest has no mainnet/testnet BCHN anchor; the @955700 record is + // meaningless here. ABLA stays at its floor anchor; the chain syncs + // from the regtest genesis. Skipping the pin keeps regtest correct. + LOG_INFO << "[EMB-BCH] cold-start anchor SKIPPED (regtest: floor ABLA, genesis-rooted chain)."; + return; + } apply_bchn_anchor(Rec::height, Rec::state(m_config->m_testnet)); const uint64_t pinned = m_abla.tracker().budget_for_tip(Rec::height); if (Rec::is_floor()) @@ -496,6 +549,7 @@ class EmbeddedDaemon { } private: + bool m_regtest = false; // regtest 3rd-net: skip mainnet/testnet anchor + drive RPC mempool sync config_t* m_config; // not owned (binary entrypoint owns it) HeaderChain m_chain; // before m_embedded + m_abla: their refs bind here Mempool m_pool; // before m_embedded diff --git a/src/impl/bch/coin/mempool.hpp b/src/impl/bch/coin/mempool.hpp index 365cabe8b..68e4a7b46 100644 --- a/src/impl/bch/coin/mempool.hpp +++ b/src/impl/bch/coin/mempool.hpp @@ -172,6 +172,42 @@ class Mempool { return true; } + /// Add a transaction with a KNOWN fee supplied by an authoritative source + /// (BCHN GBT transactions[].fee). Used by the embedded-daemon RPC mempool + /// sync: BCHN already validated the tx and computed its exact fee, so we set + /// fee_known=true and index it for template selection. Without this the tx + /// lands with fee_known=false and get_sorted_txs_with_fees() excludes it + /// (empty template -> coinbase-only won blocks, nTx=1). Fee accuracy matches + /// p2pool, which sources fees from the same daemon GBT. + bool add_tx_with_known_fee(const MutableTransaction& tx, uint64_t fee) { + uint256 txid = compute_txid(tx); + std::lock_guard lock(m_mutex); + if (m_pool.count(txid)) + return false; + MempoolEntry entry; + entry.tx = tx; + entry.txid = txid; + entry.byte_size = compute_tx_byte_size(tx); + entry.time_added = std::time(nullptr); + entry.fee = fee; + entry.fee_known = true; + int evicted = 0; + while (m_total_bytes + entry.byte_size > m_max_bytes && !m_time_index.empty()) { + auto oldest = m_time_index.begin(); + evict_one_locked(oldest->second); + ++evicted; + } + m_pool[txid] = std::move(entry); + auto& stored = m_pool[txid]; + m_time_index.emplace(stored.time_added, txid); + m_total_bytes += stored.byte_size; + for (const auto& vin : stored.tx.vin) { + m_spent_outputs[std::make_pair(vin.prevout.hash, vin.prevout.index)] = txid; + } + m_feerate_index.emplace(stored.feerate(), txid); + return true; + } + /// Remove a single transaction by txid. void remove_tx(const uint256& txid) { std::lock_guard lock(m_mutex);