From 342bc9e1ee45866ff87640b60c8c9251d39a7ffe Mon Sep 17 00:00:00 2001 From: frstrtr Date: Fri, 26 Jun 2026 16:58:44 +0000 Subject: [PATCH 1/6] =?UTF-8?q?bch(g2):=20BCHWorkSource=20skeleton=20?= =?UTF-8?q?=E2=80=94=20IWorkSource=20impl=20for=20c2pool-bch=20stratum=20s?= =?UTF-8?q?erve?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage-a of the G2 pool-serve build-out (g2-pool-serve-gap): c2pool-bch has never served stratum — main_bch.cpp is a harness, the generic pool is LTC-namespace-bound, and standup_pool_run has zero callers. This lands the first concrete piece: a BCH concrete core::stratum::IWorkSource subclass mirroring btc::stratum::BTCWorkSource, forward-declaring bch::coin types so it compiles header-only ahead of the coin-lib wiring. MVP-skeleton (parity with the BTC stage-a posture): method bodies land in follow-on slices b (read-only getters + work assembly off TemplateBuilder), c (share-validation hot path), d (StratumServer wired into standup_pool_run + main_bch --pool serve mode + live .198 bring-up + G2 5-check). BCH divergences captured in-header: no SegWit (non-witness coinbase math), CashTokens transparent-carry, ABLA budget owned by the daemon/TemplateBuilder, BCHN-RPC submitblock fallback ALWAYS retained on the won-block leg. Zero prod surface (not yet wired into node/main/CMake). Syntax-clean vs core/stratum_work_source.hpp under -std=c++20 -I src. --- src/impl/bch/stratum/work_source.hpp | 282 +++++++++++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 src/impl/bch/stratum/work_source.hpp diff --git a/src/impl/bch/stratum/work_source.hpp b/src/impl/bch/stratum/work_source.hpp new file mode 100644 index 000000000..7dd2b3177 --- /dev/null +++ b/src/impl/bch/stratum/work_source.hpp @@ -0,0 +1,282 @@ +#pragma once + +// bch::stratum::BCHWorkSource — concrete `core::stratum::IWorkSource` +// implementation for c2pool-bch. +// +// Responsibility: bridge the coin-agnostic `core::StratumServer` (TCP, +// JSON-RPC, sessions, vardiff, rate monitor) to BCH-specific work +// generation + share validation. Produces stratum jobs from the local +// header chain + mempool via `bch::coin::TemplateBuilder::build_template`, +// validates submitted shares with SHA256d PoW (BCH shares the BTC PoW +// family), and dispatches mainnet-hit blocks to the won-block submit +// callback wired in main_bch.cpp (embedded P2P relay + BCHN-RPC +// submitblock fallback — fallback ALWAYS retained per the BCH lane law). +// +// BCH divergences vs the BTC work source (kept transparent here): +// - NO SegWit: coinbase has no witness commitment; share/header math is +// non-witness throughout. mining_submit's coinbase reconstruction is +// coinb1||en1||en2||coinb2 only. +// - CashTokens (CHIP-2022-02) ride through unchanged: token-prefixed +// outputs are carried by build_template's mempool slice verbatim; the +// work source never inspects or rewrites them (M1 §4 transparent-carry). +// - ABLA dynamic block-size budget (CHIP-2023-01) is owned by the +// embedded daemon / TemplateBuilder; the work source consumes whatever +// template size the builder hands back. +// - ASERT DAA governs header-accept, not share/PoW target selection here. +// +// Lifetime: holds non-owning references to `HeaderChain` and `Mempool` +// — main_bch.cpp owns those for the process lifetime, BCHWorkSource is +// constructed after them and destroyed before. The submit-block callback +// captures whatever upstream state it needs (typically a coin_node ref + +// pending_submits map). +// +// Threading: `core::StratumServer` runs on its own io_context; methods +// here may be invoked from any thread serviced by it. Internal +// synchronisation: +// - `work_generation_`, `share_bits_`, `share_max_bits_` are atomics +// - `workers_` is guarded by `workers_mutex_` +// - the template cache is guarded by `template_mutex_` +// - `chain_` and `mempool_` have their own internal locking +// +// What's deliberately MVP-incomplete in this commit (Stage a skeleton): +// - All work-generation / submit methods return defaults or empty +// results. Subsequent G2 sub-slices (b/c/d) implement the read-only +// getters, the work assembly off TemplateBuilder, and the +// share-validation hot path, then wire StratumServer into +// standup_pool_run + main_bch.cpp --pool serve mode. +// - No vardiff feedback loop yet — `update_stratum_worker` records but +// doesn't yet drive set_difficulty back to sessions. + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Forward declarations — heavy headers live in the .cpp. +namespace bch::coin { +class HeaderChain; +class Mempool; +struct WorkData; +} // namespace bch::coin + +namespace bch::stratum { + +class BCHWorkSource : public core::stratum::IWorkSource +{ +public: + /// Callback invoked when `mining_submit` validates a submission whose + /// SHA256d PoW meets BCH mainnet difficulty. main_bch.cpp wires this + /// to a lambda that broadcasts the won block over BOTH legs (embedded + /// P2P relay + BCHN-RPC submitblock fallback) via the leg-guarded + /// dual-broadcast path. Raw-bytes form keeps BCHWorkSource decoupled + /// from the BlockType serialization details. + /// Returns true iff the won block reached at least one network sink + /// (P2P relay or submitblock RPC fallback). A false return means it + /// reached NEITHER and the won-block path logs a loud error. + using SubmitBlockFn = std::function& block_bytes, + uint32_t height)>; + + /// PPLNS payout query: walks back N shares from prev_share_hash and + /// returns {payout_script_bytes -> satoshi_amount}. main_bch.cpp wires + /// this to a lambda that calls the share tracker's v35 expected-payout + /// walk under a read guard. Caller responsibility: apply finder fee + /// (subsidy/200 to the miner's payout, deducted from donation). + /// Returning an empty map means the share tracker isn't ready yet + /// (cold start, no chain) — we then fall back to a single-output + /// coinbase (full subsidy -> miner) and skip the OP_RETURN. + using PplnsFn = std::function, double>( + const uint256& best_share_hash, + const uint256& block_target, + uint64_t subsidy, + const std::vector& donation_script)>; + + /// Computes the ref_hash AND walks the share tracker for all + /// chain-derived values needed to populate snap.frozen_ref. Mirrors + /// the BTC/LTC RefHashFn contract: returns the full + /// `core::stratum::RefHashResult` so create_local_share can override + /// its in-function compute_share_target safely (has_frozen=true), and + /// the work source updates its share_bits_/share_max_bits_ atomics so + /// stratum_server's pool_difficulty gate becomes non-zero. + using RefHashFn = std::function& coinbase_scriptSig, + const std::vector& payout_script, + uint64_t subsidy, uint32_t block_bits, uint32_t timestamp)>; + + /// Sharechain WRITE path. Called from mining_submit when a share's + /// SHA256d PoW meets sharechain (not block) target. main_bch.cpp wires + /// this to a lambda that try-locks the tracker, builds a v36 BCH share + /// via bch::create_local_share(), tracker.add()s it, and on success + /// broadcasts the share hash to peers + bumps local best so miners get + /// fresh work tied to our new tip. + /// + /// Returns the share hash on success, uint256::ZERO on failure + /// (tracker busy, PoW recheck failed, prev_share unknown, etc.). + /// + /// The full_coinbase is the reconstructed coinb1||en1||en2||coinb2 + /// (BCH non-witness form — txid math). The header_80b is the 80-byte + /// block header bytes from mining_submit's classification step. + using CreateShareFn = std::function& full_coinbase, + const std::vector& header_80b, + const core::stratum::JobSnapshot& job, + const std::vector& payout_script)>; + + BCHWorkSource(bch::coin::HeaderChain& chain, + bch::coin::Mempool& mempool, + bool is_testnet, + SubmitBlockFn submit_fn, + core::stratum::StratumConfig config = {}); + ~BCHWorkSource() override; + + BCHWorkSource(const BCHWorkSource&) = delete; + BCHWorkSource& operator=(const BCHWorkSource&) = delete; + + // ── IWorkSource: config + read-only state ──────────────────────────── + const core::stratum::StratumConfig& get_stratum_config() const override; + std::function get_best_share_hash_fn() const override; + std::string get_current_gbt_prevhash() const override; + uint64_t get_work_generation() const override; + bool has_merged_chain(uint32_t chain_id) const override; + + // ── IWorkSource: per-connection bookkeeping ────────────────────────── + void register_stratum_worker(const std::string& session_id, + const core::stratum::WorkerInfo& info) override; + void unregister_stratum_worker(const std::string& session_id) override; + void update_stratum_worker(const std::string& session_id, + double hashrate, double dead_hashrate, double difficulty, + uint64_t accepted, uint64_t rejected, uint64_t stale) override; + + // ── IWorkSource: work generation ───────────────────────────────────── + nlohmann::json get_current_work_template() const override; + std::vector get_stratum_merkle_branches() const override; + std::pair get_coinbase_parts() const override; + core::stratum::CoinbaseResult build_connection_coinbase( + const uint256& prev_share_hash, + const std::string& extranonce1_hex, + const std::vector& payout_script, + const std::vector>>& merged_addrs) const override; + + // ── IWorkSource: share submission ──────────────────────────────────── + nlohmann::json mining_submit( + const std::string& username, const std::string& job_id, + const std::string& extranonce1, const std::string& extranonce2, + const std::string& ntime, const std::string& nonce, + const std::string& request_id, + const std::map>& merged_addresses, + const core::stratum::JobSnapshot* job) override; + + // ── IWorkSource: atomic state ──────────────────────────────────────── + uint32_t get_share_bits() const override { return share_bits_.load(); } + uint32_t get_share_max_bits() const override { return share_max_bits_.load(); } + + // ── IWorkSource: per-coin PoW (BCH = SHA256d, same family as BTC) ───── + double compute_share_difficulty( + const std::string& coinb1, const std::string& coinb2, + const std::string& extranonce1, const std::string& extranonce2, + const std::string& ntime, const std::string& nonce, + uint32_t version, const std::string& prevhash_hex, + const std::string& nbits_hex, + const std::vector& merkle_branches) const override; + + // ── BCH-specific control surface (called from main_bch.cpp) ────────── + + /// Increment work_generation. Called when the BCH tip moves + /// (new_headers fires) or when sharechain tip moves. Triggers stratum + /// sessions to re-push work on their next heartbeat. + void bump_work_generation() { work_generation_.fetch_add(1, std::memory_order_relaxed); } + + /// Set the current share-target bits (compact-target encoding). + /// `max_bits` is the easiest the share target can be. Both atomically + /// visible to stratum sessions. + void set_share_target(uint32_t bits, uint32_t max_bits) + { + share_bits_.store(bits, std::memory_order_relaxed); + share_max_bits_.store(max_bits, std::memory_order_relaxed); + } + + /// Wire the share-tracker accessor that returns the current best-share + /// hash. Called once at startup from main_bch.cpp after ShareTracker + /// is constructed. + void set_best_share_hash_fn(std::function fn); + + /// Wire the PPLNS payout-map producer. Called once at startup. May be + /// left unset, in which case build_connection_coinbase falls back to + /// a single-output coinbase paying the full subsidy to the miner + /// (degraded mode — no c2pool sharechain participation but valid BCH + /// blocks still produced). + void set_pplns_fn(PplnsFn fn); + + /// Wire the ref_hash producer. Called once at startup. May be left + /// unset; in that case the coinbase OP_RETURN is omitted (degraded + /// mode, but coinbase still valid for BCH). + void set_ref_hash_fn(RefHashFn fn); + + /// Wire the share-create callback (sharechain WRITE path). Called once + /// at startup. May be left unset — mining_submit then logs accepted + /// shares but doesn't add them to the tracker, leaving c2pool-bch as + /// a stratum proxy without sharechain participation. + void set_create_share_fn(CreateShareFn fn); + + /// Set the donation script (bytes of the c2pool donation + /// scriptPubKey). For v36 BCH this is the version-gated COMBINED P2SH + /// (1-of-2 forrestv+maintainer) for sv>=36 shares. Used by + /// build_connection_coinbase as the residual recipient of any + /// payout-rounding remainder, plus added to the PPLNS map so it always + /// appears as an output. + void set_donation_script(std::vector script); + +private: + // External dependencies (non-owning references) + bch::coin::HeaderChain& chain_; + bch::coin::Mempool& mempool_; + const bool is_testnet_; + + // Submission dispatch + SubmitBlockFn submit_block_fn_; + + // Config (held by value; const after construction in MVP) + core::stratum::StratumConfig config_; + + // Atomic state + std::atomic work_generation_{0}; + // mutable so build_connection_coinbase (const) can refresh them from + // ref_hash_fn's tracker.compute_share_target result. + mutable std::atomic share_bits_{0}; + mutable std::atomic share_max_bits_{0}; + + // Worker registry (per-connection metadata) + mutable std::mutex workers_mutex_; + std::map workers_; + + // Best-share callback (from ShareTracker) + mutable std::mutex best_share_mutex_; + std::function best_share_hash_fn_; + + // PPLNS + ref_hash + share-create callbacks (from ShareTracker via main_bch.cpp) + mutable std::mutex callback_mutex_; + PplnsFn pplns_fn_; + RefHashFn ref_hash_fn_; + CreateShareFn create_share_fn_; + std::vector donation_script_; + + // Template cache (filled lazily; invalidated when work_generation_ bumps) + // Stage c populates these. + mutable std::mutex template_mutex_; + // tx_data memo (single slot, guarded by template_mutex_): the per-job + // tx-hex vector and a fingerprint over its merkle leaf set, so repeat + // build_connection_coinbase calls against an unchanged tx set reuse the + // shared_ptr instead of re-serializing the mempool. + mutable uint256 tx_data_fp_; + mutable std::shared_ptr> tx_data_memo_; +}; + +} // namespace bch::stratum From 61c48d6dea263fa2e340ea3aac19fda753536a0f Mon Sep 17 00:00:00 2001 From: frstrtr Date: Fri, 26 Jun 2026 17:18:13 +0000 Subject: [PATCH 2/6] =?UTF-8?q?bch(g2):=20wire=20bch=5Fcoin=20library=20?= =?UTF-8?q?=E2=80=94=20slice-b?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Define src/impl/bch/coin/CMakeLists.txt (mirrors btc_coin: 5 TU rpc/p2p_connection/p2p_node/coin_node/transaction + interface and BCH-specific BCHN headers) and uncomment add_subdirectory(coin) in the bch top CMakeLists so the coin module builds when -DCOIN_BCH=ON. This makes bch::coin::TemplateBuilder (and the rest of the coin lib) a real link target, the prerequisite for slice-c work-assembly off BCHWorkSource. Fenced to src/impl/bch; no bitcoin_family/src/core touch. Verified: cmake -DCOIN_BCH=ON configures; cmake --build --target bch_coin compiles all 5 TUs and archives libbch_coin.a clean (exit 0, no errors). --- src/impl/bch/CMakeLists.txt | 4 ++-- src/impl/bch/coin/CMakeLists.txt | 40 ++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 src/impl/bch/coin/CMakeLists.txt diff --git a/src/impl/bch/CMakeLists.txt b/src/impl/bch/CMakeLists.txt index 1dd506cdd..86be3ae88 100644 --- a/src/impl/bch/CMakeLists.txt +++ b/src/impl/bch/CMakeLists.txt @@ -5,8 +5,8 @@ # TODO(M3): add_library(impl_bch ...) sources, link bitcoin_family shared base, # wire embedded BCHN daemon slice under daemon/. Stub only at M2. if(COIN_BCH) - message(STATUS "c2pool: BCH coin module enabled (skeleton)") - # add_subdirectory(coin) + message(STATUS "c2pool: BCH coin module enabled") + add_subdirectory(coin) # add_subdirectory(daemon) add_subdirectory(test) endif() diff --git a/src/impl/bch/coin/CMakeLists.txt b/src/impl/bch/coin/CMakeLists.txt new file mode 100644 index 000000000..a3a46b463 --- /dev/null +++ b/src/impl/bch/coin/CMakeLists.txt @@ -0,0 +1,40 @@ + +# bch::coin library — c2pool-bch coin module (V36). Mirrors src/impl/btc/coin. +# Built when -DCOIN_BCH=ON via the parent add_subdirectory(coin). +# BCH-specific: SHA256d (BTC PoW family), NO SegWit, CashTokens transparent, +# ABLA dynamic block-size, ASERT DAA. Embedded BCHN daemon body lives here. + +set(bch_coin_impl + rpc_data.hpp + rpc.hpp rpc.cpp + p2p_connection.hpp p2p_connection.cpp + p2p_node.cpp p2p_node.hpp + p2p_messages.hpp + node.hpp + coin_node.hpp coin_node.cpp +) + +set(bch_coin_interface + txidcache.hpp + node_interface.hpp + block.hpp + transaction.hpp transaction.cpp + header_chain.hpp + mempool.hpp + template_builder.hpp +) + +# BCH-specific consensus + embedded-daemon headers (header-only at this slice). +set(bch_coin_bchn + abla.hpp abla_block_feed.hpp abla_runtime.hpp abla_tracker.hpp + asert.hpp softfork_check.hpp + merkle.hpp cashaddr.hpp regtest_block.hpp + header_sync.hpp block_download.hpp block_connector.hpp + compact_blocks.hpp block_broadcast_guard.hpp + bchn_anchor_record.hpp chain_seeds.hpp + embedded_daemon.hpp +) + +add_library(bch_coin ${bch_coin_interface} ${bch_coin_impl} ${bch_coin_bchn}) + +target_link_libraries(bch_coin core nlohmann_json::nlohmann_json) From a9d56aaad31b89cd1dc11f1265e581132bf98201 Mon Sep 17 00:00:00 2001 From: frstrtr Date: Fri, 26 Jun 2026 18:58:31 +0000 Subject: [PATCH 3/6] =?UTF-8?q?bch(g2):=20BCHWorkSource=20work-assembly=20?= =?UTF-8?q?off=20TemplateBuilder=20=E2=80=94=20slice-c?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the read path of bch::stratum::BCHWorkSource: read-only state accessors, per-connection worker registry, and the work-assembly that bridges core::StratumServer to bch::coin::TemplateBuilder::build_template. - cached_template(): single-slot template memo keyed on (work_generation_, tip block_hash). BCH Mempool has no epoch counter, so freshness rides on the tip-move work_generation_ bump plus an explicit bump on mempool roll; the tip-hash key is a belt-and-suspenders rebuild guard. Build runs outside template_mutex_ so a slow build never blocks a cache hit. - get_current_work_template(): returns build_template GBT json, curtime refreshed per-poll. - get_stratum_merkle_branches(): coinbase-left SHA256d siblings over m_hashes (CTOR order from build_template); LE-internal wire hex, no witness commitment. - get_coinbase_parts(), get_current_gbt_prevhash(), get_work_generation(), has_merged_chain()=false (BCH standalone parent), worker bookkeeping, callback setters. Share-WRITE / validation hot path (build_connection_coinbase, mining_submit, compute_share_difficulty) left as safe defaults for slice-d. Build: new bch_stratum static lib (mirrors btc_stratum); add_subdirectory (stratum) under COIN_BCH. Verified on .198: -DCOIN_BCH=ON configures, target bch_stratum compiles work_source.cpp.o + archives libbch_stratum.a, exit 0. Per-coin isolation held (bch tree only). No main/node wiring yet. --- src/impl/bch/CMakeLists.txt | 1 + src/impl/bch/stratum/CMakeLists.txt | 21 ++ src/impl/bch/stratum/work_source.cpp | 291 +++++++++++++++++++++++++++ src/impl/bch/stratum/work_source.hpp | 19 +- 4 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 src/impl/bch/stratum/CMakeLists.txt create mode 100644 src/impl/bch/stratum/work_source.cpp diff --git a/src/impl/bch/CMakeLists.txt b/src/impl/bch/CMakeLists.txt index 86be3ae88..302891054 100644 --- a/src/impl/bch/CMakeLists.txt +++ b/src/impl/bch/CMakeLists.txt @@ -7,6 +7,7 @@ if(COIN_BCH) message(STATUS "c2pool: BCH coin module enabled") add_subdirectory(coin) + add_subdirectory(stratum) # add_subdirectory(daemon) add_subdirectory(test) endif() diff --git a/src/impl/bch/stratum/CMakeLists.txt b/src/impl/bch/stratum/CMakeLists.txt new file mode 100644 index 000000000..81991c6f3 --- /dev/null +++ b/src/impl/bch/stratum/CMakeLists.txt @@ -0,0 +1,21 @@ +# bch::stratum module -- concrete IWorkSource implementation for c2pool-bch. +# Bridges core::StratumServer (coin-agnostic protocol layer) to BCH-specific +# work generation (template_builder), share validation, and the dual-path +# won-block broadcaster. Mirrors src/impl/btc/stratum. G2 pool-serve slice-c. + +add_library(bch_stratum + work_source.hpp work_source.cpp +) + +target_link_libraries(bch_stratum + core + bch_coin + btclibs + nlohmann_json::nlohmann_json + ${Boost_LIBRARIES} +) + +# Include parent so #include resolves +target_include_directories(bch_stratum PUBLIC + ${CMAKE_SOURCE_DIR}/src +) diff --git a/src/impl/bch/stratum/work_source.cpp b/src/impl/bch/stratum/work_source.cpp new file mode 100644 index 000000000..83bd3f081 --- /dev/null +++ b/src/impl/bch/stratum/work_source.cpp @@ -0,0 +1,291 @@ +// bch::stratum::BCHWorkSource — slice-c implementation. +// +// Implements the read-only state accessors, per-connection worker +// bookkeeping, and the WORK-ASSEMBLY read path that bridges the +// coin-agnostic core::StratumServer to BCH work generation off +// bch::coin::TemplateBuilder::build_template: +// +// - cached_template() single-slot template memo (template cache) +// - get_current_work_template() GBT-shaped json for mining.notify +// - get_stratum_merkle_branches()coinbase-left merkle siblings (CTOR order) +// - get_coinbase_parts() trivial fallback split +// - get_current_gbt_prevhash() tip display-hex for clean_jobs dedup +// - worker registry + atomics +// +// DEFERRED to slice-d (share-WRITE / validation hot path, left as safe +// defaults here so the TU links and the read path is exercisable): +// - build_connection_coinbase() full PPLNS + ref_hash coinbase build +// - mining_submit() SHA256d classify + sharechain add + won-block +// - compute_share_difficulty() per-submission SHA256d difficulty +// +// BCH divergences from the BTC work source are confined here: no SegWit +// (non-witness coinbase + merkle throughout), CashTokens transparent-carry +// (build_template hands token-prefixed txs through unchanged), ABLA budget +// owned by TemplateBuilder. Per-coin isolation: bch tree only. + +#include + +#include +#include +#include // merkle_hash_pair (CTOR SHA256d) +#include // build_template + rpc::WorkData + +#include +#include // HexStr + +#include +#include +#include + +namespace bch::stratum { + +BCHWorkSource::BCHWorkSource(bch::coin::HeaderChain& chain, + bch::coin::Mempool& mempool, + bool is_testnet, + SubmitBlockFn submit_fn, + core::stratum::StratumConfig config) + : chain_(chain) + , mempool_(mempool) + , is_testnet_(is_testnet) + , submit_block_fn_(std::move(submit_fn)) + , config_(std::move(config)) +{ + LOG_INFO << "[BCH-STRATUM] BCHWorkSource constructed (testnet=" + << (is_testnet_ ? "1" : "0") << ")"; +} + +BCHWorkSource::~BCHWorkSource() = default; + +// -- IWorkSource: config + read-only state ------------------------------------ + +const core::stratum::StratumConfig& BCHWorkSource::get_stratum_config() const +{ + return config_; +} + +std::function BCHWorkSource::get_best_share_hash_fn() const +{ + std::lock_guard lk(best_share_mutex_); + return best_share_hash_fn_; // empty until set_best_share_hash_fn() +} + +std::string BCHWorkSource::get_current_gbt_prevhash() const +{ + // BE display-hex of the current BCH chain tip -- both the mining.notify + // prevhash field and the clean_jobs dedup key. Empty until the header + // chain has a tip (pre-IBD). + auto tip = chain_.tip(); + if (!tip) return {}; + return tip->block_hash.GetHex(); +} + +uint64_t BCHWorkSource::get_work_generation() const +{ + return work_generation_.load(std::memory_order_relaxed); +} + +bool BCHWorkSource::has_merged_chain(uint32_t /*chain_id*/) const +{ + // BCH is a STANDALONE parent in V36 -- no merged-mining aux module. + return false; +} + +// -- IWorkSource: per-connection bookkeeping ---------------------------------- + +void BCHWorkSource::register_stratum_worker(const std::string& session_id, + const core::stratum::WorkerInfo& info) +{ + std::lock_guard lk(workers_mutex_); + workers_[session_id] = info; + LOG_INFO << "[BCH-STRATUM] worker registered: session=" << session_id + << " user=" << info.username + << " worker=" << info.worker_name + << " endpoint=" << info.remote_endpoint; +} + +void BCHWorkSource::unregister_stratum_worker(const std::string& session_id) +{ + std::lock_guard lk(workers_mutex_); + auto it = workers_.find(session_id); + if (it != workers_.end()) { + LOG_INFO << "[BCH-STRATUM] worker unregistered: session=" << session_id + << " user=" << it->second.username + << " accepted=" << it->second.accepted + << " rejected=" << it->second.rejected + << " stale=" << it->second.stale; + workers_.erase(it); + } +} + +void BCHWorkSource::update_stratum_worker(const std::string& session_id, + double hashrate, double dead_hashrate, + double difficulty, + uint64_t accepted, uint64_t rejected, + uint64_t stale) +{ + std::lock_guard lk(workers_mutex_); + auto it = workers_.find(session_id); + if (it == workers_.end()) return; + it->second.hashrate = hashrate; + it->second.dead_hashrate = dead_hashrate; + it->second.difficulty = difficulty; + it->second.accepted = accepted; + it->second.rejected = rejected; + it->second.stale = stale; +} + +// -- IWorkSource: work generation (slice-c) ----------------------------------- + +std::shared_ptr +BCHWorkSource::cached_template() const +{ + const uint64_t gen = work_generation_.load(std::memory_order_relaxed); + uint256 tip_hash{}; + if (auto tip = chain_.tip()) tip_hash = tip->block_hash; + + { + std::lock_guard lk(template_mutex_); + if (template_cache_ && + template_cache_gen_ == gen && + template_cache_tip_ == tip_hash) + return template_cache_; + } + + // build_template is read-only over chain_ + mempool_ (their own internal + // locking). Done OUTSIDE template_mutex_ so a slow build never blocks a + // concurrent cache hit on another connection thread. + auto built = bch::coin::TemplateBuilder::build_template(chain_, mempool_, is_testnet_); + if (!built) return nullptr; // chain has no tip yet + + auto sp = std::make_shared(std::move(*built)); + std::lock_guard lk(template_mutex_); + template_cache_ = sp; + template_cache_gen_ = gen; + template_cache_tip_ = tip_hash; + return sp; +} + +nlohmann::json BCHWorkSource::get_current_work_template() const +{ + // TemplateBuilder::build_template already shapes WorkData::m_data as the + // GBT-style json stratum sessions consume (previousblockhash, bits, + // version, curtime, mintime, height, coinbasevalue, transactions[]). + // Empty object until the header chain is past genesis -- sessions skip + // work-push and retry. curtime is refreshed per-poll so mining.notify + // ntime tracks wall-clock even on a cache hit. + auto wd = cached_template(); + if (!wd) return nlohmann::json::object(); + nlohmann::json data = wd->m_data; + data["curtime"] = static_cast(std::time(nullptr)); + return data; +} + +std::vector BCHWorkSource::get_stratum_merkle_branches() const +{ + // Coinbase-left merkle siblings for mining.notify. The miner rebuilds + // the root via SHA256d(coinbase_txid || branch[0]), then folds in each + // subsequent branch. BCH uses CTOR (Nov 2018) for the block tx ORDER, + // but the merkle tree itself is the same SHA256d pairwise structure as + // BTC with NO witness commitment -- so the branch algorithm is identical; + // build_template has already emitted m_hashes in canonical (CTOR) order. + // + // Wire encoding: hex of the LE-internal 32 bytes (NOT GetHex() display + // order) -- the miner hex2bin-s this straight into SHA256d, so the bytes + // must match the leaves the pool hashed. + auto wd = cached_template(); + if (!wd || wd->m_hashes.empty()) return {}; + + std::vector level = wd->m_hashes; // [0] = coinbase placeholder + std::vector branches; + while (level.size() > 1) { + branches.push_back(HexStr(std::span(level[1].data(), 32))); + std::vector next; + next.reserve((level.size() + 2) / 2); + next.push_back(uint256::ZERO); // placeholder for (coinbase, level[1]) combo + for (size_t i = 2; i < level.size(); i += 2) { + const uint256& l = level[i]; + const uint256& r = (i + 1 < level.size()) ? level[i + 1] : level[i]; + next.push_back(bch::coin::merkle_hash_pair(l, r)); + } + level = std::move(next); + } + return branches; +} + +std::pair BCHWorkSource::get_coinbase_parts() const +{ + // Fallback split with no payout output -- the per-connection builder + // (build_connection_coinbase, slice-d) produces the real coinbase. + return { {}, {} }; +} + +// -- IWorkSource: setters (callback wiring from main_bch.cpp) ------------------ + +void BCHWorkSource::set_pplns_fn(PplnsFn fn) +{ + std::lock_guard lk(callback_mutex_); + pplns_fn_ = std::move(fn); +} + +void BCHWorkSource::set_ref_hash_fn(RefHashFn fn) +{ + std::lock_guard lk(callback_mutex_); + ref_hash_fn_ = std::move(fn); +} + +void BCHWorkSource::set_create_share_fn(CreateShareFn fn) +{ + std::lock_guard lk(callback_mutex_); + create_share_fn_ = std::move(fn); +} + +void BCHWorkSource::set_donation_script(std::vector script) +{ + std::lock_guard lk(callback_mutex_); + donation_script_ = std::move(script); +} + +// -- DEFERRED to slice-d (share-WRITE / validation hot path) ------------------- +// Safe defaults so the TU links and the read path is fully exercisable now. + +core::stratum::CoinbaseResult BCHWorkSource::build_connection_coinbase( + const uint256& /*prev_share_hash*/, + const std::string& /*extranonce1_hex*/, + const std::vector& /*payout_script*/, + const std::vector>>& /*merged_addrs*/) const +{ + // slice-d: BCH v36 coinbase build (BIP34 height + /c2pool-bch/ tag, PPLNS + // payouts via pplns_fn_, OP_RETURN ref_hash via ref_hash_fn_, NO witness + // commitment). Until then no per-connection coinbase is offered. + return {}; +} + +nlohmann::json BCHWorkSource::mining_submit( + const std::string& /*username*/, const std::string& /*job_id*/, + const std::string& /*extranonce1*/, const std::string& /*extranonce2*/, + const std::string& /*ntime*/, const std::string& /*nonce*/, + const std::string& /*request_id*/, + const std::map>& /*merged_addresses*/, + const core::stratum::JobSnapshot* /*job*/) +{ + // slice-d: reconstruct coinb1||en1||en2||coinb2 (non-witness), SHA256d + // classify vs share/block target, add v36 share via create_share_fn_, and + // on a mainnet hit fire submit_block_fn_ (embedded P2P relay + BCHN-RPC + // submitblock fallback -- both legs, fallback always retained). + return nlohmann::json{{"error", "mining_submit not yet implemented (slice-d)"}}; +} + +double BCHWorkSource::compute_share_difficulty( + const std::string& /*coinb1*/, const std::string& /*coinb2*/, + const std::string& /*extranonce1*/, const std::string& /*extranonce2*/, + const std::string& /*ntime*/, const std::string& /*nonce*/, + uint32_t /*version*/, const std::string& /*prevhash_hex*/, + const std::string& /*nbits_hex*/, + const std::vector& /*merkle_branches*/) const +{ + // slice-d: rebuild header, SHA256d, target_to_difficulty (BCH shares the + // BTC SHA256d PoW family). + return 0.0; +} + +} // namespace bch::stratum diff --git a/src/impl/bch/stratum/work_source.hpp b/src/impl/bch/stratum/work_source.hpp index 7dd2b3177..dd2aeaed0 100644 --- a/src/impl/bch/stratum/work_source.hpp +++ b/src/impl/bch/stratum/work_source.hpp @@ -64,7 +64,7 @@ namespace bch::coin { class HeaderChain; class Mempool; -struct WorkData; +namespace rpc { struct WorkData; } } // namespace bch::coin namespace bch::stratum { @@ -277,6 +277,23 @@ class BCHWorkSource : public core::stratum::IWorkSource // shared_ptr instead of re-serializing the mempool. mutable uint256 tx_data_fp_; mutable std::shared_ptr> tx_data_memo_; + + // ── Template cache (slice-c) ───────────────────────────────────────── + // Single-slot memo of TemplateBuilder::build_template, keyed on + // (work_generation_, chain tip block_hash). BCH Mempool exposes no + // epoch/seq counter (unlike btc), so cache freshness rides on: a tip + // move bumps work_generation_ via the new-headers hook, and an explicit + // bump_work_generation() invalidates on a mempool roll. The tip-hash + // key is a belt-and-suspenders guard so a tip change always rebuilds + // even if a bump was missed. Guarded by template_mutex_. + mutable std::shared_ptr template_cache_; + mutable uint64_t template_cache_gen_{~0ull}; + mutable uint256 template_cache_tip_{}; + + // Build-or-reuse the current BCH work template (read-only over chain_ + + // mempool_ via TemplateBuilder). Returns nullptr if the header chain has + // no tip yet (pre-IBD / uninitialized). + std::shared_ptr cached_template() const; }; } // namespace bch::stratum From aba1a364a5080b346fb87b00cfc32f3b2157d422 Mon Sep 17 00:00:00 2001 From: frstrtr Date: Fri, 26 Jun 2026 19:40:15 +0000 Subject: [PATCH 4/6] =?UTF-8?q?bch(g2):=20slice-d=20share-write=20hot=20pa?= =?UTF-8?q?th=20=E2=80=94=20mining=5Fsubmit=20+=20coinbase=20author=20+=20?= =?UTF-8?q?share-diff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the three deferred BCHWorkSource hot-path methods, porting the BTC v35 work source minus the BCH divergences (no SegWit, /c2pool-bch/ tag, CashTokens transparent-carry, CTOR tx-order owned by TemplateBuilder): - build_connection_coinbase(): v36 coinbase author — BIP34 height + pool tag scriptSig, PPLNS payouts (sorted asc amount/script, subsidy/200 finder fee from donation), OP_RETURN ref_hash + 8B nonce slot. NON-witness throughout; output_count carries no witness-commitment output. Byte-shape is consensus-bearing (pinned by coinbase-KAT vs p2pool-merged-v36 oracle). - mining_submit(): reconstruct coinb1||en1||en2||coinb2, SHA256d coinbase txid, ascend stratum merkle branches, build 80B header, SHA256d classify vs block/share target. Block-found fires the dual-path broadcaster (embedded P2P relay + BCHN-RPC submitblock fallback) and screams if a won block reaches NEITHER sink. Share-target hit dispatches create_share_fn_. - compute_share_difficulty(): SHA256d header rebuild -> target_to_difficulty (BCH SHA256d PoW family; no scrypt). tx_data for full-block assembly built inline (no cross-tree btc memo include — per-coin isolation). Adds anon-namespace LE byte helpers. Syntax-clean (-fsyntax-only, -std=c++20). bch tree only; branch-only, merge operator-gated. --- src/impl/bch/stratum/work_source.cpp | 450 +++++++++++++++++++++++++-- 1 file changed, 424 insertions(+), 26 deletions(-) diff --git a/src/impl/bch/stratum/work_source.cpp b/src/impl/bch/stratum/work_source.cpp index 83bd3f081..26b022c8f 100644 --- a/src/impl/bch/stratum/work_source.cpp +++ b/src/impl/bch/stratum/work_source.cpp @@ -37,6 +37,54 @@ #include #include +#include // address_to_script (share write path) +#include // Hash (SHA256d) +#include // chain::target_to_difficulty +#include +#include +#include +#include + +namespace { + +// Little-endian Bitcoin-wire byte helpers (shared with BTC; non-witness here). +inline void push_u32_le(std::vector& v, uint32_t x) { + v.push_back(static_cast(x & 0xff)); + v.push_back(static_cast((x >> 8) & 0xff)); + v.push_back(static_cast((x >> 16) & 0xff)); + v.push_back(static_cast((x >> 24) & 0xff)); +} +inline void push_u64_le(std::vector& v, uint64_t x) { + for (int i = 0; i < 8; ++i) + v.push_back(static_cast((x >> (i * 8)) & 0xff)); +} +inline void push_varint(std::vector& v, uint64_t n) { + if (n < 0xfd) { v.push_back(static_cast(n)); } + else if (n <= 0xffff) { + v.push_back(0xfd); + v.push_back(static_cast(n & 0xff)); + v.push_back(static_cast((n >> 8) & 0xff)); + } else if (n <= 0xffffffff) { + v.push_back(0xfe); push_u32_le(v, static_cast(n)); + } else { v.push_back(0xff); push_u64_le(v, n); } +} +// BIP34 minimally-encoded height push for the coinbase scriptSig. +inline std::vector bip34_height_push(uint32_t h) { + std::vector enc; uint32_t tmp = h; + while (tmp) { enc.push_back(static_cast(tmp & 0xff)); tmp >>= 8; } + if (enc.empty()) enc.push_back(0); + if (enc.back() & 0x80) enc.push_back(0); + std::vector out; + out.push_back(static_cast(enc.size())); // OP_PUSHBYTES_n + out.insert(out.end(), enc.begin(), enc.end()); + return out; +} +inline uint32_t parse_be_hex_u32(const std::string& str) { + uint32_t v = 0; std::sscanf(str.c_str(), "%x", &v); return v; +} + +} // anonymous namespace + namespace bch::stratum { BCHWorkSource::BCHWorkSource(bch::coin::HeaderChain& chain, @@ -245,47 +293,397 @@ void BCHWorkSource::set_donation_script(std::vector script) donation_script_ = std::move(script); } -// -- DEFERRED to slice-d (share-WRITE / validation hot path) ------------------- -// Safe defaults so the TU links and the read path is fully exercisable now. +// -- slice-d: share-WRITE / validation hot path (SHA256d, non-SegWit) --------- core::stratum::CoinbaseResult BCHWorkSource::build_connection_coinbase( - const uint256& /*prev_share_hash*/, + const uint256& prev_share_hash, const std::string& /*extranonce1_hex*/, - const std::vector& /*payout_script*/, + const std::vector& payout_script, const std::vector>>& /*merged_addrs*/) const { - // slice-d: BCH v36 coinbase build (BIP34 height + /c2pool-bch/ tag, PPLNS - // payouts via pplns_fn_, OP_RETURN ref_hash via ref_hash_fn_, NO witness - // commitment). Until then no per-connection coinbase is offered. - return {}; + // c2pool BCH v36 coinbase. Ported from the BTC v35 work source with the + // BCH divergences applied: + // - NO SegWit: no witness-commitment output, no BIP141 reserved value; + // BCH never activated SegWit (M1 4) so the coinbase is non-witness. + // - pool tag is /c2pool-bch/. + // - CashTokens ride through build_template's mempool slice unchanged + // (transparent-carry; the coinbase author never inspects them). + // - CTOR governs block tx ORDER, not these coinbase bytes. + // Byte-shape (PPLNS split + sv>=36 donation P2SH + OP_RETURN ref_hash) is + // consensus-bearing -> pinned by the coinbase-KAT vs the p2pool-merged-v36 + // BCH oracle; any divergence is a HARD STOP, not a silent ship. + auto wd = cached_template(); + if (!wd) return {}; + + const uint32_t height = wd->m_data.value("height", 0u); + const uint64_t coinbasevalue = wd->m_data.value("coinbasevalue", uint64_t{0}); + + uint32_t block_bits = 0; + if (auto it = wd->m_data.find("bits"); it != wd->m_data.end() && it->is_string()) + block_bits = parse_be_hex_u32(it->get()); + const uint32_t curtime = static_cast(std::time(nullptr)); + + PplnsFn pplns_fn; RefHashFn ref_hash_fn; + std::vector donation_script; + { + std::lock_guard lk(callback_mutex_); + pplns_fn = pplns_fn_; ref_hash_fn = ref_hash_fn_; donation_script = donation_script_; + } + + // scriptSig (deterministic): BIP34 height + pool tag. + auto bip34 = bip34_height_push(height); + static const std::string POOL_TAG = "/c2pool-bch/"; + std::vector scriptsig; + scriptsig.insert(scriptsig.end(), bip34.begin(), bip34.end()); + if (!POOL_TAG.empty() && POOL_TAG.size() < 76) { + scriptsig.push_back(static_cast(POOL_TAG.size())); + scriptsig.insert(scriptsig.end(), POOL_TAG.begin(), POOL_TAG.end()); + } + + // PPLNS payouts. + std::map, double> payouts; + if (pplns_fn) { + uint256 block_target; + if (block_bits != 0) block_target.SetCompact(block_bits); + try { payouts = pplns_fn(prev_share_hash, block_target, coinbasevalue, donation_script); } + catch (const std::exception& e) { + LOG_WARNING << "[BCH-STRATUM] pplns_fn threw: " << e.what() + << " -- falling back to single-output coinbase"; + payouts.clear(); + } + } + + // Drop empty-script entries; reabsorb their value into donation. + { + size_t dropped_n = 0; uint64_t dropped_value = 0; + for (auto it = payouts.begin(); it != payouts.end(); ) { + if (it->first.empty()) { + dropped_n++; dropped_value += static_cast(it->second); + it = payouts.erase(it); + } else { ++it; } + } + if (dropped_n > 0 && !donation_script.empty()) + payouts[donation_script] += static_cast(dropped_value); + } + + // v36 finder fee: subsidy/200 to this miner, deducted from donation. + if (!payouts.empty() && coinbasevalue > 0 && !payout_script.empty()) { + const double finder_fee = static_cast(coinbasevalue) / 200.0; + if (!donation_script.empty()) { + auto it = payouts.find(donation_script); + if (it != payouts.end() && it->second >= finder_fee) { + it->second -= finder_fee; + payouts[payout_script] += finder_fee; + } + } + } + + // Degraded fallback: full subsidy -> miner. + if (payouts.empty() && !payout_script.empty()) + payouts[payout_script] = static_cast(coinbasevalue); + + // Sort outputs: amount asc, then script asc. + std::vector, uint64_t>> outputs; + outputs.reserve(payouts.size()); + for (const auto& [script, amount_d] : payouts) { + if (script.empty()) continue; + const uint64_t amount = static_cast(amount_d); + if (amount > 0) outputs.emplace_back(script, amount); + } + std::sort(outputs.begin(), outputs.end(), [](const auto& a, const auto& b) { + if (a.second != b.second) return a.second < b.second; + return a.first < b.first; + }); + + // ref_hash + frozen chain-walk values. + core::stratum::RefHashResult rh_result; + if (ref_hash_fn) { + try { + rh_result = ref_hash_fn(prev_share_hash, scriptsig, payout_script, + coinbasevalue, block_bits, curtime); + if (rh_result.bits != 0) { + share_bits_.store(rh_result.bits, std::memory_order_relaxed); + share_max_bits_.store(rh_result.max_bits, std::memory_order_relaxed); + } + } catch (const std::exception& e) { + LOG_WARNING << "[BCH-STRATUM] ref_hash_fn threw: " << e.what() + << " -- coinbase will lack OP_RETURN commitment"; + } + } + const uint256& ref_hash = rh_result.ref_hash; + const uint64_t ref_nonce = rh_result.last_txout_nonce; + const bool emit_op_return = ref_hash_fn && !ref_hash.IsNull(); + + // Assemble coinb1: full tx up to (and including) ref_hash. NO SegWit + // output -> output_count is PPLNS outputs + optional OP_RETURN only. + const size_t output_count = outputs.size() + (emit_op_return ? 1 : 0); + + std::vector coinb1; + push_u32_le(coinb1, 1); // tx version + coinb1.push_back(0x01); // vin_count = 1 + coinb1.insert(coinb1.end(), 32, 0x00); // prev_hash = 32 zero bytes + push_u32_le(coinb1, 0xFFFFFFFFu); // prev_vout + push_varint(coinb1, scriptsig.size()); + coinb1.insert(coinb1.end(), scriptsig.begin(), scriptsig.end()); + push_u32_le(coinb1, 0xFFFFFFFFu); // sequence + push_varint(coinb1, output_count); + + for (const auto& [script, amount] : outputs) { + push_u64_le(coinb1, amount); + push_varint(coinb1, script.size()); + coinb1.insert(coinb1.end(), script.begin(), script.end()); + } + if (emit_op_return) { + push_u64_le(coinb1, 0); // 0 sats + coinb1.push_back(0x2a); // script_len = 42 + coinb1.push_back(0x6a); // OP_RETURN + coinb1.push_back(0x28); // PUSH_40 + coinb1.insert(coinb1.end(), ref_hash.data(), ref_hash.data() + 32); + // [8B nonce slot -- coinb1 ends here; en1+en2 fills it] + } + + std::vector coinb2; + push_u32_le(coinb2, 0u); // locktime + + core::stratum::CoinbaseResult result; + result.coinb1 = HexStr(std::span(coinb1.data(), coinb1.size())); + result.coinb2 = HexStr(std::span(coinb2.data(), coinb2.size())); + + auto& snap = result.snapshot; + snap.subsidy = coinbasevalue; + snap.segwit_active = false; // BCH: never SegWit + snap.frozen_ref.share_version = rh_result.share_version; + snap.frozen_ref.desired_version = rh_result.desired_version; + snap.frozen_ref.bits = rh_result.bits ? rh_result.bits : share_bits_.load(); + snap.frozen_ref.max_bits = rh_result.max_bits ? rh_result.max_bits : share_max_bits_.load(); + snap.frozen_ref.timestamp = rh_result.timestamp ? rh_result.timestamp : curtime; + snap.frozen_ref.absheight = rh_result.absheight; + snap.frozen_ref.abswork = rh_result.abswork; + snap.frozen_ref.far_share_hash = rh_result.far_share_hash; + snap.frozen_ref.ref_hash = ref_hash; + snap.frozen_ref.last_txout_nonce = ref_nonce; + + auto branches = get_stratum_merkle_branches(); + for (const auto& h : branches) { + uint256 b; auto bb = ParseHex(h); + if (bb.size() == 32) std::memcpy(b.begin(), bb.data(), 32); + snap.frozen_ref.frozen_merkle_branches.push_back(b); + } + + // tx_data: raw tx hex for full-block assembly at submit time. Built inline + // (no cross-tree btc memo include -- per-coin isolation); a memo seam is a + // follow-up only if a BCH heaptrack shows churn here. + if (auto txs_field = wd->m_data.find("transactions"); + txs_field != wd->m_data.end() && txs_field->is_array()) + { + auto txv = std::make_shared>(); + txv->reserve(txs_field->size()); + for (const auto& t : *txs_field) { + if (!t.is_object()) continue; + if (auto d = t.find("data"); d != t.end() && d->is_string()) + txv->push_back(d->get()); + } + snap.tx_data = txv; + } + snap.merkle_branches = std::move(branches); + return result; } nlohmann::json BCHWorkSource::mining_submit( - const std::string& /*username*/, const std::string& /*job_id*/, - const std::string& /*extranonce1*/, const std::string& /*extranonce2*/, - const std::string& /*ntime*/, const std::string& /*nonce*/, + const std::string& username, const std::string& job_id, + const std::string& extranonce1, const std::string& extranonce2, + const std::string& ntime, const std::string& nonce, const std::string& /*request_id*/, const std::map>& /*merged_addresses*/, - const core::stratum::JobSnapshot* /*job*/) + const core::stratum::JobSnapshot* job) { - // slice-d: reconstruct coinb1||en1||en2||coinb2 (non-witness), SHA256d - // classify vs share/block target, add v36 share via create_share_fn_, and - // on a mainnet hit fire submit_block_fn_ (embedded P2P relay + BCHN-RPC - // submitblock fallback -- both legs, fallback always retained). - return nlohmann::json{{"error", "mining_submit not yet implemented (slice-d)"}}; + auto reject = [](int code, const char* msg) { + return nlohmann::json::array({ false, nlohmann::json::array({code, msg, nullptr}) }); + }; + if (!job) { + LOG_WARNING << "[BCH-STRATUM] submit reject (no JobSnapshot): user=" << username + << " job=" << job_id; + return reject(21, "Job not found"); + } + + // Reconstruct full coinbase: coinb1 || en1 || en2 || coinb2 (non-witness). + auto coinb1_bytes = ParseHex(job->coinb1); + auto en1_bytes = ParseHex(extranonce1); + auto en2_bytes = ParseHex(extranonce2); + auto coinb2_bytes = ParseHex(job->coinb2); + std::vector coinbase; + coinbase.reserve(coinb1_bytes.size() + en1_bytes.size() + en2_bytes.size() + coinb2_bytes.size()); + coinbase.insert(coinbase.end(), coinb1_bytes.begin(), coinb1_bytes.end()); + coinbase.insert(coinbase.end(), en1_bytes.begin(), en1_bytes.end()); + coinbase.insert(coinbase.end(), en2_bytes.begin(), en2_bytes.end()); + coinbase.insert(coinbase.end(), coinb2_bytes.begin(), coinb2_bytes.end()); + + uint256 coinbase_txid = Hash(std::span(coinbase.data(), coinbase.size())); + + // Ascend stratum merkle branches (LE-internal byte order; ParseHex+memcpy). + uint256 merkle_root = coinbase_txid; + for (const auto& branch_hex : job->merkle_branches) { + uint256 b; auto bb = ParseHex(branch_hex); + if (bb.size() == 32) std::memcpy(b.begin(), bb.data(), 32); + merkle_root = bch::coin::merkle_hash_pair(merkle_root, b); + } + + auto prevhash_be = ParseHex(job->gbt_prevhash); + std::vector prevhash_le(prevhash_be.rbegin(), prevhash_be.rend()); + + std::vector header; header.reserve(80); + push_u32_le(header, job->version); + header.insert(header.end(), prevhash_le.begin(), prevhash_le.end()); + header.insert(header.end(), merkle_root.data(), merkle_root.data() + 32); + push_u32_le(header, parse_be_hex_u32(ntime)); + push_u32_le(header, parse_be_hex_u32(job->nbits)); // share-target bits in header + push_u32_le(header, parse_be_hex_u32(nonce)); + + uint256 pow_hash = Hash(std::span(header.data(), header.size())); + + uint256 share_target; + if (job->share_bits != 0) share_target.SetCompact(job->share_bits); + else share_target.SetCompact(/*diff 1*/ 0x1d00ffff); + + uint256 block_target; + block_target.SetCompact(parse_be_hex_u32( + job->block_nbits.empty() ? job->nbits : job->block_nbits)); + + auto pow_hex_short = pow_hash.GetHex().substr(0, 16); + + // -- Classify -- + if (!(pow_hash > block_target)) { + // BLOCK FOUND. + uint32_t height = 0; + if (auto tip = chain_.tip(); tip) height = tip->height + 1; + LOG_WARNING << "[BCH-STRATUM-BLOCK] *** BLOCK FOUND *** user=" << username + << " height~=" << height << " pow_hash=" << pow_hex_short + << " job=" << job_id; + + // Full block: header || tx_count || coinbase || other_txs. BCH has NO + // SegWit, so the coinbase is serialized as-is (no marker/flag/witness). + static const std::vector kEmptyTxData; + const std::vector& txs = job->tx_data ? *job->tx_data : kEmptyTxData; + + std::vector block_bytes; + block_bytes.reserve(80 + 9 + coinbase.size() + txs.size() * 256); + block_bytes.insert(block_bytes.end(), header.begin(), header.end()); + push_varint(block_bytes, 1 + txs.size()); + block_bytes.insert(block_bytes.end(), coinbase.begin(), coinbase.end()); + for (const auto& tx_hex : txs) { + auto tx_bytes = ParseHex(tx_hex); + block_bytes.insert(block_bytes.end(), tx_bytes.begin(), tx_bytes.end()); + } + + // Won block: dual-path broadcast (embedded P2P relay + BCHN-RPC + // submitblock fallback). submit_block_fn_ returns true iff it reached + // at least one sink; NEITHER is a loud error (lost subsidy). + bool reached_network = false; + if (submit_block_fn_) { + try { reached_network = submit_block_fn_(block_bytes, height); } + catch (const std::exception& e) { + LOG_ERROR << "[BCH-STRATUM-BLOCK] submit_block_fn threw: " << e.what(); + } + if (!reached_network) + LOG_ERROR << "[BCH-STRATUM-BLOCK] WON BLOCK height=" << height + << " reached NEITHER P2P nor RPC -- lost subsidy!"; + } else { + LOG_ERROR << "[BCH-STRATUM-BLOCK] no submit_block_fn wired -- WON BLOCK height=" + << height << " not broadcast -- lost subsidy!"; + } + + { + std::lock_guard lk(workers_mutex_); + for (auto& [sid, w] : workers_) { (void)sid; if (w.username == username) { w.accepted++; break; } } + } + return nlohmann::json(true); + } + + if (!(pow_hash > share_target)) { + // Share meets sharechain target -> create_share_fn_ (v36 share add). + CreateShareFn create_fn; + { std::lock_guard lk(callback_mutex_); create_fn = create_share_fn_; } + + uint256 share_hash; + if (create_fn) { + auto payout_script = core::address_to_script(username); + try { share_hash = create_fn(coinbase, header, *job, payout_script); } + catch (const std::exception& e) { + LOG_WARNING << "[BCH-STRATUM-SHARE] create_share_fn threw: " << e.what() + << " -- share not added"; + } + } + + if (!share_hash.IsNull()) + LOG_INFO << "[BCH-STRATUM-SHARE] ACCEPTED + ADDED user=" << username + << " share_hash=" << share_hash.GetHex().substr(0, 16) + << " pow_hash=" << pow_hex_short << " job=" << job_id; + else if (create_fn) + LOG_INFO << "[BCH-STRATUM-SHARE] accepted (deferred) user=" << username + << " pow_hash=" << pow_hex_short << " job=" << job_id; + else + LOG_INFO << "[BCH-STRATUM-SHARE] accepted (no-tracker) user=" << username + << " pow_hash=" << pow_hex_short << " job=" << job_id; + + { + std::lock_guard lk(workers_mutex_); + for (auto& [sid, w] : workers_) { (void)sid; if (w.username == username) { w.accepted++; break; } } + } + return nlohmann::json(true); + } + + // pow_hash > share_target -> low-difficulty rejection. + { + std::lock_guard lk(workers_mutex_); + for (auto& [sid, w] : workers_) { (void)sid; if (w.username == username) { w.rejected++; break; } } + } + return reject(23, "Low difficulty share"); } double BCHWorkSource::compute_share_difficulty( - const std::string& /*coinb1*/, const std::string& /*coinb2*/, - const std::string& /*extranonce1*/, const std::string& /*extranonce2*/, - const std::string& /*ntime*/, const std::string& /*nonce*/, - uint32_t /*version*/, const std::string& /*prevhash_hex*/, - const std::string& /*nbits_hex*/, - const std::vector& /*merkle_branches*/) const + const std::string& coinb1, const std::string& coinb2, + const std::string& extranonce1, const std::string& extranonce2, + const std::string& ntime, const std::string& nonce, + uint32_t version, const std::string& prevhash_hex, + const std::string& nbits_hex, + const std::vector& merkle_branches) const { - // slice-d: rebuild header, SHA256d, target_to_difficulty (BCH shares the - // BTC SHA256d PoW family). - return 0.0; + // SHA256d per-submission difficulty (BCH shares the BTC SHA256d PoW + // family). Reconstruct coinbase -> txid -> merkle ascent -> 80B header + // -> SHA256d -> target_to_difficulty. NO scrypt, NO SegWit. + auto coinb1_bytes = ParseHex(coinb1); + auto en1_bytes = ParseHex(extranonce1); + auto en2_bytes = ParseHex(extranonce2); + auto coinb2_bytes = ParseHex(coinb2); + std::vector coinbase; + coinbase.reserve(coinb1_bytes.size() + en1_bytes.size() + en2_bytes.size() + coinb2_bytes.size()); + coinbase.insert(coinbase.end(), coinb1_bytes.begin(), coinb1_bytes.end()); + coinbase.insert(coinbase.end(), en1_bytes.begin(), en1_bytes.end()); + coinbase.insert(coinbase.end(), en2_bytes.begin(), en2_bytes.end()); + coinbase.insert(coinbase.end(), coinb2_bytes.begin(), coinb2_bytes.end()); + uint256 coinbase_txid = Hash(std::span(coinbase.data(), coinbase.size())); + + uint256 merkle_root = coinbase_txid; + for (const auto& bhex : merkle_branches) { + uint256 b; auto bb = ParseHex(bhex); + if (bb.size() == 32) std::memcpy(b.begin(), bb.data(), 32); + merkle_root = bch::coin::merkle_hash_pair(merkle_root, b); + } + + auto prevhash_be = ParseHex(prevhash_hex); + std::vector prevhash_le(prevhash_be.rbegin(), prevhash_be.rend()); + std::vector header; header.reserve(80); + push_u32_le(header, version); + header.insert(header.end(), prevhash_le.begin(), prevhash_le.end()); + header.insert(header.end(), merkle_root.data(), merkle_root.data() + 32); + push_u32_le(header, parse_be_hex_u32(ntime)); + push_u32_le(header, parse_be_hex_u32(nbits_hex)); + push_u32_le(header, parse_be_hex_u32(nonce)); + if (header.size() != 80) return 0.0; + + uint256 pow_hash = Hash(std::span(header.data(), header.size())); + if (pow_hash.IsNull()) return 0.0; + return chain::target_to_difficulty(pow_hash); } } // namespace bch::stratum From 5e0e7c7360009854127733abcbe8f54e93f561e3 Mon Sep 17 00:00:00 2001 From: frstrtr Date: Fri, 26 Jun 2026 20:32:37 +0000 Subject: [PATCH 5/6] bch(g2): conform coinbase output ordering to p2pool-merged-v36 gentx Oracle-conforming fix for the G2 coinbase-KAT divergence (data.py generate_transaction, v36_active path): - D1: donation/marker output forced LAST (immediately before OP_RETURN), excluded from the (amount asc, script asc) sort. Prior inline-sorted ordering diverged whenever donation was not the largest payout -> a sharechain fork. - D2: V36 >=1-satoshi consensus marker rule -- when total donation rounds to 0 and subsidy>0, decrement the largest PPLNS payout by 1 sat (deterministic (amount, script) tiebreak) and move it into the donation output. Zero-value donation is no longer dropped by the amount>0 filter. - D3: PPLNS dests capped to the largest 4000 (oracle dests[-4000:]). Single-coin fenced: src/impl/bch/stratum/work_source.cpp only. No core, no bitcoin_family. KAT + finder-fee-vs-oracle confirm follow as next slices; branch-only, merge operator-gated. --- src/impl/bch/stratum/work_source.cpp | 48 ++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/src/impl/bch/stratum/work_source.cpp b/src/impl/bch/stratum/work_source.cpp index 26b022c8f..c403eb2f6 100644 --- a/src/impl/bch/stratum/work_source.cpp +++ b/src/impl/bch/stratum/work_source.cpp @@ -382,9 +382,45 @@ core::stratum::CoinbaseResult BCHWorkSource::build_connection_coinbase( if (payouts.empty() && !payout_script.empty()) payouts[payout_script] = static_cast(coinbasevalue); - // Sort outputs: amount asc, then script asc. + // === Oracle-conforming output assembly (p2pool-merged-v36 data.py gentx) === + // The donation/marker output is forced LAST (immediately before the + // OP_RETURN), EXCLUDED from the (amount asc, script asc) sort, and obeys the + // V36 >= 1-satoshi consensus marker rule. PPLNS dests sort ascending and are + // capped to the largest 4000 (oracle dests[-4000:]). + + // Separate the donation/marker entry from the PPLNS payout set. + uint64_t donation_amount = 0; + bool have_donation = false; + if (!donation_script.empty()) { + auto dit = payouts.find(donation_script); + if (dit != payouts.end()) { + donation_amount = static_cast(dit->second); + have_donation = true; + payouts.erase(dit); + } + } + + // V36 CONSENSUS RULE (data.py: total_donation < 1 path): the donation/marker + // output must carry >= 1 satoshi. If it rounds to 0 while subsidy > 0, + // decrement the largest PPLNS payout by 1 sat (deterministic tiebreak on + // (amount, script)) and move that satoshi into the donation output. + if (have_donation && donation_amount < 1 && coinbasevalue > 0 && !payouts.empty()) { + auto largest = payouts.begin(); + for (auto it = payouts.begin(); it != payouts.end(); ++it) { + const uint64_t a = static_cast(it->second); + const uint64_t la = static_cast(largest->second); + if (a > la || (a == la && it->first > largest->first)) largest = it; + } + if (static_cast(largest->second) >= 1) { + largest->second -= 1.0; + donation_amount += 1; + } + } + + // PPLNS dests: drop zero-value entries, sort (amount asc, script asc), then + // keep only the largest 4000 (oracle dests[-4000:]). std::vector, uint64_t>> outputs; - outputs.reserve(payouts.size()); + outputs.reserve(payouts.size() + 1); for (const auto& [script, amount_d] : payouts) { if (script.empty()) continue; const uint64_t amount = static_cast(amount_d); @@ -394,6 +430,14 @@ core::stratum::CoinbaseResult BCHWorkSource::build_connection_coinbase( if (a.second != b.second) return a.second < b.second; return a.first < b.first; }); + if (outputs.size() > 4000) + outputs.erase(outputs.begin(), outputs.end() - 4000); + + // Donation/marker output appended LAST, before the OP_RETURN. It is NOT + // filtered by the amount>0 rule: a zero-value donation (subsidy==0) is still + // emitted to preserve gentx_before_refhash byte-parity with the oracle. + if (have_donation) + outputs.emplace_back(donation_script, donation_amount); // ref_hash + frozen chain-walk values. core::stratum::RefHashResult rh_result; From 362ff2999f52cfee2aa7f26eec3724a0d163c1f1 Mon Sep 17 00:00:00 2001 From: frstrtr Date: Sun, 28 Jun 2026 00:02:18 +0000 Subject: [PATCH 6/6] bch(m5): link pool-node TUs into c2pool-bch executable Register node.cpp and the BCHWorkSource/protocol dispatch TUs in the c2pool-bch add_executable stanza so NodeImpl:: symbols resolve, closing the --pool/--stratum link gap. Additive single-coin CMake stanza; no shared-base or peer-coin build changes. share_tracker.hpp carries the pool-node share-write integration. --- src/c2pool/CMakeLists.txt | 9 + src/impl/bch/coin/embedded_daemon.hpp | 2 +- src/impl/bch/coin/node.hpp | 29 +- src/impl/bch/node.cpp | 2422 +++++++++++++++++++++++++ src/impl/bch/share_tracker.hpp | 1306 ++++++++++++- 5 files changed, 3761 insertions(+), 7 deletions(-) create mode 100644 src/impl/bch/node.cpp diff --git a/src/c2pool/CMakeLists.txt b/src/c2pool/CMakeLists.txt index 4f33b03e6..476945d8f 100644 --- a/src/c2pool/CMakeLists.txt +++ b/src/c2pool/CMakeLists.txt @@ -238,6 +238,15 @@ add_executable(c2pool-bch ${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 + # M5 pool-node body TUs: NodeImpl out-of-line methods (node.cpp) + the two + # p2pool p2p protocol dispatchers (Legacy/Actual handle_message_* family) + + # the stratum work source. These define the previously-undefined NodeImpl:: + # and protocol symbols so the --pool path LINKS. Per-coin isolation holds + # (src/impl/bch only); compiled directly into the target like the coin TUs. + ${CMAKE_SOURCE_DIR}/src/impl/bch/node.cpp + ${CMAKE_SOURCE_DIR}/src/impl/bch/protocol_actual.cpp + ${CMAKE_SOURCE_DIR}/src/impl/bch/protocol_legacy.cpp + ${CMAKE_SOURCE_DIR}/src/impl/bch/stratum/work_source.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 diff --git a/src/impl/bch/coin/embedded_daemon.hpp b/src/impl/bch/coin/embedded_daemon.hpp index d06ec2b32..403d2b2f0 100644 --- a/src/impl/bch/coin/embedded_daemon.hpp +++ b/src/impl/bch/coin/embedded_daemon.hpp @@ -100,7 +100,7 @@ class EmbeddedDaemon { /// 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_node.run(/*embedded_primary=*/true); // init_rpc(): eager external GBT non-fatal (embedded-primary); RPC fallback retained 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 diff --git a/src/impl/bch/coin/node.hpp b/src/impl/bch/coin/node.hpp index 6cc276846..c816b441e 100644 --- a/src/impl/bch/coin/node.hpp +++ b/src/impl/bch/coin/node.hpp @@ -55,13 +55,30 @@ class Node : public bch::interfaces::Node m_p2p->connect(m_config->coin()->m_p2p.address); } - void init_rpc() + // embedded_primary: when the in-process embedded daemon is the PRIMARY work + // source (v36 external_fallback invariant), a dead / 0-peer external BCHN that + // cannot yet answer getblocktemplate must NOT abort bring-up -- the embedded + // source provides work and the external RPC is only the fallback sink. When the + // external RPC IS the work source (embedded_primary == false), a failure here + // stays fatal exactly as before. + void init_rpc(bool embedded_primary) { m_rpc = std::make_unique(m_context, this, m_config->m_testnet); m_rpc->connect(m_config->m_rpc.address, m_config->m_rpc.userpass); - // work - work.set(m_rpc->getwork()); + // work (eager prime from external RPC) + if (embedded_primary) { + try { + work.set(m_rpc->getwork()); + } catch (const std::exception& e) { + LOG_WARNING << "[EMB-BCH] init_rpc: external BCHN getwork() unavailable" + " at bring-up (" << e.what() << "); embedded-primary ->" + " deferring to embedded work source, RPC retained as" + " fallback sink."; + } + } else { + work.set(m_rpc->getwork()); + } } public: @@ -70,10 +87,12 @@ class Node : public bch::interfaces::Node { } - void run() + // embedded_primary defaults false: an external-RPC-as-work-source config still + // hard-fails on a dead RPC at bring-up. The embedded daemon passes true. + void run(bool embedded_primary = false) { // RPC - init_rpc(); + init_rpc(embedded_primary); } /// Start P2P connection to coin daemon for fast block relay. diff --git a/src/impl/bch/node.cpp b/src/impl/bch/node.cpp new file mode 100644 index 000000000..989be4666 --- /dev/null +++ b/src/impl/bch/node.cpp @@ -0,0 +1,2422 @@ +#include "node.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#ifndef _WIN32 +#include // backtrace() for think() watchdog stack dump (glibc-only) +#endif + +// Static members for DensePPLNSWindow precomputed decay table +std::vector bch::DensePPLNSWindow::s_decay_table; +uint64_t bch::DensePPLNSWindow::s_decay_per = 0; +bool bch::DensePPLNSWindow::s_table_initialized = false; + +// Helper: read current RSS from /proc/self/status (Linux only) +static long get_rss_mb() { + std::ifstream f("/proc/self/status"); + std::string line; + while (std::getline(f, line)) { + if (line.rfind("VmRSS:", 0) == 0) { + long kb = 0; + sscanf(line.c_str(), "VmRSS: %ld", &kb); + return kb / 1024; + } + } + return 0; +} + +static long g_rss_limit_mb = 4000; // abort if RSS exceeds this (configurable) + +// p2pool-style hashrate formatting: auto-scale to H/s, kH/s, MH/s, GH/s, TH/s +static std::string format_hashrate(double hs) { + std::ostringstream os; + os << std::fixed; + if (hs >= 1e12) os << std::setprecision(2) << hs / 1e12 << "TH/s"; + else if (hs >= 1e9) os << std::setprecision(2) << hs / 1e9 << "GH/s"; + else if (hs >= 1e6) os << std::setprecision(2) << hs / 1e6 << "MH/s"; + else if (hs >= 1e3) os << std::setprecision(1) << hs / 1e3 << "kH/s"; + else os << std::setprecision(0) << hs << "H/s"; + return os.str(); +} + +// p2pool-style duration formatting: auto-scale to seconds, hours, days, years +static std::string format_duration(double secs) { + if (secs <= 0 || !std::isfinite(secs)) return "???"; + std::ostringstream os; + os << std::fixed; + if (secs >= 86400.0 * 365.25) + os << std::setprecision(1) << secs / (86400.0 * 365.25) << " years"; + else if (secs >= 86400.0) + os << std::setprecision(1) << secs / 86400.0 << " days"; + else if (secs >= 3600.0) + os << std::setprecision(1) << secs / 3600.0 << " hours"; + else if (secs >= 60.0) + os << std::setprecision(1) << secs / 60.0 << " minutes"; + else + os << std::setprecision(1) << secs << " seconds"; + return os.str(); +} + +// p2pool-style Wilson score confidence interval (util/math.py:133-152) +// Returns "~X.Y% (lo-hi%)" string for binomial proportion x/n at 95% confidence. +static std::string format_binomial_conf(int x, int n, double conf = 0.95) { + if (n == 0) return "???"; + // z for 95% ≈ 1.96 (inverse error function approximation) + double z = 1.96; + double p = static_cast(x) / n; + double topa = p + z * z / (2.0 * n); + double topb = z * std::sqrt(p * (1.0 - p) / n + z * z / (4.0 * n * n)); + double bottom = 1.0 + z * z / n; + double lo = std::max(0.0, (topa - topb) / bottom); + double hi = std::min(1.0, (topa + topb) / bottom); + std::ostringstream os; + os << "~" << std::fixed << std::setprecision(1) << (100.0 * p) << "% (" + << static_cast(std::floor(100.0 * lo)) << "-" + << static_cast(std::ceil(100.0 * hi)) << "%)"; + return os.str(); +} + +// Wilson score confidence interval for efficiency: 1 - stale_rate, scaled +static std::string format_binomial_conf_efficiency(int stale, int n, double stale_prop) { + if (n == 0) return "???"; + double z = 1.96; + double p = static_cast(stale) / n; + double topa = p + z * z / (2.0 * n); + double topb = z * std::sqrt(p * (1.0 - p) / n + z * z / (4.0 * n * n)); + double bottom = 1.0 + z * z / n; + double lo_stale = std::max(0.0, (topa - topb) / bottom); + double hi_stale = std::min(1.0, (topa + topb) / bottom); + // Efficiency = (1 - stale_rate) / (1 - stale_prop) + double denom = (stale_prop < 0.999) ? (1.0 - stale_prop) : 1.0; + double eff = (1.0 - p) / denom; + double eff_lo = (1.0 - hi_stale) / denom; + double eff_hi = (1.0 - lo_stale) / denom; + eff_lo = std::max(0.0, eff_lo); + eff_hi = std::min(1.0, eff_hi); + std::ostringstream os; + os << "~" << std::fixed << std::setprecision(1) << (100.0 * eff) << "% (" + << static_cast(std::floor(100.0 * eff_lo)) << "-" + << static_cast(std::ceil(100.0 * eff_hi)) << "%)"; + return os.str(); +} + +namespace bch +{ + +static uint64_t make_random_nonce() +{ + std::mt19937_64 rng(std::random_device{}()); + return rng(); +} + +void NodeImpl::send_ping(peer_ptr peer) +{ + auto rmsg = bch::message_ping::make_raw(); + peer->write(std::move(rmsg)); +}; + +void NodeImpl::connected(std::shared_ptr socket) +{ + auto addr = socket->get_addr(); + bool is_outbound = m_pending_outbound.erase(addr) > 0; + + // Reject banned peers + if (is_banned(addr)) + { + LOG_INFO << "[Pool] Rejecting connection from banned peer " << addr.to_string(); + socket->close(); + return; + } + + // Let BaseNode create the peer and set up the timeout timer + base_t::connected(socket); + + if (is_outbound) + m_outbound_addrs.insert(addr); + + auto peer = m_connections[addr]; + send_version(peer); +} + +void NodeImpl::error(const message_error_type& err, const NetService& service, const std::source_location where) +{ + // If peer disconnected within 10s of us sending shares, those shares + // were likely rejected (e.g. PoW-invalid). Mark them so we don't + // keep re-broadcasting the same bad share on every reconnection. + { + auto it = m_last_broadcast_to.find(service); + if (it != m_last_broadcast_to.end()) { + auto elapsed = std::chrono::steady_clock::now() - it->second.when; + if (elapsed < std::chrono::seconds(10)) { + for (const auto& h : it->second.hashes) { + if (m_rejected_share_hashes.insert(h).second) { + LOG_WARNING << "[Pool] Marking share " << h.GetHex().substr(0, 16) + << " as rejected (peer " << service.to_string() + << " disconnected " << std::chrono::duration_cast(elapsed).count() + << "ms after broadcast)"; + } + } + } + m_last_broadcast_to.erase(it); + } + } + + // Drop stale nonce->peer entries for this endpoint before base cleanup. + // Without this, reconnects can be rejected as false duplicates because + // m_peers still contains the old nonce mapping. + for (auto it = m_peers.begin(); it != m_peers.end(); ) + { + if (it->second && it->second->addr() == service) + it = m_peers.erase(it); + else + ++it; + } + + // Clean outbound tracking before base removes the peer + m_pending_outbound.erase(service); + m_outbound_addrs.erase(service); + + // p2pool p2p.py:595: self.get_shares.respond_all(reason) + // Cancel pending share requests for this peer — invokes callbacks with + // empty response so m_downloading_shares entries are cleaned up immediately + // instead of waiting for the 5s ReplyMatcher timeout. + { + std::vector to_cancel; + for (auto& [req_id, peer_addr] : m_pending_share_reqs) { + if (peer_addr == service) + to_cancel.push_back(req_id); + } + for (auto& req_id : to_cancel) { + m_pending_share_reqs.erase(req_id); + m_share_getter.cancel(req_id); + } + } + + base_t::error(err, service, where); +} + +void NodeImpl::close_connection(const NetService& service) +{ + // Same rejection tracking as error() — close_connection is another + // path for peer disconnection. + { + auto it = m_last_broadcast_to.find(service); + if (it != m_last_broadcast_to.end()) { + auto elapsed = std::chrono::steady_clock::now() - it->second.when; + if (elapsed < std::chrono::seconds(10)) { + for (const auto& h : it->second.hashes) + m_rejected_share_hashes.insert(h); + } + m_last_broadcast_to.erase(it); + } + } + + m_pending_outbound.erase(service); + m_outbound_addrs.erase(service); + + // Cancel pending share requests for this peer (same as in error()) + { + std::vector to_cancel; + for (auto& [req_id, peer_addr] : m_pending_share_reqs) { + if (peer_addr == service) + to_cancel.push_back(req_id); + } + for (auto& req_id : to_cancel) { + m_pending_share_reqs.erase(req_id); + m_share_getter.cancel(req_id); + } + } + + base_t::close_connection(service); +} + +NodeImpl::TrackerSnapshot NodeImpl::get_tracker_snapshot() const { + std::lock_guard lock(m_snapshot_mutex); + return m_snapshot; +} + +int NodeImpl::get_chain_count() const { return get_tracker_snapshot().chain_count; } +int NodeImpl::get_verified_count() const { return get_tracker_snapshot().verified_count; } + +void NodeImpl::send_version(peer_ptr peer) +{ + auto rmsg = bch::message_version::make_raw( + bch::PoolConfig::ADVERTISED_PROTOCOL_VERSION, + 1, // services + addr_t{1, peer->addr()}, // addr_to (the remote) + addr_t{1, NetService{"0.0.0.0", bch::PoolConfig::P2P_PORT}}, // addr_from (us) + m_nonce, + m_software_version, + 1, // mode (always 1 for legacy compat) + advertised_best_share() // verified head, or raw head pre-sync (ROOT-2) + ); + peer->write(std::move(rmsg)); +} + +std::optional NodeImpl::handle_version(std::unique_ptr rmsg, peer_ptr peer) +{ + LOG_DEBUG_POOL << "handle message_version"; + std::unique_ptr msg; + msg = bch::message_version::make(rmsg->m_data); + + LOG_INFO << "[Pool] Peer " + << msg->m_addr_from.m_endpoint.to_string() + << " says protocol version is " + << msg->m_version + << ", client version " + << msg->m_subversion; + + if (peer->m_other_version.has_value()) + { + LOG_DEBUG_POOL << "more than one version message"; + throw std::runtime_error("more than one version message"); + } + + peer->m_other_version = msg->m_version; + peer->m_other_subversion = msg->m_subversion; + peer->m_other_services = msg->m_services; + + if (m_nonce == msg->m_nonce) + { + LOG_WARNING << "[Pool] was connected to self"; + return std::nullopt; + } + + if (m_peers.contains(msg->m_nonce)) + { + LOG_DEBUG_POOL << "Detected duplicate connection, disconnecting from " << peer->addr().to_string(); + return std::nullopt; + } + + peer->m_nonce = msg->m_nonce; + m_peers[peer->m_nonce] = peer; + + // Request peers from the newly established connection + { + auto getaddrs_msg = bch::message_getaddrs::make_raw(8); + peer->write(std::move(getaddrs_msg)); + } + + // Reject peers running too-old protocol + if (msg->m_version < bch::PoolConfig::MINIMUM_PROTOCOL_VERSION) + { + LOG_WARNING << "Peer " << msg->m_addr_from.m_endpoint.to_string() + << " protocol " << msg->m_version + << " < minimum " << bch::PoolConfig::MINIMUM_PROTOCOL_VERSION + << ", disconnecting"; + throw std::runtime_error("peer protocol too old"); + } + + if (!msg->m_best_share.IsNull()) + { + LOG_INFO << "Best share hash for " << msg->m_addr_from.m_endpoint.to_string() + << " = " << msg->m_best_share.ToString(); + + if (!m_chain->contains(msg->m_best_share)) { + // Start downloading shares we don't have + download_shares(peer, msg->m_best_share); + } else { + // p2pool: handle_share_hashes → handle_shares → set_best_share() + // Even when the share is known, re-run think() to re-evaluate + // best chain with the peer's perspective. Critical after restart: + // shares loaded from LevelDB may have stale best_share selection. + run_think(); + } + } + + // Advertise ourselves to the peer (matching Python p2pool sendAdvertisement) + { + auto port = core::Server::listen_port(); + auto addrme_msg = bch::message_addrme::make_raw(port); + peer->write(std::move(addrme_msg)); + } + + return pool::PeerConnectionType::legacy; +} + +void NodeImpl::processing_shares(HandleSharesData& data_ref, NetService addr) +{ + // Take ownership immediately so the caller can return/free its local. + auto data = std::make_shared(std::move(data_ref)); + size_t n = data->m_items.size(); + if (n == 0) return; + + // Phase 1 (thread pool, parallel): run share_init_verify() for each share. + // share_init_verify() does SHA256d share PoW (BCH, NOT scrypt) — must NOT block io_context. + // Each share's hash computation is independent, so we can fully parallelize. + auto remaining = std::make_shared>(static_cast(n)); + for (size_t i = 0; i < n; i++) + { + boost::asio::post(m_verify_pool, + [i, data, remaining, this, addr]() + { + auto& share = data->m_items[i]; + if (share.hash().IsNull()) + { + try + { + share.ACTION({ + obj->m_hash = share_init_verify(*obj, true); + }); + } + catch (const std::exception&) + { + // leave hash null — phase 2 will skip this share + } + } + // When all verifications are done, schedule phase 2 on io_context + if (--(*remaining) == 0) + { + boost::asio::post(*m_context, + [data, this, addr]() + { + processing_shares_phase2(*data, addr); + }); + } + }); + } +} + +void NodeImpl::processing_shares_phase2(HandleSharesData& data, NetService addr) +{ + // Phase 2 (io_context thread): topological sort + chain insertion + LevelDB store. + // All shared state (m_tracker, m_chain, m_raw_share_cache, m_storage) touched here. + // + // Non-blocking mutex: if think() holds the exclusive lock on the compute + // thread, queue this batch for processing after think() releases. The IO + // thread never blocks — keepalive timers and network I/O continue. + // + // HOLD this lock across the entire mutation body below (mirrors LTC f445db8e). + // try_to_lock keeps the IO thread non-blocking — busy => queue + return. Once + // acquired we must NOT release until all m_tracker.chain mutations are done: + // the prior code released here and ran the body lock-free, letting the + // compute-thread clean_tracker() exclusive prune (drop_tails) free chain nodes + // mid-mutation -> SIGSEGV (kr1z1s LTC/DGB). Released just before run_think(). + std::unique_lock lock(m_tracker_mutex, std::try_to_lock); + if (!lock.owns_lock()) { + // ── Backpressure (V36 livelock defense-in-depth) ────────────── + // If think() is wedged/slow the deferred queue could grow without + // bound and blow memory. Cap it: over MAX_PENDING_ADDS we DROP the + // new batch (peers re-advertise their best share, so dropped shares + // are re-requested later) and warn instead of growing unbounded. + if (m_pending_adds.size() >= MAX_PENDING_ADDS) { + LOG_WARNING << "[ASYNC-DEFER] BACKPRESSURE: pending_adds at cap (" + << m_pending_adds.size() << "/" << MAX_PENDING_ADDS + << "), dropping batch of " << data.m_items.size() + << " shares from " << addr.to_string() + << " — think() may be wedged"; + return; + } + LOG_INFO << "[ASYNC-DEFER] processing_shares_phase2: mutex busy, queuing " + << data.m_items.size() << " shares from " << addr.to_string() + << " (pending=" << m_pending_adds.size() + 1 << ")"; + m_pending_adds.push_back(PendingShareBatch{ + std::make_unique(std::move(data)), addr}); + return; + } + // Lock acquired and HELD across the mutation body below; released just before + // the async run_think() trigger so the compute thread can take the exclusive + // lock. ASIO single-thread still guarantees no overlapping IO handler. + + // Step 1: collect verified shares (skip any that failed verification, hash still null) + std::vector valid_shares; + valid_shares.reserve(data.m_items.size()); + for (size_t idx = 0; idx < data.m_items.size(); ++idx) + { + auto& share = data.m_items[idx]; + if (share.hash().IsNull()) + continue; // verification failed in phase 1 + + // Cache original raw bytes for relay (keyed by computed hash) + if (idx < data.m_raw_items.size() && !data.m_raw_items[idx].contents.m_data.empty()) + m_raw_share_cache[share.hash()] = std::move(data.m_raw_items[idx]); + valid_shares.push_back(share); + } + + // Step 2: Topologically sort valid shares by hash/prev_hash linkage + chain::PreparedList prepare_shares(valid_shares); + std::vector shares = prepare_shares.build_list(); + + // Step 3: Process sorted shares + int32_t new_count = 0; + int32_t dup_count = 0; + std::map all_new_txs; + std::vector db_batch; + for (int i = 0; i < (int)shares.size(); ++i) + { + auto& share = shares[i]; + + // Safety: abort if RSS exceeds limit + if (i % 100 == 0) { + long rss_now = get_rss_mb(); + if (rss_now > g_rss_limit_mb) { + LOG_ERROR << "RSS LIMIT EXCEEDED (" << rss_now << "MB > " << g_rss_limit_mb << "MB) — aborting!"; + std::abort(); + } + } + + auto& new_txs = data.m_txs[share.hash()]; + if (!new_txs.empty()) + { + for (auto& new_tx : new_txs) + { + // BCH divergence: plain pack(tx) -- no TX_WITH_WITNESS (no + // witness on BCH). Matches protocol_actual.cpp/protocol_legacy.cpp + // remember_tx hashing (plain pack(tx) -> Hash(span)). + PackStream packed_tx = pack(new_tx); + all_new_txs[Hash(packed_tx.get_span())] = new_tx; + } + } + + if (m_chain->contains(share.hash())) + { + ++dup_count; + continue; + } + + ++new_count; + + // Log received share — p2pool format: "Received share diff=X hash=Y miner=Z" + share.invoke([](auto* obj) { + auto target = chain::bits_to_target(obj->m_bits); + double diff = chain::target_to_difficulty(target); + // Extract miner identity (pubkey_hash for v17/v33/v36, address script for v34/v35) + std::string miner_hex; + if constexpr (requires { obj->m_pubkey_hash; }) + miner_hex = obj->m_pubkey_hash.GetHex().substr(0, 16); + else if constexpr (requires { obj->m_address; }) + miner_hex = "script"; + LOG_INFO << "Received share: diff=" << std::scientific << std::setprecision(2) << diff + << " hash=" << obj->m_hash.GetHex().substr(0, 16) + << " miner=" << miner_hex; + }); + + m_tracker.add(share); + + // Log fork detection: if this share's prev_hash has other children, it forks + { + uint256 prev; + bool is_local = false; + share.invoke([&](auto* obj) { + prev = obj->m_prev_hash; + is_local = (obj->peer_addr == NetService{"0.0.0.0", 0} || + obj->peer_addr == NetService{}); + }); + if (is_local && !prev.IsNull()) { + auto& rev = m_tracker.chain.get_reverse(); + auto it = rev.find(prev); + if (it != rev.end() && it->second.size() > 1) { + static int fork_log = 0; + if (fork_log++ < 50) + LOG_WARNING << "[FORK] Local share forks! prev=" << prev.GetHex().substr(0,16) + << " siblings=" << it->second.size() + << " verified_best=" << m_best_share_hash.GetHex().substr(0,16); + } + } + } + + // Verification is deferred to think() Phase 1 (called after this batch). + // p2pool: handle_shares() only adds, set_best_share()→think() verifies. + // Inline verification was redundant and caused double-verify CPU waste. + + // NOTE: Do NOT trim inside the processing loop. The trim in run_think() + // handles pruning between batches. Trimming here is unsafe because + // shares added at the tail can be freed while the loop still holds + // dangling raw pointers to them (use-after-free). + + // Collect for batch LevelDB persist (committed atomically after loop) + if (m_storage && m_storage->is_available()) + { + std::vector bytes; + auto raw_it = m_raw_share_cache.find(share.hash()); + if (raw_it != m_raw_share_cache.end() && + raw_it->second.type == share.version() && + !raw_it->second.contents.m_data.empty()) + { + bytes.assign(raw_it->second.contents.m_data.begin(), + raw_it->second.contents.m_data.end()); + } + else + { + PackStream ps = pack(share); + auto span = ps.get_span(); + bytes.assign(reinterpret_cast(span.data()), + reinterpret_cast(span.data()) + span.size()); + } + uint64_t ver = share.version(); + std::vector versioned; + versioned.resize(8 + bytes.size()); + std::memcpy(versioned.data(), &ver, 8); + std::memcpy(versioned.data() + 8, bytes.data(), bytes.size()); + + share.ACTION({ + uint256 target = chain::bits_to_target(obj->m_bits); + uint256 abswork_256; + std::copy(obj->m_abswork.begin(), obj->m_abswork.end(), abswork_256.begin()); + c2pool::storage::SharechainStorage::ShareBatchEntry entry; + entry.hash = obj->m_hash; + entry.serialized_data = std::move(versioned); + entry.prev_hash = obj->m_prev_hash; + entry.height = obj->m_absheight; + entry.timestamp = obj->m_timestamp; + entry.work = abswork_256; + entry.target = target; + db_batch.push_back(std::move(entry)); + }); + } + } + + // Commit all shares to LevelDB atomically (one WriteBatch for entire batch). + // Crash-safe: either ALL shares persisted or NONE. + if (!db_batch.empty() && m_storage && m_storage->is_available()) { + m_storage->store_shares_batch(db_batch); + } + + if (new_count > 0) { + auto as2 = addr.to_string(); + std::string source = (addr.port() == 0) ? as2.substr(0, as2.rfind(':')) : as2; + LOG_INFO << "Processing " << new_count << " shares from " + << source << "... (dup=" << dup_count + << " chain=" << m_tracker.chain.size() << ")"; + } + + // Release the tracker lock before triggering think(): run_think() posts the + // think+prune job to the compute thread, which needs the exclusive lock. All + // chain mutations above are complete at this point. + lock.unlock(); + + // Trigger think() after every share batch (p2pool: set_best_share after handle_shares). + // p2pool calls set_best_share() after EVERY batch with new_count > 0 — no size gate. + // think() scores heads and updates best_share + desired set for download_shares. + if (new_count > 0) { + run_think(); + } +} + +std::vector NodeImpl::handle_get_share(std::vector hashes, uint64_t parents, std::vector stops, NetService peer_addr) +{ + // try_to_lock per the architectural rule (node.hpp:67) — IO thread MUST + // never block on m_tracker_mutex. A blocking shared_lock here was the + // root cause of the periodic event-loop freeze + SIGABRT cycle on + // contabo (2026-04-12, -16, -19, -21, -25): when the compute thread + // held the exclusive lock for a long think+clean cycle on a wedged + // chain (~30+s), an incoming SHAREREQ on the IO thread would block + // here, the watchdog would fire after 30s of io_context unresponsive, + // and systemd would restart. + // + // Empty reply does NOT cause peer disconnect — p2pool's downloader + // (node.py:120) picks a random peer per request and retries; an empty + // hit just shifts to a different peer next iteration. + std::shared_lock lock(m_tracker_mutex, std::try_to_lock); + if (!lock.owns_lock()) + { + static int defer_log = 0; + if (defer_log++ % 50 == 0) + LOG_INFO << "[handle_get_share] tracker busy — returning empty to " + << peer_addr.to_string() + << " (peer will retry against another peer)"; + return {}; + } + + parents = std::min(parents, (uint64_t)1000/hashes.size()); + std::vector shares; + for (const auto& handle_hash : hashes) + { + if (!m_chain->contains(handle_hash)) + { + static int miss_log = 0; + if (miss_log++ < 5) + LOG_WARNING << "[handle_get_share] hash NOT in chain: " + << handle_hash.ToString().substr(0, 16) + << " chain_size=" << m_chain->size() + << " tracker_chain_size=" << m_tracker.chain.size(); + continue; + } + uint64_t n = std::min(parents+1, (uint64_t) m_chain->get_height(handle_hash)); + for (auto [hash, data] : m_chain->get_chain(handle_hash, n)) + { + if (std::find(stops.begin(), stops.end(), hash) != stops.end()) + break; + if (m_rejected_share_hashes.count(hash)) + continue; + shares.push_back(data.share); + } + } + + if (!shares.empty()) + { + LOG_INFO << "[Pool] Sending " << shares.size() << " shares to " << peer_addr.to_string(); + } + return shares; +} + +void NodeImpl::send_shares(peer_ptr peer, const std::vector& share_hashes) +{ + // try_to_lock per the architectural rule (node.hpp:67) — see freeze + // analysis in handle_get_share above. If we can't acquire NOW, skip + // this batch. The shares are still in our chain; the next broadcast + // cycle (or the next think() result) picks them up. + std::shared_lock lock(m_tracker_mutex, std::try_to_lock); + if (!lock.owns_lock()) + { + static int defer_log = 0; + if (defer_log++ % 50 == 0) + LOG_INFO << "[send_shares] tracker busy — skipping send to " + << peer->addr().to_string() << " (will retry next cycle)"; + return; + } + + // Collect shares that exist in our chain (skip rejected) + std::vector shares; + for (const auto& hash : share_hashes) + { + if (!m_chain->contains(hash)) + continue; + if (m_rejected_share_hashes.count(hash)) + continue; + // Retrieve the share via get_chain(hash, 1) — first element is the share itself + for (auto [h, data] : m_chain->get_chain(hash, 1)) + { + shares.push_back(data.share); + break; + } + } + + if (shares.empty()) + return; + + // Collect transactions that the peer doesn't know about + std::set needed_txs; + for (auto& share : shares) + { + share.invoke([&](auto* obj) { + if constexpr (requires { obj->m_new_transaction_hashes; }) + { + for (const auto& th : obj->m_new_transaction_hashes) + { + if (!peer->m_remote_txs.count(th) && + !peer->m_remembered_txs.count(th)) + needed_txs.insert(th); + } + } + }); + } + + // Send remember_tx for txs the peer needs + if (!needed_txs.empty()) + { + std::vector known_hashes; // hashes in peer's remote set + std::vector full_txs; // full txs otherwise + + for (const auto& th : needed_txs) + { + if (peer->m_remote_txs.count(th)) + { + known_hashes.push_back(th); + } + else + { + auto it = m_known_txs.find(th); + if (it != m_known_txs.end()) + full_txs.emplace_back(it->second); + } + } + + if (!known_hashes.empty() || !full_txs.empty()) + { + auto rtx_msg = message_remember_tx::make_raw(known_hashes, full_txs); + peer->write(std::move(rtx_msg)); + } + } + + // Pack and send shares — use cached original bytes when available + std::vector rshares; + rshares.reserve(shares.size()); + for (size_t i = 0; i < shares.size(); ++i) + { + auto it = m_raw_share_cache.find(shares[i].hash()); + if (it != m_raw_share_cache.end()) + { + rshares.push_back(it->second); + } + else + { + rshares.emplace_back(shares[i].version(), pack(shares[i])); + } + } + + auto shares_msg = message_shares::make_raw(rshares); + peer->write(std::move(shares_msg)); + + // Send forget_tx so peer can free the remembered txs + if (!needed_txs.empty()) + { + std::vector forget_vec(needed_txs.begin(), needed_txs.end()); + auto ftx_msg = message_forget_tx::make_raw(forget_vec); + peer->write(std::move(ftx_msg)); + } + + LOG_INFO << "[Pool] Sent " << shares.size() << " shares (+" << needed_txs.size() + << " txs) to " << peer->addr().to_string(); +} + +void NodeImpl::broadcast_share(const uint256& share_hash) +{ + // try_to_lock per the architectural rule (node.hpp:67) — see freeze + // analysis in handle_get_share above. If think+clean holds the + // exclusive lock right now, defer this broadcast: the share is still + // in our chain; the next local share creation, the next think cycle, + // or the next peer-driven SHAREREQ will pick it up. Blocking here + // was a contributing freeze trigger (called from local-share creation + // and from the share-add hot path). + std::shared_lock lock(m_tracker_mutex, std::try_to_lock); + if (!lock.owns_lock()) + { + static int defer_log = 0; + if (defer_log++ % 50 == 0) + LOG_INFO << "[broadcast_share] tracker busy — deferring broadcast of " + << share_hash.GetHex().substr(0, 16) + << " (next cycle will pick it up)"; + return; + } + + // Walk the chain back from share_hash, collecting un-broadcast shares + std::vector to_send; + int32_t height = m_chain->get_height(share_hash); + int32_t walk = std::min(height, 5); + + for (auto [hash, data] : m_chain->get_chain(share_hash, walk)) + { + if (m_shared_share_hashes.count(hash)) + break; + if (m_rejected_share_hashes.count(hash)) + continue; // skip shares previously rejected by peers + m_shared_share_hashes.insert(hash); + to_send.push_back(hash); + } + + if (to_send.empty()) + return; + + auto now = std::chrono::steady_clock::now(); + for (auto& [nonce, peer] : m_peers) { + send_shares(peer, to_send); + m_last_broadcast_to[peer->addr()] = {to_send, now}; + } +} + +void NodeImpl::notify_local_share(const uint256& share_hash) +{ + // p2pool: set_best_share() → think() synchronously on the reactor thread. + // Use think() for ALL best_share decisions, matching p2pool exactly. + if (share_hash.IsNull()) + return; + + // Both the chain.contains() read AND attempt_verify() run UNDER the tracker + // lock (mirrors LTC f445db8e). A bare m_tracker.chain.contains() here (the + // prior code) raced the compute-thread clean_tracker() exclusive prune freeing + // chain nodes -> SIGSEGV (kr1z1s LTC/DGB). try_to_lock keeps the IO thread + // non-blocking; if think()/clean holds the lock we skip the inline verify — + // the share is already in-chain and run_think() below will score it next cycle. + { + std::unique_lock lock(m_tracker_mutex, std::try_to_lock); + if (lock.owns_lock() && m_tracker.chain.contains(share_hash)) + m_tracker.attempt_verify(share_hash); + } + + // Trigger async think() — will pick up the local share through scoring. + run_think(); +} + +uint256 NodeImpl::best_share_hash() +{ + // p2pool's think() returns the best VERIFIED head — not the raw chain tip. + // This ensures shares are built on a chain that all peers agree on. + // Use think().best ONLY if it's on the VERIFIED chain. + // p2pool's think() only considers verified shares for the best head. + // If think().best is on an unverified fork (e.g., our own shares that + // p2pool hasn't verified yet), fall back to the best verified head. + // This prevents building a self-reinforcing fork that never joins + // the main chain. + if (!m_best_share_hash.IsNull() && m_tracker.verified.contains(m_best_share_hash)) { + static int log_count = 0; + if (log_count++ % 60 == 0) { + auto h = m_tracker.verified.get_height(m_best_share_hash); + LOG_INFO << "[best_share] using think() result height=" << h + << " verified=" << m_tracker.verified.size() + << " raw=" << (m_chain ? m_chain->size() : 0); + } + return m_best_share_hash; + } + // Fallback: if think() hasn't run yet, pick best verified head by work + auto& verified = m_tracker.verified; + if (verified.size() > 0) { + uint256 best; + uint288 best_work; + bool first = true; + for (const auto& [head_hash, tail_hash] : verified.get_heads()) { + auto* idx = verified.get_index(head_hash); + if (idx && (first || idx->work > best_work)) { + best = head_hash; + best_work = idx->work; + first = false; + } + } + if (!best.IsNull()) { + static int log_count2 = 0; + auto best_height = verified.get_height(best); + if (log_count2++ % 60 == 0) + LOG_INFO << "[best_share] fallback VERIFIED head height=" << best_height + << " work=" << best_work.GetHex().substr(0, 16) + << " verified=" << verified.size() + << " raw=" << (m_chain ? m_chain->size() : 0); + return best; + } + } + + // No verified chain yet — return null to prevent creating shares with + // MAX_TARGET max_bits. p2pool does the same: best_share_var.value = None + // until the verified chain exists, and generate_transaction refuses to + // create work when best_share is None (with peers connected). + // + // On a genesis node (no peers, fresh chain), this returns ZERO which + // triggers genesis share creation with 100% donation payout. + if (!m_peers.empty()) { + static int wait_log = 0; + if (wait_log++ % 12 == 0) + LOG_INFO << "[best_share] waiting for verified chain (peers=" + << m_peers.size() << " raw=" + << (m_chain ? m_chain->size() : 0) << ")"; + return uint256::ZERO; + } + + // True genesis: no peers at all — use raw chain to bootstrap + if (!m_chain || m_chain->size() == 0) + return uint256::ZERO; + + uint256 best; + int32_t best_height = -1; + for (const auto& [head_hash, tail_hash] : m_chain->get_heads()) { + auto h = m_chain->get_height(head_hash); + if (h > best_height) { + best = head_hash; + best_height = h; + } + } + static int raw_log = 0; + if (raw_log++ % 60 == 0) + LOG_INFO << "[best_share] using RAW head (genesis, no peers) height=" << best_height; + return best; +} + +// Head advertised to peers in the version handshake and re-advertisement ONLY. +// Unlike best_share_hash() -- which is verified-only and collapses to ZERO once +// peers exist so work/template never builds on a MAX_TARGET head -- this returns +// our tallest RAW chain head. Root-2: on a fresh (--genesis) node the verified +// set is empty at handshake, so best_share_hash() advertised ZERO and the peer +// never issued download_shares() for our shares. Advertising the raw head makes +// a connecting peer always learn we have a chain and pull it, while work/template +// stays on the verified head via best_share_hash(). +uint256 NodeImpl::advertised_best_share() +{ + // Prefer think()s current best (verified or not) -- our canonical head. + if (!m_best_share_hash.IsNull()) + return m_best_share_hash; + + // Otherwise the tallest raw chain head (mirrors the genesis fallback above). + if (m_chain && m_chain->size() > 0) { + uint256 best; + int32_t best_height = -1; + for (const auto& [head_hash, tail_hash] : m_chain->get_heads()) { + auto h = m_chain->get_height(head_hash); + if (h > best_height) { + best = head_hash; + best_height = h; + } + } + if (!best.IsNull()) + return best; + } + + // True genesis: no shares at all yet -- advertise ZERO exactly as before. + return uint256::ZERO; +} + +void NodeImpl::readvertise_best() +{ + if (m_peers.empty()) + return; + uint256 adv = advertised_best_share(); + if (adv.IsNull() || adv == uint256::ZERO) + return; + broadcast_share(adv); +} + +void NodeImpl::download_shares(peer_ptr /*unused_peer*/, const uint256& target_hash) +{ + // p2pool node.py:108-141 download_shares() — exact translation. + // + // Key differences from old c2pool implementation: + // 1. RANDOM peer selection (not the reporting peer) + // 2. RANDOM parent count 0-499 (not fixed 500) + // 3. STOPS list: known heads + their 10th parents (not empty) + // 4. Log format: "Requesting parent share XXXX from IP:PORT" + + // Already downloading this hash — avoid duplicate requests + if (m_downloading_shares.count(target_hash)) + return; + + // Stop requesting hashes that have returned empty too many times — + // the share is pruned from the network. p2pool uses reactive desired_var + // + sleep(1) backoff; we use explicit failure counting. + if (m_download_fail_count[target_hash] >= MAX_EMPTY_RETRIES) { + static int fail_log = 0; + if (fail_log++ % 20 == 0) + LOG_INFO << "[Pool] Skipping permanently failed hash " + << target_hash.ToString().substr(0,16) + << " (failed " << m_download_fail_count[target_hash] << " times)"; + return; + } + + m_downloading_shares.insert(target_hash); + + // p2pool: if len(self.peers) == 0: sleep(1); continue + if (m_peers.empty()) { + m_downloading_shares.erase(target_hash); + return; + } + + // p2pool: peer = random.choice(self.peers.values()) + auto peer_it = m_peers.begin(); + if (m_peers.size() > 1) { + std::advance(peer_it, core::random::random_uint256().GetLow64() % m_peers.size()); + } + auto& peer = peer_it->second; + + // p2pool: parents=random.randrange(500) + uint64_t parents = core::random::random_uint256().GetLow64() % 500; + + // p2pool: stops=list(set(tracker.heads) | set( + // tracker.get_nth_parent_hash(head, min(max(0, height-1), 10)) + // for head in tracker.heads))[:100] + // + // Always include stops to bound per-request reply size to the actual + // info-gap between our chain and the peer's. Without stops, peer dumps + // up to `parents` shares in one single-branch burst — and with bootstrap + // parents=random(500), the chain grows along one lineage until it + // crosses 2*CHAIN_LENGTH+10, at which point clean_tracker drop-tails + // collapses the whole branch and verified resets to 0. + std::vector stops; + { + std::set stop_set; + for (auto& [head_hash, tail_hash] : m_tracker.chain.get_heads()) { + stop_set.insert(head_hash); + auto h = m_tracker.chain.get_acc_height(head_hash); + auto nth = std::min(std::max(0, h - 1), 10); + if (nth > 0) { + auto parent = m_tracker.chain.get_nth_parent_via_skip(head_hash, nth); + if (!parent.IsNull()) + stop_set.insert(parent); + } + } + int count = 0; + for (auto& s : stop_set) { + if (count++ >= 100) break; + stops.push_back(s); + } + } + + auto req_id = core::random::random_uint256(); + std::vector hashes = { target_hash }; + + // Track req_id → peer for selective cancellation on disconnect + m_pending_share_reqs[req_id] = peer->addr(); + + // p2pool: print 'Requesting parent share %s from %s' + LOG_INFO << "[Pool] Requesting parent share " + << target_hash.ToString().substr(0,16) + << " from " << peer->addr().to_string() + << " (parents=" << parents << " stops=" << stops.size() << ")"; + + // weak_ptr prevents use-after-free if peer disconnects before reply + std::weak_ptr> weak_peer = peer; + auto peer_addr_for_log = peer->addr(); + + request_shares(req_id, peer, hashes, parents, stops, + [this, weak_peer, target_hash, peer_addr_for_log, req_id](bch::ShareReplyData reply) + { + m_downloading_shares.erase(target_hash); + m_pending_share_reqs.erase(req_id); + + if (reply.m_items.empty()) + { + // Empty reply = timeout or peer had no matching shares. + // Track failures — stop requesting after MAX_EMPTY_RETRIES. + auto& fail_cnt = m_download_fail_count[target_hash]; + ++fail_cnt; + LOG_INFO << "[Pool] Share request empty for " + << target_hash.ToString().substr(0,16) + << " from " << peer_addr_for_log.to_string() + << " (fail " << fail_cnt << "/" << MAX_EMPTY_RETRIES << ")"; + return; + } + + // Success — clear failure count for this hash + m_download_fail_count.erase(target_hash); + + LOG_INFO << "[Pool] Received " << reply.m_items.size() << " shares for download request"; + + // Feed into processing pipeline + HandleSharesData data; + for (size_t idx = 0; idx < reply.m_items.size(); ++idx) + { + if (idx < reply.m_raw_items.size()) + data.add(reply.m_items[idx], {}, reply.m_raw_items[idx]); + else + data.add(reply.m_items[idx], {}); + } + processing_shares(data, peer_addr_for_log); + + // Find the oldest share's parent — if unknown, keep fetching + uint256 oldest_parent; + reply.m_items.back().invoke([&](auto* obj) { oldest_parent = obj->m_prev_hash; }); + + if (!oldest_parent.IsNull() && !m_chain->contains(oldest_parent)) + { + auto locked = weak_peer.lock(); + if (locked) + download_shares(locked, oldest_parent); + } + } + ); +} + +void NodeImpl::load_persisted_shares() +{ + if (!m_storage || !m_storage->is_available()) + return; + + // load_sharechain iterates the height index and loads each share. + // Limit to keep_per_head shares to avoid loading unbounded history + // into memory (LevelDB is never pruned, so it accumulates forever). + LOG_INFO << "[Pool] Scanning LevelDB height index for persisted shares..."; + auto t0 = std::chrono::steady_clock::now(); + auto all_hashes = m_storage->get_shares_by_height_range(0, UINT64_MAX); + auto scan_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0).count(); + { + std::set unique(all_hashes.begin(), all_hashes.end()); + LOG_INFO << "[Pool] Height index scan: " << all_hashes.size() << " entries (" + << unique.size() << " unique) in " << scan_ms << "ms"; + } + if (all_hashes.empty()) + { + LOG_INFO << "No persisted shares found in LevelDB"; + return; + } + + const size_t keep_per_head = PoolConfig::chain_length() * 2 + 10; + const size_t total_in_db = all_hashes.size(); + + // Only load the most recent shares (highest height = end of vector) + size_t skip = 0; + if (total_in_db > keep_per_head) + { + skip = total_in_db - keep_per_head; + LOG_INFO << "[Pool] LevelDB has " << total_in_db << " shares, loading only newest " + << keep_per_head << " (skipping " << skip << " old shares)"; + } + + const size_t to_load = total_in_db - skip; + LOG_INFO << "[Pool] Loading shares from LevelDB: " << to_load << " shares to process..."; + int loaded = 0, skipped_contains = 0, skipped_load = 0, skipped_small = 0; + std::vector verified_hashes; // shares to pre-populate into verified + for (size_t i = skip; i < total_in_db; ++i) + { + const auto& hash = all_hashes[i]; + std::vector data; + core::ShareMetadata meta; + if (!m_storage->load_share(hash, data, meta)) { + ++skipped_load; + continue; + } + if (data.size() < 8) { + ++skipped_small; + continue; + } + + try + { + // First 8 bytes = version (uint64_t LE), rest = packed share + uint64_t ver; + std::memcpy(&ver, data.data(), 8); + + std::vector share_bytes(data.begin() + 8, data.end()); + PackStream ps(share_bytes); + + auto share = bch::load_share(static_cast(ver), ps, NetService{"database", 0}); + // m_hash is not part of the serialized format — it's computed + // during share_check validation. Restore it from the LevelDB key. + share.ACTION({ obj->m_hash = hash; }); + if (!m_chain->contains(share.hash())) + { + m_tracker.add(share); + loaded++; + + // Restore or compute pow_hash on the index. + // p2pool recomputes pow_hash on every load (data.py:1362). + // We cache it in LevelDB to avoid recompute, but recompute if missing. + { + auto* idx = m_tracker.chain.get_index(share.hash()); + if (idx) { + if (!meta.pow_hash.IsNull()) { + idx->pow_hash = meta.pow_hash; + } else { + // Recompute SHA256d pow_hash (matching p2pool's __init__ behavior) + try { + g_last_pow_hash = uint256(); + g_last_init_is_block = false; + uint256 computed_hash; + share.ACTION({ computed_hash = share_init_verify(*obj, true); }); + if (!g_last_pow_hash.IsNull()) + idx->pow_hash = g_last_pow_hash; + if (!computed_hash.IsNull() && computed_hash != hash) { + static int hash_mismatch = 0; + if (++hash_mismatch <= 3) + LOG_WARNING << "pow_hash recompute: hash mismatch stored=" + << hash.GetHex().substr(0,16) + << " computed=" << computed_hash.GetHex().substr(0,16); + } + } catch (...) {} + } + } + } + + // p2pool known_verified: pre-populate verified tracker without re-verification + if (meta.is_verified) + verified_hashes.push_back(share.hash()); + + if (loaded % 1000 == 0) { + size_t progress = i - skip + 1; + LOG_INFO << "[Pool] Loading shares: " << progress << "/" << to_load + << " (" << (100 * progress / to_load) << "%)"; + } + } else { + ++skipped_contains; + } + } + catch (const std::exception& e) + { + LOG_WARNING << "Failed to load share " << hash.ToString() + << " from LevelDB: " << e.what(); + } + } + + // Pre-populate verified chain from persisted known_verified set (p2pool node.py:190-192). + // Shares are loaded by ascending height, so parent-before-child order is guaranteed. + int pre_verified = 0; + for (const auto& vh : verified_hashes) { + if (m_tracker.chain.contains(vh) && !m_tracker.verified.contains(vh)) { + try { + auto& share_var = m_tracker.chain.get_share(vh); + m_tracker.verified.add(share_var); + ++pre_verified; + } catch (...) {} + } + } + if (pre_verified > 0) + LOG_INFO << "[Pool] Pre-populated " << pre_verified << " verified shares from LevelDB"; + + LOG_INFO << "[Pool] Loaded " << loaded << " shares from LevelDB storage" + << " (DB total: " << total_in_db << ", limit: " << keep_per_head + << ", skipped: load=" << skipped_load << " small=" << skipped_small + << " dup=" << skipped_contains << ")"; + + // Prune old shares from LevelDB that we skipped + if (skip > 0) + { + size_t pruned = 0; + for (size_t i = 0; i < skip; ++i) + { + if (m_storage->remove_share(all_hashes[i])) + ++pruned; + } + LOG_INFO << "[Pool] Pruned " << pruned << " old shares from LevelDB"; + } +} + +void NodeImpl::flush_verified_to_leveldb() +{ + if (m_verified_flush_buf.empty() || !m_storage || !m_storage->is_available()) + return; + // Persist pow_hash from tracker index alongside verified status + std::vector> hash_pow_pairs; + for (const auto& hash : m_verified_flush_buf) { + uint256 pow; + auto* idx = m_tracker.chain.get_index(hash); + if (idx) pow = idx->pow_hash; + hash_pow_pairs.emplace_back(hash, pow); + } + m_storage->mark_shares_verified_with_pow(hash_pow_pairs); + m_verified_flush_buf.clear(); +} + +void NodeImpl::shutdown() +{ + LOG_INFO << "[Pool] NodeImpl shutdown: flushing pending LevelDB buffers..."; + flush_verified_to_leveldb(); + if (!m_removal_flush_buf.empty() && m_storage && m_storage->is_available()) + { + m_storage->remove_shares_batch(m_removal_flush_buf); + m_removal_flush_buf.clear(); + } + LOG_INFO << "[Pool] NodeImpl shutdown complete"; +} + +void NodeImpl::start_outbound_connections() +{ + if (m_target_outbound_peers == 0) + { + LOG_INFO << "Outbound peer dialing disabled (target=0)"; + return; + } + + // Try to connect to peers right away + auto try_connect_peers = [this]() { + size_t outbound = m_outbound_addrs.size(); + if (outbound >= m_target_outbound_peers || m_connections.size() >= m_max_peers) + return; + + size_t needed = m_target_outbound_peers - outbound; + auto good_peers = get_good_peers(needed + 4); // ask for a few extra in case some are already connected + + for (auto& ap : good_peers) + { + if (needed == 0) + break; + // Skip if already connected, already dialing, or banned + if (m_connections.contains(ap.addr) || m_pending_outbound.contains(ap.addr) || is_banned(ap.addr)) + continue; + + LOG_INFO << "[Pool] Dialing outbound peer " << ap.addr.to_string(); + m_pending_outbound.insert(ap.addr); + core::Client::connect(ap.addr); + --needed; + } + }; + + // Initial burst + try_connect_peers(); + + // Periodic maintenance — every 30 seconds, check if we need more outbound peers + m_connect_timer = std::make_unique(m_context, true); + m_connect_timer->start(30, try_connect_peers); +} + +void NodeImpl::prune_shares(const uint256& /*best_share*/) +{ + // Matches p2pool node.py:381-398 tail-dropping exactly: + // - Check each tail: if min(height(head) for heads) < 2*CL+10 → skip + // - Remove ONE child of qualifying tail per iteration + // - Loop up to 1000 times (gradual, not bulk) + // - Also cascade removal to verified + const auto CL = static_cast(PoolConfig::chain_length()); + const int32_t min_depth = 2 * CL + 10; + + for (int iter = 0; iter < 1000; ++iter) + { + // p2pool node.py:382-398: find ONE qualifying tail child to remove + uint256 to_remove; + bool found = false; + auto tails_copy = m_tracker.chain.get_tails(); + for (auto& [tail_hash, head_hashes] : tails_copy) + { + // Check min height across ALL heads for this tail + int32_t min_height = std::numeric_limits::max(); + for (auto& hh : head_hashes) { + if (!m_tracker.chain.contains(hh)) continue; + try { + min_height = std::min(min_height, m_tracker.chain.get_height(hh)); + } catch (...) { continue; } + } + if (min_height < min_depth) continue; + + // Find ONE child of this tail (p2pool removes one at a time) + auto& rev = m_tracker.chain.get_reverse(); + auto rev_it = rev.find(tail_hash); + if (rev_it != rev.end() && !rev_it->second.empty()) { + to_remove = *rev_it->second.begin(); + found = true; + break; // one per iteration + } + } + + if (!found) break; + + if (!m_tracker.chain.contains(to_remove)) continue; + // p2pool node.py:393 — safety check: parent must be a tail + auto* idx = m_tracker.chain.get_index(to_remove); + if (!idx) continue; + bool parent_is_tail = m_tracker.chain.get_tails().contains(idx->tail); + if (!parent_is_tail) { + LOG_DEBUG_POOL << "prune skip: parent " << idx->tail.ToString().substr(0,16) + << " not a tail"; + continue; + } + if (m_tracker.verified.contains(to_remove)) + m_tracker.verified.remove(to_remove, /*owns_data=*/false); + m_tracker.chain.remove(to_remove); + } + + // Cache cleanup (kept from original) + if (m_shared_share_hashes.size() > m_max_shared_hashes) + m_shared_share_hashes.clear(); + if (m_known_txs.size() > m_max_known_txs) + m_known_txs.clear(); + if (m_raw_share_cache.size() > m_max_raw_shares) + m_raw_share_cache.clear(); +} + +// (old phases 5-7 removed — replaced by p2pool-style pruning above) + +// ── think() watchdog (V36 livelock defense-in-depth) ──────────────────── +// Best-effort recovery if a think() cycle wedges. Runs ENTIRELY on the IO +// thread and NEVER acquires m_tracker_mutex — so it stays responsive even +// while the compute thread holds the exclusive lock. Uses an atomic deadline +// + generation counter rather than a per-dispatch timer object lifetime so a +// late fire cannot act on a newer cycle. +void NodeImpl::arm_think_watchdog() +{ + if (!m_context) + return; + auto deadline = std::chrono::steady_clock::now() + + std::chrono::seconds(THINK_WATCHDOG_SECONDS); + m_think_deadline_ns.store( + std::chrono::duration_cast( + deadline.time_since_epoch()).count(), + std::memory_order_relaxed); + uint64_t gen = m_think_generation.fetch_add(1, std::memory_order_relaxed) + 1; + + if (!m_watchdog_timer) + m_watchdog_timer = std::make_unique(*m_context); + m_watchdog_timer->expires_after(std::chrono::seconds(THINK_WATCHDOG_SECONDS)); + m_watchdog_timer->async_wait([this, gen](const boost::system::error_code& ec) { + if (ec) return; // cancelled by disarm (normal completion) + // Only act if this is still the cycle we armed for and it has not + // already completed (deadline cleared to 0 by disarm). + if (m_think_generation.load(std::memory_order_relaxed) != gen) + return; + int64_t dl = m_think_deadline_ns.load(std::memory_order_relaxed); + if (dl == 0) + return; // cycle completed in the gap before this fire + auto now_ns = std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count(); + if (now_ns < dl) + return; // not actually overdue (spurious) — let next arm handle it + + // (1) Log + best-effort backtrace dump of the current (IO) thread. + // We CANNOT safely unwind the compute thread's stack from here, but + // a backtrace of the watchdog firing plus the timing is enough to + // confirm the wedge and correlate with the last [ASYNC-THINK] logs. + LOG_ERROR << "[THINK-WATCHDOG] think() cycle exceeded " + << THINK_WATCHDOG_SECONDS << "s (gen=" << gen + << ", pending_adds=" << m_pending_adds.size() + << ") — compute thread appears wedged; recovering pipeline"; +#ifndef _WIN32 + { + void* frames[64]; + int n = ::backtrace(frames, 64); + char** syms = ::backtrace_symbols(frames, n); + if (syms) { + for (int i = 0; i < n; ++i) + LOG_ERROR << "[THINK-WATCHDOG] bt[" << i << "] " << syms[i]; + ::free(syms); + } + } +#else + // Native backtrace is glibc-only (execinfo.h); on MSVC the timeout log + // above plus pending_adds is the diagnostic. Recovery below is identical. +#endif + + // (2)+(3) Flag the cycle aborted and reset the running flag so the + // pipeline recovers. NOTE: this does NOT forcibly unwind the compute + // thread (unsafe); it lets a fresh think() be scheduled. The stuck + // think(), if it ever returns, will find m_think_running already false + // and its IO-phase still runs harmlessly (idempotent drain + reset). + m_think_deadline_ns.store(0, std::memory_order_relaxed); + m_think_running.store(false, std::memory_order_relaxed); + LOG_WARNING << "[THINK-WATCHDOG] m_think_running reset to false; " + "a new think() cycle may now be scheduled"; + }); +} + +void NodeImpl::disarm_think_watchdog() +{ + // Mark cycle complete: clears the deadline so a racing watchdog fire is a + // no-op, and cancels the pending timer. + m_think_deadline_ns.store(0, std::memory_order_relaxed); + if (m_watchdog_timer) { + m_watchdog_timer->cancel(); + } +} + +void NodeImpl::run_think() +{ + // Skip if a think() is already running on the compute thread. + // The atomic flag serializes: only one think() in flight at a time. + if (m_think_running.exchange(true)) { + static int skip_log = 0; + if (skip_log++ % 20 == 0) + LOG_INFO << "[ASYNC-THINK] skipped — compute thread busy (skip_count=" << skip_log << ")"; + return; + } + LOG_INFO << "[ASYNC-THINK] dispatching to compute thread" + << " pending_adds=" << m_pending_adds.size() + << " peers=" << m_peers.size(); + + // Arm the think() watchdog (IO-thread timer; never touches the tracker + // mutex). Disarmed in the IO-phase below on normal completion. + arm_think_watchdog(); + + // Capture block_rel_height fn by value for thread safety + auto block_rel_height = m_block_rel_height_fn + ? m_block_rel_height_fn + : std::function([](uint256) -> int32_t { return 0; }); + + // ── Post think() to the dedicated compute thread ────────────────────── + // The compute thread acquires m_tracker_mutex exclusively, guaranteeing + // no concurrent IO-thread modifications to the tracker. The IO thread + // uses try_to_lock for all tracker access — if the mutex is held, the + // operation is deferred (shares queued, clean_tracker skipped). Network + // I/O, keepalive timers, and stratum continue unimpeded. + // + // Previous attempt at threaded think() caused SIGSEGV because there was + // NO synchronization — prune_shares freed shares while think traversed + // them. The shared_mutex + try_to_lock pattern eliminates this race: + // no mutations occur while the compute thread holds the exclusive lock. + boost::asio::post(m_think_pool, [this, block_rel_height]() { + m_compute_thread_id.store(std::this_thread::get_id(), std::memory_order_relaxed); + + // ── Compute thread: exclusive tracker access ────────────────────── + // Collect results under the lock, then release before posting to IO. + TrackerThinkResult result; + bool best_changed = false; + bool needs_continue = false; + int64_t think_ms = 0; + + try { + auto lock_t0 = std::chrono::steady_clock::now(); + std::unique_lock lock(m_tracker_mutex); // exclusive — IO defers + auto lock_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - lock_t0).count(); + if (lock_ms > 10) + LOG_INFO << "[ASYNC-THINK] mutex acquired after " << lock_ms << "ms wait"; + + uint256 prev_block; + uint32_t bits = 0; + + // Bootstrap mode: no verified chain yet → verify everything in one + // pass with unlimited budget. During bootstrap, stratum isn't serving + // real work so no IO callbacks need the tracker lock. Matches p2pool + // where think() runs synchronously on the reactor during initial sync. + bool bootstrap = m_tracker.verified.size() == 0; + LOG_INFO << "[ASYNC-THINK] compute: chain=" << m_tracker.chain.size() + << " verified=" << m_tracker.verified.size() + << " heads=" << m_tracker.chain.get_heads().size() + << " v_heads=" << m_tracker.verified.get_heads().size() + << (bootstrap ? " BOOTSTRAP" : ""); + auto think_t0 = std::chrono::steady_clock::now(); + result = m_tracker.think(block_rel_height, prev_block, bits, bootstrap); + think_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - think_t0).count(); + + // Apply tracker-mutating results while still holding the lock: + // - Ban list and download set are NodeImpl members (not tracker), + // but safe here since IO thread doesn't touch them during think. + // - best_share update and top5_heads are consumed by IO-thread + // callbacks, so just record the values for the IO-thread phase. + m_last_top5_heads = std::move(result.top5_heads); + + if (!result.best.IsNull()) { + best_changed = (m_best_share_hash != result.best); + m_best_share_hash = result.best; + } + + // Publish lock-free snapshot for all IO-thread consumers + publish_snapshot(); + + // Continue if EITHER the Phase 2 verification budget OR the V36 + // scoring-walk lock-yield budget was exhausted. Both reuse the same + // run_think() re-post: lock released here, drain_pending_adds() runs, + // continuation scheduled below. + needs_continue = m_tracker.m_think_needs_continue + || m_tracker.m_think_walk_needs_continue; + + // Flush verified hashes to LevelDB while lock is held + flush_verified_to_leveldb(); + + // Work refresh runs on the IO thread AFTER the lock is released. + // It takes 1-5s (PPLNS + MM coinbase) — must NOT hold exclusive lock + // or it blocks all shared_lock readers (handle_get_share, send_shares) + // and kills peer connections. + + } catch (const std::exception& e) { + LOG_ERROR << "run_think() failed on compute thread: " << e.what(); + } catch (...) { + LOG_ERROR << "run_think() failed on compute thread: unknown error"; + } + // ── Lock released here ──────────────────────────────────────────── + + LOG_INFO << "[ASYNC-THINK] compute done in " << think_ms << "ms" + << " best_changed=" << best_changed + << " needs_continue=" << needs_continue + << " bads=" << result.bad_peer_addresses.size() + << " desired=" << result.desired.size(); + + // ── Post IO-thread phase: work refresh + drain pending ──────────── + // These operations need the IO thread (send to peers, stratum notify). + // The mutex is NOT held — IO thread can acquire it for drain_pending. + boost::asio::post(*m_context, [this, result = std::move(result), + best_changed, needs_continue]() { + try { + // Ban peers that provided invalid/unverifiable shares + auto now = std::chrono::steady_clock::now(); + for (const auto& bad_addr : result.bad_peer_addresses) { + if (bad_addr.port() == 0) + continue; + LOG_WARNING << "run_think: banning peer " << bad_addr.to_string() + << " for unverifiable shares"; + m_ban_list[bad_addr] = now + m_ban_duration; + } + + // Expire old bans + for (auto it = m_ban_list.begin(); it != m_ban_list.end(); ) { + if (it->second <= now) it = m_ban_list.erase(it); + else ++it; + } + + // Clear stale download set (p2pool node.py:108-141) + m_downloading_shares.clear(); + + // Reset fail counts each think() cycle — matches p2pool which + // re-adds desired hashes from scratch every cycle with sleep(1) + // backoff. Permanent blacklisting caused bootstrap stalls: + // the tail parent hash would get 3 empty replies and never be + // requested again, leaving the chain stuck at 1) + std::advance(peer_it, core::random::random_uint256().GetLow64() % m_peers.size()); + download_shares(peer_it->second, hash); + } + } + + // Work refresh on IO thread — NO lock held. Takes 1-5s but doesn't + // block shared_lock readers (handle_get_share, send_shares). + // The tracker is not being modified during this window (think finished, + // pending adds not yet drained), so reading it is safe. + if (best_changed) { + LOG_INFO << "[ASYNC-THINK] IO-phase: best=" << m_best_share_hash.GetHex() + << " work refresh starting"; + auto wr_t0 = std::chrono::steady_clock::now(); + if (m_on_best_share_changed) m_on_best_share_changed(); + auto wr_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - wr_t0).count(); + LOG_INFO << "[ASYNC-THINK] IO-phase: work refresh done in " << wr_ms << "ms"; + // Root-2: re-advertise our new head. A peer that handshook + // before we had a chain saw a ZERO advert and never issued + // download_shares -- re-announce so it pulls our shares now. + readvertise_best(); + } else if (result.best.IsNull()) { + LOG_WARNING << "[ASYNC-THINK] IO-phase: result.best is NULL — verified_tails=" + << m_tracker.verified.get_tails().size() + << " verified_heads=" << m_tracker.verified.get_heads().size(); + } + + // ROOT-2: fire ONE think-independent delayed re-advert when the + // verified chain first becomes non-empty. Covers peers that + // handshook during the empty window and so never see a best-change + // event. The one-shot timer runs on the IO thread, so it still + // fires even if a later think() cycle wedges (composes with the + // #97 think-watchdog). Pure reads; broadcast stays try_to_lock. + if (m_verified_was_empty && m_tracker.verified.size() > 0) { + m_verified_was_empty = false; + if (!m_readvert_timer) + m_readvert_timer = std::make_unique(m_context, false); + m_readvert_timer->start(10, [this]() { readvertise_best(); }); + LOG_INFO << "[readvertise] verified chain populated — scheduled " + "10s re-advert (ROOT-2)"; + } + + // Drain share batches queued while think() held the mutex + LOG_INFO << "[ASYNC-THINK] IO-phase: draining " << m_pending_adds.size() + << " pending batches, peers=" << m_peers.size(); + drain_pending_adds(); + + // Cycle completed normally — disarm the watchdog first so it + // cannot fire on this (now-finished) generation. + disarm_think_watchdog(); + + // Clear flag AFTER drain — prevents new think() from starting + // while deferred shares are still being added to the tracker. + m_think_running.store(false); + LOG_INFO << "[ASYNC-THINK] cycle complete — IO thread free"; + + // If verification budget was exhausted, schedule continuation. + // The IO thread processes network I/O between verification batches. + if (needs_continue) { + boost::asio::post(*m_context, [this]() { run_think(); }); + } + } catch (const std::exception& e) { + LOG_ERROR << "run_think() IO phase failed: " << e.what(); + disarm_think_watchdog(); + m_think_running.store(false); + } catch (...) { + LOG_ERROR << "run_think() IO phase failed: unknown error"; + disarm_think_watchdog(); + m_think_running.store(false); + } + }); + }); +} + +// ── Drain pending share batches ───────────────────────────────────────── +// Called on the IO thread after think() releases m_tracker_mutex. +// Processes share batches that arrived while think was running. +void NodeImpl::drain_pending_adds() +{ + if (m_pending_adds.empty()) + return; + + // Take ownership of pending queue + auto pending = std::move(m_pending_adds); + m_pending_adds.clear(); + + LOG_INFO << "[drain] Processing " << pending.size() + << " deferred share batches"; + + for (auto& batch : pending) { + processing_shares_phase2(*batch.data, batch.addr); + } +} + +// ── Heartbeat logging ─────────────────────────────────────────────────── +// p2pool-style status lines: chain height, local/pool hashrate, orphan/DOA. +// Runs on a separate 30s timer — diagnostic only, not consensus-critical. +// Previously ran inside run_think() blocking the IO thread on every share. +void NodeImpl::heartbeat_log() +{ + // Try shared lock — if think is running, skip this heartbeat + std::shared_lock lock(m_tracker_mutex, std::try_to_lock); + if (!lock.owns_lock()) + return; + + auto chain_sz = m_tracker.chain.size(); + auto verified = m_tracker.verified.size(); + auto peers = m_peers.size(); + + int incoming_peers = 0; + for (auto& [nonce, peer] : m_peers) { + if (peer && !m_outbound_addrs.contains(peer->addr())) + ++incoming_peers; + } + + int height = 0; + if (!m_best_share_hash.IsNull()) { + if (m_tracker.chain.contains(m_best_share_hash)) + height = m_tracker.chain.get_height(m_best_share_hash); + } + if (height == 0 && verified > 0) + height = static_cast(verified); + + // L1: chain status + LOG_INFO << "c2pool: " << height << " shares in chain (" + << verified << " verified/" << chain_sz << " total)" + << " Peers: " << peers << " (" << incoming_peers << " incoming)"; + + // L2: local hashrate + uint32_t share_bits = 0; + if (!m_best_share_hash.IsNull() && m_tracker.chain.contains(m_best_share_hash)) { + m_tracker.chain.get(m_best_share_hash).share.invoke([&](auto* s) { + share_bits = s->m_bits; + }); + } + if (m_local_rate_stats_fn) { + auto stats = m_local_rate_stats_fn(); + std::ostringstream local_line; + local_line << " Local: " << format_hashrate(stats.hashrate); + if (stats.effective_dt > 0) + local_line << " in last " << format_duration(stats.effective_dt); + local_line << " Local dead on arrival: " + << format_binomial_conf(stats.dead_datums, stats.total_datums); + if (stats.hashrate > 0 && share_bits != 0) { + auto share_target = chain::bits_to_target(share_bits); + auto share_aps = chain::target_to_average_attempts(share_target); + double ets = static_cast(share_aps.GetLow64()) / stats.hashrate; + local_line << " Expected time to share: " << format_duration(ets); + } + LOG_INFO << local_line.str(); + } else if (m_local_hashrate_fn) { + LOG_INFO << " Local: " << format_hashrate(m_local_hashrate_fn()); + } + + // L3: orphan/DOA/stale stats (chain walk — diagnostic only) + int orphan_count = 0, doa_count = 0, total_recent = 0; + uint256 walk_start = m_best_share_hash; + if (walk_start.IsNull() || !m_tracker.chain.contains(walk_start)) { + auto& vheads = m_tracker.verified.get_heads(); + if (!vheads.empty()) + walk_start = vheads.begin()->first; + } + if (!walk_start.IsNull() && m_tracker.chain.contains(walk_start)) { + int window = std::min(height, static_cast( + std::min(size_t(3600) / PoolConfig::share_period(), size_t(height)))); + if (window > 0) { + auto walkable = m_tracker.chain.get_height(walk_start); + auto walk_n = std::min(window, walkable); + if (walk_n > 0) { + try { + auto view = m_tracker.chain.get_chain(walk_start, walk_n); + for (auto [hash, data] : view) { + data.share.invoke([&](auto* s) { + if (s->m_stale_info == bch::StaleInfo::orphan) ++orphan_count; + else if (s->m_stale_info == bch::StaleInfo::doa) ++doa_count; + }); + ++total_recent; + } + } catch (...) {} + } + } + } + double stale_prop = total_recent > 0 + ? static_cast(orphan_count + doa_count) / total_recent : 0.0; + + { + std::ostringstream shares_line; + shares_line << " Shares: " << chain_sz + << " (" << orphan_count << " orphan, " << doa_count << " dead)" + << " Stale rate: " << format_binomial_conf(orphan_count + doa_count, total_recent) + << " Efficiency: " << format_binomial_conf_efficiency(orphan_count + doa_count, total_recent, stale_prop); + + if (m_current_pplns_fn) { + auto outputs = m_current_pplns_fn(); + uint64_t my_payout = 0; + std::set local_scripts; + if (!m_node_payout_script_hex.empty()) + local_scripts.insert(m_node_payout_script_hex); + if (m_local_miner_scripts_fn) { + for (const auto& s : m_local_miner_scripts_fn()) + local_scripts.insert(s); + } + for (const auto& [script, amount] : outputs) { + if (local_scripts.count(script)) + my_payout += amount; + } + if (my_payout > 0) { + double coins = static_cast(my_payout) / 1e8; + shares_line << " Current payout: (" << std::fixed << std::setprecision(4) + << coins << ")=" << coins << " BCH"; + } + } + + shares_line << " [heads=" << m_tracker.chain.get_heads().size() + << " v_heads=" << m_tracker.verified.get_heads().size() + << " rss=" << get_rss_mb() << "MB]"; + LOG_INFO << shares_line.str(); + } + + // L4: pool hashrate + ETB + if (height > 2) { + try { + auto aps = m_tracker.get_pool_attempts_per_second( + m_best_share_hash, + std::min(height - 1, static_cast(PoolConfig::TARGET_LOOKBEHIND)), + /*min_work=*/false); + double pool_hs = static_cast(aps.GetLow64()); + double real_pool_hs = (stale_prop < 0.999 && pool_hs > 0) + ? pool_hs / (1.0 - stale_prop) : pool_hs; + double etb_secs = 0; + uint32_t block_bits = 0; + if (!m_best_share_hash.IsNull() && m_tracker.chain.contains(m_best_share_hash)) { + m_tracker.chain.get(m_best_share_hash).share.invoke([&](auto* s) { + block_bits = s->m_min_header.m_bits; + }); + } + if (real_pool_hs > 0 && block_bits != 0) { + auto block_target = chain::bits_to_target(block_bits); + auto block_aps = chain::target_to_average_attempts(block_target); + etb_secs = static_cast(block_aps.GetLow64()) / real_pool_hs; + if (block_aps.IsNull() && !block_target.IsNull()) + etb_secs = 1e18; + } + LOG_INFO << " Pool: " << format_hashrate(real_pool_hs) + << " Stale rate: " << std::fixed << std::setprecision(1) + << (100.0 * stale_prop) << "% Expected time to block: " + << format_duration(etb_secs); + } catch (...) {} + } +} + +// Periodic maintenance: eat stale heads, drop tails. +// Direct translation of p2pool node.py:355-402 clean_tracker(). +// +// Runs on the compute thread under exclusive lock — matching p2pool where +// clean_tracker + think() execute on the same reactor thread. Chain +// modifications (remove shares) MUST NOT happen concurrently with think(). +void NodeImpl::clean_tracker() +{ + // Prevent concurrent clean_tracker (timer re-entry safety). + if (m_clean_running.exchange(true)) + return; + + // Skip if think() is already in flight — the 5s timer will retry. + if (m_think_running.load()) { + m_clean_running.store(false); + return; + } + + // Post the entire clean_tracker body to the compute thread. + // This guarantees chain modifications happen under exclusive lock, + // never concurrent with think() or IO-thread reads. + m_think_running.store(true); // block think() re-entry during clean + + boost::asio::post(m_think_pool, [this]() { + m_compute_thread_id.store(std::this_thread::get_id(), std::memory_order_relaxed); + + bool clean_best_changed = false; + bool bootstrap = false; + try { + std::unique_lock lock(m_tracker_mutex); // exclusive + + // Step 1: Run think() inline (already holds the lock) + { + auto block_rel_height = m_block_rel_height_fn + ? m_block_rel_height_fn + : std::function([](uint256) -> int32_t { return 0; }); + + uint256 prev_block; + uint32_t bits = 0; + + bootstrap = m_tracker.verified.size() == 0; + LOG_INFO << "[CLEAN] think+clean starting on compute thread: chain=" + << m_tracker.chain.size() << " verified=" << m_tracker.verified.size() + << (bootstrap ? " BOOTSTRAP" : ""); + auto think_t0 = std::chrono::steady_clock::now(); + auto result = m_tracker.think(block_rel_height, prev_block, bits, bootstrap); + auto think_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - think_t0).count(); + + m_last_top5_heads = std::move(result.top5_heads); + + if (!result.best.IsNull()) { + m_best_share_hash = result.best; + } + + flush_verified_to_leveldb(); + // Work refresh deferred to IO thread (after lock release) + } + + // Steps 2-3: Prune (still holding exclusive lock) + auto now_sec = static_cast(std::time(nullptr)); + auto CL = static_cast(bch::PoolConfig::chain_length()); + + // Step 2: Eat stale heads (p2pool node.py:358-378) + // Three guards protect useful heads: + // 1. Top-5 scored heads (decorated_heads[-5:]) + // 2. Heads seen < 300s ago + // 3. Unverified heads whose tail has recent child activity (< 120s) + if (!m_last_top5_heads.empty()) // p2pool node.py:359: if decorated_heads: + { + // Build top-5 set for O(1) lookup + std::set top5_set(m_last_top5_heads.begin(), m_last_top5_heads.end()); + + for (int iter = 0; iter < 1000; ++iter) + { + std::vector to_remove; + auto heads_copy = m_tracker.chain.get_heads(); + for (auto& [head_hash, tail_hash] : heads_copy) + { + if (!m_tracker.chain.contains(head_hash)) continue; + + // Guard 1: keep top-5 scored heads (p2pool node.py:363) + if (top5_set.count(head_hash)) continue; + + // Guard 2: keep heads seen < 300s ago (p2pool node.py:366) + auto* idx = m_tracker.chain.get_index(head_hash); + if (!idx || idx->time_seen > now_sec - 300) continue; + + // Guard 3: keep unverified heads with recent tail activity (p2pool node.py:369) + if (!m_tracker.verified.contains(head_hash)) + { + auto& rev = m_tracker.chain.get_reverse(); + auto rev_it = rev.find(tail_hash); + if (rev_it != rev.end() && !rev_it->second.empty()) + { + int64_t max_child_ts = 0; + for (const auto& child : rev_it->second) + { + auto* cidx = m_tracker.chain.get_index(child); + if (cidx && cidx->time_seen > max_child_ts) + max_child_ts = cidx->time_seen; + } + if (max_child_ts > now_sec - 120) continue; + } + } + + to_remove.push_back(head_hash); + } + + if (to_remove.empty()) break; + + size_t _dh_idx = 0; + for (const auto& h : to_remove) + { + try { + // DIAG: per-remove marker so a freeze inside chain.contains() + // or verified.remove() leaves the last touched hash in the log. + std::fprintf(stderr, + "[drop-heads-PRE] drop_idx=%zu/%zu hash=%.16s " + "chain_size=%zu\n", + _dh_idx, to_remove.size(), h.GetHex().c_str(), + m_tracker.chain.size()); + std::fflush(stderr); + ++_dh_idx; + + if (m_tracker.verified.contains(h)) + m_tracker.verified.remove(h, /*owns_data=*/false); + if (m_tracker.chain.contains(h)) + m_tracker.chain.remove(h); + } catch (...) {} + } + } + } + + // Step 3: Drop tails — remove ALL children of qualifying tails. + // Exact translation of p2pool node.py:382-398. + // p2pool has NO best-chain protection — the 2*CHAIN_LENGTH+10 threshold + // ensures only shares far beyond the PPLNS window are removed. + { + int total_dropped = 0; + for (int iter = 0; iter < 1000; ++iter) + { + std::vector to_remove; + auto tails_copy = m_tracker.chain.get_tails(); + for (auto& [tail_hash, head_hashes] : tails_copy) + { + int32_t min_height = 0; // default 0 → skip if no valid heads + for (auto& hh : head_hashes) { + if (!m_tracker.chain.contains(hh)) continue; + auto h = m_tracker.chain.get_height(hh); + if (min_height == 0 || h < min_height) + min_height = h; + } + if (min_height < 2 * CL + 10) continue; + + if (iter == 0) { + LOG_WARNING << "[drop-tails-QUALIFY] tail=" << tail_hash.GetHex().substr(0,16) + << " min_height=" << min_height << " threshold=" << (2*CL+10) + << " n_heads=" << head_hashes.size() + << " chain_size=" << m_tracker.chain.size(); + } + + // p2pool node.py:386: to_remove.update(tracker.reverse.get(tail, set())) + auto& rev = m_tracker.chain.get_reverse(); + auto rev_it = rev.find(tail_hash); + if (rev_it != rev.end()) { + for (const auto& child : rev_it->second) + to_remove.push_back(child); + } + } + + if (to_remove.empty()) break; + + // p2pool node.py:392-398 + size_t _drop_idx = 0; + for (const auto& aftertail : to_remove) + { + try { + // DIAG: contabo 2026-04-21 froze 40s inside + // unordered_map::contains() on an aftertail at bucket 2332. + // If we spin here again this is the last line flushed — + // gives us the iter index, which aftertail triggered it, + // and the queue size to correlate with the freeze. + std::fprintf(stderr, + "[drop-tails-PRE] iter=%d drop_idx=%zu/%zu aftertail=%.16s " + "chain_size=%zu\n", + iter, _drop_idx, to_remove.size(), + aftertail.GetHex().c_str(), + m_tracker.chain.size()); + std::fflush(stderr); + ++_drop_idx; + + if (!m_tracker.chain.contains(aftertail)) continue; + // p2pool node.py:393: if items[aftertail].previous_hash not in tails: continue + auto* idx = m_tracker.chain.get_index(aftertail); + if (!idx) continue; + if (!m_tracker.chain.get_tails().count(idx->tail)) { + continue; + } + if (m_tracker.verified.contains(aftertail)) + m_tracker.verified.remove(aftertail, /*owns_data=*/false); + m_tracker.chain.remove(aftertail); + ++total_dropped; + } catch (...) {} + } + } + if (total_dropped > 0) + LOG_INFO << "[clean-drop-tails] dropped " << total_dropped << " shares" + << " chain_size=" << m_tracker.chain.size() + << " heads=" << m_tracker.chain.get_heads().size(); + } + + // Step 4: re-score after pruning (inline, already holding lock) + { + auto block_rel_height = m_block_rel_height_fn + ? m_block_rel_height_fn + : std::function([](uint256) -> int32_t { return 0; }); + uint256 prev_block; + uint32_t bits = 0; + auto result = m_tracker.think(block_rel_height, prev_block, bits, bootstrap); + m_last_top5_heads = std::move(result.top5_heads); + if (!result.best.IsNull()) { + clean_best_changed = (m_best_share_hash != result.best); + m_best_share_hash = result.best; + } + publish_snapshot(); + flush_verified_to_leveldb(); + // Work refresh deferred to IO thread (after lock release) + } + + // Step 5: Flush pruned shares from LevelDB (p2pool main.py:269-270) + if (!m_removal_flush_buf.empty() && m_storage && m_storage->is_available()) + { + auto count = m_removal_flush_buf.size(); + if (m_storage->remove_shares_batch(m_removal_flush_buf)) + LOG_INFO << "[clean-leveldb] removed " << count << " pruned shares from LevelDB"; + else + LOG_WARNING << "[clean-leveldb] batch remove failed, count=" << count; + m_removal_flush_buf.clear(); + } + + // Orphan/fork diagnostics — understand chain topology + { + auto& chain = m_tracker.chain; + auto& verified = m_tracker.verified; + size_t total_heads = chain.get_heads().size(); + size_t verified_heads = verified.get_heads().size(); + size_t total_tails = chain.get_tails().size(); + + // Count c2pool's own shares in verified vs total + int local_in_chain = 0, local_in_verified = 0; + int total_in_chain = 0; + for (auto& [head_hash, tail_hash] : chain.get_heads()) { + auto height = chain.get_height(head_hash); + total_in_chain += height; + // Check if this head is in verified + if (verified.contains(head_hash)) { + // Walk backward and count local shares + // (expensive — only sample first 50) + } + } + + static int diag_count = 0; + if (diag_count++ % 6 == 0) { // every 30s (5s * 6) + LOG_INFO << "[FORK-DIAG] heads=" << total_heads + << " verified_heads=" << verified_heads + << " tails=" << total_tails + << " chain=" << chain.size() + << " verified=" << verified.size() + << " gap=" << (chain.size() - verified.size()) + << " best_h=" << (m_best_share_hash.IsNull() ? 0 + : (verified.contains(m_best_share_hash) + ? verified.get_height(m_best_share_hash) : -1)) + << " chain_h=" << (m_best_share_hash.IsNull() ? 0 + : (chain.contains(m_best_share_hash) + ? chain.get_height(m_best_share_hash) : -1)) + << " best=" << (m_best_share_hash.IsNull() + ? std::string("null") + : m_best_share_hash.GetHex().substr(0, 16)); + } + } + + } catch (const std::exception& e) { + LOG_ERROR << "[CLEAN] failed on compute thread: " << e.what(); + } catch (...) { + LOG_ERROR << "[CLEAN] failed on compute thread: unknown error"; + } + // Lock released — post work refresh + drain to IO thread. + // Work refresh (1-5s) runs WITHOUT any lock so shared_lock readers + // (handle_get_share, send_shares) are never blocked. + boost::asio::post(*m_context, [this, clean_best_changed]() { + if (clean_best_changed && m_on_best_share_changed) { + LOG_INFO << "[CLEAN] IO-phase: work refresh (best changed)"; + m_on_best_share_changed(); + readvertise_best(); // root-2: re-announce new head to peers + } + drain_pending_adds(); + m_think_running.store(false); + m_clean_running.store(false); + }); + }); // end of m_think_pool lambda +} + +bool NodeImpl::is_whitelisted(const NetService& addr) const +{ + const std::string ip = addr.address(); + if (m_whitelist_ips.contains(ip)) return true; + if (m_whitelist_hosts.contains(addr)) return true; + return false; +} + +bool NodeImpl::is_banned(const NetService& addr) const +{ + // Whitelist bypass: permanent dial targets are immune to bans. + if (is_whitelisted(addr)) return false; + + auto now = std::chrono::steady_clock::now(); + auto it = m_ban_list.find(addr); + if (it != m_ban_list.end() && it->second > now) return true; + + auto ip_it = m_ip_ban_list.find(addr.address()); + if (ip_it != m_ip_ban_list.end() && ip_it->second > now) return true; + + return false; +} + +// ── Admin API implementation ────────────────────────────────────────── + +void NodeImpl::set_whitelist_path(const std::string& path) +{ + m_whitelist_path = path; + if (!path.empty()) load_whitelist_from_disk(); +} + +void NodeImpl::load_whitelist_from_disk() +{ + if (m_whitelist_path.empty()) return; + std::ifstream f(m_whitelist_path); + if (!f) return; + try { + nlohmann::json j; + f >> j; + if (!j.contains("entries") || !j["entries"].is_array()) return; + for (const auto& e : j["entries"]) { + if (!e.contains("host") || !e.contains("port")) continue; + std::string host = e["host"].get(); + uint16_t port = e["port"].get(); + m_whitelist_ips.insert(host); + m_whitelist_hosts.insert(NetService(host, port)); + } + LOG_INFO << "[Pool] Loaded " << m_whitelist_hosts.size() + << " whitelist entries from " << m_whitelist_path; + } catch (const std::exception& e) { + LOG_WARNING << "[Pool] Failed to parse whitelist " << m_whitelist_path + << ": " << e.what(); + } +} + +void NodeImpl::save_whitelist_to_disk() const +{ + if (m_whitelist_path.empty()) return; + try { + nlohmann::json j; + j["version"] = 1; + auto arr = nlohmann::json::array(); + auto now_unix = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + for (const auto& host : m_whitelist_hosts) { + arr.push_back({ + {"host", host.address()}, + {"port", host.port()}, + {"added_unix", now_unix} + }); + } + j["entries"] = arr; + std::string tmp = m_whitelist_path + ".new"; + { + std::ofstream f(tmp); + f << j.dump(2); + } + std::filesystem::rename(tmp, m_whitelist_path); + } catch (const std::exception& e) { + LOG_WARNING << "[Pool] Failed to persist whitelist: " << e.what(); + } +} + +static nlohmann::json build_bans_json( + const std::map& peer_bans, + const std::map& ip_bans) +{ + auto now = std::chrono::steady_clock::now(); + auto arr = nlohmann::json::array(); + for (const auto& [addr, expiry] : peer_bans) { + if (expiry <= now) continue; + auto secs = std::chrono::duration_cast(expiry - now).count(); + arr.push_back({{"host", addr.address()}, {"port", addr.port()}, + {"expires_in_sec", secs}, {"source", "auto"}}); + } + for (const auto& [ip, expiry] : ip_bans) { + if (expiry <= now) continue; + auto secs = std::chrono::duration_cast(expiry - now).count(); + arr.push_back({{"host", ip}, {"port", 0}, + {"expires_in_sec", secs}, {"source", "admin"}}); + } + return arr; +} + +static nlohmann::json build_whitelist_json(const std::set& hosts) +{ + auto arr = nlohmann::json::array(); + for (const auto& h : hosts) + arr.push_back({{"host", h.address()}, {"port", h.port()}}); + return arr; +} + +nlohmann::json NodeImpl::admin_list_bans() const +{ + return {{"ok", true}, {"bans", build_bans_json(m_ban_list, m_ip_ban_list)}}; +} + +nlohmann::json NodeImpl::admin_ban_ip(const std::string& ip, int duration_sec) +{ + if (ip.empty()) + return {{"ok", false}, {"error", "ip required"}}; + int dur = duration_sec > 0 ? duration_sec : static_cast(m_ban_duration.count()); + auto expiry = std::chrono::steady_clock::now() + std::chrono::seconds(dur); + m_ip_ban_list[ip] = expiry; + LOG_INFO << "[Pool] Admin ban: " << ip << " for " << dur << "s"; + return {{"ok", true}, {"action", "ban"}, {"target", ip}, + {"duration_sec", dur}, + {"bans", build_bans_json(m_ban_list, m_ip_ban_list)}}; +} + +nlohmann::json NodeImpl::admin_unban_ip(const std::string& ip) +{ + if (ip.empty()) + return {{"ok", false}, {"error", "ip required"}}; + size_t removed = m_ip_ban_list.erase(ip); + for (auto it = m_ban_list.begin(); it != m_ban_list.end(); ) { + if (it->first.address() == ip) { it = m_ban_list.erase(it); ++removed; } + else ++it; + } + LOG_INFO << "[Pool] Admin unban: " << ip << " (" << removed << " entries removed)"; + return {{"ok", true}, {"action", "unban"}, {"target", ip}, + {"removed", removed}, + {"bans", build_bans_json(m_ban_list, m_ip_ban_list)}}; +} + +nlohmann::json NodeImpl::admin_list_whitelist() const +{ + return {{"ok", true}, {"whitelist", build_whitelist_json(m_whitelist_hosts)}}; +} + +nlohmann::json NodeImpl::admin_whitelist_add(const std::string& host, uint16_t port) +{ + if (host.empty() || port == 0) + return {{"ok", false}, {"error", "host and port required"}}; + NetService addr(host, port); + m_whitelist_ips.insert(host); + m_whitelist_hosts.insert(addr); + // Remove any existing ban for this host — whitelisting is an explicit override. + m_ip_ban_list.erase(host); + for (auto it = m_ban_list.begin(); it != m_ban_list.end(); ) { + if (it->first.address() == host) it = m_ban_list.erase(it); + else ++it; + } + save_whitelist_to_disk(); + LOG_INFO << "[Pool] Whitelisted " << addr.to_string(); + + // Proactive dial: if not already connected, try immediately. + if (!m_connections.contains(addr) && !m_pending_outbound.contains(addr)) { + LOG_INFO << "[Pool] Dialing whitelisted peer " << addr.to_string(); + m_pending_outbound.insert(addr); + core::Client::connect(addr); + } + return {{"ok", true}, {"action", "whitelist_add"}, + {"target", addr.to_string()}, + {"whitelist", build_whitelist_json(m_whitelist_hosts)}}; +} + +nlohmann::json NodeImpl::admin_whitelist_remove(const std::string& host, uint16_t port) +{ + if (host.empty() || port == 0) + return {{"ok", false}, {"error", "host and port required"}}; + NetService addr(host, port); + size_t removed_h = m_whitelist_hosts.erase(addr); + // Only remove the IP from whitelist if no other host:port remains for it. + bool other_on_same_ip = std::any_of( + m_whitelist_hosts.begin(), m_whitelist_hosts.end(), + [&](const NetService& n) { return n.address() == host; }); + if (!other_on_same_ip) m_whitelist_ips.erase(host); + if (removed_h) save_whitelist_to_disk(); + LOG_INFO << "[Pool] De-whitelisted " << addr.to_string(); + return {{"ok", true}, {"action", "whitelist_remove"}, + {"target", addr.to_string()}, {"removed", removed_h}, + {"whitelist", build_whitelist_json(m_whitelist_hosts)}}; +} + +nlohmann::json NodeImpl::admin_list_peers() const +{ + auto arr = nlohmann::json::array(); + for (const auto& [addr, peer] : m_connections) { + bool outbound = m_outbound_addrs.contains(addr); + arr.push_back({ + {"host", addr.address()}, + {"port", addr.port()}, + {"outbound", outbound}, + {"whitelisted", is_whitelisted(addr)} + }); + } + return {{"ok", true}, {"peers", arr}}; +} + +nlohmann::json NodeImpl::admin_drop_peer(const std::string& ip) +{ + if (ip.empty()) + return {{"ok", false}, {"error", "ip required"}}; + std::vector targets; + for (const auto& [addr, peer] : m_connections) + if (addr.address() == ip) targets.push_back(addr); + for (const auto& addr : targets) + close_connection(addr); + LOG_INFO << "[Pool] Admin drop " << ip << " (" << targets.size() << " connections)"; + return {{"ok", true}, {"action", "drop"}, {"target", ip}, + {"dropped", targets.size()}}; +} + +nlohmann::json NodeImpl::admin_dial_peer(const std::string& host, uint16_t port) +{ + if (host.empty() || port == 0) + return {{"ok", false}, {"error", "host and port required"}}; + NetService addr(host, port); + if (m_connections.contains(addr)) + return {{"ok", true}, {"action", "dial"}, {"target", addr.to_string()}, + {"note", "already connected"}}; + if (m_pending_outbound.contains(addr)) + return {{"ok", true}, {"action", "dial"}, {"target", addr.to_string()}, + {"note", "dial already pending"}}; + m_pending_outbound.insert(addr); + core::Client::connect(addr); + return {{"ok", true}, {"action", "dial"}, {"target", addr.to_string()}}; +} + +void NodeImpl::set_rss_limit_mb(long mb) +{ + g_rss_limit_mb = mb; +} + +} // namespace bch diff --git a/src/impl/bch/share_tracker.hpp b/src/impl/bch/share_tracker.hpp index ad727f18e..cb5f6b2a5 100644 --- a/src/impl/bch/share_tracker.hpp +++ b/src/impl/bch/share_tracker.hpp @@ -55,6 +55,7 @@ inline uint64_t mul128_shift(uint64_t a, uint64_t b, unsigned shift) { #include "share_check.hpp" #include "config_pool.hpp" +#include // SSOT: core::version_gate::is_v36_active #include #include #include @@ -66,12 +67,14 @@ inline uint64_t mul128_shift(uint64_t a, uint64_t b, unsigned shift) { #include #include #include +#include #include #include #include #include #include #include +#include #include namespace bch @@ -270,6 +273,83 @@ class HeadPPLNS { const DensePPLNSWindow& ring() const { return m_ring; } }; +// --- Scoring types (coin-independent; mirror of btc SSOT) --- +// These carry NO merged-mining / segwit surface. BCH is a standalone SHA256d +// parent — the score math is the same family as btc (the only BCH divergence +// is the 600s BLOCK_PERIOD applied in ShareTracker::score()). + +struct TailScore +{ + int32_t chain_len{}; + uint288 hashrate; + uint288 best_head_work; // tiebreak: raw chain work of best head + + friend bool operator<(const TailScore& a, const TailScore& b) + { + return std::tie(a.chain_len, a.hashrate, a.best_head_work) + < std::tie(b.chain_len, b.hashrate, b.best_head_work); + } +}; + +struct HeadScore +{ + // p2pool: (work - min(punish,1)*ata(target), -reason, -time_seen) + // Sorted ascending, .back() = best. + uint288 adjusted_work; // work - punishment_deduction + int32_t neg_reason{}; // -reason (higher = less punished = better) + int64_t neg_time_seen{}; // -time_seen (higher = seen earlier = better) + + friend bool operator<(const HeadScore& a, const HeadScore& b) + { + if (a.adjusted_work < b.adjusted_work) return true; + if (b.adjusted_work < a.adjusted_work) return false; + return std::tie(a.neg_reason, a.neg_time_seen) < std::tie(b.neg_reason, b.neg_time_seen); + } +}; + +struct TraditionalScore +{ + // p2pool: (work, -time_seen, -reason). No is_local. Sorted ascending. + uint288 work; + int64_t neg_time_seen{}; + int32_t neg_reason{}; + + friend bool operator<(const TraditionalScore& a, const TraditionalScore& b) + { + if (a.work < b.work) return true; + if (b.work < a.work) return false; + return std::tie(a.neg_time_seen, a.neg_reason) < std::tie(b.neg_time_seen, b.neg_reason); + } +}; + +template +struct DecoratedData +{ + ScoreT score; + uint256 hash; + + friend bool operator<(const DecoratedData& a, const DecoratedData& b) + { + return a.score < b.score; + } +}; + +// -- think() result (mirror of btc TrackerThinkResult; SSOT for the field set) -- +// Produced by think() and consumed by the node-layer run_think() loop: +// best = new best verified head (null when nothing verified) +// desired = (peer, hash) shares to download from peers +// bad_peer_addresses = peers that served unverifiable shares (ban targets) +// top5_heads = best-scored heads protected from head-pruning +// The s1 link-skeleton think() returns this EMPTY; s2 populates it. +struct TrackerThinkResult +{ + uint256 best; + std::vector> desired; + std::set bad_peer_addresses; + bool punish_aggressively{false}; + std::vector top5_heads; +}; + // --- ShareTracker (M3 slice 16: PPLNS / expected-payouts surface only) --- // Subsequent M3 slices extend this class with verify_share(), think(), // head-scoring, version counting and block scanning. @@ -283,6 +363,27 @@ class ShareTracker // block scanning run against this sub-chain, never the raw "chain" buffer. ShareChain verified; + // -- ctor/dtor: wire the verified SubsetTracker (mirror btc SSOT) -- + // p2pool SubsetTracker pattern: the verified mirror navigates through the + // MAIN tracker's skip list (verified.get_nth_parent_hash delegates to + // subset_of.get_nth_parent_hash). WeightsSkipList subscribes to the raw + // chain's removed signal so pruned shares leave no stale weight entries. + // (data.py SubsetTracker; node.py clean_tracker prune path.) + ShareTracker() + { + verified.set_parent_chain(&chain); + chain.on_removed([this](const uint256& hash) { + invalidate_weight_caches(hash); + m_verify_fail_count.erase(hash); + }); + } + ~ShareTracker() + { + // verified borrows raw share pointers from chain — free its indexes + // only, then let chain's destructor free the share data. + verified.clear_unowned(); + } + // -- Node-layer callback hooks (wired by the pool node in node.hpp) -- // BCH is a SHA256d standalone-parent chain: NO merged-mining / AuxPoW, so // there is no m_on_merged_block_check (present in btc for the DOGE aux leg). @@ -294,6 +395,659 @@ class ShareTracker // (best-share difficulty tracking for the dashboard). std::function m_on_share_difficulty; + // -- V36 think() lock-yield continuation flags (mirror p2pool reactor) -- + // Set by think() when its cooperative-yield budget is exhausted so the + // node-layer run_think() schedules a deferred continuation. The s1 link- + // stub think() returns immediately and never yields, so these stay false. + bool m_think_needs_continue{false}; + bool m_think_walk_needs_continue{false}; + + // -- Share ingest: add() = RAW chain buffering only (NOT consensus) -- + // A received share enters the PPLNS / payout / block-found path ONLY once + // attempt_verify() promotes it into `verified`. add() merely buffers it in + // the raw `chain`. BCH is a SHA256d standalone parent: no merged-mining, so + // (unlike btc) there is no try_register_merged_addr() leg here. + void add(ShareType share) + { + auto h = share.hash(); + if (!chain.contains(h)) + chain.add(share); + } + + // -- s0 CONSENSUS THINK-ENGINE (M5): full verify + reorg/score + PPLNS -- + // Ports the p2pool BCH consensus core (attempt_verify / score / think + // Phase 1-6) from the btc SSOT, conformed against the p2poolBCH @6603b79 + // oracle (p2pool/data.py attempt_verify:716-728, think:731-845, score:866-878; + // p2pool/node.py clean_tracker head-protection:348-373). + // + // BCH divergences from the btc donor (oracle-driven): + // * NO merged-mining: BCH is a SHA256d standalone parent. There is no + // m_on_merged_block_check / try_register_merged_addr / DOGE aux leg. + // * score() uses BCH PARENT.BLOCK_PERIOD = 600s (oracle + // p2pool/bitcoin/networks/bitcoincash.py:24), NOT the btc/LTC 150s. + // * NO SegWit: the share/template witness path does not exist on BCH; the + // think-engine never touches witness data (share_check.hpp gates segwit + // OFF for BCH via is_segwit_activated()==false). + // * ASERT DAA / CashTokens transparency are handled in verify_share() + // (share_check.hpp), not in the think-engine — no special handling here. + // The PPLNS budget walk (get_v36_decayed_cumulative_weights) is byte-identical + // to the oracle weight split decayed_att*(65535-don)/decayed_att*don + // (p2poolBCH/p2pool/data.py:673-674, get_decayed_cumulative_weights). + + // -- Attempt to verify a share (oracle data.py:716-728) -- + // Returns true if the share is verified (already, or newly promoted into the + // `verified` SubsetTracker). p2pool has NO permanently-unverifiable concept; + // it retries share.check() every think() — a share that failed on a transient + // fork may succeed once the fork resolves. (data.py: attempt_verify is called + // unconditionally in the think() walk every cycle.) + bool attempt_verify(const uint256& share_hash) + { + if (verified.contains(share_hash)) + return true; + + // oracle: height, last = self.get_height_and_last(share.hash) + // if height < self.net.CHAIN_LENGTH + 1 and last is not None: + // raise AssertionError() + // Chain too short and unrooted: cannot verify yet. The share isn't bad; + // its parents haven't arrived. generate_share_transaction needs + // CHAIN_LENGTH ancestors for correct PPLNS — verifying with a truncated + // window produces a wrong coinbase (GENTX-MISMATCH). Phase 2 naturally + // extends verification once parents arrive. + auto acc_height = chain.get_acc_height(share_hash); + auto last = chain.get_last(share_hash); + if (acc_height < static_cast(PoolConfig::chain_length()) + 1 && !last.IsNull()) + return false; + + // oracle: share.gentx = share.check(self, known_txs, ...) + // verify_share (share_check.hpp) runs init-phase (hash-link, merkle, + // SHA256d PoW, ASERT context) + check-phase (gentx/coinbase comparison). + // It throws on any failure. BCH-faithful: no segwit, no merged leg. + try + { + auto& share_var = chain.get_share(share_hash); + share_var.ACTION({ + auto computed_hash = verify_share(*obj, *this); + (void)computed_hash; + }); + } + catch (const std::exception& e) + { + // Counter for log throttling only — p2pool retries every think(). + auto& cnt = m_verify_fail_count[share_hash]; + ++cnt; + if (cnt <= 3 || cnt % 10 == 0) + LOG_WARNING << "attempt_verify FAILED (" << cnt + << ") for " << share_hash.ToString().substr(0,16) + << " acc_height=" << acc_height + << " last=" << (last.IsNull() ? "null" : last.ToString().substr(0,16)) + << " error: " << e.what(); + return false; + } + + // Success — clear any previous fail count. + m_verify_fail_count.erase(share_hash); + + // Cache the SHA256d pow_hash on the index (restart block-scan reuse — + // avoids recomputing SHA256d). g_last_pow_hash is set by share_init_verify + // (share_check.hpp). BCH PoW is SHA256d (same family as btc). + if (!g_last_pow_hash.IsNull()) { + auto* idx = chain.get_index(share_hash); + if (idx) idx->pow_hash = g_last_pow_hash; + } + + // Promote into the verified SubsetTracker. + auto& share_var = chain.get_share(share_hash); + if (!verified.contains(share_hash)) + verified.add(share_var); + + // Notify LevelDB known_verified persistence layer. + if (m_on_share_verified) + m_on_share_verified(share_hash); + + // Block detection: share_init_verify flags a block solution when the + // share's SHA256d pow meets the BCH parent block target. Mirrors p2pool + // tracker.verified.added watcher (node.py). BCH standalone-parent: NO + // merged DOGE-target leg here. + if (m_on_block_found && g_last_init_is_block) { + g_last_init_is_block = false; + auto* idx = chain.get_index(share_hash); + if (idx) idx->is_block_solution = true; + m_on_block_found(share_hash); + } + + // Report share difficulty for the best-share dashboard tracking. + if (m_on_share_difficulty) { + share_var.invoke([&](auto* s) { + double diff = chain::target_to_difficulty(chain::bits_to_target(s->m_bits)); + std::string miner; + if constexpr (requires { s->m_pubkey_hash; }) + miner = s->m_pubkey_hash.GetHex(); + else if constexpr (requires { s->m_address; }) + miner = HexStr(s->m_address.m_data); + m_on_share_difficulty(diff, miner); + }); + } + + // Naughty propagation: if the parent is naughty, increment up to 6 + // generations (data.py ancestor punishment for invalid-block shares). + { + uint256 prev_hash; + share_var.invoke([&](auto* obj) { prev_hash = obj->m_prev_hash; }); + if (!prev_hash.IsNull() && chain.contains(prev_hash)) { + auto* parent_idx = chain.get_index(prev_hash); + if (parent_idx && parent_idx->naughty > 0) { + auto* my_idx = chain.get_index(share_hash); + if (my_idx) { + my_idx->naughty = parent_idx->naughty + 1; + if (my_idx->naughty > 6) my_idx->naughty = 0; + } + } + } + } + + // V34+ (incl. V36) shares carry transaction_hash_refs, not embedded txs, + // so other_txs is always None → p2pool SKIPS the block weight/size check + // (data.py). Coinbase correctness is already enforced by verify_share's + // generate_share_transaction comparison. CashTokens carry transparently. + return true; + } + + // -- Score a verified chain (oracle data.py:866-878) -- + // Returns (chain_len, hashrate) — higher is better. Uses self.verified for + // ALL operations (using the raw chain inflates height with unverified shares + // and breaks tie-breaking). BCH divergence: PARENT.BLOCK_PERIOD = 600s. + TailScore score(const uint256& share_hash, + const std::function& block_rel_height_func) + { + uint288 score_res; + + // oracle: head_height = self.verified.get_height(share_hash) + // if head_height < self.net.CHAIN_LENGTH: return head_height, None + auto head_height = verified.get_acc_height(share_hash); + if (head_height < static_cast(PoolConfig::chain_length())) + return {head_height, score_res}; + + // oracle: end_point = self.verified.get_nth_parent_hash( + // share_hash, self.net.CHAIN_LENGTH*15//16) + auto end_point = verified.get_nth_parent_via_skip(share_hash, + (PoolConfig::chain_length() * 15) / 16); + + // oracle: block_height = max(block_rel_height_func(share.header['previous_block']) + // for share in self.verified.get_chain(end_point, CHAIN_LENGTH//16)) + std::optional block_height; + auto tail_count = std::min( + static_cast(PoolConfig::chain_length() / 16), + verified.get_acc_height(end_point)); + if (tail_count <= 0) + return {static_cast(PoolConfig::chain_length()), score_res}; + + auto tail_view = verified.get_chain(end_point, tail_count); + for (auto [hash, data] : tail_view) + { + uint256 prev_block; + data.share.invoke([&](auto* obj) { + prev_block = obj->m_min_header.m_previous_block; + }); + auto bh = block_rel_height_func(prev_block); + if (!block_height.has_value() || bh > block_height.value()) + block_height = bh; + } + + // c2pool returns confirmations (1=tip, 0=unknown, -1=off-main-chain); + // p2pool returns relative height. When the block can't be resolved, + // p2pool computes work/(1e9*BLOCK_PERIOD) — tiny but non-zero, so the + // higher-work chain wins (stable, no oscillation). Match it. + if (!block_height.has_value() || block_height.value() <= 0) + block_height = 1000000; + + // oracle: self.verified.get_delta(share_hash, end_point).work + auto total_work = verified.get_delta_work(share_hash, end_point); + + // oracle: ((0 - block_height + 1) * self.net.PARENT.BLOCK_PERIOD) + // BCH PARENT.BLOCK_PERIOD = 600s (bitcoincash.py:24), NOT LTC's 150s. + auto time_span = block_height.value() * 600; + if (time_span <= 0) + time_span = 1; + + score_res = total_work / static_cast(time_span); + return {static_cast(PoolConfig::chain_length()), score_res}; + } + + // -- Best-chain selection with verification + punishment (oracle think) -- + // bootstrap_mode: removes the per-call verification budget so the entire + // chain is verified in one pass (initial sync, no IO needs the lock). + // Oracle: p2pool/data.py:731-845 (Tracker.think); p2pool runs think() + // synchronously on the reactor. The known_txs/feecache/block_abs_height + // args fold into verify_share via share_check.hpp. + TrackerThinkResult think(const std::function& block_rel_height_func, + const uint256& /*previous_block*/, + uint32_t /*bits*/, + bool bootstrap_mode = false) + { + // oracle: desired is a set of (peer_addr, hash, max_timestamp, min_target). + // The timestamp filters stale requests at return time. + struct DesiredEntry { + NetService peer; + uint256 hash; + uint32_t max_timestamp{0}; + }; + std::vector desired; + std::set desired_hashes; // oracle: desired = set() — dedup by hash + std::set bad_peer_addresses; + + // ── Phase 1: verify unverified heads; collect bad shares ─────────── + // oracle data.py:740-755. For each raw head not yet verified, walk back + // and attempt_verify; the first success breaks; failing shares -> bads; + // a for/else with no success on a rooted chain requests the parent. + std::vector bads; + { + auto heads_snapshot = chain.get_heads(); + for (auto& [head_hash, tail_hash] : heads_snapshot) + { + if (verified.get_heads().contains(head_hash)) + continue; + if (!chain.contains(head_hash)) continue; + + auto [head_height, last] = chain.get_height_and_last(head_hash); + + // oracle: get_chain(head, head_height if last is None + // else min(5, max(0, head_height - CHAIN_LENGTH))) + auto walk_count = last.IsNull() + ? head_height + : std::min(5, std::max(0, head_height - static_cast(PoolConfig::chain_length()))); + + if (walk_count <= 0) { + // oracle for/else: walk produced nothing → request parent. + // Skip parent requests for chains already in the pruning zone + // (>= 2*CHAIN_LENGTH+10) — clean_tracker would re-prune them. + if (!last.IsNull()) { + auto CL_prune = static_cast(PoolConfig::chain_length()); + if (head_height < 2 * CL_prune + 10 && !desired_hashes.count(last)) { + NetService peer; + uint32_t head_ts = 0; + chain.get_share(head_hash).invoke([&](auto* obj) { + peer = obj->peer_addr; + head_ts = obj->m_timestamp; + }); + desired_hashes.insert(last); + desired.push_back({peer, last, head_ts}); + } + } + continue; + } + + bool verified_one = false; + try { + auto chain_view = chain.get_chain(head_hash, walk_count); + for (auto [hash, data] : chain_view) + { + if (attempt_verify(hash)) { verified_one = true; break; } + // oracle data.py: bads.append(share.hash) — ALL failing + // shares go to bads; p2pool has no unverifiable filter. + bads.push_back(hash); + } + } catch (const std::exception& ex) { + LOG_WARNING << "[think-P1] exception walking head " + << head_hash.GetHex().substr(0,16) + << " height=" << head_height << " walk=" << walk_count + << ": " << ex.what(); + continue; + } + + // oracle for/else: loop completed without break AND unrooted. + if (!verified_one && !last.IsNull()) + { + auto CL_prune = static_cast(PoolConfig::chain_length()); + if (head_height < 2 * CL_prune + 10 && !desired_hashes.count(last)) { + NetService peer; + uint32_t head_ts = 0; + chain.get_share(head_hash).invoke([&](auto* obj) { + peer = obj->peer_addr; + head_ts = obj->m_timestamp; + }); + desired_hashes.insert(last); + desired.push_back({peer, last, head_ts}); + } + } + } + } + + // ── Remove bad shares (oracle data.py:756-768) ───────────────────── + // p2pool tries self.remove(bad) for ALL bads; catches NotImplementedError + // for mid-chain shares (have dependents). chain.remove() returns false + // in that case (equivalent). NO leaf-only filter — p2pool has none. + { + int removed_count = 0; + for (const auto& bad : bads) + { + if (verified.contains(bad)) continue; // oracle: assert bad not in verified + if (!chain.contains(bad)) continue; + + NetService bad_peer; + try { + chain.get_share(bad).invoke([&](auto* obj) { bad_peer = obj->peer_addr; }); + } catch (...) {} + if (bad_peer.port() != 0) + bad_peer_addresses.insert(bad_peer); + + try { + invalidate_weight_caches(bad); + if (verified.contains(bad)) verified.remove(bad); + if (chain.remove(bad)) ++removed_count; + } catch (...) {} + } + if (removed_count > 0) + LOG_INFO << "[think-P1] removed " << removed_count + << " shares (bads=" << bads.size() << " + descendants)"; + } + + // ── Phase 2: extend verification from verified heads ─────────────── + // oracle data.py:769-788. Budget-limited per call (cooperative yield) to + // avoid pinning the compute lock; remainder picked up next run_think(). + constexpr int THINK_VERIFY_BUDGET = 100; + int budget_remaining = bootstrap_mode ? INT_MAX : THINK_VERIFY_BUDGET; + m_think_needs_continue = false; + + // V36 livelock mirror: cooperative-yield budget for the scoring walk. + // Sized FAR above a normal full-window pass so it NEVER trips on healthy + // load (semantics unchanged); a pure starvation circuit breaker. + constexpr int THINK_WALK_YIELD_BUDGET = 1000000; + int walk_budget_remaining = bootstrap_mode ? INT_MAX : THINK_WALK_YIELD_BUDGET; + m_think_walk_needs_continue = false; + + // Sort verified heads by work (descending) so the best chain gets budget + // priority. p2pool iterates in arbitrary order but has no budget; with a + // budget, a low-work side chain going first could starve the best chain. + std::vector> sorted_vheads( + verified.get_heads().begin(), verified.get_heads().end()); + std::sort(sorted_vheads.begin(), sorted_vheads.end(), + [this](const auto& a, const auto& b) { + auto wa = verified.contains(a.first) ? verified.get_work(a.first) : uint288{}; + auto wb = verified.contains(b.first) ? verified.get_work(b.first) : uint288{}; + return wa > wb; + }); + + for (auto& [head_hash, tail_hash] : sorted_vheads) + { + if (budget_remaining <= 0) { m_think_needs_continue = true; break; } + if (!chain.contains(head_hash)) continue; + + auto [head_height, last_hash] = verified.get_height_and_last(head_hash); + if (!chain.contains(last_hash)) continue; + + auto [last_height, last_last_hash] = chain.get_height_and_last(last_hash); + + // oracle data.py:774-776 EXACTLY: + // want = max(CHAIN_LENGTH - head_height, 0) + // can = max(last_height - 1 - CHAIN_LENGTH, 0) if last_last_hash + // is not None else last_height + // get = min(want, can) + auto CL = static_cast(PoolConfig::chain_length()); + auto want = std::max(CL - head_height, 0); + auto can = last_last_hash.IsNull() + ? last_height + : std::max(last_height - 1 - CL, 0); + auto to_get = std::min(want, can); + + if (to_get > 0) + { + // Carry-forward PPLNS ring: build once at the first share's + // prev_hash, slide backward thereafter, and prime the decayed + // cache so verify_share's get_v36_decayed_cumulative_weights hits + // O(1). The ring uses the precomputed decay table — BIT-EXACT + // with the iterative walk (same per-step truncation). The weight + // split is the oracle's (p2poolBCH/p2pool/data.py:673-674). + HeadPPLNS head_pplns; + bool pplns_active = false; + auto CL_i32 = static_cast(PoolConfig::chain_length()); + + auto chain_view = chain.get_chain(last_hash, to_get); + int p2_verified_count = 0; + for (auto [hash, data] : chain_view) + { + if (budget_remaining <= 0) { m_think_needs_continue = true; break; } + + uint256 prev_hash; + int share_ver = 0; + data.share.invoke([&](auto* obj) { + prev_hash = obj->m_prev_hash; + share_ver = obj->version; + }); + if (core::version_gate::is_v36_active(share_ver)) + { + if (!prev_hash.IsNull() && chain.contains(prev_hash)) { + if (!pplns_active) { + head_pplns.rebuild(chain, prev_hash, CL_i32); + pplns_active = true; + } else { + auto ring_depth = head_pplns.window_size(); + if (ring_depth > 0) { + // deep_hash IS the new tail entry (depth + // chain_len-1 from the new start). Taking its + // .tail would shift one step too deep → an + // off-by-one PPLNS error → GENTX mismatch. + auto deep_hash = chain.get_nth_parent_via_skip( + prev_hash, std::min(ring_depth - 1, CL_i32 - 1)); + if (!deep_hash.IsNull() && chain.contains(deep_hash)) + head_pplns.extend_backward(chain, deep_hash); + else + head_pplns.rebuild(chain, prev_hash, CL_i32); + } + } + if (pplns_active && head_pplns.valid()) { + auto& w = const_cast(head_pplns).weights(); + prime_pplns_cache(prev_hash, CL_i32, w); + } + } + } + + // p2pool has no budget — already-verified shares are free + // (a head walking through another head's verified territory). + bool was_already_verified = verified.contains(hash); + if (!attempt_verify(hash)) break; + ++p2_verified_count; + if (!was_already_verified) --budget_remaining; + } + } + + // oracle data.py:781-788: request more shares if the verified chain + // is still short. Skip if the MAIN chain is already in the pruning + // zone (the unverified shares exist — they just need verification). + if (head_height < static_cast(PoolConfig::chain_length()) && !last_last_hash.IsNull()) + { + auto main_ht = chain.get_height(head_hash); + auto CL_prune = static_cast(PoolConfig::chain_length()); + if (main_ht < 2 * CL_prune + 10 && !desired_hashes.count(last_last_hash)) { + NetService peer; + uint32_t head_ts = 0; + chain.get_share(head_hash).invoke([&](auto* obj) { + peer = obj->peer_addr; + head_ts = obj->m_timestamp; + }); + desired_hashes.insert(last_last_hash); + desired.push_back({peer, last_last_hash, head_ts}); + } + } + } + + // ── Phase 3: score tails — pick the best tail (oracle data.py:790) ── + // decorated_tails = sorted(score(max(verified.tails[t], key=verified.get_work), ...) + // for t in verified.tails); best = decorated_tails[-1]. + std::vector> decorated_tails; + for (auto& [tail_hash, head_hashes] : verified.get_tails()) + { + // COARSE walk-yield boundary: checked once per scored head (not in the + // inner decay loop). Trips only on a degenerate window. + if (walk_budget_remaining <= 0) { + m_think_walk_needs_continue = true; + LOG_WARNING << "[think-WALK-YIELD] scoring-walk budget exhausted; " + "deferring remaining tails to continuation"; + break; + } + + uint256 best_head; + uint288 best_work; + bool first = true; + for (const auto& hh : head_hashes) + { + if (!verified.contains(hh)) continue; + auto w = verified.get_work(hh); + if (first || w > best_work) { best_work = w; best_head = hh; first = false; } + } + + if (!best_head.IsNull()) + { + try { + auto s = score(best_head, block_rel_height_func); + s.best_head_work = best_work; // tiebreak by total work + decorated_tails.push_back({s, tail_hash}); + walk_budget_remaining -= std::max(s.chain_len, 1); + } catch (const std::exception&) { + // Chain concurrently modified — skip; scored next cycle. + } + } + } + std::sort(decorated_tails.begin(), decorated_tails.end()); + + uint256 best_tail; + TailScore best_tail_score{}; + if (!decorated_tails.empty()) { + best_tail = decorated_tails.back().hash; + best_tail_score = decorated_tails.back().score; + } + (void)best_tail_score; + + // ── Phase 4: score heads within the best tail (oracle data.py:793-810) ── + // decorated_heads sort key = (work_of_5th_ancestor - min(punish,1)*ata(target), + // -reason, -time_seen). traditional_sort drops the + // punishment deduction. BCH head-scoring punishes + // only naughty (invalid-block) heads — the canonical + // 60% weighted version-switch is the check() gate + // (share_check), NOT head-scoring. + std::vector> decorated_heads; + std::vector> traditional_sort; + + if (verified.get_tails().contains(best_tail)) + { + const auto& head_hashes = verified.get_tails().at(best_tail); + for (const auto& hh : head_hashes) + { + if (!verified.contains(hh)) continue; + try { + // oracle: verified.get_work(verified.get_nth_parent_hash( + // h, min(5, verified.get_height(h)))) + auto v_height = verified.get_acc_height(hh); + auto recent_ancestor = verified.get_nth_parent_via_skip(hh, std::min(5, v_height)); + uint288 work_score = verified.get_work(recent_ancestor); + + auto* head_idx = chain.get_index(hh); + if (!head_idx) continue; + int64_t ts = head_idx->time_seen; + + int32_t reason = 0; + if (head_idx->naughty > 0) + reason = head_idx->naughty; + + uint288 adjusted_work = work_score; + if (reason > 0) + adjusted_work = adjusted_work - head_idx->work; // min(reason,1)*ata(target) + + decorated_heads.push_back({{adjusted_work, -reason, -ts}, hh}); + traditional_sort.push_back({{work_score, -ts, -reason}, hh}); + } catch (const std::exception&) { + // Chain concurrently modified — skip; retried next cycle. + } + } + std::sort(decorated_heads.begin(), decorated_heads.end()); + std::sort(traditional_sort.begin(), traditional_sort.end()); + } + + // oracle: punish_aggressively = traditional_sort[-1][0][2] + bool punish_aggressively = !traditional_sort.empty() + && traditional_sort.back().score.neg_reason != 0; + + // ── Phase 5: determine best share (oracle data.py:812-836) ───────── + // Walk back through punished (naughty) shares, then find the best + // non-naughty descendent. + uint256 best; + if (!decorated_heads.empty()) + best = decorated_heads.back().hash; + + if (!best.IsNull() && chain.contains(best)) + { + auto* best_idx = chain.get_index(best); + if (best_idx && best_idx->naughty > 0) + { + while (best_idx && best_idx->naughty > 0) + { + uint256 prev; + chain.get_share(best).invoke([&](auto* obj) { prev = obj->m_prev_hash; }); + if (prev.IsNull() || !chain.contains(prev)) break; + best = prev; + best_idx = chain.get_index(best); + + if (best_idx && best_idx->naughty == 0) + { + // oracle best_descendent: deepest non-naughty child. + std::function(const uint256&, int)> best_desc; + best_desc = [&](const uint256& h, int limit) -> std::pair { + if (limit < 0) return {0, h}; + auto& rev = chain.get_reverse(); + auto rit = rev.find(h); + if (rit == rev.end() || rit->second.empty()) + return {0, h}; + std::pair best_kid = {-1, h}; + for (const auto& child : rit->second) { + auto* cidx = chain.get_index(child); + if (cidx && cidx->naughty > 0) continue; + auto [gen, hash] = best_desc(child, limit - 1); + if (gen + 1 > best_kid.first) + best_kid = {gen + 1, hash}; + } + return best_kid.first >= 0 ? best_kid : std::pair{0, h}; + }; + auto [gens, desc_hash] = best_desc(best, 20); + (void)gens; + best = desc_hash; + break; + } + } + } + } + + // ── Phase 6: timestamp cutoff + desired filter (oracle data.py:838-844) ── + // timestamp_cutoff = min(now, best.timestamp) - 3600 (or now - 24h if no best). + // return best, [(peer, hash) for ... in desired if ts >= timestamp_cutoff] + uint32_t timestamp_cutoff; + if (!best.IsNull() && chain.contains(best)) + { + uint32_t best_ts = 0; + chain.get_share(best).invoke([&](auto* obj) { best_ts = obj->m_timestamp; }); + timestamp_cutoff = std::min(static_cast(now_seconds()), best_ts) - 3600; + } + else + { + timestamp_cutoff = static_cast(now_seconds()) - 24 * 60 * 60; + } + + std::vector> desired_result; + for (auto& d : desired) { + if (d.max_timestamp >= timestamp_cutoff) + desired_result.emplace_back(d.peer, d.hash); + } + + // Top-5 scored heads for clean_tracker head-protection + // (oracle p2pool/node.py:356 — decorated_heads[-5:]). + std::vector top5; + { + size_t start = decorated_heads.size() > 5 ? decorated_heads.size() - 5 : 0; + for (size_t i = start; i < decorated_heads.size(); ++i) + top5.push_back(decorated_heads[i].hash); + } + + return {best, desired_result, bad_peer_addresses, punish_aggressively, std::move(top5)}; + } + // -- PPLNS cumulative weights computation (O(log n) via skip list) -- CumulativeWeights get_cumulative_weights(const uint256& start, int32_t max_shares, const uint288& desired_weight) { @@ -419,7 +1173,13 @@ class ShareTracker 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); + // BCH block solution test: SHA256d pow vs the PARENT block + // target carried in the share's m_min_header.m_bits (NOT the + // share-difficulty m_bits). Conforms to verify_share's block + // detection (share_check.hpp:745) and the btc donor scan + // (m_min_header.m_bits). BCH is a standalone SHA256d parent — + // no merged DOGE-target leg. + const uint256 block_target = chain::bits_to_target(obj->m_min_header.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; } @@ -587,6 +1347,42 @@ class ShareTracker } private: + // -- think-engine support state (mirror btc SSOT) -- + // Retry counter for log throttling only — p2pool retries every think() with + // no limit. Cleared on successful verification or on the chain removed signal. + std::unordered_map m_verify_fail_count; + + static int64_t now_seconds() + { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + } + + // Invalidate all weight caches for a hash (chain mutated). BCH carries only + // the flat-weights skiplist + the decayed result cache — NO merged-mining + // skiplists (standalone SHA256d parent). Wired to chain.on_removed in ctor. + void invalidate_weight_caches(const uint256& hash) + { + if (m_weights_skiplist) m_weights_skiplist->forget(hash); + m_decayed_cache_valid = false; // chain changed — decayed cache stale + } + + // Pre-populate the decayed-weights cache from a HeadPPLNS ring buffer so + // verify_share -> get_v36_decayed_cumulative_weights() hits O(1). The ring + // uses the precomputed decay table which is BIT-EXACT with the iterative + // walk (identical per-step truncation) — NOT an approximation. + void prime_pplns_cache(const uint256& start, int32_t max_shares, + const CumulativeWeights& weights) + { + m_decayed_cache_start = start; + m_decayed_cache_shares = max_shares; + // Verification always uses an unlimited desired_weight. + m_decayed_cache_desired.SetHex( + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + m_decayed_cache_result = weights; + m_decayed_cache_valid = true; + } + // Previous-share lambda for RAW chain (work templates, general PPLNS) auto make_previous_fn() { @@ -621,6 +1417,10 @@ class ShareTracker // -- Skip list caches for O(log n) weight queries -- std::optional m_weights_skiplist; + // -- Per-aux-chain merged skip lists (s0 port, btc SSOT share_tracker.hpp:2699/2815) -- + // BCH is a standalone parent so m_merged_coinbase_info is empty at runtime and these + // never get a real chain_id; carried so verify_merged_coinbase_commitment compiles. + std::unordered_map m_merged_skiplists; // -- Decayed weights result cache -- // Avoids recomputing the O(chain_length) walk when the same @@ -687,6 +1487,286 @@ class ShareTracker return attempts / uint288(time_span); } + // -- Share target computation (p2pool generate_transaction Phase 1) -- + // Computes {max_bits, bits} for a new/received share, matching p2pool-v36 + // BaseShare.generate_transaction(): + // 1. Derive pre_target from the pool hashrate estimate + // 2. Clamp to ±10% of the previous share's max_target + // 3. Apply emergency time-based decay (death-spiral prevention) + // 4. Clamp to [MIN_TARGET, MAX_TARGET] + // BCH-faithful: SHA256d work/target math (same family as btc), no scrypt, + // no merged-mining divergence — coin-independent. The BCH ASERT DAA governs + // the PARENT block header (verify_share/share_check), NOT this share-diff + // retarget, which is the p2pool sharechain retarget shared by all coins. + // verify_share (share_check.hpp) calls this to cross-check the share's bits. + struct ShareTarget { + uint32_t max_bits; + uint32_t bits; + }; + + ShareTarget compute_share_target( + const uint256& prev_share_hash, + uint32_t desired_timestamp, + const uint256& desired_target) + { + // MAX_TARGET: BCH share difficulty floor (PoolConfig::max_target()). + const uint256 MAX_TARGET = PoolConfig::max_target(); + + if (prev_share_hash.IsNull() || !chain.contains(prev_share_hash)) + { + // Genesis or unknown prev: MAX_TARGET for max_bits; clip + // desired_target to [MAX_TARGET/30, MAX_TARGET] for bits. + auto pre_target3 = MAX_TARGET; + auto max_bits = chain::target_to_bits_upper_bound(pre_target3); + uint256 bits_lo = pre_target3 / 30; + if (bits_lo.IsNull()) bits_lo = uint256(1); + uint256 bits_target = desired_target; + if (bits_target < bits_lo) bits_target = bits_lo; + if (bits_target > pre_target3) bits_target = pre_target3; + auto bits = chain::target_to_bits_upper_bound(bits_target); + return {max_bits, bits}; + } + + // Accumulated height from the skip-list cache — O(1) and correct after + // pruning (a raw get_height stops at a pruned tail and under-counts). + auto acc_height = chain.get_acc_height(prev_share_hash); + + // Not enough chain depth for proper difficulty calculation. + if (acc_height < static_cast(PoolConfig::TARGET_LOOKBEHIND)) + { + auto pre_target3 = MAX_TARGET; + auto max_bits = chain::target_to_bits_upper_bound(pre_target3); + uint256 bits_lo = pre_target3 / 30; + if (bits_lo.IsNull()) bits_lo = uint256(1); + uint256 bits_target = desired_target; + if (bits_target < bits_lo) bits_target = bits_lo; + if (bits_target > pre_target3) bits_target = pre_target3; + auto bits = chain::target_to_bits_upper_bound(bits_target); + return {max_bits, bits}; + } + + // Step 1: derive target from pool hashrate (min_work APS, all shares). + auto aps = get_pool_attempts_per_second(prev_share_hash, + PoolConfig::TARGET_LOOKBEHIND, /*min_work=*/true); + + uint256 pre_target; + if (aps.IsNull()) + { + pre_target = MAX_TARGET; + } + else + { + // pre_target = 2^256 / (SHARE_PERIOD * aps) - 1 + uint288 two_256; + two_256.SetHex("10000000000000000000000000000000000000000000000000000000000000000"); + uint288 divisor = aps * static_cast(PoolConfig::share_period()); + if (divisor.IsNull()) + divisor = uint288(1); + uint288 result = two_256 / divisor; + if (result > uint288(1)) + result = result - uint288(1); + uint288 max_288; + max_288.SetHex(MAX_TARGET.GetHex()); + if (result > max_288) + pre_target = MAX_TARGET; + else + pre_target.SetHex(result.GetHex()); + } + + // Step 2: previous share's max_target. + uint256 prev_max_target; + chain.get_share(prev_share_hash).invoke([&](auto* obj) { + prev_max_target = chain::bits_to_target(obj->m_max_bits); + }); + + // Step 3: emergency time-based decay (death-spiral prevention). + // Doubles the target every SHARE_PERIOD*10s past a SHARE_PERIOD*20s + // since-last-share threshold. + uint256 clamp_ref_target = prev_max_target; + uint32_t prev_ts = 0; + chain.get_share(prev_share_hash).invoke([&](auto* obj) { + prev_ts = obj->m_timestamp; + }); + + if (prev_ts > 0 && desired_timestamp > prev_ts) + { + auto time_since_share = desired_timestamp - prev_ts; + auto emergency_threshold = PoolConfig::share_period() * 20; + if (time_since_share > emergency_threshold) + { + auto half_life = PoolConfig::share_period() * 10; + auto excess = time_since_share - emergency_threshold; + auto halvings = excess / half_life; + auto remainder = excess % half_life; + uint256 eased = prev_max_target; + if (halvings < 256) + eased <<= halvings; + else + eased = MAX_TARGET; + uint288 eased_288; + eased_288.SetHex(eased.GetHex()); + eased_288 = eased_288 * static_cast(half_life + remainder); + eased_288 = eased_288 / static_cast(half_life); + uint288 max_288; + max_288.SetHex(MAX_TARGET.GetHex()); + if (eased_288 > max_288) + clamp_ref_target = MAX_TARGET; + else + clamp_ref_target.SetHex(eased_288.GetHex()); + } + } + + // Step 4: clamp pre_target to ±10% of clamp_ref_target. + uint256 lo; + { + uint288 lo_288; + lo_288.SetHex(clamp_ref_target.GetHex()); + lo_288 = lo_288 * 9 / 10; + uint288 max_288; + max_288.SetHex(MAX_TARGET.GetHex()); + if (lo_288 > max_288) lo = MAX_TARGET; + else lo.SetHex(lo_288.GetHex()); + } + uint256 hi; + { + uint288 hi_288; + hi_288.SetHex(clamp_ref_target.GetHex()); + hi_288 = hi_288 * 11; + hi_288 = hi_288 / 10; + uint288 max_288; + max_288.SetHex(MAX_TARGET.GetHex()); + if (hi_288 > max_288) hi = MAX_TARGET; + else hi.SetHex(hi_288.GetHex()); + } + + uint256 pre_target2 = pre_target; + if (pre_target2 < lo) pre_target2 = lo; + if (pre_target2 > hi) pre_target2 = hi; + + // Step 5: clamp to network limits; never zero (bits=0 is invalid). + uint256 pre_target3 = pre_target2; + if (pre_target3.IsNull()) pre_target3 = uint256(1); + if (pre_target3 > MAX_TARGET) pre_target3 = MAX_TARGET; + + auto max_bits = chain::target_to_bits_upper_bound(pre_target3); + + // bits = from_target_upper_bound(clip(desired_target, (pre_target3/30, pre_target3))) + uint256 bits_lo = pre_target3 / 30; + if (bits_lo.IsNull()) bits_lo = uint256(1); + uint256 bits_target = desired_target; + if (bits_target < bits_lo) bits_target = bits_lo; + if (bits_target > pre_target3) bits_target = pre_target3; + auto bits = chain::target_to_bits_upper_bound(bits_target); + + return {max_bits, bits}; + } + + // -- V36 PPLNS walk dump (diagnostic; called by verify_share on mismatch) -- + // Per-share decayed-weight trace for cross-impl comparison against p2pool's + // [PARENT-PPLNS] output. Uses the SAME weight split as the oracle + // (p2poolBCH/p2pool/data.py:673-674): decayed_att*(65535-don) to the miner + // script, decayed_att*don to donation. Read-only — no consensus mutation. + void dump_v36_pplns_walk(const uint256& start_hash, int32_t max_shares) + { + if (start_hash.IsNull() || !chain.contains(start_hash)) + { + LOG_WARNING << "[PPLNS-WALK] start=" << start_hash.GetHex().substr(0,16) + << " NOT IN CHAIN — walk aborted"; + return; + } + + static constexpr uint64_t DECAY_PRECISION = 40; + static constexpr uint64_t DECAY_SCALE = uint64_t(1) << DECAY_PRECISION; + static constexpr uint64_t LN2_MICRO = 693147; + + uint32_t half_life = std::max(PoolConfig::chain_length() / 4, uint32_t(1)); + uint64_t decay_per = DECAY_SCALE - (DECAY_SCALE * LN2_MICRO) / (uint64_t(1000000) * half_life); + + int32_t share_count = 0; + uint64_t decay_fp = DECAY_SCALE; + uint288 running_total; + uint288 running_donation; + std::map, uint288> per_addr_weight; + + auto height = chain.get_height(start_hash); + auto last = chain.get_last(start_hash); + + LOG_WARNING << "[PPLNS-WALK] start=" << start_hash.GetHex().substr(0, 16) + << " max_shares=" << max_shares + << " height=" << height + << " last=" << (last.IsNull() ? "null" : last.GetHex().substr(0, 16)) + << " half_life=" << half_life + << " decay_per=" << decay_per; + + auto cur = start_hash; + while (!cur.IsNull() && chain.contains(cur) && share_count < max_shares) + { + chain.get_share(cur).invoke([&](auto* obj) { + auto target = chain::bits_to_target(obj->m_bits); + auto att = chain::target_to_average_attempts(target); + uint32_t don = obj->m_donation; + + uint288 decayed_att = (att * uint288(decay_fp)) >> DECAY_PRECISION; + auto addr_w = decayed_att * static_cast(65535 - don); + auto don_w = decayed_att * don; + + auto script = get_share_script(obj); + per_addr_weight[script] += addr_w; + running_total += addr_w + don_w; + running_donation += don_w; + + static const char* HX = "0123456789abcdef"; + std::string sh; + for (size_t i = 0; i < std::min(script.size(), size_t(20)); ++i) { + sh += HX[script[i] >> 4]; + sh += HX[script[i] & 0xf]; + } + + LOG_WARNING << "[PPLNS-WALK] #" << share_count + << " hash=" << cur.GetHex().substr(0, 16) + << " script=" << sh + << " bits=0x" << std::hex << obj->m_bits << std::dec + << " don=" << don + << " att=" << att.GetLow64() + << " decay_fp=" << decay_fp + << " decayed=" << decayed_att.GetLow64() + << " addr_w=" << addr_w.GetLow64() + << " running=" << running_total.GetLow64(); + }); + + ++share_count; + decay_fp = mul128_shift(decay_fp, decay_per, DECAY_PRECISION); + auto* idx = chain.get_index(cur); + cur = idx ? idx->tail : uint256(); + } + + LOG_WARNING << "[PPLNS-WALK] SUMMARY: shares=" << share_count + << " addrs=" << per_addr_weight.size() + << " total_w=" << running_total.GetLow64() + << " don_w=" << running_donation.GetLow64(); + for (const auto& [script, weight] : per_addr_weight) + { + static const char* HX = "0123456789abcdef"; + std::string sh; + for (size_t i = 0; i < std::min(script.size(), size_t(20)); ++i) { + sh += HX[script[i] >> 4]; + sh += HX[script[i] & 0xf]; + } + double pct = running_total.IsNull() ? 0.0 : + static_cast(weight.GetLow64()) / static_cast(running_total.GetLow64()) * 100.0; + LOG_WARNING << "[PPLNS-WALK] " << sh + << " w=" << weight.GetLow64() + << " pct=" << std::fixed << std::setprecision(2) << pct << "%"; + } + + if (share_count < max_shares && !cur.IsNull()) { + LOG_WARNING << "[PPLNS-WALK] CHAIN GAP: walk stopped at share #" << share_count + << " — next hash " << cur.GetHex().substr(0, 16) + << " is " << (chain.contains(cur) ? "IN chain (walk bug)" : "NOT IN chain (missing share)") + << ". Expected " << max_shares << " shares."; + } + } + // -- AutoRatchet: PPLNS-weighted desired-version tally (canonical) -- // Mirrors p2pool get_desired_version_counts (data.py:2651): walk `lookbehind` // shares back from share_hash and accumulate per-desired-version WORK weight @@ -714,6 +1794,230 @@ class ShareTracker } return weights; } + + // ─────────────────────────────────────────────────────────────────────── + // V36 merged-payout-hash machinery (s0 port from btc::ShareTracker SSOT) + // + // BCH is a STANDALONE SHA256d parent (oracle bitcoincash.py has NO merged / + // aux config), so at RUNTIME the share_check guard + // `!share.m_merged_payout_hash.IsNull()` keeps these bodies dead. They must + // still COMPILE because share_check.hpp std::visit-instantiates over every + // ShareVariant incl MergedMiningShare = BaseShare<36>. Ported structurally + // from src/impl/btc/share_tracker.hpp (SSOT carries the full v36 machinery + // because BTC is also a standalone v36 parent) and conformed to + // p2pool-merged-v36 p2pool/data.py. No SegWit (BCH has none); SHA256d + // coinbase/merkle preserved. The DOGE/NMC aux-payout diagnostic block of + // the btc donor is DROPPED here (no aux chains for a standalone parent). + // ─────────────────────────────────────────────────────────────────────── + + // -- merged_weights_delta (no-chain_id / v36 path) -- + // SSOT: btc::ShareTracker::merged_weights_delta (share_tracker.hpp:2311-2375). + // Oracle: MergedWeightsSkipList.get_delta (data.py:1864-1900) and + // get_v36_merged_weights (data.py invoked from compute_merged_payout_hash). + // Pre-V36 shares count toward window sizing (share_count=1) but contribute + // ZERO weight (p2pool returns (1, {}, 0, 0)). Default address key is the RAW + // parent script (share.new_script), hex-encoded by compute_merged_payout_hash. + // BCH carries no explicit merged_addresses / m_miner_merged_addr, so the + // per-chain (chain_id) resolution tiers of the SSOT are structurally absent + // here; the standalone-parent path only ever calls with chain_id == nullopt. + template + chain::WeightsDelta merged_weights_delta(ShareT* obj, std::optional chain_id) + { + (void)chain_id; // standalone parent: only the nullopt / v36 path is live + chain::WeightsDelta delta; + delta.share_count = 1; + if (obj->m_desired_version < 36) + return delta; // pre-V36: window-sizing only, zero weight (data.py:1870) + + auto target = chain::bits_to_target(obj->m_bits); + auto att = chain::target_to_average_attempts(target); + auto parent_script = get_share_script(obj); // raw scriptPubKey = new_script + if (parent_script.empty()) + return delta; + + // Default key: RAW parent script, matching p2pool address_key = + // share.new_script (data.py:1877). + delta.total_weight = att * 65535; + delta.total_donation_weight = att * static_cast(obj->m_donation); + delta.weights[parent_script] = att * static_cast(65535 - obj->m_donation); + return delta; + } + + // Per-hash wrapper: bounds-check the raw chain, dispatch to merged_weights_delta. + // SSOT: btc::ShareTracker::merged_weights_delta_for_hash (share_tracker.hpp:2378-2387). + chain::WeightsDelta merged_weights_delta_for_hash( + const uint256& hash, std::optional chain_id) + { + chain::WeightsDelta delta; + if (!chain.contains(hash)) return delta; + chain.get_share(hash).invoke([&](auto* obj) { + delta = merged_weights_delta(obj, chain_id); + }); + return delta; + } + + + // -- Merged mining: per-chain PPLNS weights -- + // SSOT: btc::ShareTracker::get_merged_cumulative_weights (share_tracker.hpp:2205-2274). + // For a specific aux chain_id, walk the share chain via the per-chain skip + // list and accumulate PPLNS weights for V36-signaling shares. Standalone + // BCH never receives a real chain_id (m_merged_coinbase_info empty); carried + // so verify_merged_coinbase_commitment instantiates/compiles. The btc + // [DOGE-PPLNS] per-address diagnostic block is DROPPED (no aux chains). + CumulativeWeights get_merged_cumulative_weights( + const uint256& start, int32_t max_shares, + const uint288& desired_weight, uint32_t target_chain_id) + { + if (start.IsNull()) + return {}; + + auto& sl = ensure_merged_skiplist(target_chain_id); + auto result = sl.query(start, max_shares, desired_weight); + return {std::move(result.weights), result.total_weight, result.total_donation_weight}; + } + + // SSOT: btc::ShareTracker::ensure_merged_skiplist (share_tracker.hpp:2815-2833). + // Lazily builds a per-chain_id WeightsSkipList whose delta fn dispatches to + // merged_weights_delta_for_hash with the chain_id (the per-chain path, which + // for BCH collapses to the default raw-script key since no merged_addresses). + chain::WeightsSkipList& ensure_merged_skiplist(uint32_t chain_id) + { + auto it = m_merged_skiplists.find(chain_id); + if (it != m_merged_skiplists.end()) + return it->second; + + auto [new_it, _] = m_merged_skiplists.emplace( + chain_id, + chain::WeightsSkipList( + [this, chain_id](const uint256& hash) -> chain::WeightsDelta { + return merged_weights_delta_for_hash(hash, chain_id); + }, + make_previous_fn() + ) + ); + return new_it->second; + } + + // -- compute_merged_payout_hash -- + // SSOT: btc::ShareTracker::compute_merged_payout_hash (share_tracker.hpp:2398-2573). + // Oracle: p2pool/data.py compute_merged_payout_hash (data.py:2782-2840). + // Deterministic hash of V36-only PPLNS weight distribution; committed into + // V36 shares so peers can verify merged payouts. Walks the RAW chain (not + // verified) matching p2pool (tracker, not tracker.verified). Format: + // "script_hex:weight|...|T:total|D:donation" -> SHA256d (bitcoin_data.hash256). + // Returns zero uint256 when no V36 shares in window / no share history. + uint256 compute_merged_payout_hash( + const uint256& prev_share_hash, const uint256& block_target) + { + (void)block_target; // V36 uses unlimited desired_weight (decay windows) + if (prev_share_hash.IsNull()) + return uint256{}; + + // RAW chain, matching p2pool compute_merged_payout_hash (data.py:2807). + if (!chain.contains(prev_share_hash)) + return uint256{}; + + auto height = chain.get_height(prev_share_hash); + if (height == 0) + return uint256{}; // data.py:2808-2809 + + // Unlimited desired_weight -- V36 exponential decay handles windowing + // (data.py:2814 uses 2**288 - 1). + uint288 unlimited_weight; + unlimited_weight.SetHex("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + // chain_length = min(height, REAL_CHAIN_LENGTH) (data.py:2812). + auto chain_len = std::min(static_cast(height), + static_cast(PoolConfig::real_chain_length())); + + // Walk RAW chain via WeightsSkipList; prev fn = raw chain tail (data.py:2807). + auto raw_prev_fn = [this](const uint256& hash) -> uint256 { + if (!chain.contains(hash)) return uint256{}; + return chain.get_index(hash)->tail; + }; + chain::WeightsSkipList raw_sl( + [this](const uint256& hash) -> chain::WeightsDelta { + return merged_weights_delta_for_hash(hash, std::nullopt); + }, + std::move(raw_prev_fn) + ); + auto result = raw_sl.query(prev_share_hash, chain_len, unlimited_weight); + auto weights = std::move(result.weights); + auto total_weight = result.total_weight; + auto donation_weight = result.total_donation_weight; + + if (weights.empty() || total_weight.IsNull()) + return uint256{}; // data.py:2818-2819 + + // Decimal stringify (Python '%d' formatting). + auto to_decimal = [](const uint288& val) -> std::string { + if (val.IsNull()) return "0"; + uint288 tmp = val; + std::string out; + while (!tmp.IsNull()) { + uint32_t rem = 0; + for (int i = uint288::WIDTH - 1; i >= 0; --i) { + uint64_t cur = (static_cast(rem) << 32) | tmp.pn[i]; + tmp.pn[i] = static_cast(cur / 10); + rem = static_cast(cur % 10); + } + out.push_back('0' + static_cast(rem)); + } + std::reverse(out.begin(), out.end()); + return out; + }; + + // Script bytes -> hex (p2pool key.encode('hex'), data.py:2830). + auto script_to_hex = [](const std::vector& script) -> std::string { + static const char digits[] = "0123456789abcdef"; + std::string hex; + hex.reserve(script.size() * 2); + for (unsigned char c : script) { + hex.push_back(digits[c >> 4]); + hex.push_back(digits[c & 0xf]); + } + return hex; + }; + + // Deterministic serialization: sorted by script hex (V36 consensus, + // data.py:2826-2833). + std::map sorted_by_script; + for (const auto& [script, w] : weights) + sorted_by_script[script_to_hex(script)] += w; + + std::string payload; + for (const auto& [script_hex, w] : sorted_by_script) + { + if (!payload.empty()) + payload.push_back('|'); + payload += script_hex; + payload.push_back(':'); + payload += to_decimal(w); + } + payload += "|T:"; + payload += to_decimal(total_weight); + payload += "|D:"; + payload += to_decimal(donation_weight); + + // SHA256d (bitcoin_data.hash256, data.py:2834). + auto span = std::span( + reinterpret_cast(payload.data()), payload.size()); + auto hash_result = Hash(span); + + { + static int32_t s_last_log_height = -1; + if (static_cast(height) != s_last_log_height) { + s_last_log_height = static_cast(height); + LOG_INFO << "[MERGED-PPLNS] height=" << height + << " chain_len=" << chain_len + << " addrs=" << sorted_by_script.size() + << " total_w=" << to_decimal(total_weight) + << " don_w=" << to_decimal(donation_weight) + << " hash=" << hash_result.GetHex(); + } + } + + return hash_result; + } }; } // namespace bch