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(' #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, 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); 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 4ce597f81..0a9c6f7a1 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,30 +52,45 @@ #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, + 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). @@ -83,7 +111,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(); } diff --git a/src/impl/bch/stratum/coinbase_outputs.hpp b/src/impl/bch/stratum/coinbase_outputs.hpp new file mode 100644 index 000000000..87d15d770 --- /dev/null +++ b/src/impl/bch/stratum/coinbase_outputs.hpp @@ -0,0 +1,97 @@ +#pragma once +// bch::stratum::assemble_v36_coinbase_outputs +// +// PURE, oracle-conforming v36 coinbase OUTPUT-ASSEMBLY, extracted verbatim from +// BCHWorkSource::build_connection_coinbase so the byte-shape can be pinned by a +// KAT against the p2pool-merged-v36 oracle (data.py generate_transaction, +// v36_active branch ~920-1085) WITHOUT standing up the whole work source. +// +// Contract (matches the oracle exactly): +// 1. The donation/marker entry (keyed by donation_script) is separated out of +// the PPLNS payout set and FORCED LAST (immediately before the OP_RETURN), +// EXCLUDED from the (amount asc, script asc) sort, regardless of its amount. +// 2. V36 consensus marker rule: if the donation amount rounds to 0 while the +// subsidy is > 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 c403eb2f6..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) @@ -366,17 +368,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()) @@ -388,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; +}