diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 297d7891a..8cbdc584f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -217,6 +217,159 @@ jobs: uses: actions/upload-artifact@v7 with: { name: test-results-linux-asan, path: build_asan/Testing/, retention-days: 7 } + # ════════════════════════════════════════════════════════════════════════════ + # COIN_BCH leg — Bitcoin Cash (SHA256d standalone parent, V36) + # + # Dedicated per-coin leg (integrator 2026-06-19 decision). build.yml previously + # configured ONLY -DCOIN_DGB=ON, so the 15 bch_ M5 targets never compiled and a + # gh rollup reported GREEN while exercising ZERO BCH — a false-green merge gate + # (the DGB #137 NOT_BUILT shape, but 15 targets, not one). Coin flags are + # additive independent if() guards (no mutual-exclusion), so -DCOIN_BCH=ON + # composes with COIN_DGB in one configure — but BCH ships its own c2pool-bch + # binary + its own 15 test targets, so a separate configure/leg is the clean + # per-coin signal: a BCH break reads as "COIN_BCH leg red," not buried in DGB. + # Mirrors the DGB linux/linux-asan coverage. Does NOT relax the per-coin + # source-presence guards (PR #47). ctest scoped to ^bch_ (network-free; the + # live VM300 IBD path lives in the c2pool-bch --ibd harness, not in ctest). + # ════════════════════════════════════════════════════════════════════════════ + coin-bch: + name: COIN_BCH Linux x86_64 + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + + - name: Install system dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends \ + g++ cmake make libleveldb-dev libsecp256k1-dev + + - uses: actions/setup-python@v6 + with: { python-version: 3.12 } + + - name: Install Conan 2 + run: pip install "conan>=2.0,<3.0" + + - name: Detect Conan profile + run: | + conan profile detect --force + sed -i 's/compiler.cppstd=.*/compiler.cppstd=20/' "$(conan profile path default)" + + - name: Restore Conan cache + uses: actions/cache@v5 + with: + path: ~/.conan2 + key: conan2-ubuntu24-gcc13-bch-${{ hashFiles('conanfile.txt') }} + + - name: Conan install + run: conan install . --build=missing --output-folder=build_bch --settings=build_type=Release + + - name: Configure (-DCOIN_BCH=ON) + run: cmake -S . -B build_bch -DCMAKE_TOOLCHAIN_FILE=build_bch/conan_toolchain.cmake -DCMAKE_BUILD_TYPE=Release -DCOIN_BCH=ON + + - name: Build c2pool-bch binary + run: cmake --build build_bch --target c2pool-bch -j$(nproc) + + - name: Build BCH test targets + run: | + cmake --build build_bch \ + --target bch_abla_floor_invariant_test bch_abla_block_feed_test \ + bch_abla_block_feed_gap_test bch_header_sync_test \ + bch_header_sync_progress_test bch_block_download_test \ + bch_genesis_conformance_test bch_abla_growth_soak_test \ + bch_coinbase_kat_segwit_predicate_test \ + bch_block_connector_test bch_block_ordering_test \ + bch_utxo_connect_test bch_reorg_connect_test \ + bch_compact_block_connector_test \ + bch_embedded_daemon_assembly_test \ + bch_embedded_getwork_test bch_embedded_seam_workview_test \ + bch_embedded_block_broadcast_test bch_coin_node_seam_test \ + -j$(nproc) + + - name: Run BCH tests + working-directory: build_bch + run: ctest --output-on-failure -j$(nproc) -R "^bch_" + + - name: Upload test results on failure + if: failure() + uses: actions/upload-artifact@v7 + with: { name: test-results-coin-bch, path: build_bch/Testing/, retention-days: 7 } + + # ════════════════════════════════════════════════════════════════════════════ + # COIN_BCH AddressSanitizer + UBSan (informational, mirrors linux-asan posture) + # ════════════════════════════════════════════════════════════════════════════ + coin-bch-asan: + name: COIN_BCH Linux x86_64 (AsAN+UBSan) + runs-on: ubuntu-24.04 + continue-on-error: true + steps: + - uses: actions/checkout@v6 + + - name: Install system dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends \ + g++ cmake make libleveldb-dev libsecp256k1-dev + + - uses: actions/setup-python@v6 + with: { python-version: 3.12 } + + - name: Install Conan 2 + run: pip install "conan>=2.0,<3.0" + + - name: Detect Conan profile + run: | + conan profile detect --force + sed -i 's/compiler.cppstd=.*/compiler.cppstd=20/' "$(conan profile path default)" + + - name: Restore Conan cache + uses: actions/cache@v5 + with: + path: ~/.conan2 + key: conan2-ubuntu24-gcc13-bch-${{ hashFiles('conanfile.txt') }} + + - name: Conan install + run: conan install . --build=missing --output-folder=build_bch_asan --settings=build_type=Release + + - name: Configure (Release + AsAN + UBSan, -DCOIN_BCH=ON) + run: | + cmake -S . -B build_bch_asan \ + -DCMAKE_TOOLCHAIN_FILE=build_bch_asan/conan_toolchain.cmake \ + -DCMAKE_BUILD_TYPE=Release \ + -DCOIN_BCH=ON \ + -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-sanitize=vptr -fno-omit-frame-pointer -fno-sanitize-recover=undefined -g" \ + -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address,undefined -fno-sanitize=vptr" \ + -DCMAKE_SHARED_LINKER_FLAGS="-fsanitize=address,undefined -fno-sanitize=vptr" + + - name: Build c2pool-bch + BCH tests + run: | + cmake --build build_bch_asan \ + --target c2pool-bch \ + bch_abla_floor_invariant_test bch_abla_block_feed_test \ + bch_abla_block_feed_gap_test bch_header_sync_test \ + bch_header_sync_progress_test bch_block_download_test \ + bch_genesis_conformance_test bch_abla_growth_soak_test \ + bch_coinbase_kat_segwit_predicate_test \ + bch_block_connector_test bch_block_ordering_test \ + bch_utxo_connect_test bch_reorg_connect_test \ + bch_compact_block_connector_test \ + bch_embedded_daemon_assembly_test \ + bch_embedded_getwork_test bch_embedded_seam_workview_test \ + bch_embedded_block_broadcast_test bch_coin_node_seam_test \ + -j$(nproc) + + - name: Run BCH tests under sanitizers + working-directory: build_bch_asan + env: + ASAN_OPTIONS: detect_leaks=0:check_initialization_order=1:strict_string_checks=1:abort_on_error=0 + UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=0 + run: ctest --output-on-failure -j$(nproc) -R "^bch_" + + - name: Upload sanitizer test results on failure + if: failure() + uses: actions/upload-artifact@v7 + with: { name: test-results-coin-bch-asan, path: build_bch_asan/Testing/, retention-days: 7 } + # ════════════════════════════════════════════════════════════════════════════ # Web-static JS verify (typecheck + build + tests + bundle-size gate) # Covers web-static/sharechain-explorer/: the Explorer + PPLNS View diff --git a/.gitignore b/.gitignore index 459317b4f..1da4fce51 100644 --- a/.gitignore +++ b/.gitignore @@ -215,3 +215,4 @@ external/**/depends/**/docs/ # Release artifact staging area (built binaries, not source) release-staging/ +/.c2pool-bch-regtest/ diff --git a/scripts/gen-bch-daemon-creds.sh b/scripts/gen-bch-daemon-creds.sh new file mode 100755 index 000000000..574e773f8 --- /dev/null +++ b/scripts/gen-bch-daemon-creds.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# gen-bch-daemon-creds.sh -- self-provision BCH daemon RPC creds for the +# co-located regtest leg-C broadcaster harness. +# +# Standing rule (operator 2026-06-19): the fleet generates its own daemon RPC +# creds -- RPCUSER fixed, RPCPASS = openssl rand -- writes a localhost-only conf, +# and wires both the daemon conf and the c2pool config. The password is NEVER +# placed in any coordination card; this is [fyi], not [decision-needed]. +# +# PER-COIN ISOLATION: BCH only. Tooling for the leg-C regtest host -- zero +# p2pool-merged-v36 surface (no share / PPLNS / coinbase bytes). Idempotent: +# refuses to clobber an existing conf unless --force is passed. +set -euo pipefail + +RPCUSER="c2pool_bch" +DATADIR="${BCH_REGTEST_DATADIR:-$HOME/.c2pool-bch-regtest}" +DAEMON_CONF="$DATADIR/bitcoin.conf" +C2POOL_CONF="${C2POOL_BCH_CONF:-$DATADIR/c2pool-bch.conf}" +FORCE=0 +[ "${1:-}" = "--force" ] && FORCE=1 + +mkdir -p "$DATADIR" +chmod 700 "$DATADIR" + +if [ -f "$DAEMON_CONF" ] && [ "$FORCE" -ne 1 ]; then + echo "conf exists: $DAEMON_CONF (pass --force to regenerate); leaving creds intact" >&2 + exit 0 +fi + +RPCPASS="$(openssl rand -hex 32)" + +umask 077 +cat > "$DAEMON_CONF" < "$C2POOL_CONF" <&2 +echo "RPCUSER=$RPCUSER RPCPASS=<32-byte openssl rand, in conf only, NOT echoed>" >&2 diff --git a/src/c2pool/CMakeLists.txt b/src/c2pool/CMakeLists.txt index 12db8412c..eb0482075 100644 --- a/src/c2pool/CMakeLists.txt +++ b/src/c2pool/CMakeLists.txt @@ -194,6 +194,54 @@ target_link_libraries(c2pool-dgb nlohmann_json::nlohmann_json ${Boost_LIBRARIES} ) +# c2pool-bch: Bitcoin Cash (SHA256d standalone parent, V36) — EXE-WIRE slice. +# Closes the "no runnable c2pool-bch entrypoint" gap (integrator 2026-06-18): +# the M5 lane was pure state-machine + ctest with no main_bch.cpp. Mirrors the +# c2pool-dgb add_executable shape but is even leaner: main_bch.cpp drives the +# LIVE header-only ABLA template-budget path (impl/bch/coin/abla.hpp, 1:1 BCHN +# consensus/abla.cpp port). abla.hpp is std-only (no core/yaml/uint256/leveldb), +# so this target links NOTHING beyond the standard runtime — it never drags in +# the core OBJECT-lib web_server/stratum tangle that forces c2pool-btc to pull +# ltc/payout/merged. The global src/ include path resolves . The +# P2P IBD run-loop (coin/p2p_node.cpp read-only sync vs VM300 .110:8333) and its +# live numbers are the NEXT --ibd slice; kept separate so this lands additive. +# Per-coin isolation held: src/impl/bch only, no shared-base/core edits. +add_executable(c2pool-bch + main_bch.cpp + # BCH coin impl TUs (M3 deferred impl_bch reg, now forced by --ibd harness + # needing to LINK bch:: symbols). Compiled directly into the target, exactly + # as the bch tests add coin/transaction.cpp; per-coin isolation holds. + ${CMAKE_SOURCE_DIR}/src/impl/bch/config_coin.cpp + ${CMAKE_SOURCE_DIR}/src/impl/bch/config_pool.cpp + ${CMAKE_SOURCE_DIR}/src/impl/bch/coin/transaction.cpp + ${CMAKE_SOURCE_DIR}/src/impl/bch/coin/p2p_connection.cpp + ${CMAKE_SOURCE_DIR}/src/impl/bch/coin/p2p_node.cpp + ${CMAKE_SOURCE_DIR}/src/impl/bch/coin/coin_node.cpp + ${CMAKE_SOURCE_DIR}/src/impl/bch/coin/rpc.cpp +) +target_compile_definitions(c2pool-bch PRIVATE C2POOL_VERSION="${C2POOL_GIT_VERSION}") +# --ibd RUN-LOOP slice (integrator 2026-06-18): the read-only headers-first IBD +# harness stands up EmbeddedDaemon over its P2P front-end (NodeP2P/HeaderChain/ +# Timer), which pulls the core OBJECT-lib + Boost.Asio. Adding ONLY these link +# deps (core + btclibs + yaml-cpp + json + Boost), matching the c2pool-dgb tier, +# is SAFE-ADDITIVE on this target (integrator explicitly cleared it): no shared- +# base/core SOURCE edit, src/impl/bch headers only, per-coin isolation held. The +# --selftest path remains the std-only ABLA budget driver. core is an OBJECT lib +# so it links all its objects (incl. web_server/stratum) — those symbols already +# resolve in the full c2pool target; we depend on the same set here. +target_link_libraries(c2pool-bch + # OBJECT-lib SCC direct-naming (#22/#39): core is monolithic; its stratum_server/ + # web_server objects reference the whole pool/payout/merged/hashrate/storage/ + # difficulty SCC, so every core consumer names the full set. Identical to the + # bch test link set; merged is the shared core SCC (BCH never invokes it), NOT + # BCH-specific merged-mining logic -> per-coin isolation holds. + core pool sharechain + c2pool_payout c2pool_merged_mining c2pool_hashrate c2pool_storage c2pool_difficulty + btclibs + yaml-cpp::yaml-cpp + nlohmann_json::nlohmann_json + ${Boost_LIBRARIES} +) # Windows: Boost.Asio requires Winsock libraries; /bigobj for large translation units if(WIN32) diff --git a/src/c2pool/main_bch.cpp b/src/c2pool/main_bch.cpp new file mode 100644 index 000000000..7533693c3 --- /dev/null +++ b/src/c2pool/main_bch.cpp @@ -0,0 +1,522 @@ +// c2pool-bch — Bitcoin Cash (BCH, SHA256d standalone parent, V36) p2pool node +// entry point. +// +// EXE-WIRE slice (integrator 2026-06-18) closed the "no runnable c2pool-bch +// entrypoint" gap. This slice adds the --ibd RUN-LOOP: a read-only headers-first +// initial-block-download harness that stands up the embedded daemon over its +// P2P front-end against VM300 bchn-bch (192.168.86.110:8333) and reports the +// live sync evidence the M5 size loop rests on: +// - synced height advancing PAST the init() checkpoint (chain-ingest works) +// - false_evict_count (0 == clean sync) +// - in_flight / reissue_count (download-window health) +// +// TWO MODES: +// --selftest (default) : drive the LIVE ABLA template-budget path +// (coin/abla.hpp, 1:1 BCHN consensus/abla.cpp port) std-only, proving the +// GROWTH and FLOOR invariants. Network-free, no core runtime. +// --ibd [opts] : stand up EmbeddedDaemon::start_ibd over the P2P +// front-end and run a bounded io_context loop, logging the evidence tuple +// each tick until caught up or the deadline. Read-only: a P2P peer +// connection issues no qm/control op, so VM300 stays read-only. This is +// the evidence harness, NOT the production daemon — start_ibd skips +// init_rpc(); run() still owns the external BCHN-RPC fallback. +// +// PER-COIN ISOLATION: src/impl/bch headers only; the --ibd path links the core +// OBJECT-lib (NodeP2P/HeaderChain/Timer) — a SAFE-ADDITIVE link-line addition +// on this target only (integrator 2026-06-18), no shared-base/core source edit. +// p2pool-merged-v36 surface: NONE — ABLA + SPV header state carry no share/ +// coinbase/PPLNS/PoW bytes. Conformance oracle: frstrtr/p2poolBCH (kr1z1sBCH); +// BCH = SHA256d standalone parent (NOT merged-mined). + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +#include +#include + +#include + +#include +#include +#include +#include +#include + +#ifndef C2POOL_VERSION +#define C2POOL_VERSION "dev" +#endif + +namespace { + +using bch::coin::abla::Config; +using bch::coin::abla::State; +using bch::coin::abla::DEFAULT_CONSENSUS_BLOCK_SIZE; +using bch::coin::abla::ONE_MEGABYTE; +using bch::coin::EmbeddedDaemon; + +void print_banner(const char* argv0) +{ + std::cout + << "c2pool-bch " << C2POOL_VERSION << " — Bitcoin Cash (SHA256d, V36)\n\n" + << "Usage: " << argv0 << " [--version] [--help] [--selftest]\n" + << " " << argv0 << " --ibd [--testnet] [--near-tip] [--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" + << "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" + << "Consensus: ABLA floor budget = " + << (DEFAULT_CONSENSUS_BLOCK_SIZE / ONE_MEGABYTE) << " MB; ASERT DAA; CTOR;\n" + << " CashTokens transparent; standalone SHA256d parent.\n"; +} + +// Drive the LIVE ABLA template-budget path (coin/abla.hpp, 1:1 BCHN port). +// (1) GROWTH — sustained full blocks raise the limit above the floor. +// (2) FLOOR — empty/small blocks never undercut the 32 MB activation floor. +int run_selftest() +{ + const Config config = Config::MakeDefault(); // 32 MB floor default + const uint64_t floor_limit = State(config, 0).GetBlockSizeLimit(); + + State grow(config, DEFAULT_CONSENSUS_BLOCK_SIZE); + for (int i = 0; i < 5000; ++i) + grow = grow.NextBlockState(config, grow.GetBlockSizeLimit()); + const uint64_t grown_limit = grow.GetBlockSizeLimit(); + + State hold(config, DEFAULT_CONSENSUS_BLOCK_SIZE); + for (int i = 0; i < 5000; ++i) + hold = hold.NextBlockState(config, 0); + const uint64_t held_limit = hold.GetBlockSizeLimit(); + + std::cout + << "[selftest] live bch::coin::abla::State replayed (BCHN consensus/abla port)\n" + << "[selftest] floor limit = " << floor_limit + << " (" << (floor_limit / ONE_MEGABYTE) << " MB)\n" + << "[selftest] grown limit = " << grown_limit + << " (" << (grown_limit / ONE_MEGABYTE) << " MB) after 5000 full blocks\n" + << "[selftest] held limit = " << held_limit + << " (" << (held_limit / ONE_MEGABYTE) << " MB) after 5000 empty blocks\n"; + + const bool grew = grown_limit > floor_limit; + const bool held = held_limit >= floor_limit; + if (!grew || !held) { + std::cout << "[selftest] FAIL — ABLA invariant violated" + << " (grew=" << grew << " held=" << held << ")\n"; + return 1; + } + std::cout << "[selftest] GROWTH ok (grown > floor); FLOOR ok (held >= floor)\n" + << "[selftest] OK\n"; + return 0; +} + +// Read-only headers-first IBD harness. Stands up the embedded daemon over the +// P2P front-end pointed at one BCHN peer, kicks the first getheaders once the +// handshake is up, and logs the evidence tuple every TICK seconds until the +// chain catches the peer tip (with no in-flight blocks) or the deadline fires. +int run_ibd(const std::string& host, uint16_t port, bool testnet, uint32_t max_seconds, + bool near_tip) +{ + boost::asio::io_context ctx; + + // Construct config WITHOUT a file load: Config(coin_name) only stores paths + // (Fileconfig ctor reads nothing until init()), so we skip init() and set + // by hand only the two fields the IBD harness touches — the testnet flag and + // the single BCHN P2P peer. No pool.yaml/coin.yaml is read or written. + bch::Config cfg("bch-ibd"); + cfg.m_testnet = testnet; + const NetService peer(host, port); + cfg.coin()->m_p2p.address = peer; + // BCH P2P network magic (pchMessageStart). The harness builds config WITHOUT + // a YAML load, so m_p2p.prefix is empty by default -> core::Socket frames the + // version message with a zero-length magic and BCHN drops the peer with EOF + // right after connect (handshake never completes). Set it by hand, the only + // other field the IBD harness touches beyond address. Values per BCHN + // chainparams.cpp: mainnet e3e1f3e8, testnet3 f4e5f3f4. + cfg.coin()->m_p2p.prefix = 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} }; + + EmbeddedDaemon daemon(&ctx, &cfg, /*anchor_height=*/0); + // --near-tip seeds the header origin at the operator-approved BCHN anchor + // (height 955700) so the sync covers only anchor -> tip; this is the ONLY + // mode that actually advances the ABLA cursor in a harness window (the + // genesis-origin cold-start stays ~900k blocks below the anchor -> cursor + // pinned at the floor by construction; see UID 1375). Default --ibd is the + // genesis-origin liveness/eviction-evidence loop. + if (near_tip) + daemon.start_ibd_near_tip(peer); + else + daemon.start_ibd(peer); + + const uint32_t init_height = daemon.ibd_synced_height(); + std::cout << "[ibd] read-only sync vs " << host << ":" << port + << (testnet ? " (testnet)" : " (mainnet)") + << (near_tip ? " [near-tip: anchor-seeded ABLA-feed]" : " [cold-start]") + << " — init checkpoint=" << init_height + << ", deadline=" << max_seconds << "s\n"; + + bool kicked = false; + uint32_t elapsed = 0; + const uint32_t TICK = 5; + + core::Timer tick(&ctx, /*repeat=*/true); + tick.start(TICK, [&]() { + elapsed += TICK; + if (!kicked && daemon.ibd_handshake_ready()) { + daemon.ibd_kick_sync(); + kicked = true; + std::cout << "[ibd] handshake up; getheaders kicked from height " + << daemon.ibd_synced_height() << "\n"; + } + const uint32_t h = daemon.ibd_synced_height(); + const uint32_t tip = daemon.ibd_peer_tip(); + std::cout << "[ibd] t=" << elapsed << "s synced=" << h + << " peer_tip=" << tip + << " in_flight=" << daemon.ibd_in_flight() + << " reissue=" << daemon.ibd_reissue_count() + << " false_evict=" << daemon.ibd_false_evict_count() + << " abla_cursor=" << daemon.ibd_abla_cursor() + << " abla_budget=" << daemon.ibd_abla_budget() << "\n"; + + const bool advanced = h > init_height; + const bool caught_up = kicked && tip > 0 && h >= tip && daemon.ibd_in_flight() == 0; + if (caught_up || elapsed >= max_seconds) { + std::cout << "[ibd] " << (caught_up ? "SYNCED" : "DEADLINE") + << " — final synced=" << h + << " (init checkpoint=" << init_height + << ", advanced=" << (advanced ? "yes" : "NO") << ")" + << " false_evict=" << daemon.ibd_false_evict_count() + << " reissue=" << daemon.ibd_reissue_count() + << " abla_cursor=" << daemon.ibd_abla_cursor() + << " abla_budget=" << daemon.ibd_abla_budget() << "\n"; + tick.stop(); + ctx.stop(); + } + }); + + ctx.run(); + return 0; +} + +// --------------------------------------------------------------------------- +// leg-C: dual-path broadcaster capture (RPC leg). +// +// The broadcaster-gate dual-path close needs ONE regtest capture proving a +// c2pool-BUILT, consensus-valid block is ACCEPTED by the node: submitblock= +// accept + a verbatim BCHN "UpdateTip: new best=... height=N" connect-block +// line. This mode drives the RPC leg of EmbeddedDaemon::broadcast_won_block -- +// NodeRPC::submit_block_hex, the exact submitblock sink -- against the co- +// located self-provisioned regtest node (leg-C host, integrator 2026-06-18). +// +// Isolated regtest has zero peers, so getblocktemplate is node-gated +// ("Bitcoin is not connected!"); submitblock carries no such guard, so the +// block params are sourced directly (regtest::build_and_solve) off the live +// tip via getblockchaininfo. The P2P-relay leg (submit_block_p2p_raw over the +// embedded front-end) is the immediate follow-on sub-slice and reuses THIS +// built blocks raw bytes. PER-COIN ISOLATION: src/impl/bch only; zero +// p2pool-merged-v36 surface (parent BCH block dispatch, no share/PPLNS bytes). +struct RegtestRpcConf { std::string user; std::string pass; uint16_t port = 18443; }; + +inline std::string trim_conf(std::string s) +{ + const char* ws = " \t\r\n"; + const auto b = s.find_first_not_of(ws); + if (b == std::string::npos) return {}; + const auto e = s.find_last_not_of(ws); + return s.substr(b, e - b + 1); +} + +// Parse rpcuser/rpcpassword/rpcport from a bitcoin.conf-style file (also accepts +// the c2pool bch_rpc_user/bch_rpc_password keys). The password stays in-file and +// is NEVER echoed (operator self-provision rule 2026-06-19). +bool load_rpc_conf(const std::string& path, RegtestRpcConf& out) +{ + std::ifstream f(path); + if (!f) return false; + std::string line; + while (std::getline(f, line)) { + const auto h = line.find(char(35)); + if (h != std::string::npos) line = line.substr(0, h); + const auto eq = line.find(char(61)); + if (eq == std::string::npos) continue; + const std::string key = trim_conf(line.substr(0, eq)); + const std::string val = trim_conf(line.substr(eq + 1)); + if (val.empty()) continue; + if (key == "rpcuser" || key == "bch_rpc_user") out.user = val; + else if (key == "rpcpassword" || key == "bch_rpc_password") out.pass = val; + else if (key == "rpcport") out.port = static_cast(std::stoi(val)); + } + return !out.user.empty() && !out.pass.empty(); +} + +int run_leg_c_capture(const std::string& conf_path) +{ + RegtestRpcConf rc; + if (!load_rpc_conf(conf_path, rc)) { + std::cout << "[leg-c] FAIL -- no rpcuser/rpcpassword in " << conf_path + << " (run scripts/gen-bch-daemon-creds.sh first)\n"; + return 1; + } + + boost::asio::io_context ctx; + bch::coin::NodeRPC rpc(&ctx, /*coin=*/nullptr, /*testnet=*/false); + const NetService addr(std::string("127.0.0.1"), rc.port); + // connect() posts async resolve/connect; the first synchronous Send() below + // self-heals via NodeRPC::sync_reconnect() (blocking connect) on the not-yet- + // connected stream -- the same path coin::Node::init_rpc() relies on. + rpc.connect(addr, rc.user + ":" + rc.pass); + + nlohmann::json info; + try { + info = rpc.getblockchaininfo(); + } catch (const std::exception& e) { + std::cout << "[leg-c] FAIL -- getblockchaininfo: " << e.what() + << " (regtest daemon up on 127.0.0.1:" << rc.port << "?)\n"; + return 1; + } + const uint256 prev = uint256S(info.at("bestblockhash").get()); + const uint32_t height = static_cast(info.at("blocks").get()) + 1; + + // Regtest consensus params (BCHN chainparams.cpp CRegTestParams): powLimit + // nBits 0x207fffff (trivial target -> short nonce sweep), BIP9 version bit, + // 50-coin subsidy for heights 1..149 (regtest halving interval 150). + const uint32_t REGTEST_BITS = 0x207fffffu; + const int32_t VERSION = 0x20000000; + const int64_t SUBSIDY = 5000000000LL; + const uint32_t curtime = core::timestamp(); + + auto built = bch::coin::regtest::build_and_solve( + prev, REGTEST_BITS, VERSION, curtime, height, SUBSIDY); + if (!built.solved) { + std::cout << "[leg-c] FAIL -- regtest block did not solve (nBits misconfigured)\n"; + return 1; + } + std::cout << "[leg-c] built consensus-valid block: height=" << height + << " hash=" << built.hash.GetHex() + << " bytes=" << built.bytes.size() << "\n"; + + // RPC leg of the dual-path broadcaster: NodeRPC::submit_block_hex is the exact + // submitblock sink EmbeddedDaemon::broadcast_won_block fires. ignore_failure + // false so a reject surfaces. + const bool ok = rpc.submit_block_hex(built.hex, /*ignore_failure=*/false); + std::cout << "[leg-c] submitblock RPC leg: " << (ok ? "ACCEPTED" : "REJECTED") << "\n" + << "[leg-c] expect in regtest debug.log -> " + << "UpdateTip: new best=" << built.hash.GetHex() + << " height=" << height << "\n"; + return ok ? 0 : 1; +} + + +// --------------------------------------------------------------------------- +// leg-C: dual-path broadcaster capture (P2P-RELAY leg). +// +// Closes the SECOND half of the broadcaster gate (item 1). The RPC leg +// (run_leg_c_capture, @81ca0ca5) proved NodeRPC::submit_block_hex reaches and +// connects. This leg proves the PRIMARY embedded path -- Node::submit_block_ +// p2p_raw, the sink EmbeddedDaemon::broadcast_won_block fires first -- relays a +// won block over the live P2P front-end and the node connects it (UpdateTip). +// +// Integrator verification catch (2026-06-19): do NOT reuse the RPC-leg block +// bytes -- that block is already the node tip, so a re-relay hits already-have- +// block and yields a FALSE-POSITIVE relay capture. This mode builds a FRESH +// block on the CURRENT tip (height N+1) and relays ONLY via P2P (no submitblock +// fired), so the resulting UpdateTip can ONLY have been caused by the embedded +// relay path. PER-COIN ISOLATION: src/impl/bch + main_bch.cpp only; P2P-only, +// zero p2pool-merged-v36 surface (parent BCH block dispatch, no share bytes). +// +// Regtest P2P magic = dab5bffa (BCHN chainparams.cpp CRegTestParams); default +// P2P port 18444 (RPC 18443). The node negotiates compact blocks; a solo- +// coinbase block prefills the only tx, so no getblocktxn round-trip is needed. +int run_leg_c_capture_p2p(const std::string& conf_path, uint16_t p2p_port) +{ + RegtestRpcConf rc; + if (!load_rpc_conf(conf_path, rc)) { + std::cout << "[leg-c-p2p] FAIL -- no rpcuser/rpcpassword in " << conf_path + << " (run scripts/gen-bch-daemon-creds.sh first)\n"; + return 1; + } + + boost::asio::io_context ctx; + + // RPC client: reads the tip to build on + confirms the relayed block + // connected (bestblockhash advances to the fresh hash). Submission itself is + // P2P-only -- this RPC is read-back, NOT a submitblock sink. + bch::coin::NodeRPC rpc(&ctx, /*coin=*/nullptr, /*testnet=*/false); + rpc.connect(NetService(std::string("127.0.0.1"), rc.port), rc.user + ":" + rc.pass); + + // P2P front-end config (built by hand, no YAML load -- mirrors run_ibd). + // Regtest magic dab5bffa, else core::Socket frames the version with empty + // magic and the node drops the peer on EOF (handshake never completes). + bch::Config cfg("bch-leg-c-p2p"); + cfg.m_testnet = false; + const NetService p2p_addr(std::string("127.0.0.1"), p2p_port); + cfg.coin()->m_p2p.address = p2p_addr; + cfg.coin()->m_p2p.prefix = std::vector{ + std::byte{0xda}, std::byte{0xb5}, std::byte{0xbf}, std::byte{0xfa} }; + + bch::coin::Node node(&ctx, &cfg); + node.start_p2p(p2p_addr); + std::cout << "[leg-c-p2p] P2P relay connecting to 127.0.0.1:" << p2p_port + << " (regtest magic dab5bffa)\n"; + + bool relayed = false; + bool confirmed = false; + uint256 want_hash; + uint32_t want_height = 0; + uint32_t elapsed = 0; + const uint32_t TICK = 2; + const uint32_t DEADLINE = 60; + + core::Timer tick(&ctx, /*repeat=*/true); + tick.start(TICK, [&]() { + elapsed += TICK; + + // Stage 1: await version/verack, then build + relay ONE fresh block. + if (!relayed) { + if (!node.is_handshake_complete()) { + if (elapsed >= DEADLINE) { + std::cout << "[leg-c-p2p] FAIL -- P2P handshake never completed " + << "(regtest daemon listening on 127.0.0.1:" << p2p_port << "?)\n"; + tick.stop(); ctx.stop(); + } + return; + } + nlohmann::json info; + try { info = rpc.getblockchaininfo(); } + catch (const std::exception& e) { + std::cout << "[leg-c-p2p] FAIL -- getblockchaininfo: " << e.what() << "\n"; + tick.stop(); ctx.stop(); return; + } + const uint256 prev = uint256S(info.at("bestblockhash").get()); + want_height = static_cast(info.at("blocks").get()) + 1; + + const uint32_t REGTEST_BITS = 0x207fffffu; + const int32_t VERSION = 0x20000000; + const int64_t SUBSIDY = 5000000000LL; + auto built = bch::coin::regtest::build_and_solve( + prev, REGTEST_BITS, VERSION, core::timestamp(), want_height, SUBSIDY); + if (!built.solved) { + std::cout << "[leg-c-p2p] FAIL -- fresh block did not solve\n"; + tick.stop(); ctx.stop(); return; + } + want_hash = built.hash; + std::cout << "[leg-c-p2p] built FRESH block: height=" << want_height + << " hash=" << built.hash.GetHex() + << " prev=" << prev.GetHex() + << " bytes=" << built.bytes.size() << "\n"; + + // PRIMARY sink: P2P relay ONLY. No submitblock fired, so any + // UpdateTip at this hash is attributable solely to the embedded path. + node.submit_block_p2p_raw(built.bytes); + relayed = true; + std::cout << "[leg-c-p2p] relayed via submit_block_p2p_raw (P2P-only); " + << "polling node for UpdateTip to " << built.hash.GetHex() << "\n"; + return; + } + + // Stage 2: confirm the node connected the relayed block (tip advanced). + nlohmann::json info; + try { info = rpc.getblockchaininfo(); } + catch (const std::exception&) { return; } + const uint256 tip = uint256S(info.at("bestblockhash").get()); + if (tip == want_hash) { + confirmed = true; + std::cout << "[leg-c-p2p] P2P relay leg: UpdateTip CONFIRMED -- node best=" + << tip.GetHex() << " height=" << want_height << "\n" + << "[leg-c-p2p] expect in regtest debug.log -> " + << "UpdateTip: new best=" << want_hash.GetHex() + << " height=" << want_height << "\n"; + tick.stop(); ctx.stop(); + return; + } + if (elapsed >= DEADLINE) { + std::cout << "[leg-c-p2p] FAIL -- relayed but node tip did not advance to " + << want_hash.GetHex() << " within " << DEADLINE << "s (still " + << tip.GetHex() << ")\n"; + tick.stop(); ctx.stop(); + } + }); + + ctx.run(); + return confirmed ? 0 : 1; +} + +} // namespace + +int main(int argc, char** argv) +{ + bool want_help = false; + bool want_ibd = false; + bool want_leg_c = false; + bool want_leg_c_p2p = false; + uint16_t leg_c_p2p_port = 18444; // BCHN regtest P2P default + std::string rpc_conf; + bool testnet = false; + bool near_tip = false; + std::string host = "192.168.86.110"; // VM300 bchn-bch + uint16_t port = 8333; + uint32_t max_seconds = 600; + + for (int i = 1; i < argc; ++i) { + if (std::strcmp(argv[i], "--version") == 0) { + std::cout << "c2pool-bch " << C2POOL_VERSION << "\n"; + return 0; + } + 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], "--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) + leg_c_p2p_port = static_cast(std::stoul(argv[++i])); + if (std::strcmp(argv[i], "--rpc-conf") == 0 && i + 1 < argc) rpc_conf = argv[++i]; + if (std::strcmp(argv[i], "--testnet") == 0) { testnet = true; port = 18333; } + if (std::strcmp(argv[i], "--near-tip") == 0) near_tip = true; + if (std::strcmp(argv[i], "--peer") == 0 && i + 1 < argc) { + std::string hp = argv[++i]; + const auto c = hp.rfind(char(58)); // ASCII colon + if (c != std::string::npos) { + host = hp.substr(0, c); + port = static_cast(std::stoi(hp.substr(c + 1))); + } else { + host = hp; + } + } + if (std::strcmp(argv[i], "--max-seconds") == 0 && i + 1 < argc) + max_seconds = static_cast(std::stoul(argv[++i])); + } + + print_banner(argv[0]); + if (want_help) + return 0; + + if (want_leg_c_p2p) { + if (rpc_conf.empty()) { + const char* home = std::getenv("HOME"); + rpc_conf = std::string(home ? home : ".") + "/bch-regtest/bitcoin.conf"; + } + return run_leg_c_capture_p2p(rpc_conf, leg_c_p2p_port); + } + + if (want_leg_c) { + if (rpc_conf.empty()) { + const char* home = std::getenv("HOME"); + rpc_conf = std::string(home ? home : ".") + "/bch-regtest/bitcoin.conf"; + } + return run_leg_c_capture(rpc_conf); + } + + if (want_ibd) + return run_ibd(host, port, testnet, max_seconds, near_tip); + + // Default / --selftest: drive the live ABLA budget path, then exit. + return run_selftest(); +} diff --git a/src/impl/CMakeLists.txt b/src/impl/CMakeLists.txt index ae12a7c2f..5a98818ca 100644 --- a/src/impl/CMakeLists.txt +++ b/src/impl/CMakeLists.txt @@ -2,4 +2,5 @@ add_subdirectory(ltc) add_subdirectory(btc) add_subdirectory(dgb) add_subdirectory(dash) +add_subdirectory(bch) add_subdirectory(nmc) diff --git a/src/impl/bch/CMakeLists.txt b/src/impl/bch/CMakeLists.txt index 419cf12b8..1dd506cdd 100644 --- a/src/impl/bch/CMakeLists.txt +++ b/src/impl/bch/CMakeLists.txt @@ -8,5 +8,5 @@ if(COIN_BCH) message(STATUS "c2pool: BCH coin module enabled (skeleton)") # add_subdirectory(coin) # add_subdirectory(daemon) - # add_subdirectory(test) + add_subdirectory(test) endif() diff --git a/src/impl/bch/coin/block_connector.hpp b/src/impl/bch/coin/block_connector.hpp new file mode 100644 index 000000000..1d4003364 --- /dev/null +++ b/src/impl/bch/coin/block_connector.hpp @@ -0,0 +1,342 @@ +#pragma once +// --------------------------------------------------------------------------- +// bch::coin::BlockConnector -- M5 full-block body. +// +// Slices 1-3 closed the FORWARD path: full_block --> header index connect --> +// BIP130 best-chain-gated mempool reconciliation, and (with a UTXO view wired) +// connect_block() + the Phase-4 stale-input sweep. But a UTXO view that only +// ever moves forward is wrong the moment the best chain switches branches: the +// old branch stays applied (its spends and its created coins linger), and the +// txs it confirmed are silently lost from the mempool. connect_block() already +// returned a BlockUndo on every connect -- and it was being DISCARDED. This +// slice retains it and closes the REORG / DISCONNECT leg. +// +// For each received block on_full_block performs: +// +// 0. REMEMBER. Cache the full block (bounded ring) so a later reorg can +// re-walk the new branch forward without re-requesting it from the wire. +// +// 1. BLOCK-CONNECT. Hand the 80-byte header to HeaderChain::add_header +// (idempotent for an already-known header). Header sync / reorg accounting +// and best-tip selection by cumulative work live there, not here. +// +// 2. BIP130 BEST-CHAIN GATE. Only a block that IS the new best tip drives any +// state change. A side / stale / orphan block leaves both the UTXO view +// and the mempool untouched. +// +// 3. UTXO SYNC TO TIP (only when a UTXO view is wired): +// - FORWARD EXTEND (new tip's prev == our connected tip, or cold start): +// connect_block() applies it; the BlockUndo is pushed on the undo stack. +// - RE-DELIVERY (new tip == our connected tip): no-op (idempotent). +// - REORG (new tip is on a different branch): walk our undo +// stack back to the fork point, disconnect_block() each old-branch +// block (restore spent inputs, remove created outputs) and return its +// non-coinbase txs to the mempool, then connect_block() forward along +// the new branch from the remembered-block cache up to the new tip. +// A new-branch block not in the cache (reorg deeper than retained +// history) leaves the view at the fork and logs for resync -- never a +// silent half-applied UTXO set. +// +// 4. MEMPOOL RECONCILIATION to the new tip: set_tip_height + remove_for_block +// (Phases 1-3, txid/outpoint) then, with a view, revalidate_inputs +// (Phase 4) sweeps anything the connect left unspendable. +// +// THREADING: single-threaded use from the daemon's block-processing context. +// +// p2pool-merged-v36 SURFACE: NONE. Pure local block-connect + UTXO/mempool +// hygiene; no PoW hash, share format, coinbase commitment, AuxPoW, or PPLNS +// math is touched. PER-COIN ISOLATION: src/impl/bch/coin/ only; every type is +// bch-owned. Build-INERT / source-only header (bch stays skip-green). +// --------------------------------------------------------------------------- + +#include "block.hpp" +#include "header_chain.hpp" // bch::coin::block_hash(), HeaderChain, IndexEntry, Uint256Hasher +#include "mempool.hpp" // Mempool, core::coin::UTXOViewCache +#include "node_interface.hpp" // bch::interfaces::Node (full_block event) +#include "compact_blocks.hpp" // CompactBlock, BlockTransactions{Request,Response}, ReconstructBlock + +#include // core::coin::BlockUndo +#include // EventDisposable +#include +#include + +#include +#include +#include +#include +#include + +namespace bch { +namespace coin { + +/// Closes the full-block UTXO/mempool path: full_block --> header connect --> +/// best-chain-gated UTXO sync (forward + reorg) + mempool reconciliation. +/// Daemon-owned; the chain and pool it references must outlive it. +class BlockConnector { +public: + /// @param chain best-chain header index (connect target + tip authority). + /// @param pool mempool to reconcile against each best-chain tip change. + BlockConnector(HeaderChain& chain, Mempool& pool) + : m_chain(chain), m_pool(pool) {} + + BlockConnector(const BlockConnector&) = delete; + BlockConnector& operator=(const BlockConnector&) = delete; + + /// Optional UTXO view. When set, each best-chain tip change applies (or, on + /// a reorg, un-applies) blocks against this view and then runs the Phase-4 + /// stale-input sweep. Left null the UTXO leg is inert -- Phases 1-3 + /// (txid/spent-outpoint based) still run fully, so cold-start/headers-only + /// is safe. + void set_utxo(core::coin::UTXOViewCache* u) { m_utxo = u; } + + /// Wire to a node's full_block event. Retained internally, torn down on + /// destruction (or detach()). Call once, after node + chain + pool exist. + void attach(bch::interfaces::Node& node) { + m_sub = node.full_block.subscribe( + [this](const BlockType& block) { on_full_block(block); }); + } + + /// Drop the subscription early (idempotent). Destruction does this anyway. + void detach() { + if (m_sub) { + m_sub->dispose(); + m_sub.reset(); + } + } + + /// Connect one received full block. Public so the out-of-tree harness can + /// drive it without a live Event/socket; in production it is invoked only + /// by the full_block subscription. + void on_full_block(const BlockType& block) { + const uint256 hash = + block_hash(static_cast(block)); + + // 0. Remember the block for a possible forward re-walk on reorg. + remember_block(hash, block); + + // 1. Block-connect: index the header if new (idempotent on the common + // headers-first path where it is already synced). + m_chain.add_header(static_cast(block)); + + // 2. BIP130 best-chain gate: act ONLY when this block is the tip. + const auto tip = m_chain.tip(); + if (!tip || tip->block_hash != hash) { + LOG_DEBUG_COIND << "[EMB-BCH] block-connect: " << hash.GetHex().substr(0, 16) + << "... not best tip -- state untouched (side/stale/unconnected)"; + return; + } + + // 3. UTXO sync (forward extend / re-delivery no-op / reorg). Inert when + // no view is wired (cold-start / headers-only contract held). + if (m_utxo) + sync_utxo_to_tip(*tip); + + // 4. Mempool reconciliation to the new tip. + m_pool.set_tip_height(tip->height); + m_pool.remove_for_block(block); // Phase 1 + 2 + 3 (txid/outpoint) + int evicted = 0; + if (m_utxo) + evicted = m_pool.revalidate_inputs(m_utxo); // Phase 4: real sweep + + LOG_DEBUG_COIND << "[EMB-BCH] block-connect: tip=" << hash.GetHex().substr(0, 16) + << "... height=" << tip->height + << " mempool reconciled (size=" << m_pool.size() + << " phase4_evicted=" << evicted + << " undo_depth=" << m_undo_stack.size() << ")"; + } + + bool is_attached() const { return static_cast(m_sub); } + + /// Ingest a BIP152 compact block. Reconstructs the full block from the + /// prefilled txs + the mempool (BCH: short IDs keyed on txid == wtxid). On a + /// complete reconstruction the block is driven straight through the normal + /// on_full_block() path and std::nullopt is returned. When txs are missing + /// the compact block is parked and a getblocktxn request (the missing + /// absolute indexes) is returned for the caller to send to the announcing + /// peer; on_block_txn() later closes it out. Idempotent re-announce of an + /// already-reconstructable block just re-drives the (idempotent) connect. + std::optional on_compact_block(const CompactBlock& cb) { + const uint256 hash = block_hash(cb.header); + const auto known = m_pool.all_txs_map_wtxid(); // BCH: aliases all_txs_map (txid) + auto rec = ReconstructBlock(cb, known); + if (rec.complete) { + m_pending.erase(hash); // supersede any earlier partial of this block + on_full_block(rec.block); + return std::nullopt; + } + m_pending[hash] = cb; // park: the blocktxn round will finish it + BlockTransactionsRequest req; + req.blockhash = hash; + req.indexes = rec.missing_indexes; + LOG_DEBUG_COIND << "[EMB-BCH] compact-block: " << hash.GetHex().substr(0, 16) + << "... incomplete -- getblocktxn for " << req.indexes.size() + << " tx(s) (pending=" << m_pending.size() << ")"; + return req; + } + + /// Close out a parked compact block with a blocktxn response. The response + /// txs (in the order they were requested) are folded into the known-tx set + /// and the SAME ReconstructBlock pass finishes the block, which is then + /// driven through on_full_block(). Returns true iff a block was connected. + /// A response for an unknown/expired blockhash, or one that still leaves the + /// block short, is a logged no-op (await re-announce) -- never a half-block. + bool on_block_txn(const BlockTransactionsResponse& resp) { + auto it = m_pending.find(resp.blockhash); + if (it == m_pending.end()) { + LOG_DEBUG_COIND << "[EMB-BCH] blocktxn: no parked compact block for " + << resp.blockhash.GetHex().substr(0, 16) << "... -- dropped"; + return false; + } + const CompactBlock cb = it->second; + auto known = m_pool.all_txs_map_wtxid(); + for (const auto& tx : resp.txs) + known.emplace(compute_txid(tx), tx); // missing tx not in mempool -> inserts + auto rec = ReconstructBlock(cb, known); + m_pending.erase(it); + if (!rec.complete) { + LOG_WARNING << "[EMB-BCH] blocktxn for " << resp.blockhash.GetHex().substr(0, 16) + << "... left " << rec.missing_indexes.size() + << " tx(s) missing (wrong/short set) -- awaiting re-announce"; + return false; + } + on_full_block(rec.block); + return true; + } + + /// Number of compact blocks parked awaiting a blocktxn round. Diagnostic. + size_t pending_compact_count() const { return m_pending.size(); } + + /// Depth of retained block-undo (number of best-chain blocks we can roll + /// back). Diagnostic / test accessor. + size_t undo_depth() const { return m_undo_stack.size(); } + + ~BlockConnector() { detach(); } + +private: + struct ConnectedEntry { + uint256 hash; // block hash (best-chain tip when applied) + uint256 prev; // header.m_previous_block + uint32_t height; + BlockType block; // retained for disconnect_block() input restore + core::coin::BlockUndo undo; // from connect_block() + }; + + static constexpr size_t kSeenCap = 64; // remembered-block ring bound + + static uint256 txid_of(const MutableTransaction& tx) { return compute_txid(tx); } + + // ----- remembered-block ring (bounded) --------------------------------- + void remember_block(const uint256& h, const BlockType& b) { + if (m_seen.find(h) != m_seen.end()) return; + m_seen.emplace(h, b); + m_seen_order.push_back(h); + while (m_seen_order.size() > kSeenCap) { + m_seen.erase(m_seen_order.front()); + m_seen_order.pop_front(); + } + } + const BlockType* recall_block(const uint256& h) const { + auto it = m_seen.find(h); + return it == m_seen.end() ? nullptr : &it->second; + } + + // ----- connected-chain bookkeeping ------------------------------------- + /// True if `h` is part of the UTXO-applied chain: the current tip, any + /// retained undo entry, or the pre-connector base (the parent of the bottom + /// of the undo stack -- the checkpoint/seed we started syncing from). + bool is_connected(const uint256& h) const { + if (h.IsNull()) return false; + if (h == m_connected_tip) return true; + for (const auto& e : m_undo_stack) + if (e.hash == h) return true; + if (!m_undo_stack.empty() && h == m_undo_stack.front().prev) + return true; // fork at the base we began from + return false; + } + + void connect_one(const uint256& hash, const BlockType& block, uint32_t height) { + core::coin::BlockUndo undo = + m_utxo->connect_block(block, height, &BlockConnector::txid_of); + m_undo_stack.push_back( + ConnectedEntry{hash, block.m_previous_block, height, block, std::move(undo)}); + m_connected_tip = hash; + } + + void disconnect_one() { + ConnectedEntry e = std::move(m_undo_stack.back()); + m_undo_stack.pop_back(); + m_utxo->disconnect_block(e.block, e.undo, &BlockConnector::txid_of); + // Return the disconnected block's non-coinbase txs to the mempool: they + // are no longer confirmed on our best chain. add_tx de-dupes and + // re-derives fee against the now-rolled-back view. + for (size_t i = 1; i < e.block.m_txs.size(); ++i) + m_pool.add_tx(e.block.m_txs[i], m_utxo); + m_connected_tip = e.prev; + LOG_DEBUG_COIND << "[EMB-BCH] reorg: disconnected " << e.hash.GetHex().substr(0, 16) + << "... height=" << e.height + << " (restored " << (e.block.m_txs.empty() ? 0 : e.block.m_txs.size() - 1) + << " tx to mempool)"; + } + + /// Bring the UTXO view from m_connected_tip to `tip`: forward extend, an + /// idempotent re-delivery no-op, or a full reorg (disconnect old branch to + /// the fork point, reconnect the new branch from the remembered-block ring). + void sync_utxo_to_tip(const IndexEntry& tip) { + const uint256 new_tip = tip.block_hash; + const uint256& new_prev = tip.header.m_previous_block; + + if (new_tip == m_connected_tip) + return; // re-delivery of the current tip: nothing to apply. + + if (m_connected_tip.IsNull() || new_prev == m_connected_tip) { + const BlockType* blk = recall_block(new_tip); + if (blk) connect_one(new_tip, *blk, tip.height); + return; // forward extend (covers the first connect: cold start). + } + + // REORG. Build the new-branch path from the new tip back to the first + // ancestor that is already on our connected chain (the fork point). + std::vector connect_path; // new tip ... first-above-fork (reverse order) + uint256 cursor = new_tip; + while (!is_connected(cursor)) { + connect_path.push_back(cursor); + auto e = m_chain.get_header(cursor); + if (!e || e->header.m_previous_block.IsNull()) break; + cursor = e->header.m_previous_block; + } + const uint256 fork = cursor; + + // Disconnect everything above the fork point (deepest-first via pop). + while (!m_undo_stack.empty() && m_undo_stack.back().hash != fork) + disconnect_one(); + + // Reconnect the new branch fork -> new tip from the remembered ring. + for (auto it = connect_path.rbegin(); it != connect_path.rend(); ++it) { + const BlockType* blk = recall_block(*it); + if (!blk) { + LOG_WARNING << "[EMB-BCH] reorg: new-branch block " + << it->GetHex().substr(0, 16) + << "... not retained -- UTXO left at fork, awaiting resync"; + return; + } + auto e = m_chain.get_header(*it); + connect_one(*it, *blk, e ? e->height : 0); + } + } + + HeaderChain& m_chain; + Mempool& m_pool; + core::coin::UTXOViewCache* m_utxo {nullptr}; // optional UTXO view + + uint256 m_connected_tip {}; // null = nothing applied yet + std::vector m_undo_stack; // applied best-chain blocks + std::unordered_map m_seen; // remembered ring + std::deque m_seen_order; // ring eviction order + // Compact blocks awaiting a getblocktxn/blocktxn round (keyed by block hash). + std::unordered_map m_pending; + + std::shared_ptr m_sub; +}; + +} // namespace coin +} // namespace bch diff --git a/src/impl/bch/coin/block_download.hpp b/src/impl/bch/coin/block_download.hpp new file mode 100644 index 000000000..49e1277f0 --- /dev/null +++ b/src/impl/bch/coin/block_download.hpp @@ -0,0 +1,184 @@ +#pragma once +// --------------------------------------------------------------------------- +// bch::coin::block_download -- M5 full-block body. Windowed headers-first block +// download, factored out of NodeP2P as a PURE, peer-free state machine so the +// in-flight window policy is unit-testable without a live socket. +// +// THE GAP THIS CLOSES: header_sync.hpp drives the *header* chain forward -- +// ContinueSync re-issues getheaders so cold-start IBD walks the whole header +// chain to the peer tip. But those headers' BLOCKS were never getdata'd: only +// the tiny BIP130 tip-announce path (RequestBlocks, <= 3 headers) pulled full +// blocks. So IBD synced 2000-header batches forward indefinitely and never +// downloaded a single block body -- and the embedded daemon's ABLA size feed +// (abla_block_feed.hpp) and the full-block -> mempool reconciliation +// (block_connector.hpp) need REAL block data, not just headers. Cold-start IBD +// could never actually complete. +// +// POLICY (mirrors Bitcoin/BCHN headers-first block download, net_processing): +// * headers learned during IBD are enqueued in chain order; +// * at most MAX_BLOCKS_IN_FLIGHT getdata(MSG_BLOCK) are outstanding at once +// (a bounded window -- never blast the whole 2000-header batch as getdata +// and stall on a slow peer / unbounded memory); +// * each arriving block frees one window slot, and the window tops up from +// the front of the queue (oldest-first), so blocks stream in at the peer's +// pace until the queue drains. +// +// DEDUPE: a hash already queued, in flight, or already received is never +// re-requested, so an overlapping getheaders locator batch or a re-announce +// cannot double-download. +// +// NOT IN THIS SLICE (deferred per integrator 2026-06-18): in-flight timeout / +// eviction (a block requested but never delivered by a stalling peer). That is +// robustness hardening worth doing only once blocks are actually flowing; this +// slice builds the happy-path window first. +// +// p2pool-merged-v36 SURFACE: NONE -- pure SPV/IBD wire-sync plumbing; no PoW +// hash, share format, coinbase commitment, AuxPoW, or PPLNS math. PER-COIN +// ISOLATION: src/impl/bch/coin/ only; header-only, build-INERT (bch stays +// skip-green). +// --------------------------------------------------------------------------- + +#include +#include +#include +#include +#include +#include + +#include + +namespace bch::coin::block_download { + +/// Default cap on outstanding getdata(MSG_BLOCK) requests. 16 matches the +/// Bitcoin/BCHN per-peer MAX_BLOCKS_IN_FLIGHT default -- enough to keep a fast +/// peer's pipe full without unbounded memory on a slow one. +inline constexpr std::size_t DEFAULT_MAX_BLOCKS_IN_FLIGHT = 16; + +/// Bounded headers-first block-download window. Peer-free + deterministic: +/// callers feed it learned header hashes (enqueue), drain the requests it wants +/// issued now (next_requests), and report arrivals (on_block_received) which +/// free window slots for the next drain. +class BlockDownloadWindow { + /// Low-64 of a cryptographic hash is already uniform -- no further mixing. + struct HashHasher { + std::size_t operator()(const uint256& h) const { return h.GetLow64(); } + }; + +public: + explicit BlockDownloadWindow(std::size_t max_in_flight = DEFAULT_MAX_BLOCKS_IN_FLIGHT) + : m_max_in_flight(max_in_flight ? max_in_flight : 1) {} + + /// Enqueue block hashes learned from a headers batch, in chain order. + /// Skips any hash already queued, in flight, or already received so a + /// re-announce / overlapping locator batch never double-requests. Returns + /// the count newly enqueued. + std::size_t enqueue(const std::vector& hashes) + { + std::size_t added = 0; + for (const auto& h : hashes) { + if (m_known.count(h)) continue; + m_known.insert(h); + m_queue.push_back(h); + ++added; + } + return added; + } + + /// Pop hashes to request now, up to the free window slots + /// (max_in_flight - in_flight). Marks them in flight. Oldest-first + /// (chain order) so block bodies are pulled in the order they were learned. + std::vector next_requests(uint64_t now_tick = 0) + { + std::vector out; + while (m_in_flight.size() < m_max_in_flight && !m_queue.empty()) { + uint256 h = m_queue.front(); + m_queue.pop_front(); + m_in_flight.emplace(h, now_tick); + out.push_back(h); + } + return out; + } + + /// Report an arrived block. Removes it from the in-flight set (freeing a + /// window slot) and remembers it so a later re-announce is not re-queued. + /// Returns true iff it was one we actually had in flight (vs. an + /// unsolicited / already-handled block). + bool on_block_received(const uint256& h) + { + // False-eviction accounting: a hash we previously expired() now arrived. + // On a healthy peer with a generous timeout NOTHING expires, so this + // stays 0 -- a non-zero value means the BLOCK_DL_TIMEOUT_SEC fired on a + // request the peer was merely slow to answer, not genuinely stalled. + if (m_evicted.erase(h)) ++m_false_evict_count; + auto it = m_in_flight.find(h); + if (it == m_in_flight.end()) { + // Unsolicited or already handled: still remember it so a later + // headers batch never queues it, but report not-in-flight. + m_known.insert(h); + return false; + } + m_in_flight.erase(it); + return true; + } + + /// Evict in-flight requests that have gone stale -- a block we getdata'd but a + /// stalling peer never delivered. now_tick and timeout_ticks are in the caller's + /// monotonic unit (the same unit passed to next_requests()); any request issued + /// at issue_tick with now_tick - issue_tick >= timeout_ticks is pushed back to + /// the FRONT of the queue (retry oldest-first, ahead of not-yet-requested hashes) + /// and dropped from the in-flight set, freeing its window slot. The hash stays in + /// m_known (still wanted, still deduped). Returns the evicted hashes so the caller + /// can log / consider peer demotion. A block that arrives after eviction is handled + /// normally (on_block_received reports it unsolicited, but emit_full_block still + /// applies it). Deferred from the windowed-download slice (2cc7de44) until block + /// bodies actually flowed end-to-end. + std::vector expire(uint64_t now_tick, uint64_t timeout_ticks) + { + std::vector evicted; + for (auto it = m_in_flight.begin(); it != m_in_flight.end(); ) { + if (now_tick - it->second >= timeout_ticks) { + evicted.push_back(it->first); + it = m_in_flight.erase(it); + } else { + ++it; + } + } + // Re-queue the stalled hashes ahead of not-yet-requested ones so a stuck + // block is retried before fresh tip blocks. + for (const auto& h : evicted) m_queue.push_front(h); + // Cumulative re-issue tally: every evicted hash is requeued and + // re-issued by the next drain, so eviction count == re-issue count. + // Surfaced for the read-only IBD writeup (real re-issue metric, not + // inferred) and for peer-demotion heuristics on a noisy single peer. + m_reissue_count += evicted.size(); + for (const auto& h : evicted) m_evicted.insert(h); + return evicted; + } + + std::size_t in_flight() const { return m_in_flight.size(); } + std::size_t queued() const { return m_queue.size(); } + std::size_t max_in_flight() const { return m_max_in_flight; } + /// True when there is window room AND something queued to fill it. + bool has_capacity() const { return m_in_flight.size() < m_max_in_flight && !m_queue.empty(); } + /// True when nothing is queued and nothing is outstanding (IBD drained). + bool idle() const { return m_queue.empty() && m_in_flight.empty(); } + /// Cumulative count of stalled in-flight requests re-issued over the + /// window lifetime (== total expire() evictions). Read-only IBD evidence. + std::size_t reissue_count() const { return m_reissue_count; } + + /// Count of evicted requests whose block later arrived anyway -- a premature + /// eviction (timeout too aggressive for the peer). 0 on a clean sync. + /// Read-only IBD evidence alongside reissue_count(). + std::size_t false_evict_count() const { return m_false_evict_count; } + +private: + std::size_t m_max_in_flight; + std::deque m_queue; // pending, chain order + std::unordered_map m_in_flight; // hash -> tick getdata issued + std::unordered_set m_known; // queued ∪ inflight ∪ received + std::size_t m_reissue_count = 0; // lifetime re-issues (stall-driven) + std::unordered_set m_evicted; // expired, awaiting (re-)arrival + std::size_t m_false_evict_count = 0; // evicted-then-arrived (premature) +}; + +} // namespace bch::coin::block_download diff --git a/src/impl/bch/coin/embedded_daemon.hpp b/src/impl/bch/coin/embedded_daemon.hpp index 6afb209fb..da4e79d36 100644 --- a/src/impl/bch/coin/embedded_daemon.hpp +++ b/src/impl/bch/coin/embedded_daemon.hpp @@ -51,6 +51,8 @@ #include #include +#include +#include #include "header_chain.hpp" // HeaderChain #include "mempool.hpp" // Mempool @@ -61,6 +63,8 @@ #include "bchn_anchor_record.hpp" // BchnAnchorRecord (cold-start anchor) #include +#include // EventDisposable (new_headers subscription handle) +#include // uint256S (near-tip checkpoint seed) // EventDisposable (new_headers subscription handle) namespace bch { namespace coin { @@ -77,7 +81,8 @@ class EmbeddedDaemon { /// daemon (owned by the binary entrypoint, same contract as coin::Node). EmbeddedDaemon(auto* context, config_t* config, uint32_t anchor_height) : m_config(config), - m_chain(), + m_chain(config->m_testnet ? BCHChainParams::testnet() + : BCHChainParams::mainnet()), m_pool(), m_embedded(m_chain, m_pool, config->m_testnet), m_node(context, config), @@ -91,16 +96,164 @@ class EmbeddedDaemon { /// flow node --> feed --> tracker --> EmbeddedCoinNode dynamic budget, and /// EmbeddedCoinNode::getwork() is the live in-process work source. void run() { + m_chain.init(); // load genesis / fast-start checkpoint (network-free) m_node.run(); // init_rpc(): external BCHN-RPC fallback retained - m_abla.wire(m_node, m_embedded); - // Build the CoinNode seam NOW (not in the ctor): m_node.rpc() is only - // live after run()/init_rpc(). Embedded work source = primary, the - // external BCHN-RPC = retained fallback (v36 external_fallback law). - m_coin_node = std::make_unique(&m_embedded, m_node.rpc()); + assemble(); // network-free seam + ABLA wiring (see below) + wire_chain_ingest(); // new_headers --> HeaderChain (advances synced height) + pin_cold_start_anchor(); // operator-APPROVED VM300 anchor (decisions@ 2026-06-18); floor-equivalent LOG_INFO << "[EMB-BCH] embedded daemon up: embedded-primary work source," << " external BCHN-RPC fallback retained, ABLA loop closed" - << " (cold-start floor anchor; VM300 pin pending operator)."; + << " (cold-start anchor pinned @" << BchnAnchorRecord::height << ")."; + } + + /// 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 + /// headers-first ingest subscription (wire_chain_ingest) live, so a REAL + /// sync advances m_chain past its init() checkpoint and the block-download + /// window accrues the in_flight / reissue / false_evict counters the harness + /// reports. Deliberately does NOT call init_rpc(): this is a read-only + /// header/block pull for evidence, NOT the production daemon -- run() still + /// owns the external BCHN-RPC fallback (external_fallback invariant intact). + /// A P2P peer connection issues NO qm/control op, so VM300 stays read-only. + /// p2pool-merged-v36 surface: NONE (local SPV header state only). + void start_ibd(const NetService& peer) { + m_chain.init(); // genesis/checkpoint origin (network-free) + assemble(); // ABLA loop + CoinNode seam (network-free) + wire_chain_ingest(); // new_headers --> HeaderChain (height advance) + pin_cold_start_anchor(); // operator-approved floor-equivalent anchor + m_node.start_p2p(peer); // read-only P2P connect to the BCHN peer + } + + /// NEAR-TIP variant of the read-only IBD harness (UID 1375 follow-up). The + /// plain --ibd cold-start CANNOT exercise AblaBlockFeed advancement: by + /// construction the ABLA cursor only moves when full blocks arrive + /// CONTIGUOUSLY from the anchor (height == cursor+1), and the anchor sits at + /// BchnAnchorRecord::height (955700) -- only ~100+ blocks below the live + /// VM300 tip. A genesis-origin sync reaches at most a few ten-thousand + /// headers in a harness window, all far below the anchor, so every + /// downloaded block is height <= cursor -> idempotently ignored -> the + /// cursor never moves (exactly the pre-tip state UID 1375 confirmed). + /// + /// This variant seeds the header chain's dynamic checkpoint AT the + /// operator-approved anchor {height,hash} BEFORE connecting, so the locator + /// anchors at 955700 and the peer streams ONLY the last handful of headers + /// to its tip. Their block bodies backfill through the download window and + /// fold into AblaTracker as REAL serialized sizes -- advancing the cursor + /// 955700 -> tip, the proof that full_block --> AblaBlockFeed --> AblaTracker + /// is live on live-network data (UID 1369 acceptance (a): real, not + /// synthetic). Still strictly read-only: a seeded checkpoint + P2P + /// header/block pull issues NO qm/control op, VM300 stays read-only. NO + /// init_rpc() -- the external BCHN-RPC fallback path is run()'s, untouched. + /// The anchor hash is the SAME static record run() pins (no new VM read). + /// p2pool-merged-v36 surface: NONE (local SPV + ABLA budget only). + void start_ibd_near_tip(const NetService& peer) { + m_chain.init(); // genesis/checkpoint origin (network-free) + // Seed the header origin at the operator-approved BCHN anchor so the + // sync covers only anchor -> tip (a handful of blocks), letting the ABLA + // feed actually fold real block sizes within one harness run. + m_chain.set_dynamic_checkpoint( + BchnAnchorRecord::height, + uint256S(std::string(BchnAnchorRecord::hash))); + assemble(); // ABLA loop + CoinNode seam (network-free) + wire_chain_ingest(); // new_headers --> HeaderChain (height advance) + pin_cold_start_anchor(); // ABLA anchor @ the SAME height as the seed + m_node.start_p2p(peer); // read-only P2P connect to the BCHN peer + } + + /// True once the BCHN peer handshake (version/verack) has completed, so the + /// first locator-anchored getheaders can be issued. + bool ibd_handshake_ready() { + return m_node.has_p2p() && m_node.p2p()->is_handshake_complete(); + } + + /// Kick the first headers-first getheaders from our current locator + /// (genesis/checkpoint). The peer streams its chain forward and the + /// p2p_node ContinueSync follow-up self-drives the rest of IBD; block + /// bodies backfill through the bounded download window. Caller issues this + /// once, after ibd_handshake_ready() turns true. + void ibd_kick_sync() { + m_node.send_getheaders(70016, m_chain.get_locator(), uint256::ZERO); + } + + /// NETWORK-FREE assembly of the in-process daemon graph: close the ABLA + /// size loop (full_block --> feed --> tracker --> EmbeddedCoinNode budget) + /// and build the CoinNode seam (core::coin::ICoinNode) embedded-primary. + /// Split out of run() so the embedded cluster can be assembled and verified + /// against the REAL EmbeddedCoinNode without bringing up the external + /// BCHN-RPC / P2P front-end (run() = m_node.run() THEN assemble()). + /// + /// The seam binds &m_embedded (always-live, primary) + m_node.rpc() (the + /// external FALLBACK sink). When assemble() runs BEFORE run(), m_node.rpc() + /// is still null -> the seam is embedded-primary with the fallback absent, + /// which is the correct offline contract; run() calls assemble() AFTER + /// init_rpc() so production binds the live RPC fallback. Idempotent: guarded + /// on m_coin_node so a second call (e.g. assemble()-then-run()) is a no-op. + /// p2pool-merged-v36 surface: NONE -- pure local wiring, no PoW/share/ + /// coinbase/PPLNS/WorkData-shape change. + void assemble() { + if (m_coin_node) + return; // already assembled; idempotent no-op + m_abla.wire(m_node, m_embedded); + m_coin_node = std::make_unique(&m_embedded, m_node.rpc()); + } + + /// Drive the authoritative HeaderChain from the live P2P header stream. + /// The P2P front-end (NodeP2P) parses `headers` messages and fires + /// new_headers; until this subscription existed NOTHING advanced m_chain + /// during a sync (the handler only queued block-body downloads), so the + /// synced height stayed pinned at the init() checkpoint. Here we feed every + /// received batch into m_chain.add_headers() -- headers-first IBD: the tip + /// tracks the peer as batches stream, block bodies backfill via the + /// block-download window. The peer's advertised tip is propagated first so + /// add_headers() picks its fast-sync batch size. Idempotent (guarded). + /// p2pool-merged-v36 surface: NONE -- local SPV header state only (no + /// PoW/share/coinbase/PPLNS/WorkData-shape change). + void wire_chain_ingest() { + if (m_headers_sub) + return; // already wired; idempotent no-op + m_headers_sub = m_node.new_headers.subscribe( + [this](const std::vector& headers) { + if (auto* p2p = m_node.p2p()) { + const uint32_t peer_tip = p2p->peer_start_height(); + if (peer_tip > 0) + m_chain.set_peer_tip_height(peer_tip); + } + m_chain.add_headers(headers); + }); + } + + // Read-only IBD evidence for the --ibd run-loop: synced height vs the peer's + // advertised tip, plus the block-download window stall counters. All derived + // from live members; valid once run()/start_p2p() has connected a peer. + uint32_t ibd_synced_height() { return m_chain.height(); } + uint32_t ibd_peer_tip() { + return m_node.has_p2p() ? m_node.p2p()->peer_start_height() : 0; } + std::size_t ibd_reissue_count() { + return m_node.has_p2p() ? m_node.p2p()->ibd_reissue_count() : 0; + } + std::size_t ibd_false_evict_count() { + return m_node.has_p2p() ? m_node.p2p()->ibd_false_evict_count() : 0; + } + std::size_t ibd_in_flight() { + return m_node.has_p2p() ? m_node.p2p()->ibd_in_flight() : 0; + } + + /// Live ABLA size-feed evidence for the --ibd harness: the dynamic block-size + /// budget the feed has folded from the REAL blocks streamed off the peer + /// (VM300 bchn-bch), anchored at the cursor the feed has advanced to. The + /// budget sits at the 32 MB safe floor until the feed has folded blocks + /// CONTIGUOUSLY from the cursor; the cursor trails ibd_synced_height by design + /// (headers-first: headers race ahead, block bodies backfill through the + /// download window, and ONLY a folded full block advances this cursor). A + /// moving cursor here is the proof that full_block --> AblaBlockFeed --> + /// AblaTracker is live on real network data, not merely the cold-start anchor. + /// Read-only; no p2pool-merged-v36 surface (local ABLA budget only). + uint64_t ibd_abla_budget() { + return m_abla.tracker().budget_for_tip(m_abla.tracker().cursor_height()); + } + uint32_t ibd_abla_cursor() { return m_abla.tracker().cursor_height(); } /// Apply a BCHN-pinned {height, State} captured from VM300 bchn-bch. This /// is the operator-gated reanchor step -- call ONLY after the read is @@ -109,6 +262,28 @@ class EmbeddedDaemon { m_abla.reanchor(height, state); } + /// Pin the operator-APPROVED VM300 BCHN cold-start anchor. decisions@ + /// 2026-06-18 flipped this dry-run -> live (floor-equivalent): the recorded + /// control state @955700 is still at the 32 MB floor, so pinning changes NO + /// ABLA budget vs the cold-start floor -- it only fixes the height/chainwork + /// origin so a future SPV cold-start can trust the recorded header instead + /// of climbing from genesis. The moment a future capture is ABOVE floor this + /// path RAISES the budget to the real recorded limit (never undercuts). The + /// static record is read here; VM300 stays read-only (no qm op). Zero + /// p2pool-merged-v36 surface (ABLA is BCH embedded-internal). + void pin_cold_start_anchor() { + using Rec = BchnAnchorRecord; + 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()) + LOG_INFO << "[EMB-BCH] cold-start anchor PINNED (operator-approved):" + << " height=" << Rec::height << " budget=" << pinned + << " (32 MB floor-equivalent; provenance hash=" << Rec::hash << ")."; + else + LOG_WARNING << "[EMB-BCH] cold-start anchor PINNED above floor:" + << " height=" << Rec::height << " budget=" << pinned << "."; + } + /// DRY RUN of the cold-start reanchor: read the STATIC VM300 anchor record /// (BchnAnchorRecord -- captured once, read-only; the live VM is never /// touched here) and LOG exactly what apply_bchn_anchor() WOULD pin, with @@ -156,6 +331,64 @@ class EmbeddedDaemon { Mempool& mempool() { return m_pool; } bool is_wired() const { return m_abla.is_wired(); } + /// Outcome of a won-block broadcast: which of the two sinks fired and + /// whether the network accepted. P2P is primary; the external BCHN-RPC + /// submitblock is the dual-path FALLBACK (fired ALWAYS, per the + /// broadcaster-gate dual-path rule -- NOT a P2P-only path with RPC as a + /// catch). A `duplicate` on the RPC leg AFTER a P2P accept still proves + /// both paths reached the node; `landed_first` records which won the race. + struct BlockBroadcast { + bool p2p_sent = false; // submit_block_p2p_raw issued (sink present) + bool rpc_ok = false; // submitblock returned ok OR duplicate + const char* landed_first = "none"; // "p2p" | "rpc" | "none" + bool any() const { return p2p_sent || rpc_ok; } + }; + + /// Fire a won block down BOTH broadcast paths. `block_bytes` is the + /// pre-serialized (header || tx_count || coinbase || tx_data) blob the + /// embedded P2P broadcaster relays; `block_hex` is the same block hex for + /// the external submitblock fallback. BCH is SHA256d standalone parent -- + /// no merged-coinbase leg. Read-only vs VM300 (a block relay/submit issues + /// no qm/control op). Zero p2pool-merged-v36 surface (block dispatch, not + /// share/PPLNS/coinbase bytes). This is the sink the pool node wires its + /// tracker().m_on_block_found to so an in-operation win emits immediately. + BlockBroadcast broadcast_won_block(const std::vector& block_bytes, + const std::string& block_hex) + { + BlockBroadcast r; + + // PRIMARY: embedded P2P relay (fastest propagation). + if (m_node.has_p2p()) { + m_node.submit_block_p2p_raw(block_bytes); + r.p2p_sent = true; + r.landed_first = "p2p"; + LOG_INFO << "[EMB-BCH] won-block P2P relay issued (" << block_bytes.size() + << " bytes) -- primary path."; + } else { + LOG_WARNING << "[EMB-BCH] won-block: no embedded P2P sink; relying on RPC fallback."; + } + + // FALLBACK (always fired): external BCHN submitblock. A duplicate here + // after a P2P accept is success, not failure -- ignore_failure=true so a + // duplicate/already-have does not mask the P2P win. + if (m_coin_node && m_coin_node->has_rpc()) { + r.rpc_ok = m_coin_node->submit_block_hex(block_hex, /*ignore_failure=*/true); + if (r.rpc_ok && !r.p2p_sent) r.landed_first = "rpc"; + LOG_INFO << "[EMB-BCH] won-block submitblock RPC fallback " + << (r.rpc_ok ? "ok/duplicate" : "no-ack") << "."; + } else { + LOG_WARNING << "[EMB-BCH] won-block: no external BCHN-RPC fallback sink wired."; + } + + if (!r.any()) + LOG_ERROR << "[EMB-BCH] won-block had NEITHER broadcast sink -- block NOT relayed!"; + else + LOG_INFO << "[EMB-BCH] won-block broadcast: p2p=" << (r.p2p_sent ? "sent" : "off") + << " rpc=" << (r.rpc_ok ? "ok" : "off") + << " landed_first=" << r.landed_first << "."; + return r; + } + private: config_t* m_config; // not owned (binary entrypoint owns it) HeaderChain m_chain; // before m_embedded + m_abla: their refs bind here @@ -163,6 +396,8 @@ class EmbeddedDaemon { EmbeddedCoinNode m_embedded; // in-process work source Node m_node; // P2P + external-RPC fallback; full_block source AblaRuntime m_abla; // owns tracker + feed; wired in run() + // new_headers -> m_chain.add_headers() subscription (headers-first IBD). + std::shared_ptr m_headers_sub; // Built in run() once m_node.rpc() is live; binds raw ptrs to m_embedded // (primary) + m_node's NodeRPC (fallback), both outlive it (daemon-owned). std::unique_ptr m_coin_node; diff --git a/src/impl/bch/coin/header_chain.hpp b/src/impl/bch/coin/header_chain.hpp index e73b44825..830bac7b2 100644 --- a/src/impl/bch/coin/header_chain.hpp +++ b/src/impl/bch/coin/header_chain.hpp @@ -185,8 +185,8 @@ struct BCHChainParams { /// BCH testnet4 params (port 28333). NOTE: BCH testnet4 has its OWN genesis /// (distinct from BTC testnet4) — this is a consensus constant. - /// TODO(M3): verify genesis_hash against VM300 bchn-bch chainparams.cpp - /// before any testnet4 integration run. + /// Verified vs BCHN v29.0.0 src/chainparams.cpp (commit 89a591f) genesis + /// assert — pinned by test/genesis_conformance_test.cpp (M3 closed). static BCHChainParams testnet4() { BCHChainParams p; p.asert = asert_testnet4(); @@ -652,7 +652,15 @@ class HeaderChain { // our mining builds on the new tip, so no new blocks appear on the // old fork. bool dominated = entry.chain_work > m_best_work; - bool equal_at_tip = entry.chain_work == m_best_work + // First-seen-wins (BCHN / Bitcoin consensus): an equal-work competitor + // does NOT replace the incumbent tip. The equal-work reorg is permitted + // ONLY under min-difficulty params (testnet 20-minute rule), where our + // own miner and the network can independently mint same-work blocks at + // one height and the peer header represents network consensus. On + // mainnet this stays false, so a same-work fork can never flip-flop the + // tip (which would also be a cheap reorg-DoS vector). + bool equal_at_tip = m_params.allow_min_difficulty + && entry.chain_work == m_best_work && new_height == m_tip_height && bhash != m_tip; if (dominated || equal_at_tip) { diff --git a/src/impl/bch/coin/header_sync.hpp b/src/impl/bch/coin/header_sync.hpp new file mode 100644 index 000000000..7022e3c72 --- /dev/null +++ b/src/impl/bch/coin/header_sync.hpp @@ -0,0 +1,71 @@ +#pragma once +// --------------------------------------------------------------------------- +// bch::coin::header_sync -- M5 full-block body. Headers-first IBD continuation +// decision, factored out of NodeP2P's `headers` handler as a PURE function so +// the three-way follow-up policy is unit-testable without a live peer/socket. +// +// THE GAP THIS CLOSES: the `headers` handler fired new_headers and, for a +// small BIP130 tip-announce batch (<= 3), issued a getdata for the announced +// full block(s). But a MAXIMAL headers batch -- the peer capped the response at +// MAX_HEADERS_RESULTS because it has MORE -- was a dead end: cold-start initial +// block download (IBD) advanced exactly one batch of headers and then stalled. +// The header chain (which the embedded daemon drives ASERT DAA and the ABLA +// size feed off) could never sync past the first 2000 headers from genesis / +// the cold-start anchor. +// +// POLICY (mirrors Bitcoin/BCHN headers-first sync): +// * empty batch -> Idle (nothing arrived) +// * batch size >= MAX_HEADERS_RESULTS -> ContinueSync (peer has more: re- +// issue getheaders with a locator +// anchored at the last header to walk +// the chain forward to the peer tip) +// * batch size <= announce_threshold -> RequestBlocks (BIP130 header-first +// block announcement: getdata it) +// * otherwise (partial IBD batch) -> Idle (a non-maximal batch +// means we reached the peer's tip; +// nothing further to pull) +// +// MAX_HEADERS_RESULTS == 2000 matches Bitcoin Cash Node (the upstream the +// embedded daemon forks from); a peer never returns more than this per +// `headers` message, so "== cap" is the canonical "has more" signal. +// +// p2pool-merged-v36 SURFACE: NONE -- pure SPV/IBD wire-sync plumbing; no PoW +// hash, share format, coinbase commitment, AuxPoW, or PPLNS math. PER-COIN +// ISOLATION: src/impl/bch/coin/ only; header-only, build-INERT (bch stays +// skip-green). +// --------------------------------------------------------------------------- + +#include + +namespace bch::coin::header_sync { + +/// Upstream cap on headers returned per `headers` message (BCHN/Bitcoin). +inline constexpr std::size_t MAX_HEADERS_RESULTS = 2000; + +/// BIP130 header-first announcement is a small batch; at or below this we treat +/// the headers as block announcements and getdata the corresponding block(s). +inline constexpr std::size_t DEFAULT_ANNOUNCE_THRESHOLD = 3; + +/// Follow-up action after a `headers` message is ingested into the chain. +enum class Followup { + Idle, ///< Nothing to do (empty, or a non-maximal IBD batch = caught up). + RequestBlocks, ///< BIP130 tip announce: getdata the announced block(s). + ContinueSync, ///< Maximal IBD batch: re-issue getheaders for the next batch. +}; + +/// Classify the follow-up for a just-ingested headers batch. PURE. +inline Followup classify_headers_batch( + std::size_t batch_size, + std::size_t announce_threshold = DEFAULT_ANNOUNCE_THRESHOLD, + std::size_t max_results = MAX_HEADERS_RESULTS) +{ + if (batch_size == 0) + return Followup::Idle; + if (batch_size >= max_results) + return Followup::ContinueSync; + if (batch_size <= announce_threshold) + return Followup::RequestBlocks; + return Followup::Idle; +} + +} // namespace bch::coin::header_sync diff --git a/src/impl/bch/coin/node.hpp b/src/impl/bch/coin/node.hpp index d7b2b3a2c..6cc276846 100644 --- a/src/impl/bch/coin/node.hpp +++ b/src/impl/bch/coin/node.hpp @@ -110,6 +110,11 @@ class Node : public bch::interfaces::Node /// the caller -- the Node retains ownership for its whole lifetime. NodeRPC* rpc() { return m_rpc.get(); } + /// Embedded BCHN P2P driver, or nullptr until start_p2p()/init_p2p() ran. + /// Exposes the read-only IBD counters (ibd_reissue_count / false_evict) to + /// the --ibd run-loop. Node retains ownership. + NodeP2P* p2p() { return m_p2p.get(); } + /// Send getheaders to drive header sync. /// Locator should be hashes from chain tip back to genesis (sparsely); /// for an empty chain pass {genesis_hash}. Stop = uint256::ZERO means diff --git a/src/impl/bch/coin/p2p_node.hpp b/src/impl/bch/coin/p2p_node.hpp index 73a18a723..7dfdb1d1d 100644 --- a/src/impl/bch/coin/p2p_node.hpp +++ b/src/impl/bch/coin/p2p_node.hpp @@ -49,6 +49,8 @@ #include "compact_blocks.hpp" #include "mempool.hpp" #include "merkle.hpp" +#include "header_sync.hpp" +#include "block_download.hpp" #include @@ -94,6 +96,13 @@ class NodeP2P : public core::ICommunicator, public core::INetwork, public core:: static constexpr time_t CONNECT_TIMEOUT_SEC = 10; static constexpr time_t IDLE_TIMEOUT_SEC = 100; static constexpr time_t PING_INTERVAL_SEC = 30; + // Headers-first block-download stall recovery. Every BLOCK_DL_EXPIRE_TICK_SEC + // the in-flight getdata(MSG_BLOCK) window is scanned; any request outstanding + // >= BLOCK_DL_TIMEOUT_SEC is presumed dropped by a stalling peer, requeued, and + // re-issued from the freed slot. Tick << timeout so a stall is caught within one + // cadence of the deadline without churning the window. See block_download.hpp. + static constexpr time_t BLOCK_DL_EXPIRE_TICK_SEC = 5; + static constexpr time_t BLOCK_DL_TIMEOUT_SEC = 60; bch::interfaces::Node* m_coin; io::io_context* m_context; @@ -104,6 +113,7 @@ class NodeP2P : public core::ICommunicator, public core::INetwork, public core:: std::unique_ptr m_reconnect_timer; std::unique_ptr m_ping_timer; std::unique_ptr m_timeout_timer; + std::unique_ptr m_block_dl_timer; NetService m_target_addr; bool m_reconnect_enabled = false; bool m_handshake_complete = false; @@ -128,6 +138,10 @@ class NodeP2P : public core::ICommunicator, public core::INetwork, public core:: uint256 m_sent_cmpct_hash; // External mempool for compact block tx matching Mempool* m_mempool{nullptr}; + // Headers-first windowed block download (cold-start IBD). Bounds outstanding + // getdata(MSG_BLOCK) so the synced header stream is pulled as block bodies at + // the peer's pace instead of stalling after header sync. See block_download.hpp. + block_download::BlockDownloadWindow m_block_dl; // Callbacks for broadcaster integration using AddrCallback = std::function&)>; @@ -202,6 +216,7 @@ class NodeP2P : public core::ICommunicator, public core::INetwork, public core:: { stop_ping_timer(); stop_timeout_timer(); + stop_block_dl_timer(); m_handshake_complete = false; m_peer.reset(); } @@ -252,6 +267,7 @@ class NodeP2P : public core::ICommunicator, public core::INetwork, public core:: stop_ping_timer(); stop_timeout_timer(); + stop_block_dl_timer(); m_handshake_complete = false; } @@ -347,6 +363,24 @@ class NodeP2P : public core::ICommunicator, public core::INetwork, public core:: } } + /// Drain the IBD block-download window: issue getdata(MSG_BLOCK) for every + /// hash the window releases (bounded by MAX_BLOCKS_IN_FLIGHT). Called after + /// enqueuing a synced headers batch and again on each block arrival to top + /// the window back up. No-op when the window is full or the queue is empty. + void drain_block_window() + { + if (!m_peer) return; + for (const auto& bhash : m_block_dl.next_requests(now_tick_sec())) { + auto getdata_msg = message_getdata::make_raw( + {inventory_type(inventory_type::block, bhash)}); + m_peer->write(getdata_msg); + LOG_DEBUG_COIND << "[" << m_chain_label << "] IBD getdata block " + << bhash.GetHex().substr(0, 16) << "... (in flight " + << m_block_dl.in_flight() << ", queued " + << m_block_dl.queued() << ")"; + } + } + /// Whether this peer supports compact blocks (BIP 152). bool supports_compact_blocks() const { return m_peer_supports_cmpct; } /// Peer's service flags from version message (for NODE_BLOOM check etc.) @@ -358,6 +392,14 @@ class NodeP2P : public core::ICommunicator, public core::INetwork, public core:: uint32_t peer_version() const { return m_peer_version; } const std::string& peer_subver() const { return m_peer_subver; } uint32_t peer_start_height() const { return m_peer_start_height; } + + /// Read-only IBD block-download evidence (M5 --ibd run-loop). reissue = + /// stalled in-flight requests re-issued; false_evict = evicted requests + /// whose block arrived anyway (premature timeout). Both 0 on a clean sync. + std::size_t ibd_reissue_count() const { return m_block_dl.reissue_count(); } + std::size_t ibd_false_evict_count() const { return m_block_dl.false_evict_count(); } + std::size_t ibd_in_flight() const { return m_block_dl.in_flight(); } + std::size_t ibd_queued() const { return m_block_dl.queued(); } int64_t peer_uptime_sec() const { return std::chrono::duration_cast( std::chrono::steady_clock::now() - m_connected_at).count(); @@ -465,6 +507,41 @@ class NodeP2P : public core::ICommunicator, public core::INetwork, public core:: m_ping_timer->stop(); } + void ensure_block_dl_timer() + { + if (!m_block_dl_timer) + m_block_dl_timer = std::make_unique(m_context, true); + } + + void stop_block_dl_timer() + { + if (m_block_dl_timer) + m_block_dl_timer->stop(); + } + + /// Monotonic seconds since connect -- the tick domain for the block-download + /// window's issue/expire bookkeeping. steady_clock so it never runs backwards. + uint64_t now_tick_sec() const + { + return static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now() - m_connected_at).count()); + } + + /// Periodic stall-recovery tick: requeue in-flight block requests outstanding + /// >= BLOCK_DL_TIMEOUT_SEC and re-issue from the freed slots. No-op while + /// nothing is in flight or nothing has timed out. + void expire_block_window() + { + auto requeued = m_block_dl.expire(now_tick_sec(), BLOCK_DL_TIMEOUT_SEC); + if (requeued.empty()) return; + LOG_DEBUG_COIND << "[" << m_chain_label << "] IBD block-dl expire requeued " + << requeued.size() << " stalled request(s) (in flight " + << m_block_dl.in_flight() << ", queued " + << m_block_dl.queued() << ")"; + drain_block_window(); + } + void on_activity() { if (!m_peer) @@ -531,6 +608,11 @@ class NodeP2P : public core::ICommunicator, public core::INetwork, public core:: send_ping(); }); + ensure_block_dl_timer(); + m_block_dl_timer->start(BLOCK_DL_EXPIRE_TICK_SEC, [this]() { + expire_block_window(); + }); + // BIP 130: request header-first block announcements auto msg_sendheaders = message_sendheaders::make_raw(); m_peer->write(msg_sendheaders); @@ -677,6 +759,11 @@ class NodeP2P : public core::ICommunicator, public core::INetwork, public core:: << blockhash.GetHex().substr(0, 16) << "..." << " txs=" << block.m_txs.size(); emit_full_block(block, blockhash); + + // IBD window: this block freed a slot (if it was one we requested) -- + // top the window back up so the next queued block bodies stream in. + m_block_dl.on_block_received(blockhash); + drain_block_window(); } ADD_P2P_HANDLER(headers) @@ -699,19 +786,54 @@ class NodeP2P : public core::ICommunicator, public core::INetwork, public core:: if (!vheaders.empty()) { m_coin->new_headers.happened(vheaders); - // BIP 130: when receiving a small headers batch (new block - // announcement), request the full block via getdata. - // BCH: plain MSG_BLOCK (0x02) — no witness-bearing block inv. - if (vheaders.size() <= 3 && m_peer) { - for (auto& hdr : vheaders) { - auto packed = pack(hdr); - auto bhash = Hash(packed.get_span()); - auto getdata_msg = message_getdata::make_raw( - {inventory_type(inventory_type::block, bhash)}); - m_peer->write(getdata_msg); - LOG_INFO << "[" << m_chain_label << "] Requesting full block " - << bhash.GetHex().substr(0, 16) << "..."; + // Headers-first BLOCK download is INDEPENDENT of the getheaders + // follow-up policy: EVERY header batch we learn -- a maximal IBD + // batch, a BIP130 tip announce, OR a non-maximal partial batch (the + // --near-tip anchor->tip span is ~100 headers) -- must have its + // block BODIES pulled, or the ABLA size feed / block-connector + // starve: the header chain advances to the peer tip but + // AblaTracker's cursor never moves (pinned at the 32 MB floor). The + // old code queued bodies ONLY inside the ContinueSync branch, so the + // near-tip span classified Idle and downloaded NOTHING -- exactly + // the pinned-cursor symptom. Queue this batch's hashes through the + // bounded, deduping, reissue-accounted window for ALL batch shapes; + // block_download.hpp dedupes overlap and bounds in_flight while + // accounting reissue/false_evict. + std::vector batch_hashes; + batch_hashes.reserve(vheaders.size()); + for (auto& hdr : vheaders) { + auto p = pack(hdr); + batch_hashes.push_back(Hash(p.get_span())); + } + m_block_dl.enqueue(batch_hashes); + drain_block_window(); + + // getheaders follow-up policy (header_sync.hpp, PURE + tested). + // Block bodies for ALL batch shapes were already queued above; here + // we only decide whether to re-issue getheaders for the NEXT header + // batch: + // ContinueSync -- maximal IBD batch, peer has more -> getheaders + // RequestBlocks -- BIP130 tip announce / partial batch -> bodies + // only (already queued); no further header sync + // Idle -- caught up; nothing further to fetch + using header_sync::Followup; + switch (header_sync::classify_headers_batch(vheaders.size())) { + case Followup::ContinueSync: + if (m_peer) { + // Anchor the next locator at the last header we just learned + // so the peer streams the next batch forward to its tip. + auto last_packed = pack(vheaders.back()); + auto last_hash = Hash(last_packed.get_span()); + send_getheaders(m_peer_version ? m_peer_version : 1, + {last_hash}, uint256::ZERO); + LOG_INFO << "[" << m_chain_label << "] IBD: got " + << vheaders.size() << " headers, continuing from " + << last_hash.GetHex().substr(0, 16) << "..."; } + break; + case Followup::RequestBlocks: + case Followup::Idle: + break; } } } diff --git a/src/impl/bch/coin/regtest_block.hpp b/src/impl/bch/coin/regtest_block.hpp new file mode 100644 index 000000000..c2fa99bd8 --- /dev/null +++ b/src/impl/bch/coin/regtest_block.hpp @@ -0,0 +1,184 @@ +#pragma once + +// regtest_block.hpp -- valid-block construction for the leg-C broadcaster-gate +// capture (integrator 2026-06-19). The dual-path gate closes ONLY on one regtest +// capture showing submitblock=accept AND a verbatim BCHN "UpdateTip: new best=.. +// height=N" connect-block line. Opaque dummy bytes prove the two sinks FIRE but +// not that the network ACCEPTS -- so a consensus-valid block is the load-bearing +// slice integrator named: GBT-derived coinbase + BIP34 height + merkle + nBits + +// regtest PoW solve. +// +// This builder is the construction half; the live RPC drive + debug.log capture +// lives in src/c2pool/main_bch.cpp (--leg-c-capture mode). Solo-coinbase block: +// a freshly self-provisioned regtest node has an empty mempool, so a single +// coinbase tx is a complete, consensus-valid block. Regtest nBits (0x207fffff) +// has a trivially large target, so the PoW solve is a short nonce sweep. +// +// PER-COIN ISOLATION: src/impl/bch only -- reuses bch BIP34/merkle/block/tx +// primitives, no src/core or bitcoin_family edit. p2pool-merged-v36 surface: +// NONE -- this constructs a parent BCH block for relay, touching no share / +// PPLNS / coinbase-commitment-share bytes. Conformance oracle for the coinbase +// layout: coinbase_commitment.hpp (M3 s19, pinned vs BCHN ContextualCheckBlock). + +#include "block.hpp" +#include "header_chain.hpp" // sha256d_hash, target_from_bits +#include "merkle.hpp" // compute_merkle_root (shared SHA256d) +#include "transaction.hpp" +#include "../coinbase_commitment.hpp" // bch::consensus::build_bip34_height_push + +#include +#include +#include +#include + +#include // HexStr +#include + +#include +#include +#include +#include + +namespace bch { +namespace coin { +namespace regtest { + +/// Result of a solved regtest block: the wire bytes both broadcast sinks consume +/// (P2P relay takes `bytes`; submitblock RPC takes `hex`), plus the solved hash +/// for the post-broadcast getblock acceptance check. +struct BuiltBlock { + std::vector bytes; // full serialized block (header||txcount||coinbase) + std::string hex; // HexStr(bytes) for submitblock + uint256 hash; // SHA256d(header) -- solved block hash + uint256 merkle_root; // coinbase txid (solo-coinbase block) + uint32_t nonce = 0; // winning nonce + bool solved = false; +}; + +/// Assemble the coinbase scriptSig: [BIP34 height push]["/c2pool/"][8B extranonce]. +/// BIP34 height is consensus-required and MUST be the first push (BCHN +/// ContextualCheckBlock "bad-cb-height"); the tag + extranonce keep distinct +/// coinbases distinct across re-solves. Layout matches coinbase_commitment.hpp. +inline OPScript build_coinbase_script_sig(uint32_t height, uint64_t extranonce) +{ + std::vector s = + bch::consensus::build_bip34_height_push(static_cast(height)); + + static const unsigned char kTag[8] = {0x2f, 0x63, 0x32, 0x70, 0x6f, 0x6f, 0x6c, 0x2f}; // "/c2pool/" + s.push_back(static_cast(sizeof(kTag))); // push-len + s.insert(s.end(), kTag, kTag + sizeof(kTag)); + + s.push_back(8); // 8-byte extranonce push + for (int i = 0; i < 8; ++i) + s.push_back(static_cast((extranonce >> (8 * i)) & 0xffu)); + + return OPScript(s.data(), s.data() + s.size()); +} + +/// Build the single coinbase MutableTransaction paying `coinbasevalue` to an +/// anyone-can-spend (OP_TRUE) output. Consensus-valid for relay; standardness of +/// the coinbase scriptPubKey is not consensus-checked, so OP_TRUE keeps the +/// builder key-free (no wallet, matching the -DBUILD_BITCOIN_WALLET=OFF posture). +inline MutableTransaction build_coinbase_tx(uint32_t height, int64_t coinbasevalue, + uint64_t extranonce) +{ + MutableTransaction tx; + tx.version = 2; + tx.locktime = 0; + + TxIn in; + in.prevout.hash = uint256(); // null prevout + in.prevout.index = 0xffffffffu; + in.scriptSig = build_coinbase_script_sig(height, extranonce); + in.sequence = 0xffffffffu; + tx.vin.push_back(in); + + static const unsigned char kOpTrue[1] = {0x51}; + TxOut out; + out.value = coinbasevalue; + out.scriptPubKey = OPScript(kOpTrue, kOpTrue + 1); + tx.vout.push_back(out); + + return tx; +} + +/// Construct and PoW-solve a solo-coinbase regtest block. `max_tries` bounds the +/// nonce sweep; regtest target is trivially large so this returns on the first +/// (or near-first) nonce -- a non-solve means a misconfigured nBits, surfaced via +/// BuiltBlock::solved=false rather than a silent zero-block. +inline BuiltBlock build_and_solve(const uint256& prev_block_hash, uint32_t bits, + int32_t version, uint32_t timestamp, uint32_t height, + int64_t coinbasevalue, uint64_t extranonce = 0, + uint32_t max_tries = 0xffffffffu) +{ + BuiltBlock r; + + MutableTransaction coinbase = build_coinbase_tx(height, coinbasevalue, extranonce); + const uint256 coinbase_txid = Hash(pack(coinbase).get_span()); + r.merkle_root = compute_merkle_root({coinbase_txid}); // solo coinbase -> root == txid + + BlockHeaderType hdr; + hdr.m_version = version; + hdr.m_previous_block = prev_block_hash; + hdr.m_merkle_root = r.merkle_root; + hdr.m_timestamp = timestamp; + hdr.m_bits = bits; + + const uint256 target = target_from_bits(bits); + for (uint64_t n = 0; n <= max_tries; ++n) { + hdr.m_nonce = static_cast(n); + const uint256 h = sha256d_hash(hdr); + if (h <= target) { + r.solved = true; + r.nonce = hdr.m_nonce; + r.hash = h; + break; + } + } + if (!r.solved) + return r; // caller checks .solved; bits almost certainly misconfigured + + BlockType block; + block.m_version = version; + block.m_previous_block = prev_block_hash; + block.m_merkle_root = r.merkle_root; + block.m_timestamp = timestamp; + block.m_bits = bits; + block.m_nonce = r.nonce; + block.m_txs.push_back(coinbase); + + auto packed = pack(block); + auto span = packed.get_span(); + r.bytes.assign(reinterpret_cast(span.data()), + reinterpret_cast(span.data()) + span.size()); + r.hex = HexStr(span); + return r; +} + +/// Build + solve from a BCHN getblocktemplate result. Reads previousblockhash, +/// bits, height, curtime, version, coinbasevalue. Solo-coinbase: any template +/// `transactions` are intentionally NOT included -- valid only against a node +/// whose mempool we control (the self-provisioned regtest node), which is exactly +/// the leg-C host. Throws if a required field is missing. +inline BuiltBlock build_from_gbt(const nlohmann::json& tmpl, uint64_t extranonce = 0) +{ + if (!tmpl.contains("previousblockhash") || !tmpl.contains("bits") || + !tmpl.contains("height") || !tmpl.contains("coinbasevalue")) + throw std::invalid_argument("regtest::build_from_gbt: incomplete getblocktemplate"); + + const uint256 prev = uint256S(tmpl.at("previousblockhash").get()); + const uint32_t bits = + static_cast(std::stoul(tmpl.at("bits").get(), nullptr, 16)); + const uint32_t height = static_cast(tmpl.at("height").get()); + const int64_t value = tmpl.at("coinbasevalue").get(); + const int32_t version = + tmpl.contains("version") ? tmpl.at("version").get() : 0x20000000; + const uint32_t curtime = + tmpl.contains("curtime") ? tmpl.at("curtime").get() : 0u; + + return build_and_solve(prev, bits, version, curtime, height, value, extranonce); +} + +} // namespace regtest +} // namespace coin +} // namespace bch diff --git a/src/impl/bch/node.hpp b/src/impl/bch/node.hpp index 52d9d626b..ce48ead42 100644 --- a/src/impl/bch/node.hpp +++ b/src/impl/bch/node.hpp @@ -24,6 +24,7 @@ #include "share_tracker.hpp" #include "peer.hpp" #include "messages.hpp" +#include // SSOT: core::version_gate::is_v36_active #include #include @@ -40,6 +41,7 @@ #include #include #include +#include namespace bch { @@ -70,6 +72,18 @@ class NodeImpl : public pool::BaseNode // Protocol handlers look up tx hashes here when processing shares. std::map m_known_txs; + // -- Won-block broadcaster sink (broadcaster-gate A+B in-operation fire) -- + // The pool node stays coin-daemon-agnostic: the binary entrypoint wires this + // to bch::coin::EmbeddedDaemon::broadcast_won_block (dual path: embedded P2P + // PRIMARY + external BCHN submitblock FALLBACK). m_on_block_found fires it + // the instant a verified share meets the BCH block target -- the live sink + // the dispatcher (90a35536) was waiting on. No p2pool-v36 surface (block + // dispatch, not share/PPLNS/coinbase bytes). + using BlockBroadcastFn = + std::function& /*block_bytes*/, + const std::string& /*block_hex*/)>; + BlockBroadcastFn m_block_broadcaster; + // Thread pool for parallel share_init_verify (SHA256d CPU work). // Keeps expensive crypto off the io_context thread. boost::asio::thread_pool m_verify_pool{4}; @@ -173,6 +187,157 @@ class NodeImpl : public pool::BaseNode : m_share_getter(nullptr, [](uint256, peer_ptr, std::vector, uint64_t, std::vector){}) {} + // Reconstruct the full BCH block (header + [coinbase] + other_txs) for + // a won share -- p2pool data.py as_block(tracker, known_txs). Declared + // before the ctor so the m_on_block_found lambda can call it. Gathers + // other_txs from m_known_txs via the share transaction_hash_refs over + // parent shares (data.py get_other_tx_hashes / _get_other_txs); returns + // nullopt when not all parent txs are present (== as_block None). + // NOTE: coinbase (gentx) full-body reconstruction (GenerateShareTransaction, + // share_check.hpp:472) is the remaining dependency before this emits a + // complete block; until it lands this returns nullopt -- no partial/ + // invalid block is ever broadcast. The other_tx gather seam is here. + std::optional, std::string>> + reconstruct_won_block(const uint256& share_hash) + { + // p2pool data.py Share.as_block(tracker, known_txs): a won block = + // header + [gentx] + other_txs, where other_txs resolve from known_txs + // and gentx (coinbase) is the reconstructed GenerateShareTransaction. + auto& chain = m_tracker.chain; + if (!chain.contains(share_hash)) { + LOG_WARNING << "[BCH-POOL] reconstruct: won share " + << share_hash.GetHex().substr(0, 16) + << " absent from sharechain -- cannot build block"; + return std::nullopt; + } + + // --- other_txs (p2pool data.py get_other_tx_hashes / _get_other_txs) --- + // The won block's "other txs" are the FULL ordered list named by this + // share's transaction_hash_refs: each (share_count, tx_count) indexes + // new_transaction_hashes[tx_count] of the ancestor share_count steps back + // (0 == this share). We resolve every ref to a tx hash, then every hash to + // a full tx in m_known_txs. If ANY ancestor share or tx body is absent we + // return nullopt (== as_block None) and NEVER relay a partial block. + // Strict equality vs the oracle ref-chain walk -- no best-effort assembly. + std::vector refs; + chain.get_share(share_hash).invoke([&](auto* s) { + if constexpr (requires { s->m_tx_info; }) + refs = s->m_tx_info.m_transaction_hash_refs; + }); + + // Walk the ancestor chain (via index->tail) to the deepest referenced + // share_count. contains() guards every hop so get_index() never throws. + uint64_t max_back = 0; + for (const auto& r : refs) + max_back = std::max(max_back, r.m_share_count); + std::vector chain_hashes; + chain_hashes.reserve(max_back + 1); + { + uint256 cur = share_hash; + for (uint64_t i = 0; i <= max_back; ++i) { + if (cur.IsNull() || !chain.contains(cur)) break; + chain_hashes.push_back(cur); + auto* idx = chain.get_index(cur); + cur = idx ? idx->tail : uint256(); + } + } + if (chain_hashes.size() <= max_back) { + LOG_INFO << "[BCH-POOL] reconstruct: share " + << share_hash.GetHex().substr(0, 16) + << " ancestor depth " << chain_hashes.size() << "/" + << (max_back + 1) + << " not present -> as_block None (nullopt)"; + return std::nullopt; + } + + // Resolve each ref -> tx hash -> full tx body. + std::vector other_txs; + other_txs.reserve(refs.size()); + for (const auto& r : refs) { + uint256 th; + bool got = false; + chain.get_share(chain_hashes[r.m_share_count]).invoke([&](auto* s) { + if constexpr (requires { s->m_tx_info; }) { + const auto& nh = s->m_tx_info.m_new_transaction_hashes; + if (r.m_tx_count < nh.size()) { th = nh[r.m_tx_count]; got = true; } + } + }); + if (!got) { + LOG_WARNING << "[BCH-POOL] reconstruct: tx_hash_ref (" + << r.m_share_count << "," << r.m_tx_count + << ") out of range -> nullopt"; + return std::nullopt; + } + auto it = m_known_txs.find(th); + if (it == m_known_txs.end()) { + LOG_INFO << "[BCH-POOL] reconstruct: other tx " + << th.GetHex().substr(0, 16) + << " not yet known -> as_block None (nullopt)"; + return std::nullopt; + } + other_txs.push_back(&it->second); + } + + // --- coinbase (gentx) body + 80-byte header --- + // Reuse the EXACT serialized coinbase buffer the txid was hashed from + // (out_gentx_bytes), so the relayed coinbase is byte-identical to what + // consensus + the sharechain validated -- no second derivation path, no + // divergence risk. merkle_root via check_merkle_link (BCH has no segwit, + // so always the coinbase txid merkle link). Header layout mirrors + // share_check.hpp:695-705 exactly. + std::vector gentx_bytes; + PackStream header_stream; + bool built = false; + chain.get_share(share_hash).invoke([&](auto* s) { + using ST = std::remove_pointer_t; + constexpr int64_t ver = ST::version; + const bool v36 = core::version_gate::is_v36_active(ver); + const uint256 gentx_hash = + generate_share_transaction(*s, m_tracker, false, v36, &gentx_bytes); + const uint256 merkle_root = + check_merkle_link(gentx_hash, s->m_merkle_link); + uint32_t hdr_version = static_cast(s->m_min_header.m_version); + header_stream << hdr_version; + header_stream << s->m_min_header.m_previous_block; + header_stream << merkle_root; + header_stream << s->m_min_header.m_timestamp; + header_stream << s->m_min_header.m_bits; + header_stream << s->m_min_header.m_nonce; + built = true; + }); + if (!built || gentx_bytes.empty() || header_stream.size() != 80) { + LOG_WARNING << "[BCH-POOL] reconstruct: gentx/header build failed (built=" + << built << " gentx=" << gentx_bytes.size() + << " hdr=" << header_stream.size() << ") -> nullopt"; + return std::nullopt; + } + + // --- serialize full block (Bitcoin wire) --- + // header || CompactSize(1 + other_txs) || coinbase_bytes || other txs. + PackStream block_stream; + block_stream.write(header_stream.get_span()); + WriteCompactSize(block_stream, + static_cast(1 + other_txs.size())); + block_stream.write(std::as_bytes(std::span( + gentx_bytes.data(), gentx_bytes.size()))); + for (const auto* tx : other_txs) + block_stream << *tx; + + auto block_span = block_stream.get_span(); + std::vector block_bytes( + reinterpret_cast(block_span.data()), + reinterpret_cast(block_span.data()) + + block_span.size()); + std::string block_hex = HexStr(block_span); + + LOG_INFO << "[BCH-POOL] reconstruct: share " + << share_hash.GetHex().substr(0, 16) + << " -> full block " << block_bytes.size() << " bytes, " + << (1 + other_txs.size()) << " txs (1 coinbase + " + << other_txs.size() << " other)"; + return std::make_pair(std::move(block_bytes), std::move(block_hex)); + } + NodeImpl(boost::asio::io_context* ctx, config_t* config) : base_t(ctx, config), m_share_getter(ctx, @@ -211,6 +376,31 @@ class NodeImpl : public pool::BaseNode m_tracker.chain.on_removed([this](const uint256& hash) { m_removal_flush_buf.push_back(hash); }); + + // Wire the in-operation won-block fire path (broadcaster-gate A+B). + // A verified share that meets the BCH block target -> reconstruct the + // full block -> fire BOTH broadcast paths via the entrypoint-supplied + // sink. Mirrors the m_on_share_verified wiring above; the sink itself + // is set by the binary entrypoint via set_block_broadcaster(). + m_tracker.m_on_block_found = [this](const uint256& share_hash) { + if (!m_block_broadcaster) { + LOG_WARNING << "[BCH-POOL] won block on share " + << share_hash.GetHex().substr(0, 16) + << " but NO broadcaster sink wired -- block NOT relayed"; + return; + } + auto blk = reconstruct_won_block(share_hash); + if (!blk) { + LOG_WARNING << "[BCH-POOL] won block on share " + << share_hash.GetHex().substr(0, 16) + << " not yet broadcastable (full block not reconstructable)"; + return; + } + LOG_INFO << "[BCH-POOL] won block on share " + << share_hash.GetHex().substr(0, 16) + << " -- firing dual-path broadcast (" << blk->first.size() << " bytes)"; + m_block_broadcaster(blk->first, blk->second); + }; } // INetwork: Pool node does not initiate disconnect — peer connections @@ -235,6 +425,19 @@ class NodeImpl : public pool::BaseNode /// IO-thread code MUST use read_tracker() instead. ShareTracker& tracker() { return m_tracker; } + /// Wire the won-block broadcaster sink (binary entrypoint -> + /// EmbeddedDaemon::broadcast_won_block). Call once at startup before + /// run(); idempotent replace. Pool-entrypoint leg of broadcaster-gate + /// criterion C: gives tracker().m_on_block_found a live sink so an + /// in-operation win emits down both paths (P2P + external submitblock). + void set_block_broadcaster(BlockBroadcastFn fn) { m_block_broadcaster = std::move(fn); } + + /// True once a won-block sink is wired (set_block_broadcaster called). Lets + /// the binary entrypoint assert the broadcaster-gate criterion-C sink is + /// LIVE at standup before the run-loop, and is the structural half of the + /// dual-path evidence (the live VM300 e2e is the behavioural half). + bool has_block_broadcaster() const { return static_cast(m_block_broadcaster); } + /// RAII guard for IO-thread tracker reads. /// - IO thread: acquires shared_lock(try_to_lock). Returns falsy if busy. /// - Compute thread: skips locking (exclusive already held). Always truthy. diff --git a/src/impl/bch/pool_entrypoint.hpp b/src/impl/bch/pool_entrypoint.hpp new file mode 100644 index 000000000..4ce597f81 --- /dev/null +++ b/src/impl/bch/pool_entrypoint.hpp @@ -0,0 +1,90 @@ +#pragma once + +// BCH pool run-loop ENTRYPOINT standup -- broadcaster-gate leg C, pool side +// (integrator 2026-06-18: "NodeImpl + EmbeddedDaemon constructed, +// bch::wire_won_block_sink called live"). +// +// This is the single production call site the c2pool-bch binary main invokes to +// bring the pool node up WITH its embedded coin daemon and the won-block sink +// already bound. It owns both objects for the lifetime of the run-loop (they +// outlive io_context.run(), so the wire_won_block_sink lambda -- which captures +// the daemon by reference -- never dangles) and enforces the structural half of +// broadcaster-gate criterion C: the won-block sink MUST be live before the +// 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. +// +// 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() + +// ABLA loop + operator-approved cold-start anchor pin. Embedded-primary +// work source live, external fallback retained (external_fallback law). +// 3. NodeImpl(ctx, config) -- the pool node (sharechain, storage, P2P). +// 4. wire_won_block_sink(node, daemon) -- bind tracker().m_on_block_found -> +// daemon.broadcast_won_block (dual path: embedded P2P PRIMARY + external +// BCHN submitblock FALLBACK). +// 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). +// +// 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). +// +// PER-COIN ISOLATION: src/impl/bch only. p2pool-merged-v36 surface: NONE +// (block dispatch + run-loop bring-up, not share/PPLNS/coinbase/AuxPoW bytes). +// BCH = SHA256d standalone parent. + +#include "node.hpp" +#include "pool_standup.hpp" +#include "coin/embedded_daemon.hpp" + +#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. +// `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. +inline void standup_pool_run(boost::asio::io_context& ioc, + Config& config, + uint32_t anchor_height) +{ + // 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); + daemon.run(); + + // 3: the pool node (sharechain, LevelDB, P2P, Stratum). + Node node(&ioc, &config); // concrete NodeBridge -- NodeImpl alone is abstract (ICommunicator::handle lives in NodeBridge) + + // 4: bind the in-operation won-block fire path to the dual-path broadcaster. + wire_won_block_sink(node, daemon); + + // 5: structural criterion-C gate -- refuse to run shares without a live sink. + if (!node.has_block_broadcaster()) { + LOG_FATAL << "[BCH-POOL] won-block sink NOT live after wire_won_block_sink" + << " -- refusing to start run-loop (a won block would be dropped)."; + throw std::runtime_error("bch::standup_pool_run: won-block broadcaster sink not wired"); + } + + LOG_INFO << "[BCH-POOL] pool run-loop standup complete: embedded daemon up," + << " won-block sink LIVE (dual-path: embedded P2P primary + BCHN" + << " 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. + ioc.run(); +} + +} // namespace bch diff --git a/src/impl/bch/pool_standup.hpp b/src/impl/bch/pool_standup.hpp new file mode 100644 index 000000000..4b3039c7a --- /dev/null +++ b/src/impl/bch/pool_standup.hpp @@ -0,0 +1,46 @@ +#pragma once + +// BCH pool-node won-block sink standup -- broadcaster-gate leg C, pool-entrypoint +// (integrator 2026-06-18, entrypoint-standup slice, ordered BEFORE gentx). +// +// THE single startup wiring call. The pool node (NodeImpl) stays coin-daemon- +// agnostic: its tracker().m_on_block_found lambda fires the instant a verified +// share meets the BCH block target, but only down a sink the binary entrypoint +// supplies via set_block_broadcaster(). This helper IS that one call -- it binds +// the sink to the embedded daemon dual-path broadcast_won_block (embedded P2P +// PRIMARY + external BCHN submitblock FALLBACK, both fired per the dual-path +// gate rule). Construct NodeImpl + EmbeddedDaemon, call this once before the +// run-loop; idempotent (set_block_broadcaster replaces). +// +// The sink being live is NECESSARY but NOT SUFFICIENT to close the gate to +// verified: reconstruct_won_block() still returns nullopt until gentx (coinbase) +// full-body reconstruction lands (share_check.hpp:472) and leg C is exercised +// against VM300 (192.168.86.110:8333). Until then the lambda has a live sink yet +// emits nothing -- the gate stays honestly GAP and no partial block is relayed. +// +// PER-COIN ISOLATION: src/impl/bch only. Zero p2pool-merged-v36 surface -- block +// dispatch, NOT share/PPLNS/coinbase bytes. BCH = SHA256d standalone parent. + +#include "node.hpp" +#include "coin/embedded_daemon.hpp" + +#include +#include + +namespace bch +{ + +// Bind the pool node won-block sink to the embedded daemon dual-path broadcaster. +// Capture by reference: both objects are owned by the binary entrypoint and +// outlive the pool run-loop, so the lambda never dangles. Call once at startup. +inline void wire_won_block_sink(NodeImpl& node, coin::EmbeddedDaemon& daemon) +{ + node.set_block_broadcaster( + [&daemon](const std::vector& block_bytes, + const std::string& block_hex) + { + daemon.broadcast_won_block(block_bytes, block_hex); + }); +} + +} // namespace bch diff --git a/src/impl/bch/protocol_actual.cpp b/src/impl/bch/protocol_actual.cpp new file mode 100644 index 000000000..cd70fa3d6 --- /dev/null +++ b/src/impl/bch/protocol_actual.cpp @@ -0,0 +1,323 @@ +// BCH actual-protocol dispatch router + HANDLER cluster (broadcaster-gate / +// pool p2p ingress). +// +// Mirror of btc::Actual (oracle protocol_actual.cpp), namespace-swapped +// btc -> bch. Conformance anchor: p2poolBCH @6603b79 + btc oracle. +// +// The Legacy vs Actual split is the WIRE-PROTOCOL GENERATION, not the dispatch +// shape: the router body is byte-identical to Legacy, but the HANDLER bodies +// carry the genuine Actual-generation differences the reviewer diffs against +// the oracle -- they are NOT a qualifier-swap of bch::Legacy: +// * addrme self-probe is 127.0.0.1 (Legacy: 127.0.0.0), and the relay +// guard is !m_peers.empty() (Legacy: m_peers.empty()). +// * shares preserves the original raw bytes (chain::RawShare raw_copy) +// before deserialization consumes them and feeds the 3-arg +// result.add(share, txs, raw_copy) (Legacy: 2-arg add). +// These mirror btc::Actual exactly; only the two STANDING BCH divergences are +// applied at the type seam (already established in messages.hpp / peer.hpp, +// NOT new policy): +// 1. tx-info presence probed via the requires{} idiom (BCH), NOT btc's +// version range. Version-agnostic, segwit-free. +// 2. remember_tx hashes via plain pack(tx) -- BCH has NO witness, so no +// coin::TX_WITH_WITNESS wrapper. wtxid == txid on BCH; hash unchanged. +// +// p2pool-merged-v36 surface: NONE. Pure P2P transport/dispatch (share ingest +// to processing_shares, sharereq to handle_get_share serve, tx-relay book- +// keeping, peer-book gossip). Share-format / PPLNS / coinbase / AuxPoW bytes +// are untouched. STOP-and-flag fence held -- no oracle byte diverges here. +#include "node.hpp" +#include "share.hpp" + +#include +#include +#include + +#include +#include +#include + +namespace bch +{ + +void Actual::handle_message(std::unique_ptr rmsg, peer_ptr peer) +{ + bch::Handler::result_t result; + try + { + result = m_handler.parse(rmsg); + } catch (const std::exception& ec) + { + LOG_WARNING << "Failed to parse message '" << rmsg->m_command << "' from " + << peer->addr().to_string() << ": " << ec.what(); + return; + } + + try + { + std::visit([&](auto& msg){ handle(std::move(msg), peer); }, result); + } + catch (const std::exception& ec) + { + LOG_WARNING << "Handler error for '" << rmsg->m_command << "' from " + << peer->addr().to_string() << ": " << ec.what(); + return; + } +} + +// --------------------------------------------------------------------------- +// Peer-book HANDLER cluster (addrs / addrme / ping / getaddrs). +// Mechanical mirror of btc::Actual peer-book handlers; addrme carries the +// Actual-generation 127.0.0.1 / !m_peers.empty() form. Zero v36 surface. +// --------------------------------------------------------------------------- + +void Actual::HANDLER(addrs) +{ + for (auto& addr : msg->m_addrs) + { + addr.m_timestamp = std::min((uint64_t) core::timestamp(), addr.m_timestamp); + got_addr(addr.m_endpoint, addr.m_services, addr.m_timestamp); + + if ((core::random::random_float(0, 1) < 0.8) && (!m_connections.empty())) + { + auto wpeer = core::random::random_choice(m_connections); + auto rmsg = bch::message_addrs::make_raw({addr}); + wpeer->write(std::move(rmsg)); + } + } +} + +void Actual::HANDLER(addrme) +{ + if (peer->addr().address() == "127.0.0.1") + { + if (!m_peers.empty() && (core::random::random_float(0, 1) < 0.8)) + { + auto random_peer = core::random::random_choice(m_peers); + auto rmsg = bch::message_addrme::make_raw(msg->m_port); + random_peer->write(std::move(rmsg)); + } + } else + { + auto endpoint = NetService{peer->addr().address(), msg->m_port}; + got_addr(endpoint, peer->m_other_services, core::timestamp()); + if (!m_peers.empty() && (core::random::random_float(0, 1) < 0.8)) + { + auto random_peer = core::random::random_choice(m_peers); + auto rmsg = bch::message_addrs::make_raw({addr_record_t{peer->m_other_services, endpoint, core::timestamp()} }); + random_peer->write(std::move(rmsg)); + } + } +} + +void Actual::HANDLER(ping) +{ +} + +void Actual::HANDLER(getaddrs) +{ + if (msg->m_count > 100) + msg->m_count = 100; + + std::vector addrs; + for (const auto& pair : get_good_peers(msg->m_count)) + { + addrs.push_back({pair.value.m_service, pair.addr, pair.value.m_last_seen}); + } + + auto rmsg = bch::message_addrs::make_raw({addrs}); + peer->write(std::move(rmsg)); +} + +// --------------------------------------------------------------------------- +// Share / tx HANDLER cluster. Mechanical mirror of btc::Actual share/tx +// handlers; shares preserves raw_copy + 3-arg add (Actual generation), and the +// two standing BCH divergences (requires{} tx-info probe; witness-free +// remember_tx) are applied at the type seam. Zero v36 surface. +// --------------------------------------------------------------------------- + +void Actual::HANDLER(shares) +{ + bch::HandleSharesData result; + + for (auto wrappedshare : msg->m_shares) + { + // Save a copy of the original raw bytes before deserialization consumes them + chain::RawShare raw_copy(wrappedshare.type, + BaseScript(wrappedshare.contents.m_data)); + + bch::ShareType share; + try + { + share = bch::load_share(wrappedshare, peer->addr()); + } + catch(const std::exception& e) + { + LOG_WARNING << "Failed to load share (type=" << wrappedshare.type + << ") from " << peer->addr().to_string() << ": " << e.what(); + continue; + } + + std::vector txs; + share.ACTION + ({ + if constexpr (requires { obj->m_tx_info; }) + for (auto tx_hash : obj->m_tx_info.m_new_transaction_hashes) + { + auto it = m_known_txs.find(tx_hash); + if (it != m_known_txs.end()) + { + txs.emplace_back(it->second); + } + else + { + LOG_WARNING << "Peer referenced unknown transaction " << tx_hash.ToString(); + } + } + }); + + result.add(share, txs, raw_copy); + } + + processing_shares(result, peer->addr()); +} + +void Actual::HANDLER(sharereq) +{ + auto shares = handle_get_share(msg->m_hashes, msg->m_parents, msg->m_stops, peer->addr()); + + std::vector rshares; + + try + { + for (auto& share : shares) + { + rshares.emplace_back(share.version(), pack(share)); + } + auto reply_msg = bch::message_sharereply::make_raw(msg->m_id, bch::ShareReplyResult::good, rshares); + peer->write(std::move(reply_msg)); + } + catch (const std::invalid_argument &e) + { + // Serialization overflow: packed shares exceeded the P2P message size + // limit. Reply too_long so the peer requests a smaller batch. + LOG_WARNING << "Share reply too large, sending too_long: " << e.what(); + auto reply_msg = bch::message_sharereply::make_raw(msg->m_id, bch::ShareReplyResult::too_long, {}); + peer->write(std::move(reply_msg)); + } +} + +void Actual::HANDLER(sharereply) +{ + bch::ShareReplyData result; + if (msg->m_result == ShareReplyResult::good) + { + result.m_items.reserve(msg->m_shares.size()); + result.m_raw_items.reserve(msg->m_shares.size()); + for (auto& rshare : msg->m_shares) + { + try + { + auto share = bch::load_share(rshare, peer->addr()); + result.m_items.push_back(share); + result.m_raw_items.push_back(rshare); + } + catch(const std::exception& e) + { + LOG_WARNING << "Failed to deserialize share (type=" << rshare.type + << ") from " << peer->addr().to_string() << ": " << e.what(); + continue; + } + } + } + got_share_reply(msg->m_id, result); +} + +void Actual::HANDLER(bestblock) +{ + auto header_hash = Hash(pack(msg->m_header).get_span()); + LOG_INFO << "[Pool] New best block from peer " << peer->addr().to_string() + << ": " << header_hash.ToString(); + + // p2pool does NOT relay bestblock -- each node broadcasts only from its own + // block source. Relaying creates an A->B->C->A amplification loop. + if (m_on_bestblock) m_on_bestblock(header_hash); +} + +void Actual::HANDLER(have_tx) +{ + peer->m_remote_txs.insert(msg->m_tx_hashes.begin(), msg->m_tx_hashes.end()); + if (peer->m_remote_txs.size() > 10000) + { + peer->m_remote_txs.erase(peer->m_remote_txs.begin(), + std::next(peer->m_remote_txs.begin(), + peer->m_remote_txs.size() - 10000)); + } +} + +void Actual::HANDLER(losing_tx) +{ + // Remove every msg tx hash from the peer's advertised-tx set. + std::set losing_txs; + losing_txs.insert(msg->m_tx_hashes.begin(), msg->m_tx_hashes.end()); + + std::set diff_txs; + std::set_difference(peer->m_remote_txs.begin(), peer->m_remote_txs.end(), + losing_txs.begin(), losing_txs.end(), + std::inserter(diff_txs, diff_txs.begin())); + + peer->m_remote_txs = diff_txs; +} + +void Actual::HANDLER(remember_tx) +{ + // Phase 1: tx_hashes -- peer references txs to remember by hash (must be + // present in m_known_txs already). + for (auto tx_hash : msg->m_tx_hashes) + { + if (peer->m_remembered_txs.contains(tx_hash)) + { + LOG_WARNING << "Peer referenced transaction twice: " << tx_hash.ToString(); + continue; + } + + auto it = m_known_txs.find(tx_hash); + if (it != m_known_txs.end()) + { + peer->m_remembered_txs.insert_or_assign(tx_hash, it->second); + } + else + { + LOG_WARNING << "Peer referenced unknown transaction " << tx_hash.ToString(); + } + } + + // Phase 2: txs -- peer sends full transaction bodies; compute hash + store. + // BCH divergence: plain pack(tx) -- no TX_WITH_WITNESS (no witness on BCH). + for (auto& tx : msg->m_txs) + { + auto packed = pack(tx); + auto tx_hash = Hash(packed.get_span()); + + if (peer->m_remembered_txs.contains(tx_hash)) + { + LOG_WARNING << "Peer sent duplicate transaction: " << tx_hash.ToString(); + continue; + } + + coin::Transaction full_tx(tx); + peer->m_remembered_txs.insert_or_assign(tx_hash, full_tx); + + if (!m_known_txs.contains(tx_hash)) + m_known_txs.emplace(tx_hash, std::move(full_tx)); + } +} + +void Actual::HANDLER(forget_tx) +{ + for (auto tx_hash : msg->m_tx_hashes) + { + peer->m_remembered_txs.erase(tx_hash); + } +} + +} // namespace bch diff --git a/src/impl/bch/protocol_legacy.cpp b/src/impl/bch/protocol_legacy.cpp new file mode 100644 index 000000000..1b215895d --- /dev/null +++ b/src/impl/bch/protocol_legacy.cpp @@ -0,0 +1,340 @@ +// BCH legacy-protocol dispatch router (broadcaster-gate / pool p2p ingress). +// +// Mirror of btc::Legacy::handle_message with the namespace swapped to bch. +// Conformance anchor: p2poolBCH @6603b79 + btc oracle protocol_legacy.cpp. +// Routing contract (matches oracle exactly, the thing reviewers diff): +// parse RawMessage via m_handler -> std::visit-dispatch to the typed +// handle(msg, peer) HANDLER overload for the command. Unknown/over-old +// peers are gated upstream by NodeBridge via handle_version (require-version +// before any command routes here). Parse and handler faults are logged and +// swallowed per-message — never tear down the peer loop on one bad frame. +// +// Router skeleton (handle_message) + peer-book HANDLER cluster +// (addrs/addrme/ping/getaddrs) land here; share/tx HANDLER bodies +// (shares/sharereq/...) follow in later slices. +#include "node.hpp" +#include "share.hpp" + +#include +#include +#include + +#include +#include +#include + +namespace bch +{ + +void Legacy::handle_message(std::unique_ptr rmsg, peer_ptr peer) +{ + bch::Handler::result_t result; + try + { + result = m_handler.parse(rmsg); + } catch (const std::exception& ec) + { + LOG_WARNING << "Failed to parse message '" << rmsg->m_command << "' from " + << peer->addr().to_string() << ": " << ec.what(); + return; + } + + try + { + std::visit([&](auto& msg){ handle(std::move(msg), peer); }, result); + } + catch (const std::exception& ec) + { + LOG_WARNING << "Handler error for '" << rmsg->m_command << "' from " + << peer->addr().to_string() << ": " << ec.what(); + return; + } +} + +// --------------------------------------------------------------------------- +// Peer-book HANDLER cluster (addrs / addrme / ping / getaddrs). +// +// Mechanical mirror of btc::Legacy peer-book handlers (oracle protocol_legacy +// .cpp), namespace-swapped btc -> bch. Pure pool-p2p address-book maintenance: +// gossip-relay learned peers, answer getaddrs from get_good_peers, no-op ping. +// p2pool-merged-v36 surface: NONE (no share / PPLNS / coinbase / AuxPoW bytes; +// peer discovery only). Share/tx HANDLER bodies (shares/sharereq/...) follow in +// later slices; routed via the same std::visit dispatcher in handle_message(). +// --------------------------------------------------------------------------- + +void Legacy::HANDLER(addrs) +{ + for (auto& addr : msg->m_addrs) + { + addr.m_timestamp = std::min((uint64_t) core::timestamp(), addr.m_timestamp); + got_addr(addr.m_endpoint, addr.m_services, addr.m_timestamp); + + if ((core::random::random_float(0, 1) < 0.8) && (!m_connections.empty())) + { + auto wpeer = core::random::random_choice(m_connections); + auto rmsg = bch::message_addrs::make_raw({addr}); + wpeer->write(std::move(rmsg)); + } + } +} + +void Legacy::HANDLER(addrme) +{ + if (peer->addr().address() == "127.0.0.0") + { + if (m_peers.empty() && (core::random::random_float(0, 1) < 0.8)) + { + auto random_peer = core::random::random_choice(m_peers); + auto rmsg = bch::message_addrme::make_raw(msg->m_port); + random_peer->write(std::move(rmsg)); + } + } else + { + auto endpoint = NetService{peer->addr().address(), msg->m_port}; + got_addr(endpoint, peer->m_other_services, core::timestamp()); + if (m_peers.empty() && (core::random::random_float(0, 1) < 0.8)) + { + auto random_peer = core::random::random_choice(m_peers); + auto rmsg = bch::message_addrs::make_raw({addr_record_t{peer->m_other_services, endpoint, core::timestamp()} }); + random_peer->write(std::move(rmsg)); + } + } +} + +void Legacy::HANDLER(ping) +{ +} + +void Legacy::HANDLER(getaddrs) +{ + if (msg->m_count > 100) + msg->m_count = 100; + + std::vector addrs; + for (const auto& pair : get_good_peers(msg->m_count)) + { + addrs.push_back({pair.value.m_service, pair.addr, pair.value.m_last_seen}); + } + + auto rmsg = bch::message_addrs::make_raw({addrs}); + peer->write(std::move(rmsg)); +} + + +// --------------------------------------------------------------------------- +// Share / tx HANDLER cluster (shares / sharereq / sharereply / bestblock / +// have_tx / losing_tx / remember_tx / forget_tx). +// +// Mechanical mirror of btc::Legacy share/tx handlers (oracle protocol_legacy +// .cpp), namespace-swapped btc -> bch, with the two STANDING BCH divergences +// applied at the type seam (not new policy -- both already established in +// messages.hpp / peer.hpp / node.hpp): +// 1. tx-info presence is probed via `requires { obj->m_tx_info; }` (the BCH +// idiom already used by node.hpp reconstruct), NOT btc's hard-coded +// `version >= 13 && version < 34` range. BCH shares carry m_tx_info by +// type; the SFINAE probe is version-agnostic and segwit-free. +// 2. remember_tx hashes each tx via plain `pack(tx)` -- BCH has NO witness, +// so there is no TX_WITH_WITNESS wrapper (messages.hpp remember_tx field +// + peer.hpp note). On BCH wtxid == txid, so the hash is unchanged. +// Debug-only SHAREREQ/SHAREREPLY trace counters from the oracle are omitted +// (logging scaffolding, zero behavior). +// +// p2pool-merged-v36 surface: NONE. Pure P2P transport/dispatch (share ingest +// -> processing_shares, sharereq -> handle_get_share serve, tx-relay book- +// keeping). Share-format / PPLNS / coinbase / AuxPoW bytes are untouched; +// serialization + PPLNS conformance already landed verified vs p2poolBCH +// @6603b79. STOP-and-flag fence held -- no oracle byte diverges here. +// --------------------------------------------------------------------------- + +void Legacy::HANDLER(shares) +{ + try { + bch::HandleSharesData result; // share, txs + + for (auto wrappedshare : msg->m_shares) + { + bch::ShareType share; + try + { + share = bch::load_share(wrappedshare, peer->addr()); + } + catch(const std::exception& e) + { + LOG_WARNING << "Failed to load share (type=" << wrappedshare.type + << ") from " << peer->addr().to_string() << ": " << e.what(); + continue; + } + + std::vector txs; + share.ACTION + ({ + if constexpr (requires { obj->m_tx_info; }) + for (auto tx_hash : obj->m_tx_info.m_new_transaction_hashes) + { + auto it = m_known_txs.find(tx_hash); + if (it != m_known_txs.end()) + { + txs.emplace_back(it->second); + } + else + { + LOG_WARNING << "Peer referenced unknown transaction " << tx_hash.ToString(); + } + } + }); + + result.add(share, txs); + } + + processing_shares(result, peer->addr()); + } catch (const std::exception& e) { + LOG_ERROR << "[Pool] shares handler exception: " << e.what(); + } +} + +void Legacy::HANDLER(sharereq) +{ + auto shares = handle_get_share(msg->m_hashes, msg->m_parents, msg->m_stops, peer->addr()); + + std::vector rshares; + + try + { + for (auto& share : shares) + { + rshares.emplace_back(share.version(), pack(share)); + } + auto reply_msg = bch::message_sharereply::make_raw(msg->m_id, bch::ShareReplyResult::good, rshares); + peer->write(std::move(reply_msg)); + } + catch (const std::invalid_argument &e) + { + // Serialization overflow: the packed shares exceeded the P2P message + // size limit. Reply too_long so the peer requests a smaller batch + // (matches Python p2pool behavior). + LOG_WARNING << "Share reply too large, sending too_long: " << e.what(); + auto reply_msg = bch::message_sharereply::make_raw(msg->m_id, bch::ShareReplyResult::too_long, {}); + peer->write(std::move(reply_msg)); + } +} + +void Legacy::HANDLER(sharereply) +{ + bch::ShareReplyData result; + if (msg->m_result == ShareReplyResult::good) + { + result.m_items.reserve(msg->m_shares.size()); + result.m_raw_items.reserve(msg->m_shares.size()); + for (auto& rshare : msg->m_shares) + { + try + { + auto share = bch::load_share(rshare, peer->addr()); + result.m_items.push_back(share); + result.m_raw_items.push_back(rshare); + } + catch(const std::exception& e) + { + LOG_WARNING << "Failed to deserialize share (type=" << rshare.type + << ") from " << peer->addr().to_string() << ": " << e.what(); + continue; + } + } + } + // Resolve the async request that originally sent the sharereq + got_share_reply(msg->m_id, result); +} + +void Legacy::HANDLER(bestblock) +{ + try { + auto header_hash = Hash(pack(msg->m_header).get_span()); + LOG_INFO << "[Pool] New best block from peer " << peer->addr().to_string() + << ": " << header_hash.ToString(); + + // p2pool does NOT relay bestblock -- each node broadcasts only from its + // own block source. Relaying creates an A->B->C->A amplification loop. + if (m_on_bestblock) m_on_bestblock(header_hash); + } catch (const std::exception& e) { + LOG_WARNING << "[Pool] bestblock handler exception: " << e.what(); + } +} + +void Legacy::HANDLER(have_tx) +{ + peer->m_remote_txs.insert(msg->m_tx_hashes.begin(), msg->m_tx_hashes.end()); + if (peer->m_remote_txs.size() > 10000) + { + peer->m_remote_txs.erase(peer->m_remote_txs.begin(), + std::next(peer->m_remote_txs.begin(), + peer->m_remote_txs.size() - 10000)); + } +} + +void Legacy::HANDLER(losing_tx) +{ + // Remove every msg tx hash from the peer's advertised-tx set. + std::set losing_txs; + losing_txs.insert(msg->m_tx_hashes.begin(), msg->m_tx_hashes.end()); + + std::set diff_txs; + std::set_difference(peer->m_remote_txs.begin(), peer->m_remote_txs.end(), + losing_txs.begin(), losing_txs.end(), + std::inserter(diff_txs, diff_txs.begin())); + + peer->m_remote_txs = diff_txs; +} + +void Legacy::HANDLER(remember_tx) +{ + // Phase 1: tx_hashes -- peer references txs to remember by hash (must be + // present in m_known_txs already). + for (auto tx_hash : msg->m_tx_hashes) + { + if (peer->m_remembered_txs.contains(tx_hash)) + { + LOG_WARNING << "Peer referenced transaction twice: " << tx_hash.ToString(); + continue; + } + + auto it = m_known_txs.find(tx_hash); + if (it != m_known_txs.end()) + { + peer->m_remembered_txs.insert_or_assign(tx_hash, it->second); + } + else + { + LOG_WARNING << "Peer referenced unknown transaction " << tx_hash.ToString(); + } + } + + // Phase 2: txs -- peer sends full transaction bodies; compute hash + store. + // BCH divergence: plain pack(tx) -- no TX_WITH_WITNESS (no witness on BCH). + for (auto& tx : msg->m_txs) + { + auto packed = pack(tx); + auto tx_hash = Hash(packed.get_span()); + + if (peer->m_remembered_txs.contains(tx_hash)) + { + LOG_WARNING << "Peer sent duplicate transaction: " << tx_hash.ToString(); + continue; + } + + coin::Transaction full_tx(tx); + peer->m_remembered_txs.insert_or_assign(tx_hash, full_tx); + + if (!m_known_txs.contains(tx_hash)) + m_known_txs.emplace(tx_hash, std::move(full_tx)); + } +} + +void Legacy::HANDLER(forget_tx) +{ + for (auto tx_hash : msg->m_tx_hashes) + { + peer->m_remembered_txs.erase(tx_hash); + } +} + +} // namespace bch diff --git a/src/impl/bch/share_check.hpp b/src/impl/bch/share_check.hpp index 949fbf19b..eb46061b2 100644 --- a/src/impl/bch/share_check.hpp +++ b/src/impl/bch/share_check.hpp @@ -929,7 +929,8 @@ inline std::vector get_share_script(const auto* obj) // Reference: frstrtr/p2pool-merged-v36 p2pool/data.py generate_transaction() // ============================================================================ template -uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker, bool dump_diag = false, bool v36_active = false) +uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker, bool dump_diag = false, bool v36_active = false, + std::vector* out_gentx_bytes = nullptr) { auto gst_t0 = std::chrono::steady_clock::now(); constexpr int64_t ver = ShareT::version; @@ -1125,10 +1126,18 @@ uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker, bool return a.first < b.first; // asc by script for tie-breaking }); - // Keep last MAX_OUTPUTS (highest amounts), matching Python's [-4000:] + // Keep last MAX_OUTPUTS (highest amounts), matching Python's [-4000:]. + // Oracle (p2pool data.py) applies [-4000:] over the donation-INCLUSIVE dest + // list; we append the donation output separately below, so reserve one slot + // for it and clamp miners to MAX_OUTPUTS-1. This keeps total payout vouts + // <= 4000, byte-matching oracle. Residual edge: if the donation amount + // ranked below 4000 miner amounts the oracle would trim it instead -- not + // reachable in a BCH PPLNS window (leftover + 0.5% finder fee dominate), but + // documented here rather than relied on silently. (broadcaster-gate clamp) constexpr size_t MAX_OUTPUTS = 4000; - if (payout_outputs.size() > MAX_OUTPUTS) - payout_outputs.erase(payout_outputs.begin(), payout_outputs.end() - MAX_OUTPUTS); + constexpr size_t MAX_MINER_OUTPUTS = MAX_OUTPUTS - 1; // reserve donation slot + if (payout_outputs.size() > MAX_MINER_OUTPUTS) + payout_outputs.erase(payout_outputs.begin(), payout_outputs.end() - MAX_MINER_OUTPUTS); // --- 4. Serialise the coinbase transaction --- // Non-witness serialization (for txid computation): @@ -1371,6 +1380,18 @@ uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker, bool reinterpret_cast(tx.data()), tx.size()); auto txid = Hash(tx_span); + // Expose the exact serialized coinbase (gentx) bytes the txid was hashed + // from. This is the SAME byte buffer that produced the txid -- reusing it + // (rather than re-deriving in a second codepath) guarantees the won-block + // reconstruction emits a coinbase byte-identical to the one consensus and + // the sharechain validated. Coinbase byte-layout conformance vs p2poolBCH + // already adjudicated (no re-derivation = no divergence risk). + if (out_gentx_bytes) { + const unsigned char* p = + reinterpret_cast(tx.data()); + out_gentx_bytes->assign(p, p + tx.size()); + } + // V36 hash_link cross-check: compute prefix hash_link from our coinbase // and compare with the share's stored hash_link. If states differ, // the prefix (outputs/amounts) differs from what p2pool built. @@ -2356,8 +2377,10 @@ uint256 create_local_share_v35( if (a.second != b.second) return a.second < b.second; return a.first < b.first; }); - if (payout_outputs.size() > 4000) - payout_outputs.erase(payout_outputs.begin(), payout_outputs.end() - 4000); + // Reserve one slot for the separately-appended donation output so total + // payout vouts stay <= 4000, matching oracle donation-inclusive [-4000:]. + if (payout_outputs.size() > 3999) + payout_outputs.erase(payout_outputs.begin(), payout_outputs.end() - 3999); PackStream gentx; { uint32_t v = 1; gentx.write(std::span(reinterpret_cast(&v), 4)); } @@ -2910,8 +2933,10 @@ uint256 create_local_share( if (a.second != b.second) return a.second < b.second; return a.first < b.first; }); - if (payout_outputs.size() > 4000) - payout_outputs.erase(payout_outputs.begin(), payout_outputs.end() - 4000); + // Reserve one slot for the separately-appended donation output so total + // payout vouts stay <= 4000, matching oracle donation-inclusive [-4000:]. + if (payout_outputs.size() > 3999) + payout_outputs.erase(payout_outputs.begin(), payout_outputs.end() - 3999); PackStream gentx; { uint32_t v = 1; gentx.write(std::span(reinterpret_cast(&v), 4)); } diff --git a/src/impl/bch/share_tracker.hpp b/src/impl/bch/share_tracker.hpp index 29874817a..70cf5665c 100644 --- a/src/impl/bch/share_tracker.hpp +++ b/src/impl/bch/share_tracker.hpp @@ -395,6 +395,41 @@ class ShareTracker return result; } + // -- Startup block-solution scan (BCH standalone parent) -- + // Walk the verified best chain and fire m_on_block_found for any share whose + // cached pow_hash meets the parent BCH block target. BCH is SHA256d + // standalone (NOT merged-mined), so there is no merged-coinbase leg: this + // mirrors btc share_tracker::scan_chain_for_blocks MINUS that check. O(1) per + // share -- uses the pow_hash cached at init_verify, no SHA256d recompute. + // The m_on_block_found sink is wired by the pool node to BOTH broadcast + // paths: embedded P2P (Node::submit_block_p2p) + external submitblock RPC + // fallback (CoinNode::submit_block_hex). Zero p2pool-v36 surface (block + // detection, not share/PPLNS/coinbase bytes). Returns found-block count. + int scan_chain_for_blocks(const uint256& tip, int max_shares) + { + if (tip.IsNull() || max_shares <= 0) return 0; + int found = 0; + uint256 pos = tip; + for (int i = 0; i < max_shares && !pos.IsNull(); ++i) { + if (!chain.contains(pos)) break; + auto* idx = chain.get_index(pos); + if (!idx) break; + + const uint256 pow = idx->pow_hash; + if (pow.IsNull()) { pos = idx->tail; continue; } + + chain.get_share(pos).invoke([&](auto* obj) { + const uint256 block_target = chain::bits_to_target(obj->m_bits); + if (!block_target.IsNull() && pow <= block_target) { + idx->is_block_solution = true; + if (m_on_block_found) { m_on_block_found(pos); ++found; } + } + }); + pos = idx->tail; + } + return found; + } + // -- Expected payouts from PPLNS weights -- // Uses exact integer arithmetic matching generate_share_transaction(): // V36: amount = (uint288(subsidy) * weight / total_weight).GetLow64() diff --git a/src/impl/bch/test/CMakeLists.txt b/src/impl/bch/test/CMakeLists.txt new file mode 100644 index 000000000..a98eab331 --- /dev/null +++ b/src/impl/bch/test/CMakeLists.txt @@ -0,0 +1,151 @@ +# c2pool-bch coin-module tests (V36). Built when -DCOIN_BCH=ON AND BUILD_TESTING. +# Harness: plain int main() with assert/iostream (CTest treats exit 0 as PASS) — +# matches the existing ABLA seam tests; no GTest dependency. +# +# Link set mirrors src/impl/ltc/test (OBJECT-lib SCC direct-naming, #22/#39): +# `core` is a monolithic OBJECT lib whose objects (stratum_server/web_server) +# reference pool/payout/hashrate/storage symbols, so every consumer must name the +# whole SCC directly. We deliberately link NO coin lib (no bch/ltc/btc) — the ABLA +# tests are header-only over coin/*.hpp + (uint256/leveldb/log), so the +# per-coin isolation invariant stays clean. +if(BUILD_TESTING) + set(BCH_ABLA_TESTS + abla_floor_invariant_test + abla_block_feed_test + abla_block_feed_gap_test + header_sync_test # M5: headers-first IBD continuation policy (pure) + header_sync_progress_test # M5: sync-to-peer-tip convergence + steady-state-follow soak (pure) + block_download_test # M5: windowed headers-first block download (pure) + genesis_conformance_test # M5: genesis-hash pin vs BCHN v29.0.0 chainparams (pure) + abla_growth_soak_test # M5: dynamic ABLA budget-GROWTH proof above 32 MB floor (pure) + coinbase_kat_segwit_predicate_test # KAT: DGB<->BCH coinbase pairing -- gated-segwit-OFF predicate pin (pure) + ) + foreach(t IN LISTS BCH_ABLA_TESTS) + add_executable(bch_${t} ${t}.cpp) + target_include_directories(bch_${t} PRIVATE ${CMAKE_SOURCE_DIR}/src) + target_link_libraries(bch_${t} PRIVATE + core pool sharechain + c2pool_payout c2pool_merged_mining c2pool_hashrate c2pool_storage c2pool_difficulty + btclibs) + add_test(NAME bch_${t} COMMAND bch_${t}) + endforeach() + + # block_connector_test exercises the full-block -> mempool reconciliation + # path, so it constructs MutableTransaction objects whose ctors are out-of-line + # in coin/transaction.cpp. Add that single TU to this test only; the link set + # and header-only-over-coin/*.hpp isolation posture are otherwise identical + # (still no impl_bch coin lib -> per-coin isolation stays clean). + add_executable(bch_block_connector_test + block_connector_test.cpp + ${CMAKE_SOURCE_DIR}/src/impl/bch/coin/transaction.cpp) + target_include_directories(bch_block_connector_test PRIVATE ${CMAKE_SOURCE_DIR}/src) + target_link_libraries(bch_block_connector_test PRIVATE + core pool sharechain + c2pool_payout c2pool_merged_mining c2pool_hashrate c2pool_storage c2pool_difficulty + btclibs) + add_test(NAME bch_block_connector_test COMMAND bch_block_connector_test) + + # block_ordering_test: BIP130 ordering edge cases (slice 2) over the same + # connector seam; same link set + transaction.cpp TU as block_connector_test. + add_executable(bch_block_ordering_test + block_ordering_test.cpp + ${CMAKE_SOURCE_DIR}/src/impl/bch/coin/transaction.cpp) + target_include_directories(bch_block_ordering_test PRIVATE ${CMAKE_SOURCE_DIR}/src) + target_link_libraries(bch_block_ordering_test PRIVATE + core pool sharechain + c2pool_payout c2pool_merged_mining c2pool_hashrate c2pool_storage c2pool_difficulty + btclibs) + add_test(NAME bch_block_ordering_test COMMAND bch_block_ordering_test) + + # utxo_connect_test: M5 (a) -- the now-live UTXO-connect leg. connect_block() + # applies a best-chain block to a wired UTXO view, then revalidate_inputs() + # (Phase 4) sweeps mempool txs whose inputs the connect left unspendable + # (double-spend rejection). Same link set + transaction.cpp TU as the other + # connector tests; still no impl_bch coin lib -> per-coin isolation holds. + add_executable(bch_utxo_connect_test + utxo_connect_test.cpp + ${CMAKE_SOURCE_DIR}/src/impl/bch/coin/transaction.cpp) + target_include_directories(bch_utxo_connect_test PRIVATE ${CMAKE_SOURCE_DIR}/src) + target_link_libraries(bch_utxo_connect_test PRIVATE + core pool sharechain + c2pool_payout c2pool_merged_mining c2pool_hashrate c2pool_storage c2pool_difficulty + btclibs) + add_test(NAME bch_utxo_connect_test COMMAND bch_utxo_connect_test) + + # reorg_connect_test: M5 reorg/disconnect leg -- retained BlockUndo + a + # best-chain branch switch. Disconnects the orphaned branch (restore spent + # inputs, remove created outputs, return its txs to the mempool) and re-applies + # the new branch from the remembered-block ring. Same link set as the other + # connector tests; no impl_bch coin lib -> per-coin isolation holds. + add_executable(bch_reorg_connect_test + reorg_connect_test.cpp + ${CMAKE_SOURCE_DIR}/src/impl/bch/coin/transaction.cpp) + target_include_directories(bch_reorg_connect_test PRIVATE ${CMAKE_SOURCE_DIR}/src) + target_link_libraries(bch_reorg_connect_test PRIVATE + core pool sharechain + c2pool_payout c2pool_merged_mining c2pool_hashrate c2pool_storage c2pool_difficulty + btclibs) + add_test(NAME bch_reorg_connect_test COMMAND bch_reorg_connect_test) + + # compact_block_connector_test: M5 (b) -- BIP152 compact-block depth on the + # connector seam. on_compact_block reconstructs from prefilled + mempool; a + # missing tx drives a getblocktxn round that on_block_txn closes out before + # the normal block-connect/mempool reconciliation. Uses btclibs for SipHash; + # same link set + transaction.cpp TU; no impl_bch coin lib -> isolation holds. + add_executable(bch_compact_block_connector_test + compact_block_connector_test.cpp + ${CMAKE_SOURCE_DIR}/src/impl/bch/coin/transaction.cpp) + target_include_directories(bch_compact_block_connector_test PRIVATE ${CMAKE_SOURCE_DIR}/src) + target_link_libraries(bch_compact_block_connector_test PRIVATE + core pool sharechain + c2pool_payout c2pool_merged_mining c2pool_hashrate c2pool_storage c2pool_difficulty + btclibs) + add_test(NAME bch_compact_block_connector_test COMMAND bch_compact_block_connector_test) + + # embedded_daemon_assembly_test: network-free assembly of the full + # EmbeddedDaemon graph (Node + EmbeddedCoinNode + AblaRuntime + CoinNode + # seam) plus the operator-approved cold-start anchor LIVE PIN (floor- + # equivalent). Same link set + transaction.cpp TU as the connector tests. + add_executable(bch_embedded_daemon_assembly_test + embedded_daemon_assembly_test.cpp + ${CMAKE_SOURCE_DIR}/src/impl/bch/coin/transaction.cpp + ${CMAKE_SOURCE_DIR}/src/impl/bch/coin/coin_node.cpp + ${CMAKE_SOURCE_DIR}/src/impl/bch/coin/p2p_connection.cpp + ${CMAKE_SOURCE_DIR}/src/impl/bch/coin/p2p_node.cpp + ${CMAKE_SOURCE_DIR}/src/impl/bch/coin/rpc.cpp) + target_include_directories(bch_embedded_daemon_assembly_test PRIVATE ${CMAKE_SOURCE_DIR}/src) + target_link_libraries(bch_embedded_daemon_assembly_test PRIVATE + core pool sharechain + c2pool_payout c2pool_merged_mining c2pool_hashrate c2pool_storage c2pool_difficulty + btclibs) + add_test(NAME bch_embedded_daemon_assembly_test COMMAND bch_embedded_daemon_assembly_test) + + # M5 embedded-daemon body tests (#156): EmbeddedDaemon / EmbeddedCoinNode / + # CoinNode-seam coverage that was authored alongside the body but never wired + # into CMake -> it built locally yet never ran in CI (authored-but-unrun = + # false-green). Same TU + link set as bch_embedded_daemon_assembly_test (each + # constructs the real Node/EmbeddedCoinNode/CoinNode graph, so it needs the + # out-of-line coin_node/p2p/rpc/transaction TUs). No impl_bch coin lib -> + # per-coin isolation holds. + set(BCH_EMBEDDED_BODY_TESTS + embedded_getwork_test # EmbeddedCoinNode::getwork() contract: sync gate + GBT + ABLA at-tip/stale floor + embedded_seam_workview_test # EmbeddedDaemon real-seam + network-free assemble() split + embedded_block_broadcast_test # dual-path won-block broadcaster (gate A submitblock-RPC + B BIP152 P2P) + coin_node_seam_test # CoinNode seam: embedded-primary / external-RPC-fallback invariants + ) + foreach(t IN LISTS BCH_EMBEDDED_BODY_TESTS) + add_executable(bch_${t} + ${t}.cpp + ${CMAKE_SOURCE_DIR}/src/impl/bch/coin/transaction.cpp + ${CMAKE_SOURCE_DIR}/src/impl/bch/coin/coin_node.cpp + ${CMAKE_SOURCE_DIR}/src/impl/bch/coin/p2p_connection.cpp + ${CMAKE_SOURCE_DIR}/src/impl/bch/coin/p2p_node.cpp + ${CMAKE_SOURCE_DIR}/src/impl/bch/coin/rpc.cpp) + target_include_directories(bch_${t} PRIVATE ${CMAKE_SOURCE_DIR}/src) + target_link_libraries(bch_${t} PRIVATE + core pool sharechain + c2pool_payout c2pool_merged_mining c2pool_hashrate c2pool_storage c2pool_difficulty + btclibs) + add_test(NAME bch_${t} COMMAND bch_${t}) + endforeach() +endif() diff --git a/src/impl/bch/test/abla_block_feed_gap_test.cpp b/src/impl/bch/test/abla_block_feed_gap_test.cpp new file mode 100644 index 000000000..fa3fe0606 --- /dev/null +++ b/src/impl/bch/test/abla_block_feed_gap_test.cpp @@ -0,0 +1,146 @@ +// --------------------------------------------------------------------------- +// bch::coin AblaBlockFeed GAP/never-undercut test (M5 -- full-block -> ABLA). +// +// abla_block_feed_test pins the feed's height-resolution, off-chain-skip, and +// duplicate-ignore paths -- but it only ever feeds CONTIGUOUS blocks, so the +// single most load-bearing safety claim of the whole size feed is left +// unexercised END-TO-END through the feed: +// +// "a reorg or a skipped block can only ever DROP the budget to the safe +// floor, never raise it on bad data" (abla_block_feed.hpp). +// +// The tracker's gap logic (height > cursor+1 -> m_valid=false -> stale -> floor) +// is unit-tested directly in abla_floor_invariant_test, but that bypasses the +// feed's height-resolution -- the exact seam where a real missed/replaced block +// turns into a non-contiguous height. This test drives that path through the +// REAL AblaBlockFeed::on_full_block: it resolves a height from the header index +// that is non-contiguous with the tracker cursor and asserts the never-undercut +// invariant holds, latches, and is recoverable only via reanchor(). +// +// Setup: a block indexed at height H, but the tracker anchored at H-5, so the +// feed resolves H and hands the tracker a GAP (H != cursor+1). Asserts: +// 1. GAP -> tracker goes stale; cursor is NOT advanced on a non-contiguous +// block; budget_for_tip falls back to EXACTLY the 32 MB floor (never sub-, +// never raised on data we cannot fold) for every queried height. +// 2. STALE LATCHES: re-feeding the same block while stale does not silently +// "recover" -- still stale, still floor, cursor still pinned. +// 3. RECOVERY is reanchor()-only: a fresh known-good {height,State} clears the +// stale flag and restores a current, >= floor budget. +// +// Build-INERT / source-only (matches abla_block_feed_test): impl_bch stays +// unregistered in CMake (bch = skip-green; don't race ci-steward). Verified +// -fsyntax-only EXIT=0 AND compile+link+run ALL PASS EXIT=0, linking the REAL +// core objects (same recipe as abla_block_feed_test): core.dir/{uint256,log, +// leveldb_store}.cpp.o + libbtclibs.a + -lboost_log[_setup] -lleveldb -lssl +// -lcrypto -lpthread, built -DBOOST_LOG_DYN_LINK to match the project's boost +// ABI. (Pull only those 3 core objects -- the full core.dir/*.o set drags in +// YAML/hashrate/web_server transitively.) No daemon-graph stubbing. +// p2pool-merged-v36 surface: NONE +// (pure local build-time block-size budget; no PoW/share/coinbase/PPLNS math). +// --------------------------------------------------------------------------- + +#include +#include + +#include "../coin/abla.hpp" +#include "../coin/abla_block_feed.hpp" +#include "../coin/abla_tracker.hpp" +#include "../coin/block.hpp" +#include "../coin/header_chain.hpp" + +namespace { + +int failures = 0; +#define CHECK(cond) do { if (!(cond)) { \ + std::cerr << "FAIL: " #cond " @ line " << __LINE__ << "\n"; ++failures; } } while (0) + +// Same minimal-block crafting as abla_block_feed_test: non-null prev (not the +// genesis-special case) + distinct nonce so each block has a distinct hash. +bch::coin::BlockType make_block(uint32_t nonce) { + bch::coin::BlockType b; + b.m_version = 0x20000000; + b.m_previous_block.SetHex( + "00000000000000000001a2b3c4d5e6f700000000000000000000000000000000"); + b.m_merkle_root.SetHex( + "1111111111111111111111111111111111111111111111111111111111111111"); + b.m_timestamp = 1700000000; + b.m_bits = 0x1d00ffff; + b.m_nonce = nonce; + return b; +} + +} // namespace + +int main() { + using bch::coin::AblaTracker; + using bch::coin::AblaBlockFeed; + using bch::coin::BCHChainParams; + using bch::coin::HeaderChain; + + const uint32_t H = 800001; // the height our indexed block resolves to + const uint32_t GAP = 5; // anchor this far below H -> non-contiguous + + // Index exactly one block at height H via the fast-start checkpoint, mirror + // of abla_block_feed_test's seeding. + bch::coin::BlockType on_chain = make_block(/*nonce=*/1); + const uint256 on_hash = + bch::coin::block_hash(static_cast(on_chain)); + + BCHChainParams params = BCHChainParams::mainnet(); + params.fast_start_checkpoint = BCHChainParams::Checkpoint{H, on_hash}; + HeaderChain chain(params); + CHECK(chain.init()); + { + auto e = chain.get_header(on_hash); + CHECK(e.has_value()); + CHECK(e && e->height == H); + } + + const uint64_t floor = bch::coin::abla::floor_block_size_limit(/*is_testnet=*/false); + + // Anchor the tracker GAP blocks BELOW H so the resolved height H is + // non-contiguous (H != (H-GAP)+1). This is the shape a missed/replaced + // block download produces: header index ahead of where ABLA folded to. + AblaTracker tracker = AblaTracker::floor_anchored(/*is_testnet=*/false, H - GAP); + AblaBlockFeed feed(tracker, chain); + + CHECK(!tracker.is_stale()); + CHECK(tracker.is_current(H - GAP)); + CHECK(tracker.cursor_height() == H - GAP); + + // 1) GAP: feed resolves H from the index, hands the tracker a non-contiguous + // height -> stale; cursor NOT advanced; budget falls to EXACTLY the floor + // for every tip we could ask about (never sub-floor, never raised on data + // we cannot fold). This is the never-undercut invariant through the feed. + feed.on_full_block(on_chain); + CHECK(tracker.is_stale()); // gap detected + CHECK(!tracker.is_current(H)); // did NOT fold at the gap height + CHECK(tracker.cursor_height() == H - GAP); // cursor pinned, not advanced on a gap + CHECK(tracker.budget_for_tip(H) == floor); + CHECK(tracker.budget_for_tip(H - GAP) == floor); + CHECK(tracker.budget_for_tip(H + 1) == floor); + + // 2) STALE LATCHES: re-feeding the same block must not silently recover -- + // record_block_size() short-circuits while invalid, so the feed cannot + // climb back off the floor on its own; only reanchor() may. + feed.on_full_block(on_chain); + CHECK(tracker.is_stale()); + CHECK(tracker.cursor_height() == H - GAP); + CHECK(tracker.budget_for_tip(H) == floor); + + // 3) RECOVERY is reanchor()-only: a fresh known-good {height, State} (here + // the floor State at H, the BCHN-pin / explicit-reanchor path) clears the + // stale flag and restores a current, >= floor budget. + tracker.reanchor(H, bch::coin::abla::State(bch::coin::abla::mainnet_config(), 0)); + CHECK(!tracker.is_stale()); + CHECK(tracker.is_current(H)); + CHECK(tracker.cursor_height() == H); + CHECK(tracker.budget_for_tip(H) >= floor); + + if (failures == 0) { + std::cout << "abla_block_feed_gap_test: ALL PASS\n"; + return 0; + } + std::cerr << "abla_block_feed_gap_test: " << failures << " FAILURE(S)\n"; + return 1; +} diff --git a/src/impl/bch/test/abla_growth_soak_test.cpp b/src/impl/bch/test/abla_growth_soak_test.cpp new file mode 100644 index 000000000..f0f9f5ed9 --- /dev/null +++ b/src/impl/bch/test/abla_growth_soak_test.cpp @@ -0,0 +1,112 @@ +// --------------------------------------------------------------------------- +// bch::abla AblaTracker budget-GROWTH soak test (M5 dynamic-sizing proof). +// +// The floor-invariant test (abla_floor_invariant_test.cpp) pins the SAFETY +// claim: the embedded build budget can never undercut the 32 MB floor. But +// that test only ever observes the budget AT the floor -- it never exercises +// the feature ABLA actually exists for: raising the limit above 32 MB when the +// network sustains full blocks. Live near-tip BCH stays well sub-floor, so the +// dynamic-RISE path is structurally unprovable against VM300 data. This fixture +// closes that gap with a synthetic over-floor stream and pins the GROWTH claim +// directly against AblaTracker -- the same node->feed->tracker path the daemon +// folds live full-block sizes through. +// +// Drive: sustained MAXIMALLY-FULL blocks. Each height feeds a block exactly the +// size of the previous tip's limit (budget_for_tip), the worst-case load ABLA +// is designed to absorb. Under the BCHN v29 control function (zeta=192/128=1.5x +// amplification, gamma/theta reciprocal 37938) this is a monotonic ramp off the +// floor of ~2 KB/block. +// +// Invariants asserted: +// 1. cold start -> budget == 32 MB floor exactly +// 2. sustained full blocks -> budget is monotonic NON-DECREASING +// every step (never dips under load) +// 3. the rise is STRICT and sustained -> a strong majority of steps strictly +// increase (not a one-off blip / mere +// change) +// 4. end state -> budget strictly ABOVE the 32 MB +// floor (the dynamic feature fires) +// 5. testnet (fixedSize ABLA) -> budget stays pinned at floor even +// under identical full-block load +// +// Build-INERT / source-only: impl_bch stays unregistered in CMake (bch = +// skip-green; don't race ci-steward). Verified with -fsyntax-only; runs under +// the embedded ABLA test target. Header-only against coin/abla*.hpp -- no node, +// RPC, or boost graph. p2pool-merged-v36 surface: NONE (pure local build-time +// block-size budget; no PoW/share/coinbase math; p2poolBCH carries no ABLA, so +// this path is purely additive with zero shared-surface divergence). +// --------------------------------------------------------------------------- + +#include +#include + +#include "../coin/abla.hpp" +#include "../coin/abla_tracker.hpp" + +namespace { + +int failures = 0; +#define CHECK(cond) do { if (!(cond)) { \ + std::cerr << "FAIL: " #cond " @ line " << __LINE__ << "\n"; ++failures; } } while (0) + +} // namespace + +int main() { + using bch::coin::AblaTracker; + + const uint32_t H0 = 800000; // arbitrary anchor height + const int WINDOW = 5000; // ~5000 sustained full blocks (~3.5 days @10min) + + // ---- mainnet: sustained full blocks must ramp the budget off the floor -- + { + const uint64_t floor = bch::coin::abla::floor_block_size_limit(/*is_testnet=*/false); + AblaTracker t = AblaTracker::floor_anchored(/*is_testnet=*/false, H0); + + // 1) Cold start sits exactly on the 32 MB floor. + CHECK(t.budget_for_tip(H0) == floor); + CHECK(t.is_current(H0)); + + uint64_t prev = floor; + uint64_t strict_increases = 0; + for (int i = 1; i <= WINDOW; ++i) { + const uint32_t h = H0 + static_cast(i); + // Maximally-full block: exactly the previous tip's limit. + t.record_block_size(h, prev); + CHECK(t.is_current(h)); // contiguous feed never goes stale + const uint64_t b = t.budget_for_tip(h); + CHECK(b >= prev); // 2) monotonic under sustained load + if (b > prev) ++strict_increases; + prev = b; + } + + // 3) The rise is strict and sustained, not a single blip. + CHECK(strict_increases >= static_cast(WINDOW) * 9 / 10); + // 4) The budget genuinely rose ABOVE the 32 MB floor (feature fired). + CHECK(prev > floor); + + std::cout << "abla_growth_soak_test[mainnet]: floor=" << floor + << " final=" << prev + << " delta=" << (prev - floor) + << " strict_steps=" << strict_increases << "/" << WINDOW << "\n"; + } + + // ---- testnet: fixedSize ABLA must NOT grow under the same full-block load - + { + const uint64_t tfloor = bch::coin::abla::floor_block_size_limit(/*is_testnet=*/true); + AblaTracker t = AblaTracker::floor_anchored(/*is_testnet=*/true, H0); + CHECK(t.budget_for_tip(H0) == tfloor); + for (int i = 1; i <= WINDOW; ++i) { + const uint32_t h = H0 + static_cast(i); + t.record_block_size(h, tfloor); + CHECK(t.budget_for_tip(h) == tfloor); // 5) fixed at floor, no ramp + } + std::cout << "abla_growth_soak_test[testnet]: pinned=" << tfloor << " (no ramp)\n"; + } + + if (failures == 0) { + std::cout << "abla_growth_soak_test: ALL PASS\n"; + return 0; + } + std::cerr << "abla_growth_soak_test: " << failures << " FAILURE(S)\n"; + return 1; +} diff --git a/src/impl/bch/test/block_connector_test.cpp b/src/impl/bch/test/block_connector_test.cpp new file mode 100644 index 000000000..98c1ec04d --- /dev/null +++ b/src/impl/bch/test/block_connector_test.cpp @@ -0,0 +1,171 @@ +// --------------------------------------------------------------------------- +// bch::coin BlockConnector test (M5 full-block body, slice 1: block-connect). +// +// BlockConnector closes the path AblaBlockFeed left open: Mempool::remove_for_block +// existed with ZERO call sites and nothing tied a received best-chain block to a +// mempool reconciliation. This test pins the connector's three load-bearing +// decisions directly, against a real in-memory HeaderChain + Mempool: +// +// 1. BEST-CHAIN RECONCILE -- a block that IS the tip reconciles the mempool: +// Phase 1 removes a tx confirmed-by-txid in the block; Phase 2 removes a +// mempool tx that double-spends a block tx's outpoint; an unrelated tx +// survives; mempool tip_height is advanced to the block's height. +// 2. BIP130 OFF-TIP GATE -- a block whose hash is NOT the best-chain tip +// leaves the mempool completely untouched, even when it contains a tx that +// WOULD be removed if it were the tip (its txs aren't confirmed for us). +// 3. IDEMPOTENT RE-CONNECT -- replaying the tip block is a clean no-op +// (nothing left to remove; survivor still present). +// +// Build posture matches the ABLA seam tests: header-only over coin/*.hpp + +// , plus coin/transaction.cpp for the MutableTransaction ctors. impl_bch +// is NOT linked -> per-coin isolation stays clean. p2pool-merged-v36 surface: +// NONE (local block-connect + mempool hygiene; no PoW/share/coinbase/PPLNS). +// --------------------------------------------------------------------------- + +#include +#include + +#include "../coin/block.hpp" +#include "../coin/block_connector.hpp" +#include "../coin/header_chain.hpp" +#include "../coin/mempool.hpp" +#include "../coin/transaction.hpp" + +namespace { + +int failures = 0; +#define CHECK(cond) do { if (!(cond)) { \ + std::cerr << "FAIL: " #cond " @ line " << __LINE__ << "\n"; ++failures; } } while (0) + +// A minimal spend of outpoint (prev_hash, prev_index) producing `out_value`. +// Distinct (prevout, value) tuples => distinct txids (compute_txid = SHA256d of +// the legacy serialization), which is all the test needs. +bch::coin::MutableTransaction make_tx(const char* prev_hex, uint32_t prev_index, + int64_t out_value) { + bch::coin::MutableTransaction tx; + bch::coin::TxIn in; + in.prevout.hash.SetHex(prev_hex); + in.prevout.index = prev_index; + in.sequence = 0xffffffff; + tx.vin.push_back(in); + bch::coin::TxOut out; + out.value = out_value; + tx.vout.push_back(out); + return tx; +} + +// A header-only block whose hash is fixed by the 80-byte header (the tx vector +// does NOT feed block_hash), so m_txs can be set freely without moving the hash +// the fast-start checkpoint pins. +bch::coin::BlockType make_block(uint32_t nonce) { + bch::coin::BlockType b; + b.m_version = 0x20000000; + b.m_previous_block.SetHex( + "00000000000000000001a2b3c4d5e6f700000000000000000000000000000000"); + b.m_merkle_root.SetHex( + "1111111111111111111111111111111111111111111111111111111111111111"); + b.m_timestamp = 1700000000; + b.m_bits = 0x1d00ffff; + b.m_nonce = nonce; + return b; +} + +const char* OUTPOINT_A = "aa00000000000000000000000000000000000000000000000000000000000000"; +const char* OUTPOINT_C = "cc00000000000000000000000000000000000000000000000000000000000000"; + +} // namespace + +int main() { + using namespace bch::coin; + + const uint32_t H = 800001; + + // The tip block; seed a HeaderChain whose fast-start checkpoint IS its hash + // at height H, so the connector's best-chain gate sees it as the tip without + // fighting PoW/difficulty validation (same technique as abla_block_feed_test). + BlockType tip_block = make_block(/*nonce=*/1); + const uint256 tip_hash = + block_hash(static_cast(tip_block)); + + BCHChainParams params = BCHChainParams::mainnet(); + params.fast_start_checkpoint = BCHChainParams::Checkpoint{H, tip_hash}; + HeaderChain chain(params); + CHECK(chain.init()); + CHECK(chain.height() == H); + CHECK(chain.tip().has_value()); + CHECK(chain.tip() && chain.tip()->block_hash == tip_hash); + + // tx_A: spends OUTPOINT_A, INCLUDED in the block (confirmed) -> Phase 1. + MutableTransaction tx_A = make_tx(OUTPOINT_A, 0, 100); + // tx_B: double-spends OUTPOINT_A, mempool-only -> Phase 2 conflict removal. + MutableTransaction tx_B = make_tx(OUTPOINT_A, 0, 200); + // tx_C: spends OUTPOINT_C, mempool-only, unrelated -> survives. + MutableTransaction tx_C = make_tx(OUTPOINT_C, 0, 300); + const uint256 id_A = compute_txid(tx_A); + const uint256 id_B = compute_txid(tx_B); + const uint256 id_C = compute_txid(tx_C); + CHECK(id_A != id_B && id_B != id_C && id_A != id_C); + + tip_block.m_txs.push_back(tx_A); // tx_A confirmed in the block + + // ---- 1) Best-chain reconcile ---------------------------------------- + { + Mempool pool; + CHECK(pool.add_tx(tx_A)); + CHECK(pool.add_tx(tx_B)); + CHECK(pool.add_tx(tx_C)); + CHECK(pool.size() == 3); + + BlockConnector conn(chain, pool); + conn.on_full_block(tip_block); + + CHECK(!pool.contains(id_A)); // Phase 1: confirmed-by-txid removed + CHECK(!pool.contains(id_B)); // Phase 2: same-outpoint conflict removed + CHECK(pool.contains(id_C)); // unrelated tx survives + CHECK(pool.size() == 1); + } + + // ---- 2) BIP130 off-tip gate ----------------------------------------- + { + Mempool pool; + CHECK(pool.add_tx(tx_A)); + CHECK(pool.add_tx(tx_B)); + CHECK(pool.add_tx(tx_C)); + CHECK(pool.size() == 3); + + // A different block (distinct nonce -> distinct hash): NOT the tip. Even + // though it carries tx_A (which WOULD be removed if it connected), the + // gate must leave the mempool fully intact. + BlockType off_block = make_block(/*nonce=*/99); + off_block.m_txs.push_back(tx_A); + CHECK(block_hash(static_cast(off_block)) != tip_hash); + + BlockConnector conn(chain, pool); + conn.on_full_block(off_block); + + CHECK(pool.size() == 3); // zero reconciliation off the best chain + CHECK(pool.contains(id_A)); + CHECK(pool.contains(id_B)); + CHECK(pool.contains(id_C)); + } + + // ---- 3) Idempotent re-connect --------------------------------------- + { + Mempool pool; + CHECK(pool.add_tx(tx_C)); // only the survivor left after a reconcile + CHECK(pool.size() == 1); + + BlockConnector conn(chain, pool); + conn.on_full_block(tip_block); // replay the tip block + conn.on_full_block(tip_block); // again + CHECK(pool.size() == 1); // nothing left to remove + CHECK(pool.contains(id_C)); + } + + if (failures == 0) { + std::cout << "block_connector_test: ALL PASS\n"; + return 0; + } + std::cerr << "block_connector_test: " << failures << " FAILURE(S)\n"; + return 1; +} diff --git a/src/impl/bch/test/block_download_test.cpp b/src/impl/bch/test/block_download_test.cpp new file mode 100644 index 000000000..0d5567626 --- /dev/null +++ b/src/impl/bch/test/block_download_test.cpp @@ -0,0 +1,206 @@ +// --------------------------------------------------------------------------- +// bch::coin::block_download::BlockDownloadWindow -- M5 full-block body. +// Pins the windowed headers-first block-download policy that NodeP2P drives +// during cold-start IBD: enqueue learned header hashes, keep at most +// MAX_BLOCKS_IN_FLIGHT getdata(MSG_BLOCK) outstanding, and top the window up +// as blocks arrive. Header-only over coin/block_download.hpp + ; +// no peer, socket, or coin lib. +// +// The behavior under test (the gap this slice closed): before this, IBD walked +// the header chain to the peer tip but never getdata'd a single block body, so +// the ABLA size feed / block-connector had no real block data and cold-start +// IBD could not complete. The window turns the synced header stream into a +// bounded, self-topping-up block download. +// +// p2pool-merged-v36 surface: NONE. per-coin isolation: src/impl/bch/ only. +// --------------------------------------------------------------------------- + +#include +#include +#include + +#include + +#include "../coin/block_download.hpp" + +using bch::coin::block_download::BlockDownloadWindow; +using bch::coin::block_download::DEFAULT_MAX_BLOCKS_IN_FLIGHT; + +// Deterministic distinct hashes (no Math.random) -- hash i = uint256(i+1). +static uint256 H(uint64_t i) { return uint256(base_uint<256>(i + 1)); } + +static std::vector hashes(uint64_t lo, uint64_t hi) { + std::vector v; + for (uint64_t i = lo; i < hi; ++i) v.push_back(H(i)); + return v; +} + +int main() +{ + // ---- Window bounds outstanding getdata at max_in_flight ---------------- + { + BlockDownloadWindow w(4); + assert(w.idle()); + // Enqueue a maximal-ish IBD batch of 10 headers. + assert(w.enqueue(hashes(0, 10)) == 10); + assert(w.queued() == 10); + assert(w.has_capacity()); + + // First drain requests exactly the window size (4), oldest-first. + auto req = w.next_requests(); + assert(req.size() == 4); + assert(req[0] == H(0) && req[3] == H(3)); // chain order preserved + assert(w.in_flight() == 4); + assert(w.queued() == 6); + + // Window is full -> a second drain with no arrivals yields nothing. + assert(w.next_requests().empty()); + assert(w.in_flight() == 4); + } + + // ---- Arrivals free slots; window tops up oldest-first ------------------ + { + BlockDownloadWindow w(4); + w.enqueue(hashes(0, 10)); + w.next_requests(); // H0..H3 in flight + + // H0 arrives (was in flight) -> frees one slot. + assert(w.on_block_received(H(0)) == true); + assert(w.in_flight() == 3); + auto req = w.next_requests(); // top up by 1 + assert(req.size() == 1 && req[0] == H(4)); + assert(w.in_flight() == 4 && w.queued() == 5); + + // Drain the rest as blocks arrive, in order. + for (uint64_t i = 1; i < 10; ++i) { + assert(w.on_block_received(H(i)) == true); + w.next_requests(); + } + assert(w.idle()); + assert(w.queued() == 0 && w.in_flight() == 0); + } + + // ---- Dedupe: re-announce / overlapping locator batch never re-requests -- + { + BlockDownloadWindow w(8); + assert(w.enqueue(hashes(0, 5)) == 5); + // Overlapping batch (3..8): only 5,6,7 are new. + assert(w.enqueue(hashes(3, 8)) == 3); + assert(w.queued() == 8); + + auto req = w.next_requests(); // window 8 >= 8 queued + assert(req.size() == 8); + // No hash requested twice. + std::vector seen; + for (auto& h : req) { + for (auto& s : seen) assert(!(s == h)); + seen.push_back(h); + } + + // A block we already pulled, re-announced, is not re-queued. + assert(w.on_block_received(H(2)) == true); + assert(w.enqueue({H(2)}) == 0); + assert(w.queued() == 0); + } + + // ---- Unsolicited block: not in flight, reported false, still remembered -- + { + BlockDownloadWindow w(4); + assert(w.on_block_received(H(99)) == false); // never requested + // ...and remembering it means a later headers batch won't queue it. + assert(w.enqueue({H(99)}) == 0); + } + + // ---- Degenerate window (0 -> clamped to 1) still makes progress -------- + { + BlockDownloadWindow w(0); + assert(w.max_in_flight() == 1); + w.enqueue(hashes(0, 3)); + auto req = w.next_requests(); + assert(req.size() == 1 && req[0] == H(0)); + assert(w.next_requests().empty()); // window full at 1 + assert(w.on_block_received(H(0)) == true); + req = w.next_requests(); + assert(req.size() == 1 && req[0] == H(1)); + } + + // ---- Default window size is the BCHN/Bitcoin per-peer default ---------- + { + BlockDownloadWindow w; + assert(w.max_in_flight() == DEFAULT_MAX_BLOCKS_IN_FLIGHT); + assert(DEFAULT_MAX_BLOCKS_IN_FLIGHT == 16); + } + + // ---- expire(): stalled in-flight requests are evicted + re-queued ------ + { + BlockDownloadWindow w(2); + w.enqueue(hashes(0, 4)); // [H0 H1 H2 H3] + auto req = w.next_requests(/*now=*/100); // H0,H1 issued @100 + assert(req.size() == 2); + assert(w.in_flight() == 2 && w.queued() == 2); + + // Not yet past the timeout: nothing evicted. + assert(w.expire(/*now=*/105, /*timeout=*/10).empty()); + assert(w.in_flight() == 2); + + // Past the timeout (110-100 >= 10): both stalled requests evicted, + // window freed, and they go back to the FRONT of the queue. + auto evicted = w.expire(/*now=*/110, /*timeout=*/10); + assert(evicted.size() == 2); + assert(w.reissue_count() == 2); // both stalls counted as re-issues + assert(w.in_flight() == 0); + assert(w.queued() == 4); // H0,H1 re-queued ahead of H2,H3 + + // Retried oldest-first: the next window pulls exactly the two stalled + // hashes (ahead of the never-requested H2/H3). + auto retry = w.next_requests(/*now=*/200); + assert(retry.size() == 2); + std::vector rset(retry.begin(), retry.end()); + assert((rset[0] == H(0) || rset[0] == H(1))); + assert((rset[1] == H(0) || rset[1] == H(1)) && rset[0] != rset[1]); + } + + // ---- expire(): only the stale request goes, fresh ones stay ----------- + { + BlockDownloadWindow w(2); + w.enqueue(hashes(0, 3)); // [H0 H1 H2] + w.next_requests(/*now=*/100); // H0,H1 issued @100 + assert(w.on_block_received(H(1)) == true); // H1 arrives, frees a slot + w.next_requests(/*now=*/150); // H2 issued @150 + // in flight: H0@100, H2@150. timeout 50 at now=155 => only H0 is stale. + auto evicted = w.expire(/*now=*/155, /*timeout=*/50); + assert(evicted.size() == 1 && evicted[0] == H(0)); + assert(w.in_flight() == 1); // H2 still outstanding + + // A block that arrives AFTER its eviction is reported unsolicited + // (no longer in flight) -- caller still applies it; window unaffected. + assert(w.on_block_received(H(0)) == false); + } + + // ---- tick-driven re-issue cycle: a requeued request gets a FRESH issue + // tick on redrain, so it is not immediately re-expired the next tick + // (mirrors the NodeP2P expire-timer -> drain_block_window contract) -- + { + BlockDownloadWindow w(1); + w.enqueue(hashes(0, 1)); // [H0] + auto r0 = w.next_requests(/*now=*/10); // H0 issued @10 + assert(r0.size() == 1 && r0[0] == H(0)); + // Stall: at now=70 (>= timeout 60) H0 is evicted + requeued to front. + auto ev = w.expire(/*now=*/70, /*timeout=*/60); + assert(ev.size() == 1 && ev[0] == H(0)); + assert(w.in_flight() == 0 && w.queued() == 1); + // Redrain re-issues H0 with a NEW issue tick (=72), not the stale @10. + auto r1 = w.next_requests(/*now=*/72); + assert(r1.size() == 1 && r1[0] == H(0)); + // One cadence later it must NOT re-expire (72..77 elapsed 5 < 60), + // proving the issue tick was refreshed rather than carried over. + assert(w.expire(/*now=*/77, /*timeout=*/60).empty()); + assert(w.in_flight() == 1); + // The one stall produced exactly one re-issue; a non-expiring tick + // does not inflate the tally (this is the IBD writeup re-issue metric). + assert(w.reissue_count() == 1); + } + + std::cout << "bch block_download window: ALL PASS\n"; + return 0; +} diff --git a/src/impl/bch/test/block_ordering_test.cpp b/src/impl/bch/test/block_ordering_test.cpp new file mode 100644 index 000000000..d45e5ccb6 --- /dev/null +++ b/src/impl/bch/test/block_ordering_test.cpp @@ -0,0 +1,210 @@ +// --------------------------------------------------------------------------- +// bch::coin BlockConnector — BIP130 ORDERING edge cases (M5 full-block body, +// slice 2). The slice-1 test (block_connector_test.cpp) pinned the three +// happy-path decisions against a block that was ALREADY the seeded checkpoint +// tip. It never exercised the two ordering branches the connector's step-1 +// add_header() actually has to handle when blocks and headers arrive in the +// "wrong" order on the wire: +// +// 1. OUT-OF-ORDER / ORPHAN block (block arrives before its parent header): +// the block's m_previous_block is not in the header index, so +// HeaderChain::add_header() rejects it as an orphan, the tip does NOT +// move, and the BIP130 best-chain gate must leave the mempool fully +// intact — even though the orphan carries a tx that WOULD be removed if +// it ever connected. No reconciliation off an unconnected block. +// +// 2. DIRECT-BLOCK DELIVERY that CONNECTS (block with no prior synced header, +// e.g. one we mined or a peer `block` msg extending our tip): add_header() +// indexes it, it becomes the NEW best tip at height+1, the gate then +// passes, and the mempool is reconciled to the new tip. This is the +// "indexes the connect on direct-block delivery" path asserted in the +// connector header but left untested by slice 1 (which relied on the block +// already being the checkpoint tip). PoW is skipped via a far-ahead +// peer-tip (structural-only validation, the connector's normal fast-sync +// contract); difficulty is trusted for the single block building on the +// synthetic checkpoint seed (null-prev trust branch in validate_difficulty). +// +// 3. IDEMPOTENT RE-DELIVERY of the now-connected tip: replaying it is a clean +// no-op (header already known -> add_header false; gate still passes but +// remove_for_block has nothing left to remove). +// +// Build posture is identical to block_connector_test: header-only over +// coin/*.hpp + plus coin/transaction.cpp for the MutableTransaction +// ctors. impl_bch is NOT linked -> per-coin isolation stays clean. +// p2pool-merged-v36 surface: NONE. +// --------------------------------------------------------------------------- + +#include +#include + +#include "../coin/block.hpp" +#include "../coin/block_connector.hpp" +#include "../coin/header_chain.hpp" +#include "../coin/mempool.hpp" +#include "../coin/transaction.hpp" + +namespace { + +int failures = 0; +#define CHECK(cond) do { if (!(cond)) { \ + std::cerr << "FAIL: " #cond " @ line " << __LINE__ << "\n"; ++failures; } } while (0) + +bch::coin::MutableTransaction make_tx(const char* prev_hex, uint32_t prev_index, + int64_t out_value) { + bch::coin::MutableTransaction tx; + bch::coin::TxIn in; + in.prevout.hash.SetHex(prev_hex); + in.prevout.index = prev_index; + in.sequence = 0xffffffff; + tx.vin.push_back(in); + bch::coin::TxOut out; + out.value = out_value; + tx.vout.push_back(out); + return tx; +} + +// 80-byte header fully fixes block_hash (the tx vector does not feed it), so +// m_txs can be set freely. `prev` and `nonce` are the only fields the tests +// vary to control connectivity and hash-distinctness. +bch::coin::BlockType make_block(const uint256& prev, uint32_t nonce) { + bch::coin::BlockType b; + b.m_version = 0x20000000; + b.m_previous_block = prev; + b.m_merkle_root.SetHex( + "1111111111111111111111111111111111111111111111111111111111111111"); + b.m_timestamp = 1700000000; + b.m_bits = 0x1d00ffff; + b.m_nonce = nonce; + return b; +} + +const char* OUTPOINT_A = "aa00000000000000000000000000000000000000000000000000000000000000"; +const char* OUTPOINT_C = "cc00000000000000000000000000000000000000000000000000000000000000"; + +// Params for a fast-start chain whose checkpoint hash IS `tip_hash` at height H, +// the same technique the slice-1 test uses to get a known tip without fighting +// PoW. HeaderChain is non-copyable/non-movable, so callers construct it in place +// from these params (no return-by-value). +bch::coin::BCHChainParams seeded_params(const uint256& tip_hash, uint32_t H) { + bch::coin::BCHChainParams params = bch::coin::BCHChainParams::mainnet(); + params.fast_start_checkpoint = bch::coin::BCHChainParams::Checkpoint{H, tip_hash}; + return params; +} + +} // namespace + +int main() { + using namespace bch::coin; + + const uint32_t H = 800001; + + // The seeded checkpoint tip. Its hash is fixed by an arbitrary header; we + // only ever use the hash as the index root + the prev of the connecting + // block, never deliver this block itself. (Seed header content is + // irrelevant — only the resulting hash matters.) + uint256 seed_prev; + seed_prev.SetHex( + "00000000000000000001a2b3c4d5e6f700000000000000000000000000000000"); + BlockType seed_block = make_block(seed_prev, /*nonce=*/1); + const uint256 seed_hash = + block_hash(static_cast(seed_block)); + + // tx_A: confirmed in the connecting block -> Phase 1. + MutableTransaction tx_A = make_tx(OUTPOINT_A, 0, 100); + // tx_B: double-spends tx_A's outpoint, mempool-only -> Phase 2. + MutableTransaction tx_B = make_tx(OUTPOINT_A, 0, 200); + // tx_C: unrelated, mempool-only -> survives. + MutableTransaction tx_C = make_tx(OUTPOINT_C, 0, 300); + const uint256 id_A = compute_txid(tx_A); + const uint256 id_B = compute_txid(tx_B); + const uint256 id_C = compute_txid(tx_C); + CHECK(id_A != id_B && id_B != id_C && id_A != id_C); + + // ---- 1) OUT-OF-ORDER / ORPHAN block: prev not in index -------------- + { + HeaderChain chain(seeded_params(seed_hash, H)); + CHECK(chain.init()); + CHECK(chain.height() == H); + CHECK(chain.tip() && chain.tip()->block_hash == seed_hash); + + Mempool pool; + CHECK(pool.add_tx(tx_A)); + CHECK(pool.add_tx(tx_B)); + CHECK(pool.add_tx(tx_C)); + CHECK(pool.size() == 3); + + // prev is an unknown hash (parent header not yet synced). The block even + // carries tx_A, which WOULD be removed on connect — must NOT be. + uint256 unknown_prev; + unknown_prev.SetHex( + "deadbeef00000000000000000000000000000000000000000000000000000000"); + BlockType orphan = make_block(unknown_prev, /*nonce=*/42); + orphan.m_txs.push_back(tx_A); + const uint256 orphan_hash = + block_hash(static_cast(orphan)); + CHECK(orphan_hash != seed_hash); + + BlockConnector conn(chain, pool); + conn.on_full_block(orphan); + + // Orphan rejected: tip did not move, nothing reconciled. + CHECK(chain.height() == H); + CHECK(chain.tip() && chain.tip()->block_hash == seed_hash); + CHECK(pool.size() == 3); + CHECK(pool.contains(id_A)); + CHECK(pool.contains(id_B)); + CHECK(pool.contains(id_C)); + } + + // ---- 2) DIRECT-BLOCK DELIVERY that connects + advances tip ---------- + { + HeaderChain chain(seeded_params(seed_hash, H)); + CHECK(chain.init()); + // Far-ahead peer tip => structural-only validation (PoW skipped) for a + // block well below (peer_tip - 2100); difficulty is trusted for the one + // block building on the null-prev seed. + chain.set_peer_tip_height(H + 5000); + + Mempool pool; + CHECK(pool.add_tx(tx_A)); + CHECK(pool.add_tx(tx_B)); + CHECK(pool.add_tx(tx_C)); + CHECK(pool.size() == 3); + + // A real connecting block: prev == current tip (the seed), so add_header + // indexes it at H+1 and (work = seed_work + block_proof > seed_work) it + // becomes the new best tip. + BlockType next = make_block(seed_hash, /*nonce=*/7); + next.m_txs.push_back(tx_A); // tx_A confirmed in the connecting block + const uint256 next_hash = + block_hash(static_cast(next)); + CHECK(next_hash != seed_hash); + + BlockConnector conn(chain, pool); + conn.on_full_block(next); + + // Connected: tip advanced to H+1 on this exact block... + CHECK(chain.height() == H + 1); + CHECK(chain.tip() && chain.tip()->block_hash == next_hash); + // ...and the mempool reconciled to the new tip. + CHECK(!pool.contains(id_A)); // Phase 1 + CHECK(!pool.contains(id_B)); // Phase 2 (same-outpoint conflict) + CHECK(pool.contains(id_C)); // unrelated survives + CHECK(pool.size() == 1); + + // ---- 3) IDEMPOTENT RE-DELIVERY of the connected tip ------------- + conn.on_full_block(next); // header already known; gate still passes + conn.on_full_block(next); + CHECK(chain.height() == H + 1); + CHECK(chain.tip() && chain.tip()->block_hash == next_hash); + CHECK(pool.size() == 1); // nothing left to remove + CHECK(pool.contains(id_C)); + } + + if (failures == 0) { + std::cout << "block_ordering_test: ALL PASS\n"; + return 0; + } + std::cerr << "block_ordering_test: " << failures << " FAILURE(S)\n"; + return 1; +} diff --git a/src/impl/bch/test/coinbase_kat_segwit_predicate_test.cpp b/src/impl/bch/test/coinbase_kat_segwit_predicate_test.cpp new file mode 100644 index 000000000..3c0542c52 --- /dev/null +++ b/src/impl/bch/test/coinbase_kat_segwit_predicate_test.cpp @@ -0,0 +1,64 @@ +// --------------------------------------------------------------------------- +// bch coinbase KAT -- gated-segwit predicate pin (DGB<->BCH pairing, slice 1). +// +// The DGB<->BCH coinbase-byte KAT pairing (integrator PR #171 DGB side; this is +// the SEPARATE BCH-lane PR per the per-coin isolation invariant) has ONE real +// divergence between the two SHA256d coinbases: the SegWit-commitment output +// the builder gates on `is_segwit_activated(version)` at serialize time. +// +// - DGB (SegWit active): the gate is TRUE for share versions >= its +// SEGWIT_ACTIVATION_VERSION, so the coinbase carries the witness-commitment +// vout (OP_RETURN 0x6a24aa21a9ed || commitment). +// - BCH (SegWit rejected at the 2017 fork): the oracle p2poolBCH derives the +// gate from getattr(net,"SEGWIT_ACTIVATION_VERSION",0) (data.py:63-66); the +// bitcoincash[_testnet].py nets define NO such attribute -> default 0 -> +// the gate is FALSE for EVERY version -> NO witness-commitment vout is ever +// emitted, and segwit_data is never present in a BCH share. +// +// This slice pins exactly that predicate: across the full v17..v36 share-version +// range, bch::is_segwit_activated MUST be false, matching the oracle's +// default-0 guard. A regression that re-introduces a non-zero +// SEGWIT_ACTIVATION_VERSION (the old stray-17 BTC/LTC port that forced +// segwit_data on for v17..v35 -> sharechain fork; see share_types.hpp) fails +// here. The byte-vector half of the KAT (full coinbase vs oracle ground-truth +// from p2poolBCH util/pack) is the follow sub-slice. +// +// p2pool-merged-v36 surface: NONE (pins a conformance invariant, adds no wire +// field). per-coin isolation: src/impl/bch/ only. Header-only over +// share_types.hpp -- no peer, socket, or coin lib. +// --------------------------------------------------------------------------- + +#include +#include + +#include "../share_types.hpp" + +int main() +{ + // The sentinel itself must stay disabled (== 0), reproducing the oracle + // getattr(net,"SEGWIT_ACTIVATION_VERSION",0) default for the BCH nets. + assert(bch::SEGWIT_ACTIVATION_VERSION == 0 + && "BCH must keep SEGWIT_ACTIVATION_VERSION disabled (oracle default 0)"); + + // The predicate must be FALSE for every share version BCH ships -- the full + // v17..v36 span and a few sentinels around the boundaries. There is no + // version at which a BCH coinbase emits the witness-commitment vout. + for (uint64_t ver = 0; ver <= 64; ++ver) + { + assert(!bch::is_segwit_activated(ver) + && "BCH gated-segwit predicate must be OFF at every share version"); + } + + // Spot-check the versions that actually matter on the sharechain: the + // legacy floor (17), the field-order transition versions, and v36. + for (uint64_t ver : {uint64_t(17), uint64_t(33), uint64_t(34), + uint64_t(35), uint64_t(36)}) + { + assert(!bch::is_segwit_activated(ver)); + } + + std::cout << "bch coinbase-KAT segwit-predicate pin: OK " + << "(is_segwit_activated == false for all versions; " + << "no witness-commitment vout on any BCH coinbase)\n"; + return 0; +} diff --git a/src/impl/bch/test/compact_block_connector_test.cpp b/src/impl/bch/test/compact_block_connector_test.cpp new file mode 100644 index 000000000..273af373f --- /dev/null +++ b/src/impl/bch/test/compact_block_connector_test.cpp @@ -0,0 +1,195 @@ +// --------------------------------------------------------------------------- +// bch::coin BlockConnector compact-block test (M5 full-block body, slice (b): +// BIP152 compact-block depth on the connector seam). +// +// compact_blocks.hpp already carried the BIP152 wire types + ReconstructBlock, +// but nothing tied them to the block-connect path: a received compact block had +// no way to become a connected best-chain tip + mempool reconciliation. This +// slice adds BlockConnector::on_compact_block() / on_block_txn() and this test +// pins the three load-bearing decisions of that seam against a real in-memory +// HeaderChain + Mempool: +// +// 1. COMPLETE-FROM-MEMPOOL -- a compact block whose non-coinbase txs are all +// already in the mempool reconstructs immediately: on_compact_block returns +// std::nullopt (no getblocktxn round), drives the normal on_full_block path, +// and the confirmed tx is reconciled out of the mempool. Nothing is parked. +// 2. MISSING -> getblocktxn -> blocktxn -- a compact block referencing a tx +// NOT in the mempool returns a BlockTransactionsRequest naming exactly the +// missing absolute index and parks the block; the matching blocktxn response +// completes reconstruction, connects, and clears the parked entry. +// 3. UNKNOWN/EXPIRED blocktxn -- a blocktxn for a blockhash we never parked is +// a clean no-op (returns false, no crash, parked count unchanged). +// +// Build posture matches the other connector tests: header-only over coin/*.hpp + +// , plus coin/transaction.cpp for the MutableTransaction ctors and the +// btclibs lib for SipHash. impl_bch is NOT linked -> per-coin isolation holds. +// p2pool-merged-v36 surface: NONE (local compact-block reconstruction + the same +// block-connect/mempool hygiene; no PoW/share/coinbase/PPLNS touched). +// --------------------------------------------------------------------------- + +#include +#include +#include + +#include "../coin/block.hpp" +#include "../coin/block_connector.hpp" +#include "../coin/compact_blocks.hpp" +#include "../coin/header_chain.hpp" +#include "../coin/mempool.hpp" +#include "../coin/transaction.hpp" + +namespace { + +int failures = 0; +#define CHECK(cond) do { if (!(cond)) { \ + std::cerr << "FAIL: " #cond " @ line " << __LINE__ << "\n"; ++failures; } } while (0) + +// A minimal spend of outpoint (prev_hash, prev_index) producing `out_value`. +// Distinct (prevout, value) tuples => distinct txids. +bch::coin::MutableTransaction make_tx(const char* prev_hex, uint32_t prev_index, + int64_t out_value) { + bch::coin::MutableTransaction tx; + bch::coin::TxIn in; + in.prevout.hash.SetHex(prev_hex); + in.prevout.index = prev_index; + in.sequence = 0xffffffff; + tx.vin.push_back(in); + bch::coin::TxOut out; + out.value = out_value; + tx.vout.push_back(out); + return tx; +} + +// Header whose hash is fixed by the 80-byte header (txs do not feed block_hash), +// so it can be pinned as a fast-start checkpoint and seen as the best tip. +bch::coin::BlockType make_block(uint32_t nonce) { + bch::coin::BlockType b; + b.m_version = 0x20000000; + b.m_previous_block.SetHex( + "00000000000000000001a2b3c4d5e6f700000000000000000000000000000000"); + b.m_merkle_root.SetHex( + "2222222222222222222222222222222222222222222222222222222222222222"); + b.m_timestamp = 1700000000; + b.m_bits = 0x1d00ffff; + b.m_nonce = nonce; + return b; +} + +const char* OUTPOINT_CB = "c0ffee0000000000000000000000000000000000000000000000000000000000"; +const char* OUTPOINT_A = "aa00000000000000000000000000000000000000000000000000000000000000"; +const char* OUTPOINT_M = "11d1551100000000000000000000000000000000000000000000000000000000"; +const char* OUTPOINT_C = "cc00000000000000000000000000000000000000000000000000000000000000"; + +// A HeaderChain whose fast-start checkpoint IS `header`'s hash at height H, so +// the connector's best-chain gate treats that header as the tip without fighting +// PoW/difficulty validation (same technique as the other connector tests). +bch::coin::HeaderChain make_chain_pinning(const bch::coin::BlockHeaderType& header, + uint32_t H) { + using namespace bch::coin; + BCHChainParams params = BCHChainParams::mainnet(); + params.fast_start_checkpoint = + BCHChainParams::Checkpoint{H, block_hash(header)}; + return HeaderChain(params); +} + +} // namespace + +int main() { + using namespace bch::coin; + + const uint32_t H = 800002; + + // Shared header -> shared block hash -> shared checkpoint across cases. + BlockType hdr_block = make_block(/*nonce=*/7); + const BlockHeaderType& header = static_cast(hdr_block); + const uint256 blk_hash = block_hash(header); + + MutableTransaction coinbase = make_tx(OUTPOINT_CB, 0xffffffff, 50); + MutableTransaction tx_A = make_tx(OUTPOINT_A, 0, 100); // mempool-known + MutableTransaction tx_M = make_tx(OUTPOINT_M, 0, 200); // missing from mempool + MutableTransaction tx_C = make_tx(OUTPOINT_C, 0, 300); // unrelated survivor + const uint256 id_A = compute_txid(tx_A); + const uint256 id_M = compute_txid(tx_M); + const uint256 id_C = compute_txid(tx_C); + CHECK(id_A != id_M && id_A != id_C && id_M != id_C); + + // ---- 1) Complete-from-mempool: no getblocktxn round ------------------ + { + HeaderChain chain = make_chain_pinning(header, H); + CHECK(chain.init()); + CHECK(chain.tip() && chain.tip()->block_hash == blk_hash); + + Mempool pool; + CHECK(pool.add_tx(tx_A)); + CHECK(pool.add_tx(tx_C)); + CHECK(pool.size() == 2); + + BlockConnector conn(chain, pool); + + // Block body = [coinbase, tx_A]; tx_A is in the mempool -> reconstructs + // with no missing txs. + CompactBlock cb = BuildCompactBlock(header, {coinbase, tx_A}, /*nonce=*/99); + std::optional req = conn.on_compact_block(cb); + + CHECK(!req.has_value()); // fully reconstructed, no round-trip + CHECK(conn.pending_compact_count() == 0); // nothing parked + CHECK(!pool.contains(id_A)); // tx_A confirmed -> reconciled out + CHECK(pool.contains(id_C)); // unrelated tx survives + CHECK(pool.size() == 1); + } + + // ---- 2) Missing tx -> getblocktxn -> blocktxn completes -------------- + { + HeaderChain chain = make_chain_pinning(header, H); + CHECK(chain.init()); + + Mempool pool; + CHECK(pool.add_tx(tx_C)); // unrelated; tx_M deliberately absent + CHECK(pool.size() == 1); + + BlockConnector conn(chain, pool); + + // Block body = [coinbase, tx_M]; tx_M not in mempool -> index 1 missing. + CompactBlock cb = BuildCompactBlock(header, {coinbase, tx_M}, /*nonce=*/99); + std::optional req = conn.on_compact_block(cb); + + CHECK(req.has_value()); // round-trip needed + CHECK(req && req->blockhash == blk_hash); + CHECK(req && req->indexes.size() == 1 && req->indexes[0] == 1); + CHECK(conn.pending_compact_count() == 1); // parked awaiting blocktxn + CHECK(pool.contains(id_C)); // nothing reconciled yet + + // Peer answers with the requested tx (response order == request order). + BlockTransactionsResponse resp; + resp.blockhash = blk_hash; + resp.txs.push_back(tx_M); + bool connected = conn.on_block_txn(resp); + + CHECK(connected); // reconstruction completed + CHECK(conn.pending_compact_count() == 0); // parked entry cleared + } + + // ---- 3) Unknown/expired blocktxn is a clean no-op -------------------- + { + HeaderChain chain = make_chain_pinning(header, H); + CHECK(chain.init()); + Mempool pool; + BlockConnector conn(chain, pool); + + BlockTransactionsResponse resp; + resp.blockhash.SetHex( + "dead00000000000000000000000000000000000000000000000000000000beef"); + resp.txs.push_back(tx_M); + bool connected = conn.on_block_txn(resp); + + CHECK(!connected); // nothing parked -> no-op + CHECK(conn.pending_compact_count() == 0); + } + + if (failures == 0) { + std::cout << "compact_block_connector_test: ALL PASS\n"; + return 0; + } + std::cerr << "compact_block_connector_test: " << failures << " FAILURE(S)\n"; + return 1; +} diff --git a/src/impl/bch/test/embedded_block_broadcast_test.cpp b/src/impl/bch/test/embedded_block_broadcast_test.cpp new file mode 100644 index 000000000..9af308c08 --- /dev/null +++ b/src/impl/bch/test/embedded_block_broadcast_test.cpp @@ -0,0 +1,82 @@ +// --------------------------------------------------------------------------- +// bch::coin::EmbeddedDaemon::broadcast_won_block dual-path contract test +// (M5 -- embedded body; broadcaster-gate A+B). +// +// The gate requires a won block to reach the network down BOTH paths: embedded +// P2P relay (submit_block_p2p_raw, PRIMARY) AND external BCHN submitblock +// (CoinNode::submit_block_hex, FALLBACK -- always fired, not a P2P-only catch). +// broadcast_won_block() is that production dispatcher; the pool node wires +// tracker().m_on_block_found -> this method so an in-operation win emits +// immediately (not only on the init sweep, 30d7f2c2). +// +// This is the OFFLINE contract slice: with no network bring-up (no run()/ +// start_p2p), NEITHER sink is live, so the method must hit its guard and report +// any()=false / landed_first="none" WITHOUT throwing. It proves the dispatcher +// is reachable from the daemons real members and its no-sink contract holds. +// Proving both paths actually FIRE+ACCEPT live is the VM300 bchn-bch +// (192.168.86.110:8333) soak (gate criterion C) -- a separate, read-only slice; +// code-exists != fires, so it is NOT claimed here. +// +// Build-INERT / source-only: impl_bch stays unregistered in CMake (bch = +// skip-green). p2pool-merged-v36 surface: NONE (block dispatch, not +// share/PPLNS/coinbase/AuxPoW bytes). +// --------------------------------------------------------------------------- + +#include +#include +#include +#include + +#include + +#include "../coin/embedded_daemon.hpp" + +namespace { + +int failures = 0; +#define CHECK(cond) do { if (!(cond)) { \ + std::cerr << "FAIL: " #cond " @ line " << __LINE__ << "\n"; ++failures; } } while (0) + +struct TestConfig { + bool m_testnet = false; + struct P2P { std::vector prefix; }; + struct Coin { P2P m_p2p; }; + Coin m_coin; + const Coin* coin() const { return &m_coin; } +}; + +} // namespace + +int main() { + boost::asio::io_context ioc; + TestConfig config; + + bch::coin::EmbeddedDaemon daemon(&ioc, &config, /*anchor_height=*/955700); + daemon.assemble(); // network-free: builds the seam, but no P2P/RPC sink live + + // A dummy won block (header||tx_count||coinbase||... shape is opaque to the + // dispatcher -- it relays bytes and submits hex; no parse/PoW recompute). + const std::vector block_bytes(80, 0x00); + const std::string block_hex(160, 0); + + auto r = daemon.broadcast_won_block(block_bytes, block_hex); + + // Offline contract: no P2P sink (start_p2p never ran), no RPC fallback + // (run()/init_rpc never ran) -> guard fires, nothing relayed, no throw. + CHECK(!r.p2p_sent); + CHECK(!r.rpc_ok); + CHECK(!r.any()); + CHECK(std::string(r.landed_first) == "none"); + + // The seam is still intact after a guarded broadcast attempt. + CHECK(daemon.seam_ready()); + CHECK(!daemon.coin_node().has_rpc()); // confirms why the fallback leg was skipped + CHECK(!daemon.node().has_p2p()); // confirms why the primary leg was skipped + + if (failures == 0) { + std::cout << "embedded_block_broadcast_test: ALL PASS\n"; + return 0; + } + std::cerr << "embedded_block_broadcast_test: " << failures << " FAILURE(S)\n"; + return 1; +} diff --git a/src/impl/bch/test/embedded_daemon_assembly_test.cpp b/src/impl/bch/test/embedded_daemon_assembly_test.cpp new file mode 100644 index 000000000..be92883a2 --- /dev/null +++ b/src/impl/bch/test/embedded_daemon_assembly_test.cpp @@ -0,0 +1,118 @@ +// --------------------------------------------------------------------------- +// bch::coin::EmbeddedDaemon assembly test (M5 -- embedded body). +// +// 95c80402 proved EmbeddedCoinNode::getwork() satisfies the CoinNode (ICoinNode) +// seam via a *test-local* CoinNode(&emb, nullptr). What was still uncovered is +// the EmbeddedDaemon CLUSTER itself: that the daemon, from its OWN real members, +// closes the ABLA loop and builds the embedded-primary CoinNode seam -- the +// wiring that lived buried inside run() behind the network bring-up and so could +// not be instantiated, let alone verified. This test instantiates the REAL +// EmbeddedDaemon and drives the network-free assemble() seam-build +// against its own real EmbeddedCoinNode (no fake, per integrator scope #1): +// +// 1. PRE-assemble -- seam_ready()=false, is_wired()=false (nothing built yet). +// 2. assemble() -- network-free: closes ABLA loop + builds the seam WITHOUT +// m_node.run() (no RPC/P2P connect). After it: +// - seam_ready()=true, is_wired()=true +// - coin_node().is_embedded()=true (embedded primary) +// - coin_node().has_rpc()=false (RPC fallback absent +// offline -- bound live only when run() precedes it) +// 3. REAL not FAKE -- daemon.embedded().is_synced()=false on a fresh, un-init'd +// chain (a FakeEmbedded would report synced=true): the seam +// is backed by the daemon's genuine EmbeddedCoinNode. +// 4. IDEMPOTENT -- a second assemble() is a no-op: the same CoinNode object +// (stable address) and no re-wire. +// 5. COLD-START ANCHOR (integrator scope #2: VM300 = build-validation only) -- +// dry_run_bchn_anchor() reads the STATIC recorded anchor and +// logs the floor no-op; the live VM is never touched and the +// real reanchor stays operator-gated. +// +// Build-INERT / source-only: impl_bch stays unregistered in CMake (bch = +// skip-green; don't race ci-steward). p2pool-merged-v36 surface: NONE -- pure +// local daemon assembly; no PoW/share/coinbase/AuxPoW/PPLNS/WorkData-shape change. +// --------------------------------------------------------------------------- + +#include +#include +#include + +#include + +#include "../coin/embedded_daemon.hpp" + +namespace { + +int failures = 0; +#define CHECK(cond) do { if (!(cond)) { \ + std::cerr << "FAIL: " #cond " @ line " << __LINE__ << "\n"; ++failures; } } while (0) + +// Minimal duck-typed config: EmbeddedDaemon's ctor + assemble() path only reads +// m_testnet (chain-params selection, EmbeddedCoinNode + AblaRuntime testnet flag). +// The external RPC/P2P config fields (coin()->m_p2p / m_rpc) are touched only by +// Node::run()/init_rpc(), which this offline test never calls. +struct TestConfig { + bool m_testnet = false; + // Duck-typed coin()->m_p2p.prefix: NodeP2P::get_prefix() is a virtual + // override (eagerly instantiated via the vtable) and reads it. Empty here -- + // assemble() never sends a P2P message, so the value is inert. + struct P2P { std::vector prefix; }; + struct Coin { P2P m_p2p; }; + Coin m_coin; + const Coin* coin() const { return &m_coin; } +}; + +} // namespace + +int main() { + boost::asio::io_context ioc; + TestConfig config; + + // Cold-start floor anchor at a representative mainnet-ish height; assemble() + // does not consult it (the ABLA tracker is floor-anchored in the ctor), so the + // exact value only matters once a block-size fold occurs. + bch::coin::EmbeddedDaemon daemon(&ioc, &config, /*anchor_height=*/955700); + + // 1) Nothing assembled yet. + CHECK(!daemon.seam_ready()); + CHECK(!daemon.is_wired()); + + // 2) Network-free assembly: ABLA loop closed + embedded-primary seam built. + daemon.assemble(); + CHECK(daemon.seam_ready()); + CHECK(daemon.is_wired()); + CHECK(daemon.coin_node().is_embedded()); // embedded work source = primary + CHECK(!daemon.coin_node().has_rpc()); // RPC fallback absent offline + + // 3) The seam is backed by the daemon's REAL EmbeddedCoinNode, not a fake: + // a fresh, un-init'd HeaderChain reports NOT synced (FakeEmbedded=true). + CHECK(!daemon.embedded().is_synced()); + + // 4) assemble() is idempotent: same CoinNode object, no re-wire. + bch::coin::CoinNode* before = &daemon.coin_node(); + daemon.assemble(); + CHECK(&daemon.coin_node() == before); + CHECK(daemon.seam_ready()); + + // 5) Cold-start anchor DRY RUN: record-only, VM300 untouched, floor no-op. + // (Just exercises the path -- it logs and must not throw or mutate.) + daemon.dry_run_bchn_anchor(); + CHECK(daemon.is_wired()); // unchanged by the dry run + + // 6) Cold-start anchor LIVE PIN (operator-approved, decisions@ 2026-06-18, + // dry-run -> live flip). The recorded @955700 control state is at the + // 32 MB floor, so the pin is floor-equivalent: it reanchors but the ABLA + // budget stays exactly the 32 MB consensus floor (never undercuts). + using Rec = bch::coin::BchnAnchorRecord; + daemon.pin_cold_start_anchor(); + CHECK(daemon.is_wired()); // still wired after the pin + CHECK(Rec::is_floor()); // record provenance: at floor + CHECK(daemon.abla().tracker().budget_for_tip(Rec::height) + == Rec::abla_blocksizelimit); // floor-equivalent: 32 MB + + if (failures == 0) { + std::cout << "embedded_daemon_assembly_test: ALL PASS\n"; + return 0; + } + std::cerr << "embedded_daemon_assembly_test: " << failures << " FAILURE(S)\n"; + return 1; +} diff --git a/src/impl/bch/test/embedded_seam_workview_test.cpp b/src/impl/bch/test/embedded_seam_workview_test.cpp new file mode 100644 index 000000000..f4ff4b1c5 --- /dev/null +++ b/src/impl/bch/test/embedded_seam_workview_test.cpp @@ -0,0 +1,116 @@ +// --------------------------------------------------------------------------- +// bch::coin::EmbeddedDaemon production-seam WorkView test (M5 -- embedded body). +// +// Three sibling tests bracket the CoinNode seam but leave ONE production path +// uncovered: +// * coin_node_seam_test -- drives get_work_view()/submit_block_hex() but +// through a *fake* CoinNodeInterface (synced=true). +// * embedded_getwork_test -- drives get_work_view() against the REAL +// EmbeddedCoinNode, but via a *test-local* +// CoinNode(&emb, nullptr) -- NOT the seam the +// daemon actually hands to web_server. +// * embedded_daemon_assembly_test -- builds the daemon's REAL seam +// (daemon.coin_node()) but only inspects its +// flags (is_embedded/has_rpc); it never DRIVES +// get_work_view()/submit_block_hex() through it. +// +// What none of them assert: the daemon's OWN assembled production seam, DRIVEN, +// at cold start. web_server calls coin_node().get_work_view() the instant the +// daemon is up -- before header sync completes. The contract it depends on is +// that the embedded sync gate PROPAGATES through the seam as a thrown +// std::exception (never a half-built template, never a crash), and that +// submit_block_hex() on the embedded-primary / no-RPC-fallback offline seam is +// the false guard (not a throw). This test closes exactly that gap by driving +// the REAL EmbeddedDaemon::coin_node() seam (no fake, no test-local +// CoinNode) against the daemon's own genesis-seeded-but-not-yet-synced chain: +// +// 1. ASSEMBLE -- network-free; production seam built embedded-primary, +// RPC fallback absent offline (is_embedded=true, +// has_rpc=false) -- the exact shape handed to web_server. +// 2. COLD START -- daemon.chain().init() seeds genesis (height 0); the +// genesis timestamp is decades old so is_synced()=false: +// a realistic "daemon up, headers still syncing" state. +// 3. SYNC GATE through the REAL seam -- coin_node().get_work_view() THROWS +// (EmbeddedCoinNode::getwork sync gate propagated across +// the ICoinNode seam), proving web_server gets an +// exception, not a malformed WorkView, at cold start. +// 4. SUBMIT GUARD -- coin_node().submit_block_hex("00", true) == false on the +// no-RPC-sink offline seam: the guard, not a throw. +// +// Build-INERT / source-only (matches the sibling tests): impl_bch stays +// unregistered in CMake (bch = skip-green; don't race ci-steward). Verified with +// -fsyntax-only and standalone compile+run. p2pool-merged-v36 surface: NONE -- +// pure local seam exercise; no PoW/share/coinbase/AuxPoW/PPLNS/WorkData-shape +// change; the WorkData getwork would emit is the slice the sweep pinned. +// --------------------------------------------------------------------------- + +#include +#include +#include +#include + +#include + +#include "../coin/embedded_daemon.hpp" + +namespace { + +int failures = 0; +#define CHECK(cond) do { if (!(cond)) { \ + std::cerr << "FAIL: " #cond " @ line " << __LINE__ << "\n"; ++failures; } } while (0) + +// Same duck-typed config the assembly test uses: the ctor + assemble() path +// reads only m_testnet (chain-params / EmbeddedCoinNode / AblaRuntime flag) and, +// via the eagerly-instantiated NodeP2P vtable, coin()->m_p2p.prefix. The +// external RPC/P2P config fields are touched only by Node::run()/init_rpc(), +// which this offline test never calls. +struct TestConfig { + bool m_testnet = false; + struct P2P { std::vector prefix; }; + struct Coin { P2P m_p2p; }; + Coin m_coin; + const Coin* coin() const { return &m_coin; } +}; + +} // namespace + +int main() { + boost::asio::io_context ioc; + TestConfig config; + + bch::coin::EmbeddedDaemon daemon(&ioc, &config, /*anchor_height=*/955700); + + // 1) Build the production seam network-free (the seam web_server receives). + daemon.assemble(); + CHECK(daemon.seam_ready()); + CHECK(daemon.coin_node().is_embedded()); // embedded-primary + CHECK(!daemon.coin_node().has_rpc()); // RPC fallback absent offline + + // 2) Cold start: seed genesis; the daemon is up but not yet header-synced. + CHECK(daemon.chain().init()); // default mainnet -> genesis seed + CHECK(!daemon.chain().is_synced()); // genesis ts decades old + CHECK(!daemon.embedded().is_synced()); // REAL embedded node, not a fake + + // 3) Sync gate PROPAGATES through the daemon's REAL seam: web_server gets a + // thrown exception, never a half-built WorkView, at cold start. + { + bool threw = false; + try { + (void)daemon.coin_node().get_work_view(); + } catch (const std::exception&) { + threw = true; + } + CHECK(threw); + } + + // 4) submit on the embedded-primary / no-RPC-fallback offline seam: the + // false guard, NOT a throw (web_server treats false as "no sink"). + CHECK(daemon.coin_node().submit_block_hex("00", /*ignore_failure=*/true) == false); + + if (failures == 0) { + std::cout << "embedded_seam_workview_test: ALL PASS\n"; + return 0; + } + std::cerr << "embedded_seam_workview_test: " << failures << " FAILURE(S)\n"; + return 1; +} diff --git a/src/impl/bch/test/genesis_conformance_test.cpp b/src/impl/bch/test/genesis_conformance_test.cpp new file mode 100644 index 000000000..12a691632 --- /dev/null +++ b/src/impl/bch/test/genesis_conformance_test.cpp @@ -0,0 +1,67 @@ +// --------------------------------------------------------------------------- +// bch::coin::BCHChainParams -- genesis-hash conformance vs Bitcoin Cash Node. +// +// Pins the mainnet / testnet3 / testnet4 genesis block hashes that the embedded +// daemon's HeaderChain seeds as its chain root against the AUTHORITATIVE values +// in BCHN v29.0.0 src/chainparams.cpp (commit 89a591f). The literals below are +// transcribed verbatim from BCHN's `assert(consensus.hashGenesisBlock == ...)` +// statements -- NOT copied from header_chain.hpp -- so this is a real reference +// pin: a future edit that silently drifts a genesis constant fails here, even +// though header-internal round-trips would still pass. +// +// mainnet chainparams.cpp:203 uint256S("000000000019d6...8ce26f") (== BTC) +// testnet3 chainparams.cpp:456 uint256S("000000000933ea...7f4943") (== BTC) +// testnet4 chainparams.cpp:676 BlockHash::fromHex("00000000...fd9f7b") (own) +// +// mainnet/testnet3 share Bitcoin's pre-fork genesis (BCH forked at height +// 478558); testnet4 has its OWN genesis -- a BCH-specific consensus constant. +// Closes header_chain.hpp:188 TODO(M3) "verify genesis_hash vs VM300 bchn-bch +// chainparams.cpp" -- verified vs the BCHN v29.0.0 source the VM300 node runs. +// +// p2pool-merged-v36 surface: NONE. per-coin isolation: src/impl/bch/ only. +// Header-only over coin/header_chain.hpp (no peer, socket, or coin lib). +// --------------------------------------------------------------------------- + +#include +#include +#include + +#include "../coin/header_chain.hpp" + +using bch::coin::BCHChainParams; + +int main() +{ + // BCHN v29.0.0 src/chainparams.cpp authoritative genesis constants. + const std::string BCHN_MAINNET_GENESIS = + "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"; + const std::string BCHN_TESTNET3_GENESIS = + "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943"; + const std::string BCHN_TESTNET4_GENESIS = + "000000001dd410c49a788668ce26751718cc797474d3152a5fc073dd44fd9f7b"; + + const auto mainnet = BCHChainParams::mainnet(); + const auto testnet3 = BCHChainParams::testnet(); + const auto testnet4 = BCHChainParams::testnet4(); + + assert(mainnet.genesis_hash.GetHex() == BCHN_MAINNET_GENESIS); + assert(testnet3.genesis_hash.GetHex() == BCHN_TESTNET3_GENESIS); + assert(testnet4.genesis_hash.GetHex() == BCHN_TESTNET4_GENESIS); + + // The fast-start checkpoint root must seed AT genesis (height 0) with the + // genesis hash -- the cold-start IBD invariant the HeaderChain relies on. + assert(mainnet.fast_start_checkpoint.has_value()); + assert(testnet3.fast_start_checkpoint.has_value()); + assert(testnet4.fast_start_checkpoint.has_value()); + assert(mainnet.fast_start_checkpoint->height == 0); + assert(mainnet.fast_start_checkpoint->hash == mainnet.genesis_hash); + assert(testnet3.fast_start_checkpoint->hash == testnet3.genesis_hash); + assert(testnet4.fast_start_checkpoint->hash == testnet4.genesis_hash); + + // mainnet and testnet3 share Bitcoin's pre-fork genesis; testnet4 diverges. + assert(testnet4.genesis_hash != mainnet.genesis_hash); + assert(testnet4.genesis_hash != testnet3.genesis_hash); + + std::cout << "bch genesis conformance vs BCHN v29.0.0: ALL PASS\n"; + return 0; +} diff --git a/src/impl/bch/test/header_sync_progress_test.cpp b/src/impl/bch/test/header_sync_progress_test.cpp new file mode 100644 index 000000000..4a13b68c1 --- /dev/null +++ b/src/impl/bch/test/header_sync_progress_test.cpp @@ -0,0 +1,149 @@ +// --------------------------------------------------------------------------- +// bch::coin::header_sync SYNC-TO-PEER-TIP sequence soak (M5 full-block body). +// +// header_sync_test.cpp pins classify_headers_batch() one batch at a time. But +// the property the embedded daemon actually relies on is COMPOSITIONAL: that +// the per-batch policy, driven in a loop, makes cold-start IBD CONVERGE -- walk +// the whole header chain from the anchor and HALT exactly at the peer tip, +// without (a) stalling mid-chain after one batch, or (b) spinning forever on +// ContinueSync. A single-batch unit test cannot observe either failure mode. +// This fixture drives the real sync loop NodeP2P runs (getheaders -> ingest -> +// classify -> re-issue) as a pure simulation over batch sizes and pins the +// convergence + steady-state-follow claims directly. +// +// Invariants asserted: +// 1. cold-start IBD over a large gap CONVERGES: synced == peer_tip exactly +// (no overshoot, no header left behind) for both a non-multiple gap and +// an exact-multiple-of-cap gap. +// 2. the loop TERMINATES: round count is bounded at floor(gap/cap)+1 for +// every gap -- the exact-multiple case takes one trailing empty/partial +// round and then halts (never an infinite ContinueSync). +// 3. mid-IBD rounds are all ContinueSync until the final round; the final +// round is the ONLY non-ContinueSync (the convergence point). +// 4. steady-state follow: once caught up, a BIP130 single-block tip announce +// drives RequestBlocks (fetch the new block) -- NOT a spurious re-entry +// into ContinueSync IBD. +// 5. degenerate gaps (0 and 1) behave: empty chain -> immediate Idle in one +// round; one-header gap -> RequestBlocks (announce) in one round. +// +// Build-INERT / source-only: header-only over coin/header_sync.hpp -- no node, +// socket, RPC, or coin lib (impl_bch stays unregistered; bch = skip-green, +// don't race ci-steward). p2pool-merged-v36 surface: NONE -- pure SPV/IBD wire- +// sync plumbing (no PoW hash, share format, coinbase commitment, AuxPoW, or +// PPLNS math). per-coin isolation: src/impl/bch/coin/ only. +// --------------------------------------------------------------------------- + +#include +#include +#include + +#include "../coin/header_sync.hpp" + +using bch::coin::header_sync::Followup; +using bch::coin::header_sync::classify_headers_batch; +using bch::coin::header_sync::MAX_HEADERS_RESULTS; +using bch::coin::header_sync::DEFAULT_ANNOUNCE_THRESHOLD; + +namespace { + +int failures = 0; +#define CHECK(cond) do { if (!(cond)) { \ + std::cerr << "FAIL: " #cond " @ line " << __LINE__ << "\n"; ++failures; } } while (0) + +// Result of driving the headers-first IBD loop against a peer that holds +// `gap` headers beyond our anchor, serving at most `cap` per `headers` message. +struct SyncRun { + std::uint64_t synced = 0; // headers ingested when the loop halted + std::size_t rounds = 0; // getheaders round-trips taken + std::size_t continue_n = 0; // rounds that classified ContinueSync + Followup final_action = Followup::Idle; // why the loop halted +}; + +// Mirror of NodeP2P's `headers` follow-up loop: request up to `cap`, ingest the +// batch, classify; ContinueSync re-issues, anything else halts the IBD walk. +// MAX_ROUNDS is a TEST-SIDE runaway guard -- if the policy ever fails to +// converge, we trip it and the bound assertion (inv. 2) catches the bug instead +// of the test hanging. +SyncRun drive_ibd(std::uint64_t gap, std::size_t cap = MAX_HEADERS_RESULTS, + std::size_t announce = DEFAULT_ANNOUNCE_THRESHOLD) +{ + SyncRun r; + const std::size_t MAX_ROUNDS = 1'000'000; + while (r.rounds < MAX_ROUNDS) { + ++r.rounds; + std::uint64_t remaining = gap - r.synced; + std::size_t batch = (remaining >= cap) ? cap + : static_cast(remaining); + r.synced += batch; + Followup f = classify_headers_batch(batch, announce, cap); + if (f == Followup::ContinueSync) { ++r.continue_n; continue; } + r.final_action = f; + break; + } + return r; +} + +} // namespace + +int main() +{ + const std::size_t CAP = MAX_HEADERS_RESULTS; // 2000 + + // ---- inv. 1 + 2 + 3: non-multiple gap (realistic cold-start anchor) ----- + // 955700 headers ~ a fresh sync to a near-tip BCH peer from the anchor. + { + const std::uint64_t gap = 955700; + SyncRun r = drive_ibd(gap); + CHECK(r.synced == gap); // converged exactly (inv.1) + const std::size_t expect_rounds = gap / CAP + 1; // 477 full + 1 partial + CHECK(r.rounds == expect_rounds); // bounded + terminates (inv.2) + CHECK(r.continue_n == r.rounds - 1); // all but last = ContinueSync (inv.3) + // 955700 % 2000 == 1700, above the announce threshold -> caught-up Idle. + CHECK(r.final_action == Followup::Idle); + } + + // ---- inv. 1 + 2: exact-multiple gap takes a trailing round, still halts -- + { + const std::uint64_t gap = 954000; // == 477 * 2000, exact multiple + SyncRun r = drive_ibd(gap); + CHECK(r.synced == gap); // converged exactly + // 477 maximal ContinueSync rounds, then one remaining==0 (empty) Idle round. + CHECK(r.rounds == gap / CAP + 1); // 478, NOT infinite + CHECK(r.continue_n == gap / CAP); // exactly the 477 full batches + CHECK(r.final_action == Followup::Idle); // empty trailing batch -> Idle + } + + // ---- inv. 2 (bound holds across many gap shapes) ------------------------ + for (std::uint64_t gap = 0; gap <= 8005; ++gap) { + SyncRun r = drive_ibd(gap); + CHECK(r.synced == gap); // always converges + CHECK(r.rounds == gap / CAP + 1); // always bounded + CHECK(r.rounds <= 5); // <= floor(8005/2000)+1 = 5 + } + + // ---- inv. 4: steady-state follow after caught up ------------------------ + // A single new block announced via BIP130 -> RequestBlocks (fetch it), and + // crucially NOT ContinueSync (no spurious IBD re-entry on a 1-block tip). + CHECK(classify_headers_batch(1) == Followup::RequestBlocks); + { + SyncRun tip_advance = drive_ibd(/*gap=*/1); + CHECK(tip_advance.rounds == 1); // one round, no IBD walk + CHECK(tip_advance.continue_n == 0); // never entered ContinueSync + CHECK(tip_advance.final_action == Followup::RequestBlocks); + } + + // ---- inv. 5: degenerate gaps -------------------------------------------- + { + SyncRun empty = drive_ibd(/*gap=*/0); + CHECK(empty.synced == 0); + CHECK(empty.rounds == 1); // single empty batch + CHECK(empty.continue_n == 0); + CHECK(empty.final_action == Followup::Idle); // nothing arrived -> Idle + } + + if (failures == 0) + std::cout << "bch header_sync sync-to-peer-tip soak: ALL PASS\n"; + else + std::cout << "bch header_sync sync-to-peer-tip soak: " << failures << " FAIL\n"; + return failures == 0 ? 0 : 1; +} diff --git a/src/impl/bch/test/header_sync_test.cpp b/src/impl/bch/test/header_sync_test.cpp new file mode 100644 index 000000000..b2f55716a --- /dev/null +++ b/src/impl/bch/test/header_sync_test.cpp @@ -0,0 +1,58 @@ +// --------------------------------------------------------------------------- +// bch::coin::header_sync::classify_headers_batch -- M5 full-block body. +// Pins the headers-first IBD continuation policy that NodeP2P's `headers` +// handler dispatches on. Pure function over a batch size, so this test needs +// NO peer, socket, or coin lib -- header-only over coin/header_sync.hpp. +// +// The behavior under test (the gap this slice closed): a MAXIMAL headers batch +// (== MAX_HEADERS_RESULTS) must drive ContinueSync so cold-start IBD walks the +// whole header chain instead of stalling after the first batch; a small BIP130 +// tip-announce batch (<= threshold) drives RequestBlocks; everything else +// (empty, or a non-maximal "caught up" batch) is Idle. +// +// p2pool-merged-v36 surface: NONE. per-coin isolation: src/impl/bch/ only. +// --------------------------------------------------------------------------- + +#include +#include + +#include "../coin/header_sync.hpp" + +using bch::coin::header_sync::Followup; +using bch::coin::header_sync::classify_headers_batch; +using bch::coin::header_sync::MAX_HEADERS_RESULTS; +using bch::coin::header_sync::DEFAULT_ANNOUNCE_THRESHOLD; + +int main() +{ + // Empty batch: nothing arrived. + assert(classify_headers_batch(0) == Followup::Idle); + + // BIP130 tip-announce sizes (1..threshold) -> request the announced blocks. + assert(classify_headers_batch(1) == Followup::RequestBlocks); + assert(classify_headers_batch(DEFAULT_ANNOUNCE_THRESHOLD) == Followup::RequestBlocks); + + // Just above the announce threshold but well below the cap: a partial IBD + // batch means we've reached the peer's tip -> Idle (no further getheaders). + assert(classify_headers_batch(DEFAULT_ANNOUNCE_THRESHOLD + 1) == Followup::Idle); + assert(classify_headers_batch(MAX_HEADERS_RESULTS - 1) == Followup::Idle); + + // Maximal batch (== cap): the peer capped its response because it has more + // -> ContinueSync (re-issue getheaders for the next batch). THE FIX. + assert(classify_headers_batch(MAX_HEADERS_RESULTS) == Followup::ContinueSync); + // Defensive: a batch somehow larger than the cap is still ContinueSync. + assert(classify_headers_batch(MAX_HEADERS_RESULTS + 1) == Followup::ContinueSync); + + // ContinueSync dominates RequestBlocks at the cap even with a wide announce + // threshold (ordering of the policy checks is correct). + assert(classify_headers_batch(MAX_HEADERS_RESULTS, MAX_HEADERS_RESULTS) == Followup::ContinueSync); + + // Custom thresholds: announce_threshold widens the RequestBlocks band. + assert(classify_headers_batch(5, /*announce=*/5) == Followup::RequestBlocks); + assert(classify_headers_batch(6, /*announce=*/5) == Followup::Idle); + // Custom small cap: at-cap batch is ContinueSync. + assert(classify_headers_batch(10, /*announce=*/3, /*cap=*/10) == Followup::ContinueSync); + + std::cout << "bch header_sync classify: ALL PASS\n"; + return 0; +} diff --git a/src/impl/bch/test/reorg_connect_test.cpp b/src/impl/bch/test/reorg_connect_test.cpp new file mode 100644 index 000000000..627f3cf2f --- /dev/null +++ b/src/impl/bch/test/reorg_connect_test.cpp @@ -0,0 +1,173 @@ +// --------------------------------------------------------------------------- +// bch::coin BlockConnector -- REORG / DISCONNECT leg (M5 full-block body). +// +// The forward UTXO-connect leg (utxo_connect_test) only ever moved the view +// forward and DISCARDED the BlockUndo connect_block() returned. This test pins +// the now-retained undo + reorg path: when the best chain switches branches, +// the connector must roll the UTXO view back to the fork point (restore spent +// inputs, remove created outputs), return the orphaned branch's txs to the +// mempool, then re-apply the new branch. +// +// Topology (all equal-work headers; B-branch is 1 block longer => it wins): +// +// seed(H) --- A1(H+1, spends X) <- connected first (tip=A1) +// \ +// `---- B1(H+1, spends Y) --- B2(H+2) <- becomes best tip => REORG +// +// After the reorg to B2 the connector must have: +// - DISCONNECTED A1: coin X (spent only by A1) restored live; A1's tx back in +// the mempool (and, since X is live again, it survives the Phase-4 sweep). +// - CONNECTED B1+B2: coin Y (spent by B1) now gone; undo depth = 2. +// - coin Z (never referenced) live throughout (control). +// +// PoW is skipped via a far-ahead peer tip; the checkpoint is seeded BELOW the +// ASERT anchor (661647) so a 2-deep branch is trusted structurally. Build +// posture matches the other connector tests: header-only over coin/*.hpp + +// plus transaction.cpp; no impl_bch lib. p2pool-merged-v36 surface: NONE. +// --------------------------------------------------------------------------- + +#include +#include + +#include "../coin/block.hpp" +#include "../coin/block_connector.hpp" +#include "../coin/header_chain.hpp" +#include "../coin/mempool.hpp" +#include "../coin/transaction.hpp" + +#include + +namespace { + +int failures = 0; +#define CHECK(cond) do { if (!(cond)) { \ + std::cerr << "FAIL: " #cond " @ line " << __LINE__ << "\n"; ++failures; } } while (0) + +bch::coin::MutableTransaction make_tx(const char* prev_hex, uint32_t prev_index, + int64_t out_value) { + bch::coin::MutableTransaction tx; + bch::coin::TxIn in; + in.prevout.hash.SetHex(prev_hex); + in.prevout.index = prev_index; + in.sequence = 0xffffffff; + tx.vin.push_back(in); + bch::coin::TxOut out; + out.value = out_value; + tx.vout.push_back(out); + return tx; +} + +// A coinbase-position tx (distinct per nonce so blocks don't collide on txid). +bch::coin::MutableTransaction make_coinbase(uint32_t tag) { + char buf[65]; + std::snprintf(buf, sizeof(buf), + "%064x", tag); + return make_tx(buf, 0xffffffff, 5000000000LL); +} + +bch::coin::BlockType make_block(const uint256& prev, uint32_t nonce) { + bch::coin::BlockType b; + b.m_version = 0x20000000; + b.m_previous_block = prev; + b.m_merkle_root.SetHex( + "1111111111111111111111111111111111111111111111111111111111111111"); + b.m_timestamp = 1500000000 + nonce; // pre-ASERT-anchor era timestamps + b.m_bits = 0x1d00ffff; + b.m_nonce = nonce; + return b; +} + +const char* OUTPOINT_X = "aa00000000000000000000000000000000000000000000000000000000000000"; +const char* OUTPOINT_Y = "bb00000000000000000000000000000000000000000000000000000000000000"; +const char* OUTPOINT_Z = "cc00000000000000000000000000000000000000000000000000000000000000"; + +} // namespace + +int main() { + using namespace bch::coin; + + // Checkpoint BELOW the ASERT anchor (661647) -> difficulty trusted + // structurally for the whole branch (validate_difficulty early-out). + const uint32_t H = 600000; + + // seed = pre-connector base (checkpoint tip). Not fed to the connector. + BlockType seed = make_block(uint256{} /*null prev*/, /*nonce=*/0); + const ::uint256 seed_hash = block_hash(static_cast(seed)); + + // Branch A: one block spending X. + BlockType A1 = make_block(seed_hash, /*nonce=*/1); + A1.m_txs.push_back(make_coinbase(0xA0)); + A1.m_txs.push_back(make_tx(OUTPOINT_X, 0, 4000)); + const ::uint256 a1_hash = block_hash(static_cast(A1)); + const ::uint256 id_a1_spend = compute_txid(A1.m_txs[1]); + + // Branch B: B1 spends Y, B2 extends B1 (=> B-branch is longer, wins). + BlockType B1 = make_block(seed_hash, /*nonce=*/2); + B1.m_txs.push_back(make_coinbase(0xB1)); + B1.m_txs.push_back(make_tx(OUTPOINT_Y, 0, 4000)); + const ::uint256 b1_hash = block_hash(static_cast(B1)); + + BlockType B2 = make_block(b1_hash, /*nonce=*/3); + B2.m_txs.push_back(make_coinbase(0xB2)); + const ::uint256 b2_hash = block_hash(static_cast(B2)); + + CHECK(a1_hash != b1_hash && b1_hash != b2_hash && a1_hash != b2_hash); + + // Header chain seeded at the synthetic checkpoint = seed_hash @ H. + BCHChainParams params = BCHChainParams::mainnet(); + params.fast_start_checkpoint = BCHChainParams::Checkpoint{H, seed_hash}; + HeaderChain chain(params); + CHECK(chain.init()); + CHECK(chain.tip() && chain.tip()->block_hash == seed_hash); + chain.set_peer_tip_height(H + 5000); // far-ahead tip => structural-only (PoW skipped) + + // UTXO view: X, Y, Z all live at start. + uint256 hx; hx.SetHex(OUTPOINT_X); + uint256 hy; hy.SetHex(OUTPOINT_Y); + uint256 hz; hz.SetHex(OUTPOINT_Z); + const core::coin::Outpoint op_X(hx, 0), op_Y(hy, 0), op_Z(hz, 0); + + core::coin::UTXOViewCache utxo(nullptr); + utxo.add_coin(op_X, core::coin::Coin(5000, {}, 1, false)); + utxo.add_coin(op_Y, core::coin::Coin(5000, {}, 1, false)); + utxo.add_coin(op_Z, core::coin::Coin(5000, {}, 1, false)); + + Mempool pool; + BlockConnector conn(chain, pool); + conn.set_utxo(&utxo); + + // ---- 1) Connect branch A (forward extend) --------------------------- + conn.on_full_block(A1); + CHECK(chain.tip() && chain.tip()->block_hash == a1_hash); // A1 is the tip + CHECK(conn.undo_depth() == 1); + CHECK(!utxo.have_coin(op_X)); // A1 spent X + CHECK(utxo.have_coin(op_Y)); // Y untouched + CHECK(utxo.have_coin(op_Z)); + + // ---- 2) B1 arrives as a SIDE branch (equal work, A1 stays tip) ------ + conn.on_full_block(B1); + CHECK(chain.tip() && chain.tip()->block_hash == a1_hash); // tip unchanged + CHECK(conn.undo_depth() == 1); // not connected + CHECK(!utxo.have_coin(op_X)); // still A1's view + CHECK(utxo.have_coin(op_Y)); // B1 NOT applied + + // ---- 3) B2 extends B1 -> B-branch wins -> REORG --------------------- + conn.on_full_block(B2); + CHECK(chain.tip() && chain.tip()->block_hash == b2_hash); // reorged to B2 + CHECK(conn.undo_depth() == 2); // B1 + B2 applied; A1 gone + + CHECK(utxo.have_coin(op_X)); // A1 disconnected -> X restored live + CHECK(!utxo.have_coin(op_Y)); // B1 connected -> Y now spent + CHECK(utxo.have_coin(op_Z)); // control: never touched + + // A1's tx was returned to the mempool on disconnect; X is live again so it + // survives the Phase-4 sweep. + CHECK(pool.contains(id_a1_spend)); + + if (failures == 0) { + std::cout << "reorg_connect_test: ALL PASS\n"; + return 0; + } + std::cerr << "reorg_connect_test: " << failures << " FAILURE(S)\n"; + return 1; +} diff --git a/src/impl/bch/test/utxo_connect_test.cpp b/src/impl/bch/test/utxo_connect_test.cpp new file mode 100644 index 000000000..4836846ed --- /dev/null +++ b/src/impl/bch/test/utxo_connect_test.cpp @@ -0,0 +1,164 @@ +// --------------------------------------------------------------------------- +// bch::coin UTXO-connect / Phase-4 revalidate test (M5 full-block body, (a)). +// +// BlockConnector slices 1-2 closed the txid/outpoint reconciliation (Phases +// 1-3) but the Phase-4 stale-input sweep was inert: no UTXO view was ever +// wired, so revalidate_inputs() was a permanent no-op and connect_block() was +// never applied to a UTXO set. This test pins the now-live UTXO-connect leg: +// +// 1. UTXO-CONNECT IS A REAL SWEEP -- with a UTXO view wired via set_utxo(), +// a best-chain connect runs revalidate_inputs() against it (not a no-op). +// A mempool tx whose input is still live in the view SURVIVES; a mempool +// tx whose input has been spent in the authoritative view is REJECTED. +// 2. DOUBLE-SPEND REJECTION ON CONNECT -- tx_F enters the pool with a live +// input (fee_known=true), is then double-spent + confirmed elsewhere so +// its coin disappears from the UTXO set, and the next connect evicts it. +// 3. NO-VIEW CONTRACT -- with NO UTXO view wired the same connect leaves the +// pool untouched on the Phase-4 axis (cold-start / headers-only safe). +// +// Build posture matches block_connector_test: header-only over coin/*.hpp + +// , plus coin/transaction.cpp for the MutableTransaction ctors; NO +// impl_bch coin lib -> per-coin isolation stays clean. p2pool-merged-v36 +// surface: NONE (local mempool/UTXO hygiene; no PoW/share/coinbase/PPLNS). +// --------------------------------------------------------------------------- + +#include +#include + +#include "../coin/block.hpp" +#include "../coin/block_connector.hpp" +#include "../coin/header_chain.hpp" +#include "../coin/mempool.hpp" +#include "../coin/transaction.hpp" + +#include + +namespace { + +int failures = 0; +#define CHECK(cond) do { if (!(cond)) { \ + std::cerr << "FAIL: " #cond " @ line " << __LINE__ << "\n"; ++failures; } } while (0) + +bch::coin::MutableTransaction make_tx(const char* prev_hex, uint32_t prev_index, + int64_t out_value) { + bch::coin::MutableTransaction tx; + bch::coin::TxIn in; + in.prevout.hash.SetHex(prev_hex); + in.prevout.index = prev_index; + in.sequence = 0xffffffff; + tx.vin.push_back(in); + bch::coin::TxOut out; + out.value = out_value; + tx.vout.push_back(out); + return tx; +} + +bch::coin::BlockType make_block(uint32_t nonce) { + bch::coin::BlockType b; + b.m_version = 0x20000000; + b.m_previous_block.SetHex( + "00000000000000000001a2b3c4d5e6f700000000000000000000000000000000"); + b.m_merkle_root.SetHex( + "1111111111111111111111111111111111111111111111111111111111111111"); + b.m_timestamp = 1700000000; + b.m_bits = 0x1d00ffff; + b.m_nonce = nonce; + return b; +} + +// Distinct outpoints (=> distinct txids). +const char* OUTPOINT_A = "aa00000000000000000000000000000000000000000000000000000000000000"; +const char* OUTPOINT_F = "ff00000000000000000000000000000000000000000000000000000000000000"; +const char* OUTPOINT_G = "0f00000000000000000000000000000000000000000000000000000000000000"; + +// Seed a HeaderChain whose fast-start checkpoint IS the tip block hash, so the +// connector best-chain gate sees the block as the tip without fighting PoW. +bch::coin::HeaderChain make_chain(const bch::coin::BlockType& tip_block, uint32_t H) { + using namespace bch::coin; + const uint256 tip_hash = block_hash(static_cast(tip_block)); + BCHChainParams params = BCHChainParams::mainnet(); + params.fast_start_checkpoint = BCHChainParams::Checkpoint{H, tip_hash}; + return HeaderChain(params); +} + +} // namespace + +int main() { + using namespace bch::coin; + + const uint32_t H = 800001; + BlockType tip_block = make_block(/*nonce=*/1); + tip_block.m_txs.push_back(make_tx(OUTPOINT_A, 0, 100)); // coinbase-position tx + + // tx_F: live input at add-time -> fee_known=true; double-spent later. + // tx_G: live input, never disturbed -> survives. + MutableTransaction tx_F = make_tx(OUTPOINT_F, 0, 4000); + MutableTransaction tx_G = make_tx(OUTPOINT_G, 0, 4000); + const uint256 id_F = compute_txid(tx_F); + const uint256 id_G = compute_txid(tx_G); + CHECK(id_F != id_G); + + uint256 f_hash; f_hash.SetHex(OUTPOINT_F); + uint256 g_hash; g_hash.SetHex(OUTPOINT_G); + const core::coin::Outpoint op_F(f_hash, 0); + const core::coin::Outpoint op_G(g_hash, 0); + + // ---- 1+2) UTXO-connect is a real sweep; double-spend rejection ------ + { + HeaderChain chain = make_chain(tip_block, H); + CHECK(chain.init()); + CHECK(chain.tip().has_value()); + + core::coin::UTXOViewCache utxo(nullptr); // cache-only, no DB backend + utxo.add_coin(op_F, core::coin::Coin(5000, {}, 1, false)); + utxo.add_coin(op_G, core::coin::Coin(5000, {}, 1, false)); + + Mempool pool; + CHECK(pool.add_tx(tx_F, &utxo)); // input live -> fee_known=true + CHECK(pool.add_tx(tx_G, &utxo)); // input live -> fee_known=true + CHECK(pool.size() == 2); + + // tx_F double-spent + confirmed elsewhere (compact/pruned path): its + // coin vanishes from the authoritative UTXO set. + CHECK(utxo.spend_coin(op_F).has_value()); + CHECK(!utxo.have_coin(op_F)); + CHECK(utxo.have_coin(op_G)); + + BlockConnector conn(chain, pool); + conn.set_utxo(&utxo); // Phase-4 view wired -> real sweep + conn.on_full_block(tip_block); + + CHECK(!pool.contains(id_F)); // stale/double-spent input -> rejected + CHECK(pool.contains(id_G)); // live input -> survives + CHECK(pool.size() == 1); + } + + // ---- 3) No-view contract: Phase 4 inert when no UTXO view wired ----- + { + HeaderChain chain = make_chain(tip_block, H); + CHECK(chain.init()); + + core::coin::UTXOViewCache utxo(nullptr); + utxo.add_coin(op_G, core::coin::Coin(5000, {}, 1, false)); + + Mempool pool; + // tx_F added WITHOUT a view -> fee_known=false (quarantined), but the + // point of this case is that with NO view wired into the connector the + // Phase-4 sweep never runs at all, so tx_G (a survivor) is untouched. + CHECK(pool.add_tx(tx_G, &utxo)); + CHECK(pool.size() == 1); + + BlockConnector conn(chain, pool); // NO set_utxo() + conn.on_full_block(tip_block); + + CHECK(pool.contains(id_G)); // no Phase-4 sweep -> survivor kept + CHECK(pool.size() == 1); + } + + if (failures == 0) { + std::cout << "utxo_connect_test: ALL PASS\n"; + return 0; + } + std::cerr << "utxo_connect_test: " << failures << " FAILURE(S)\n"; + return 1; +}