diff --git a/.gitignore b/.gitignore index efb3430c4..459317b4f 100644 --- a/.gitignore +++ b/.gitignore @@ -212,3 +212,6 @@ external/**/relic_conf.h.in external/**/relic_conf.h.old # Doxygen output (e.g. external/dashbls/depends/mimalloc/docs/) external/**/depends/**/docs/ + +# Release artifact staging area (built binaries, not source) +release-staging/ diff --git a/src/c2pool/CMakeLists.txt b/src/c2pool/CMakeLists.txt index e8fa4123b..2d7ad277a 100644 --- a/src/c2pool/CMakeLists.txt +++ b/src/c2pool/CMakeLists.txt @@ -135,6 +135,25 @@ target_link_libraries(c2pool_enhanced ) target_link_libraries(c2pool_enhanced c2pool_hashrate pool) # OBJECT-lib SCC direct-naming (#22/#39) +# c2pool-btc: BTC SPV embedded p2pool — Phase B0 scaffold (stub entry). +# Library `btc` is a clone of `ltc` with namespace renamed and CMake targets +# adjusted; real entry-point port is in later B-phases. +# Links `ltc` transitively because core/web_server.cpp references LTC symbols +# (see comment at the bottom of this file). Untangling that is out of B0 scope. +add_executable(c2pool-btc main_btc.cpp) +target_compile_definitions(c2pool-btc PRIVATE C2POOL_VERSION="${C2POOL_GIT_VERSION}") +target_link_libraries(c2pool-btc + c2pool_node_enhanced + c2pool_payout + c2pool_merged_mining + core + ltc + btc + btc_stratum + nlohmann_json::nlohmann_json + ${Boost_LIBRARIES} +) + # Windows: Boost.Asio requires Winsock libraries; /bigobj for large translation units if(WIN32) target_link_libraries(c2pool ws2_32 mswsock bcrypt dbghelp) diff --git a/src/c2pool/main_btc.cpp b/src/c2pool/main_btc.cpp new file mode 100644 index 000000000..0470dd962 --- /dev/null +++ b/src/c2pool/main_btc.cpp @@ -0,0 +1,1208 @@ +// c2pool-btc — Bitcoin embedded SPV p2pool node. +// +// PR-B2-net (focused header-sync entry point). +// +// Wires `btc::coin::HeaderChain` to a single `btc::coin::Node` connection +// against a known bitcoind P2P endpoint. No sharechain, stratum, mempool, +// template builder, broadcaster, or web dashboard — those land in B3+. +// +// This main is small on purpose: it's the smoke-test target for verifying +// that the BTC port (jtoomim/SPB v35 + protocol 3502 + SHA256d PoW + BTC +// genesis + DAA) actually handshakes with bitcoind, sends getheaders, +// receives the response, and ingests headers into HeaderChain. +// +// Usage: +// c2pool-btc --bitcoind HOST:PORT [--testnet | --testnet4] +// +// Examples: +// c2pool-btc --testnet4 --bitcoind 127.0.0.1:48333 +// c2pool-btc --testnet --bitcoind 127.0.0.1:18333 +// c2pool-btc --bitcoind 127.0.0.1:8333 +// +// Reference port from src/c2pool/c2pool_refactored.cpp lines 1500-1900 +// (LTC's HeaderChain + EmbeddedCoinNode wiring), pruned to a single-peer +// non-broadcaster shape suitable for B2-net smoke testing. + +#include +#include +#include +#include +#include +#include +#include +#include +#include // RefHashParams + compute_ref_hash_for_work +#include // get_v35_expected_payouts +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include // [MEM] periodic logger reads /proc/self/status +#include // [MEM] periodic malloc_trim(0) bounds glibc pool fragmentation +#include +#include +#include +#include +#include +#include +#include +#include + +namespace io = boost::asio; + +static void print_usage() +{ + std::cerr << + "Usage: c2pool-btc [--testnet | --testnet4] --bitcoind HOST:PORT\n" + " [--p2pool HOST:PORT]\n" + "\n" + " --testnet BTC testnet3 chain (genesis 000000000933ea01...)\n" + " --testnet4 BTC testnet4 chain (genesis 00000000da84f2ba...)\n" + " default: mainnet\n" + " --bitcoind H:P bitcoind P2P endpoint host:port\n" + " e.g. 127.0.0.1:8333 (mainnet)\n" + " 127.0.0.1:18333 (testnet3)\n" + " 127.0.0.1:48333 (testnet4)\n" + " --p2pool H:P BTC p2pool peer (jtoomim/SPB v35 + protocol 3502)\n" + " e.g. p2p-spb.xyz:9333\n" + " --stratum [H:]P stratum TCP listener for miners (B4-stratum)\n" + " e.g. --stratum 9332 (binds 0.0.0.0:9332)\n" + " --stratum 127.0.0.1:9332 (loopback only)\n" + " Omit to disable stratum listener.\n"; +} + +/// BTC wire-protocol magic bytes per network (pchMessageStart). +/// Source: ref/bitcoin/src/kernel/chainparams.cpp. +static std::vector btc_magic_bytes(bool testnet, bool testnet4) +{ + std::string hex; + if (testnet4) hex = "1c163f28"; // testnet4 (line 335-338) + else if (testnet) hex = "0b110907"; // testnet3 (line 235-238) + else hex = "f9beb4d9"; // mainnet (line 117-120) + return ParseHexBytes(hex); +} + +int main(int argc, char* argv[]) +{ + core::log::Logger::init(); + + bool testnet = false; + bool testnet4 = false; + std::string bitcoind_host; + uint16_t bitcoind_port = 0; + std::string p2pool_host; + uint16_t p2pool_port = 0; + std::string stratum_addr = "0.0.0.0"; // listen all interfaces by default + uint16_t stratum_port = 0; // 0 disables stratum; --stratum sets it + + for (int i = 1; i < argc; ++i) + { + std::string arg = argv[i]; + if (arg == "-h" || arg == "--help") + { + print_usage(); + return 0; + } + else if (arg == "--testnet") + { + testnet = true; + } + else if (arg == "--testnet4") + { + testnet = true; + testnet4 = true; + } + else if (arg == "--bitcoind" && i + 1 < argc) + { + std::string ep = argv[++i]; + auto colon = ep.find(':'); + if (colon == std::string::npos) + { + std::cerr << "--bitcoind requires HOST:PORT\n"; + return 1; + } + bitcoind_host = ep.substr(0, colon); + bitcoind_port = static_cast(std::stoi(ep.substr(colon + 1))); + } + else if (arg == "--p2pool" && i + 1 < argc) + { + std::string ep = argv[++i]; + auto colon = ep.find(':'); + if (colon == std::string::npos) + { + std::cerr << "--p2pool requires HOST:PORT\n"; + return 1; + } + p2pool_host = ep.substr(0, colon); + p2pool_port = static_cast(std::stoi(ep.substr(colon + 1))); + } + else if (arg == "--stratum" && i + 1 < argc) + { + // --stratum [HOST:]PORT — bind a stratum TCP listener for miners. + // HOST defaults to 0.0.0.0 (all interfaces). When omitted entirely, + // stratum is disabled. + std::string ep = argv[++i]; + auto colon = ep.find(':'); + if (colon == std::string::npos) { + stratum_port = static_cast(std::stoi(ep)); + } else { + stratum_addr = ep.substr(0, colon); + stratum_port = static_cast(std::stoi(ep.substr(colon + 1))); + } + } + else + { + std::cerr << "unknown arg: " << arg << "\n"; + print_usage(); + return 1; + } + } + + if (bitcoind_host.empty() || bitcoind_port == 0) + { + print_usage(); + return 1; + } + + btc::PoolConfig::is_testnet = testnet; + + auto chain_params = testnet4 + ? btc::coin::BTCChainParams::testnet4() + : (testnet ? btc::coin::BTCChainParams::testnet() + : btc::coin::BTCChainParams::mainnet()); + + const std::string net_subdir = testnet4 ? "bitcoin_testnet4" + : (testnet ? "bitcoin_testnet" + : "bitcoin"); + + const std::filesystem::path net_dir = core::filesystem::config_path() / net_subdir; + std::error_code ec; + std::filesystem::create_directories(net_dir, ec); // best effort + + const std::string chain_db_path = (net_dir / "embedded_headers").string(); + const std::string utxo_db_path = (net_dir / "utxo_view_db").string(); + + LOG_INFO << "[BTC] c2pool-btc starting — net=" + << (testnet4 ? "testnet4" : (testnet ? "testnet3" : "mainnet")); + LOG_INFO << "[BTC] HeaderChain DB: " << chain_db_path; + LOG_INFO << "[BTC] UTXO DB: " << utxo_db_path; + LOG_INFO << "[BTC] Genesis: " << chain_params.genesis_hash.GetHex(); + LOG_INFO << "[BTC] bitcoind P2P: " << bitcoind_host << ":" << bitcoind_port; + + btc::coin::HeaderChain header_chain(chain_params, chain_db_path); + if (!header_chain.init()) + { + LOG_WARNING << "[BTC] HeaderChain init failed — running in-memory only"; + } + else + { + LOG_INFO << "[BTC] HeaderChain initialized: size=" << header_chain.size() + << " height=" << header_chain.height(); + } + + // BTC reuses LTC_LIMITS — both chains share max_money≤2.1e15<8.4e15 (so + // LTC's bound never falsely rejects a BTC value) and 100-block coinbase + // maturity. pegout_maturity=6 is moot for BTC (no MWEB → no pegouts). + // KEEP_DEPTH=288 matches Bitcoin Core MIN_BLOCKS_TO_KEEP exactly. + core::coin::UTXOViewDB utxo_db(utxo_db_path); + if (!utxo_db.open()) + { + LOG_WARNING << "[BTC] UTXOViewDB open failed — running without UTXO persistence"; + } + core::coin::UTXOViewCache utxo_cache(&utxo_db); + LOG_INFO << "[BTC] UTXO loaded: best_height=" << utxo_cache.get_best_height() + << " best_block=" << utxo_cache.get_best_block().GetHex().substr(0, 16); + constexpr uint32_t BTC_KEEP_DEPTH = core::coin::LTC_MIN_BLOCKS_TO_KEEP; + + io::io_context ioc; + + // ── Graceful shutdown via boost::asio::signal_set ───────────────────── + // + // Why not std::signal? Two reasons: + // 1. std::signal handlers run in the signal-delivery context, which + // is async-signal-safe-only. boost::asio::io_context::stop is + // thread-safe but not documented as signal-safe — calling it from + // a signal handler is undefined-behaviour-adjacent. + // 2. Even if we get past UB, the queued asio handlers (pending reads, + // timer callbacks) hold shared_ptrs to sessions; stopping ioc + // drops the queue but leaves those captures alive until ioc itself + // is destroyed — by which time other subsystems have started their + // own RAII teardown, producing destruction-order races. + // + // signal_set runs its handler on the io_context thread, in an ordinary + // async callback. We use it to drive an EXPLICIT graceful shutdown: + // stop the stratum acceptor + close every active session FIRST (so + // their pending async ops are cancelled cleanly via cancel_timers), + // THEN call ioc.stop() to drain the rest. By the time ioc is destroyed + // at end-of-main, all async operations have completed via cancellation + // and there are no stale captures referencing torn-down state. + // + // The shutdown lambda captures by reference variables that are + // declared further down in this function (stratum_server, work_source, + // etc.) — those references resolve at *invocation* time (i.e., when + // SIGINT/SIGTERM arrives), by which point the variables exist. If a + // signal arrives before the variables are constructed (e.g., during + // HeaderChain::init), the signal handler will see them as nullptr/ + // empty and the `if` guards below short-circuit safely. + std::shared_ptr work_source_for_shutdown; // populated later + std::unique_ptr stratum_server_for_shutdown; // populated later + bool shutdown_initiated = false; + + io::signal_set signals(ioc, SIGINT, SIGTERM); + signals.async_wait( + [&ioc, &stratum_server_for_shutdown, &shutdown_initiated] + (const boost::system::error_code& ec, int signo) { + if (ec) return; + if (shutdown_initiated) return; + shutdown_initiated = true; + + LOG_INFO << "[BTC] received signal " << signo + << " — initiating graceful shutdown"; + + // 1) Stop stratum BEFORE ioc.stop() so sessions close cleanly: + // StratumServer::stop() cancels the acceptor + iterates + // session set, calling shutdown() on each (cancels the + // work_push_timer + closes the socket). The pending + // async_read_some on each session then fails with + // operation_aborted, the read-error path runs, the session + // self-removes from the workers map. By the time we call + // ioc.stop(), no session-bound async op is left. + if (stratum_server_for_shutdown) { + stratum_server_for_shutdown->stop(); + } + + // 2) Stop the io_context. Other subsystems (sharechain peer + // btc::Node, bitcoind P2P btc::coin::Node) handle their own + // teardown via RAII when main() exits. They don't have + // queued state that depends on this ioc (their timers/ + // sockets are owned by THEIR objects, destroyed in reverse + // construction order before ioc itself). + ioc.stop(); + }); + + // btc::Config = core::Config — composite holds + // both the c2pool sharechain identity (PoolConfig: prefix/identifier + // from B1) AND the bitcoind wire-protocol identity (CoinConfig::m_p2p: + // BTC magic bytes per network). NodeP2P reads m_config->coin()->m_p2p.prefix + // to frame outbound bitcoind messages — getting this wrong = peer + // disconnect. + btc::Config config(net_subdir); + // Skip Config::init() — it would try to load pool.yaml + coin.yaml + // from disk; for B2-net smoke we set fields directly from chainparams. + config.coin()->m_p2p.prefix = btc_magic_bytes(testnet, testnet4); + config.coin()->m_p2p.address = NetService(bitcoind_host, bitcoind_port); + config.coin()->m_testnet = testnet; + config.coin()->m_symbol = "BTC"; + + btc::coin::Node coin_node(&ioc, &config); + + // Constants for getheaders driver: protocol version sent in the message + // (matches what we advertised in version handshake — see B1 coin/p2p_node.hpp). + constexpr uint32_t BTC_PROTOCOL_VERSION = 70016; + + // Hash a BlockHeaderType into its canonical block-id (SHA256d of the + // 80-byte serialized header). BTC unifies pow_hash and block_hash via + // SHA256d; LTC distinguishes them (scrypt vs SHA256d). Used to compute + // the locator for the next getheaders. + auto header_block_hash = [](const btc::coin::BlockHeaderType& hdr) { + auto packed = pack(hdr); + return Hash(packed.get_span()); + }; + + // ───────────────────────────────────────────────────────────────────────── + // B5: P2P submit + roundtrip tracking infrastructure. + // + // submit_block() is the public entry point for future found-block + // producers (stratum, debug hook, etc.). It broadcasts the block to + // bitcoind via MSG_BLOCK and records the hash in pending_submits. + // + // Confirmation strategy: bitcoind does NOT echo MSG_BLOCK back to its + // sender (it tracks per-peer m_inv_known_blocks), so on_full_block won't + // fire for our own submission. Instead we observe arrival in HeaderChain + // via new_headers — bitcoind announces the new tip via headers relay, + // which our locator-driven getheaders captures naturally. A 30s scan + // timer warns on submits unconfirmed for >60s — typically a sign of + // bitcoind rejecting (consensus failure) or mempool/network hiccup. + // + // shared_ptr captures: pending state outlives any single callback; the + // new_headers subscriber, the warn timer, and submit_block all need it. + // ───────────────────────────────────────────────────────────────────────── + struct PendingSubmit { + std::chrono::steady_clock::time_point submitted_at; + uint32_t height; + }; + auto pending_mu = std::make_shared(); + // std::map (not unordered_map): uint256 has operator< from base_uint::CompareTo + // but no std::hash specialization in this codebase. + auto pending_submits = std::make_shared>(); + + [[maybe_unused]] + auto submit_block = [&coin_node, pending_mu, pending_submits] + (btc::coin::BlockType& block, uint32_t height) + { + auto packed_hdr = pack(static_cast(block)); + uint256 block_hash = Hash(packed_hdr.get_span()); + { + std::lock_guard lk(*pending_mu); + (*pending_submits)[block_hash] = { std::chrono::steady_clock::now(), height }; + } + LOG_INFO << "[BTC-SUBMIT] sending block " << block_hash.GetHex().substr(0, 16) + << " height=" << height; + coin_node.submit_block_p2p(block); + }; + + // Forward bitcoind's headers batches into HeaderChain AND chain the + // getheaders locator forward to drive sync to peer's tip. LTC dispatches + // this off-thread (scrypt CPU cost); BTC's PoW is SHA256d so inline is + // fine for testnet (and for mainnet IBD too, given SHA256d is ~us/header). + coin_node.new_headers.subscribe( + [&header_chain, &coin_node, header_block_hash, &chain_params, + pending_mu, pending_submits] + (const std::vector& headers) + { + if (headers.empty()) return; + int accepted = header_chain.add_headers(headers); + uint256 last_hash = header_block_hash(headers.back()); + LOG_INFO << "[BTC] new_headers: received=" << headers.size() + << " accepted=" << accepted + << " chain_height=" << header_chain.height() + << " last=" << last_hash.GetHex().substr(0, 16); + + // B5 roundtrip detection: any header in this batch matching a + // pending submit means bitcoind accepted our block and built on it. + if (!pending_submits->empty()) { + std::lock_guard lk(*pending_mu); + for (const auto& hdr : headers) { + uint256 h = header_block_hash(hdr); + auto it = pending_submits->find(h); + if (it != pending_submits->end()) { + auto age_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - it->second.submitted_at).count(); + LOG_INFO << "[BTC-SUBMIT] roundtrip CONFIRMED: block " + << h.GetHex().substr(0, 16) + << " height=" << it->second.height + << " latency=" << age_ms << "ms"; + pending_submits->erase(it); + } + } + } + + // Continue header sync if peer likely has more (full 2000-header + // batch); otherwise we're caught up and bitcoind will push new + // tips via inv/sendheaders. + if (headers.size() >= 2000) { + coin_node.send_getheaders( + BTC_PROTOCOL_VERSION, {last_hash}, uint256::ZERO); + } else { + LOG_INFO << "[BTC] Header sync caught up (last batch=" << headers.size() + << " < 2000). Waiting on inv announcements."; + } + (void)chain_params; // captured for genesis fallback if needed later + }); + + coin_node.new_block.subscribe( + [](const uint256& block_hash) + { + LOG_INFO << "[BTC] new_block: " << block_hash.GetHex().substr(0, 32) << "..."; + }); + + // BTC txid: SHA256d of the non-witness serialization (BIP 144). Witness + // bytes change wtxid only — txid stays stable. UTXO indexing must use + // txid since vin.prevout.hash references parents by txid. + auto btc_txid = [](const btc::coin::MutableTransaction& tx) { + auto packed = pack(btc::coin::TX_NO_WITNESS(tx)); + return Hash(packed.get_span()); + }; + + // Subscribe to full_block events to maintain UTXO. The p2p_node + // auto-requests every inv'd block (request_full_block uses + // MSG_WITNESS_BLOCK 0x40000002) so witness data arrives intact — + // txid (non-witness) is what UTXO keys on, but the wire path needs + // witness or peer drops us for advertising NODE_WITNESS yet not honoring it. + coin_node.full_block.subscribe( + [&header_chain, &utxo_cache, &utxo_db, btc_txid] + (const btc::coin::BlockType& block) + { + auto packed_hdr = pack(static_cast(block)); + uint256 block_hash = Hash(packed_hdr.get_span()); + + auto entry = header_chain.get_header(block_hash); + if (!entry) { + LOG_WARNING << "[BTC] full_block: header unknown for " + << block_hash.GetHex().substr(0, 16) + << " — dropping (header sync lagging?)"; + return; + } + uint32_t height = entry->height; + + // Skip already-processed heights (warm restart, duplicate inv, + // or replayed block). UTXO is monotonic on this single-peer path. + if (height <= utxo_cache.get_best_height()) { + LOG_INFO << "[BTC] full_block: skip duplicate h=" << height + << " best_height=" << utxo_cache.get_best_height(); + return; + } + + try { + auto undo = utxo_cache.connect_block(block, height, btc_txid); + utxo_db.put_block_undo(height, undo); + utxo_cache.flush(block_hash, height); + utxo_cache.prune_undo(height, BTC_KEEP_DEPTH); + + LOG_INFO << "[BTC] UTXO connect: h=" << height + << " txs=" << block.m_txs.size() + << " undo_added=" << undo.added_outpoints.size() + << " undo_spent=" << undo.tx_undos.size() + << " best_height=" << utxo_cache.get_best_height(); + } catch (const std::exception& e) { + LOG_WARNING << "[BTC] UTXO connect_block failed h=" << height + << " hash=" << block_hash.GetHex().substr(0, 16) + << ": " << e.what(); + } + }); + + LOG_INFO << "[BTC] Connecting to bitcoind..."; + coin_node.start_p2p(NetService(bitcoind_host, bitcoind_port)); + + // Drive initial header sync. Per BTC protocol, NodeP2P's verack handler + // sends sendheaders/sendcmpct/feefilter but NOT getheaders — header sync + // is the consumer's responsibility (LTC drives this from the broadcaster). + // Wait 3s for handshake to complete, then send getheaders([genesis], 0) + // to start streaming headers from genesis. The new_headers callback above + // chains the locator forward for the next batch. + boost::asio::steady_timer initial_getheaders(ioc); + initial_getheaders.expires_after(std::chrono::seconds(3)); + initial_getheaders.async_wait( + [&coin_node, &header_chain, &chain_params, header_block_hash] + (const boost::system::error_code& ec) + { + if (ec) return; + if (!coin_node.has_p2p()) { + LOG_WARNING << "[BTC] initial getheaders: no P2P connection yet"; + return; + } + if (!coin_node.is_handshake_complete()) { + LOG_WARNING << "[BTC] initial getheaders: handshake not complete (peer slow?)"; + // Fire anyway — at worst the peer ignores and we retry later. + } + // Locator: if HeaderChain has any tip, use its hash; else genesis. + // For a fresh DB the chain is empty so locator = [genesis]. + uint256 locator; + if (auto tip = header_chain.tip(); tip) + locator = tip->block_hash; + else + locator = chain_params.genesis_hash; + LOG_INFO << "[BTC] Sending initial getheaders, locator=" + << locator.GetHex().substr(0, 16) + << " (chain_height=" << header_chain.height() << ")"; + coin_node.send_getheaders( + BTC_PROTOCOL_VERSION, {locator}, uint256::ZERO); + }); + + // B5: stale-submit warning timer. Every 30s, scan pending_submits for + // entries older than 60s and log [BTC-SUBMIT] STALE — typically means + // bitcoind rejected the block (consensus failure / dup hash / wrong + // chain) since an accepted submission should appear in HeaderChain + // within seconds. The recursive scheduler uses weak_ptr to itself to + // avoid a self-referencing shared_ptr cycle. + auto warn_timer = std::make_shared(ioc); + auto schedule_warn = std::make_shared>(); + std::weak_ptr> weak_warn = schedule_warn; + *schedule_warn = [warn_timer, pending_mu, pending_submits, weak_warn]() { + warn_timer->expires_after(std::chrono::seconds(30)); + warn_timer->async_wait([warn_timer, pending_mu, pending_submits, weak_warn] + (const boost::system::error_code& ec) { + if (ec) return; + { + std::lock_guard lk(*pending_mu); + auto now = std::chrono::steady_clock::now(); + for (auto it = pending_submits->begin(); it != pending_submits->end(); ) { + auto age_s = std::chrono::duration_cast( + now - it->second.submitted_at).count(); + if (age_s >= 60) { + LOG_WARNING << "[BTC-SUBMIT] STALE: block " + << it->first.GetHex().substr(0, 16) + << " height=" << it->second.height + << " pending " << age_s + << "s — bitcoind likely rejected"; + it = pending_submits->erase(it); + } else { + ++it; + } + } + } + if (auto self = weak_warn.lock()) (*self)(); + }); + }; + (*schedule_warn)(); + + // [MEM] periodic memory logger: every 60s, log VmRSS / VmSize from + // /proc/self/status. Catches slow leaks invisible from in-process + // state alone — boost::asio handler queues, leveldb caches, fragmented + // allocator pools, etc. Lightweight (~50µs per fire). Pair with a + // MemoryMax cgroup cap so any runaway is caught at the systemd layer. + // Other component sizes (mempool, chain) are already logged elsewhere + // by their own subsystems; this timer adds the OS-level RSS/VSZ that + // was previously visible only via external `ps` polling. + auto mem_timer = std::make_shared(ioc); + auto schedule_mem = std::make_shared>(); + std::weak_ptr> weak_mem = schedule_mem; + *schedule_mem = [mem_timer, weak_mem]() { + mem_timer->expires_after(std::chrono::seconds(60)); + mem_timer->async_wait([mem_timer, weak_mem] + (const boost::system::error_code& ec) { + if (ec) return; + long vm_rss_kb = 0, vm_size_kb = 0, vm_data_kb = 0; + if (FILE* f = std::fopen("/proc/self/status", "r")) { + char line[256]; + while (std::fgets(line, sizeof(line), f)) { + long v; + if (std::sscanf(line, "VmRSS: %ld kB", &v) == 1) { vm_rss_kb = v; continue; } + if (std::sscanf(line, "VmSize: %ld kB", &v) == 1) { vm_size_kb = v; continue; } + if (std::sscanf(line, "VmData: %ld kB", &v) == 1) { vm_data_kb = v; continue; } + } + std::fclose(f); + } + // Hand glibc-resident-but-freed pages back to the kernel. Heaptrack + // v4 (post Phase 1A+1B+1C) showed peak heap at 182 MB but RSS still + // grew ~140 MB/h — confirmed glibc malloc-pool fragmentation, not a + // true heap leak. malloc_trim(0) walks the top chunk of every arena + // and madvise(MADV_DONTNEED)s pages that are free. Cheap (~ms) on + // typical heap sizes, returns 1 if it actually released memory. + int trimmed = ::malloc_trim(0); + LOG_INFO << "[MEM] vmrss=" << vm_rss_kb << "kB" + << " vmsize=" << vm_size_kb << "kB" + << " vmdata=" << vm_data_kb << "kB" + << " malloc_trim=" << trimmed; + if (auto self = weak_mem.lock()) (*self)(); + }); + }; + (*schedule_mem)(); + + // ── B4-net: c2pool sharechain peer ─────────────────────────────────── + // pool::NodeBridge from src/impl/btc/node.{hpp,cpp}. + // Speaks the jtoomim/SPB BTC p2pool wire protocol (proto 3502, share v35 + // PaddingBugfixShare). NodeImpl ctor opens ~/.c2pool//sharechain_leveldb + // for share persistence and seeds the addr store from m_bootstrap_addrs; + // we set those + m_prefix BEFORE constructing the node. + config.pool()->m_prefix = ParseHexBytes(btc::PoolConfig::prefix_hex()); + config.m_testnet = testnet; + + if (!p2pool_host.empty() && p2pool_port != 0) + { + // Single explicit target — clear defaults so we dial only the named peer. + config.pool()->m_bootstrap_addrs.clear(); + config.pool()->m_bootstrap_addrs.emplace_back(p2pool_host, p2pool_port); + + // AddrStore ctor reads addrs.json from disk if present, MERGING any + // saved-from-prior-runs peers with our bootstrap_addrs. Saved peers + // can outrank our just-added explicit target (get_good_peers scores + // by first_seen/last_seen). When --p2pool is given the user has + // explicitly chosen this peer — reset addr store so it actually wins. + const auto addrs_path = net_dir / "addrs.json"; + std::error_code rm_ec; + std::filesystem::remove(addrs_path, rm_ec); // best-effort + + LOG_INFO << "[BTC] Sharechain bootstrap: explicit --p2pool " + << p2pool_host << ":" << p2pool_port + << " (addrs.json reset to enforce exclusive target)"; + } + else + { + // Default seed list (PoolConfig::DEFAULT_BOOTSTRAP_HOSTS, port 9333). + for (const auto& host : btc::PoolConfig::DEFAULT_BOOTSTRAP_HOSTS) + { + // Some entries already include ":port" — preserve, else append. + std::string addr = host.find(':') == std::string::npos + ? host + ":" + std::to_string(btc::PoolConfig::P2P_PORT) + : host; + config.pool()->m_bootstrap_addrs.emplace_back(addr); + } + LOG_INFO << "[BTC] Sharechain bootstrap: " + << config.pool()->m_bootstrap_addrs.size() + << " default seeds"; + } + + auto p2p_node = std::make_unique(&ioc, &config); + p2p_node->set_target_outbound_peers(p2pool_host.empty() ? 4 : 1); + p2p_node->core::Server::listen(btc::PoolConfig::P2P_PORT); + LOG_INFO << "[BTC] Sharechain peer listening on port " + << btc::PoolConfig::P2P_PORT + << " — proto adv=" << btc::PoolConfig::ADVERTISED_PROTOCOL_VERSION + << " min=" << btc::PoolConfig::MINIMUM_PROTOCOL_VERSION + << " share=v35 prefix=" << btc::PoolConfig::prefix_hex(); + p2p_node->start_outbound_connections(); + LOG_INFO << "[BTC] Outbound peer dialing started (" + << config.pool()->m_bootstrap_addrs.size() << " bootstrap addrs)"; + + // ── B7-stratum: stratum server + BTCWorkSource (miner-facing TCP) ──── + // + // Mempool is constructed unwired for the MVP — TemplateBuilder still + // produces valid coinbase-only templates from chain_.tip() + subsidy. + // Wiring bitcoind P2P inv_tx → mempool.add_tx is a follow-up phase + // (current LTC integration uses RPC getrawmempool which we deliberately + // don't have in embedded mode). + btc::coin::Mempool mempool; + mempool.set_utxo(&utxo_cache); + + // ── Mempool wiring (Phase 10c) ──────────────────────────────────────── + // + // Feed the local mempool from bitcoind's P2P inv stream so TemplateBuilder + // produces real fee-bearing templates instead of coinbase-only ones. + // bitcoind announces new mempool txs via `inv` of MSG_TX, NodeP2P + // requests them via getdata, and on receipt fires coin_node.new_tx with + // the parsed Transaction. We add to mempool; a later [BTC] full_block + // subscriber removes confirmed txs. + // + // Mempool::add_tx without UTXO context cannot compute the fee — fees + // start at sentinel UNKNOWN. Subsequent block connects bring new + // UTXOs into view, so fees of previously-unknown txs become resolvable. + // The full_block hook below drives this revalidation each tip change. + coin_node.new_tx.subscribe( + [&mempool, &utxo_cache](const btc::coin::Transaction& tx) { + // The Transaction → MutableTransaction round-trip is required + // because Mempool::add_tx takes the mutable variant (matches the + // pattern used by btc::coin::TemplateBuilder). + btc::coin::MutableTransaction mtx(tx); + (void)mempool.add_tx(mtx, &utxo_cache); + }); + + // remove_for_block on every full block we receive — connecting txs are + // no longer mempool-eligible. Keeps the mempool honest after each new + // tip without us having to track individual confirmations. + // + // After remove, also drive two UTXO-dependent maintenance passes: + // 1. revalidate_inputs: evict txs whose inputs the new block spent + // out from under us (catches the case where remove_for_block's + // conflict detection didn't see the spend — e.g., the spending + // tx wasn't tracked in m_spent_outputs, parent-of-CPFP, etc). + // 2. recompute_unknown_fees: re-attempt fee computation for txs + // that came in via new_tx before their inputs were visible. + // Without this, txs with unresolved inputs stay fee=? forever + // and are skipped by TemplateBuilder's fee-sorted include path + // → blocks miss legitimate fees that were just one tip behind. + // + // Subscriber-call order: this lambda registers AFTER the UTXO + // connect_block subscriber at line 434, so by the time we run the + // UTXO has the new block already applied. revalidate_inputs and + // recompute_unknown_fees both operate on the post-connect snapshot. + coin_node.full_block.subscribe( + [&mempool, &utxo_cache](const btc::coin::BlockType& block) { + mempool.remove_for_block(block); + int evicted = mempool.revalidate_inputs(&utxo_cache); + int resolved = mempool.recompute_unknown_fees(&utxo_cache); + if (evicted > 0 || resolved > 0) { + LOG_INFO << "[EMB] post-tip mempool maintenance: evicted=" + << evicted << " resolved_fees=" << resolved; + } + }); + + // submit_block_fn: bridges BTCWorkSource → coin_node.submit_block_p2p_raw + // + adds to B5's pending_submits map for roundtrip tracking. Lambda + // captures by reference so it reuses the existing B5 infrastructure + // instead of duplicating it. + auto stratum_submit_fn = [&coin_node, pending_mu, pending_submits] + (const std::vector& block_bytes, uint32_t height) + { + // Compute block_hash for pending_submits tracking. BTC block_hash = + // SHA256d of the first 80 bytes (the header). + if (block_bytes.size() < 80) { + LOG_WARNING << "[BTC-STRATUM-BLOCK] block bytes too short (" + << block_bytes.size() << " < 80) — not submitting"; + return; + } + uint256 block_hash = Hash(std::span(block_bytes.data(), 80)); + { + std::lock_guard lk(*pending_mu); + (*pending_submits)[block_hash] = { + std::chrono::steady_clock::now(), height + }; + } + LOG_INFO << "[BTC-SUBMIT] sending block " << block_hash.GetHex().substr(0, 16) + << " height=" << height << " (via stratum)"; + coin_node.submit_block_p2p_raw(block_bytes); + }; + + // Construct the work source. Holds non-owning refs to chain + mempool; + // both outlive it (stack-scoped main() lifetime). + auto work_source = std::make_shared( + header_chain, mempool, testnet, std::move(stratum_submit_fn)); + work_source_for_shutdown = work_source; // expose to signal handler + + // ── PPLNS + ref_hash callbacks (Phase 8d) ──────────────────────────── + // + // These wire the BTCWorkSource's coinbase builder to the live BTC + // sharechain via btc::ShareTracker. The pplns lambda is the real + // deal — calls get_v35_expected_payouts under read_tracker() guard. + // The ref_hash lambda is a SOPHISTICATED STUB: it calls + // compute_ref_hash_for_work with sane defaults for tracker-derived + // fields (absheight, abswork, far_share_hash). Until those are + // properly walked from the tracker, the resulting ref_hash will not + // match what live SPB peers expect — c2pool-btc-built shares get + // produced locally but won't be accepted by the wider sharechain. + // That's the next concrete TODO; the wiring + payouts are now real. + + auto* p2p_node_raw = p2p_node.get(); // captured by reference into lambdas + + // Wire best-share lookup: BTCWorkSource asks via this fn to determine + // prev_share_hash for new jobs. Returns the sharechain tip the local + // node has built up to. Empty (uint256::ZERO) on cold start; the + // ref_hash callback then falls into its genesis branch — that ref_hash + // won't match peers, but it's the right answer pre-bootstrap. + work_source->set_best_share_hash_fn( + [p2p_node_raw]() -> uint256 { + if (!p2p_node_raw) return uint256::ZERO; + return p2p_node_raw->best_share_hash(); + }); + + work_source->set_donation_script( + btc::PoolConfig::get_donation_script(/*share_version*/ 35)); + + work_source->set_pplns_fn( + [p2p_node_raw](const uint256& best_share_hash, + const uint256& block_target, + uint64_t subsidy, + const std::vector& donation_script) + -> std::map, double> + { + if (!p2p_node_raw) return {}; + auto guard = p2p_node_raw->read_tracker(); + if (!guard) { + // Tracker busy with compute thread — return empty so the + // coinbase falls back to single-output mode for THIS work + // refresh. Next refresh will likely succeed. + return {}; + } + try { + return guard->get_v35_expected_payouts( + best_share_hash, block_target, subsidy, donation_script); + } catch (const std::exception& e) { + LOG_WARNING << "[BTC-STRATUM] get_v35_expected_payouts threw: " << e.what(); + return {}; + } + }); + + work_source->set_ref_hash_fn( + [p2p_node_raw](const uint256& prev_share_hash, + const std::vector& scriptSig, + const std::vector& payout_script, + uint64_t subsidy, uint32_t block_bits, uint32_t timestamp) + -> core::stratum::RefHashResult + { + // Phase 12 contract: produce both the ref_hash AND the full + // chain-walked snapshot needed to populate snap.frozen_ref — + // bits / max_bits (from tracker.compute_share_target), + // absheight / abswork / far_share_hash (from prev walk), + // timestamp_clipped (>= prev->m_timestamp + 1). + // + // The block_bits parameter is the BTC mainnet GBT block target; + // we feed it as desired_target into compute_share_target to + // get the actual sharechain target the network is currently + // running. Pre-Phase-12 we used `bits` (block bits) directly + // as p.bits — that produced ref_hashes peers couldn't verify + // (ref_hash includes share_bits, not block_bits). + core::stratum::RefHashResult result; + result.share_version = 35; + result.desired_version = 35; + result.timestamp = timestamp; // overwritten below if clipped + + btc::RefHashParams p; + p.share_version = 35; + p.prev_share = prev_share_hash; + p.coinbase_scriptSig = scriptSig; + p.share_nonce = 0; // matches LTC line 4281 + p.subsidy = subsidy; + p.donation = 50; // 0.5% (matches finder fee) + p.stale_info = 0; + p.desired_version = 35; + p.has_segwit = false; // TODO Phase 8c+: detect from rules + p.timestamp = timestamp; + // p.bits / p.max_bits set below from compute_share_target + + // Heuristic v35 pubkey extract: P2PKH is 0x76 0xa9 0x14 + 20B + 0x88 0xac. + if (payout_script.size() == 25 && payout_script[0] == 0x76 && + payout_script[1] == 0xa9 && payout_script[2] == 0x14 && + payout_script[23] == 0x88 && payout_script[24] == 0xac) + { + std::memcpy(p.pubkey_hash.begin(), payout_script.data() + 3, 20); + p.pubkey_type = 0; + } + // Bech32 P2WSH/P2WPKH: leave pubkey_hash zeroed for now (TODO). + + // Defaults if tracker access fails — fall back to block bits so + // we still emit *some* ref_hash. These won't validate against + // peers but at least produce a well-formed coinbase OP_RETURN. + auto set_block_bits_fallback = [&] { + p.bits = block_bits; + p.max_bits = block_bits; + result.bits = block_bits; + result.max_bits = block_bits; + }; + + // ── Walk the share tracker for chain-position fields + share_target ── + // + // All values below are deterministically derived from the share + // chain — every node walking the same chain MUST produce the + // same values, otherwise ref_hash diverges and peers reject. + if (p2p_node_raw) { + auto guard = p2p_node_raw->read_tracker(); + if (guard) { + auto& tracker = *guard; + + // Step 1 (must run before compute_share_target): clip + // timestamp + walk for absheight + far_share_hash. + // create_local_share_v35 (share_check.hpp:2126-2134) + // clips share.m_timestamp BEFORE calling compute_share + // _target with that clipped value — we must match that + // ordering so the (max_bits, bits) we report equal the + // values peers will compute on the same prev_share. + if (!prev_share_hash.IsNull() && tracker.chain.contains(prev_share_hash)) { + tracker.chain.get(prev_share_hash).share.invoke([&](auto* prev) { + p.absheight = prev->m_absheight + 1; + if (p.timestamp <= prev->m_timestamp) + p.timestamp = prev->m_timestamp + 1; + }); + + auto [prev_height, _last] = tracker.chain.get_height_and_last(prev_share_hash); + if (prev_height >= 99) { + try { + p.far_share_hash = tracker.chain.get_nth_parent_key(prev_share_hash, 99); + } catch (const std::exception&) { + p.far_share_hash = uint256::ZERO; + } + } else { + p.far_share_hash = uint256::ZERO; + } + } else { + p.absheight = 1; // genesis + p.far_share_hash = uint256::ZERO; + } + + // Step 2: share_target via compute_share_target with + // the *clipped* timestamp — same call create_local + // _share_v35 makes (share_check.hpp:2138). + auto desired_target = chain::bits_to_target(block_bits); + try { + auto st = tracker.compute_share_target( + prev_share_hash, p.timestamp, desired_target); + p.bits = st.bits; + p.max_bits = st.max_bits; + result.bits = st.bits; + result.max_bits = st.max_bits; + } catch (const std::exception&) { + set_block_bits_fallback(); + } + + // Step 3: abswork = prev_abswork + work-of-this-share-at-bits + if (!prev_share_hash.IsNull() && tracker.chain.contains(prev_share_hash)) { + tracker.chain.get(prev_share_hash).share.invoke([&](auto* prev) { + auto attempts = chain::target_to_average_attempts( + chain::bits_to_target(p.bits)); + p.abswork = prev->m_abswork + uint128(attempts.GetLow64()); + }); + } else { + // Genesis case (cold-start or unknown prev) + p.absheight = 1; + p.abswork = uint128(chain::target_to_average_attempts( + chain::bits_to_target(p.bits)).GetLow64()); + p.far_share_hash = uint256::ZERO; + } + } else { + // Tracker busy — fallback values, ref_hash won't match peers + set_block_bits_fallback(); + p.absheight = 1; + p.abswork = uint128(chain::target_to_average_attempts( + chain::bits_to_target(p.bits)).GetLow64()); + p.far_share_hash = uint256::ZERO; + } + } else { + set_block_bits_fallback(); + p.absheight = 1; + p.abswork = uint128(chain::target_to_average_attempts( + chain::bits_to_target(p.bits)).GetLow64()); + p.far_share_hash = uint256::ZERO; + } + + // Mirror the walked values into the result for snap.frozen_ref + result.absheight = p.absheight; + result.abswork = p.abswork; + result.far_share_hash = p.far_share_hash; + result.timestamp = p.timestamp; + + try { + auto [rh, nn] = btc::compute_ref_hash_for_work(p); + result.ref_hash = rh; + result.last_txout_nonce = nn; + } catch (const std::exception& e) { + LOG_WARNING << "[BTC-STRATUM] compute_ref_hash_for_work threw: " << e.what(); + // result.ref_hash stays default (zero) — caller emits no OP_RETURN + } + return result; + }); + + LOG_INFO << "[BTC-STRATUM] PPLNS + ref_hash callbacks wired" + << " (donation_script=" << btc::PoolConfig::get_donation_script(35).size() + << "B P2PK; ref_hash walks share tracker for share_target/absheight/abswork/far_share)"; + + // ── Sharechain WRITE path (Phase 11) ────────────────────────────────── + // + // mining_submit calls this when a stratum submission's PoW meets the + // sharechain target. The lambda: + // 1. Parses 80-byte header → SmallBlockHeaderType (the v35 share's + // "min_header" field — a trimmed block header that omits the + // merkle_root since that's reconstructible from the share's + // coinbase + frozen branches). + // 2. Wraps full_coinbase in a BaseScript. + // 3. Converts string-hex merkle_branches → vector. + // 4. Acquires EXCLUSIVE tracker lock via try_to_lock — non-blocking; + // defer to next miner submission if compute thread is mid-think. + // 5. Calls btc::create_local_share(...) which builds a + // v35 PaddingBugfixShare, runs PoW recheck, and tracker.add()s + // it. Returns share_hash on success, uint256::ZERO on failure. + // 6. On success: broadcast_share + notify_local_share so peers learn + // our new tip and miners get fresh work. + // + // create_local_share INTERNAL behavior (relevant to debug): + // - validates PoW against bits-derived target + // - reconstructs ref_hash from frozen fields and verifies it matches + // what the coinbase OP_RETURN claims + // - calls tracker.add(share) — attempt_verify runs later in think() + work_source->set_create_share_fn( + [p2p_node_raw](const std::vector& full_coinbase, + const std::vector& header_80b, + const core::stratum::JobSnapshot& job, + const std::vector& payout_script) + -> uint256 + { + if (!p2p_node_raw || header_80b.size() != 80) { + LOG_WARNING << "[BTC-CREATE-SHARE] precondition fail: p2p_node=" + << (p2p_node_raw ? "ok" : "null") + << " header_size=" << header_80b.size(); + return uint256::ZERO; + } + + // ── Parse 80B BTC block header ── + auto read_le32 = [](const uint8_t* p) -> uint32_t { + return uint32_t(p[0]) | (uint32_t(p[1]) << 8) + | (uint32_t(p[2]) << 16) | (uint32_t(p[3]) << 24); + }; + + btc::coin::SmallBlockHeaderType min_header; + min_header.m_version = read_le32(header_80b.data() + 0); + std::memcpy(min_header.m_previous_block.data(), + header_80b.data() + 4, 32); + // bytes 36..67 are merkle_root — not stored in min_header + min_header.m_timestamp = read_le32(header_80b.data() + 68); + min_header.m_bits = read_le32(header_80b.data() + 72); + min_header.m_nonce = read_le32(header_80b.data() + 76); + + // ── Wrap coinbase + convert merkle branches ── + BaseScript coinbase_bs(std::vector( + full_coinbase.begin(), full_coinbase.end())); + + // Wire format for stratum branches is hex of LE-internal bytes + // (see get_stratum_merkle_branches). Parse via ParseHex+memcpy + // — SetHex would reverse, producing uint256s that don't match + // the bytes the miner already used to build their merkle root. + std::vector merkle_branches; + merkle_branches.reserve(job.merkle_branches.size()); + for (const auto& bhex : job.merkle_branches) { + uint256 b; + auto bb = ParseHex(bhex); + if (bb.size() == 32) std::memcpy(b.begin(), bb.data(), 32); + merkle_branches.push_back(b); + } + + // ── Acquire EXCLUSIVE tracker lock (try, non-blocking) ── + std::unique_lock lk( + p2p_node_raw->tracker_mutex(), std::try_to_lock); + if (!lk.owns_lock()) { + LOG_INFO << "[BTC-CREATE-SHARE] tracker busy — share deferred"; + return uint256::ZERO; + } + + // ── Call into btc::create_local_share ── + // v35 path: dispatches internally based on share_version arg. + // No merged_addrs (BTC v35 has no merged mining). + uint256 share_hash; + try { + share_hash = btc::create_local_share( + p2p_node_raw->tracker(), + min_header, + coinbase_bs, + /* subsidy */ job.subsidy, + /* prev_share */ job.prev_share_hash, + merkle_branches, + payout_script, + /* donation */ 50, // 0.5% + /* merged_addrs */ {}, + /* stale_info */ btc::StaleInfo::none, + /* segwit_active */ job.segwit_active, + /* witness_commitment */ job.witness_commitment_hex, + /* message_data */ {}, + /* actual_coinbase_bytes */ std::vector( + full_coinbase.begin(), + full_coinbase.end()), + /* witness_root */ job.witness_root, + /* override_max_bits */ job.frozen_ref.max_bits, + /* override_bits */ job.frozen_ref.bits, + /* frozen_absheight */ job.frozen_ref.absheight, + /* frozen_abswork */ job.frozen_ref.abswork, + /* frozen_far_share_hash */ job.frozen_ref.far_share_hash, + /* frozen_timestamp */ job.frozen_ref.timestamp, + /* frozen_merged_payout */ job.frozen_ref.merged_payout_hash, + // Phase 12: frozen_ref is now fully populated by + // build_connection_coinbase from the ref_hash_fn result + // (which walks the tracker for share_target + absheight + // + abswork + far_share_hash + clipped timestamp). The + // override values match what create_local_share_v35 would + // compute itself — but using the FROZEN values avoids + // races between template-build time and submit time + // (best_share could move under us mid-submit otherwise). + /* has_frozen */ true, + /* frozen_merkle_branches*/ job.frozen_ref.frozen_merkle_branches, + /* frozen_witness_root */ job.frozen_ref.frozen_witness_root, + /* frozen_merged_cb_info */ job.frozen_ref.frozen_merged_coinbase_info, + /* share_version */ 35, + /* desired_version */ 35); + } catch (const std::exception& e) { + LOG_WARNING << "[BTC-CREATE-SHARE] threw: " << e.what(); + return uint256::ZERO; + } + + // Lock release order: drop the unique_lock BEFORE calling + // broadcast_share / notify_local_share — those acquire their + // own locks (or post to io_context) and we don't want to hold + // exclusive while they run. + lk.unlock(); + + if (!share_hash.IsNull()) { + p2p_node_raw->broadcast_share(share_hash); + p2p_node_raw->notify_local_share(share_hash); + LOG_INFO << "[BTC-CREATE-SHARE] OK + broadcast: hash=" + << share_hash.GetHex().substr(0, 16); + } + return share_hash; + }); + + LOG_INFO << "[BTC-STRATUM] sharechain write path wired (mining_submit" + << " → create_local_share → broadcast_share + notify_local_share)"; + + // Share-target: leave at 0 → frozen_ref.bits=0 / frozen_ref.max_bits=0 + // → override_bits=0 in create_local_share_v35 → share.m_bits is taken + // from tracker.compute_share_target(prev_share, ...), the REAL network + // sharechain target. Without this, a hardcoded diff-1 trivially passes + // the internal PoW check, we add+broadcast invalid shares, and peers + // promptly RST/ban us (verified empirically 2026-05-02 on .122 oracle: + // disconnect 14ms after first broadcast). + // + // Stratum-side mining_submit's share_target check falls back to diff-1 + // when share_bits==0 (work_source.cpp:711), so pseudoshares are still + // accepted for PPLNS bookkeeping. The chain WRITE path is reached only + // when a bitaxe genuinely finds a hash meeting network sharechain + // difficulty (~2e8) — that's the correct gate. + LOG_INFO << "[BTC-STRATUM] share target left at 0 — create_local_share_v35" + << " will use tracker.compute_share_target (real network target)"; + + // Bump work-generation counter on every chain tip change. The stratum + // server uses this to detect stale work between job-push timer firings + // without snapshotting full template state. + coin_node.new_headers.subscribe( + [work_source](const std::vector&) + { work_source->bump_work_generation(); }); + coin_node.full_block.subscribe( + [work_source](const btc::coin::BlockType&) + { work_source->bump_work_generation(); }); + + std::unique_ptr stratum_server; + if (stratum_port != 0) { + stratum_server = std::make_unique( + ioc, stratum_addr, stratum_port, work_source); + if (stratum_server->start()) { + LOG_INFO << "[BTC-STRATUM] listening on " << stratum_addr << ":" << stratum_port + << " (work source: BTCWorkSource, share v35, full c2pool stack:" + << " PPLNS + ref_hash + segwit_commit + sharechain write)"; + } else { + LOG_WARNING << "[BTC-STRATUM] failed to bind " << stratum_addr << ":" << stratum_port + << " — stratum disabled"; + stratum_server.reset(); + } + } else { + LOG_INFO << "[BTC-STRATUM] disabled (no --stratum flag)"; + } + // Expose to the signal handler for graceful shutdown. Note: signal_set + // can fire as soon as we registered the async_wait, which means + // theoretically the handler could see an empty stratum_server_for_shutdown + // if a signal arrived during early init. That's fine — the `if` guard in + // the lambda handles it. + stratum_server_for_shutdown = std::move(stratum_server); + + LOG_INFO << "[BTC] io_context running. Ctrl-C to stop."; + + // Diagnostic [IOC-LAT]: when a single run_for slice takes far longer + // than its 100ms target, log it loud — boost::asio doesn't preempt + // handlers so any handler blocking for N seconds shows up here. Same + // signal as LTC's c2pool freeze-diag instrumentation + // (c2pool_refactored.cpp:7065). The original blocking ioc.run() can't + // emit per-slice diagnostics; switching to a 100ms-slice loop with + // the signal-set handler still calling ioc.stop() preserves graceful + // shutdown — once stop() is requested, run_for returns promptly and + // g_shutdown_requested terminates the loop. + while (!shutdown_initiated) { + ioc.restart(); + auto loop_t0 = std::chrono::steady_clock::now(); + try { + ioc.run_for(std::chrono::milliseconds(100)); + } catch (const std::exception& e) { + LOG_ERROR << "ioc handler exception (non-fatal): " << e.what(); + } + auto loop_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - loop_t0).count(); + if (loop_ms > 2000) { + LOG_WARNING << "[IOC-LAT] iteration took " << loop_ms + << "ms (target 100ms)"; + } + } + + // ── Graceful shutdown — explicit teardown order ────────────────────── + // + // ioc.run() returned because the signal handler called ioc.stop(). Now + // we tear down in a controlled order BEFORE letting RAII at end-of-main + // do the rest: + // + // 1. StratumServer is already stopped by the signal handler — sessions + // cancelled, acceptor closed. Reset the unique_ptr to invoke the + // destructor early so its sessions_ set is freed before anything + // else runs. + // 2. work_source: drop our ref via the expose-shutdown variable. The + // coin_node subscribers still hold shared_ptrs to it, so it stays + // alive until coin_node's destructors clear those subscribers + // (which happens automatically at end-of-scope below). Important + // that work_source outlives any in-flight stratum-handler that + // might call into it. + // 3. p2p_node (sharechain peer): explicit reset so its NodeP2P + // destructor closes peer connections before coin_node tears down + // its subordinate state. + // 4. coin_node (bitcoind P2P) + ioc + the rest: regular RAII at end + // of scope. + LOG_INFO << "[BTC] Shutting down..."; + stratum_server_for_shutdown.reset(); + work_source_for_shutdown.reset(); + p2p_node.reset(); + LOG_INFO << "[BTC] Shutdown complete."; + return 0; +} diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 5071dc7c2..8335af9e9 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -30,6 +30,8 @@ set(source addr_store.hpp addr_store.cpp web_server.hpp web_server.cpp stratum_server.hpp stratum_server.cpp + stratum_types.hpp + stratum_work_source.hpp address_utils.hpp address_utils.cpp leveldb_store.hpp leveldb_store.cpp address_validator.hpp address_validator.cpp diff --git a/src/core/coin/node_iface.hpp b/src/core/coin/node_iface.hpp new file mode 100644 index 000000000..0cabd528a --- /dev/null +++ b/src/core/coin/node_iface.hpp @@ -0,0 +1,64 @@ +#pragma once + +// --------------------------------------------------------------------------- +// core::coin::ICoinNode -- generic per-coin node interface for src/core/web_server +// (family-1 link fix, P2 WorkView seam). +// +// web_server is SHARED core but historically forward-declared and held raw +// pointers to CONCRETE impl/ltc types (ltc::coin::NodeRPC, +// ltc::interfaces::Node, ltc::coin::CoinNodeInterface). That breaks the BTC link +// (ltc symbols absent; btc's per-coin types differ). +// +// SUPERSEDES the earlier template INodeRPC/ICoinNode draft. The +// nm census proved web_server consumes a WorkData via only its agnostic slice +// (m_data/m_hashes/m_latency), never m_txs -- so getwork() does NOT need to +// return the per-coin WorkData across the seam. Instead the concrete coin node +// keeps the FULL WorkData coin-side (work.set(wd)) and hands core a WorkView. +// A single NON-TEMPLATE virtual now suffices, and the original "keep getwork +// parametric" gate is preserved where it matters: full WorkData (incl. m_txs) +// never leaves the coin. +// +// Each coin's concrete node (ltc::coin::CoinNode, btc::coin::CoinNode) inherits +// this base directly (direct-inherit, no adapter -- ltc-doge OK) and web_server +// holds one core::coin::ICoinNode* m_coin_node. +// --------------------------------------------------------------------------- + +#include + +#include + +namespace core::coin { + +struct ICoinNode { + virtual ~ICoinNode() = default; + + // Build (or fetch) a block template and return its coin-agnostic slice. + // The concrete impl makes the embedded-vs-rpc decision internally, calls + // work.set(wd) to retain the FULL per-coin WorkData coin-side, then moves + // m_data/m_hashes/m_latency into the returned view. Throws std::runtime_error + // if no template is available. (Folds web_server.cpp:2167-2172.) + virtual WorkView get_work_view() = 0; + + // Submit a pre-serialised block as hex. Coin-agnostic 2-arg form: the + // LTC-MWEB extension block is NOT carried on this shared virtual -- mweb is + // LTC-specific and the concrete ltc node forwards to its 3-arg rpc overload + // with mweb="" coin-side. Sole caller (web_server.cpp:2730) passed mweb="", + // so dropping the slot cannot break MWEB submit. Returns true iff accepted. + virtual bool submit_block_hex(const std::string& block_hex, + bool ignore_failure) = 0; + + // True iff this node has the embedded in-process template source. + // is_embedded() and has_rpc() are ORTHOGONAL -- a node may have BOTH (embedded + // preferred for get_work_view(), external RPC as fallback). Preserves the + // source labels (web_server.cpp:4423-4451) and the status JSON key "embedded" + // (web_server.cpp:4696). + virtual bool is_embedded() const = 0; + + // True iff this node has an external coin-RPC client. ORTHOGONAL to + // is_embedded() -- may co-exist with embedded as the getwork fallback and is + // the sole path for submit_block_hex(). Backs the status JSON key "has_rpc" + // (web_server.cpp:4697). + virtual bool has_rpc() const = 0; +}; + +} // namespace core::coin diff --git a/src/core/coin/utxo_view_cache.hpp b/src/core/coin/utxo_view_cache.hpp index dfbfd03d8..762712953 100644 --- a/src/core/coin/utxo_view_cache.hpp +++ b/src/core/coin/utxo_view_cache.hpp @@ -145,8 +145,12 @@ class UTXOViewCache { bool is_coinbase = first_tx; first_tx = false; - // Detect HogEx (MWEB): last tx with m_hogEx flag → outputs are pegouts - bool is_hogex = tx.m_hogEx; + // Detect HogEx (MWEB): last tx with m_hogEx flag → outputs are pegouts. + // BTC's MutableTransaction has no m_hogEx (no MWEB) — gate by requires. + bool is_hogex = false; + if constexpr (requires { tx.m_hogEx; }) { + is_hogex = tx.m_hogEx; + } if (!is_coinbase) { // Spend inputs — save spent coins for undo diff --git a/src/core/coin/work_view.hpp b/src/core/coin/work_view.hpp new file mode 100644 index 000000000..73e9a67ed --- /dev/null +++ b/src/core/coin/work_view.hpp @@ -0,0 +1,38 @@ +#pragma once + +// --------------------------------------------------------------------------- +// core::coin::WorkView -- the coin-AGNOSTIC slice of a per-coin WorkData that +// src/core/web_server actually consumes (family-1 link fix, P2 seam). +// +// nm census of web_server.cpp.o @ c4547575 (ci-steward, authoritative): the TU +// reads a WorkData via ONLY these three fields -- m_data, m_hashes, m_latency. +// It NEVER touches m_txs (grep 'm_txs' src/core/web_server.cpp == 0 hits). So +// the link break is not "abstract the interface", it is "stop crossing the seam +// with the payload": the per-coin WorkData (incl. vector m_txs) +// stays coin-side via work.set(wd); only this view crosses into core. That +// severs BOTH cluster A (ltc:: symbols) and cluster B (Transaction type) at +// once -- web_server.cpp.o then names no ltc:: symbol and no Transaction type. +// +// Field names mirror the concrete per-coin WorkData EXACTLY so every existing +// web_server line (wd.m_data / wd.m_hashes / wd.m_latency) is UNCHANGED. +// - m_data : getblocktemplate-shaped json the stratum/getwork path serves +// - m_hashes : merkle/branch hashes (vector) +// - m_latency : template build latency; consumed at web_server.cpp:2381 as +// static_cast(wd.m_latency) -> m_last_work_latency.store +// --------------------------------------------------------------------------- + +#include +#include + +#include +#include + +namespace core::coin { + +struct WorkView { + nlohmann::json m_data; + std::vector m_hashes; + std::time_t m_latency = 0; +}; + +} // namespace core::coin diff --git a/src/core/stratum_server.cpp b/src/core/stratum_server.cpp index c92ebded2..56cd938d1 100644 --- a/src/core/stratum_server.cpp +++ b/src/core/stratum_server.cpp @@ -62,7 +62,7 @@ RateMonitor::get_datums_in_last(double dt) const { /// Static member definition std::atomic StratumSession::job_counter_{0}; -StratumServer::StratumServer(net::io_context& ioc, const std::string& address, uint16_t port, std::shared_ptr mining_interface) +StratumServer::StratumServer(net::io_context& ioc, const std::string& address, uint16_t port, std::shared_ptr mining_interface) : ioc_(ioc) , acceptor_(ioc) , mining_interface_(mining_interface) @@ -102,14 +102,41 @@ bool StratumServer::start() void StratumServer::stop() { - if (running_) { - try { - acceptor_.close(); - running_ = false; - LOG_INFO << "StratumServer stopped"; - } catch (const std::exception& e) { - LOG_ERROR << "Error stopping StratumServer: " << e.what(); + if (!running_) return; + + try { + // Stop accepting new connections first — no more sessions can spawn + // after this point, so the snapshot below is exhaustive. + boost::system::error_code ec; + acceptor_.cancel(ec); + acceptor_.close(ec); + running_ = false; + + // Snapshot + clear the live-sessions set under the mutex, then close + // each one OUTSIDE the lock. cancel_timers() does asio operations + // (timer.cancel + socket.close) that should not run with our + // sessions_mutex_ held — the read-error handler will fire on the + // io_context thread and try to acquire sessions_mutex_ via + // unregister_session. + std::set> snapshot; + { + std::lock_guard lk(sessions_mutex_); + snapshot = std::move(sessions_); + sessions_.clear(); } + + for (auto& session : snapshot) { + try { + session->shutdown(); + } catch (const std::exception& e) { + LOG_WARNING << "StratumSession shutdown threw: " << e.what(); + } + } + + LOG_INFO << "StratumServer stopped (closed " << snapshot.size() + << " active session" << (snapshot.size() == 1 ? "" : "s") << ")"; + } catch (const std::exception& e) { + LOG_ERROR << "Error stopping StratumServer: " << e.what(); } } @@ -275,7 +302,7 @@ void StratumServer::notify_all() } /// StratumSession Implementation -StratumSession::StratumSession(tcp::socket socket, std::shared_ptr mining_interface, +StratumSession::StratumSession(tcp::socket socket, std::shared_ptr mining_interface, StratumServer* server) : socket_(std::move(socket)) , mining_interface_(mining_interface) @@ -917,8 +944,20 @@ nlohmann::json StratumSession::handle_submit(const nlohmann::json& params, const response["error"] = nlohmann::json::array({20, "Invalid version mask", nullptr}); return response; } - // Apply: keep non-rolling bits from job, take rolling bits from miner - effective_version = (job.version & ~version_rolling_mask_) | (miner_version_bits & version_rolling_mask_); + // BIP 320: version_bits is the XOR-difference between the miner's + // rolled version and the job version (per spec: "bits set by miner + // to be different from version field given by mining.notify"). + // Recovery: effective = job ^ version_bits. ESP-Miner / bitaxe + // firmware sends version_bits this way (asic_result_task.c:60: + // `version_bits = rolled_version ^ active_job->version`). Earlier + // mask check ensures version_bits flips only mask-bits, so XOR + // only modifies the negotiated bits. + // + // Old REPLACE convention `(job & ~mask) | (bits & mask)` silently + // zeroed any job-version bits in the mask region (e.g. BIP 9 + // signal bits) before OR'ing — produced a header version that + // bitaxe never hashed, every share rejected at vardiff gate. + effective_version = job.version ^ miner_version_bits; } catch (...) { LOG_WARNING << "[Stratum] Invalid version_bits hex from " << username_ << ": " << version_bits; } @@ -926,7 +965,12 @@ nlohmann::json StratumSession::handle_submit(const nlohmann::json& params, const // Use gbt_prevhash (BE display hex) — SetHex converts to LE internal, // matching build_block_from_stratum which the daemon accepts. - double share_difficulty = MiningInterface::calculate_share_difficulty( + // Per-coin PoW dispatch via IWorkSource virtual: LTC delegates to the + // existing static scrypt-based calculate_share_difficulty; BTC's + // BTCWorkSource implements this with SHA256d. Without this dispatch + // every BTC stratum submission gets a garbage scrypt-based diff and + // rejects at the vardiff gate. + double share_difficulty = mining_interface_->compute_share_difficulty( job.coinb1, job.coinb2, extranonce1_, extranonce2, ntime, nonce, effective_version, job.gbt_prevhash, job.nbits, job.merkle_branches); @@ -937,7 +981,7 @@ nlohmann::json StratumSession::handle_submit(const nlohmann::json& params, const // Pool share difficulty (for P2P share creation threshold) double pool_difficulty = 0.0; - uint32_t sb = mining_interface_->m_share_bits.load(); + uint32_t sb = mining_interface_->get_share_bits(); if (sb != 0) pool_difficulty = chain::target_to_difficulty(chain::bits_to_target(sb)); @@ -975,8 +1019,8 @@ nlohmann::json StratumSession::handle_submit(const nlohmann::json& params, const snapshot.subsidy = job.subsidy; snapshot.witness_commitment_hex = job.witness_commitment_hex; snapshot.witness_root = job.witness_root; - snapshot.share_bits = mining_interface_->m_share_bits.load(); - snapshot.share_max_bits = mining_interface_->m_share_max_bits.load(); + snapshot.share_bits = mining_interface_->get_share_bits(); + snapshot.share_max_bits = mining_interface_->get_share_max_bits(); // Pass frozen share fields through to ShareCreationParams snapshot.frozen_ref.absheight = job.frozen_absheight; snapshot.frozen_ref.abswork = job.frozen_abswork; @@ -1317,7 +1361,7 @@ void StratumSession::send_notify_work(bool force_clean, const uint256* frozen_be // coinbase/tx_data consistency. Overriding them caused GENTX validation // failures on p2pool nodes (absheight, bits mismatch). if (!cbr.snapshot.merkle_branches.empty()) { - merkle_branches_vec = cbr.snapshot.merkle_branches; + merkle_branches_vec = std::move(cbr.snapshot.merkle_branches); merkle_branches = nlohmann::json::array(); for (const auto& h : merkle_branches_vec) merkle_branches.push_back(h); @@ -1340,7 +1384,7 @@ void StratumSession::send_notify_work(bool force_clean, const uint256* frozen_be je.coinb1 = coinb1; je.coinb2 = coinb2; je.version = version_u32; - je.merkle_branches = merkle_branches_vec; + je.merkle_branches = std::move(merkle_branches_vec); je.gbt_block_nbits = gbt_block_nbits; active_jobs_[job_id] = std::move(je); } @@ -1379,7 +1423,10 @@ void StratumSession::send_notify_work(bool force_clean, const uint256* frozen_be // Use tx_data from the atomic snapshot — NOT from the potentially stale // tmpl fetched at the top of send_notify_work(). This ensures the block // body transactions match the witness commitment and merkle branches. - je.tx_data = cbr.snapshot.tx_data; + // std::move because cbr is a local of this function and snapshot.tx_data + // is not referenced again after this assignment — saves a deep copy of + // the per-tx hex string vector on every notify (Option 2 fix). + je.tx_data = std::move(cbr.snapshot.tx_data); } // VARDIFF: do NOT override per-connection difficulty with pool share_bits. diff --git a/src/core/stratum_server.hpp b/src/core/stratum_server.hpp index 356b32939..830798583 100644 --- a/src/core/stratum_server.hpp +++ b/src/core/stratum_server.hpp @@ -20,6 +20,7 @@ #include #include +#include #include namespace core { @@ -27,8 +28,10 @@ namespace core { namespace net = boost::asio; using tcp = net::ip::tcp; -// Forward declaration — defined in web_server.hpp -class MiningInterface; +// IWorkSource is the abstract interface stratum_server holds (defined in +// core/stratum_work_source.hpp). LTC's MiningInterface and BTC's +// btc::stratum::BTCWorkSource both implement it. +using IWorkSource = core::stratum::IWorkSource; /// Sliding-window rate monitor matching p2pool's util.math.RateMonitor. /// Records timestamped work datums for smooth hashrate estimation. @@ -89,7 +92,7 @@ class StratumSession : public std::enable_shared_from_this boost::asio::streambuf buffer_; std::deque write_queue_; // async write queue (non-blocking) bool writing_ = false; // true while an async_write is in flight - std::shared_ptr mining_interface_; + std::shared_ptr mining_interface_; StratumServer* server_ = nullptr; // back-pointer for RateMonitor recording std::string subscription_id_; std::string extranonce1_; @@ -117,7 +120,7 @@ class StratumSession : public std::enable_shared_from_this std::string coinb2; uint32_t version{}; std::vector merkle_branches; - std::vector tx_data; // raw tx hex from GBT template + std::shared_ptr> tx_data; // raw tx hex from GBT template (a1: shared/lazy) std::string mweb; // MWEB extension data bool segwit_active{false}; uint256 prev_share_hash; // share chain tip when this job was built @@ -173,7 +176,7 @@ class StratumSession : public std::enable_shared_from_this boost::asio::steady_timer work_push_timer_; public: - explicit StratumSession(tcp::socket socket, std::shared_ptr mining_interface, + explicit StratumSession(tcp::socket socket, std::shared_ptr mining_interface, StratumServer* server = nullptr); void start(); @@ -206,6 +209,13 @@ class StratumSession : public std::enable_shared_from_this // best_share_hash_fn() live. This prevents PPLNS recomputation race when // best_share changes mid-iteration in notify_all(). void send_notify_work(bool force_clean = false, const uint256* frozen_best_share = nullptr); + + // Graceful shutdown: cancel timers + close socket. The pending async_read + // fails with operation_aborted, the read handler runs the standard + // disconnect path (cancel_timers, unregister_stratum_worker, log). Idempotent. + // Called by StratumServer::stop() during process shutdown so no queued + // async handlers reference half-destroyed state when the io_context dies. + void shutdown() { cancel_timers(); } private: void start_periodic_work_push(); void schedule_work_push_timer(); @@ -220,7 +230,7 @@ class StratumServer { net::io_context& ioc_; tcp::acceptor acceptor_; - std::shared_ptr mining_interface_; + std::shared_ptr mining_interface_; std::string bind_address_; uint16_t port_; bool running_; @@ -243,7 +253,7 @@ class StratumServer mutable std::mutex cache_mutex_; public: - StratumServer(net::io_context& ioc, const std::string& address, uint16_t port, std::shared_ptr mining_interface); + StratumServer(net::io_context& ioc, const std::string& address, uint16_t port, std::shared_ptr mining_interface); ~StratumServer(); bool start(); diff --git a/src/core/stratum_types.hpp b/src/core/stratum_types.hpp new file mode 100644 index 000000000..d8a1b2cbe --- /dev/null +++ b/src/core/stratum_types.hpp @@ -0,0 +1,136 @@ +#pragma once + +// Coin-agnostic data types crossing the stratum API boundary. +// +// These types were originally defined as nested structs inside +// `core::MiningInterface` (web_server.hpp), tightly coupled to LTC's +// concrete stratum implementation. They were hoisted here as part of +// the IWorkSource extraction (B4-stratum / 2026-05) so multiple coin +// modules — LTC (`core::MiningInterface`), BTC (`btc::stratum:: +// BTCWorkSource`), and future BCH/DGB ports — can share a single +// `core::StratumServer` driven by the same `IWorkSource` interface. +// +// Shape neutrality: every field here is either a primitive, a +// `uint256`/`uint128`, or a stdlib container of those. No coin-specific +// types appear. Where defaults exist (e.g. `share_version=36`, +// `desired_version=36`) they are LTC's defaults; non-LTC implementors +// override at construction time. The `mweb` field on `JobSnapshot` / +// `WorkSnapshot` is empty for chains without MimbleWimble (BTC, DOGE, +// DGB) — present only so the same struct serializes through both LTC +// and non-LTC paths without divergence. + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace core::stratum { + +/// Static config for the stratum server's vardiff + work loop. +/// Shared across all sessions. Values match p2pool defaults. +struct StratumConfig { + double min_difficulty = 0.0005; // floor for per-connection vardiff + double max_difficulty = 65536.0; // ceiling for per-connection vardiff + double target_time = 3.0; // seconds between pseudoshares (p2pool default: 3) + bool vardiff_enabled = true; // auto-adjust per-connection difficulty + size_t max_coinbase_outputs = 4000; // Python p2pool's [-4000:] cap; no consensus limit +}; + +/// Frozen share-construction fields returned by ref_hash_fn. These +/// capture the share-tracker state at the moment a job was assembled +/// so the resulting share matches even if the tip moved before submit. +struct RefHashResult { + uint256 ref_hash; + uint64_t last_txout_nonce{0}; + uint32_t absheight{0}; + uint128 abswork; + uint256 far_share_hash; + uint32_t max_bits{0}; + uint32_t bits{0}; + uint32_t timestamp{0}; + uint256 merged_payout_hash; + int64_t share_version{36}; // LTC default; BTC/etc. override at construction + uint64_t desired_version{36}; // version vote (always target version) + // Frozen mm_commitment — cached from rebuild_cached_blocks(). + // Empty if no merged mining (BTC: always empty in MVP). + std::vector frozen_mm_commitment; + // Frozen segwit data — merkle branches and witness root change between + // GBT updates, but the ref_hash was computed with the values at template time. + std::vector frozen_merkle_branches; + uint256 frozen_witness_root; + // V36 LTC: frozen merged coinbase info (pre-serialized vector) + // Contains DOGE block header + merkle proof for consensus verification. + // Empty for non-merged coins. + std::vector frozen_merged_coinbase_info; +}; + +/// All template data frozen at the time a mining job was sent, plus the +/// share-target bits the miner actually hashed against. Passed back into +/// IWorkSource::mining_submit() so the work source can validate without +/// re-fetching template state that may have rolled. +struct JobSnapshot { + std::string coinb1, coinb2; + std::string gbt_prevhash; // BE display hex + std::string nbits; // BE hex e.g. "1e0fffff" (share target bits for header) + uint32_t version{0}; + std::vector merkle_branches; + std::shared_ptr> tx_data; // raw tx hex from GBT (a1: shared/lazy) + std::string mweb; // empty for non-MWEB coins + bool segwit_active{false}; + uint256 prev_share_hash; // share chain tip when this job was built + uint64_t subsidy{0}; // coinbasevalue frozen at job creation + std::string witness_commitment_hex; // P2Pool witness commitment frozen at job creation + uint256 witness_root; // raw wtxid merkle root frozen at job creation + uint32_t share_bits{0}; // share target bits from compute_share_target() + uint32_t share_max_bits{0}; // share max_bits from compute_share_target() + std::string block_nbits; // original GBT block bits (for block target check) + RefHashResult frozen_ref; // frozen share fields from template time + int stale_info{0}; // 0=none, 253=orphan (stale block template) +}; + +/// Atomic snapshot of work-related fields under the work source's mutex. +/// Used by the connection-coinbase builder to freeze consistent state +/// matching the coinbase output it produces. +struct WorkSnapshot { + bool segwit_active{false}; + std::string mweb; // empty for non-MWEB coins + uint64_t subsidy{0}; + std::string witness_commitment_hex; + uint256 witness_root; + RefHashResult frozen_ref; + // Block body data — captured atomically with witness_commitment to + // prevent merkle root mismatch when refresh_work() updates the template. + std::shared_ptr> tx_data; // raw tx hex from template (a1: shared/lazy) + std::vector merkle_branches; // stratum merkle branches +}; + +/// Result of build_connection_coinbase(): the two coinbase fragments +/// the miner needs (coinb1 + extranonce + coinb2 = full coinbase tx) +/// plus the work snapshot frozen at construction. +struct CoinbaseResult { + std::string coinb1; + std::string coinb2; + WorkSnapshot snapshot; +}; + +/// Per-connection metadata maintained by the work source for dashboard +/// + stats display. Updated by the stratum session as shares accumulate. +struct WorkerInfo { + std::string username; // miner address (after parsing) + std::string worker_name; // worker suffix from "ADDRESS.worker" (e.g. "alpha") + double hashrate{0.0}; // measured H/s from HashrateTracker + double dead_hashrate{0.0}; // DOA H/s + double difficulty{1.0}; // current vardiff difficulty + uint64_t accepted{0}; + uint64_t rejected{0}; + uint64_t stale{0}; + std::chrono::steady_clock::time_point connected_at; + std::string remote_endpoint; // "ip:port" +}; + +} // namespace core::stratum diff --git a/src/core/stratum_work_source.hpp b/src/core/stratum_work_source.hpp new file mode 100644 index 000000000..986f939ae --- /dev/null +++ b/src/core/stratum_work_source.hpp @@ -0,0 +1,181 @@ +#pragma once + +// IWorkSource — abstract interface that decouples `core::StratumServer` +// (the protocol layer: TCP, JSON-RPC, session lifecycle, vardiff, +// rate-monitor) from the coin-specific work-generation + share-validation +// logic. Single source of truth for the stratum server: LTC's +// `core::MiningInterface` and BTC's `btc::stratum::BTCWorkSource` both +// implement this interface, and a single `core::StratumServer` instance +// drives either via virtual dispatch. +// +// Extraction history: prior to 2026-05, `core::StratumServer` held a +// `std::shared_ptr` (concrete LTC class) and +// invoked 13 non-virtual methods plus accessed two `std::atomic` +// members directly. This made the entire stratum protocol layer +// LTC-only. The B4-stratum work introduced this interface as the +// minimum surface area covering every call site (verified by +// exhaustive grep of `mining_interface_->*` in stratum_server.cpp). +// +// Performance: every method on this interface is reached via virtual +// dispatch (~1 ns per call). For the hot paths — `mining_submit` is +// called once per share submission (3-30 Hz typical), the others +// once per job issue — this is well below the floor of meaningful +// overhead. No path is in a tight inner loop. +// +// Threading: implementors must guarantee internal synchronisation. +// `core::StratumServer` runs on a dedicated `boost::asio::io_context` +// and may invoke methods from any thread serviced by that context +// (typically one thread, but configurable). Implementors that share +// state with other subsystems (block templates, sharechain) must use +// their own mutexes/atomics. + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace core::stratum { + +class IWorkSource { +public: + virtual ~IWorkSource() = default; + + // ── Config + read-only state ───────────────────────────────────────── + // Called frequently from the stratum loop; kept lightweight by impls. + + /// Vardiff bounds + target share rate + coinbase output cap. + /// Reference must remain valid for the lifetime of the work source; + /// the server caches no copy and re-reads each time. + virtual const StratumConfig& get_stratum_config() const = 0; + + /// Returns a callable that yields the current best-share hash from + /// the share tracker. May be empty (`!fn`) if no share tracker is + /// wired yet — the server then falls back to the GBT prevhash. + virtual std::function get_best_share_hash_fn() const = 0; + + /// Current GBT-format previousblockhash (the BTC/LTC tip the pool is + /// mining on top of), in BE display-hex form (matches what miners + /// receive in `mining.notify`'s prevhash field). Used as a fallback + /// when the best-share fn is unset and as the basis for `clean_jobs` + /// detection. + virtual std::string get_current_gbt_prevhash() const = 0; + + /// Monotonic counter that bumps on every template refresh. The server + /// uses it to detect stale work between job-push timer firings without + /// snapshotting full template state. + virtual uint64_t get_work_generation() const = 0; + + /// True if the work source has merged-mining state for the given + /// chain id. BTC MVP returns false unconditionally. + virtual bool has_merged_chain(uint32_t chain_id) const = 0; + + // ── Per-connection bookkeeping ─────────────────────────────────────── + // Lifecycle: register on authorize, update each share, unregister on + // disconnect. All keyed by stratum session_id. + + virtual void register_stratum_worker(const std::string& session_id, + const WorkerInfo& info) = 0; + virtual void unregister_stratum_worker(const std::string& session_id) = 0; + virtual 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) = 0; + + // ── Work generation ────────────────────────────────────────────────── + // Called per-job-issue (when sending mining.notify). Implementors + // must produce a snapshot consistent with their template state at + // call time — the stratum session freezes the result into a JobEntry. + + /// Current full block template as JSON (legacy GBT-shaped). The + /// stratum session reads `previousblockhash`, `bits`, `version`, + /// `curtime`/`mintime`, etc. for header construction. + virtual nlohmann::json get_current_work_template() const = 0; + + /// Stratum-format merkle branches (excludes coinbase txid; each + /// branch hex is the natural-byte-order hash a miner needs to + /// hash-with-coinbase-then-up to reach the merkle root). + virtual std::vector get_stratum_merkle_branches() const = 0; + + /// Cached fallback coinbase split for `mining.notify` when the + /// per-connection builder isn't applicable. Returns (coinb1, coinb2) + /// with the extranonce slot expected to be inserted between them. + virtual std::pair get_coinbase_parts() const = 0; + + /// Build a per-connection coinbase. The work source computes the + /// p2pool ref_hash for the (prev_share, payout_script, merged_addrs) + /// triple and produces (coinb1, coinb2) plus a frozen WorkSnapshot + /// matching the extra outputs it just wrote into the coinbase. + /// Caller passes the prev_share_hash to avoid race with a concurrent + /// best-share update. + virtual 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 = 0; + + // ── Share submission (the hot path) ────────────────────────────────── + + /// Process a `mining.submit` from a stratum session. Validates the + /// (extranonce1, extranonce2, ntime, nonce) tuple against the frozen + /// JobSnapshot, computes the PoW hash, classifies the result: + /// - PoW ≤ block target → submit to coin daemon (B5 path for BTC) + /// - PoW ≤ share target → record share in tracker + /// - otherwise → reject as low-difficulty + /// Returns the JSON-RPC response payload (`true` for accepted, or + /// `[code, message, null]` for rejection). + /// `job` may be null for legacy callers; modern stratum sessions + /// always pass a frozen snapshot to avoid template-rolling races. + virtual 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 JobSnapshot* job) = 0; + + // ── Atomic state read access ───────────────────────────────────────── + // These replace direct `m_share_bits.load()` / `m_share_max_bits.load()` + // member access in the prior LTC-coupled stratum_server.cpp. Implementors + // back them with std::atomic so reads are wait-free; the virtual + // indirection adds ~1 ns. Only reads — writes happen inside the + // implementor when its template refreshes. + + /// Current share-target bits (compact-target encoding) the server + /// will set as `nbits` in mining.notify. Matches the difficulty + /// the miner must beat for the submission to count as a share. + virtual uint32_t get_share_bits() const = 0; + + /// Maximum share-target bits — the easiest the share target can be + /// at the moment. Used to validate stale jobs and detect retarget + /// transitions. + virtual uint32_t get_share_max_bits() const = 0; + + /// Compute the share difficulty for a stratum submission. The + /// per-coin PoW hash function (scrypt for LTC, SHA256d for BTC, + /// X11/Quark/etc. for future ports) is encapsulated here — the + /// stratum server invokes this rather than calling a hardcoded + /// scrypt function. The returned difficulty is "diff 1 / pow_hash" + /// in standard Bitcoin convention. Returns 0.0 on parse error. + /// + /// Without this, a coin-agnostic stratum server can't validate + /// pseudoshares from miners using a different PoW than the + /// implementor's default — every submission gets garbage diff and + /// rejects at the vardiff gate. + virtual 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 = 0; +}; + +} // namespace core::stratum diff --git a/src/core/web_server.cpp b/src/core/web_server.cpp index f8972d0a5..cde79edfc 100644 --- a/src/core/web_server.cpp +++ b/src/core/web_server.cpp @@ -1,5 +1,6 @@ #include "web_server.hpp" #include "stratum_server.hpp" +#include #include "address_utils.hpp" #include "socket.hpp" @@ -1413,7 +1414,7 @@ MiningInterface::build_block_from_stratum(const std::string& extranonce1, block << HexStr(std::span(nonce_bytes.data(), nonce_bytes.size())); // Transaction count (varint) + coinbase + rest of transactions - const auto& tx_list = job ? job->tx_data : std::vector{}; + const std::vector tx_list = (job && job->tx_data) ? *job->tx_data : std::vector{}; // If no job snapshot, collect tx data from the live template std::vector live_tx_data; if (!job && m_cached_template.contains("transactions")) { @@ -2139,9 +2140,11 @@ MiningInterface::build_connection_coinbase( // when refresh_work() updates the template between separate reads. snap.merkle_branches = ws.merkle_branches; if (ws.tmpl.contains("transactions")) { + auto txd = std::make_shared>(); for (const auto& tx : ws.tmpl["transactions"]) if (tx.contains("data")) - snap.tx_data.push_back(tx["data"].get()); + txd->push_back(tx["data"].get()); + snap.tx_data = std::move(txd); } return {std::move(cb1), std::move(cb2), std::move(snap)}; } @@ -7666,7 +7669,7 @@ nlohmann::json MiningInterface::mining_submit(const std::string& username, const // means the root was computed and should be used as-is. // Only recompute when witness_commitment is empty but segwit active. if (params.witness_commitment_hex.empty() && params.segwit_active) { - const auto& txd = job ? job->tx_data : std::vector{}; + const std::vector txd = (job && job->tx_data) ? *job->tx_data : std::vector{}; if (!txd.empty()) { std::vector wtxids; wtxids.push_back(uint256()); // coinbase wtxid = 0 diff --git a/src/core/web_server.hpp b/src/core/web_server.hpp index 57cdd9dbc..64f16dbc2 100644 --- a/src/core/web_server.hpp +++ b/src/core/web_server.hpp @@ -26,6 +26,8 @@ #include #include #include +#include +#include #include #include #include @@ -130,16 +132,18 @@ inline nlohmann::json to_json(const std::optional& tip) } /// Stratum mining configuration — tuneable from CLI, YAML, or API. -struct StratumConfig { - double min_difficulty = 0.0005; // floor for per-connection vardiff - double max_difficulty = 65536.0; // ceiling for per-connection vardiff - double target_time = 3.0; // seconds between pseudoshares (p2pool default: 3) - bool vardiff_enabled = true; // auto-adjust per-connection difficulty - size_t max_coinbase_outputs = 4000; // Python p2pool's [-4000:] cap; no consensus limit -}; +// Hoisted to core::stratum (see core/stratum_types.hpp). Alias kept here +// so existing references to `core::StratumConfig` resolve transparently. +using StratumConfig = core::stratum::StratumConfig; -/// Mining interface that provides RPC methods for miners -class MiningInterface : public jsonrpccxx::JsonRpc2Server +/// Mining interface that provides RPC methods for miners. +/// +/// Implements `core::stratum::IWorkSource` so the same `core::StratumServer` +/// can drive both LTC's `MiningInterface` and BTC's `btc::stratum:: +/// BTCWorkSource`. The IWorkSource methods on this class are annotated +/// `override`. See `core/stratum_work_source.hpp` for the contract. +class MiningInterface : public jsonrpccxx::JsonRpc2Server, + public core::stratum::IWorkSource { public: MiningInterface(bool testnet = false, std::shared_ptr node = nullptr, Blockchain blockchain = Blockchain::LITECOIN); @@ -293,57 +297,17 @@ class MiningInterface : public jsonrpccxx::JsonRpc2Server double pool_hashrate = 0, uint64_t subsidy = 0); - // Frozen share fields returned by ref_hash_fn — must be defined before JobSnapshot. - struct RefHashResult { - uint256 ref_hash; - uint64_t last_txout_nonce{0}; - uint32_t absheight{0}; - uint128 abswork; - uint256 far_share_hash; - uint32_t max_bits{0}; - uint32_t bits{0}; - uint32_t timestamp{0}; - uint256 merged_payout_hash; - int64_t share_version{36}; // AutoRatchet: V35 or V36 - uint64_t desired_version{36}; // Version vote (always target version) - // Frozen mm_commitment — cached from rebuild_cached_blocks(). - // Empty if no merged mining. - std::vector frozen_mm_commitment; - // Frozen segwit data — merkle branches and witness root change between - // GBT updates, but the ref_hash was computed with the values at template time. - std::vector frozen_merkle_branches; - uint256 frozen_witness_root; - // V36: frozen merged coinbase info (pre-serialized vector) - // Contains DOGE block header + merkle proof for consensus verification. - std::vector frozen_merged_coinbase_info; - }; - - // Stratum-style methods (for advanced miners) - // Job snapshot: holds all template data frozen at the time a mining job was sent. - struct JobSnapshot { - std::string coinb1, coinb2; - std::string gbt_prevhash; // BE display hex - std::string nbits; // BE hex e.g. "1e0fffff" (share target bits for header) - uint32_t version{0}; - std::vector merkle_branches; - std::vector tx_data; // raw tx hex from GBT - std::string mweb; - bool segwit_active{false}; - uint256 prev_share_hash; // share chain tip when this job was built - uint64_t subsidy{0}; // coinbasevalue frozen at job creation - std::string witness_commitment_hex; // P2Pool witness commitment frozen at job creation - uint256 witness_root; // raw wtxid merkle root frozen at job creation - uint32_t share_bits{0}; // share target bits from compute_share_target() - uint32_t share_max_bits{0}; // share max_bits from compute_share_target() - std::string block_nbits; // original GBT block bits (for block target check) - RefHashResult frozen_ref; // frozen share fields from template time - int stale_info{0}; // 0=none, 253=orphan (stale block template) - }; + // Boundary types hoisted to core::stratum (see core/stratum_types.hpp). + // Aliases kept here so existing references like `MiningInterface::JobSnapshot` + // continue to resolve. Not needed for IWorkSource extraction itself — + // these are convenience aliases for the existing call sites. + using RefHashResult = core::stratum::RefHashResult; + using JobSnapshot = core::stratum::JobSnapshot; nlohmann::json mining_subscribe(const std::string& user_agent = "", const std::string& request_id = ""); nlohmann::json mining_authorize(const std::string& username, const std::string& password, const std::string& request_id = ""); 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 JobSnapshot* job = nullptr); + const JobSnapshot* job = nullptr) override; // Enhanced coinbase and validation methods nlohmann::json validate_address(const std::string& address); @@ -386,9 +350,25 @@ class MiningInterface : public jsonrpccxx::JsonRpc2Server const std::string& nbits_hex, const std::vector& merkle_branches); + /// IWorkSource virtual override: LTC uses scrypt, so delegate to the + /// existing scrypt-based static `calculate_share_difficulty` directly. + /// (BTC's BTCWorkSource overrides with a SHA256d implementation.) + 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 + { + return calculate_share_difficulty(coinb1, coinb2, extranonce1, extranonce2, + ntime, nonce, version, prevhash_hex, + nbits_hex, merkle_branches); + } + // Hook: returns the best share hash from the share tracker (for prev_hash wiring) void set_best_share_hash_fn(std::function fn) { m_best_share_hash_fn = thread_safe_wrap(std::move(fn)); } - std::function get_best_share_hash_fn() const { return m_best_share_hash_fn; } + std::function get_best_share_hash_fn() const override { return m_best_share_hash_fn; } // Hook: walk back from best_share to find nearest peer share for work template. // Ensures c2pool shares extend the main chain, not local forks. @@ -420,35 +400,20 @@ class MiningInterface : public jsonrpccxx::JsonRpc2Server // Atomically snapshot work-related fields under m_work_mutex. // Used by StratumSession to freeze consistent state matching the coinbase. - struct WorkSnapshot { - bool segwit_active{false}; - std::string mweb; - uint64_t subsidy{0}; - std::string witness_commitment_hex; - uint256 witness_root; - // Frozen share fields from ref_hash_fn - RefHashResult frozen_ref; - // Block body data — must be captured atomically with witness_commitment - // to prevent merkle root mismatch when refresh_work() updates the template. - std::vector tx_data; // raw tx hex from template - std::vector merkle_branches; // stratum merkle branches - }; + // (Hoisted to core::stratum::WorkSnapshot — see core/stratum_types.hpp.) + using WorkSnapshot = core::stratum::WorkSnapshot; // Build per-connection coinbase parts: computes ref_hash using the ref_hash callback, // then generates coinb1/coinb2 with full output set including OP_RETURN. // prev_share_hash is frozen at the caller and passed in to avoid race conditions. // Also returns work snapshot atomically (under same lock) to prevent race with refresh_work. // Returns (coinb1, coinb2) or empty strings if not possible. - struct CoinbaseResult { - std::string coinb1; - std::string coinb2; - WorkSnapshot snapshot; - }; + using CoinbaseResult = core::stratum::CoinbaseResult; 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; + const std::vector>>& merged_addrs) const override; // Hook: called by mining_submit() pool path to create a share in the tracker. // All block template data needed by create_local_share() is passed through. @@ -692,11 +657,11 @@ class MiningInterface : public jsonrpccxx::JsonRpc2Server // Fetch a fresh block template from the coin daemon and cache it void refresh_work(); // Return the most recently cached block template (empty json if unavailable) - nlohmann::json get_current_work_template() const; + nlohmann::json get_current_work_template() const override; // Return Stratum-ready merkle branch hashes - std::vector get_stratum_merkle_branches() const; + std::vector get_stratum_merkle_branches() const override; // Return coinb1 and coinb2 (coinbase parts split around extranonce) - std::pair get_coinbase_parts() const; + std::pair get_coinbase_parts() const override; // Return whether segwit is active in the current template bool get_segwit_active() const; @@ -710,7 +675,7 @@ class MiningInterface : public jsonrpccxx::JsonRpc2Server WorkSnapshot get_work_snapshot() const; // Returns current GBT previousblockhash (for stale block template detection) - std::string get_current_gbt_prevhash() const; + std::string get_current_gbt_prevhash() const override; // Found block status and record (Layer +2 — blockchain-accepted blocks) enum class BlockStatus : uint8_t { @@ -937,7 +902,7 @@ class MiningInterface : public jsonrpccxx::JsonRpc2Server void set_address_fallback_fn(address_fallback_fn_t fn) { m_address_fallback_fn = std::move(fn); } // Return true if the merged-mining manager has a configured chain with chain_id. - bool has_merged_chain(uint32_t chain_id) const; + bool has_merged_chain(uint32_t chain_id) const override; // Extract the 40-char hex hash160 from the node fee scriptPubKey (P2PKH bytes 3-22). // Returns "" if the fee script is not a 25-byte P2PKH. @@ -1020,7 +985,12 @@ class MiningInterface : public jsonrpccxx::JsonRpc2Server public: // Monotonically increasing counter — incremented on each refresh_work(). // Used by per-miner safety timer to avoid pushing unchanged work. - uint64_t get_work_generation() const { return m_work_generation.load(); } + uint64_t get_work_generation() const override { return m_work_generation.load(); } + + // IWorkSource atomic-state getters — replace direct m_share_bits.load() + // member access from stratum_server (post-extraction). + uint32_t get_share_bits() const override { return m_share_bits.load(); } + uint32_t get_share_max_bits() const override { return m_share_max_bits.load(); } // Share target from compute_share_target() — set by ref_hash_fn, used by // mining.notify (nbits) and share creation (params.bits). @@ -1215,7 +1185,7 @@ class MiningInterface : public jsonrpccxx::JsonRpc2Server std::string m_auth_token; // auth token for sensitive endpoints; empty = no auth public: void set_stratum_config(const StratumConfig& cfg) { m_stratum_config = cfg; } - const StratumConfig& get_stratum_config() const { return m_stratum_config; } + const StratumConfig& get_stratum_config() const override { return m_stratum_config; } void set_cors_origin(const std::string& origin) { m_cors_origin = origin; } const std::string& get_cors_origin() const { return m_cors_origin; } @@ -1472,24 +1442,13 @@ class MiningInterface : public jsonrpccxx::JsonRpc2Server // ── Stratum worker session tracking ────────────────────────────────── public: - struct WorkerInfo { - std::string username; // miner address (after parsing) - std::string worker_name; // worker suffix from "ADDRESS.worker" (e.g. "alpha") - double hashrate{0.0}; // measured H/s from HashrateTracker - double dead_hashrate{0.0}; // DOA H/s - double difficulty{1.0}; // current vardiff difficulty - uint64_t accepted{0}; - uint64_t rejected{0}; - uint64_t stale{0}; - std::chrono::steady_clock::time_point connected_at; - std::string remote_endpoint; // "ip:port" - }; + using WorkerInfo = core::stratum::WorkerInfo; - void register_stratum_worker(const std::string& session_id, const WorkerInfo& info); - void unregister_stratum_worker(const std::string& session_id); + void register_stratum_worker(const std::string& session_id, const 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); + uint64_t accepted, uint64_t rejected, uint64_t stale) override; std::map get_stratum_workers() const; private: diff --git a/src/impl/CMakeLists.txt b/src/impl/CMakeLists.txt index 712c77ba6..42f7719e4 100644 --- a/src/impl/CMakeLists.txt +++ b/src/impl/CMakeLists.txt @@ -1 +1,2 @@ -add_subdirectory(ltc) \ No newline at end of file +add_subdirectory(ltc) +add_subdirectory(btc) \ No newline at end of file diff --git a/src/impl/btc/CMakeLists.txt b/src/impl/btc/CMakeLists.txt new file mode 100644 index 000000000..162604d17 --- /dev/null +++ b/src/impl/btc/CMakeLists.txt @@ -0,0 +1,15 @@ +add_subdirectory(coin) +add_subdirectory(stratum) + +add_library(btc + config_coin.hpp config_coin.cpp config_pool.hpp config_pool.cpp config.hpp + node.hpp node.cpp + peer.hpp + protocol_actual.cpp protocol_legacy.cpp + messages.hpp + share_types.hpp share.hpp +) + +target_link_libraries(btc core pool sharechain btc_coin btclibs c2pool_storage c2pool_hashrate ${SECP256K1_LIBRARIES}) # OBJECT-lib SCC direct-naming (#22/#39) — vardiff/HashrateTracker link fix + +# add_subdirectory(test) # B0: disabled — share_test.cpp references LTC types, restore in later phase \ No newline at end of file diff --git a/src/impl/btc/auto_ratchet.hpp b/src/impl/btc/auto_ratchet.hpp new file mode 100644 index 000000000..46fb0f077 --- /dev/null +++ b/src/impl/btc/auto_ratchet.hpp @@ -0,0 +1,380 @@ +#pragma once + +// AutoRatchet: autonomous share version transition state machine. +// +// Manages V35 → V36 (and future) share format transitions without manual +// operator coordination. Persists state to JSON file so restarts don't +// regress once the network has confirmed a new version. +// +// State machine: +// +// VOTING -------(95% desired_version >= target)------> ACTIVATED +// ^ | +// |---(<50% desired_version >= target)---< | +// | +// (sustained 2*CHAIN_LENGTH at 95% new-format shares) | +// v +// VOTING <--(follows old network, keeps voting)------- CONFIRMED +// ^ | +// |---(<50% votes, network genuinely old)---< | +// | +// (permanent on restart) | +// CONFIRMED <--------------------------+ +// +// Port of p2pool-v36 data.py AutoRatchet (lines 2109-2344). + +#include "config_pool.hpp" +#include "share_tracker.hpp" +#include + +#include +#include +#include +#include +#include + +namespace btc +{ + +enum class RatchetState : uint8_t +{ + VOTING = 0, // producing old-format shares, voting for upgrade + ACTIVATED = 1, // producing new-format shares, monitoring support + CONFIRMED = 2 // permanent: new format confirmed, survives restart +}; + +inline const char* ratchet_state_str(RatchetState s) +{ + switch (s) { + case RatchetState::VOTING: return "VOTING"; + case RatchetState::ACTIVATED: return "ACTIVATED"; + case RatchetState::CONFIRMED: return "CONFIRMED"; + } + return "UNKNOWN"; +} + +class AutoRatchet +{ +public: + // Thresholds (matching Python reference) + static constexpr int ACTIVATION_THRESHOLD = 95; // % votes to activate + static constexpr int DEACTIVATION_THRESHOLD = 50; // % below which to revert + static constexpr int CONFIRMATION_MULTIPLIER = 2; // confirm after 2x CHAIN_LENGTH + static constexpr int SWITCH_THRESHOLD = 60; // % required for format switch in validation + + explicit AutoRatchet(const std::string& state_file_path = "", + int64_t target_version = 36) + : state_file_(state_file_path) + , target_version_(target_version) + { + load(); + } + + RatchetState state() const { return state_; } + int64_t target_version() const { return target_version_; } + + /// Determine which share version to produce based on network state. + /// Returns (share_version, desired_version_to_vote). + /// + /// current_version: the version we're currently producing (e.g. 36) + /// target_version: the version we want to upgrade to (e.g. 37) + std::pair get_share_version( + ShareTracker& tracker, + const uint256& best_share_hash) + { + const int64_t current_version = target_version_ - 1; // e.g. 36 + const uint32_t chain_length = PoolConfig::chain_length(); + const uint32_t confirmation_window = chain_length * CONFIRMATION_MULTIPLIER; + + // No chain — use persisted state for bootstrap + if (best_share_hash.IsNull() || + !tracker.chain.contains(best_share_hash) || + tracker.chain.get_height(best_share_hash) < 1) + { + if (state_ == RatchetState::CONFIRMED) + return {target_version_, target_version_}; + return {current_version, target_version_}; + } + + // Count votes and actual new-format shares in window. + // p2pool uses tracker.get_height() (chain depth) for both sampling + // and confirmation counting. This matches data.py:2488,2576. + int32_t height = tracker.chain.get_height(best_share_hash); + int32_t sample = std::min(height, static_cast(chain_length)); + + int32_t target_votes = 0; // shares voting desired_version >= target + int32_t target_shares = 0; // shares actually IN target format + int32_t total = 0; + + auto chain_view = tracker.chain.get_chain(best_share_hash, sample); + for (auto [hash, data] : chain_view) + { + ++total; + data.share.invoke([&](auto* obj) { + if (static_cast(obj->m_desired_version) >= target_version_) + ++target_votes; + if (static_cast(std::remove_pointer_t::version) >= target_version_) + ++target_shares; + }); + } + + if (total == 0) + { + if (state_ == RatchetState::CONFIRMED) + return {target_version_, target_version_}; + return {current_version, target_version_}; + } + + int vote_pct = (target_votes * 100) / total; + int share_pct = (target_shares * 100) / total; + bool full_window = (total >= static_cast(chain_length)); + + // --- State transitions --- + auto old_state = state_; + + if (state_ == RatchetState::VOTING) + { + if (full_window && vote_pct >= ACTIVATION_THRESHOLD) + { + // Tail guard: oldest 10% of window must have >= 60% signaling + // (jtoomim rule). Prevents activation before the entire PPLNS + // window has transitioned — p2pool check() rejects V36 shares + // when the oldest 10% has < 60% signaling. + uint32_t tail_start = (chain_length * 9) / 10; + uint32_t tail_size = chain_length / 10; + auto tail_ancestor = tracker.chain.get_nth_parent_key(best_share_hash, tail_start); + auto tail_counts = tracker.get_desired_version_counts(tail_ancestor, tail_size); + + int64_t tail_target = 0, tail_total = 0; + for (auto& [ver, cnt] : tail_counts) { + tail_total += cnt; + if (ver >= target_version_) + tail_target += cnt; + } + int tail_pct = (tail_total > 0) ? static_cast(tail_target * 100 / tail_total) : 0; + + if (tail_pct < SWITCH_THRESHOLD) { + static int tail_log = 0; + if (tail_log++ % 20 == 0) + LOG_INFO << "[AutoRatchet] VOTING: full window " << vote_pct + << "% >= " << ACTIVATION_THRESHOLD << "% but oldest 10% only " + << tail_pct << "% (need " << SWITCH_THRESHOLD << "%) — waiting"; + // Don't transition yet + } + else + { + state_ = RatchetState::ACTIVATED; + activated_at_ = now_seconds(); + activated_height_ = height; + // Credit retroactive shares for late-joining nodes + // p2pool data.py:2535: retroactive = max(0, height - net.CHAIN_LENGTH) + int32_t retroactive = std::max(0, height - static_cast(chain_length)); + confirm_count_ = retroactive; + last_seen_height_ = height; + + LOG_INFO << "[AutoRatchet] VOTING -> ACTIVATED (" + << vote_pct << "% of " << total << " shares vote V" + << target_version_ << ", window=" << chain_length + << ", retroactive=" << retroactive << ")"; + + // Skip to CONFIRMED if chain is already deep enough + if (retroactive >= static_cast(confirmation_window) && + share_pct >= ACTIVATION_THRESHOLD) + { + state_ = RatchetState::CONFIRMED; + confirmed_at_ = now_seconds(); + LOG_INFO << "[AutoRatchet] VOTING -> CONFIRMED (retroactive: " + << retroactive << " >= " << confirmation_window << ")"; + } + save(); + } // else (tail guard passed) + } + } + else if (state_ == RatchetState::ACTIVATED) + { + if (full_window && vote_pct < DEACTIVATION_THRESHOLD) + { + // Network genuinely reverted + state_ = RatchetState::VOTING; + activated_at_ = 0; + activated_height_ = 0; + confirm_count_ = 0; + last_seen_height_ = 0; + LOG_INFO << "[AutoRatchet] ACTIVATED -> VOTING (" + << vote_pct << "% < " << DEACTIVATION_THRESHOLD << "% threshold)"; + save(); + } + else if (activated_height_ > 0) + { + // Track cumulative height increases using chain depth. + // p2pool data.py:2576: uses tracker.get_height() (chain depth). + if (last_seen_height_ > 0 && height > last_seen_height_) + confirm_count_ += (height - last_seen_height_); + last_seen_height_ = height; + + { + static int ac_log = 0; + if (ac_log++ % 20 == 0) + LOG_INFO << "[AutoRatchet] ACTIVATED: vote=" << vote_pct + << "% share=" << share_pct << "% full=" << (full_window ? "True" : "False") + << " height=" << height + << " confirm=" << confirm_count_ << "/" << confirmation_window; + } + + if (confirm_count_ >= static_cast(confirmation_window) && + share_pct >= ACTIVATION_THRESHOLD) + { + state_ = RatchetState::CONFIRMED; + confirmed_at_ = now_seconds(); + LOG_INFO << "[AutoRatchet] ACTIVATED -> CONFIRMED (" + << confirm_count_ << " cumulative shares, " + << share_pct << "% V" << target_version_ << ")"; + save(); + } + } + } + else if (state_ == RatchetState::CONFIRMED) + { + // CONFIRMED is permanent, but respect network consensus + if (full_window && vote_pct < DEACTIVATION_THRESHOLD) + { + LOG_WARNING << "[AutoRatchet] CONFIRMED but network is " + << (100 - vote_pct) << "% old version — following consensus"; + return {current_version, target_version_}; + } + } + + if (old_state != state_) + { + LOG_INFO << "[AutoRatchet] State: " << ratchet_state_str(old_state) + << " -> " << ratchet_state_str(state_); + } + + // Output + if (state_ == RatchetState::ACTIVATED || state_ == RatchetState::CONFIRMED) + return {target_version_, target_version_}; + else + return {current_version, target_version_}; + } + + /// Validate a version switch between consecutive shares. + /// Returns empty string if valid, error message if invalid. + /// Implements the 60% switch rule from Python check() method. + static std::string validate_version_switch( + int64_t share_version, int64_t prev_version, + ShareTracker& tracker, const uint256& prev_hash) + { + // Same version — always ok + if (share_version == prev_version) + return {}; + + int32_t height = tracker.chain.get_height(prev_hash); + uint32_t chain_length = PoolConfig::chain_length(); + + if (height < static_cast(chain_length)) + { + // Not enough history for version switch + if (share_version > prev_version) + return "version switch without enough history"; + return {}; // downgrade ok without history + } + + // Upgrade: requires 60% in sampling window [CHAIN_LENGTH*9/10, CHAIN_LENGTH] + if (share_version == prev_version + 1) + { + uint32_t window_start = (chain_length * 9) / 10; + uint32_t window_size = chain_length / 10; + auto ancestor = tracker.chain.get_nth_parent_key(prev_hash, window_start); + auto counts = tracker.get_desired_version_counts(ancestor, window_size); + + int64_t new_ver_count = 0; + int64_t total_count = 0; + for (auto& [ver, cnt] : counts) + { + total_count += cnt; + if (ver >= share_version) + new_ver_count += cnt; + } + + if (total_count > 0 && new_ver_count * 100 < total_count * SWITCH_THRESHOLD) + return "version switch without enough hash power upgraded (" + + std::to_string(new_ver_count * 100 / total_count) + + "% < " + std::to_string(SWITCH_THRESHOLD) + "%)"; + return {}; + } + + // Downgrade by 1 (AutoRatchet deactivation): allowed + if (share_version == prev_version - 1) + return {}; + + // Multi-version jump: not allowed + return "invalid version jump from " + std::to_string(prev_version) + + " to " + std::to_string(share_version); + } + +private: + std::string state_file_; + int64_t target_version_; + RatchetState state_ = RatchetState::VOTING; + int64_t activated_at_ = 0; + int32_t activated_height_ = 0; + int64_t confirmed_at_ = 0; + int32_t confirm_count_ = 0; + int32_t last_seen_height_ = 0; + + static int64_t now_seconds() + { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + } + + void load() + { + if (state_file_.empty()) return; + try { + std::ifstream f(state_file_); + if (!f.is_open()) return; + nlohmann::json j; + f >> j; + std::string s = j.value("state", "voting"); + if (s == "activated") state_ = RatchetState::ACTIVATED; + else if (s == "confirmed") state_ = RatchetState::CONFIRMED; + else state_ = RatchetState::VOTING; + activated_at_ = j.value("activated_at", int64_t(0)); + activated_height_ = j.value("activated_height", int32_t(0)); + confirmed_at_ = j.value("confirmed_at", int64_t(0)); + confirm_count_ = j.value("confirm_count", int32_t(0)); + LOG_INFO << "[AutoRatchet] Loaded state: " << ratchet_state_str(state_) + << " (target=V" << target_version_ + << " confirmed_at=" << confirmed_at_ + << " confirm_count=" << confirm_count_ << ")"; + } catch (const std::exception& e) { + LOG_WARNING << "[AutoRatchet] Failed to load state: " << e.what(); + } + } + + void save() + { + if (state_file_.empty()) return; + try { + nlohmann::json j; + switch (state_) { + case RatchetState::VOTING: j["state"] = "voting"; break; + case RatchetState::ACTIVATED: j["state"] = "activated"; break; + case RatchetState::CONFIRMED: j["state"] = "confirmed"; break; + } + j["activated_at"] = activated_at_; + j["activated_height"] = activated_height_; + j["confirmed_at"] = confirmed_at_; + j["confirm_count"] = confirm_count_; + j["target_version"] = target_version_; + + std::ofstream f(state_file_); + f << j.dump(2); + } catch (const std::exception& e) { + LOG_WARNING << "[AutoRatchet] Failed to save state: " << e.what(); + } + } +}; + +} // namespace btc diff --git a/src/impl/btc/coin/CMakeLists.txt b/src/impl/btc/coin/CMakeLists.txt new file mode 100644 index 000000000..9b4c36124 --- /dev/null +++ b/src/impl/btc/coin/CMakeLists.txt @@ -0,0 +1,25 @@ + +set(btc_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(btc_coin_interface + txidcache.hpp + node_interface.hpp + block.hpp + transaction.hpp transaction.cpp + header_chain.hpp + mempool.hpp + template_builder.hpp +) + + +add_library(btc_coin ${btc_coin_interface} ${btc_coin_impl}) + +target_link_libraries(btc_coin core nlohmann_json::nlohmann_json) \ No newline at end of file diff --git a/src/impl/btc/coin/block.hpp b/src/impl/btc/coin/block.hpp new file mode 100644 index 000000000..35fee8985 --- /dev/null +++ b/src/impl/btc/coin/block.hpp @@ -0,0 +1,116 @@ +#pragma once + +#include "transaction.hpp" + +#include +#include +#include + +namespace btc +{ + +namespace coin +{ + +struct SmallBlockHeaderType +{ + uint64_t m_version {}; + uint256 m_previous_block{}; + uint32_t m_timestamp{}; + uint32_t m_bits{}; + uint32_t m_nonce{}; + + SERIALIZE_METHODS(SmallBlockHeaderType) { READWRITE(VarInt(obj.m_version), obj.m_previous_block, obj.m_timestamp, obj.m_bits, obj.m_nonce); } + + SmallBlockHeaderType() {} + + void SetNull() + { + m_version = 0; + m_previous_block.SetNull(); + m_timestamp = 0; + m_bits = 0; + m_nonce = 0; + } + + bool IsNull() const + { + return (m_bits == 0); + } +}; + +struct BlockHeaderType : SmallBlockHeaderType +{ + uint256 m_merkle_root; + + // Full block header uses fixed 4-byte int32 version (not VarInt like SmallBlockHeaderType) + template + void Serialize(Stream& s) const { + uint32_t version32 = static_cast(m_version); + ::Serialize(s, version32); + ::Serialize(s, m_previous_block); + ::Serialize(s, m_merkle_root); + ::Serialize(s, m_timestamp); + ::Serialize(s, m_bits); + ::Serialize(s, m_nonce); + } + template + void Unserialize(Stream& s) { + uint32_t version32; + ::Unserialize(s, version32); + m_version = version32; + ::Unserialize(s, m_previous_block); + ::Unserialize(s, m_merkle_root); + ::Unserialize(s, m_timestamp); + ::Unserialize(s, m_bits); + ::Unserialize(s, m_nonce); + } + + BlockHeaderType() : SmallBlockHeaderType() { } + + void SetNull() + { + SmallBlockHeaderType::SetNull(); + m_merkle_root.SetNull(); + } + + bool IsNull() const + { + return (m_bits == 0); + } + +}; + +struct BlockType : BlockHeaderType +{ + std::vector m_txs; + + template + void Serialize(Stream& s) const { + BlockHeaderType::Serialize(s); + ::Serialize(s, TX_WITH_WITNESS(m_txs)); + } + + template + void Unserialize(Stream& s) { + BlockHeaderType::Unserialize(s); + ::Unserialize(s, TX_WITH_WITNESS(m_txs)); + } + + BlockType() : BlockHeaderType() { } + + void SetNull() + { + BlockHeaderType::SetNull(); + m_txs.clear(); + } + + bool IsNull() const + { + return BlockHeaderType::IsNull(); + } +}; + +} // namespace coin + +} // namespace btc \ No newline at end of file diff --git a/src/impl/btc/coin/block_json.hpp b/src/impl/btc/coin/block_json.hpp new file mode 100644 index 000000000..fb8f78755 --- /dev/null +++ b/src/impl/btc/coin/block_json.hpp @@ -0,0 +1,259 @@ +#pragma once + +/// block_to_explorer_json(): Convert a BlockType into litecoind-compatible +/// getblock verbosity=2 JSON for the lite block explorer API. +/// +/// Handles: full header fields, all script types (P2PKH/P2SH/P2WPKH/P2WSH/ +/// P2TR/P2PK/P2MS/OP_RETURN/nonstandard), THE commitment decoding for +/// c2pool-found blocks. + +#include "block.hpp" +#include "transaction.hpp" +#include "mempool.hpp" // compute_txid() + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace btc { +namespace coin { + +/// Chain parameters needed for address encoding. +struct ExplorerChainParams { + std::string bech32_hrp; // "bc" (BTC mainnet), "tb" (testnet) + uint8_t p2pkh_ver; // 0x00 (BTC mainnet), 0x6f (testnet) + uint8_t p2sh_ver; // 0x05 (BTC mainnet), 0xc4 (testnet) + std::string chain_name; // "main", "test", etc. +}; + +namespace detail { + +inline std::string to_hex(const unsigned char* data, size_t len) { + static const char H[] = "0123456789abcdef"; + std::string out; + out.reserve(len * 2); + for (size_t i = 0; i < len; ++i) { + out += H[data[i] >> 4]; + out += H[data[i] & 0x0f]; + } + return out; +} + +inline std::string bits_to_hex_str(uint32_t bits) { + char buf[9]; + snprintf(buf, sizeof(buf), "%08x", bits); + return buf; +} + +inline nlohmann::json classify_to_json(const core::ScriptClassification& sc) { + nlohmann::json spk; + spk["type"] = sc.type; + spk["hex"] = sc.hex; + + if (!sc.addresses.empty()) { + spk["addresses"] = sc.addresses; + if (sc.addresses.size() == 1) + spk["address"] = sc.addresses[0]; + } + + if (sc.type == "pubkey" && !sc.pubkey.empty()) + spk["pubkey"] = sc.pubkey; + + if (sc.type == "multisig") { + nlohmann::json ms; + ms["required"] = sc.multisig_required; + ms["total"] = sc.multisig_total; + nlohmann::json pks = nlohmann::json::array(); + for (size_t i = 0; i < sc.multisig_pubkeys.size(); ++i) { + nlohmann::json pk; + pk["hex"] = sc.multisig_pubkeys[i]; + if (i < sc.multisig_addresses.size()) + pk["address"] = sc.multisig_addresses[i]; + pks.push_back(std::move(pk)); + } + ms["pubkeys"] = std::move(pks); + spk["multisig"] = std::move(ms); + } + + if (sc.type == "nulldata" && !sc.op_return_hex.empty()) + spk["data"] = sc.op_return_hex; + + return spk; +} + +/// Parse c2pool THE commitment from coinbase scriptSig. +/// Layout: [BIP34 height push][...optional AuxPoW...]["/c2pool/" tag][state_root 32B][TheMetadata] +inline nlohmann::json parse_the_commitment(const std::vector& scriptSig) { + // Search for "/c2pool/" tag + const uint8_t tag[] = {'/', 'c', '2', 'p', 'o', 'o', 'l', '/'}; + const size_t tag_len = 8; + const auto* data = scriptSig.data(); + const size_t len = scriptSig.size(); + + size_t tag_pos = std::string::npos; + for (size_t i = 0; i + tag_len <= len; ++i) { + if (std::memcmp(data + i, tag, tag_len) == 0) { + tag_pos = i; + break; + } + } + if (tag_pos == std::string::npos) + return nullptr; + + nlohmann::json the; + the["pool_tag"] = "/c2pool/"; + + size_t after_tag = tag_pos + tag_len; + + // State root: 32 bytes after tag + if (after_tag + 32 <= len) { + the["state_root"] = to_hex(data + after_tag, 32); + size_t meta_start = after_tag + 32; + + // TheMetadata: remaining bytes + if (meta_start < len) { + auto meta = c2pool::TheMetadata::unpack(data + meta_start, len - meta_start); + nlohmann::json md; + md["version"] = meta.version; + md["sharechain_height"] = meta.sharechain_height; + md["miner_count"] = meta.miner_count; + md["hashrate_class"] = meta.hashrate_class; + double hr = c2pool::TheMetadata::decode_hashrate(meta.hashrate_class); + // Format human-readable hashrate + const char* suffix = "H/s"; + if (hr >= 1e12) { hr /= 1e12; suffix = "TH/s"; } + else if (hr >= 1e9) { hr /= 1e9; suffix = "GH/s"; } + else if (hr >= 1e6) { hr /= 1e6; suffix = "MH/s"; } + else if (hr >= 1e3) { hr /= 1e3; suffix = "KH/s"; } + char buf[64]; + snprintf(buf, sizeof(buf), "%.2f %s", hr, suffix); + md["hashrate_human"] = buf; + + // Chain fingerprint as hex + char fp[17]; + snprintf(fp, sizeof(fp), "%016lx", + static_cast(meta.chain_fingerprint)); + md["chain_fingerprint"] = fp; + md["share_period"] = meta.share_period; + md["verified_length"] = meta.verified_length; + the["metadata"] = std::move(md); + } + } else { + the["state_root"] = nullptr; + } + + return the; +} + +} // namespace detail + +/// Convert a BlockType + metadata into litecoind-compatible getblock JSON. +inline nlohmann::json block_to_explorer_json( + const BlockType& block, + uint32_t height, + const uint256& block_hash, + const ExplorerChainParams& params) +{ + using namespace detail; + + nlohmann::json j; + + // Block header fields + j["hash"] = block_hash.GetHex(); + j["height"] = height; + j["version"] = static_cast(block.m_version); + j["previousblockhash"] = block.m_previous_block.GetHex(); + j["merkleroot"] = block.m_merkle_root.GetHex(); + j["time"] = block.m_timestamp; + j["bits"] = bits_to_hex_str(block.m_bits); + j["nonce"] = block.m_nonce; + + // Difficulty from bits + auto target = chain::bits_to_target(block.m_bits); + j["difficulty"] = chain::target_to_difficulty(target); + + // Compute serialized size + { + PackStream ps; + ps << block; + j["size"] = ps.get_span().size(); + } + + // Transactions + nlohmann::json txs = nlohmann::json::array(); + bool first_tx = true; + for (const auto& mtx : block.m_txs) { + nlohmann::json tx; + + // txid (non-witness hash) + uint256 txid = compute_txid(mtx); + tx["txid"] = txid.GetHex(); + + // vin + nlohmann::json vins = nlohmann::json::array(); + if (first_tx && !mtx.vin.empty()) { + // Coinbase + nlohmann::json vin; + auto& scriptSig = mtx.vin[0].scriptSig; + std::vector sig_vec(scriptSig.m_data.begin(), scriptSig.m_data.end()); + vin["coinbase"] = to_hex(sig_vec.data(), sig_vec.size()); + vin["sequence"] = mtx.vin[0].sequence; + vins.push_back(std::move(vin)); + } else { + for (const auto& in : mtx.vin) { + nlohmann::json vin; + vin["txid"] = in.prevout.hash.GetHex(); + vin["vout"] = in.prevout.index; + vins.push_back(std::move(vin)); + } + } + tx["vin"] = std::move(vins); + + // vout + nlohmann::json vouts = nlohmann::json::array(); + for (size_t i = 0; i < mtx.vout.size(); ++i) { + nlohmann::json vout; + vout["value"] = static_cast(mtx.vout[i].value) / 1e8; + vout["n"] = i; + + std::vector script_vec( + mtx.vout[i].scriptPubKey.m_data.begin(), + mtx.vout[i].scriptPubKey.m_data.end()); + auto sc = core::classify_script(script_vec, + params.bech32_hrp, params.p2pkh_ver, params.p2sh_ver); + vout["scriptPubKey"] = classify_to_json(sc); + + vouts.push_back(std::move(vout)); + } + tx["vout"] = std::move(vouts); + + txs.push_back(std::move(tx)); + first_tx = false; + } + j["tx"] = std::move(txs); + + // THE commitment (c2pool-found blocks) + if (!block.m_txs.empty() && !block.m_txs[0].vin.empty()) { + auto& scriptSig = block.m_txs[0].vin[0].scriptSig; + std::vector sig_vec(scriptSig.m_data.begin(), scriptSig.m_data.end()); + auto the = parse_the_commitment(sig_vec); + if (!the.is_null()) + j["_the"] = std::move(the); + } + + return j; +} + +} // namespace coin +} // namespace btc diff --git a/src/impl/btc/coin/chain_seeds.hpp b/src/impl/btc/coin/chain_seeds.hpp new file mode 100644 index 000000000..ec5687c32 --- /dev/null +++ b/src/impl/btc/coin/chain_seeds.hpp @@ -0,0 +1,106 @@ +#pragma once + +/// DNS seed hostnames and hardcoded fallback IPs for Bitcoin P2P networks. +/// Sources: +/// - mainnet/testnet3/testnet4 DNS seeds: ref/bitcoin/src/kernel/chainparams.cpp +/// - mainnet fixed seed sample: well-known long-running BTC nodes +/// +/// Trailing dot on hostnames matches bitcoind's chainparams (skips the local +/// resolv.conf search list — important when the host's first search domain +/// shadows a public TLD). + +#include +#include +#include + +namespace btc { +namespace coin { + +/// DNS seeds for Bitcoin mainnet (port 8333). +/// Source: ref/bitcoin/src/kernel/chainparams.cpp lines 136-143. +inline std::vector btc_mainnet_dns_seeds() +{ + return { + {"seed.bitcoin.sipa.be", 8333}, // Pieter Wuille + {"dnsseed.bluematt.me", 8333}, // Matt Corallo + {"seed.bitcoin.jonasschnelli.ch", 8333}, // Jonas Schnelli + {"seed.btc.petertodd.net", 8333}, // Peter Todd + {"seed.bitcoin.sprovoost.nl", 8333}, // Sjors Provoost + {"dnsseed.emzy.de", 8333}, // Stephan Oeste + {"seed.bitcoin.wiz.biz", 8333}, // Jason Maurice + {"seed.mainnet.achownodes.xyz", 8333}, // Ava Chow + }; +} + +/// DNS seeds for Bitcoin testnet3 (port 18333). +/// Source: ref/bitcoin/src/kernel/chainparams.cpp lines 252-256. +inline std::vector btc_testnet_dns_seeds() +{ + return { + {"testnet-seed.bitcoin.jonasschnelli.ch", 18333}, + {"seed.tbtc.petertodd.net", 18333}, + {"seed.testnet.bitcoin.sprovoost.nl", 18333}, + {"testnet-seed.bluematt.me", 18333}, + {"seed.testnet.achownodes.xyz", 18333}, + }; +} + +/// DNS seeds for Bitcoin testnet4 (port 48333). +/// Source: ref/bitcoin/src/kernel/chainparams.cpp lines 360-361. +/// testnet4 is the preferred c2pool-btc B2 integration target — fast, fresh. +inline std::vector btc_testnet4_dns_seeds() +{ + return { + {"seed.testnet4.bitcoin.sprovoost.nl", 48333}, + {"seed.testnet4.wiz.biz", 48333}, + }; +} + +/// Hardcoded fallback peers for Bitcoin mainnet (port 8333). +/// Used if DNS seeds fail after 60 seconds. Sample of well-known long-running +/// BTC nodes — replace with current top-uptime peers if these become stale. +inline std::vector btc_mainnet_fixed_seeds() +{ + return { + // Bitcoin Core developer + community-known stable nodes + {"seed.bitcoin.sipa.be", 8333}, + {"dnsseed.bluematt.me", 8333}, + {"seed.bitcoin.sprovoost.nl", 8333}, + {"seed.bitcoin.jonasschnelli.ch", 8333}, + }; +} + +/// Hardcoded fallback peers for Bitcoin testnet3. +inline std::vector btc_testnet_fixed_seeds() +{ + return { + {"seed.testnet.bitcoin.sprovoost.nl", 18333}, + {"testnet-seed.bitcoin.jonasschnelli.ch", 18333}, + }; +} + +/// Hardcoded fallback peers for Bitcoin testnet4. +inline std::vector btc_testnet4_fixed_seeds() +{ + return { + {"seed.testnet4.bitcoin.sprovoost.nl", 48333}, + {"seed.testnet4.wiz.biz", 48333}, + }; +} + +/// Get DNS seeds for the appropriate BTC network. +/// (testnet currently routes to testnet3 — switch to testnet4 in B2 if it's +/// the integration target there.) +inline std::vector btc_dns_seeds(bool testnet) +{ + return testnet ? btc_testnet_dns_seeds() : btc_mainnet_dns_seeds(); +} + +/// Get fixed fallback seeds for the appropriate BTC network. +inline std::vector btc_fixed_seeds(bool testnet) +{ + return testnet ? btc_testnet_fixed_seeds() : btc_mainnet_fixed_seeds(); +} + +} // namespace coin +} // namespace btc diff --git a/src/impl/btc/coin/coin_node.cpp b/src/impl/btc/coin/coin_node.cpp new file mode 100644 index 000000000..58e2fc789 --- /dev/null +++ b/src/impl/btc/coin/coin_node.cpp @@ -0,0 +1,50 @@ +#include "coin_node.hpp" + +#include +#include + +namespace btc +{ + +namespace coin +{ + +core::coin::WorkView CoinNode::get_work_view() +{ + // No work source configured at all -> empty view (1:1 with ltc reference). + if (!m_embedded && !m_rpc) + return {}; + + // Embedded preferred, external RPC fallback. getwork() throws + // std::runtime_error when no template can be produced -- propagated to the + // caller (web_server), matching the ICoinNode contract. + rpc::WorkData wd = m_embedded ? m_embedded->getwork() : m_rpc->getwork(); + + // Retain the FULL WorkData (incl. m_txs) coin-side. Variable::set takes its + // argument BY VALUE, so this copies wd; sequenced BEFORE the std::move()s + // below so the copy completes first -- never move out of wd beforehand. + work.set(wd); + + core::coin::WorkView v; + v.m_data = std::move(wd.m_data); + v.m_hashes = std::move(wd.m_hashes); + v.m_latency = wd.m_latency; + return v; +} + +bool CoinNode::submit_block_hex(const std::string& block_hex, bool ignore_failure) +{ + // Guard sits ahead of the submit: in embedded-preferred mode m_rpc can be + // null. Returning false is the correct "no RPC sink" result. + if (!m_rpc) + return false; + + // BTC's NodeRPC::submit_block_hex is already the agnostic 2-arg/no-mweb + // form (MWEB is LTC-specific and absent here), so we forward directly -- + // no 3-arg mweb="" coin-side overload exists or is needed for BTC. + return m_rpc->submit_block_hex(block_hex, ignore_failure); +} + +} // namespace coin + +} // namespace btc diff --git a/src/impl/btc/coin/coin_node.hpp b/src/impl/btc/coin/coin_node.hpp new file mode 100644 index 000000000..61441945b --- /dev/null +++ b/src/impl/btc/coin/coin_node.hpp @@ -0,0 +1,61 @@ +#pragma once + +// --------------------------------------------------------------------------- +// btc::coin::CoinNode -- concrete core::coin::ICoinNode for BTC (family-1 P2 +// WorkView seam). 1:1 mirror of the ltc::coin::CoinNode reference (piece-5(c)), +// member-mapped onto BTC's actual node types per ltc-doge's delegation +// ("swap for btc's actual node members; the control flow / sequencing are the +// load-bearing part"): +// m_embedded : CoinNodeInterface* -- EmbeddedCoinNode in-process source +// m_rpc : NodeRPC* -- external coin-RPC client +// work : Variable -- full per-coin WorkData kept coin-side +// +// Build-INERT: this defines the type but is NOT yet wired into web_server (that +// is a separate member-wiring cluster). BTC is intentionally NOT green at this +// commit -- the seam gate flips only after wiring + a clean nm census. +// --------------------------------------------------------------------------- + +#include + +#include +#include +#include + +#include "rpc.hpp" +#include "rpc_data.hpp" +#include "template_builder.hpp" + +namespace btc +{ + +namespace coin +{ + +class CoinNode : public core::coin::ICoinNode +{ + // Embedded in-process template source (EmbeddedCoinNode). May be null when + // the node runs RPC-only. is_embedded() reports its presence. + CoinNodeInterface* m_embedded = nullptr; + + // External coin-RPC client. May be null in embedded-preferred mode; it is + // the sole sink for submit_block_hex(). has_rpc() reports its presence. + NodeRPC* m_rpc = nullptr; + + // Full per-coin WorkData (incl. m_txs) retained coin-side via work.set(wd); + // only the agnostic WorkView slice crosses the seam into core/web_server. + Variable work; + +public: + CoinNode(CoinNodeInterface* embedded, NodeRPC* rpc) + : m_embedded(embedded), m_rpc(rpc) {} + + core::coin::WorkView get_work_view() override; + bool submit_block_hex(const std::string& block_hex, bool ignore_failure) override; + + bool is_embedded() const override { return m_embedded != nullptr; } + bool has_rpc() const override { return m_rpc != nullptr; } +}; + +} // namespace coin + +} // namespace btc diff --git a/src/impl/btc/coin/compact_blocks.hpp b/src/impl/btc/coin/compact_blocks.hpp new file mode 100644 index 000000000..43e789d45 --- /dev/null +++ b/src/impl/btc/coin/compact_blocks.hpp @@ -0,0 +1,319 @@ +#pragma once + +/// BIP 152 Compact Block Support +/// +/// Data structures and wire-format serialization for compact block relay. +/// Reduces block relay bandwidth by ~90-95%. +/// +/// Wire format (HeaderAndShortIDs): +/// block_header (80 bytes) | nonce (uint64) | +/// shortids_length (compact) | shortids[] (6 bytes each) | +/// prefilledtxn_length (compact) | prefilledtxn[] { diff_index (compact), tx } +/// +/// Prefilled transaction indexes use differential encoding: +/// first index is absolute; subsequent = (delta from previous + 1). + +#include "block.hpp" +#include "transaction.hpp" +#include "mempool.hpp" // compute_txid() + +#include +#include +#include +#include + +#include +#include +#include + +namespace btc { +namespace coin { + +// ─── Short Transaction ID (6 bytes) ───────────────────────────────────────── + +struct ShortTxID { + uint8_t data[6]{}; + + ShortTxID() = default; + explicit ShortTxID(uint64_t v) { + for (int i = 0; i < 6; ++i) data[i] = static_cast((v >> (i*8)) & 0xff); + } + uint64_t to_uint64() const { + uint64_t v = 0; + for (int i = 0; i < 6; ++i) v |= static_cast(data[i]) << (i*8); + return v; + } + bool operator==(const ShortTxID& o) const { return std::memcmp(data, o.data, 6) == 0; } + bool operator<(const ShortTxID& o) const { return std::memcmp(data, o.data, 6) < 0; } + + template + void Serialize(Stream& s) const { + s.write(std::as_bytes(std::span{data, 6})); + } + template + void Unserialize(Stream& s) { + s.read(std::as_writable_bytes(std::span{data, 6})); + } +}; + +// ─── Prefilled Transaction ─────────────────────────────────────────────────── + +struct PrefilledTransaction { + uint32_t index{0}; // absolute index in block (differential on wire) + MutableTransaction tx; +}; + +// ─── Compact Block (HeaderAndShortIDs) ────────────────────────────────────── + +struct CompactBlock { + BlockHeaderType header; + uint64_t nonce{0}; + std::vector short_ids; + std::vector prefilled_txns; + + /// Compute SipHash keys from header hash and nonce. + void GetSipHashKeys(uint64_t& k0, uint64_t& k1) const { + auto packed = pack(header); + uint256 hdr_hash = Hash(packed.get_span()); + k0 = hdr_hash.GetUint64(0) ^ nonce; + k1 = hdr_hash.GetUint64(1) ^ nonce; + } + + /// Compute SipHash short ID for a txid using this block's key. + ShortTxID GetShortID(const uint256& txid) const { + uint64_t k0, k1; + GetSipHashKeys(k0, k1); + uint64_t h = SipHashUint256(k0, k1, txid); + return ShortTxID(h & 0xFFFFFFFFFFFFULL); + } + + /// Compute SipHash short ID with pre-computed keys (for batch lookups). + static ShortTxID GetShortID(uint64_t k0, uint64_t k1, const uint256& txid) { + uint64_t h = SipHashUint256(k0, k1, txid); + return ShortTxID(h & 0xFFFFFFFFFFFFULL); + } + + /// Serialize as BIP 152 HeaderAndShortIDs wire format. + template + void Serialize(Stream& s) const { + ::Serialize(s, header); + ::Serialize(s, nonce); + + // Short IDs: compact_size + raw 6-byte entries + WriteCompactSize(s, short_ids.size()); + for (const auto& sid : short_ids) + sid.Serialize(s); + + // Prefilled txns: compact_size + differential-index-encoded entries + WriteCompactSize(s, prefilled_txns.size()); + uint32_t prev_index = 0; + for (size_t i = 0; i < prefilled_txns.size(); ++i) { + const auto& pt = prefilled_txns[i]; + // BIP 152: first index is absolute, subsequent are (index - prev - 1) + uint32_t diff = (i == 0) ? pt.index : (pt.index - prev_index - 1); + WriteCompactSize(s, diff); + ::Serialize(s, TX_WITH_WITNESS(pt.tx)); + prev_index = pt.index; + } + } + + /// Deserialize from BIP 152 HeaderAndShortIDs wire format. + template + void Unserialize(Stream& s) { + ::Unserialize(s, header); + ::Unserialize(s, nonce); + + // Short IDs + uint64_t n_short_ids = ReadCompactSize(s); + short_ids.resize(n_short_ids); + for (auto& sid : short_ids) + sid.Unserialize(s); + + // Prefilled txns (differential index decoding) + uint64_t n_prefilled = ReadCompactSize(s); + prefilled_txns.resize(n_prefilled); + uint32_t prev_index = 0; + for (size_t i = 0; i < n_prefilled; ++i) { + uint32_t diff = static_cast(ReadCompactSize(s)); + prefilled_txns[i].index = (i == 0) ? diff : (prev_index + diff + 1); + UnserializeTransaction(prefilled_txns[i].tx, s, TX_WITH_WITNESS); + prev_index = prefilled_txns[i].index; + } + } +}; + +// ─── Block Transactions Request (getblocktxn) ────────────────────────────── + +struct BlockTransactionsRequest { + uint256 blockhash; + std::vector indexes; // absolute indexes (differential on wire) + + template + void Serialize(Stream& s) const { + ::Serialize(s, blockhash); + WriteCompactSize(s, indexes.size()); + uint32_t prev = 0; + for (size_t i = 0; i < indexes.size(); ++i) { + uint32_t diff = (i == 0) ? indexes[i] : (indexes[i] - prev - 1); + WriteCompactSize(s, diff); + prev = indexes[i]; + } + } + + template + void Unserialize(Stream& s) { + ::Unserialize(s, blockhash); + uint64_t n = ReadCompactSize(s); + indexes.resize(n); + uint32_t prev = 0; + for (size_t i = 0; i < n; ++i) { + uint32_t diff = static_cast(ReadCompactSize(s)); + indexes[i] = (i == 0) ? diff : (prev + diff + 1); + prev = indexes[i]; + } + } +}; + +// ─── Block Transactions Response (blocktxn) ───────────────────────────────── + +struct BlockTransactionsResponse { + uint256 blockhash; + std::vector txs; + + template + void Serialize(Stream& s) const { + ::Serialize(s, blockhash); + WriteCompactSize(s, txs.size()); + for (const auto& tx : txs) + ::Serialize(s, TX_WITH_WITNESS(tx)); + } + + template + void Unserialize(Stream& s) { + ::Unserialize(s, blockhash); + uint64_t n = ReadCompactSize(s); + txs.resize(n); + for (auto& tx : txs) + UnserializeTransaction(tx, s, TX_WITH_WITNESS); + } +}; + +// ─── Builder ───────────────────────────────────────────────────────────────── + +/// Build a compact block from a full block header + transactions. +inline CompactBlock BuildCompactBlock(const BlockHeaderType& header, + const std::vector& txs, + uint64_t nonce = 0) +{ + CompactBlock cb; + cb.header = header; + cb.nonce = nonce; + + // Coinbase always prefilled (index 0) + if (!txs.empty()) { + PrefilledTransaction pt; + pt.index = 0; + pt.tx = txs[0]; + cb.prefilled_txns.push_back(std::move(pt)); + } + + // Remaining txs as short IDs (pre-compute SipHash keys once) + // BIP 152 v2: use wtxid (witness serialization hash) for short IDs. + if (txs.size() > 1) { + uint64_t k0, k1; + cb.GetSipHashKeys(k0, k1); + for (size_t i = 1; i < txs.size(); ++i) { + auto packed = pack(TX_WITH_WITNESS(txs[i])); + uint256 wtxid = Hash(packed.get_span()); + cb.short_ids.push_back(CompactBlock::GetShortID(k0, k1, wtxid)); + } + } + + return cb; +} + +// ─── Reconstruction ───────────────────────────────────────────────────────── + +/// Result of attempting to reconstruct a full block from a compact block. +struct CompactBlockReconstructionResult { + bool complete{false}; + BlockType block; + std::vector missing_indexes; // absolute tx indexes still needed +}; + +/// Attempt to reconstruct a full block from a compact block + known transactions. +/// @param cb The received compact block. +/// @param known_txs Map of wtxid → transaction (BIP 152 v2 uses wtxid for short ID matching). +/// @return Result with reconstructed block (if complete) or missing indexes. +inline CompactBlockReconstructionResult ReconstructBlock( + const CompactBlock& cb, + const std::map& known_txs) +{ + CompactBlockReconstructionResult result; + + // Total transaction count = short_ids + prefilled + size_t total_txs = cb.short_ids.size() + cb.prefilled_txns.size(); + + // Build transaction array + std::vector txs(total_txs); + std::vector filled(total_txs, false); + + // Place prefilled transactions + for (const auto& pt : cb.prefilled_txns) { + if (pt.index >= total_txs) { + // Invalid index — reconstruction fails + result.complete = false; + return result; + } + txs[pt.index] = pt.tx; + filled[pt.index] = true; + } + + // Build short ID → known tx lookup + uint64_t k0, k1; + cb.GetSipHashKeys(k0, k1); + + std::map short_id_map; + for (const auto& [txid, tx] : known_txs) { + ShortTxID sid = CompactBlock::GetShortID(k0, k1, txid); + uint64_t key = sid.to_uint64(); + // Collision detection: if two txs map to the same short ID, skip both + if (short_id_map.count(key)) + short_id_map[key] = nullptr; // mark collision + else + short_id_map[key] = &tx; + } + + // Match short IDs to known transactions + size_t sid_idx = 0; + for (size_t i = 0; i < total_txs; ++i) { + if (filled[i]) continue; + + if (sid_idx >= cb.short_ids.size()) { + // More unfilled slots than short IDs — malformed + result.complete = false; + return result; + } + + uint64_t key = cb.short_ids[sid_idx].to_uint64(); + auto it = short_id_map.find(key); + if (it != short_id_map.end() && it->second != nullptr) { + txs[i] = *(it->second); + filled[i] = true; + } else { + result.missing_indexes.push_back(static_cast(i)); + } + ++sid_idx; + } + + if (result.missing_indexes.empty()) { + result.complete = true; + static_cast(result.block) = cb.header; + result.block.m_txs = std::move(txs); + } + + return result; +} + +} // namespace coin +} // namespace btc diff --git a/src/impl/btc/coin/header_chain.hpp b/src/impl/btc/coin/header_chain.hpp new file mode 100644 index 000000000..6c146cd1e --- /dev/null +++ b/src/impl/btc/coin/header_chain.hpp @@ -0,0 +1,1212 @@ +#pragma once + +/// BTC Header Chain +/// +/// Validated header-only chain for Bitcoin, implementing headers-first +/// sync from bitcoind P2P peers. Tracks chain tip, height, and cumulative +/// work. Persistence via LevelDB for fast restarts. +/// +/// Adapted from src/impl/ltc/coin/header_chain.hpp — same retarget algorithm +/// (Bitcoin's classic 2016-block window), different constants (1209600s/600s +/// vs LTC's 302400s/150s) and PoW (SHA256d vs scrypt). + +#include "block.hpp" +#include // DEFAULT_MAX_TIP_AGE + +#include +#include +#include +#include +#include + +// (LTC's btclibs/crypto/scrypt.h removed — BTC PoW is SHA256d via core/hash.hpp) + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace btc { +namespace coin { + +// uint256 hasher for unordered containers (low-64 bits is already a uniform +// distribution over a cryptographic hash, no further mixing needed). +struct Uint256Hasher { + size_t operator()(const uint256& h) const { return h.GetLow64(); } +}; + +// ─── Index Entry ──────────────────────────────────────────────────────────── + +/// Status flags for header validation state +enum HeaderStatus : uint32_t { + HEADER_VALID_UNKNOWN = 0, + HEADER_VALID_HEADER = 1, // Header parsed and PoW valid + HEADER_VALID_TREE = 2, // Connected to genesis via prev_hash chain + HEADER_VALID_CHAIN = 3, // Difficulty validated +}; + +/// A validated header with chain metadata (in-memory representation). +/// +/// On BTC, two fields that the on-disk layout carries are computable from +/// the rest and are not stored in RAM: +/// - "PoW hash" is identical to block_hash (both SHA256d(header)). +/// - "prev_hash" is always header.m_previous_block. +/// Dropping them saves 64 B/entry × ~1M headers = ~60 MB peak heap. +/// +/// On-disk format is unchanged — see IndexEntryDiskV1 below. +struct IndexEntry { + BlockHeaderType header; + uint256 block_hash; // SHA256d(header) — the block hash used for getdata/inv + uint32_t height{0}; + uint256 chain_work; // cumulative work up to this header + HeaderStatus status{HEADER_VALID_UNKNOWN}; +}; + +/// Legacy 6-field on-disk layout. Kept verbatim for backward read compat AND +/// forward write compat (so a roll-back to a pre-Phase-1B binary can still +/// parse what we wrote). The duplicate `hash` and `prev_hash` fields are +/// derived from the slim IndexEntry at write time. +struct IndexEntryDiskV1 { + BlockHeaderType header; + uint256 hash; // SHA256d(header) — same as block_hash on BTC + uint256 block_hash; // SHA256d(header) — the block hash used for getdata/inv + uint32_t height{0}; + uint256 chain_work; // cumulative work up to this header + uint256 prev_hash; // == header.m_previous_block on BTC + HeaderStatus status{HEADER_VALID_UNKNOWN}; + + template + void Serialize(Stream& s) const { + ::Serialize(s, header); + ::Serialize(s, hash); + ::Serialize(s, block_hash); + ::Serialize(s, height); + ::Serialize(s, chain_work); + ::Serialize(s, prev_hash); + ::Serialize(s, static_cast(status)); + } + template + void Unserialize(Stream& s) { + ::Unserialize(s, header); + ::Unserialize(s, hash); + ::Unserialize(s, block_hash); + ::Unserialize(s, height); + ::Unserialize(s, chain_work); + ::Unserialize(s, prev_hash); + uint32_t st; + ::Unserialize(s, st); + status = static_cast(st); + } + + /// Materialize the slim in-memory form (drops the duplicate fields). + IndexEntry to_entry() const { + IndexEntry e; + e.header = header; + e.block_hash = block_hash; + e.height = height; + e.chain_work = chain_work; + e.status = status; + return e; + } + + /// Build the legacy on-disk form from a slim entry (computes duplicates). + static IndexEntryDiskV1 from_entry(const IndexEntry& e) { + IndexEntryDiskV1 d; + d.header = e.header; + d.hash = e.block_hash; + d.block_hash = e.block_hash; + d.height = e.height; + d.chain_work = e.chain_work; + d.prev_hash = e.header.m_previous_block; + d.status = e.status; + return d; + } +}; + +// ─── BTC Chain Parameters ─────────────────────────────────────────────────── + +struct BTCChainParams { + // Mainnet — BTC retarget per ref/bitcoin/src/kernel/chainparams.cpp + static constexpr int64_t MAINNET_TARGET_TIMESPAN = 1209600; // 2 weeks (BTC retarget window) + static constexpr int64_t MAINNET_TARGET_SPACING = 600; // 10 minutes (BTC block target) + static constexpr bool MAINNET_ALLOW_MIN_DIFF = false; + + // Testnet — BTC testnet3+testnet4 use mainnet retarget window with min-diff override + static constexpr int64_t TESTNET_TARGET_TIMESPAN = 1209600; // 2 weeks + static constexpr int64_t TESTNET_TARGET_SPACING = 600; // 10 minutes + static constexpr bool TESTNET_ALLOW_MIN_DIFF = true; + + // Computed: difficulty adjustment interval = timespan / spacing + static constexpr int64_t difficulty_adjustment_interval(int64_t timespan, int64_t spacing) { + return timespan / spacing; + } + + int64_t target_timespan; + int64_t target_spacing; + bool allow_min_difficulty; + bool no_retargeting{false}; + uint256 pow_limit; + uint256 genesis_hash; // SHA256d genesis block hash (for identification) + + // Fast-start checkpoint: skip syncing from genesis, start from a recent height. + // The header chain seeds this checkpoint as if it were the genesis block. + // All headers before this height are implicitly trusted. + struct Checkpoint { uint32_t height{0}; uint256 hash; }; + std::optional fast_start_checkpoint; + + /// Standard BTC mainnet params (port 8333). + /// Genesis + powLimit per ref/bitcoin/src/kernel/chainparams.cpp. + static BTCChainParams mainnet() { + BTCChainParams p; + p.target_timespan = MAINNET_TARGET_TIMESPAN; + p.target_spacing = MAINNET_TARGET_SPACING; + p.allow_min_difficulty = MAINNET_ALLOW_MIN_DIFF; + p.no_retargeting = false; + // BTC mainnet powLimit (Bitcoin Core CMainParams). + p.pow_limit.SetHex("00000000ffff0000000000000000000000000000000000000000000000000000"); + // Genesis: ref/bitcoin/src/kernel/chainparams.cpp line 128. + p.genesis_hash.SetHex("000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"); + // Seed genesis as the chain root (height=0). Without this, HeaderChain + // rejects the first headers batch because no prev_block resolves to + // anything in the index. fast_start_checkpoint normally points at a + // recent height to skip early IBD; using {0, genesis} just makes + // genesis itself the anchor. + p.fast_start_checkpoint = Checkpoint{0, p.genesis_hash}; + return p; + } + + /// Standard BTC testnet3 params (port 18333). + /// Genesis: ref/bitcoin/src/kernel/chainparams.cpp line 246. + /// (For testnet4 use the testnet4() factory below — different genesis.) + static BTCChainParams testnet() { + BTCChainParams p; + p.target_timespan = TESTNET_TARGET_TIMESPAN; + p.target_spacing = TESTNET_TARGET_SPACING; + p.allow_min_difficulty = TESTNET_ALLOW_MIN_DIFF; + p.no_retargeting = false; + p.pow_limit.SetHex("00000000ffff0000000000000000000000000000000000000000000000000000"); + p.genesis_hash.SetHex("000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943"); + p.fast_start_checkpoint = Checkpoint{0, p.genesis_hash}; + return p; + } + + /// BTC testnet4 params (port 48333) — preferred B2 integration target. + /// Genesis: ref/bitcoin/src/kernel/chainparams.cpp line 354. + static BTCChainParams testnet4() { + BTCChainParams p; + p.target_timespan = TESTNET_TARGET_TIMESPAN; + p.target_spacing = TESTNET_TARGET_SPACING; + p.allow_min_difficulty = TESTNET_ALLOW_MIN_DIFF; + p.no_retargeting = false; + p.pow_limit.SetHex("00000000ffff0000000000000000000000000000000000000000000000000000"); + p.genesis_hash.SetHex("00000000da84f2bafbbc53dee25a72ae507ff4914b867c565be350b0da8bf043"); + p.fast_start_checkpoint = Checkpoint{0, p.genesis_hash}; + return p; + } + + int64_t difficulty_adjustment_interval() const { + return target_timespan / target_spacing; + } +}; + +// ─── PoW Functions ────────────────────────────────────────────────────────── + +/// Compute SHA256d hash of an 80-byte block header. +/// On BTC the PoW hash and the block-identity hash are the same value +/// (both SHA256d). LTC distinguishes them: scrypt for PoW, SHA256d for +/// identity. Function name retained for callsite symmetry across coins. +inline uint256 scrypt_hash(const BlockHeaderType& header) { + auto packed = pack(header); + return Hash(packed.get_span()); +} + +/// Compute SHA256d hash of an 80-byte block header (block identification). +inline uint256 block_hash(const BlockHeaderType& header) { + auto packed = pack(header); + return Hash(packed.get_span()); +} + +/// Compute the target from compact nBits representation. +inline uint256 target_from_bits(uint32_t bits) { + uint256 target; + target.SetCompact(bits); + return target; +} + +/// Check that a scrypt hash meets the target specified by nBits. +inline bool check_pow(const uint256& scrypt_hash_val, uint32_t bits, const uint256& pow_limit) { + bool negative, overflow; + uint256 target; + target.SetCompact(bits, &negative, &overflow); + + if (negative || target.IsNull() || overflow || target > pow_limit) + return false; + + return scrypt_hash_val <= target; +} + +/// Compute work represented by a compact target. +/// Work = 2^256 / (target + 1) +inline uint256 get_block_proof(uint32_t bits) { + bool negative, overflow; + uint256 target; + target.SetCompact(bits, &negative, &overflow); + + if (negative || target.IsNull() || overflow) + return uint256::ZERO; + + // work = ~target / (target + 1) + 1 + return (~target / (target + uint256::ONE)) + uint256::ONE; +} + +// ─── BTC Difficulty Retarget ──────────────────────────────────────────────── +// Adapted from Bitcoin Core pow.cpp (MIT license). Identical algorithm to +// LTC — only the constants differ: +// nPowTargetTimespan = 2 weeks (1209600 s) +// nPowTargetSpacing = 10 min (600 s) +// DifficultyAdjustmentInterval = 1209600 / 600 = 2016 + +/// Core retarget calculation: adjust difficulty based on actual vs target timespan. +inline uint32_t calculate_next_work_required( + uint32_t tip_bits, + int64_t tip_time, + int64_t first_block_time, + const BTCChainParams& params) +{ + if (params.no_retargeting) + return tip_bits; + + int64_t actual_timespan = tip_time - first_block_time; + + // Clamp to [timespan/4, timespan*4] + if (actual_timespan < params.target_timespan / 4) + actual_timespan = params.target_timespan / 4; + if (actual_timespan > params.target_timespan * 4) + actual_timespan = params.target_timespan * 4; + + // Retarget + uint256 bn_new; + bn_new.SetCompact(tip_bits); + const uint256 bn_pow_limit = params.pow_limit; + + // Litecoin: intermediate uint256 can overflow by 1 bit + bool shift = bn_new.bits() > bn_pow_limit.bits() - 1; + if (shift) + bn_new >>= 1; + bn_new *= static_cast(actual_timespan); + bn_new /= uint256(static_cast(params.target_timespan)); + if (shift) + bn_new <<= 1; + + if (bn_new > bn_pow_limit) + bn_new = bn_pow_limit; + + return bn_new.GetCompact(); +} + +/// Calculate next work required at a given height. +/// @param get_ancestor Function to look up ancestor header by height. +/// @param tip_height Height of the current tip (the block we're building on). +/// @param tip_bits nBits of the current tip. +/// @param tip_time Timestamp of the current tip. +/// @param new_time Timestamp of the new block being validated. +/// @param params Chain parameters. +inline uint32_t get_next_work_required( + std::function(uint32_t)> get_ancestor, + uint32_t tip_height, + uint32_t tip_bits, + uint32_t tip_time, + uint32_t new_time, + const BTCChainParams& params) +{ + uint256 pow_limit_compact; + pow_limit_compact = params.pow_limit; + uint32_t pow_limit_bits = pow_limit_compact.GetCompact(); + + // Next block height + uint32_t new_height = tip_height + 1; + int64_t interval = params.difficulty_adjustment_interval(); + + // Only change once per difficulty adjustment interval + if (new_height % interval != 0) { + if (params.allow_min_difficulty) { + // Testnet special rule: if >2x target spacing since last block, + // allow min-difficulty block. + if (static_cast(new_time) > static_cast(tip_time) + params.target_spacing * 2) + return pow_limit_bits; + + // Return the last non-special-min-difficulty-rules-block + uint32_t h = tip_height; + uint32_t last_bits = tip_bits; + while (h > 0 && (h % interval) != 0 && last_bits == pow_limit_bits) { + auto ancestor = get_ancestor(h - 1); + if (!ancestor) break; + last_bits = ancestor->header.m_bits; + h--; + } + return last_bits; + } + return tip_bits; + } + + if (params.no_retargeting) + return tip_bits; + + // Bitcoin Core always goes back `interval - 1` blocks (= 2015 for the + // 2016-block retarget window). The "Art Forz fix" that goes back + // `interval` blocks for non-first retargets is LITECOIN-SPECIFIC: LTC + // shipped with the off-by-one bug, then patched it via height gating + // because the chain had locked the buggy behavior in. BTC has no such + // history; always use interval - 1. + // Reference: ref/bitcoin/src/pow.cpp GetNextWorkRequired() → + // nHeightFirst = pindexLast->nHeight - (DifficultyAdjustmentInterval()-1) + int64_t blocks_to_go_back = interval - 1; + + // Get the first block of the retarget period + uint32_t first_height = static_cast(tip_height - blocks_to_go_back); + auto first_entry = get_ancestor(first_height); + if (!first_entry) + return tip_bits; // shouldn't happen for a connected chain + + int64_t first_time = first_entry->header.m_timestamp; + + return calculate_next_work_required(tip_bits, tip_time, first_time, params); +} + +// ─── HeaderChain ──────────────────────────────────────────────────────────── + +class HeaderChain { +public: + HeaderChain(const BTCChainParams& params, const std::string& db_path = "") + : m_params(params) + , m_db_path(db_path) + { + } + + ~HeaderChain() = default; + + // Disable copy + HeaderChain(const HeaderChain&) = delete; + HeaderChain& operator=(const HeaderChain&) = delete; + + /// Initialize: open LevelDB (if path given), load persisted state. + /// Returns false if LevelDB open fails. + bool init() { + LOG_INFO << "[EMB-BTC] HeaderChain::init() db_path=" << (m_db_path.empty() ? "(in-memory)" : m_db_path) + << " genesis=" << m_params.genesis_hash.GetHex().substr(0, 16) << "..." + << " pow_limit=" << m_params.pow_limit.GetHex().substr(0, 16) << "..." + << " timespan=" << m_params.target_timespan << "s spacing=" << m_params.target_spacing << "s" + << " allow_min_diff=" << m_params.allow_min_difficulty; + if (!m_db_path.empty()) { + core::LevelDBOptions opts; + opts.write_buffer_size = 2 * 1024 * 1024; // 2MB + opts.block_cache_size = 4 * 1024 * 1024; // 4MB + m_db = std::make_unique(m_db_path, opts); + if (!m_db->open()) { + LOG_WARNING << "[EMB-BTC] HeaderChain LevelDB open FAILED at " << m_db_path; + return false; + } + LOG_INFO << "[EMB-BTC] HeaderChain LevelDB opened at " << m_db_path; + load_from_db(); + } + // Fast-start checkpoint: if chain is empty and a checkpoint is configured, + // seed it as the starting point. All headers before this height are + // implicitly trusted. The chain will sync forward from this point. + if (m_tip.IsNull() && m_params.fast_start_checkpoint.has_value()) { + auto& cp = m_params.fast_start_checkpoint.value(); + IndexEntry entry; + entry.block_hash = cp.hash; + entry.height = cp.height; + entry.chain_work = uint256::ONE; // minimal non-zero work + entry.status = HEADER_VALID_CHAIN; + // Minimal header — we don't have the actual header data, but we + // have the hash. Peers will send headers AFTER this point. The + // null prev_block doubles as the "trusted root" marker for the + // chain walk in get_header_by_height_internal(). + entry.header.m_previous_block.SetNull(); + + put_header_internal(cp.hash, entry); + m_height_index[cp.height] = cp.hash; + mark_height_dirty_internal(cp.height); + m_tip = cp.hash; + m_tip_height = cp.height; + m_best_work = entry.chain_work; + persist_tip(); + LOG_INFO << "HeaderChain: fast-start from checkpoint height=" + << cp.height << " hash=" << cp.hash.GetHex().substr(0, 16) << "..."; + } + return true; + } + + /// Current chain tip (best header). + std::optional tip() const { + std::lock_guard lock(m_mutex); + if (m_tip.IsNull()) return std::nullopt; + return lookup_header_internal(m_tip); + } + + /// Current chain tip height. Returns 0 if empty. + uint32_t height() const { + std::lock_guard lock(m_mutex); + return m_tip_height; + } + + /// Cumulative work of the best chain. + uint256 cumulative_work() const { + std::lock_guard lock(m_mutex); + return m_best_work; + } + + /// Number of headers stored on the best chain. Backed by m_height_index + /// after Phase 1C — m_headers is a bounded cache, not authoritative. + size_t size() const { + std::lock_guard lock(m_mutex); + return m_height_index.size(); + } + + /// Check if we have a header by its SHA256d block hash. Looks in the LRU + /// cache first, then the LevelDB store. + bool has_header(const uint256& block_hash) const { + std::lock_guard lock(m_mutex); + return has_header_internal(block_hash); + } + + /// Get header by SHA256d block hash. Lazy-loads from disk on cache miss. + std::optional get_header(const uint256& block_hash) const { + std::lock_guard lock(m_mutex); + return lookup_header_internal(block_hash); + } + + /// Get header by height (only works for headers on the best chain). + std::optional get_header_by_height(uint32_t h) const { + std::lock_guard lock(m_mutex); + auto it = m_height_index.find(h); + if (it == m_height_index.end()) return std::nullopt; + return lookup_header_internal(it->second); + } + + /// Add a single header. Returns true if accepted (new + valid). + bool add_header(const BlockHeaderType& header) { + PendingTipChange ptc; + { + std::lock_guard lock(m_mutex); + bool ok = add_header_internal(header); + if (ok) { + persist_tip(); + LOG_INFO << "[EMB-BTC] Single header accepted: height=" << m_tip_height + << " hash=" << m_tip.GetHex().substr(0, 16) << "..." + << " bits=0x" << std::hex << header.m_bits << std::dec + << " ts=" << header.m_timestamp; + } + ptc = m_pending_tip_change; + m_pending_tip_change.fired = false; + if (!ok) return false; + } + // Fire tip-changed callback OUTSIDE the mutex to avoid deadlock + if (ptc.fired && m_on_tip_changed) + m_on_tip_changed(ptc.old_tip, ptc.old_height, ptc.new_tip, ptc.new_height); + return true; + } + + /// Set the estimated network tip height (from peer's version message). + /// Used to skip scrypt PoW validation for old headers during initial sync. + /// Headers below (tip - SCRYPT_VALIDATION_DEPTH) are validated structurally + /// only (prev_hash linkage + difficulty retarget), making bulk sync ~100x faster. + /// Set the estimated network tip height (from peer's version message). + void set_peer_tip_height(uint32_t height) { m_peer_tip_height.store(height); } + + /// Dynamically seed a checkpoint at runtime (e.g., from RPC getblockhash). + /// Only applied if the chain is still at or below the hardcoded checkpoint. + /// This allows an accessible daemon to provide a more recent starting point. + void set_dynamic_checkpoint(uint32_t height, const uint256& hash) { + std::lock_guard lock(m_mutex); + if (height > m_tip_height) { + IndexEntry entry; + entry.block_hash = hash; + entry.height = height; + entry.chain_work = uint256::ONE; + entry.status = HEADER_VALID_CHAIN; + entry.header.m_previous_block.SetNull(); + put_header_internal(hash, entry); + m_height_index[height] = hash; + mark_height_dirty_internal(height); + m_tip = hash; + m_tip_height = height; + m_best_work = entry.chain_work; + persist_tip(); + LOG_INFO << "HeaderChain: dynamic checkpoint at height=" + << height << " hash=" << hash.GetHex().substr(0, 16) << "..."; + } + } + + /// Add a batch of headers (from a `headers` P2P message). + /// Returns the number of new headers accepted. + /// Processes in sub-batches to avoid holding the mutex for the entire batch. + /// With fast-sync (scrypt skipped for old headers), structural validation + /// is microseconds per header, so large batches are fine. Near the tip + /// (scrypt active), each header takes ~20ms, so we use smaller batches. + int add_headers(const std::vector& headers) { + uint32_t peer_tip = m_peer_tip_height.load(std::memory_order_relaxed); + // Large batches during fast-sync (no scrypt), small near tip (scrypt active) + size_t BATCH_SIZE = (peer_tip > 0 && m_tip_height + 2100 < peer_tip) ? 500 : 50; + int accepted = 0; + PendingTipChange last_ptc; + for (size_t offset = 0; offset < headers.size(); offset += BATCH_SIZE) { + { + std::lock_guard lock(m_mutex); + size_t end = std::min(offset + BATCH_SIZE, headers.size()); + for (size_t i = offset; i < end; ++i) { + if (add_header_internal(headers[i])) + ++accepted; + } + // Capture any pending tip change from this batch + if (m_pending_tip_change.fired) + last_ptc = m_pending_tip_change; + m_pending_tip_change.fired = false; + } + // Yield between batches so ioc-thread callers (get_height, + // is_synced, get_tip) can acquire the mutex. + if (offset + BATCH_SIZE < headers.size()) { + // Short yield during fast-sync (structural only), longer near tip (scrypt) + auto ms = (BATCH_SIZE >= 500) ? 1 : 10; + std::this_thread::sleep_for(std::chrono::milliseconds(ms)); + } + } + if (accepted > 0) { + std::lock_guard lock(m_mutex); + persist_tip(); + // Progress indicator for header sync + uint32_t peer_tip = m_peer_tip_height.load(std::memory_order_relaxed); + if (peer_tip > 0 && m_tip_height > 0) { + double pct = 100.0 * m_tip_height / peer_tip; + // Log every 1% or every 2000 blocks + static uint32_t s_last_logged = 0; + if (m_tip_height - s_last_logged >= 2000 || pct >= 99.9) { + s_last_logged = m_tip_height; + LOG_INFO << "[BTC] Header sync: " << m_tip_height << "/" << peer_tip + << " (" << std::fixed << std::setprecision(1) << pct << "%)"; + } + } + } + // Fire tip-changed callback OUTSIDE the mutex to avoid deadlock + if (last_ptc.fired && m_on_tip_changed) + m_on_tip_changed(last_ptc.old_tip, last_ptc.old_height, last_ptc.new_tip, last_ptc.new_height); + return accepted; + } + + /// Build a block locator for getheaders (BIP 31-style exponential backoff). + /// Returns a list of block hashes from tip back to genesis. + std::vector get_locator() const { + std::lock_guard lock(m_mutex); + return get_locator_internal(); + } + + /// Check if a prev_hash connects to our chain. + bool is_connected(const uint256& prev_hash) const { + std::lock_guard lock(m_mutex); + return has_header_internal(prev_hash); + } + + /// Whether the chain is synced (tip timestamp within DEFAULT_MAX_TIP_AGE of wall clock). + /// Both litecoind and dogecoind use 24 hours (86400s) as DEFAULT_MAX_TIP_AGE. + /// Reference: litecoin/src/validation.h DEFAULT_MAX_TIP_AGE = 24 * 60 * 60 + /// p2pool uses the same gate implicitly: getblocktemplate() fails until + /// litecoind considers itself synced (tip within nMaxTipAge). + bool is_synced() const { + std::lock_guard lock(m_mutex); + if (m_tip.IsNull()) return false; + auto tip_opt = lookup_header_internal(m_tip); + if (!tip_opt) return false; + auto now = static_cast(std::time(nullptr)); + uint32_t age = now - tip_opt->header.m_timestamp; + bool synced = age < core::coin::DEFAULT_MAX_TIP_AGE; // 24 hours (86400s) + // Log state changes (throttled via static) + static bool s_last_synced = false; + if (synced != s_last_synced) { + LOG_INFO << "[EMB-BTC] Sync state changed: synced=" << synced + << " tip_age=" << age << "s height=" << m_tip_height; + s_last_synced = synced; + } + return synced; + } + + /// Get params (for external difficulty validation tests). + const BTCChainParams& params() const { return m_params; } + + /// Register a callback fired when the chain tip changes (reorg or equal-work switch). + /// Signature: void(old_tip_hash, old_height, new_tip_hash, new_height) + using TipChangedCallback = std::function; + void set_on_tip_changed(TipChangedCallback cb) { m_on_tip_changed = std::move(cb); } + +private: + /// Add a single header (caller holds mutex). + bool add_header_internal(const BlockHeaderType& header) { + // Compute hashes + uint256 bhash = block_hash(header); + + // Skip if already known + if (has_header_internal(bhash)) + return false; + + // Genesis block special case: accept if it matches known genesis + if (header.m_previous_block.IsNull()) { + if (bhash != m_params.genesis_hash) { + LOG_WARNING << "[EMB-BTC] REJECT genesis: hash=" << bhash.GetHex().substr(0, 16) + << " expected=" << m_params.genesis_hash.GetHex().substr(0, 16); + return false; // wrong genesis + } + + IndexEntry entry; + entry.header = header; + entry.block_hash = bhash; + entry.height = 0; + entry.chain_work = get_block_proof(header.m_bits); + entry.status = HEADER_VALID_CHAIN; + + put_header_internal(bhash, entry); + m_height_index[0] = bhash; + mark_height_dirty_internal(0); + m_tip = bhash; + m_tip_height = 0; + m_best_work = entry.chain_work; + LOG_INFO << "[EMB-BTC] Genesis accepted: hash=" << bhash.GetHex() + << " bits=0x" << std::hex << header.m_bits << std::dec; + return true; + } + + // Must connect to an existing header + auto prev_opt = lookup_header_internal(header.m_previous_block); + if (!prev_opt) { + LOG_DEBUG_COIND << "[EMB-BTC] ORPHAN header: hash=" << bhash.GetHex().substr(0, 16) + << " prev=" << header.m_previous_block.GetHex().substr(0, 16) + << " — not connected to chain"; + return false; // orphan — not connected + } + + const auto& prev = *prev_opt; + uint32_t new_height = prev.height + 1; + + // Validate PoW — skip expensive scrypt for old headers during initial sync. + // Headers below (peer_tip - SCRYPT_VALIDATION_DEPTH) are validated + // structurally only (prev_hash + difficulty retarget). This makes bulk + // sync ~100x faster: 170k headers × 0.01ms vs 170k × 20ms. + // The depth threshold (2100) covers: 1 difficulty retarget interval (2016) + // + margin for block_rel_height scoring (~72 blocks). + static constexpr uint32_t SCRYPT_VALIDATION_DEPTH = 2100; + uint32_t peer_tip = m_peer_tip_height.load(std::memory_order_relaxed); + bool need_scrypt = (peer_tip == 0) // unknown tip → validate everything + || (new_height + SCRYPT_VALIDATION_DEPTH >= peer_tip); + uint256 pow_hash; + if (need_scrypt) { + pow_hash = scrypt_hash(header); + if (!check_pow(pow_hash, header.m_bits, m_params.pow_limit)) { + LOG_WARNING << "[EMB-BTC] PoW FAIL at height=" << new_height + << " hash=" << bhash.GetHex().substr(0, 16) + << " scrypt=" << pow_hash.GetHex().substr(0, 16) + << " bits=0x" << std::hex << header.m_bits << std::dec; + return false; + } + } else { + // Structural-only: trust PoW, store zero hash (not needed for old blocks) + pow_hash = block_hash(header); // SHA256d, not scrypt — cheap placeholder + } + + // Validate difficulty + if (!validate_difficulty(header, new_height)) { + LOG_WARNING << "[EMB-BTC] Difficulty FAIL at height=" << new_height + << " hash=" << bhash.GetHex().substr(0, 16) + << " bits=0x" << std::hex << header.m_bits << std::dec + << " prev_bits=0x" << prev.header.m_bits << std::dec; + return false; + } + + // Build index entry + IndexEntry entry; + entry.header = header; + entry.block_hash = bhash; + entry.height = new_height; + entry.chain_work = prev.chain_work + get_block_proof(header.m_bits); + entry.status = HEADER_VALID_CHAIN; + (void)pow_hash; // PoW already checked above; not stored on BTC since == block_hash + + put_header_internal(bhash, entry); + + // Update tip if this chain has more work, OR if a competing block + // at the same height has equal work (equal-work reorg). + // + // On testnet (min-difficulty), ALL blocks have identical per-block work. + // When our miner and the network both find a block at the same height, + // the `>` check alone never fires — the chain permanently diverges. + // The peer sending us this header represents network consensus (litecoind + // accepted their block), so we switch to it. + // + // Flip-flop is impossible: once both headers are stored, re-receiving + // either is a no-op (line 551 skips known hashes). After switching, + // our mining builds on the new tip, so no new blocks appear on the + // old fork. + bool dominated = entry.chain_work > m_best_work; + bool equal_at_tip = entry.chain_work == m_best_work + && new_height == m_tip_height + && bhash != m_tip; + if (dominated || equal_at_tip) { + uint32_t old_height = m_tip_height; + uint256 old_tip = m_tip; + m_best_work = entry.chain_work; + m_tip = bhash; + m_tip_height = new_height; + // Incremental height index update: if this header extends the + // previous tip (common case during sync), just add one entry. + // Only do a full rebuild on reorgs (tip changed branch). + if (new_height == old_height + 1 && entry.header.m_previous_block == m_height_index[old_height]) { + m_height_index[new_height] = bhash; + mark_height_dirty_internal(new_height); + } else { + rebuild_height_index(bhash); + if (equal_at_tip) { + LOG_WARNING << "[EMB-BTC] EQUAL-WORK REORG at height " << new_height + << ": old_tip=" << old_tip.GetHex().substr(0, 16) + << " new_tip=" << bhash.GetHex().substr(0, 16); + } else if (new_height <= old_height && old_height > 0) { + LOG_WARNING << "[EMB-BTC] REORG detected: old_height=" << old_height + << " new_height=" << new_height << " hash=" << bhash.GetHex().substr(0, 16); + } + } + // Defer reorg callback — will fire AFTER mutex is released by caller. + // Firing inside the lock causes deadlock (callback → getwork → get_header → lock). + m_pending_tip_change.fired = true; + m_pending_tip_change.old_tip = old_tip; + m_pending_tip_change.old_height = old_height; + m_pending_tip_change.new_tip = bhash; + m_pending_tip_change.new_height = new_height; + } + + return true; + } + + /// Validate that the header's nBits matches the expected difficulty. + bool validate_difficulty(const BlockHeaderType& header, uint32_t new_height) { + int64_t interval = m_params.difficulty_adjustment_interval(); + if (new_height < 2) return true; // genesis + first block + + // After a fast-start checkpoint, skip difficulty validation until we have + // enough headers for the retarget lookback (interval = 2016). + // Without this, get_ancestor() fails because the checkpoint doesn't have + // the previous 2016 headers needed for difficulty calculation. + if (m_params.fast_start_checkpoint.has_value()) { + uint32_t cp_h = m_params.fast_start_checkpoint->height; + if (new_height > cp_h && new_height < cp_h + static_cast(interval) + 10) + return true; // trust difficulty within first retarget window after checkpoint + } + // Also skip when we have a dynamic checkpoint (no fast_start_checkpoint set + // but chain started at a non-zero height). Use m_height_index (authoritative + // best-chain length) since m_headers is a bounded LRU after Phase 1C. + if (m_height_index.size() < static_cast(interval + 10)) + return true; + + // Get tip (the block we're building on) + auto prev_opt = lookup_header_internal(header.m_previous_block); + if (!prev_opt) return false; + const auto& tip = *prev_opt; + + auto get_ancestor = [this](uint32_t h) -> std::optional { + return this->get_header_by_height_internal(h); + }; + + uint32_t expected_bits = get_next_work_required( + get_ancestor, tip.height, tip.header.m_bits, tip.header.m_timestamp, + header.m_timestamp, m_params); + + return header.m_bits == expected_bits; + } + + /// Internal get_header_by_height (caller holds mutex). + std::optional get_header_by_height_internal(uint32_t h) const { + auto it = m_height_index.find(h); + if (it == m_height_index.end()) return std::nullopt; + return lookup_header_internal(it->second); + } + + /// Rebuild height index from a new tip back to genesis (chain walk). + /// Marks every changed height dirty so flush_dirty() persists it. + /// On a one-time legacy migration this walks the full chain — subsequent + /// reorgs walk only as far as the divergence depth. + void rebuild_height_index(const uint256& new_tip) { + // Snapshot old mapping so we only dirty heights that actually change. + std::unordered_map old_index; + old_index.swap(m_height_index); + uint256 current = new_tip; + while (!current.IsNull()) { + auto cur_opt = lookup_header_internal(current); + if (!cur_opt) break; + uint32_t h = cur_opt->height; + m_height_index[h] = current; + auto oit = old_index.find(h); + if (oit == old_index.end() || oit->second != current) + mark_height_dirty_internal(h); + current = cur_opt->header.m_previous_block; + } + // Any heights that were in the old index but not in the new one are now + // orphan-chain entries; we don't have a way to delete LevelDB rows yet, + // so they're left as stale records. Acceptable: reads through + // m_height_index never see them, and the next reorg through the same + // height will overwrite. + } + + /// Block locator: exponential backoff from tip (caller holds mutex). + std::vector get_locator_internal() const { + std::vector locator; + if (m_tip.IsNull()) return locator; + + int64_t step = 1; + int64_t h = static_cast(m_tip_height); + + while (h >= 0) { + auto it = m_height_index.find(static_cast(h)); + if (it != m_height_index.end()) + locator.push_back(it->second); + + if (h == 0) break; + h -= step; + if (h < 0) h = 0; + if (locator.size() > 10) + step *= 2; + } + return locator; + } + + // ─── LevelDB persistence ────────────────────────────────────────────── + // Schema: + // "h" + block_hash(32 bytes) → IndexEntry (serialized as IndexEntryDiskV1) + // "i" + height(4 bytes BE) → block_hash(32 bytes) (Phase 1C — m_height_index persistence) + // "tip" → block_hash(32 bytes) + // "height" → uint32_t (4-byte BE) + + static std::string make_height_key(uint32_t h) { + std::string k = "i"; + char buf[4]; + buf[0] = static_cast((h >> 24) & 0xFF); + buf[1] = static_cast((h >> 16) & 0xFF); + buf[2] = static_cast((h >> 8) & 0xFF); + buf[3] = static_cast(h & 0xFF); + k.append(buf, 4); + return k; + } + + static uint32_t parse_height_key(const std::string& k) { + if (k.size() != 5 || k[0] != 'i') return UINT32_MAX; + return (static_cast(static_cast(k[1])) << 24) + | (static_cast(static_cast(k[2])) << 16) + | (static_cast(static_cast(k[3])) << 8) + | static_cast(static_cast(k[4])); + } + + // ─── Phase 1C: LRU cache helpers ────────────────────────────────────── + + /// Move an existing cache entry to the front of the LRU list (most recent). + void touch_lru_internal(const uint256& hash) const { + auto pit = m_lru_iter.find(hash); + if (pit == m_lru_iter.end()) return; + if (pit->second == m_lru_order.begin()) return; + m_lru_order.splice(m_lru_order.begin(), m_lru_order, pit->second); + } + + /// Evict from the back of the LRU until we're at or below the cap. + /// Dirty entries are never evicted — they have unwritten changes. + void evict_lru_if_full_internal() const { + auto bound = static_cast(HEADER_CACHE_CAP); + size_t guard = 0; + while (m_headers.size() > bound && !m_lru_order.empty() && guard++ < bound) { + const uint256 victim = m_lru_order.back(); + // Don't evict if dirty (would lose unflushed write). + if (m_dirty_headers.count(victim)) { + // Rotate dirty victim to the front so we try a different one. + m_lru_order.splice(m_lru_order.begin(), m_lru_order, std::prev(m_lru_order.end())); + auto it = m_lru_iter.find(victim); + if (it != m_lru_iter.end()) it->second = m_lru_order.begin(); + continue; + } + m_lru_order.pop_back(); + m_lru_iter.erase(victim); + m_headers.erase(victim); + } + } + + /// Insert into cache (or replace existing). Returns pointer into m_headers + /// for the inserted entry. Triggers eviction if over cap. Pointer is stable + /// only until the next cache mutation. + const IndexEntry* insert_into_cache_internal(const uint256& hash, IndexEntry&& entry) const { + auto pit = m_lru_iter.find(hash); + if (pit != m_lru_iter.end()) { + m_lru_order.splice(m_lru_order.begin(), m_lru_order, pit->second); + auto& slot = m_headers[hash]; + slot = std::move(entry); + return &slot; + } + m_lru_order.push_front(hash); + m_lru_iter[hash] = m_lru_order.begin(); + auto [it, inserted] = m_headers.emplace(hash, std::move(entry)); + (void)inserted; + evict_lru_if_full_internal(); + // After eviction, the iterator might still be valid (we don't evict the + // entry we just inserted — it's at the front, eviction starts at back). + return &it->second; + } + + /// Read a single header from LevelDB by block_hash. Returns true if found. + bool try_load_header_from_db_internal(const uint256& hash, IndexEntry& out) const { + if (!m_db || !m_db->is_open()) return false; + std::string key = "h"; + key.append(reinterpret_cast(hash.data()), 32); + std::vector data; + if (!m_db->get(key, data)) return false; + try { + PackStream ps(data); + IndexEntryDiskV1 disk; + ps >> disk; + out = disk.to_entry(); + return true; + } catch (const std::exception& e) { + LOG_WARNING << "[EMB-BTC] try_load_header_from_db: corrupt entry hash=" + << hash.GetHex().substr(0, 16) << " err=" << e.what(); + return false; + } + } + + /// Cache-then-DB lookup. Returns a copy (avoids pointer-stability hazards + /// when callers do follow-up lookups). nullopt = not present anywhere. + std::optional lookup_header_internal(const uint256& hash) const { + auto it = m_headers.find(hash); + if (it != m_headers.end()) { + touch_lru_internal(hash); + return it->second; + } + IndexEntry tmp; + if (!try_load_header_from_db_internal(hash, tmp)) return std::nullopt; + IndexEntry copy = tmp; + insert_into_cache_internal(hash, std::move(tmp)); + return copy; + } + + /// Existence check using cache + DB. Avoids loading the full entry into the + /// cache for transient checks (count() / has_header() pattern). + bool has_header_internal(const uint256& hash) const { + if (m_headers.count(hash) > 0) return true; + if (!m_db || !m_db->is_open()) return false; + std::string key = "h"; + key.append(reinterpret_cast(hash.data()), 32); + return m_db->exists(key); + } + + /// Store an entry in the cache and mark it dirty for the next flush. + /// Use this in place of `m_headers[hash] = entry` to keep LRU + persistence + /// state in sync. + void put_header_internal(const uint256& hash, IndexEntry entry) { + insert_into_cache_internal(hash, std::move(entry)); + m_dirty_headers.insert(hash); + } + + /// Persist m_height_index entry to LevelDB on next flush. + void mark_height_dirty_internal(uint32_t h) { + m_dirty_heights.insert(h); + } + + void persist_header(const IndexEntry& entry) { + // Write-back model (matches Litecoin Core's setDirtyBlockIndex): + // Mark header as dirty — actual DB write happens in flush_dirty(). + m_dirty_headers.insert(entry.block_hash); + } + + /// Flush all dirty headers + height_index + tip to LevelDB in a single + /// atomic WriteBatch with sync=true (fsync). Matches Litecoin Core's + /// FlushStateToDisk() pattern. Caller must hold m_mutex. + void flush_dirty() { + if (!m_db || !m_db->is_open()) return; + if (m_dirty_headers.empty() && m_dirty_heights.empty()) return; + + auto batch = m_db->create_batch(); + int header_count = 0; + int height_count = 0; + + for (const auto& hash : m_dirty_headers) { + auto it = m_headers.find(hash); + if (it == m_headers.end()) continue; // evicted before flush — should never happen given evict_lru protects dirty + + // Serialize as legacy V1 layout (with hash + prev_hash duplicates) + // so a pre-Phase-1B binary can still read what we write. + auto disk = IndexEntryDiskV1::from_entry(it->second); + auto packed = pack(disk); + std::vector data( + reinterpret_cast(packed.data()), + reinterpret_cast(packed.data()) + packed.size()); + + std::string key = "h"; + key.append(reinterpret_cast(hash.data()), 32); + batch.put(key, data); + ++header_count; + } + + // Phase 1C: persist m_height_index dirty entries under "i" + BE_height + for (uint32_t h : m_dirty_heights) { + auto it = m_height_index.find(h); + if (it == m_height_index.end()) continue; + std::vector data(it->second.data(), it->second.data() + 32); + batch.put(make_height_key(h), data); + ++height_count; + } + + // Include tip in the same atomic batch + { + std::vector tip_data(m_tip.data(), m_tip.data() + 32); + batch.put("tip", tip_data); + + uint32_t h = m_tip_height; + std::vector height_data(4); + height_data[0] = (h >> 24) & 0xFF; + height_data[1] = (h >> 16) & 0xFF; + height_data[2] = (h >> 8) & 0xFF; + height_data[3] = h & 0xFF; + batch.put("height", height_data); + } + + if (batch.commit_sync()) { + m_dirty_headers.clear(); + m_dirty_heights.clear(); + LOG_DEBUG_COIND << "[EMB-BTC] flush_dirty: wrote " << header_count + << " headers + " << height_count << " height-index entries (synced)"; + } else { + LOG_ERROR << "[EMB-BTC] flush_dirty: WriteBatch FAILED for " << header_count + << " headers + " << height_count << " height-index entries"; + } + } + + void persist_tip() { + // Write-back: flush all dirty headers + tip atomically. + flush_dirty(); + } + + void load_from_db() { + if (!m_db || !m_db->is_open()) return; + + auto t0 = std::chrono::steady_clock::now(); + + // Phase 1C fast path: load m_height_index directly from "i:" entries. + // This skips the full m_headers RAM residency that pre-Phase-1C used. + auto i_keys = m_db->list_keys("i", 10000000); + int hi_loaded = 0; + if (!i_keys.empty()) { + m_height_index.reserve(i_keys.size()); + for (auto& k : i_keys) { + uint32_t h = parse_height_key(k); + if (h == UINT32_MAX) continue; + std::vector data; + if (!m_db->get(k, data) || data.size() != 32) continue; + uint256 hash; + memcpy(hash.data(), data.data(), 32); + m_height_index[h] = hash; + ++hi_loaded; + } + LOG_INFO << "[EMB-BTC] load_from_db: loaded " << hi_loaded + << " height-index entries directly (Phase 1C fast path)"; + } + + // Load tip + bool need_migration = false; + std::vector tip_data; + if (m_db->get("tip", tip_data) && tip_data.size() == 32) { + memcpy(m_tip.data(), tip_data.data(), 32); + // Lazy-load tip header into the cache so subsequent queries (e.g. + // is_synced()) don't need to hit disk. + auto tip_opt = lookup_header_internal(m_tip); + if (tip_opt) { + m_tip_height = tip_opt->height; + m_best_work = tip_opt->chain_work; + if (hi_loaded == 0) { + // Phase 1C migration: older on-disk data has no "i:" entries. + // Do the legacy walk ONCE to populate m_height_index, persist, + // and let subsequent restarts use the fast path. + need_migration = true; + } + } else { + LOG_WARNING << "[EMB-BTC] Tip hash in DB not found — resetting"; + m_tip.SetNull(); + } + } + + if (need_migration) { + LOG_INFO << "[EMB-BTC] load_from_db: legacy data detected, running one-time " + << "height-index migration (walks back from tip)"; + rebuild_height_index(m_tip); + for (auto& [h, _] : m_height_index) mark_height_dirty_internal(h); + // Flush right away so the next restart hits the fast path. + flush_dirty(); + LOG_INFO << "[EMB-BTC] load_from_db: migration done, " << m_height_index.size() + << " height-index entries persisted"; + } + + auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0).count(); + LOG_INFO << "[EMB-BTC] load_from_db: ready in " << elapsed << "ms" + << " tip_height=" << m_tip_height + << " tip=" << (m_tip.IsNull() ? "(null)" : m_tip.GetHex().substr(0, 16) + "...") + << " m_headers_cached=" << m_headers.size() + << " m_height_index=" << m_height_index.size(); + } + + // ─── State ──────────────────────────────────────────────────────────── + + BTCChainParams m_params; + std::string m_db_path; + + mutable std::mutex m_mutex; + + // Phase 1C: bounded LRU cache for IndexEntry. The full m_height_index is + // kept in RAM (~46 MB for the BTC mainnet tip), but m_headers is a + // ~3 MB working-set cache backed by LevelDB. Front of m_lru_order is most + // recently used; back is the eviction candidate. m_lru_iter gives O(1) + // moves to front. All three are mutable so that const lookup helpers can + // refresh LRU position / lazy-load on miss. + static constexpr size_t HEADER_CACHE_CAP = 16384; + mutable std::unordered_map m_headers; // block_hash → entry (LRU-bounded) + mutable std::list m_lru_order; + mutable std::unordered_map::iterator, + Uint256Hasher> m_lru_iter; + + std::unordered_map m_height_index; // height → block_hash (best chain only) + + uint256 m_tip; // best chain tip hash + uint32_t m_tip_height{0}; + uint256 m_best_work; + + // Peer-reported tip height for fast-sync scrypt skip. + // Set from version message; headers below (tip - 2100) skip scrypt PoW. + std::atomic m_peer_tip_height{0}; + + std::unique_ptr m_db; + + /// Dirty sets: items modified since last flush (write-back model). + std::unordered_set m_dirty_headers; + std::unordered_set m_dirty_heights; + + /// Callback fired on tip change (reorg / equal-work switch). + TipChangedCallback m_on_tip_changed; + + /// Deferred tip change — fired OUTSIDE mutex to avoid deadlock. + struct PendingTipChange { + bool fired{false}; + uint256 old_tip, new_tip; + uint32_t old_height{0}, new_height{0}; + }; + PendingTipChange m_pending_tip_change; +}; + +} // namespace coin +} // namespace btc diff --git a/src/impl/btc/coin/mempool.hpp b/src/impl/btc/coin/mempool.hpp new file mode 100644 index 000000000..0c8cbd819 --- /dev/null +++ b/src/impl/btc/coin/mempool.hpp @@ -0,0 +1,735 @@ +#pragma once + +/// LTC Mempool — Phase 2 + UTXO fee computation +/// +/// In-memory transaction pool receiving transactions from P2P peers. +/// When a UTXOViewCache is available, computes per-transaction fees +/// (sum_inputs - sum_outputs) and maintains a feerate-sorted index +/// for optimal block template building. +/// +/// Thread-safe via internal mutex. + +#include "block.hpp" +#include "transaction.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace btc { +namespace coin { + +// ─── MempoolEntry ──────────────────────────────────────────────────────────── + +struct MempoolEntry { + MutableTransaction tx; + uint256 txid; + uint32_t base_size{0}; // legacy serialized bytes (no witness) + uint32_t witness_size{0}; // extra bytes for witness data + uint32_t weight{0}; // base_size*4 + witness_size (BIP 141) + uint64_t fee{0}; // satoshi (computed from UTXO when available) + bool fee_known{false}; // true when fee was computed from UTXO lookups + time_t time_added{0}; + + double feerate() const { + uint32_t vsize = (weight + 3) / 4; // ceil(weight/4) + return (fee_known && vsize > 0) ? static_cast(fee) / vsize : 0.0; + } +}; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/// Compute txid = SHA256d of legacy (non-witness) serialization. +inline uint256 compute_txid(const MutableTransaction& tx) { + auto packed = pack(TX_NO_WITNESS(tx)); + return Hash(packed.get_span()); +} + +/// Compute BIP 141 weight: base_size*4 + witness_size. +/// base_size = legacy serialized bytes; full_size = with-witness bytes. +inline void compute_tx_weight(const MutableTransaction& tx, + uint32_t& base_size, + uint32_t& witness_size, + uint32_t& weight) +{ + auto legacy = pack(TX_NO_WITNESS(tx)); + auto full = pack(TX_WITH_WITNESS(tx)); + base_size = static_cast(legacy.size()); + uint32_t full_sz = static_cast(full.size()); + witness_size = full_sz > base_size ? full_sz - base_size : 0; + weight = base_size * 4 + witness_size; +} + +// ─── Mempool ───────────────────────────────────────────────────────────────── + +class Mempool { +public: + /// Maximum total bytes (legacy serialized) in the pool. + static constexpr size_t DEFAULT_MAX_BYTES = 300ULL * 1024 * 1024; // 300 MB + + /// Transaction expiry window. + static constexpr time_t DEFAULT_EXPIRY_SECS = 14 * 24 * 3600; // 14 days + + explicit Mempool(core::coin::ChainLimits limits = core::coin::LTC_LIMITS, + size_t max_bytes = DEFAULT_MAX_BYTES, + time_t expiry_sec = DEFAULT_EXPIRY_SECS) + : m_limits(limits) + , m_max_bytes(max_bytes) + , m_expiry_sec(expiry_sec) + {} + + /// Update the current chain tip height (for coinbase maturity checks). + /// Call after each block connection. + void set_tip_height(uint32_t h) { m_tip_height = h; } + void set_utxo(core::coin::UTXOViewCache* u) { m_utxo.store(u); } + + // Disable copy + Mempool(const Mempool&) = delete; + Mempool& operator=(const Mempool&) = delete; + + // ─── Mutation ──────────────────────────────────────────────────────── + + /// Add a transaction to the pool (no fee computation). + /// Returns false if already known or if the tx is malformed. + bool add_tx(const MutableTransaction& tx) { + return add_tx(tx, nullptr); + } + + /// Add a transaction to the pool with optional UTXO-based fee computation. + /// When utxo is non-null, computes fee = sum(input_values) - sum(output_values). + /// Falls back to fee_known=false if any input is missing from the UTXO set + /// or from a parent mempool transaction (chain-of-unconfirmed / CPFP). + bool add_tx(const MutableTransaction& tx, core::coin::UTXOViewCache* utxo) { + uint256 txid = compute_txid(tx); + + std::lock_guard lock(m_mutex); + + // Reject duplicates + if (m_pool.count(txid)) + return false; + + MempoolEntry entry; + entry.tx = tx; + entry.txid = txid; + compute_tx_weight(tx, entry.base_size, entry.witness_size, entry.weight); + entry.time_added = std::time(nullptr); + + // Compute fee from UTXO + mempool parent lookups + compute_fee_locked(entry, utxo); + + // Enforce size cap: evict oldest entries until we have room + int evicted = 0; + while (m_total_bytes + entry.base_size > m_max_bytes && !m_time_index.empty()) { + auto oldest = m_time_index.begin(); + evict_one_locked(oldest->second); + ++evicted; + } + + m_pool[txid] = std::move(entry); + auto& stored = m_pool[txid]; + m_time_index.emplace(stored.time_added, txid); + m_total_bytes += stored.base_size; + + // Track spent outputs for conflict detection + for (const auto& vin : stored.tx.vin) { + m_spent_outputs[std::make_pair(vin.prevout.hash, vin.prevout.index)] = txid; + } + + // Add to feerate index if fee is known + if (stored.fee_known) { + m_feerate_index.emplace(stored.feerate(), txid); + } + + // Periodic mempool stats (every 100 txs) + if (m_pool.size() % 100 == 0 || m_pool.size() <= 5) { + LOG_INFO << "[EMB] Mempool: size=" << m_pool.size() + << " bytes=" << m_total_bytes << "/" << m_max_bytes + << " txid=" << txid.GetHex().substr(0, 16) + << " w=" << stored.weight + << " fee=" << (stored.fee_known ? std::to_string(stored.fee) : "?") + << (evicted > 0 ? " evict=" + std::to_string(evicted) : ""); + } + return true; + } + + /// Remove a single transaction by txid. + void remove_tx(const uint256& txid) { + std::lock_guard lock(m_mutex); + remove_tx_locked(txid); + } + + /// Manually set a transaction's fee (for testing without a UTXO set). + void set_tx_fee(const uint256& txid, uint64_t fee) { + std::lock_guard lock(m_mutex); + auto it = m_pool.find(txid); + if (it == m_pool.end()) return; + it->second.fee = fee; + it->second.fee_known = true; + m_feerate_index.emplace(it->second.feerate(), txid); + } + + /// Remove confirmed txs + double-spend conflicts from mempool. + /// Called from the full_block callback when a new block arrives via P2P. + /// + /// Phase 1: remove txs whose txid appears in the block. + /// Phase 2: remove mempool txs that spend the same outputs as block txs + /// (double-spend / conflict detection via m_spent_outputs). + void remove_for_block(const BlockType& block) { + std::lock_guard lock(m_mutex); + int removed = 0, conflicts = 0; + + // Phase 1: remove confirmed txs by txid + for (const auto& mtx : block.m_txs) { + uint256 txid = compute_txid(mtx); + if (m_pool.count(txid)) ++removed; + remove_tx_locked(txid); + } + + // Phase 2: remove mempool txs that conflict with block txs + // (spend the same input as a confirmed tx) + for (const auto& mtx : block.m_txs) { + for (const auto& vin : mtx.vin) { + auto key = std::make_pair(vin.prevout.hash, vin.prevout.index); + auto it = m_spent_outputs.find(key); + if (it != m_spent_outputs.end()) { + // A mempool tx spends the same output — it's now invalid + auto conflict_txid = it->second; + if (m_pool.count(conflict_txid)) { + LOG_INFO << "[EMB-BTC] Mempool: removing conflict tx " + << conflict_txid.GetHex().substr(0, 16) + << " (spends same input as confirmed tx)"; + ++conflicts; + } + remove_tx_locked(conflict_txid); + } + } + } + + // Phase 3: remove orphaned children of conflict-removed txs. + // When a mempool tx is removed due to double-spend conflict (Phase 2), + // any child mempool tx that spends its outputs becomes orphaned. + // Reference: LTC txmempool.cpp removeRecursive() + int orphans = 0; + if (conflicts > 0) { + // Collect orphaned children: mempool txs whose inputs reference + // a txid that was just removed (no longer in m_pool). + std::vector orphan_txids; + for (auto& [txid, entry] : m_pool) { + for (const auto& vin : entry.tx.vin) { + // If input references a tx NOT in UTXO and NOT in mempool, + // then this tx is orphaned (parent was conflict-removed). + // Quick check: if prevout.hash was a conflict victim. + if (!m_pool.count(vin.prevout.hash)) { + // Parent not in mempool — check if it was a removed conflict + // by testing if this output is in m_spent_outputs + // (it won't be, since the parent was erased from m_spent_outputs + // during Phase 2). So just check: is the parent hash absent? + // This is conservative — could false-positive for confirmed txs, + // but those are fine (child remains valid, input is in UTXO). + // We only mark as orphan if fee computation would fail. + if (entry.fee_known) { + // Fee was known → inputs were valid before this block. + // Re-check by attempting fee recompute next cycle. + entry.fee_known = false; + } + } + } + } + } + + if (removed > 0 || conflicts > 0) { + LOG_INFO << "[EMB] Mempool: block cleanup removed=" << removed + << " conflicts=" << conflicts + << " remaining=" << m_pool.size(); + } + } + + /// Evict entries older than expiry_sec. + void evict_expired() { + time_t cutoff = std::time(nullptr) - m_expiry_sec; + std::lock_guard lock(m_mutex); + while (!m_time_index.empty() && m_time_index.begin()->first < cutoff) { + evict_one_locked(m_time_index.begin()->second); + } + } + + /// Clear the entire mempool. + void clear() { + std::lock_guard lock(m_mutex); + m_pool.clear(); + m_time_index.clear(); + m_feerate_index.clear(); + m_spent_outputs.clear(); + m_total_bytes = 0; + } + + // ─── Queries ───────────────────────────────────────────────────────── + + bool contains(const uint256& txid) const { + std::lock_guard lock(m_mutex); + return m_pool.count(txid) > 0; + } + + size_t size() const { + std::lock_guard lock(m_mutex); + return m_pool.size(); + } + + size_t byte_size() const { + std::lock_guard lock(m_mutex); + return m_total_bytes; + } + + /// Sum of all known fees across mempool transactions. + uint64_t total_fees() const { + std::lock_guard lock(m_mutex); + uint64_t sum = 0; + for (const auto& [txid, entry] : m_pool) { + if (entry.fee_known) + sum += entry.fee; + } + return sum; + } + + std::optional get_entry(const uint256& txid) const { + std::lock_guard lock(m_mutex); + auto it = m_pool.find(txid); + if (it == m_pool.end()) return std::nullopt; + return it->second; + } + + /// Return up to max_weight BIP141 weight units worth of transactions, + /// in FIFO order (oldest first — fair ordering without feerate data). + /// Legacy method for backward compatibility. + std::vector get_sorted_txs(uint32_t max_weight) const { + std::lock_guard lock(m_mutex); + + std::vector result; + uint32_t total_weight = 0; + + // Iterate by arrival time (oldest first) + for (auto& [ts, txid] : m_time_index) { + auto it = m_pool.find(txid); + if (it == m_pool.end()) continue; + + const auto& entry = it->second; + if (total_weight + entry.weight > max_weight) continue; + + total_weight += entry.weight; + result.push_back(entry.tx); + } + return result; + } + + /// Result struct for fee-aware transaction selection. + struct SelectedTx { + MutableTransaction tx; + uint64_t fee{0}; + bool fee_known{false}; + }; + + /// Return transactions sorted by feerate (highest first), up to max_weight. + /// Transactions with known fees are prioritized; unknown-fee txs fill remaining space. + /// Returns total_fees across all selected transactions (known fees only). + std::pair, uint64_t> + get_sorted_txs_with_fees(uint32_t max_weight) const { + std::lock_guard lock(m_mutex); + + std::vector result; + uint64_t total_fees = 0; + uint32_t total_weight = 0; + std::set selected; + + // Pass 1: highest feerate first (known-fee txs) + auto* utxo = m_utxo.load(); + for (auto it = m_feerate_index.begin(); it != m_feerate_index.end(); ++it) { + auto pit = m_pool.find(it->second); + if (pit == m_pool.end()) continue; + const auto& entry = pit->second; + if (!entry.fee_known) continue; + if (total_weight + entry.weight > max_weight) continue; + + // Guard: verify inputs still exist in UTXO (or parent mempool tx). + // Catches stale txs in the window between tip-change and full_block arrival. + if (utxo) { + bool inputs_ok = true; + for (const auto& vin : entry.tx.vin) { + core::coin::Outpoint op(vin.prevout.hash, vin.prevout.index); + core::coin::Coin coin; + if (!utxo->get_coin(op, coin)) { + // Check if parent is in mempool (CPFP chain) + if (!m_pool.count(vin.prevout.hash) || + vin.prevout.index >= m_pool.at(vin.prevout.hash).tx.vout.size()) { + inputs_ok = false; + break; + } + } + } + if (!inputs_ok) continue; // skip stale tx + } + + total_weight += entry.weight; + total_fees += entry.fee; + result.push_back({entry.tx, entry.fee, true}); + selected.insert(entry.txid); + } + + // Unknown-fee txs excluded from template — they'll be included + // after fee revalidation once UTXO processes their input blocks. + // Including them with fee=0 would cause coinbasevalue mismatch + // vs p2pool (which gets accurate fees from daemon GBT). + + return {std::move(result), total_fees}; + } + + /// Re-attempt fee computation for all transactions with fee_known=false. + /// Call after a new block is connected (new UTXOs may resolve missing inputs). + /// Returns the number of transactions whose fees were successfully computed. + int recompute_unknown_fees(core::coin::UTXOViewCache* utxo) { + if (!utxo) return 0; + std::lock_guard lock(m_mutex); + int resolved = 0; + int still_unknown = 0; + uint64_t resolved_total_fee = 0; + for (auto& [txid, entry] : m_pool) { + if (entry.fee_known) continue; + if (compute_fee_locked(entry, utxo)) { + m_feerate_index.emplace(entry.feerate(), txid); + resolved_total_fee += entry.fee; + ++resolved; + } else { + ++still_unknown; + } + } + if (resolved > 0 || still_unknown > 0) { + LOG_INFO << "[EMB] Mempool fee revalidation: resolved=" << resolved + << " still_unknown=" << still_unknown + << " resolved_fees=" << resolved_total_fee << " sat" + << " pool_size=" << m_pool.size(); + } + return resolved; + } + + /// Re-validate all fee-known mempool transactions against the UTXO set. + /// Evicts any transaction whose inputs are no longer in the UTXO set + /// (i.e., were spent by a confirmed block but not caught by remove_for_block's + /// conflict detection — e.g., when the spending tx wasn't tracked in m_spent_outputs). + /// Call after remove_for_block() + UTXO connect to catch stale transactions. + /// Returns the number of evicted transactions. + int revalidate_inputs(core::coin::UTXOViewCache* utxo) { + if (!utxo) return 0; + std::lock_guard lock(m_mutex); + + std::vector to_remove; + for (const auto& [txid, entry] : m_pool) { + if (!entry.fee_known) continue; // already quarantined + for (const auto& vin : entry.tx.vin) { + core::coin::Outpoint op(vin.prevout.hash, vin.prevout.index); + core::coin::Coin coin; + if (!utxo->get_coin(op, coin)) { + // Input not in UTXO — check if parent is in mempool (CPFP) + if (m_pool.count(vin.prevout.hash) && + vin.prevout.index < m_pool.at(vin.prevout.hash).tx.vout.size()) + continue; // parent still in mempool, tx is valid + to_remove.push_back(txid); + break; + } + } + } + + for (const auto& txid : to_remove) + remove_tx_locked(txid); + + if (!to_remove.empty()) { + LOG_INFO << "[EMB] Mempool revalidate: evicted " << to_remove.size() + << " stale txs (inputs spent), remaining=" << m_pool.size(); + } + return static_cast(to_remove.size()); + } + + /// Snapshot of all txids currently in the pool. + std::vector all_txids() const { + std::lock_guard lock(m_mutex); + std::vector out; + out.reserve(m_pool.size()); + for (auto& [txid, _] : m_pool) + out.push_back(txid); + return out; + } + + /// Snapshot of all transactions as a txid→tx map (for compact block reconstruction). + std::map all_txs_map() const { + std::lock_guard lock(m_mutex); + std::map out; + for (const auto& [txid, entry] : m_pool) + out[txid] = entry.tx; + return out; + } + + /// Return all transactions keyed by wtxid (BIP 152 v2 compact block reconstruction). + std::map all_txs_map_wtxid() const { + std::lock_guard lock(m_mutex); + std::map out; + for (const auto& [txid, entry] : m_pool) { + auto packed = pack(TX_WITH_WITNESS(entry.tx)); + uint256 wtxid = Hash(packed.get_span()); + out[wtxid] = entry.tx; + } + return out; + } + + // ─── Lightweight snapshot for explorer API ─────────────────────────── + + /// Lightweight entry metadata (no MutableTransaction copy). + struct MempoolEntrySummary { + uint256 txid; + uint32_t base_size{0}; + uint32_t weight{0}; + uint64_t fee{0}; + bool fee_known{false}; + time_t time_added{0}; + uint32_t n_vin{0}; + uint32_t n_vout{0}; + double feerate() const { + uint32_t vsize = (weight + 3) / 4; + return (fee_known && vsize > 0) ? static_cast(fee) / vsize : 0.0; + } + }; + + /// Aggregate mempool statistics + sorted entry list. + struct MempoolSummary { + size_t tx_count{0}; + size_t total_bytes{0}; + uint64_t total_fees{0}; + uint32_t total_weight{0}; + size_t fee_known_count{0}; + double min_feerate{0}; + double max_feerate{0}; + double median_feerate{0}; + double avg_feerate{0}; + time_t oldest_time{0}; + std::vector entries; // sorted by feerate descending + }; + + /// Build a lightweight snapshot: copies only metadata (no tx bodies), + /// computes aggregates, sorts by feerate descending. Single lock hold. + MempoolSummary get_summary() const { + std::lock_guard lock(m_mutex); + MempoolSummary s; + s.tx_count = m_pool.size(); + s.total_bytes = m_total_bytes; + s.entries.reserve(m_pool.size()); + + std::vector feerates; // for median computation + double feerate_sum = 0; + s.oldest_time = std::numeric_limits::max(); + + for (const auto& [id, e] : m_pool) { + MempoolEntrySummary es; + es.txid = e.txid; + es.base_size = e.base_size; + es.weight = e.weight; + es.fee = e.fee; + es.fee_known = e.fee_known; + es.time_added = e.time_added; + es.n_vin = static_cast(e.tx.vin.size()); + es.n_vout = static_cast(e.tx.vout.size()); + s.total_weight += e.weight; + if (e.fee_known) { + s.total_fees += e.fee; + ++s.fee_known_count; + double fr = es.feerate(); + feerates.push_back(fr); + feerate_sum += fr; + if (fr < s.min_feerate || s.min_feerate == 0) s.min_feerate = fr; + if (fr > s.max_feerate) s.max_feerate = fr; + } + if (e.time_added < s.oldest_time) s.oldest_time = e.time_added; + s.entries.push_back(std::move(es)); + } + + // Sort by feerate descending + std::sort(s.entries.begin(), s.entries.end(), + [](const MempoolEntrySummary& a, const MempoolEntrySummary& b) { + return a.feerate() > b.feerate(); + }); + + // Median + average feerate + if (!feerates.empty()) { + std::sort(feerates.begin(), feerates.end()); + s.median_feerate = feerates[feerates.size() / 2]; + s.avg_feerate = feerate_sum / feerates.size(); + } + if (s.tx_count == 0) s.oldest_time = 0; + + return s; + } + +private: + // ─── Internal (caller holds mutex) ─────────────────────────────────── + + /// Compute fee for a mempool entry using UTXO + parent mempool lookups. + /// Includes MoneyRange overflow checks and coinbase/pegout maturity. + /// Reference: LTC consensus/tx_verify.cpp CheckTxInputs() + /// Returns true if fee was successfully computed. + bool compute_fee_locked(MempoolEntry& entry, core::coin::UTXOViewCache* utxo) { + if (!utxo) { + entry.fee = 0; + entry.fee_known = false; + return false; + } + + int64_t value_in = 0; + bool all_found = true; + + for (const auto& vin : entry.tx.vin) { + core::coin::Outpoint op(vin.prevout.hash, vin.prevout.index); + core::coin::Coin coin; + + if (utxo->get_coin(op, coin)) { + // MoneyRange check on individual coin value + if (!core::coin::money_range(coin.value, m_limits)) { + entry.fee = 0; entry.fee_known = false; + return false; + } + // Coinbase/pegout maturity check + if (m_tip_height > 0 && !coin.is_mature(m_tip_height, m_limits)) { + entry.fee = 0; entry.fee_known = false; + return false; // premature spend + } + value_in += coin.value; + // MoneyRange check on accumulated value_in + if (!core::coin::money_range(value_in, m_limits)) { + entry.fee = 0; entry.fee_known = false; + return false; + } + } else { + // Not in UTXO — check parent mempool tx (CPFP) + auto parent_it = m_pool.find(vin.prevout.hash); + if (parent_it != m_pool.end() + && vin.prevout.index < parent_it->second.tx.vout.size()) { + int64_t parent_val = parent_it->second.tx.vout[vin.prevout.index].value; + if (!core::coin::money_range(parent_val, m_limits)) { + entry.fee = 0; entry.fee_known = false; + return false; + } + value_in += parent_val; + if (!core::coin::money_range(value_in, m_limits)) { + entry.fee = 0; entry.fee_known = false; + return false; + } + } else { + all_found = false; + break; + } + } + } + + if (!all_found) { + entry.fee = 0; + entry.fee_known = false; + return false; + } + + // Sum outputs with overflow check + int64_t value_out = 0; + for (const auto& vout : entry.tx.vout) { + value_out += vout.value; + if (!core::coin::money_range(value_out, m_limits)) { + entry.fee = 0; entry.fee_known = false; + return false; + } + } + + int64_t fee = value_in - value_out; + if (fee < 0 || !core::coin::money_range(fee, m_limits)) { + entry.fee = 0; + entry.fee_known = false; + return false; + } + + entry.fee = static_cast(fee); + entry.fee_known = true; + return true; + } + + void remove_tx_locked(const uint256& txid) { + auto it = m_pool.find(txid); + if (it == m_pool.end()) return; + + m_total_bytes -= it->second.base_size; + + // Clean up spent outputs index + for (const auto& vin : it->second.tx.vin) { + auto key = std::make_pair(vin.prevout.hash, vin.prevout.index); + auto so = m_spent_outputs.find(key); + if (so != m_spent_outputs.end() && so->second == txid) + m_spent_outputs.erase(so); + } + + // Remove from time index + auto range = m_time_index.equal_range(it->second.time_added); + for (auto ti = range.first; ti != range.second; ++ti) { + if (ti->second == txid) { + m_time_index.erase(ti); + break; + } + } + + // Remove from feerate index + if (it->second.fee_known) { + auto fr_range = m_feerate_index.equal_range(it->second.feerate()); + for (auto fi = fr_range.first; fi != fr_range.second; ++fi) { + if (fi->second == txid) { + m_feerate_index.erase(fi); + break; + } + } + } + + m_pool.erase(it); + } + + void evict_one_locked(const uint256& txid) { + remove_tx_locked(txid); + } + + // ─── State ─────────────────────────────────────────────────────────── + + mutable std::mutex m_mutex; + + std::map m_pool; // txid → entry + std::multimap m_time_index; // arrival time → txid (FIFO) + + /// Feerate index: feerate (sat/vbyte, descending) → txid. + /// Only contains entries with fee_known=true. + /// Used by get_sorted_txs_with_fees() for optimal block template building. + std::multimap> m_feerate_index; + + /// Conflict detection: (prev_txid, prev_n) → spending mempool txid. + /// Mirrors Litecoin Core's mapNextTx for O(1) double-spend detection. + std::map, uint256> m_spent_outputs; + + size_t m_total_bytes{0}; // sum of base_size across all entries + size_t m_max_bytes; + time_t m_expiry_sec; + core::coin::ChainLimits m_limits; // MoneyRange + maturity constants + uint32_t m_tip_height{0}; // current chain tip (for maturity checks) + std::atomic m_utxo{nullptr}; // for template-time input validation +}; + +} // namespace coin +} // namespace btc diff --git a/src/impl/btc/coin/node.hpp b/src/impl/btc/coin/node.hpp new file mode 100644 index 000000000..48842ca0e --- /dev/null +++ b/src/impl/btc/coin/node.hpp @@ -0,0 +1,126 @@ +#pragma once + +#include + +#include + +#include "rpc.hpp" +#include "p2p_node.hpp" +#include "node_interface.hpp" + +namespace btc +{ + +namespace coin +{ + +using p2p::NodeP2P; + +template +class Node : public btc::interfaces::Node +{ + using config_t = ConfigType; + + boost::asio::io_context* m_context; + config_t* m_config; + + std::unique_ptr m_rpc; + std::unique_ptr> m_p2p; + + void init_p2p() + { + m_p2p = std::make_unique>(m_context, this, m_config); + m_p2p->connect(m_config->coin()->m_p2p.address); + } + + void init_rpc() + { + // m_thread_rpc = std::thread + // ( + // [&] + // { + // auto* rpc_context = new boost::asio::io_context(); + // m_rpc = std::make_unique(rpc_context, this, m_config->m_testnet); + // m_rpc->connect(m_config->m_rpc.address, m_config->m_rpc.userpass); + // // for test: + // boost::asio::post(*rpc_context, [&]{ + // auto res = m_rpc->getwork(); + // std::cout << res.m_data.dump() << std::endl; + // }); + // rpc_context->run(); + // } + // ); + + 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.set(m_rpc->getwork()); + } + +public: + + Node(auto* context, auto* config) : m_context(context), m_config(config) + { + } + + void run() + { + // RPC + init_rpc(); + } + + /// Start P2P connection to coin daemon for fast block relay. + /// Call after run() when P2P address is configured. + void start_p2p(const NetService& addr) + { + m_p2p = std::make_unique>(m_context, this, m_config); + m_p2p->connect(addr); + LOG_INFO << "Coin P2P broadcaster connecting to " << addr.to_string(); + } + + /// Submit a block via P2P directly (faster propagation than RPC). + void submit_block_p2p(BlockType& block) + { + if (m_p2p) + m_p2p->submit_block(block); + } + + /// Submit a pre-serialized block via P2P. Used by the stratum work + /// source which already has the full block bytes assembled from + /// (header || tx_count || coinbase || tx_data) and doesn't need to + /// round-trip through BlockType deserialization. + void submit_block_p2p_raw(const std::vector& block_bytes) + { + if (m_p2p) + m_p2p->submit_block_raw(block_bytes); + } + + bool has_p2p() const { return m_p2p != nullptr; } + + /// Send getheaders to drive header sync (BIP 31). + /// Locator should be hashes from chain tip back to genesis (sparsely); + /// for an empty chain pass {genesis_hash}. Stop = uint256::ZERO means + /// "send up to 2000 headers from locator's first match forward". + /// Per BTC protocol: the version field is the requester's protocol_version + /// (we use 70016 to match B1's coin/p2p_node.hpp version handshake). + void send_getheaders(uint32_t version, + const std::vector& locator, + const uint256& stop) + { + if (m_p2p) + m_p2p->send_getheaders(version, locator, stop); + } + + /// True once the version+verack handshake completed with the peer. + bool is_handshake_complete() const + { + return m_p2p && m_p2p->is_handshake_complete(); + } +}; + +} // namespace coin + + +} // namespace coin diff --git a/src/impl/btc/coin/node_interface.hpp b/src/impl/btc/coin/node_interface.hpp new file mode 100644 index 000000000..b2e983149 --- /dev/null +++ b/src/impl/btc/coin/node_interface.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +#include "block.hpp" +#include "rpc_data.hpp" +#include "transaction.hpp" +#include "txidcache.hpp" + +#include +#include + +#include + +namespace btc +{ + +namespace interfaces +{ + +struct Node +{ + Variable work; //get_work result + Event new_block; //block_hash + Event new_tx; //bitcoin_data.tx_type + Event> new_headers; //bitcoin_data.block_header_type + Event full_block; // full block with txs + MWEB data + + coin::TXIDCache txidcache; + std::map known_txs; // TODO: move to other? +}; + +} // namespace interfaces + +} // namespace btc::coin + + diff --git a/src/impl/btc/coin/p2p_connection.cpp b/src/impl/btc/coin/p2p_connection.cpp new file mode 100644 index 000000000..1e175fea7 --- /dev/null +++ b/src/impl/btc/coin/p2p_connection.cpp @@ -0,0 +1,40 @@ +#include "p2p_connection.hpp" + +namespace btc +{ + +namespace coin +{ + +namespace p2p +{ + +void Connection::request_block(uint256 id, uint256 hash, std::function handler) +{ + if (m_get_block) + m_get_block->request(id, handler, hash); +} + +void Connection::get_block(uint256 id, BlockType response) +{ + if (m_get_block) + m_get_block->got_response(id, response); +} + +void Connection::request_header(uint256 id, uint256 hash, std::function handler) +{ + if (m_get_header) + m_get_header->request(id, handler, hash); +} + +void Connection::get_header(uint256 id, BlockHeaderType response) +{ + if (m_get_header) + m_get_header->got_response(id, response); +} + +} // namespace p2p + +} // namespace coin + +} // namespace btc diff --git a/src/impl/btc/coin/p2p_connection.hpp b/src/impl/btc/coin/p2p_connection.hpp new file mode 100644 index 000000000..49b8b6d24 --- /dev/null +++ b/src/impl/btc/coin/p2p_connection.hpp @@ -0,0 +1,90 @@ +#pragma once + +#include "block.hpp" + +#include +#include +#include +#include + +namespace btc +{ + +namespace coin +{ + +namespace p2p +{ + +class Connection +{ + static constexpr int REQUEST_TIMEOUT_SEC = 15; + using get_block_t = ReplyMatcher::ID::RESPONSE::REQUEST; + using get_header_t = ReplyMatcher::ID::RESPONSE::REQUEST; + +private: + boost::asio::io_context* m_context{}; + std::shared_ptr m_socket; + + get_block_t* m_get_block{}; + get_header_t* m_get_header{}; + +public: + + Connection(boost::asio::io_context* context, std::shared_ptr socket) : m_context(context), m_socket(socket) + { + + } + + ~Connection() + { + if (m_get_block) + delete m_get_block; + if (m_get_header) + delete m_get_header; + + if (m_socket) + { + m_socket->cancel(); + m_socket->close(); + m_socket.reset(); // prevent use-after-close + } + } + + void init_requests(std::function block_req, std::function header_req) + { + m_get_block = new get_block_t(m_context, block_req, REQUEST_TIMEOUT_SEC); + m_get_header = new get_header_t(m_context, header_req, REQUEST_TIMEOUT_SEC); + } + + void request_block(uint256 id, uint256 hash, std::function handler); + void get_block(uint256 id, BlockType response); + + void request_header(uint256 id, uint256 hash, std::function handler); + void get_header(uint256 id, BlockHeaderType response); + + void write(std::unique_ptr& rmsg) + { + if (!m_socket) return; // peer disconnected or destroyed + try { + m_socket->write(std::move(rmsg)); + } catch (const std::exception& e) { + // Socket may be closed/broken — don't crash + m_socket.reset(); + } + } + + auto get_addr() const + { + if (m_socket) + return m_socket->get_addr(); + else + return NetService{}; + } +}; + +} // namespace p2p + +} // namespace coin + +} // namespace btc diff --git a/src/impl/btc/coin/p2p_messages.hpp b/src/impl/btc/coin/p2p_messages.hpp new file mode 100644 index 000000000..4e39cdc34 --- /dev/null +++ b/src/impl/btc/coin/p2p_messages.hpp @@ -0,0 +1,396 @@ +#pragma once + +#include "transaction.hpp" +#include "block.hpp" +#include "compact_blocks.hpp" + +#include + +#include +#include +#include +#include + +namespace btc +{ +namespace coin +{ + +namespace p2p +{ + +// Bitcoin wire protocol uses uint32_t timestamp in addr messages, +// unlike the pool protocol which uses uint64_t. +struct btc_addr_record_t : addr_t +{ + uint32_t m_timestamp{}; + + btc_addr_record_t() : addr_t() {} + + SERIALIZE_METHODS(btc_addr_record_t) { READWRITE(obj.m_timestamp, AsBase(obj)); } +}; +//[+] void handle_message_tx(std::shared_ptr msg, CoindProtocol* protocol); // +//[+] void handle_message_block(std::shared_ptr msg, CoindProtocol* protocol); // +//[+] void handle_message_headers(std::shared_ptr msg, CoindProtocol* protocol); // + +// message_version +BEGIN_MESSAGE(version) + MESSAGE_FIELDS + ( + (uint32_t, m_version), + (uint64_t, m_services), + (uint64_t, m_timestamp), + (addr_t , m_addr_to), + (addr_t, m_addr_from), + (uint64_t, m_nonce), + (std::string, m_subversion), + (uint32_t, m_start_height) + ) + { + READWRITE(obj.m_version, obj.m_services, obj.m_timestamp, obj.m_addr_to, obj.m_addr_from, obj.m_nonce, obj.m_subversion, obj.m_start_height); + } +END_MESSAGE() + +BEGIN_MESSAGE(verack) + WITHOUT_MESSAGE_FIELDS() { } +END_MESSAGE() + +BEGIN_MESSAGE(ping) + MESSAGE_FIELDS + ( + (uint64_t, m_nonce) + ) + { + READWRITE(obj.m_nonce); + } +END_MESSAGE() + +BEGIN_MESSAGE(pong) + MESSAGE_FIELDS + ( + (uint64_t, m_nonce) + ) + { + READWRITE(obj.m_nonce); + } +END_MESSAGE() + +BEGIN_MESSAGE(alert) + MESSAGE_FIELDS + ( + (std::string, m_message), + (std::string, m_signature) + ) + { + READWRITE(obj.m_message, obj.m_signature); + } +END_MESSAGE() + +struct inventory_type +{ + enum inv_type : uint32_t + { + tx = 1, + block = 2, + filtered_block = 3, + cmpct_block = 4, + wtx = 5, // MSG_WTX (BIP 339 — wtxid-based relay) + witness_tx = 0x40000001, // MSG_WITNESS_TX (BIP 144) + witness_block = 0x40000002, // MSG_WITNESS_BLOCK (BIP 144) + }; + + static constexpr uint32_t MSG_WITNESS_FLAG = 0x40000000; + + inv_type m_type; + uint256 m_hash; + + inventory_type() { } + inventory_type(inv_type type, uint256 hash) : m_type(type), m_hash(hash) { } + + /// Strip the witness flag to get the base type (tx or block). + inv_type base_type() const + { + return static_cast(static_cast(m_type) & ~MSG_WITNESS_FLAG); + } + + bool is_witness() const + { + return (static_cast(m_type) & MSG_WITNESS_FLAG) != 0; + } + + SERIALIZE_METHODS(inventory_type) {READWRITE(Using>>(obj.m_type), obj.m_hash);} +}; + +BEGIN_MESSAGE(inv) + MESSAGE_FIELDS + ( + (std::vector, m_invs) + ) + { + READWRITE(obj.m_invs); + } +END_MESSAGE() + +BEGIN_MESSAGE(getdata) + MESSAGE_FIELDS + ( + (std::vector, m_requests) + ) + { + READWRITE(obj.m_requests); + } +END_MESSAGE() + +BEGIN_MESSAGE(getblocks) + MESSAGE_FIELDS + ( + (uint32_t, m_version), + (std::vector, m_have), + (uint256, m_last) + ) + { + READWRITE(obj.m_version, obj.m_have, obj.m_last); + } +END_MESSAGE() + +BEGIN_MESSAGE(getheaders) + MESSAGE_FIELDS + ( + (uint32_t, m_version), + (std::vector, m_have), + (uint256, m_last) + ) + { + READWRITE(obj.m_version, obj.m_have, obj.m_last); + } +END_MESSAGE() + +BEGIN_MESSAGE(tx) + MESSAGE_FIELDS + ( + (MutableTransaction, m_tx) + ) + { + READWRITE(TX_WITH_WITNESS(obj.m_tx)); + } +END_MESSAGE() + +BEGIN_MESSAGE(block) + MESSAGE_FIELDS + ( + (BlockType, m_block), + (std::vector, m_raw_payload) + ) + { + READWRITE(obj.m_block); + } +END_MESSAGE() + +BEGIN_MESSAGE(headers) + MESSAGE_FIELDS + ( + (std::vector, m_headers), + (std::vector, m_raw_payload) + ) + { + READWRITE(obj.m_headers); + } +END_MESSAGE() + +// P2P address discovery messages (used by coin broadcaster) +BEGIN_MESSAGE(getaddr) + WITHOUT_MESSAGE_FIELDS() { } +END_MESSAGE() + +BEGIN_MESSAGE(addr) + MESSAGE_FIELDS + ( + (std::vector, m_addrs) + ) + { + READWRITE(obj.m_addrs); + } +END_MESSAGE() + +// BIP 61 — reject message (deprecated in Bitcoin Core 0.20 but still sent by some peers) +BEGIN_MESSAGE(reject) + MESSAGE_FIELDS + ( + (std::string, m_message), + (uint8_t, m_ccode), + (std::string, m_reason), + (uint256, m_data) + ) + { + READWRITE(obj.m_message, obj.m_ccode, obj.m_reason, obj.m_data); + } +END_MESSAGE() + +// BIP 130 — sendheaders (empty, signals header-first block announcements) +BEGIN_MESSAGE(sendheaders) + WITHOUT_MESSAGE_FIELDS() { } +END_MESSAGE() + +// notfound — same layout as inv; response when getdata items are unavailable +BEGIN_MESSAGE(notfound) + MESSAGE_FIELDS + ( + (std::vector, m_invs) + ) + { + READWRITE(obj.m_invs); + } +END_MESSAGE() + +// BIP 133 — feefilter (minimum feerate for tx relay, in sat/kB) +BEGIN_MESSAGE(feefilter) + MESSAGE_FIELDS + ( + (uint64_t, m_feerate) + ) + { + READWRITE(obj.m_feerate); + } +END_MESSAGE() + +// BIP 35 — mempool (empty, requests peer to send inv for all mempool txs) +BEGIN_MESSAGE(mempool) + WITHOUT_MESSAGE_FIELDS() { } +END_MESSAGE() + +// BIP 152 — sendcmpct (compact block negotiation) +BEGIN_MESSAGE(sendcmpct) + MESSAGE_FIELDS + ( + (bool, m_announce), + (uint64_t, m_version) + ) + { + READWRITE(obj.m_announce, obj.m_version); + } +END_MESSAGE() + +// BIP 152 — cmpctblock (HeaderAndShortIDs) +class message_cmpctblock : public Message { +private: + using message_type = message_cmpctblock; +public: + message_cmpctblock() : Message("cmpctblock") {} + + CompactBlock m_compact_block; + + static std::unique_ptr make_raw(const CompactBlock& cb) { + auto temp = std::make_unique(); + temp->m_compact_block = cb; + auto result = std::make_unique(temp->m_command, pack(*temp)); + return result; + } + static std::unique_ptr make_raw() { + return std::make_unique("cmpctblock", PackStream{}); + } + static std::unique_ptr make(PackStream& stream) { + auto result = std::make_unique(); + stream >> *result; + return result; + } + template void Serialize(Stream& s) const { m_compact_block.Serialize(s); } + template void Unserialize(Stream& s) { m_compact_block.Unserialize(s); } +}; + +// BIP 152 — getblocktxn (request missing transactions) +class message_getblocktxn : public Message { +private: + using message_type = message_getblocktxn; +public: + message_getblocktxn() : Message("getblocktxn") {} + + BlockTransactionsRequest m_request; + + static std::unique_ptr make_raw(const BlockTransactionsRequest& req) { + auto temp = std::make_unique(); + temp->m_request = req; + auto result = std::make_unique(temp->m_command, pack(*temp)); + return result; + } + static std::unique_ptr make_raw() { + return std::make_unique("getblocktxn", PackStream{}); + } + static std::unique_ptr make(PackStream& stream) { + auto result = std::make_unique(); + stream >> *result; + return result; + } + template void Serialize(Stream& s) const { m_request.Serialize(s); } + template void Unserialize(Stream& s) { m_request.Unserialize(s); } +}; + +// BIP 152 — blocktxn (missing transactions response) +class message_blocktxn : public Message { +private: + using message_type = message_blocktxn; +public: + message_blocktxn() : Message("blocktxn") {} + + BlockTransactionsResponse m_response; + + static std::unique_ptr make_raw(const BlockTransactionsResponse& resp) { + auto temp = std::make_unique(); + temp->m_response = resp; + auto result = std::make_unique(temp->m_command, pack(*temp)); + return result; + } + static std::unique_ptr make_raw() { + return std::make_unique("blocktxn", PackStream{}); + } + static std::unique_ptr make(PackStream& stream) { + auto result = std::make_unique(); + stream >> *result; + return result; + } + template void Serialize(Stream& s) const { m_response.Serialize(s); } + template void Unserialize(Stream& s) { m_response.Unserialize(s); } +}; + +// BIP 339 — wtxidrelay (empty, signals wtxid-based tx relay; sent before verack) +BEGIN_MESSAGE(wtxidrelay) + WITHOUT_MESSAGE_FIELDS() { } +END_MESSAGE() + +// BIP 155 — sendaddrv2 (empty, signals addrv2 support; sent before verack) +BEGIN_MESSAGE(sendaddrv2) + WITHOUT_MESSAGE_FIELDS() { } +END_MESSAGE() + +using Handler = MessageHandler< + message_version, + message_verack, + message_ping, + message_pong, + message_alert, + message_inv, + message_getdata, + message_getblocks, + message_getheaders, + message_tx, + message_block, + message_headers, + message_getaddr, + message_addr, + message_reject, + message_sendheaders, + message_notfound, + message_feefilter, + message_mempool, + message_sendcmpct, + message_cmpctblock, + message_getblocktxn, + message_blocktxn, + message_wtxidrelay, + message_sendaddrv2 +>; + +} // namespace p2p + +} // namespace coin + +} // namespace btc \ No newline at end of file diff --git a/src/impl/btc/coin/p2p_node.cpp b/src/impl/btc/coin/p2p_node.cpp new file mode 100644 index 000000000..ef654cc41 --- /dev/null +++ b/src/impl/btc/coin/p2p_node.cpp @@ -0,0 +1,26 @@ +#include "p2p_node.hpp" + +namespace btc +{ +namespace coin +{ + +namespace p2p +{ + +std::string parse_net_error(const boost::system::error_code& ec) +{ + switch (ec.value()) + { + case boost::asio::error::eof: + return "EOF, socket disconnected"; + default: + return ec.message(); + } +} + +} // p2p + +} // namespace coin + +} // namespace btc \ No newline at end of file diff --git a/src/impl/btc/coin/p2p_node.hpp b/src/impl/btc/coin/p2p_node.hpp new file mode 100644 index 000000000..87bad1ba6 --- /dev/null +++ b/src/impl/btc/coin/p2p_node.hpp @@ -0,0 +1,988 @@ +#pragma once + +#include "p2p_messages.hpp" +#include "p2p_connection.hpp" +#include "node_interface.hpp" +#include "compact_blocks.hpp" +#include "mempool.hpp" + +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace io = boost::asio; + +#define ADD_P2P_HANDLER(name)\ + void handle(std::unique_ptr msg) +namespace btc +{ +namespace coin +{ + +namespace p2p +{ + +std::string parse_net_error(const boost::system::error_code& ec); + +//-core::ICommmunicator: +// void error(const message_error_type& err, const NetService& service, const std::source_location where = std::source_location::current()) = 0; +// void error(const boost::system::error_code& ec, const NetService& service, const std::source_location where = std::source_location::current()) = 0; +// void handle(std::unique_ptr rmsg, const NetService& service) = 0; +// const std::vector& get_prefix() const = 0; +// +//-core::INetwork: +// void connected(std::shared_ptr socket) = 0; +// void disconnect() = 0; + +template +class NodeP2P : public core::ICommunicator, public core::INetwork, public core::Factory +{ + using config_t = ConfigType; + +private: + static constexpr time_t CONNECT_TIMEOUT_SEC = 10; + static constexpr time_t IDLE_TIMEOUT_SEC = 100; + static constexpr time_t PING_INTERVAL_SEC = 30; + + btc::interfaces::Node* m_coin; + io::io_context* m_context; + config_t* m_config; + p2p::Handler m_handler; + + std::unique_ptr m_peer; + std::unique_ptr m_reconnect_timer; + std::unique_ptr m_ping_timer; + std::unique_ptr m_timeout_timer; + NetService m_target_addr; + bool m_reconnect_enabled = false; + bool m_handshake_complete = false; + std::string m_chain_label = "CoinP2P"; + // BIP 152 compact block state + bool m_peer_supports_cmpct{false}; + uint64_t m_peer_cmpct_version{0}; + bool m_peer_wants_cmpct_announce{false}; + // BIP 339 wtxidrelay state + bool m_peer_wtxidrelay{false}; + // Peer metadata from version message + uint64_t m_peer_services{0}; + uint32_t m_peer_version{0}; // protocol version (e.g. 70016) + std::string m_peer_subver; // user agent (e.g. "/Satoshi:28.0.0/") + uint32_t m_peer_start_height{0}; // chain height at connect time + std::chrono::steady_clock::time_point m_connected_at{std::chrono::steady_clock::now()}; + // BIP 35: request full mempool inventory after handshake + bool m_request_mempool_on_connect{false}; + // Compact block reconstruction state: pending compact block awaiting blocktxn + std::unique_ptr m_pending_cmpct; + std::vector m_pending_missing_indexes; + // Last compact block we SENT — cached to serve getblocktxn requests + BlockType m_sent_cmpct_block; + uint256 m_sent_cmpct_hash; + // External mempool for compact block tx matching + Mempool* m_mempool{nullptr}; + + // Callbacks for broadcaster integration + using AddrCallback = std::function&)>; + AddrCallback m_addr_callback; + using PeerHeightCallback = std::function; + PeerHeightCallback m_on_peer_height; + // Raw headers parser: if set, called with raw payload data instead of + // the standard 80+1 byte parser. Used for DOGE AuxPoW extended headers. + using RawHeadersParser = std::function(const uint8_t*, size_t)>; + RawHeadersParser m_raw_headers_parser; + // Raw block parser: if set, re-parses DOGE AuxPoW full blocks from raw P2P bytes. + using RawBlockParser = std::function; + RawBlockParser m_raw_block_parser; + +public: + NodeP2P(io::io_context* context, btc::interfaces::Node* coin, config_t* config, + const std::string& chain_label = "CoinP2P") + : core::Factory(context, this, chain_label) + , m_context(context), m_coin(coin), m_config(config) + , m_chain_label(chain_label) + { + } + + /// Connect with automatic reconnection on failure/disconnect (30s interval). + void connect(NetService addr) + { + m_target_addr = addr; + m_reconnect_enabled = true; + core::Factory::connect(addr); + + // Periodic reconnect check: if m_peer is null, try again + m_reconnect_timer = std::make_unique(m_context, true); + m_reconnect_timer->start(30, [this]() { + if (!m_peer && m_reconnect_enabled) { + LOG_INFO << "" << "[" << m_chain_label << "] Reconnecting to " << m_target_addr.to_string() << "..."; + core::Factory::connect(m_target_addr); + } + }); + } + + // INetwork + void connected(std::shared_ptr socket) override + { + m_peer = std::make_unique(m_context, socket); + m_handshake_complete = false; + LOG_INFO << "" << "[" << m_chain_label << "] Connected to " << m_target_addr.to_string(); + + // Require version/verack progress soon after connect. + ensure_timeout_timer(); + m_timeout_timer->start(CONNECT_TIMEOUT_SEC, [this]() { + timeout("handshake timeout"); + }); + + // BTC service flags + protocol version: + // BTC: NODE_NETWORK | NODE_WITNESS (no MWEB; LTC-only). + // Protocol 70016 = BIP 339 wtxidrelay activation point (Bitcoin Core 0.21+). + // (LTC-DOGE conditional removed; this is the BTC-specific module.) + static constexpr uint64_t NODE_NETWORK = 1; + static constexpr uint64_t NODE_WITNESS = (1 << 3); + uint64_t our_services = NODE_NETWORK | NODE_WITNESS; + uint32_t protocol_version = 70016; + + auto msg_version = message_version::make_raw( + protocol_version, + our_services, + core::timestamp(), + addr_t{our_services, m_peer->get_addr()}, + addr_t{our_services, NetService{"192.168.0.1", 8333}}, + core::random::random_nonce(), + "c2pool-btc", + 0 + ); + + m_peer->write(msg_version); + } + + void disconnect() override + { + stop_ping_timer(); + stop_timeout_timer(); + m_handshake_complete = false; + m_peer.reset(); + } + + /// Send a getheaders request to the connected peer. + /// @param version Protocol version (typically 70015 or 70017). + /// @param locator Block locator hashes (tip-to-genesis order). + /// @param stop Stop hash (uint256::ZERO to request up to tip). + void send_getheaders(uint32_t version, const std::vector& locator, const uint256& stop) + { + if (!m_peer) return; + // Suppress per-request logging — Header sync progress indicator + // in add_headers() provides the meaningful status update. + auto msg = message_getheaders::make_raw(version, locator, stop); + m_peer->write(msg); + } + + /// Whether the handshake with the peer is complete. + bool is_handshake_complete() const { return m_handshake_complete; } + + /// Send BIP 35 mempool request — ask peer to announce all mempool txs via inv. + void send_mempool() { + if (!m_peer) return; + auto msg = message_mempool::make_raw(); + m_peer->write(msg); + } + + /// Send BIP 133 feefilter — advise peer of minimum feerate we accept (sat/kB). + /// Pass 0 to request all transactions (no filtering). + void send_feefilter(uint64_t min_feerate_sat_per_kb = 0) { + if (!m_peer) return; + auto msg = message_feefilter::make_raw(min_feerate_sat_per_kb); + m_peer->write(msg); + } + + // ICommmunicator + void error(const message_error_type& err, const NetService& service, const std::source_location where = std::source_location::current()) override + { + // Copy — the NetService reference may dangle if the socket is already freed + NetService svc_copy = service; + LOG_WARNING << "[" << m_chain_label << "] Peer " << svc_copy.to_string() + << " disconnected: " << err; + if (m_peer) + { + m_peer.reset(); + } + // else: already disconnected (double-fire race) — safe to ignore + + stop_ping_timer(); + stop_timeout_timer(); + m_handshake_complete = false; + } + + void error(const boost::system::error_code& ec, const NetService& service, const std::source_location where = std::source_location::current()) override + { + error(parse_net_error(ec), service, where); + } + + void handle(std::unique_ptr rmsg, const NetService& service) override + { + on_activity(); + + p2p::Handler::result_t result; + try + { + result = m_handler.parse(rmsg); + } catch (const std::runtime_error& ec) + { + LOG_ERROR << "NodeP2P handle(" << rmsg->m_command << ", " + << rmsg->m_data.size() << " bytes): " << ec.what(); + // todo: error + return; + } catch (const std::out_of_range& ec) + { + LOG_ERROR << "NodeP2P: " << ec.what(); + return; + } + + std::visit([&](auto& msg){ handle(std::move(msg)); }, result); + } + + const std::vector& get_prefix() const override + { + return m_config->coin()->m_p2p.prefix; + } + + void submit_block(BlockType& block) + { + if (m_peer) + { + auto rmsg = btc::coin::p2p::message_block::make_raw(block, {}); + m_peer->write(rmsg); + } else + { + LOG_ERROR << "No bitcoind connection when block submittal attempted!"; + throw std::runtime_error("No bitcoind connection in submit_block"); + } + } + + /// Set callback for received addr messages (peer discovery). + void set_addr_callback(AddrCallback cb) { m_addr_callback = std::move(cb); } + /// Set callback for peer's reported chain height (from version message). + void set_on_peer_height(PeerHeightCallback cb) { m_on_peer_height = std::move(cb); } + /// Set custom raw headers parser (for DOGE AuxPoW extended headers). + void set_raw_headers_parser(RawHeadersParser p) { m_raw_headers_parser = std::move(p); } + + /// Set custom raw block parser (for DOGE AuxPoW full blocks). + void set_raw_block_parser(RawBlockParser p) { m_raw_block_parser = std::move(p); } + + /// Send getaddr to request peer addresses. + void send_getaddr() + { + if (m_peer) { + auto msg = message_getaddr::make_raw(); + m_peer->write(msg); + } + } + + /// Send inv for a block hash (merged chain relay — announcement only). + void send_block_inv(const uint256& block_hash) + { + if (m_peer) { + auto msg = message_inv::make_raw({inventory_type(inventory_type::block, block_hash)}); + m_peer->write(msg); + } + } + + /// Request a full block via getdata. + /// BTC: MSG_WITNESS_BLOCK (0x40000002) — BIP 144 witness-bearing block. + /// (LTC used MSG_MWEB_BLOCK 0x60000002 for MWEB state extraction; + /// bitcoind doesn't recognise that inv type and would peer-disconnect.) + void request_full_block(const uint256& block_hash) + { + if (m_peer) { + auto msg = message_getdata::make_raw( + {inventory_type(inventory_type::witness_block, block_hash)}); + m_peer->write(msg); + } + } + + /// Request a block via plain MSG_BLOCK (0x02) getdata. + /// Works for any block regardless of MWEB support. + void request_block(const uint256& block_hash) + { + if (m_peer) { + auto msg = message_getdata::make_raw( + {inventory_type(inventory_type::block, block_hash)}); + m_peer->write(msg); + } + } + + /// Whether this peer supports compact blocks (BIP 152). + bool supports_compact_blocks() const { return m_peer_supports_cmpct; } + bool peer_wtxidrelay() const { return m_peer_wtxidrelay; } + /// Peer's service flags from version message (for NODE_BLOOM check etc.) + uint64_t peer_services() const { return m_peer_services; } + /// Check if peer supports NODE_BLOOM (required for BIP 35 mempool). + bool peer_has_bloom() const { return (m_peer_services & 4) != 0; } + + /// Peer metadata accessors + uint32_t peer_version() const { return m_peer_version; } + const std::string& peer_subver() const { return m_peer_subver; } + uint32_t peer_start_height() const { return m_peer_start_height; } + int64_t peer_uptime_sec() const { + return std::chrono::duration_cast( + std::chrono::steady_clock::now() - m_connected_at).count(); + } + const std::string& chain_label() const { return m_chain_label; } + + /// Set mempool reference for compact block reconstruction. + void set_mempool(Mempool* mp) { m_mempool = mp; } + + /// Enable BIP 35 mempool request after handshake. + /// Call after UTXO is initialized so incoming txs can have fees computed. + void enable_mempool_request() { m_request_mempool_on_connect = true; } + + /// Relay a pre-serialized block via P2P. + /// Uses compact block format (BIP 152 v2) for peers that support it, + /// falling back to full block otherwise. + void submit_block_raw(const std::vector& block_bytes) + { + if (!m_peer) return; + + if (m_peer_supports_cmpct && m_peer_cmpct_version >= 2) { + // Deserialize the block to build a compact representation + try { + PackStream ps(block_bytes); + BlockType block; + ps >> block; + auto cb = BuildCompactBlock( + static_cast(block), block.m_txs); + auto rmsg = message_cmpctblock::make_raw(cb); + m_peer->write(rmsg); + + auto packed_hdr = pack(static_cast(block)); + auto blockhash = Hash(packed_hdr.get_span()); + + // Cache the full block so we can serve getblocktxn requests. + m_sent_cmpct_block = std::move(block); + m_sent_cmpct_hash = blockhash; + + LOG_INFO << "[" << m_chain_label << "] Sent compact block " + << blockhash.GetHex() + << " (" << cb.short_ids.size() << " short IDs, " + << cb.prefilled_txns.size() << " prefilled)"; + return; + } catch (const std::exception& e) { + LOG_WARNING << "[" << m_chain_label + << "] Compact block build failed (block_size=" << block_bytes.size() + << "), sending full block: " << e.what(); + } + } else { + LOG_DEBUG_COIND << "[" << m_chain_label << "] Peer does not support compact blocks" + << " (cmpct=" << m_peer_supports_cmpct + << " ver=" << m_peer_cmpct_version + << "), sending full block (" << block_bytes.size() << " bytes)"; + } + + // Fallback: send full block + submit_block_full(block_bytes); + } + + /// Send a full block message (legacy relay). + void submit_block_full(const std::vector& block_bytes) + { + if (!m_peer) return; + PackStream ps(block_bytes); + auto rmsg = std::make_unique("block", std::move(ps)); + m_peer->write(rmsg); + LOG_INFO << "[" << m_chain_label << "] Sent full block message (" + << block_bytes.size() << " bytes) to " << m_target_addr.to_string(); + } + + //[x][x][x] void handle_message_version(std::shared_ptr msg, CoindProtocol* protocol); // + //[x][x][x] void handle_message_verack(std::shared_ptr msg, CoindProtocol* protocol); // + //[x][x][x] void handle_message_ping(std::shared_ptr msg, CoindProtocol* protocol); // + //[x][x][x] void handle_message_pong(std::shared_ptr msg, CoindProtocol* protocol); // + //[x][x][x] void handle_message_alert(std::shared_ptr msg, CoindProtocol* protocol); // + //[x][x][x] void handle_message_inv(std::shared_ptr msg, CoindProtocol* protocol); // + //[x][x][x] void handle_message_tx(std::shared_ptr msg, CoindProtocol* protocol); // + //[x][x][x] void handle_message_block(std::shared_ptr msg, CoindProtocol* protocol); // + //[x][x][x] void handle_message_headers(std::shared_ptr msg, CoindProtocol* protocol); // + +private: + void ensure_timeout_timer() + { + if (!m_timeout_timer) + m_timeout_timer = std::make_unique(m_context, false); + } + + void ensure_ping_timer() + { + if (!m_ping_timer) + m_ping_timer = std::make_unique(m_context, true); + } + + void stop_timeout_timer() + { + if (m_timeout_timer) + m_timeout_timer->stop(); + } + + void stop_ping_timer() + { + if (m_ping_timer) + m_ping_timer->stop(); + } + + void on_activity() + { + if (!m_peer) + return; + + ensure_timeout_timer(); + auto timeout = m_handshake_complete ? IDLE_TIMEOUT_SEC : CONNECT_TIMEOUT_SEC; + m_timeout_timer->restart(timeout); + } + + void timeout(const char* reason) + { + auto endpoint = m_peer ? m_peer->get_addr() : m_target_addr; + error(std::string("peer timeout: ") + reason, endpoint); + } + + void send_ping() + { + if (!m_peer || !m_handshake_complete) + return; + + auto msg_ping = message_ping::make_raw(core::random::random_nonce()); + m_peer->write(msg_ping); + } + + ADD_P2P_HANDLER(version) + { + m_peer_services = msg->m_services; + m_peer_version = msg->m_version; + m_peer_subver = msg->m_subversion; + m_peer_start_height = msg->m_start_height; + LOG_INFO << "[" << m_chain_label << "] version: " << msg->m_command + << " start_height=" << msg->m_start_height + << " services=0x" << std::hex << msg->m_services << std::dec + << " subver=" << msg->m_subversion; + // Notify header chain of peer's tip height for fast-sync scrypt skip. + if (m_on_peer_height && msg->m_start_height > 0) + m_on_peer_height(msg->m_start_height); + auto verack_msg = message_verack::make_raw(); + m_peer->write(verack_msg); + } + + ADD_P2P_HANDLER(verack) + { + m_peer->init_requests( + [&](uint256 hash) + { + auto getdata_msg = message_getdata::make_raw({inventory_type(inventory_type::block, hash)}); + m_peer->write(getdata_msg); + }, + [&](uint256 hash) + { + auto getheaders_msg = message_getheaders::make_raw(1, {}, hash); + m_peer->write(getheaders_msg); + } + ); + + m_handshake_complete = true; + ensure_timeout_timer(); + m_timeout_timer->restart(IDLE_TIMEOUT_SEC); + + ensure_ping_timer(); + m_ping_timer->start(PING_INTERVAL_SEC, [this]() { + send_ping(); + }); + + bool is_doge = (m_chain_label == "DOGE" || m_chain_label == "doge"); + + // BIP 130: request header-first block announcements + // DOGE Core may not fully support BIP 130 — skip to avoid misbehaving score + if (!is_doge) { + auto msg_sendheaders = message_sendheaders::make_raw(); + m_peer->write(msg_sendheaders); + } + // BIP 152: compact blocks — DOGE doesn't support segwit compact blocks (v2) + if (!is_doge) { + auto msg_cmpct = message_sendcmpct::make_raw(false, 2); + m_peer->write(msg_cmpct); + } + + // BIP 133: advertise minimum feerate (0 = accept all transactions) + // DOGE Core may not support BIP 133 — skip to avoid disconnection + if (!is_doge) { + send_feefilter(0); + } + + // BIP 35: Request mempool contents from peer. + // CRITICAL: Peers without NODE_BLOOM (0x04) will DISCONNECT us if we + // send the mempool message (litecoind net_processing.cpp:3918-3926). + // Only send if peer advertises NODE_BLOOM in their version services. + // Normal inv relay delivers NEW txs without BIP 35. + static constexpr uint64_t SVC_NODE_BLOOM = 4; + if (m_request_mempool_on_connect) { + if (m_peer_services & SVC_NODE_BLOOM) { + send_mempool(); + LOG_INFO << "[" << m_chain_label << "] Sent BIP 35 mempool request" + << " (peer has NODE_BLOOM)"; + } else { + LOG_INFO << "[" << m_chain_label << "] Skipped BIP 35 mempool request" + << " — peer lacks NODE_BLOOM (0x" << std::hex << m_peer_services + << std::dec << "), would cause disconnect"; + } + } + } + + ADD_P2P_HANDLER(ping) + { + auto msg_pong = message_pong::make_raw(msg->m_nonce); + m_peer->write(msg_pong); + } + + ADD_P2P_HANDLER(pong) + { + // just handled pong + } + + ADD_P2P_HANDLER(alert) + { + LOG_WARNING << "Handled message_alert signature: " << msg->m_signature; + } + + ADD_P2P_HANDLER(inv) + { + std::vector vinv; + + for (auto& inv : msg->m_invs) + { + auto btype = inv.base_type(); + // BIP 339: MSG_WTX (type 5) uses wtxid instead of txid. + // Request via MSG_WITNESS_TX (0x40000001) since getdata doesn't accept MSG_WTX. + // Reference: Bitcoin Core protocol.h line 447, net_processing.cpp line 3036 + if (inv.m_type == inventory_type::wtx) { + vinv.push_back(inventory_type(inventory_type::witness_tx, inv.m_hash)); + continue; + } + + switch (btype) + { + case inventory_type::tx: + // Always request with witness (MSG_WITNESS_TX) so segwit + // transactions arrive with their witness data intact. + // Without this, P2WPKH/P2WSH spends arrive stripped and + // fail CheckQueue when included in blocks. + vinv.push_back(inventory_type(inventory_type::witness_tx, inv.m_hash)); + break; + case inventory_type::block: + m_coin->new_block.happened(inv.m_hash); + // BTC advertises NODE_WITNESS — getdata for blocks must use + // MSG_WITNESS_BLOCK (0x40000002) per BIP 144, otherwise the + // peer drops witness data on the wire. + vinv.push_back(inventory_type( + inventory_type::witness_block, inv.m_hash)); + break; + case inventory_type::filtered_block: + case inventory_type::cmpct_block: + // Recognized but not requested — ignore + break; + default: + LOG_WARNING << "[" << m_chain_label << "] Unknown inv type 0x" << std::hex + << static_cast(inv.m_type) << std::dec; + break; + } + } + + if (!vinv.empty()) + { + auto msg_getdata = message_getdata::make_raw(vinv); + m_peer->write(msg_getdata); + } + } + + ADD_P2P_HANDLER(tx) + { + m_coin->new_tx.happened(Transaction(msg->m_tx)); + } + + ADD_P2P_HANDLER(block) + { + // When a raw block parser is set (DOGE AuxPoW), re-parse the block from + // raw P2P bytes. The standard BlockType deserialization misinterprets + // AuxPoW data as transactions, producing garbage. + BlockType block; + if (m_raw_block_parser && !msg->m_raw_payload.empty()) { + try { + block = m_raw_block_parser(msg->m_raw_payload.data(), + msg->m_raw_payload.size()); + } catch (const std::exception& e) { + LOG_WARNING << "[" << m_chain_label << "] AuxPoW block parser failed: " << e.what() + << " — falling back to standard parse"; + block = msg->m_block; + } + } else { + block = msg->m_block; + } + + auto header = static_cast(block); + auto packed_header = pack(header); + auto blockhash = Hash(packed_header.get_span()); + // ReplyMatcher may throw if nobody registered a pending request for + // this block (e.g., unsolicited block or getdata-triggered response). + // Catch to ensure full_block event always fires. + try { m_peer->get_block(blockhash, block); } catch (...) {} + try { m_peer->get_header(blockhash, header); } catch (...) {} + LOG_INFO << "[" << m_chain_label << "] Full block received: " + << blockhash.GetHex().substr(0, 16) << "..." + << " txs=" << block.m_txs.size(); + m_coin->full_block.happened(block); + } + + ADD_P2P_HANDLER(headers) + { + std::vector vheaders; + + // When a raw parser is set (DOGE AuxPoW), always prefer it over the + // standard parser. The standard parser misinterprets AuxPoW data as + // block transactions, producing a small number of garbage entries + // instead of the full 2000-header batch. + if (m_raw_headers_parser && !msg->m_raw_payload.empty()) { + try { + vheaders = m_raw_headers_parser( + msg->m_raw_payload.data(), msg->m_raw_payload.size()); + LOG_INFO << "[" << m_chain_label << "] AuxPoW parser: " + << vheaders.size() << " headers from " + << msg->m_raw_payload.size() << " bytes"; + } catch (const std::exception& e) { + LOG_WARNING << "[" << m_chain_label << "] AuxPoW headers parser failed: " << e.what(); + } + } + + if (vheaders.empty() && !msg->m_headers.empty()) { + // Standard path: headers parsed as 80-byte BlockType (LTC, BTC) + for (auto block : msg->m_headers) + { + auto header = (BlockHeaderType)block; + auto packed_header = pack(header); + auto blockhash = Hash(packed_header.get_span()); + try { + m_peer->get_header(blockhash, header); + } catch (const std::invalid_argument&) {} + vheaders.push_back(header); + } + } + + if (!vheaders.empty()) { + m_coin->new_headers.happened(vheaders); + + // BIP 130: when receiving a small headers batch (new block + // announcement), request the full block via getdata. + // BTC: MSG_WITNESS_BLOCK (0x40000002) — BIP 144 witness-bearing. + // LTC's MSG_MWEB_BLOCK (0x60000002) would be rejected by bitcoind. + if (vheaders.size() <= 3 && m_peer) { + for (auto& hdr : vheaders) { + auto packed = pack(hdr); + auto bhash = Hash(packed.get_span()); + auto getdata_msg = message_getdata::make_raw( + {inventory_type(inventory_type::witness_block, bhash)}); + m_peer->write(getdata_msg); + LOG_INFO << "[" << m_chain_label << "] Requesting full block " + << bhash.GetHex().substr(0, 16) << "..."; + } + } + } + } + + ADD_P2P_HANDLER(getaddr) + { + // We don't serve addresses — ignore + } + + ADD_P2P_HANDLER(addr) + { + if (m_addr_callback && !msg->m_addrs.empty()) { + std::vector addrs; + addrs.reserve(msg->m_addrs.size()); + for (auto& rec : msg->m_addrs) { + addrs.push_back(rec.m_endpoint); + } + m_addr_callback(addrs); + } + } + + ADD_P2P_HANDLER(reject) + { + LOG_WARNING << "Peer rejected " << msg->m_message + << " (code=" << static_cast(msg->m_ccode) + << "): " << msg->m_reason + << " hash=" << msg->m_data.GetHex(); + } + + ADD_P2P_HANDLER(sendheaders) + { + // Peer prefers header announcements — acknowledged + LOG_DEBUG_COIND << "Peer supports sendheaders (BIP 130)"; + } + + ADD_P2P_HANDLER(notfound) + { + for (auto& inv : msg->m_invs) + { + switch (inv.base_type()) + { + case inventory_type::block: + // Complete the ReplyMatcher with a default (empty) response + // so we don't wait for the 15s timeout. + try { + m_peer->get_block(inv.m_hash, BlockType{}); + } catch (...) {} + try { + m_peer->get_header(inv.m_hash, BlockHeaderType{}); + } catch (...) {} + break; + default: + break; + } + LOG_DEBUG_COIND << "Peer does not have inv 0x" << std::hex + << static_cast(inv.m_type) << std::dec + << " " << inv.m_hash.GetHex(); + } + } + + ADD_P2P_HANDLER(feefilter) + { + LOG_DEBUG_COIND << "Peer feefilter: " << msg->m_feerate << " sat/kB"; + } + + ADD_P2P_HANDLER(mempool) + { + // We don't serve mempool — ignore incoming request + } + + ADD_P2P_HANDLER(sendcmpct) + { + // BIP 152: Compact block negotiation — record peer capability + m_peer_supports_cmpct = true; + m_peer_cmpct_version = msg->m_version; + m_peer_wants_cmpct_announce = msg->m_announce; + LOG_INFO << "[" << m_chain_label << "] Peer supports compact blocks v" + << msg->m_version << " (announce=" << msg->m_announce << ")"; + } + + ADD_P2P_HANDLER(cmpctblock) + { + auto& cb = msg->m_compact_block; + auto packed_hdr = pack(cb.header); + auto blockhash = Hash(packed_hdr.get_span()); + + LOG_INFO << "[" << m_chain_label << "] Received compact block " + << blockhash.GetHex() + << " (" << cb.short_ids.size() << " short IDs, " + << cb.prefilled_txns.size() << " prefilled)"; + + // Always announce the new block to the node (header-based) + m_coin->new_block.happened(blockhash); + + // Attempt reconstruction from mempool + known_txs + // BIP 152 v2: short IDs are keyed by wtxid (witness txid). + std::map known; + + // Gather from node's known_txs (re-key by wtxid) + for (const auto& [txid, tx] : m_coin->known_txs) { + MutableTransaction mtx(tx); + auto packed = pack(TX_WITH_WITNESS(mtx)); + uint256 wtxid = Hash(packed.get_span()); + known[wtxid] = std::move(mtx); + } + + // Gather from mempool (wtxid-keyed) + if (m_mempool) { + auto mp_txs = m_mempool->all_txs_map_wtxid(); + known.merge(mp_txs); + } + + auto result = ReconstructBlock(cb, known); + + if (result.complete) { + LOG_INFO << "[" << m_chain_label << "] Compact block reconstructed: " + << blockhash.GetHex() + << " txs=" << result.block.m_txs.size(); + // Deliver as a full block + m_peer->get_block(blockhash, result.block); + auto header = static_cast(result.block); + m_peer->get_header(blockhash, header); + m_coin->full_block.happened(result.block); + } else { + LOG_INFO << "[" << m_chain_label << "] Compact block incomplete, " + << result.missing_indexes.size() << " txs missing — requesting via getblocktxn"; + // Save pending state and request missing transactions + m_pending_cmpct = std::make_unique(cb); + m_pending_missing_indexes = result.missing_indexes; + + BlockTransactionsRequest req; + req.blockhash = blockhash; + req.indexes = result.missing_indexes; + auto req_msg = message_getblocktxn::make_raw(req); + m_peer->write(req_msg); + } + } + + ADD_P2P_HANDLER(getblocktxn) + { + auto& req = msg->m_request; + + // Only serve our most recently sent compact block + if (req.blockhash != m_sent_cmpct_hash || m_sent_cmpct_block.m_txs.empty()) { + LOG_DEBUG_COIND << "[" << m_chain_label << "] getblocktxn for unknown block " + << req.blockhash.GetHex() << " — ignoring"; + return; + } + + BlockTransactionsResponse resp; + resp.blockhash = req.blockhash; + resp.txs.reserve(req.indexes.size()); + + for (uint32_t idx : req.indexes) { + if (idx >= m_sent_cmpct_block.m_txs.size()) { + LOG_WARNING << "[" << m_chain_label << "] getblocktxn: index " << idx + << " out of range (block has " << m_sent_cmpct_block.m_txs.size() << " txs)"; + return; // malformed request — drop + } + resp.txs.push_back(m_sent_cmpct_block.m_txs[idx]); + } + + auto rmsg = message_blocktxn::make_raw(resp); + m_peer->write(rmsg); + LOG_INFO << "[" << m_chain_label << "] Served " << resp.txs.size() + << " txs via blocktxn for " << req.blockhash.GetHex(); + } + + ADD_P2P_HANDLER(blocktxn) + { + auto& resp = msg->m_response; + + if (!m_pending_cmpct || m_pending_missing_indexes.empty()) { + LOG_WARNING << "[" << m_chain_label << "] Received blocktxn without pending compact block"; + return; + } + + if (resp.txs.size() != m_pending_missing_indexes.size()) { + LOG_WARNING << "[" << m_chain_label << "] blocktxn size mismatch: got " + << resp.txs.size() << ", expected " << m_pending_missing_indexes.size(); + m_pending_cmpct.reset(); + m_pending_missing_indexes.clear(); + return; + } + + // Reconstruct the full block with the missing transactions + auto& cb = *m_pending_cmpct; + size_t total_txs = cb.short_ids.size() + cb.prefilled_txns.size(); + std::vector txs(total_txs); + std::vector filled(total_txs, false); + + // Place prefilled transactions + for (const auto& pt : cb.prefilled_txns) { + if (pt.index < total_txs) { + txs[pt.index] = pt.tx; + filled[pt.index] = true; + } + } + + // Re-match from mempool (same as cmpctblock handler) + std::map known; + for (const auto& [txid, tx] : m_coin->known_txs) + known[txid] = MutableTransaction(tx); + if (m_mempool) { + auto mp_txs = m_mempool->all_txs_map(); + known.merge(mp_txs); + } + + uint64_t k0, k1; + cb.GetSipHashKeys(k0, k1); + std::map sid_map; + for (const auto& [txid, tx] : known) { + ShortTxID sid = CompactBlock::GetShortID(k0, k1, txid); + sid_map[sid.to_uint64()] = &tx; + } + + size_t sid_idx = 0; + for (size_t i = 0; i < total_txs; ++i) { + if (filled[i]) continue; + if (sid_idx < cb.short_ids.size()) { + auto it = sid_map.find(cb.short_ids[sid_idx].to_uint64()); + if (it != sid_map.end() && it->second) + txs[i] = *(it->second); + // else: will be filled from blocktxn response below + } + ++sid_idx; + } + + // Fill in the missing transactions from blocktxn response + for (size_t i = 0; i < m_pending_missing_indexes.size(); ++i) { + uint32_t idx = m_pending_missing_indexes[i]; + if (idx < total_txs) + txs[idx] = resp.txs[i]; + } + + // Build and deliver the full block + auto packed_hdr = pack(cb.header); + auto blockhash = Hash(packed_hdr.get_span()); + + BlockType block; + static_cast(block) = cb.header; + block.m_txs = std::move(txs); + + m_peer->get_block(blockhash, block); + auto header = static_cast(block); + m_peer->get_header(blockhash, header); + + LOG_INFO << "[" << m_chain_label << "] Compact block completed via blocktxn: " + << blockhash.GetHex(); + + m_pending_cmpct.reset(); + m_pending_missing_indexes.clear(); + } + + ADD_P2P_HANDLER(wtxidrelay) + { + // BIP 339: Peer wants wtxid-based tx relay + m_peer_wtxidrelay = true; + LOG_DEBUG_COIND << "[" << m_chain_label << "] Peer supports wtxidrelay (BIP 339)"; + } + + ADD_P2P_HANDLER(sendaddrv2) + { + // BIP 155: Peer wants addrv2 messages — acknowledged + LOG_DEBUG_COIND << "Peer supports sendaddrv2 (BIP 155)"; + } + + ADD_P2P_HANDLER(getdata) + { + // Peer requesting data from us — we don't serve blocks/txs + LOG_DEBUG_COIND << "Peer getdata with " << msg->m_requests.size() << " items (ignored)"; + } + + ADD_P2P_HANDLER(getblocks) + { + // Peer requesting block locator — we don't serve blocks + } + + ADD_P2P_HANDLER(getheaders) + { + // Peer requesting headers — we don't serve headers + LOG_DEBUG_COIND << "Peer getheaders (ignored, we don't serve headers)"; + } + + #undef ADD_P2P_HANDLER +}; + +} // namespace p2p + +} // namespace node + +} // namespace btc diff --git a/src/impl/btc/coin/rpc.cpp b/src/impl/btc/coin/rpc.cpp new file mode 100644 index 000000000..d945e41e3 --- /dev/null +++ b/src/impl/btc/coin/rpc.cpp @@ -0,0 +1,442 @@ +#include "rpc.hpp" + +#include +#include + +#include +#include +namespace btc +{ + +namespace coin +{ + +NodeRPC::NodeRPC(io::io_context* context, btc::interfaces::Node* coin, bool testnet) + : m_context(context), IS_TESTNET(testnet), m_resolver(*context), m_stream(*context), + m_client(*this, RPC_VER), m_coin(coin) +{ +} + +void NodeRPC::connect(NetService address, std::string userpass) +{ + m_address = address; + m_userpass = userpass; + + m_auth = std::make_unique(); + m_http_request = {http::verb::post, "/", 11}; + + m_auth->host = address.to_string(); + m_http_request.set(http::field::host, m_auth->host); + + m_http_request.set(http::field::user_agent, BOOST_BEAST_VERSION_STRING); + m_http_request.set(http::field::content_type, "application/json"); + m_http_request.set(http::field::connection, "keep-alive"); + + std::string encoded_login2; + encoded_login2.resize(boost::beast::detail::base64::encoded_size(userpass.size())); + const auto result = boost::beast::detail::base64::encode(&encoded_login2[0], userpass.data(), userpass.size()); + encoded_login2.resize(result); + m_auth->authorization = "Basic " + encoded_login2; + + m_http_request.set(http::field::authorization, m_auth->authorization); + + // Async DNS resolve — must NOT use the blocking m_resolver.resolve() overload + // as that stalls the entire io_context thread for the DNS round-trip. + m_resolver.async_resolve(address.address(), address.port_str(), + [this](boost::system::error_code ec, boost::asio::ip::tcp::resolver::results_type results) + { + if (ec) + { + LOG_ERROR << "CoindRPC DNS resolve failed: " << ec.message() << ", retrying in 15s"; + m_reconnect_timer = std::make_unique(m_context, false); + m_reconnect_timer->start(15, [this]() { connect(m_address, m_userpass); }); + return; + } + boost::asio::ip::tcp::endpoint endpoint = *results.begin(); + m_stream.async_connect(endpoint, + [this](boost::system::error_code ec) + { + if (ec) + { + if (ec == boost::system::errc::operation_canceled) + return; + + LOG_ERROR << "CoindRPC error when try connect: [" << ec.message() << "]."; + } else + { + try + { + if (check()) + { + m_connected = true; + LOG_INFO << "...CoindRPC connected!"; + return; + } + } + catch(const std::runtime_error& ec) + { + LOG_ERROR << "Error when try check CoindRPC: " << ec.what(); + } + } + + LOG_INFO << "Retry after 15 seconds..."; + m_connected = false; + m_stream.close(); + m_reconnect_timer = std::make_unique(m_context, false); + m_reconnect_timer->start(15, [this]() { connect(m_address, m_userpass); }); + } + ); + }); +} + +NodeRPC::~NodeRPC() +{ + beast::error_code ec; + m_stream.socket().shutdown(io::ip::tcp::socket::shutdown_both, ec); + if (ec) + { + // shutdown errors on close are typically benign; ignore + } +} + +void NodeRPC::reconnect() +{ + if (!m_connected) + return; // already reconnecting or never connected + m_connected = false; + LOG_WARNING << "RPC connection lost — reconnecting in 15 seconds..."; + m_stream.close(); + m_reconnect_timer = std::make_unique(m_context, false); + m_reconnect_timer->start(15, [this]() { connect(m_address, m_userpass); }); +} + +void NodeRPC::sync_reconnect() +{ + beast::error_code ec; + m_stream.socket().shutdown(io::ip::tcp::socket::shutdown_both, ec); + m_stream.close(); + + // Blocking resolve + connect for immediate retry + auto results = m_resolver.resolve(m_address.address(), m_address.port_str(), ec); + if (ec) { + LOG_WARNING << "CoindRPC sync_reconnect resolve failed: " << ec.message(); + return; + } + m_stream.connect(*results.begin(), ec); + if (ec) { + LOG_WARNING << "CoindRPC sync_reconnect connect failed: " << ec.message(); + return; + } + LOG_INFO << "CoindRPC reconnected (sync)"; +} + +std::string NodeRPC::Send(const std::string &request) +{ + // Retry once after synchronous reconnect on write/read failure + for (int attempt = 0; attempt < 2; ++attempt) + { + m_http_request.body() = request; + m_http_request.prepare_payload(); + try + { + http::write(m_stream, m_http_request); + } + catch(const std::exception& e) + { + LOG_WARNING << "CoindRPC write failed: " << e.what() + << (attempt == 0 ? " — reconnecting..." : ""); + if (attempt == 0) { + sync_reconnect(); + continue; + } + return {}; + } + + beast::flat_buffer buffer; + boost::beast::http::response response; + + try + { + boost::beast::http::read(m_stream, buffer, response); + } + catch (const std::exception& ex) + { + LOG_WARNING << "CoindRPC read failed: " << ex.what() + << (attempt == 0 ? " — reconnecting..." : ""); + if (attempt == 0) { + sync_reconnect(); + continue; + } + return {}; + } + + auto body = boost::beast::buffers_to_string(response.body().data()); + if (body.empty()) { + static int _empty_count = 0; + if (_empty_count++ < 5) + LOG_WARNING << "CoindRPC empty response: HTTP " << response.result_int() + << " content-length=" << response[http::field::content_length] + << " connection=" << response[http::field::connection]; + if (attempt == 0 && response.result_int() != 200) { + sync_reconnect(); + continue; + } + } + return body; + } + return {}; +} + +nlohmann::json NodeRPC::CallAPIMethod(const std::string& method, const jsonrpccxx::positional_parameter& params) +{ + return m_client.CallMethod(ID, method, params); +} + +bool NodeRPC::check() +{ + bool has_block = check_blockheader(uint256S("12a765e31ffd4059bada1e25190f6e98c99d9714d334efa41a195a7e7e04bfe2")); + bool is_main_chain = getblockchaininfo()["chain"].get() == "main"; + nlohmann::json blockchaininfo; + + if (is_main_chain && !has_block) + { + LOG_ERROR << "Check failed! Make sure that you're connected to the right bitcoind with --bitcoind-rpc-port, and that it has finished syncing!" << std::endl; + return false; + } + + try + { + auto networkinfo = getnetworkinfo(); + bool version_check_result = (100400 <= networkinfo["version"].get()); + if (!version_check_result) + { + LOG_ERROR << "Coin daemon too old! Upgrade!"; + return false; + } + } catch (const jsonrpccxx::JsonRpcException& ex) + { + LOG_WARNING << "NodeRPC::check() exception: " << ex.what(); + return false; + } + + try + { + blockchaininfo = getblockchaininfo(); + } catch (const jsonrpccxx::JsonRpcException& ex) + { + return false; + } + + std::set softforks_supported; + + if (blockchaininfo.contains("softforks")) + btc::coin::collect_softfork_names(blockchaininfo["softforks"], softforks_supported); + if (blockchaininfo.contains("bip9_softforks")) + btc::coin::collect_softfork_names(blockchaininfo["bip9_softforks"], softforks_supported); + + // Fallback for daemons that don't populate getblockchaininfo softfork fields. + if (softforks_supported.empty()) + { + try + { + auto gbt = getblocktemplate({"segwit"}); + if (gbt.contains("rules") && gbt["rules"].is_array()) + { + for (const auto& rule : gbt["rules"]) + { + if (!rule.is_string()) continue; + auto r = rule.get(); + if (!r.empty() && r[0] == '!') r.erase(r.begin()); + softforks_supported.insert(r); + } + } + } + catch (const std::exception&) + { + // Keep empty set; missing forks check below will fail safely. + } + } + + std::vector missing; + for (const auto& req : btc::PoolConfig::SOFTFORKS_REQUIRED) + { + if (!softforks_supported.contains(req)) + missing.push_back(req); + } + + if (!missing.empty()) + { + std::string joined; + for (size_t i = 0; i < missing.size(); ++i) + { + if (i) joined += ", "; + joined += missing[i]; + } + LOG_ERROR << "Coin daemon missing required softfork features: " << joined; + LOG_ERROR << "Refusing to start to avoid mining invalid/non-consensus blocks."; + return false; + } + + return true; +} + +bool NodeRPC::check_blockheader(uint256 header) +{ + try + { + getblockheader(header); + return true; + } catch (const jsonrpccxx::JsonRpcException& ex) + { + return false; + } +} + +rpc::WorkData NodeRPC::getwork() +{ + auto start = core::timestamp(); + auto work = getblocktemplate({"segwit"}); + auto end = core::timestamp(); + + if (!m_coin->txidcache.is_started()) + m_coin->txidcache.start(); + + std::vector txhashes; + std::vector unpacked_transactions; + for (auto& packed_tx : work["transactions"]) + { + PackStream ps_tx; + + uint256 txid; + std::string x; + + if (packed_tx.contains("data")) + x = packed_tx["data"].get(); + else + x = packed_tx.get(); + + // Use the "txid" field from GBT when available — it is + // the non-witness (stripped) hash, which is what the block + // header merkle tree must use. Hashing "data" directly + // gives the wtxid when segwit witness data is present. + if (packed_tx.is_object() && packed_tx.contains("txid")) { + txid.SetHex(packed_tx["txid"].get()); + txhashes.push_back(txid); + } else if (m_coin->txidcache.exist(x)) + { + txid = m_coin->txidcache.get(x); + txhashes.push_back(txid); + } else + { + ps_tx = PackStream(ParseHex(x)); + txid = Hash(ps_tx.get_span()); + m_coin->txidcache.add(x, txid); + txhashes.push_back(txid); + } + + btc::coin::MutableTransaction unpacked_tx; + if (m_coin->known_txs.contains(txid)) + { + unpacked_tx = btc::coin::MutableTransaction(m_coin->known_txs.at(txid)); + } else + { + if (ps_tx.empty()) + ps_tx = PackStream(ParseHex(x)); + UnserializeTransaction(unpacked_tx, ps_tx, TX_WITH_WITNESS); + } + unpacked_transactions.push_back(btc::coin::Transaction(unpacked_tx)); + } + + if ((core::timestamp() - m_coin->txidcache.time()) > 1800) + { + std::map keepers; + for (int i = 0; i < txhashes.size(); i++) + { + auto x = work["transactions"].at(i); + std::string keep; + if (x.contains("data")) + keep = x["data"].get(); + else + keep = x.get(); + uint256 txid = txhashes[i]; + keepers[keep] = txid; + } + m_coin->txidcache.clear(); + m_coin->txidcache.add(keepers); + } + + if (!work.contains("height")) + { + uint256 previous_block_hash = work["previousblockhash"].get(); + work["height"] = getblock(previous_block_hash)["height"].get() + 1; + } + + return rpc::WorkData{work, unpacked_transactions, txhashes, end - start}; +} + +void NodeRPC::submit_block(BlockType& block, bool ignore_failure) +{ + // BTC: segwit + taproot are required forks; full-block packing always + // includes witness data. No MWEB tail to append. + PackStream packed_block = pack(block); + auto result = m_client.CallMethod(ID, "submitblock", {HexStr(packed_block.get_span())}); + bool success = result.is_null(); + + auto block_header = pack(block); // cast to header? + // We always expect the submit to succeed (non-null result means rejection). + auto success_expected = true; + + if ((!success && success_expected && !ignore_failure) || (success && !success_expected)) + LOG_ERROR << "Block submittal result: " << success << "(" << result.dump() << ") Expected: " << success_expected; +} + +bool NodeRPC::submit_block_hex(const std::string& block_hex, bool ignore_failure) +{ + auto result = m_client.CallMethod(ID, "submitblock", {block_hex}); + bool success = result.is_null(); + if (!success && !ignore_failure) + LOG_ERROR << "submit_block_hex result: " << result.dump(); + else if (success) + LOG_INFO << "submit_block_hex accepted"; + return success; +} + +// RPC Methods + +nlohmann::json NodeRPC::getblocktemplate(std::vector rules) +{ + nlohmann::json j = nlohmann::json::object({{"rules", rules}}); + return CallAPIMethod("getblocktemplate", {j}); +} + +nlohmann::json NodeRPC::getnetworkinfo() +{ + return CallAPIMethod("getnetworkinfo"); +} + +nlohmann::json NodeRPC::getblockchaininfo() +{ + return CallAPIMethod("getblockchaininfo"); +} + +nlohmann::json NodeRPC::getmininginfo() +{ + return CallAPIMethod("getmininginfo"); +} + +// verbose: true -- json result, false -- hex-encode result; +nlohmann::json NodeRPC::getblockheader(uint256 header, bool verbose) +{ + return CallAPIMethod("getblockheader", {header, verbose}); +} + +// verbosity: 0 for hex-encoded data, 1 for a json object, and 2 for json object with transaction data +nlohmann::json NodeRPC::getblock(uint256 blockhash, int verbosity) +{ + return CallAPIMethod("getblock", {blockhash, verbosity}); +} + +} // namespace coin + + +} // namespace btc \ No newline at end of file diff --git a/src/impl/btc/coin/rpc.hpp b/src/impl/btc/coin/rpc.hpp new file mode 100644 index 000000000..1b7fcf408 --- /dev/null +++ b/src/impl/btc/coin/rpc.hpp @@ -0,0 +1,91 @@ +#pragma once + +#include "block.hpp" +#include "rpc_data.hpp" +#include "node_interface.hpp" + +#include + +#include +#include + +#include +#include +#include +#include + +namespace io = boost::asio; +namespace beast = boost::beast; +namespace http = beast::http; + +namespace btc +{ + +namespace coin +{ + +struct RPCAuthData; +class NodeRPC : public jsonrpccxx::IClientConnector +{ + const std::string ID = "curltest"; + const jsonrpccxx::version RPC_VER = jsonrpccxx::version::v2; + + const bool IS_TESTNET; +private: + btc::interfaces::Node* m_coin; + + io::io_context* m_context; + beast::tcp_stream m_stream; + boost::asio::ip::tcp::resolver m_resolver; + http::request m_http_request; + + std::unique_ptr m_auth; + jsonrpccxx::JsonRpcClient m_client; + + // Reconnection state + NetService m_address; + std::string m_userpass; + bool m_connected = false; + std::unique_ptr m_reconnect_timer; + + std::string Send(const std::string &request) override; + nlohmann::json CallAPIMethod(const std::string& method, const jsonrpccxx::positional_parameter& params = {}); + +public: + NodeRPC(io::io_context* context, btc::interfaces::Node* coin, bool testnet); + ~NodeRPC(); + + void connect(NetService address, std::string userpass); + void reconnect(); + void sync_reconnect(); + bool check(); + bool check_blockheader(uint256 header); + rpc::WorkData getwork(); //coind::getwork_result getwork(coind::TXIDCache &txidcache, const map &known_txs = map()); + void submit_block(BlockType& block, bool ignore_failure); + // Submit a pre-serialised block passed in as a hex string (avoids re-packing) + // Returns true if the daemon accepted the block (result is null), false otherwise. + bool submit_block_hex(const std::string& block_hex, bool ignore_failure); + + // RPC Methods + nlohmann::json getblocktemplate(std::vector rules); + nlohmann::json getnetworkinfo(); + nlohmann::json getblockchaininfo(); + nlohmann::json getmininginfo(); + // verbose: true -- json result, false -- hex-encode result; + nlohmann::json getblockheader(uint256 header, bool verbose = true); + // verbosity: 0 for hex-encoded data, 1 for a json object, and 2 for json object with transaction data + nlohmann::json getblock(uint256 blockhash, int verbosity = 1); + +}; + +struct RPCAuthData +{ + std::string authorization; + std::string host; +}; + +} // namespace coin + +} // namespace btc + + diff --git a/src/impl/btc/coin/rpc_data.hpp b/src/impl/btc/coin/rpc_data.hpp new file mode 100644 index 000000000..8a8f6aa94 --- /dev/null +++ b/src/impl/btc/coin/rpc_data.hpp @@ -0,0 +1,113 @@ +#pragma once + +#include + +#include "transaction.hpp" + +#include + +namespace btc +{ + +namespace coin +{ + +namespace rpc +{ + +struct WorkData +{ + nlohmann::json m_data; + std::vector m_txs; + std::vector m_hashes; // transaction hashes + time_t m_latency; + + WorkData() {} + WorkData(nlohmann::json data, std::vector txs, std::vector txhashes, time_t latency) + : m_data(data), m_txs(txs), m_hashes(txhashes), m_latency(latency) + { + + } + + bool operator==(const WorkData& rhs) const { return m_data == rhs.m_data; } + bool operator!=(const WorkData& rhs) const { return !(*this == rhs); } + +/* + + +// version=work['version'], +// previous_block=int(work['previousblockhash'], 16), +// transactions=unpacked_transactions, +// transaction_hashes=txhashes, +// transaction_fees=[x.get('fee', None) if isinstance(x, dict) else None for x in work['transactions']], +// subsidy=work['coinbasevalue'], +// time=work['time'] if 'time' in work else work['curtime'], +// height=work['height'], +// rules=work.get('rules', []), +// last_update=time.time(), +// use_getblocktemplate=use_getblocktemplate, +// latency=end - start, + +// bits=bitcoin_data.FloatingIntegerType().unpack(work['bits'].decode('hex')[::-1]) if isinstance(work['bits'], (str, unicode)) else bitcoin_data.FloatingInteger(work['bits']), +// coinbaseflags=work['coinbaseflags'].decode('hex') if 'coinbaseflags' in work else ''.join(x.decode('hex') for x in work['coinbaseaux'].itervalues()) if 'coinbaseaux' in work else '', + + version = work["version"].get(); + previous_block = work["previousblockhash"].get(); + transactions = unpacked_txs; + transaction_hashes = txhashes; + + for (auto x : work["transactions"]) + { + optional fee; + if (x.contains("fee")) + fee = x["fee"].get(); + + transaction_fees.push_back(fee); + } + + subsidy = work["coinbasevalue"].get(); + if (work.contains("time")) + time = work["time"].get(); + else + time = work["curtime"].get(); + + if (work.contains("coinbaseflags")) + { + coinbaseflags = PackStream(ParseHex(work["coinbaseflags"].get())); + } + else if (work.contains("coinbaseaux")) + { + for (auto x : work["coinbaseaux"]) + { + PackStream _x(ParseHex(x.get())); + coinbaseflags << _x; + } + } + + if (work["bits"].is_string()) + { + auto _bits_v = ParseHex(work["bits"].get()); + std::reverse(_bits_v.begin(), _bits_v.end()); + PackStream _bits_stream(_bits_v); + FloatingIntegerType _bits; + _bits_stream >> _bits; + bits = _bits.bits; + } + // bits is parsed from GBT hex — FloatingInteger fallback not needed + + height = work["height"].get(); + rules = work["rules"].get>(); + + last_update = c2pool::dev::timestamp(); + latency = _latency; + + mweb = "01" + (work.contains("mweb") ? work["mweb"].get() : ""); +*/ + +}; + +} // namespace rpc + +} // namespace coin + +} // namespace btc \ No newline at end of file diff --git a/src/impl/btc/coin/softfork_check.hpp b/src/impl/btc/coin/softfork_check.hpp new file mode 100644 index 000000000..cecc0451b --- /dev/null +++ b/src/impl/btc/coin/softfork_check.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include + +#include +#include + +namespace btc::coin { + +/** + * Populate `out` with all softfork names found in a single getblockchaininfo + * field value (either the "softforks" or "bip9_softforks" entry). + * + * Handles three formats produced by different bitcoind versions: + * - Array of objects: [{"id":"segwit",...}, ...] (modern bitcoind) + * - Array of strings: ["segwit", "taproot", ...] (compact form) + * - Object with keys: {"segwit":{...}, "taproot":{...}} (BIP9 style) + */ +inline void collect_softfork_names(const nlohmann::json& value, + std::set& out) +{ + if (value.is_array()) + { + for (const auto& item : value) + { + if (item.is_object() && item.contains("id") && item["id"].is_string()) + out.insert(item["id"].get()); + else if (item.is_string()) + out.insert(item.get()); + } + } + else if (value.is_object()) + { + for (auto it = value.begin(); it != value.end(); ++it) + out.insert(it.key()); + } +} + +} // namespace btc::coin diff --git a/src/impl/btc/coin/template_builder.hpp b/src/impl/btc/coin/template_builder.hpp new file mode 100644 index 000000000..f6fd3bd15 --- /dev/null +++ b/src/impl/btc/coin/template_builder.hpp @@ -0,0 +1,352 @@ +#pragma once + +/// Phase 3: LTC Template Builder +/// +/// Builds block templates (WorkData) natively from HeaderChain + Mempool, +/// removing the getblocktemplate RPC dependency for LTC. +/// +/// Provides: +/// get_block_subsidy() — BTC halving schedule (50 BTC, halving every 210,000 blocks) +/// compute_merkle_root() — SHA256d-based Merkle tree (Bitcoin/Litecoin compatible) +/// TemplateBuilder — static build_template() → WorkData +/// CoinNodeInterface — abstract interface (getwork / submit_block / getblockchaininfo) +/// EmbeddedCoinNode — concrete implementation backed by HeaderChain + Mempool + +#include +#include "header_chain.hpp" +#include "mempool.hpp" +#include "rpc_data.hpp" +#include "transaction.hpp" +#include "block.hpp" + +#include +#include +#include + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace btc { +namespace coin { + +// ─── BTC Subsidy ───────────────────────────────────────────────────────────── + +/// BTC block subsidy (miner reward) in satoshis at a given block height. +/// Initial subsidy: 50 BTC = 5,000,000,000 satoshis. +/// Halving: every 210,000 blocks (BTC original halving schedule). +/// Subsidy drops to 0 after 64 halvings (never in practice — block ~13.4 M). +/// Reference: ref/bitcoin/src/validation.cpp GetBlockSubsidy(). +inline uint64_t get_block_subsidy(uint32_t height) { + static constexpr uint64_t COIN = 100'000'000ULL; // satoshis per BTC + static constexpr uint64_t INITIAL_SUBSIDY = 50ULL * COIN; // 50 BTC + static constexpr uint32_t HALVING_INTERVAL = 210'000u; + + int halvings = static_cast(height / HALVING_INTERVAL); + if (halvings >= 64) return 0; + return INITIAL_SUBSIDY >> halvings; +} + +// ─── Merkle Tree ───────────────────────────────────────────────────────────── + +/// Hash two adjacent Merkle nodes together (SHA256d of concatenation). +inline uint256 merkle_hash_pair(const uint256& left, const uint256& right) { + auto sl = std::span(left.data(), 32); + auto sr = std::span(right.data(), 32); + return Hash(sl, sr); +} + +/// Compute the Merkle root of a list of txids. +/// Implements Bitcoin/Litecoin Core's ComputeMerkleRoot() algorithm +/// (SHA256d pairwise, duplicating the last element if count is odd). +/// Returns uint256::ZERO for an empty list. +inline uint256 compute_merkle_root(std::vector hashes) { + if (hashes.empty()) return uint256::ZERO; + + while (hashes.size() > 1) { + if (hashes.size() & 1u) + hashes.push_back(hashes.back()); // duplicate last for odd count + + std::vector next; + next.reserve(hashes.size() / 2); + for (size_t i = 0; i < hashes.size(); i += 2) + next.push_back(merkle_hash_pair(hashes[i], hashes[i + 1])); + hashes = std::move(next); + } + return hashes[0]; +} + +// ─── CoinNodeInterface ──────────────────────────────────────────────────────── + +/// Abstract interface for obtaining work and submitting blocks. +/// Allows swapping between RPC (legacy), embedded, or hybrid implementations +/// without changing downstream code (share creation, Stratum, etc.). +class CoinNodeInterface { +public: + virtual ~CoinNodeInterface() = default; + + /// Return a block template as WorkData. + /// Throws std::runtime_error if no template can be produced. + virtual rpc::WorkData getwork() = 0; + + /// Submit a found block. + virtual void submit_block(BlockType& block) = 0; + + /// Return chain info (analogous to getblockchaininfo RPC). + virtual nlohmann::json getblockchaininfo() = 0; + + /// True when the embedded chain is up to date with the network + /// AND has enough UTXO depth for coinbase maturity validation. + virtual bool is_synced() const { return false; } +}; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/// Format a compact bits value as an 8-character lowercase hex string, +/// matching the format litecoind returns in getblocktemplate. +inline std::string bits_to_hex(uint32_t bits) { + char buf[9]; + std::snprintf(buf, sizeof(buf), "%08x", bits); + return std::string(buf); +} + +// ─── TemplateBuilder ───────────────────────────────────────────────────────── + +/// Builds an LTC block template from a validated HeaderChain and Mempool. +/// +/// The resulting WorkData is layout-compatible with the GBT JSON that +/// NodeRPC::getwork() returns, so all downstream code (web_server, share +/// creation, Stratum) works without modification. +/// +/// JSON fields produced: +/// version — derived from chain tip (BIP9 base 0x20000000 + signaling bits) +/// previousblockhash — SHA256d hex of tip header (big-endian display) +/// bits — compact target for next block as 8-char hex +/// height — next block height +/// curtime — current wall-clock timestamp +/// coinbasevalue — miner subsidy in satoshis (no fees — no UTXO set) +/// transactions — array with "data" (hex) and "txid" per tx +/// rules — ["segwit"] +/// coinbaseflags — "" (empty) +/// sigoplimit — 80000 +/// sizelimit — 4000000 +/// weightlimit — 4000000 +/// mintime — tip.timestamp + 1 +class TemplateBuilder { +public: + // BIP9 base version — all modern blocks use 0x20000000 as the base with + // optional version-bit signaling. We derive the actual version from the + // chain tip so we automatically mirror whatever signaling bits the network + // is using (e.g. Taproot, future soft-forks). + static constexpr uint32_t BIP9_BASE_VERSION = 0x20000000u; + static constexpr uint32_t MAX_BLOCK_WEIGHT = 4'000'000u; + static constexpr uint32_t COINBASE_RESERVE = 2'000u; // weight reserved for coinbase tx + + /// Build a WorkData template from the current chain tip + mempool. + /// Returns std::nullopt if the chain has no tip yet (not synced to genesis). + static std::optional build_template( + const HeaderChain& chain, + const Mempool& pool, + bool is_testnet = false) + { + (void)is_testnet; // reserved for future per-network rules + auto t0 = std::chrono::steady_clock::now(); + + auto tip_opt = chain.tip(); + if (!tip_opt) + return std::nullopt; // chain has no genesis yet + + const IndexEntry& tip = *tip_opt; + uint32_t next_h = tip.height + 1; + uint32_t now_ts = static_cast(std::time(nullptr)); + + // ── Next difficulty ──────────────────────────────────────────────── + auto get_ancestor = [&](uint32_t h) -> std::optional { + return chain.get_header_by_height(h); + }; + uint32_t next_bits = get_next_work_required( + get_ancestor, + tip.height, + tip.header.m_bits, + tip.header.m_timestamp, + now_ts, + chain.params()); + // After a checkpoint, the chain may lack enough headers for difficulty + // calculation (need 2016+ ancestors). Fall back to tip's bits or + // pow_limit if bits came back as 0. + if (next_bits == 0) { + if (tip.header.m_bits != 0) + next_bits = tip.header.m_bits; + else + next_bits = chain.params().pow_limit.GetCompact(); + LOG_INFO << "[EMB-BTC] TemplateBuilder: bits fallback to 0x" + << std::hex << next_bits << std::dec + << " (chain too short for retarget)"; + } + + // ── Block version ────────────────────────────────────────────────── + uint32_t block_version = static_cast(tip.header.m_version); + if (block_version < BIP9_BASE_VERSION) + block_version = BIP9_BASE_VERSION; + + // ── Subsidy ──────────────────────────────────────────────────────── + uint64_t subsidy = get_block_subsidy(next_h); + + // ── Transactions from mempool (fee-sorted when UTXO available) ──── + auto [selected_txs, total_fees] = + pool.get_sorted_txs_with_fees(MAX_BLOCK_WEIGHT - COINBASE_RESERVE); + + // coinbasevalue = block reward + included transaction fees + // Matches litecoind's getblocktemplate coinbasevalue field. + uint64_t coinbasevalue = subsidy + total_fees; + + nlohmann::json tx_array = nlohmann::json::array(); + std::vector tx_objects; + std::vector tx_hashes; + + for (const auto& stx : selected_txs) { + uint256 txid = compute_txid(stx.tx); + auto packed = pack(TX_WITH_WITNESS(stx.tx)); + std::string hex_data = HexStr(packed.get_span()); + // wtxid = SHA256d of witness serialization (for witness merkle tree) + uint256 wtxid = Hash(packed.get_span()); + + nlohmann::json entry; + entry["data"] = hex_data; + entry["txid"] = txid.GetHex(); + entry["hash"] = wtxid.GetHex(); // wtxid for witness commitment + // Per-tx fee field — p2pool reads this in helper.py:123 + // and uses it in data.py:876-884 to adjust subsidy when + // transactions are excluded from shares. + if (stx.fee_known) + entry["fee"] = static_cast(stx.fee); + else + entry["fee"] = nullptr; // JSON null → p2pool uses base_subsidy fallback + tx_array.push_back(std::move(entry)); + + tx_objects.push_back(Transaction(stx.tx)); + tx_hashes.push_back(txid); + } + + // ── Build GBT-compatible JSON ────────────────────────────────────── + nlohmann::json data; + data["version"] = static_cast(block_version); + data["previousblockhash"] = tip.block_hash.GetHex(); + data["bits"] = bits_to_hex(next_bits); + data["height"] = static_cast(next_h); + data["curtime"] = static_cast(now_ts); + data["coinbasevalue"] = static_cast(coinbasevalue); + data["transactions"] = std::move(tx_array); + // Bitcoin rules: BTC has only segwit (no MWEB). + data["rules"] = nlohmann::json::array({"segwit"}); + data["coinbaseflags"] = ""; + data["sigoplimit"] = 80000; + data["sizelimit"] = 4'000'000; + data["weightlimit"] = 4'000'000; + data["mintime"] = static_cast(tip.header.m_timestamp + 1); + + LOG_INFO << "[EMB-BTC] TemplateBuilder: height=" << next_h + << " version=0x" << std::hex << block_version << std::dec + << " prev=" << tip.block_hash.GetHex().substr(0, 16) << "..." + << " bits=" << bits_to_hex(next_bits) + << " subsidy=" << subsidy << " fees=" << total_fees + << " coinbasevalue=" << coinbasevalue << " sat" + << " txs=" << data["transactions"].size() + << " tip_ts=" << tip.header.m_timestamp + << " now=" << now_ts + << " synced=" << chain.is_synced(); + auto t1 = std::chrono::steady_clock::now(); + auto latency_ms = std::chrono::duration_cast(t1 - t0).count(); + return rpc::WorkData{std::move(data), std::move(tx_objects), std::move(tx_hashes), latency_ms}; + } + +}; + +// ─── EmbeddedCoinNode ───────────────────────────────────────────────────────── + +/// Concrete CoinNodeInterface backed by a HeaderChain and Mempool. +/// Calls TemplateBuilder::build_template() for getwork(). +class EmbeddedCoinNode : public CoinNodeInterface { +public: + EmbeddedCoinNode(HeaderChain& chain, Mempool& pool, bool testnet = false) + : m_chain(chain) + , m_pool(pool) + , m_testnet(testnet) + {} + + /// Build a template from the current chain tip + mempool. + /// Throws std::runtime_error if the chain has no genesis yet or not synced. + rpc::WorkData getwork() override { + LOG_DEBUG_COIND << "[EMB-BTC] EmbeddedCoinNode::getwork() called" + << " chain_height=" << m_chain.height() + << " mempool_size=" << m_pool.size() + << " synced=" << m_chain.is_synced(); + if (!m_chain.is_synced()) { + LOG_INFO << "[EMB-BTC] getwork() blocked: chain not synced (height=" + << m_chain.height() << ")"; + throw std::runtime_error("EmbeddedCoinNode::getwork: chain not synced — waiting for header sync"); + } + auto result = TemplateBuilder::build_template(m_chain, m_pool, m_testnet); + if (!result) { + LOG_WARNING << "[EMB-BTC] EmbeddedCoinNode::getwork() FAILED: no tip (chain empty)"; + throw std::runtime_error("EmbeddedCoinNode::getwork: chain has no tip (not yet synced to genesis)"); + } + return *result; + } + + /// Block relay in embedded mode is handled by CoinBroadcaster via + /// MiningInterface::on_block_relay, not through this interface. + /// This override is intentionally empty — the RPC-based NodeRPC path + /// uses its own submit_block_hex() directly. + void submit_block(BlockType& /*block*/) override { } + + /// Return basic chain state info (analogous to getblockchaininfo RPC). + nlohmann::json getblockchaininfo() override { + nlohmann::json info; + info["chain"] = m_testnet ? "test" : "main"; + info["blocks"] = static_cast(m_chain.height()); + info["headers"] = static_cast(m_chain.height()); + info["synced"] = m_chain.is_synced(); + + auto tip = m_chain.tip(); + if (tip) { + info["bestblockhash"] = tip->block_hash.GetHex(); + info["bits"] = bits_to_hex(tip->header.m_bits); + } else { + info["bestblockhash"] = std::string(64, '0'); + info["bits"] = "00000000"; + } + LOG_DEBUG_COIND << "[EMB-BTC] getblockchaininfo: height=" << info["blocks"].get() + << " synced=" << info["synced"].get() + << " best=" << info["bestblockhash"].get().substr(0, 16); + return info; + } + + /// Set UTXO readiness callback — blocks getwork() until UTXO has + /// enough depth for coinbase maturity validation (100 blocks for LTC). + void set_utxo_ready_fn(std::function fn) { m_utxo_ready = std::move(fn); } + + bool is_synced() const override { + if (!m_chain.is_synced()) return false; + if (m_utxo_ready && !m_utxo_ready()) return false; + return true; + } + +private: + HeaderChain& m_chain; + Mempool& m_pool; + std::function m_utxo_ready; // coinbase maturity gate + bool m_testnet; +}; + +} // namespace coin +} // namespace btc diff --git a/src/impl/btc/coin/transaction.cpp b/src/impl/btc/coin/transaction.cpp new file mode 100644 index 000000000..0cb0cdd76 --- /dev/null +++ b/src/impl/btc/coin/transaction.cpp @@ -0,0 +1,24 @@ +#include "transaction.hpp" + +namespace btc +{ + +namespace coin +{ + +Transaction::Transaction(const MutableTransaction& tx) : vin(tx.vin), vout(tx.vout), version(tx.version), locktime(tx.locktime), m_has_witness{ComputeHasWitness()} {} +Transaction::Transaction(MutableTransaction&& tx) : vin(std::move(tx.vin)), vout(std::move(tx.vout)), version(tx.version), locktime(tx.locktime), m_has_witness{ComputeHasWitness()} {} + +bool Transaction::ComputeHasWitness() const +{ + return std::any_of(vin.begin(), vin.end(), [](const auto& input) { + return !input.scriptWitness.IsNull(); + }); +} + +MutableTransaction::MutableTransaction() : version(Transaction::CURRENT_VERSION), locktime(0) {} +MutableTransaction::MutableTransaction(const Transaction& tx) : version(tx.version), vin(tx.vin), vout(tx.vout), locktime(tx.locktime) {} + +} // namespace coin + +} // namespace btc \ No newline at end of file diff --git a/src/impl/btc/coin/transaction.hpp b/src/impl/btc/coin/transaction.hpp new file mode 100644 index 000000000..f46f9b812 --- /dev/null +++ b/src/impl/btc/coin/transaction.hpp @@ -0,0 +1,220 @@ +#pragma once + +#include +#include +#include + +namespace btc +{ + +namespace coin +{ + +struct MutableTransaction; + +struct TxParams +{ + const bool allow_witness; + + SER_PARAMS_OPFUNC +}; + +constexpr static TxParams TX_WITH_WITNESS {.allow_witness = true}; +constexpr static TxParams TX_NO_WITNESS {.allow_witness = false}; + +class TxPrevOut +{ +public: + uint256 hash; + uint32_t index; + + SERIALIZE_METHODS(TxPrevOut) { READWRITE(obj.hash, obj.index); } +}; + +class TxIn +{ +public: + TxPrevOut prevout; + OPScript scriptSig; + uint32_t sequence; + OPScriptWitness scriptWitness; //!< Only serialized through Transaction + + SERIALIZE_METHODS(TxIn) { READWRITE(obj.prevout, obj.scriptSig, obj.sequence); } +}; + +class TxOut +{ +public: + int64_t value; + OPScript scriptPubKey; + + SERIALIZE_METHODS(TxOut) { READWRITE(obj.value, obj.scriptPubKey); } +}; + +class Transaction +{ +public: + // Default transaction version. + static const int32_t CURRENT_VERSION = 2; + + std::vector vin; + std::vector vout; + int32_t version; + uint32_t locktime; + +private: + bool m_has_witness; + + bool ComputeHasWitness() const; + +public: + /** Convert a MutableTransaction into a Transaction. */ + explicit Transaction(const MutableTransaction& tx) ; + explicit Transaction(MutableTransaction&& tx); + + template + inline void Serialize(StreamType& os) const + { + SerializeTransaction(*this, os, os.GetParams()); + } + + bool HasWitness() const { return m_has_witness; } +}; + +/** A mutable version of Transaction. */ +struct MutableTransaction +{ + std::vector vin; + std::vector vout; + int32_t version; + uint32_t locktime; + + explicit MutableTransaction(); + explicit MutableTransaction(const Transaction& tx); + + template + inline void Serialize(StreamType& os) const + { + SerializeTransaction(*this, os, os.GetParams()); + } + + template + inline void Unserialize(StreamType& os) + { + UnserializeTransaction(*this, os, os.GetParams()); + } + + bool HasWitness() const + { + for (size_t i = 0; i < vin.size(); i++) + { + if (!vin[i].scriptWitness.IsNull()) + return true; + } + return false; + } +}; + +/** + * Basic transaction serialization format: + * - int32_t version + * - std::vector vin + * - std::vector vout + * - uint32_t locktime + * + * Extended transaction serialization format: + * - int32_t version + * - unsigned char dummy = 0x00 + * - unsigned char flags (!= 0) + * - std::vector vin + * - std::vector vout + * - if (flags & 1): + * - OPScriptWitness scriptWitness; (deserialized into TxIn) + * - uint32_t locktime + */ +template +void UnserializeTransaction(TxType& tx, StreamType& s, const TxParams& params) +{ + const bool fAllowWitness = params.allow_witness; + + s >> tx.version; + unsigned char flags = 0; + tx.vin.clear(); + tx.vout.clear(); + /* Try to read the vin. In case the dummy is there, this will be read as an empty vector. */ + s >> tx.vin; + if (tx.vin.size() == 0 && fAllowWitness) + { + /* We read a dummy or an empty vin. */ + s >> flags; + if (flags != 0) + { + s >> tx.vin; + s >> tx.vout; + } + } else + { + /* We read a non-empty vin. Assume a normal vout follows. */ + s >> tx.vout; + } + if ((flags & 1) && fAllowWitness) + { + /* The witness flag is present, and we support witnesses. */ + flags ^= 1; + for (size_t i = 0; i < tx.vin.size(); i++) + { + s >> tx.vin[i].scriptWitness.stack; + } + if (!tx.HasWitness()) + { + /* It's illegal to encode witnesses when all witness stacks are empty. */ + throw std::ios_base::failure("Superfluous witness record"); + } + } + + if (flags) + { + /* Unknown flag in the serialization */ + throw std::ios_base::failure("Unknown transaction optional data"); + } + s >> tx.locktime; +} + +template +void SerializeTransaction(const TxType& tx, StreamType& s, const TxParams& params) +{ + const bool fAllowWitness = params.allow_witness; + + s << tx.version; + unsigned char flags = 0; + // Consistency check + if (fAllowWitness) + { + /* Check whether witnesses need to be serialized. */ + if (tx.HasWitness()) + { + flags |= 1; + } + } + if (flags) + { + /* Use extended format in case witnesses to be serialized. */ + std::vector vinDummy; + s << vinDummy; + s << flags; + } + s << tx.vin; + s << tx.vout; + if (flags & 1) + { + for (size_t i = 0; i < tx.vin.size(); i++) + { + s << tx.vin[i].scriptWitness.stack; + } + } + s << tx.locktime; +} + +} // namespace coin + +} // namespace btc diff --git a/src/impl/btc/coin/txidcache.hpp b/src/impl/btc/coin/txidcache.hpp new file mode 100644 index 000000000..58110032a --- /dev/null +++ b/src/impl/btc/coin/txidcache.hpp @@ -0,0 +1,86 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +namespace btc +{ + +namespace coin +{ + +class TXIDCache +{ + using key_t = std::string; + + mutable std::shared_mutex m_mutex; + bool m_started {false}; + time_t m_when_started{0}; + + std::map m_cache; //getblocktemplate.transacions[].data; hash256(packed data) + +public: + + void start() + { + std::shared_lock lock(m_mutex); + m_when_started = core::timestamp(); + m_started = true; + } + + bool exist(const key_t& key) const + { + std::shared_lock lock(m_mutex); + return m_cache.contains(key); + } + + bool is_started() const + { + std::shared_lock lock(m_mutex); + return m_started; + } + + bool time() const + { + std::shared_lock lock(m_mutex); + return m_when_started; + } + + void clear() + { + std::unique_lock lock(m_mutex); + m_cache.clear(); + } + + void add(const key_t& key, const uint256& value) + { + std::unique_lock lock(m_mutex); + m_cache[key] = value; + } + + void add(std::map values) + { + std::unique_lock lock(m_mutex); + m_cache.insert(values.begin(), values.end()); + } + + uint256 get(const key_t& key) + { + std::shared_lock lock(m_mutex); + + if (m_cache.contains(key)) + return m_cache[key]; + else + throw std::out_of_range("key not found in TXIDCache!"); + } + +}; + +} // namespace coin + +} // namespace btc diff --git a/src/impl/btc/config.hpp b/src/impl/btc/config.hpp new file mode 100644 index 000000000..2a53ec9b5 --- /dev/null +++ b/src/impl/btc/config.hpp @@ -0,0 +1,11 @@ +#pragma once + +#include "config_pool.hpp" +#include "config_coin.hpp" + +namespace btc +{ + +using Config = core::Config; + +} // namespace btc diff --git a/src/impl/btc/config_coin.cpp b/src/impl/btc/config_coin.cpp new file mode 100644 index 000000000..f506bb233 --- /dev/null +++ b/src/impl/btc/config_coin.cpp @@ -0,0 +1,35 @@ +#include "config_coin.hpp" + +#include + +namespace btc +{ + +std::ofstream& CoinConfig::get_default(std::ofstream& file) +{ + YAML::Node out; + + out["symbol"] = "defaultNet"; + out["p2p"] = config::P2PData(); + out["rpc"] = config::RPCData(); + out["share_period"] = 0; + out["testnet"] = false; + + file << out; + return file; +} + +void CoinConfig::load() +{ + YAML::Node node = YAML::LoadFile(m_filepath.string()); + + PARSE_CONFIG(node, symbol, std::string); + + m_p2p = node["p2p"].as(); + m_rpc = node["rpc"].as(); + + PARSE_CONFIG(node, share_period, int); + PARSE_CONFIG(node, testnet, bool); +} + +} // namespace btc diff --git a/src/impl/btc/config_coin.hpp b/src/impl/btc/config_coin.hpp new file mode 100644 index 000000000..7b1f080dd --- /dev/null +++ b/src/impl/btc/config_coin.hpp @@ -0,0 +1,104 @@ +#pragma once + +#include +#include +#include + +#include + +namespace btc +{ +namespace config +{ + struct P2PData + { + std::vector prefix; + NetService address; + }; + + struct RPCData + { + NetService address; + std::string userpass; + }; + +} // config +} // namespace btc + +namespace YAML +{ +template<> struct convert +{ + static Node encode(const btc::config::P2PData& rhs) + { + Node node; + node["prefix"] = HexStr(rhs.prefix); + node["address"] = rhs.address; + return node; + } + + static bool decode(const Node& node, btc::config::P2PData& rhs) + { + // prefix + rhs.prefix = ParseHexBytes(node["prefix"].as()); + // address + rhs.address = node["address"].as(); + return true; + } +}; + +template<> struct convert +{ + static Node encode(const btc::config::RPCData& rhs) + { + Node node; + node["address"] = rhs.address; + node["userpass"] = rhs.userpass; + return node; + } + + static bool decode(const Node& node, btc::config::RPCData& rhs) + { + rhs.address = node["address"].as(); + rhs.userpass = node["userpass"].as(); + return true; + } +}; +} + +namespace btc +{ + +class CoinConfig : protected core::Fileconfig +{ + +protected: + std::ofstream& get_default(std::ofstream& file) override; + void load() override; + +public: + CoinConfig(const std::filesystem::path& path) : core::Fileconfig(path) + { + + } + +public: + + config::P2PData m_p2p; + config::RPCData m_rpc; + + std::string m_symbol; + int m_share_period{}; + bool m_testnet {false}; + // std::string coin_prefix; //TODO: const unsigned char*? + int identifier lenght + // int32_t block_period; + // std::string p2p_address; + // int p2p_port + // int address_vesion; + // int address_p2sh_version; + // int rpc_port; + + // uint256 dumb_scrypt_diff; +}; + +} // namespace btc diff --git a/src/impl/btc/config_pool.cpp b/src/impl/btc/config_pool.cpp new file mode 100644 index 000000000..60531f23a --- /dev/null +++ b/src/impl/btc/config_pool.cpp @@ -0,0 +1,48 @@ +#include "config_pool.hpp" + +#include +#include + +namespace btc +{ + +std::ofstream& PoolConfig::get_default(std::ofstream& file) +{ + YAML::Node out; + + // Use the canonical LTC p2pool mainnet prefix when no prefix is set yet + out["prefix"] = m_prefix.empty() ? prefix_hex() : HexStr(m_prefix); + out["worker"] = m_worker; + + YAML::Node addrs_node; + for (const auto& host : DEFAULT_BOOTSTRAP_HOSTS) + addrs_node.push_back(host + ":" + std::to_string(P2P_PORT)); + out["bootstrap_addrs"] = addrs_node; + + file << out; + return file; +} + +void PoolConfig::load() +{ + YAML::Node node = YAML::LoadFile(m_filepath.string()); + + // prefix + m_prefix = ParseHexBytes(node["prefix"].as()); + + PARSE_CONFIG(node, worker, std::string); + + // Bootstrap addresses: load from YAML if present, otherwise use hardcoded defaults + if (node["bootstrap_addrs"] && node["bootstrap_addrs"].IsSequence()) + { + for (const auto& item : node["bootstrap_addrs"]) + m_bootstrap_addrs.emplace_back(item.as()); + } + else + { + for (const auto& host : DEFAULT_BOOTSTRAP_HOSTS) + m_bootstrap_addrs.emplace_back(host + ":" + std::to_string(P2P_PORT)); + } +} + +} // namespace btc diff --git a/src/impl/btc/config_pool.hpp b/src/impl/btc/config_pool.hpp new file mode 100644 index 000000000..52d96ed38 --- /dev/null +++ b/src/impl/btc/config_pool.hpp @@ -0,0 +1,260 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace btc +{ + +class PoolConfig : protected core::Fileconfig +{ +protected: + std::ofstream& get_default(std::ofstream& file) override; + void load() override; + +public: + PoolConfig(const std::filesystem::path& path) : core::Fileconfig(path) + { + + } + + // ----------------------------------------------------------------------- + // BTC p2pool network constants (jtoomim/SPB-compat — must match the live + // BTC p2pool sharechain at p2p-spb.xyz:9333 + jtoomim-derived peers). + // Source: ref/p2pool-btc + p2pool-jtoomim/p2pool/networks/bitcoin.py. + // Live network probe 2026-04-28: SPB cluster runs version 77.0.0 with + // protocol_version 3502 (one bump above jtoomim master's 3501). + // ----------------------------------------------------------------------- + static constexpr uint16_t P2P_PORT = 9333; // BTC p2pool sharechain port + static constexpr uint32_t SPREAD = 3; // blocks (PPLNS window) + static constexpr uint32_t TARGET_LOOKBEHIND = 200; + // MINIMUM 3500 admits jtoomim master (3501) and SPB cluster (3502). + // Below that lies forrestv-era v17/v32 — not interoperable with v35. + static constexpr uint32_t MINIMUM_PROTOCOL_VERSION = 3500; + // Our capability — match SPB cluster's bump for forward-compat. + static constexpr uint32_t ADVERTISED_PROTOCOL_VERSION = 3502; + static constexpr uint32_t SEGWIT_ACTIVATION_VERSION = 33; // jtoomim BTC bitcoin.py:35 + static constexpr uint32_t BLOCK_MAX_SIZE = 1000000; + static constexpr uint32_t BLOCK_MAX_WEIGHT = 4000000; + + // Mainnet constants — jtoomim BTC bitcoin.py + static constexpr uint32_t SHARE_PERIOD = 30; // seconds (BTC slower than LTC's 15) + static constexpr uint32_t CHAIN_LENGTH = 8640; // 24*60*60 / 10 + static constexpr uint32_t REAL_CHAIN_LENGTH = 8640; + + // DUST_THRESHOLD: minimum payout per share output. + // BTC mainnet: 0.001 BTC = 100000 sat (jtoomim bitcoin/networks/bitcoin.py:32). + // Testnet: 1.0 BTC placeholder until jtoomim BTC testnet config is consulted. + static constexpr uint64_t DUST_THRESHOLD = 100000; // satoshis (BTC mainnet) + static constexpr uint64_t TESTNET_DUST_THRESHOLD = 100000000; // satoshis placeholder + static uint64_t dust_threshold() { return is_testnet ? TESTNET_DUST_THRESHOLD : DUST_THRESHOLD; } + + // Testnet constants (jtoomim BTC testnet bitcoin/networks/bitcoin_testnet.py + // not yet probed; using BTC-mainnet defaults until live testnet network is + // selected). Conservative copy of mainnet for now — adjust in B-testnet phase. + static constexpr uint32_t TESTNET_SHARE_PERIOD = 30; + static constexpr uint32_t TESTNET_CHAIN_LENGTH = 8640; + static constexpr uint32_t TESTNET_REAL_CHAIN_LENGTH = 8640; + + // Runtime testnet flag — set once at startup + static inline bool is_testnet = false; + + // Accessors that return correct value for current network + static uint32_t share_period() { return is_testnet ? TESTNET_SHARE_PERIOD : SHARE_PERIOD; } + static uint32_t chain_length() { return is_testnet ? TESTNET_CHAIN_LENGTH : CHAIN_LENGTH; } + static uint32_t real_chain_length() { return is_testnet ? TESTNET_REAL_CHAIN_LENGTH : REAL_CHAIN_LENGTH; } + + // MAX_TARGET: share difficulty floor (easiest allowed share PoW). + // BTC mainnet: 2^256 / 2^32 - 1 (≈ 2^224, bdiff 1) — jtoomim BTC bitcoin.py:18 + // MAX_TARGET = 2**256//2**32 - 1 + // Testnet: same as mainnet for now (jtoomim BTC testnet placeholder). + static uint256 max_target() + { + static const uint256 MAINNET_MAX = [] { + uint256 t; + // 2^256 / 2^32 - 1 = 0x00000000ffffffff...ffffffff (224 bits set). + t.SetHex("00000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + return t; + }(); + static const uint256 TESTNET_MAX = [] { + uint256 t; + t.SetHex("00000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + return t; + }(); + return is_testnet ? TESTNET_MAX : MAINNET_MAX; + } + + // ----------------------------------------------------------------------- + // Consensus-critical donation scripts + // Must match frstrtr/p2pool-merged-v36 p2pool/data.py exactly. + // ----------------------------------------------------------------------- + + // V35 DONATION_SCRIPT (P2PK: OP_PUSHBYTES_65 OP_CHECKSIG). + // Same script bytes as forrestv/jtoomim BTC p2pool data.py:68 — the + // uncompressed pubkey is forrestv's; encoded address differs by network + // (1... on BTC mainnet, L... on LTC). Verified bit-identical 2026-04-28. + static constexpr std::array DONATION_SCRIPT = { + 0x41, // OP_PUSHBYTES_65 + 0x04, 0xff, 0xd0, 0x3d, 0xe4, 0x4a, 0x6e, 0x11, + 0xb9, 0x91, 0x7f, 0x3a, 0x29, 0xf9, 0x44, 0x32, + 0x83, 0xd9, 0x87, 0x1c, 0x9d, 0x74, 0x3e, 0xf3, + 0x0d, 0x5e, 0xdd, 0xcd, 0x37, 0x09, 0x4b, 0x64, + 0xd1, 0xb3, 0xd8, 0x09, 0x04, 0x96, 0xb5, 0x32, + 0x56, 0x78, 0x6b, 0xf5, 0xc8, 0x29, 0x32, 0xec, + 0x23, 0xc3, 0xb7, 0x4d, 0x9f, 0x05, 0xa6, 0xf9, + 0x5a, 0x8b, 0x55, 0x29, 0x35, 0x26, 0x56, 0x66, + 0x4b, + 0xac // OP_CHECKSIG + }; + + // V36+ COMBINED_DONATION_SCRIPT (P2SH: OP_HASH160 OP_EQUAL) + // 1-of-2 multisig: forrestv + frstrtr/c2pool dev key. + // LTC-specific (BTC stays at v35 per jtoomim — see plan v2 §3); kept + // here as inert byte data so get_donation_script() compiles for both + // BTC v35 (returns the P2PK above) and LTC v36 paths. + // Address: MLhSmVQxMusLE3pjGFvp4unFckgjeD8LUA (LTC mainnet P2SH). + static constexpr std::array COMBINED_DONATION_SCRIPT = { + 0xa9, // OP_HASH160 + 0x14, // PUSH 20 bytes + 0x8c, 0x62, 0x72, 0x62, 0x1d, 0x89, 0xe8, 0xfa, + 0x52, 0x6d, 0xd8, 0x6a, 0xcf, 0xf6, 0x0c, 0x71, + 0x36, 0xbe, 0x8e, 0x85, + 0x87 // OP_EQUAL + }; + + // Returns the correct donation script based on share version. + // Pre-V36 shares use the original P2PK donation script. + // V36+ shares use the combined P2SH 1-of-2 multisig script. + static std::vector get_donation_script(int64_t share_version) + { + if (share_version >= 36) + return {COMBINED_DONATION_SCRIPT.begin(), COMBINED_DONATION_SCRIPT.end()}; + return {DONATION_SCRIPT.begin(), DONATION_SCRIPT.end()}; + } + + // Message framing prefix — BTC mainnet (jtoomim bitcoin.py:14): + // PREFIX = '2472ef181efcd37b' + static inline const std::string DEFAULT_PREFIX_HEX = "2472ef181efcd37b"; + // Testnet placeholder until jtoomim BTC testnet prefix is probed + static inline const std::string TESTNET_PREFIX_HEX = "2472ef181efcd37b"; + // Network identifier — BTC mainnet (jtoomim bitcoin.py:13): + // IDENTIFIER = 'fc70035c7a81bc6f' + static inline const std::string DEFAULT_IDENTIFIER_HEX = "fc70035c7a81bc6f"; + // Testnet placeholder + static inline const std::string TESTNET_IDENTIFIER_HEX = "fc70035c7a81bc6f"; + + // Private chain overrides — set once at startup via --network-id + static inline std::string override_identifier_hex; + static inline std::string override_prefix_hex; + + /// Set private network identity. IDENTIFIER is the consensus secret + /// (hashed into ref_hash). PREFIX is derived from it for transport framing. + /// Call once at startup before any P2P or share operations. + static void set_network_id(const std::string& network_id_hex) { + if (network_id_hex.empty() || network_id_hex == "0" || network_id_hex == "00000000") + return; // public network, use defaults + + // Pad to 16 hex chars (8 bytes) if shorter + std::string padded = network_id_hex; + while (padded.size() < 16) padded = "0" + padded; + if (padded.size() > 16) padded = padded.substr(0, 16); + + override_identifier_hex = padded; + + // Derive PREFIX from IDENTIFIER using simple XOR mixing + // PREFIX = IDENTIFIER bytes XOR-rotated (fast, deterministic, non-reversible enough + // for transport framing — the real security is in IDENTIFIER via ref_hash) + auto id_bytes = ParseHex(padded); + static const char* HEX = "0123456789abcdef"; + override_prefix_hex.clear(); + override_prefix_hex.reserve(16); + for (size_t i = 0; i < 8 && i < id_bytes.size(); ++i) { + // XOR with rotated byte + constant to ensure PREFIX != IDENTIFIER + uint8_t b = id_bytes[i] ^ id_bytes[(i + 3) % id_bytes.size()] ^ 0x5A; + override_prefix_hex += HEX[b >> 4]; + override_prefix_hex += HEX[b & 0x0f]; + } + } + + static const std::string& identifier_hex() { + if (!override_identifier_hex.empty()) + return override_identifier_hex; + return is_testnet ? TESTNET_IDENTIFIER_HEX : DEFAULT_IDENTIFIER_HEX; + } + + static const std::string& prefix_hex() { + if (!override_prefix_hex.empty()) + return override_prefix_hex; + return is_testnet ? TESTNET_PREFIX_HEX : DEFAULT_PREFIX_HEX; + } + + /// Chain fingerprint: SHA256d(PREFIX || IDENTIFIER)[0:8] + /// + /// 16-byte preimage → 2^128 preimage space. 8-byte output → + /// collision-free for all practical chain counts (birthday at 2^32 chains). + /// Standard Bitcoin SHA256d, no custom cryptography. + static uint64_t chain_fingerprint_u64() { + if (override_identifier_hex.empty()) + return 0; // public network + + auto pfx_bytes = ParseHex(override_prefix_hex); + auto id_bytes = ParseHex(override_identifier_hex); + std::vector preimage; + preimage.reserve(pfx_bytes.size() + id_bytes.size()); + preimage.insert(preimage.end(), pfx_bytes.begin(), pfx_bytes.end()); + preimage.insert(preimage.end(), id_bytes.begin(), id_bytes.end()); + + // SHA256d: Hash = SHA256(SHA256(preimage)) + unsigned char hash1[32], hash2[32]; + CSHA256().Write(preimage.data(), preimage.size()).Finalize(hash1); + CSHA256().Write(hash1, 32).Finalize(hash2); + + uint64_t fp = 0; + for (int i = 0; i < 8; ++i) + fp |= uint64_t(hash2[i]) << (8 * i); + return fp; + } + + // BTC mainnet softforks per jtoomim bitcoin.py:33 + post-2021 additions. + // jtoomim master pre-dates Taproot activation but the bitcoind we connect + // to enforces it; tracking it here keeps the softfork-check pass aligned + // with bitcoind's getblockchaininfo. + static inline const std::set SOFTFORKS_REQUIRED = { + "bip65", "csv", "segwit", "taproot" + }; + + // Default bootstrap peers — live BTC p2pool network (probed 2026-04-28 + // via http://p2p-spb.xyz:9334/peer_versions). Mix of the SPB-cluster + // 77.0.0 fork + jtoomim-master-derived peers + jtoomim BTC bitcoin.py + // BOOTSTRAP_ADDRS defaults. + static inline const std::vector DEFAULT_BOOTSTRAP_HOSTS = { + // SPB cluster (4 nodes RU + USA, version 77.0.0) + "p2p-spb.xyz", + "ekb.p2p-spb.xyz", + "rov.p2p-spb.xyz", + "usa.p2p-spb.xyz", + // jtoomim-master defaults (BOOTSTRAP_ADDRS in bitcoin.py) + "ml.toom.im", + "btc-fork.coinpool.pw:9335", + "btc.p2pool.leblancnet.us", + }; + + // ----------------------------------------------------------------------- + // Runtime config loaded from pool.yaml + // ----------------------------------------------------------------------- + std::vector m_prefix; + std::string m_worker; + std::vector m_bootstrap_addrs; +}; + +} // namespace btc diff --git a/src/impl/btc/donation_consensus.hpp b/src/impl/btc/donation_consensus.hpp new file mode 100644 index 000000000..210449b59 --- /dev/null +++ b/src/impl/btc/donation_consensus.hpp @@ -0,0 +1,123 @@ +#pragma once +// +// donation_consensus.hpp — Consensus-level donation script validation for LTC p2pool shares. +// +// This module validates that coinbase transactions correctly include the +// donation output as required by the p2pool protocol. The donation script +// receives the rounding remainder from PPLNS payout distribution. +// +// Reference: frstrtr/p2pool-merged-v36 p2pool/data.py lines 114–200 +// + +#include "config_pool.hpp" +#include "share_tracker.hpp" + +#include + +#include +#include +#include +#include + +namespace btc::consensus +{ + +// A single coinbase output (scriptPubKey + value) +struct CoinbaseOutput +{ + std::vector script; + uint64_t value{0}; +}; + +// Result of donation validation +struct DonationValidationResult +{ + bool valid{false}; + std::string error; +}; + +// Validate that a coinbase transaction contains the correct donation output. +// +// Rules (from p2pool consensus): +// 1. The donation script MUST appear as an output in the coinbase. +// 2. The donation amount >= expected_donation (from PPLNS remainder). +// 3. Pre-V36 shares use DONATION_SCRIPT (P2PK); V36+ use COMBINED_DONATION_SCRIPT (P2SH). +// +// Parameters: +// coinbase_outputs: all outputs from the coinbase transaction +// expected_payouts: result of ShareTracker::get_expected_payouts() +// share_version: the share's version number (selects donation script) +// +inline DonationValidationResult validate_donation_output( + const std::vector& coinbase_outputs, + const std::map, double>& expected_payouts, + int64_t share_version) +{ + auto donation_script = PoolConfig::get_donation_script(share_version); + + // Look up expected donation amount + auto it = expected_payouts.find(donation_script); + if (it == expected_payouts.end()) + return {true, {}}; // No donation expected (e.g., all miners set donation=0) + + double expected_amount = it->second; + if (expected_amount <= 0.0) + return {true, {}}; + + // Find the donation output in the coinbase + uint64_t actual_donation = 0; + bool found = false; + for (const auto& out : coinbase_outputs) + { + if (out.script == donation_script) + { + actual_donation += out.value; + found = true; + } + } + + if (!found) + return {false, "coinbase missing donation output for script"}; + + // Allow 1-satoshi tolerance for integer rounding + if (actual_donation + 1 < static_cast(expected_amount)) + return {false, "donation output value too low"}; + + return {true, {}}; +} + +// Verify that the sum of all coinbase outputs does not exceed the subsidy. +// The donation output is included in this sum. +inline DonationValidationResult validate_coinbase_total( + const std::vector& coinbase_outputs, + uint64_t subsidy) +{ + uint64_t total = 0; + for (const auto& out : coinbase_outputs) + { + if (total + out.value < total) // overflow check + return {false, "coinbase output sum overflow"}; + total += out.value; + } + + if (total > subsidy) + return {false, "coinbase outputs exceed block subsidy"}; + + return {true, {}}; +} + +// Build the expected payout map for consensus validation. +// This is the canonical entry point connecting ShareTracker PPLNS weights +// to the donation script selection. +inline std::map, double> +build_expected_payouts(ShareTracker& tracker, + const uint256& best_share_hash, + const uint256& block_target, + uint64_t subsidy, + int64_t share_version) +{ + auto donation_script = PoolConfig::get_donation_script(share_version); + return tracker.get_expected_payouts(best_share_hash, block_target, subsidy, donation_script); +} + +} // namespace btc::consensus diff --git a/src/impl/btc/messages.hpp b/src/impl/btc/messages.hpp new file mode 100644 index 000000000..8b782b012 --- /dev/null +++ b/src/impl/btc/messages.hpp @@ -0,0 +1,191 @@ +#pragma once + +#include "coin/block.hpp" +#include "coin/transaction.hpp" + +#include + +#include +#include +#include + +namespace btc +{ + +// message_version +BEGIN_MESSAGE(version) + MESSAGE_FIELDS + ( + (uint32_t, m_version), + (uint64_t, m_services), + (addr_t , m_addr_to), + (addr_t, m_addr_from), + (uint64_t, m_nonce), + (std::string, m_subversion), + (uint32_t, m_mode), //# always 1 for legacy compatibility + (uint256, m_best_share) + ) + { + READWRITE(obj.m_version, obj.m_services, obj.m_addr_to, obj.m_addr_from, obj.m_nonce, obj.m_subversion, obj.m_mode, obj.m_best_share); + } +END_MESSAGE() + +// message_ping +BEGIN_MESSAGE(ping) + WITHOUT_MESSAGE_FIELDS() { } +END_MESSAGE() + +// message_addrme +BEGIN_MESSAGE(addrme) + MESSAGE_FIELDS + ( + (uint16_t, m_port) + ) + { + READWRITE(obj.m_port); + } +END_MESSAGE() + +// message_getaddrs +BEGIN_MESSAGE(getaddrs) + MESSAGE_FIELDS + ( + (uint32_t, m_count) + ) + { + READWRITE(obj.m_count); + } +END_MESSAGE() + +// message_addrs +BEGIN_MESSAGE(addrs) + MESSAGE_FIELDS + ( + (std::vector, m_addrs) + ) + { + READWRITE(obj.m_addrs); + } +END_MESSAGE() + +// message_shares +BEGIN_MESSAGE(shares) + MESSAGE_FIELDS + ( + (std::vector, m_shares) + ) + { + READWRITE(obj.m_shares); + } +END_MESSAGE() + +// message_sharereq +BEGIN_MESSAGE(sharereq) + MESSAGE_FIELDS + ( + (uint256, m_id), + (std::vector, m_hashes), + (uint64_t, m_parents), + (std::vector, m_stops) + ) + { + READWRITE(obj.m_id, obj.m_hashes, VarInt(obj.m_parents), obj.m_stops); + } +END_MESSAGE() + +enum ShareReplyResult +{ + good = 0, + too_long = 1, + unk2 = 2, + unk3 = 3, + unk4 = 4, + unk5 = 5, + unk6 = 6 +}; + +// message_sharereply +BEGIN_MESSAGE(sharereply) + MESSAGE_FIELDS + ( + (uint256, m_id), + (ShareReplyResult, m_result), + (std::vector, m_shares) + ) + { + READWRITE(obj.m_id, Using>(obj.m_result), obj.m_shares); + } +END_MESSAGE() + +// message_bestblock +BEGIN_MESSAGE(bestblock) + MESSAGE_FIELDS + ( + (coin::BlockHeaderType, m_header) + ) + { + READWRITE(obj.m_header); + } +END_MESSAGE() + +// message_have_tx +BEGIN_MESSAGE(have_tx) + MESSAGE_FIELDS + ( + (std::vector, m_tx_hashes) + ) + { + READWRITE(obj.m_tx_hashes); + } +END_MESSAGE() + +// message_losing_tx +BEGIN_MESSAGE(losing_tx) + MESSAGE_FIELDS + ( + (std::vector, m_tx_hashes) + ) + { + READWRITE(obj.m_tx_hashes); + } +END_MESSAGE() + +// message_forget_tx +BEGIN_MESSAGE(forget_tx) + MESSAGE_FIELDS + ( + (std::vector, m_tx_hashes) + ) + { + READWRITE(obj.m_tx_hashes); + } +END_MESSAGE() + +// message_remember_tx +BEGIN_MESSAGE(remember_tx) + MESSAGE_FIELDS + ( + (std::vector, m_tx_hashes), + (std::vector, m_txs) + ) + { + READWRITE(obj.m_tx_hashes, coin::TX_WITH_WITNESS(obj.m_txs)); + } +END_MESSAGE() + +using Handler = MessageHandler< + message_ping, + message_addrme, + message_getaddrs, + message_addrs, + message_shares, + message_sharereq, + message_sharereply, + message_bestblock, + message_have_tx, + message_losing_tx, + message_forget_tx, + message_remember_tx +>; + +} // namespace btc diff --git a/src/impl/btc/node.cpp b/src/impl/btc/node.cpp new file mode 100644 index 000000000..050c329a4 --- /dev/null +++ b/src/impl/btc/node.cpp @@ -0,0 +1,2230 @@ +#include "node.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +// Static members for DensePPLNSRing precomputed decay table +std::vector btc::DensePPLNSRing::s_decay_table; +uint64_t btc::DensePPLNSRing::s_decay_per = 0; +bool btc::DensePPLNSRing::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 btc +{ + +static uint64_t make_random_nonce() +{ + std::mt19937_64 rng(std::random_device{}()); + return rng(); +} + +void NodeImpl::send_ping(peer_ptr peer) +{ + auto rmsg = btc::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 = btc::message_version::make_raw( + btc::PoolConfig::ADVERTISED_PROTOCOL_VERSION, + 1, // services + addr_t{1, peer->addr()}, // addr_to (the remote) + addr_t{1, NetService{"0.0.0.0", btc::PoolConfig::P2P_PORT}}, // addr_from (us) + m_nonce, + m_software_version, + 1, // mode (always 1 for legacy compat) + best_share_hash() // advertise our tallest chain head + ); + 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 = btc::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 = btc::message_getaddrs::make_raw(8); + peer->write(std::move(getaddrs_msg)); + } + + // Reject peers running too-old protocol + if (msg->m_version < btc::PoolConfig::MINIMUM_PROTOCOL_VERSION) + { + LOG_WARNING << "Peer " << msg->m_addr_from.m_endpoint.to_string() + << " protocol " << msg->m_version + << " < minimum " << btc::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 = btc::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 scrypt-1024 (~20ms each) — 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. + { + std::unique_lock lock(m_tracker_mutex, std::try_to_lock); + if (!lock.owns_lock()) { + 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 released — proceed with normal processing. + // No lock needed for the rest: we only enter here when think() is NOT + // running (m_tracker_mutex was available), and ASIO single-thread + // guarantees no other IO handler overlaps. + + // 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) + { + PackStream packed_tx = pack(coin::TX_WITH_WITNESS(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() << ")"; + } + + // 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() || !m_tracker.chain.contains(share_hash)) + return; + + // Try inline verify — if think() holds the mutex, defer to next think() cycle. + // The share is already in the chain; think() will verify + score it. + { + std::unique_lock lock(m_tracker_mutex, std::try_to_lock); + if (lock.owns_lock()) + 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; +} + +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](btc::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 = btc::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 scrypt, 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 scrypt 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) + +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(); + + // 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(); + + needs_continue = m_tracker.m_think_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"; + } 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(); + } + + // 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(); + + // 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(); + m_think_running.store(false); + } catch (...) { + LOG_ERROR << "run_think() IO phase failed: unknown error"; + 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 == btc::StaleInfo::orphan) ++orphan_count; + else if (s->m_stale_info == btc::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 << " tLTC"; + } + } + + 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(btc::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(); + } + 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 btc diff --git a/src/impl/btc/node.hpp b/src/impl/btc/node.hpp new file mode 100644 index 000000000..3026f1d33 --- /dev/null +++ b/src/impl/btc/node.hpp @@ -0,0 +1,599 @@ +#pragma once + +#include "config.hpp" +#include "share.hpp" +#include "share_tracker.hpp" +#include "peer.hpp" +#include "messages.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace btc +{ +struct HandleSharesData; +struct ShareReplyData +{ + std::vector m_items; + std::vector m_raw_items; +}; + +class NodeImpl : public pool::BaseNode +{ + // Async share downloader: + // ID = uint256 (matches sharereq id to sharereply id) + // RESPONSE = parsed shares plus their original raw payloads + // REQUEST args: req_id, peer, hashes, parents, stops + using share_getter_t = ReplyMatcher::ID + ::RESPONSE + ::REQUEST, uint64_t, std::vector>; + +protected: + btc::Handler m_handler; + share_getter_t m_share_getter; + ShareTracker m_tracker; + std::unique_ptr m_storage; + + // Global pool of known transactions, populated by remember_tx and coin daemon. + // Protocol handlers look up tx hashes here when processing shares. + std::map m_known_txs; + + // Thread pool for parallel share_init_verify (scrypt CPU work). + // Keeps expensive crypto off the io_context thread. + boost::asio::thread_pool m_verify_pool{4}; + + // ── Async compute pipeline ────────────────────────────────────────── + // think() runs on m_think_pool (1 thread) holding m_tracker_mutex exclusively. + // The IO thread NEVER calls lock() — only try_to_lock(). If the mutex is + // held by the compute thread, the IO thread defers the operation and continues + // processing network I/O, keepalive timers, and stratum. This eliminates the + // event loop freeze that previously caused all peers to timeout simultaneously. + // + // Synchronization contract: + // Compute thread: unique_lock(m_tracker_mutex) — exclusive, blocking + // IO thread reads: shared_lock(try_to_lock) — non-blocking, skip if busy + // IO thread writes: unique_lock(try_to_lock) — non-blocking, queue if busy + boost::asio::thread_pool m_think_pool{1}; + std::atomic m_think_running{false}; + std::atomic m_clean_running{false}; + mutable std::shared_mutex m_tracker_mutex; + + // ── Lock-free stats snapshot ───────────────────────────────────────── + // Published by think() on the compute thread under m_tracker_mutex. + // Read by ALL consumers (sync_status, loading page, global_stats, + // graph data, etc.) WITHOUT needing the tracker lock. + // + // This is the c2pool equivalent of p2pool's reactor-thread stats + // variables: updated atomically each think() cycle, always available. + // Eliminates the systemic "0/empty" data problem where 20+ callbacks + // returned stale data whenever think() held the exclusive lock. + struct TrackerSnapshot { + int chain_count{0}; + int verified_count{0}; + int head_count{0}; + int orphan_shares{0}; + int dead_shares{0}; + int fork_count{0}; + double pool_hashrate{0}; + }; + void publish_snapshot() { + TrackerSnapshot s; + s.chain_count = static_cast(m_tracker.chain.size()); + s.verified_count = static_cast(m_tracker.verified.size()); + s.head_count = static_cast(m_tracker.chain.get_heads().size()); + s.fork_count = s.head_count; + std::lock_guard lock(m_snapshot_mutex); + m_snapshot = s; + } + mutable std::mutex m_snapshot_mutex; + TrackerSnapshot m_snapshot; + + // Identity of the compute thread (m_think_pool's single thread). + // Used by TrackerReadGuard to skip shared_lock when the caller is + // already on the compute thread (which holds exclusive lock). + std::atomic m_compute_thread_id{}; + + // Pending share batches queued while think() holds the mutex. + // Drained on the IO thread after think() releases the lock. + // Uses unique_ptr because HandleSharesData is forward-declared here. + struct PendingShareBatch { + std::unique_ptr data; + NetService addr; + }; + std::vector m_pending_adds; + + // Top-5 scored heads from last think() — used by clean_tracker() + // to protect the best chains from head pruning (p2pool node.py:363). + std::vector m_last_top5_heads; + + // Buffer of newly verified share hashes, flushed to LevelDB periodically + std::vector m_verified_flush_buf; + + // Buffer of pruned share hashes, batch-deleted from LevelDB after clean_tracker() + std::vector m_removal_flush_buf; + +public: + NodeImpl() + : m_share_getter(nullptr, + [](uint256, peer_ptr, std::vector, uint64_t, std::vector){}) {} + + NodeImpl(boost::asio::io_context* ctx, config_t* config) + : base_t(ctx, config), + m_share_getter(ctx, + [](uint256 req_id, peer_ptr to_peer, + std::vector hashes, uint64_t parents, + std::vector stops) + { + auto rmsg = btc::message_sharereq::make_raw(req_id, hashes, parents, stops); + to_peer->write(std::move(rmsg)); + }, + 15) // p2pool p2p.py:80: timeout=15 for share requests + { + // Seed addr store with hardcoded bootstrap peers + m_addrs.load(config->pool()->m_bootstrap_addrs); + // Randomise our nonce so we detect self-connections + std::mt19937_64 rng(std::random_device{}()); + m_nonce = rng(); + // Route m_chain (used by BaseNode) to the tracker's main chain + m_chain = &m_tracker.chain; + + // Open LevelDB storage and load any persisted shares + std::string net_name = config->m_testnet ? "bitcoin_testnet" : "bitcoin"; + m_storage = std::make_unique(net_name); + load_persisted_shares(); + + // Wire up verified-hash persistence callback (p2pool known_verified pattern) + m_tracker.m_on_share_verified = [this](const uint256& hash) { + m_verified_flush_buf.push_back(hash); + if (m_verified_flush_buf.size() >= 50) + flush_verified_to_leveldb(); + }; + + // Wire up share removal → LevelDB cleanup (p2pool main.py:269-270) + // Buffer removals; clean_tracker() flushes after drop-tails. + // Safe on crash: unflushed shares get pruned at next startup by load_persisted_shares(). + m_tracker.chain.on_removed([this](const uint256& hash) { + m_removal_flush_buf.push_back(hash); + }); + } + + // INetwork: Pool node does not initiate disconnect — peer connections + // manage their own lifecycle via close_connection()/error() below. + void disconnect() override { } + void connected(std::shared_ptr socket) override; + + // ICommunicator (override BaseNode to track outbound lifecycle): + void error(const message_error_type& err, const NetService& service, const std::source_location where = std::source_location::current()) override; + void close_connection(const NetService& service) override; + + // BaseNode: + void send_ping(peer_ptr peer) override; + std::optional handle_version(std::unique_ptr rmsg, peer_ptr peer) override; + + // ltc + void send_version(peer_ptr peer); + void processing_shares(HandleSharesData& data, NetService addr); + void processing_shares_phase2(HandleSharesData& data, NetService addr); + /// Direct tracker access — compute-thread-only (already holds exclusive lock) + /// or startup code (before compute thread exists). + /// IO-thread code MUST use read_tracker() instead. + ShareTracker& tracker() { return m_tracker; } + + /// RAII guard for IO-thread tracker reads. + /// - IO thread: acquires shared_lock(try_to_lock). Returns falsy if busy. + /// - Compute thread: skips locking (exclusive already held). Always truthy. + /// Guard lifetime = lock lifetime. No way to hold a dangling reference. + class TrackerReadGuard { + std::shared_lock lock_; + ShareTracker& tracker_; + bool ok_; + public: + TrackerReadGuard(std::shared_mutex& mtx, ShareTracker& t, bool on_compute) + : lock_(mtx, std::defer_lock), tracker_(t) + { + if (on_compute) { + ok_ = true; // exclusive lock already held by caller + } else { + lock_.try_lock(); + ok_ = lock_.owns_lock(); + } + } + TrackerReadGuard(TrackerReadGuard&&) = default; + TrackerReadGuard(const TrackerReadGuard&) = delete; + TrackerReadGuard& operator=(const TrackerReadGuard&) = delete; + TrackerReadGuard& operator=(TrackerReadGuard&&) = default; + + explicit operator bool() const { return ok_; } + ShareTracker& operator*() { return tracker_; } + ShareTracker* operator->() { return &tracker_; } + }; + + /// True if the calling thread is the compute thread (m_think_pool). + /// Compute thread already holds exclusive lock — shared_lock must be skipped. + bool is_compute_thread() const { + return std::this_thread::get_id() == m_compute_thread_id.load(std::memory_order_relaxed); + } + + /// Preferred tracker accessor for IO-thread callbacks. + /// Returns a guard that: + /// - On IO thread: acquires shared_lock(try_to_lock). Check `if (!guard)` before use. + /// - On compute thread: skips locking (exclusive lock already held). Always valid. + TrackerReadGuard read_tracker() { + return TrackerReadGuard(m_tracker_mutex, m_tracker, is_compute_thread()); + } + + /// Acquire shared (reader) lock on the tracker mutex — BLOCKING. + /// Only for consensus-critical paths (share creation) where skipping is not acceptable. + /// IO-thread callers: prefer read_tracker() (non-blocking) unless you MUST have the lock. + std::shared_lock tracker_shared_lock() { + return std::shared_lock(m_tracker_mutex); + } + + // Async share download — response delivered to callback when sharereply arrives + void request_shares(uint256 id, peer_ptr peer, + std::vector hashes, uint64_t parents, + std::vector stops, + std::function callback) + { + m_share_getter.request(id, callback, id, peer, hashes, parents, stops); + } + + // Called from HANDLER(sharereply) to complete a pending async request + void got_share_reply(uint256 id, btc::ShareReplyData shares) + { + try { m_share_getter.got_response(id, shares); } + catch (const std::invalid_argument&) { /* request already timed out */ } + } + + std::vector handle_get_share(std::vector hashes, uint64_t parents, std::vector stops, NetService peer_addr); + + /// Broadcast a new best-block notification to all connected P2P peers. + void broadcast_bestblock(const coin::BlockHeaderType& header) { + for (auto& [nonce, peer] : m_peers) + peer->write(message_bestblock::make_raw(header)); + } + + /// Return a JSON array of connected peer info for the /peer_list endpoint. + nlohmann::json get_peer_info_json() const { + nlohmann::json arr = nlohmann::json::array(); + for (const auto& [nonce, peer] : m_peers) { + auto addr = peer->addr(); + bool incoming = (m_outbound_addrs.find(addr) == m_outbound_addrs.end()); + auto uptime_sec = std::chrono::duration_cast( + std::chrono::steady_clock::now() - peer->m_connected_at).count(); + arr.push_back({ + {"address", addr.to_string()}, + {"version", peer->m_other_subversion}, + {"incoming", incoming}, + {"uptime", uptime_sec}, + {"downtime", 0}, + {"web_port", 0} + }); + } + return arr; + } + + /// Register a callback invoked whenever a bestblock message is received + /// from any peer (after relaying). Use this to trigger work refresh. + void set_on_bestblock(std::function fn) { m_on_bestblock = std::move(fn); } + + /// Set the software version string announced to peers in P2P version messages. + void set_software_version(std::string ver) { m_software_version = std::move(ver); } + + /// Send a set of shares (with any needed txs) to a single peer. + /// Skips shares that originated from that peer. + void send_shares(peer_ptr peer, const std::vector& share_hashes); + + /// Broadcast a locally-generated (or newly-received) share to all peers. + void broadcast_share(const uint256& share_hash); + + /// Start downloading shares from a peer, beginning at `target_hash`. + /// Recursively fetches parents until the chain is connected or CHAIN_LENGTH reached. + void download_shares(peer_ptr peer, const uint256& target_hash); + + /// Return the hash of our tallest chain head, or uint256::ZERO if empty. + uint256 best_share_hash(); + + /// Load persisted shares from LevelDB storage into the tracker. + void load_persisted_shares(); + void flush_verified_to_leveldb(); + + /// Graceful shutdown: flush pending verified/removal buffers to LevelDB. + void shutdown(); + + /// Start dialing outbound peers from AddrStore / bootstrap list. + /// Maintains target outbound peer count active outbound connections. + void start_outbound_connections(); + + /// Set desired number of outbound peers maintained by connection loop. + /// A value of 0 disables outbound dialing. + void set_target_outbound_peers(size_t count) { m_target_outbound_peers = count; } + + /// Set max total P2P peers (inbound + outbound). + void set_max_peers(size_t count) { m_max_peers = count; } + + /// Set P2P ban duration (seconds). + void set_ban_duration(int seconds) { m_ban_duration = std::chrono::seconds(seconds); } + + /// Set cache size limits for memory control. + void set_cache_limits(size_t max_shared, size_t max_known_txs, size_t max_raw) { + m_max_shared_hashes = max_shared; + m_max_known_txs = max_known_txs; + m_max_raw_shares = max_raw; + } + + /// Set RSS memory limit in MB (abort if exceeded). Static because checked in process_shares. + static void set_rss_limit_mb(long mb); + + /// Expose tracker mutex for IO-thread callbacks that access the tracker. + /// Callers MUST use shared_lock(try_to_lock) — NEVER blocking lock(). + std::shared_mutex& tracker_mutex() { return m_tracker_mutex; } + + /// Unified share retention: single-pass prune of chain + verified + LevelDB. + /// Replaces multi-pass trim with work-based dead head detection and + /// deferred destruction for verified cascade safety. + /// Called from run_think() on the ioc thread. + void prune_shares(const uint256& best_share); + + /// Run the share tracker think() cycle: verifies chains, scores heads, + /// identifies bad peers, and requests needed shares. + /// Should be called periodically (e.g. after processing_shares or on a timer). + void run_think(); + + /// Fast-path: update best share after creating a local share. + /// Bypasses run_think() scoring — just sets the new tip and triggers + /// work refresh so miners immediately build on the new share. + /// p2pool equivalent: set_best_share() → work_event.happened(). + void notify_local_share(const uint256& share_hash); + + /// Periodic maintenance: eat stale heads, drop tails, then run_think(). + /// Matches p2pool's clean_tracker() (node.py:355-402). + void clean_tracker(); + + /// Drain share batches queued while think() held the tracker mutex. + /// Called on the IO thread after think() releases the lock. + void drain_pending_adds(); + + /// p2pool-style heartbeat: chain height, local/pool hashrate, orphan/DOA stats. + /// Runs on a separate 30s timer — diagnostic only, not consensus-critical. + void heartbeat_log(); + + /// Set the block_rel_height function used by run_think() for chain scoring. + /// fn(block_hash) should return confirmations from the coin daemon: + /// >= 0 : block is in main chain (0 = tip, higher = deeper) + /// < 0 : block is NOT in main chain (orphaned/stale) + using block_rel_height_fn_t = std::function; + void set_block_rel_height_fn(block_rel_height_fn_t fn) { m_block_rel_height_fn = std::move(fn); } + + /// Lock-free stats snapshot — published by think(), never fails, never needs tracker lock. + /// Inline definition must precede callers in the same header. + TrackerSnapshot get_tracker_snapshot() const; + int get_chain_count() const; + int get_verified_count() const; + + /// Called when best_share changes (p2pool: new_work_event) + /// Triggers immediate work update for all stratum miners. + void set_on_best_share_changed(std::function fn) { m_on_best_share_changed = std::move(fn); } + + /// Callback to get local hashrate from stratum server (H/s) + void set_local_hashrate_fn(std::function fn) { m_local_hashrate_fn = std::move(fn); } + + /// Local mining stats from RateMonitor (for p2pool-style status lines) + struct LocalRateStats { + double hashrate = 0; // H/s (total local) + double effective_dt = 0; // seconds of data in window + int total_datums = 0; // pseudoshares in window + int dead_datums = 0; // dead (DOA) pseudoshares in window + }; + void set_local_rate_stats_fn(std::function fn) { m_local_rate_stats_fn = std::move(fn); } + + /// Current PPLNS outputs {script_hex, satoshis} for payout display + void set_current_pplns_fn(std::function>()> fn) { + m_current_pplns_fn = std::move(fn); + } + + /// Node operator's payout script hex (for matching in PPLNS outputs) + void set_node_payout_script_hex(const std::string& hex) { m_node_payout_script_hex = hex; } + const std::string& get_node_payout_script_hex() const { return m_node_payout_script_hex; } + + /// Local miner scripts (from stratum sessions' pubkey_hashes → all script forms) + void set_local_miner_scripts_fn(std::function()> fn) { + m_local_miner_scripts_fn = std::move(fn); + } + + /// Check whether a peer address is currently banned. + bool is_banned(const NetService& addr) const; + + /// ── Runtime admin API (pool peer bans + whitelist) ───────────────── + /// All methods assumed to run on the io_context thread — callers + /// (web_server HTTP handlers) dispatch via thread_safe_wrap(). + /// + /// Returned JSON uses the shape: + /// {"ok": true|false, "error"?: "...", "bans": [...], "whitelist": [...]} + nlohmann::json admin_list_bans() const; + nlohmann::json admin_ban_ip(const std::string& ip, int duration_sec); + nlohmann::json admin_unban_ip(const std::string& ip); + nlohmann::json admin_list_whitelist() const; + nlohmann::json admin_whitelist_add(const std::string& host, uint16_t port); + nlohmann::json admin_whitelist_remove(const std::string& host, uint16_t port); + nlohmann::json admin_list_peers() const; + nlohmann::json admin_drop_peer(const std::string& ip); + nlohmann::json admin_dial_peer(const std::string& host, uint16_t port); + + /// Path to persisted whitelist file (~/.c2pool/pool_whitelist.json). + /// Set by c2pool_refactored.cpp before start(); empty = no persistence. + void set_whitelist_path(const std::string& path); + + /// True if addr's IP matches a whitelist entry (IP or host:port). + bool is_whitelisted(const NetService& addr) const; + +protected: + std::string m_software_version = "/c2pool:0.1/"; // overridden by set_software_version() + std::function m_on_bestblock; + std::function m_on_best_share_changed; + std::function m_local_hashrate_fn; + std::function m_local_rate_stats_fn; + std::function>()> m_current_pplns_fn; + std::function()> m_local_miner_scripts_fn; + std::string m_node_payout_script_hex; + std::set m_shared_share_hashes; // de-dup set for broadcast_share + std::set m_rejected_share_hashes; // shares rejected by peers — never re-broadcast + std::set m_downloading_shares; // hashes currently being fetched + + // Track share hashes that peers couldn't provide (empty reply). + // After MAX_EMPTY_RETRIES failures, stop requesting — the share is + // pruned from the network. p2pool avoids this via reactive desired_var + // + sleep(1) backoff. We use explicit failure counting. + static constexpr int MAX_EMPTY_RETRIES = 3; + std::unordered_map m_download_fail_count; + + // Track req_id → peer addr for selective cancellation on disconnect. + // p2pool has per-peer get_shares (GenericDeferrer), so connectionLost calls + // respond_all() on just that peer's deferrer. c2pool has a shared m_share_getter, + // so we track which req_ids belong to which peer for cancel-on-disconnect. + std::map m_pending_share_reqs; // req_id → peer addr + + // Track recently-broadcast share hashes + timestamp so we can detect + // rapid disconnections (peer rejected our share → PoW invalid loop). + struct BroadcastRecord { + std::vector hashes; + std::chrono::steady_clock::time_point when; + }; + std::map m_last_broadcast_to; // per-peer + + // Connection maintenance + static constexpr size_t DEFAULT_TARGET_OUTBOUND_PEERS = 8; + size_t m_max_peers = 30; + size_t m_target_outbound_peers = DEFAULT_TARGET_OUTBOUND_PEERS; + std::unique_ptr m_connect_timer; + std::set m_pending_outbound; // addresses currently being dialed + std::set m_outbound_addrs; // successfully connected outbound peers + + // Peer banning: maps address → ban expiry time + std::map m_ban_list; + std::chrono::seconds m_ban_duration{300}; // 5 minutes (configurable) + + // IP-only manual bans (admin endpoint). Keyed by IP string so the + // operator can ban/unban without knowing the peer's source port. + std::map m_ip_ban_list; + + // Whitelist: IPs that bypass is_banned() and host:port entries kept as + // permanent dial targets. Persists across restart via m_whitelist_path. + std::set m_whitelist_ips; + std::set m_whitelist_hosts; + std::string m_whitelist_path; + + void load_whitelist_from_disk(); + void save_whitelist_to_disk() const; + + // Rate-limiting no longer needed: think() runs on a dedicated compute + // thread (m_think_pool), serialized by m_think_running atomic flag. + // The IO thread never blocks. p2pool's reactor.callLater equivalent. + + // Cache limits (configurable) + size_t m_max_shared_hashes = 50000; + size_t m_max_known_txs = 10000; + size_t m_max_raw_shares = 50000; + + // Block depth function for chain scoring (set via set_block_rel_height_fn) + block_rel_height_fn_t m_block_rel_height_fn; + + // Cached best share hash from the most recent think() cycle + uint256 m_best_share_hash; + + // Cache of original raw serialized bytes keyed by share hash. + // Used for relay so we send the exact bytes we received, avoiding + // any round-trip serialization differences. + std::unordered_map m_raw_share_cache; +}; + +struct HandleSharesData +{ + std::vector m_items; + std::vector m_raw_items; // original raw bytes, parallel with m_items + std::map> m_txs; + + void add(const ShareType& share, std::vector txs) + { + m_items.push_back(share); + m_raw_items.emplace_back(); // no cached raw bytes + m_txs[share.hash()] = std::move(txs); + } + + void add(const ShareType& share, std::vector txs, + const chain::RawShare& raw) + { + m_items.push_back(share); + m_raw_items.push_back(raw); + m_txs[share.hash()] = std::move(txs); + } +}; + + +/* + void handle_message_addrs(std::shared_ptr msg, PoolProtocol* protocol); + void handle_message_addrme(std::shared_ptr msg, PoolProtocol* protocol); + void handle_message_ping(std::shared_ptr msg, PoolProtocol* protocol); + void handle_message_getaddrs(std::shared_ptr msg, PoolProtocol* protocol); + void handle_message_shares(std::shared_ptr msg, PoolProtocol* protocol); + void handle_message_sharereq(std::shared_ptr msg, PoolProtocol* protocol); + void handle_message_sharereply(std::shared_ptr msg, PoolProtocol* protocol); + void handle_message_bestblock(std::shared_ptr msg, PoolProtocol* protocol); + void handle_message_have_tx(std::shared_ptr msg, PoolProtocol* protocol); + void handle_message_losing_tx(std::shared_ptr msg, PoolProtocol* protocol); + void handle_message_remember_tx(std::shared_ptr msg, PoolProtocol* protocol); + void handle_message_forget_tx(std::shared_ptr msg, PoolProtocol* protocol); +*/ + +class Legacy : public pool::Protocol +{ +public: + void handle_message(std::unique_ptr rmsg, NodeImpl::peer_ptr peer) override; + + ADD_HANDLER(addrs, btc::message_addrs); + ADD_HANDLER(addrme, btc::message_addrme); + ADD_HANDLER(ping, btc::message_ping); + ADD_HANDLER(getaddrs, btc::message_getaddrs); + ADD_HANDLER(shares, btc::message_shares); + ADD_HANDLER(sharereq, btc::message_sharereq); + ADD_HANDLER(sharereply, btc::message_sharereply); + ADD_HANDLER(bestblock, btc::message_bestblock); + ADD_HANDLER(have_tx, btc::message_have_tx); + ADD_HANDLER(losing_tx, btc::message_losing_tx); + ADD_HANDLER(remember_tx, btc::message_remember_tx); + ADD_HANDLER(forget_tx, btc::message_forget_tx); +}; + +class Actual : public pool::Protocol +{ +public: + void handle_message(std::unique_ptr rmsg, NodeImpl::peer_ptr peer) override; + + ADD_HANDLER(addrs, btc::message_addrs); + ADD_HANDLER(addrme, btc::message_addrme); + ADD_HANDLER(ping, btc::message_ping); + ADD_HANDLER(getaddrs, btc::message_getaddrs); + ADD_HANDLER(shares, btc::message_shares); + ADD_HANDLER(sharereq, btc::message_sharereq); + ADD_HANDLER(sharereply, btc::message_sharereply); + ADD_HANDLER(bestblock, btc::message_bestblock); + ADD_HANDLER(have_tx, btc::message_have_tx); + ADD_HANDLER(losing_tx, btc::message_losing_tx); + ADD_HANDLER(remember_tx, btc::message_remember_tx); + ADD_HANDLER(forget_tx, btc::message_forget_tx); +}; + +using Node = pool::NodeBridge; + +} // namespace btc diff --git a/src/impl/btc/peer.hpp b/src/impl/btc/peer.hpp new file mode 100644 index 000000000..6719c81f7 --- /dev/null +++ b/src/impl/btc/peer.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include "coin/transaction.hpp" + +#include +#include +#include +#include + +namespace btc +{ + +struct Peer +{ + std::optional m_other_version; + std::string m_other_subversion; + uint64_t m_other_services; + uint64_t m_nonce; + std::chrono::steady_clock::time_point m_connected_at{std::chrono::steady_clock::now()}; + + std::set m_remote_txs; // hashes + // int32_t remote_remembered_txs_size = 0; + + std::map m_remembered_txs; + // int32_t remembered_txs_size = 0; + // const int32_t max_remembered_txs_size = 25000000; +}; + +}; // namespace btc \ No newline at end of file diff --git a/src/impl/btc/pool_monitor.hpp b/src/impl/btc/pool_monitor.hpp new file mode 100644 index 000000000..3d7be3db1 --- /dev/null +++ b/src/impl/btc/pool_monitor.hpp @@ -0,0 +1,360 @@ +#pragma once + +// Phase 3L: Log-based pool monitoring for attack detection. +// +// Emits structured [MONITOR] log lines every status cycle (~30s). +// All output is grep-friendly — no HTTP endpoints, no web changes. +// +// Log line prefixes: +// [MONITOR-HASHRATE] — pool hashrate vs moving average +// [MONITOR-CONC] — per-address work concentration +// [MONITOR-EMERGENCY] — emergency decay triggers / share gaps +// [MONITOR-DIFF] — difficulty anomaly detection +// [MONITOR-SUMMARY] — one-line health summary +// +// Port of p2pool-v36 p2pool/monitor.py (commit d831a045). + +#include "config_pool.hpp" +#include "share_tracker.hpp" +#include "share_check.hpp" +#include "core/address_utils.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace btc +{ + +// SI-suffix formatter for hashrate values +inline std::string fmt_hashrate(double n) +{ + static const char* suffixes[] = {"", "k", "M", "G", "T", "P"}; + for (const char* s : suffixes) + { + if (std::abs(n) < 1000.0) + { + std::ostringstream os; + os.precision(1); + os << std::fixed << n << s; + return os.str(); + } + n /= 1000.0; + } + std::ostringstream os; + os.precision(1); + os << std::fixed << n << "E"; + return os.str(); +} + +class PoolMonitor +{ +public: + // Configurable thresholds + static constexpr double CONCENTRATION_WARN_PCT = 25.0; + static constexpr double CONCENTRATION_ALERT_PCT = 40.0; + static constexpr double HASHRATE_SPIKE_FACTOR = 1.5; + static constexpr double HASHRATE_DROP_FACTOR = 0.5; + static constexpr double DIFF_ANOMALY_FACTOR = 2.0; + + PoolMonitor() = default; + + // Run one monitoring cycle. Returns number of alerts emitted. + int run_cycle(ShareTracker& tracker, const uint256& best_share_hash) + { + ++cycle_; + int alerts = 0; + + if (best_share_hash.IsNull()) + return 0; + + auto [height, last] = tracker.chain.get_height_and_last(best_share_hash); + if (height < 10) + return 0; + + alerts += check_hashrate(tracker, best_share_hash, height); + alerts += check_concentration(tracker, best_share_hash, height); + alerts += check_share_gap(tracker, best_share_hash); + alerts += check_difficulty(tracker, best_share_hash, height); + + // Summary line + const char* status = (alerts == 0) ? "OK" : "ALERT"; + uint32_t gap = 0; + { + uint32_t ts = 0; + tracker.chain.get_share(best_share_hash).invoke([&](auto* obj) { + ts = obj->m_timestamp; + }); + auto now = static_cast(std::time(nullptr)); + gap = (now > ts) ? (now - ts) : 0; + } + LOG_INFO << "[MONITOR-SUMMARY] cycle=" << cycle_ + << " height=" << height + << " gap=" << gap << "s" + << " status=" << status + << " alerts=" << alerts; + + return alerts; + } + +private: + uint64_t cycle_ = 0; + uint64_t emergency_count_ = 0; + + // 1-hour rolling hashrate history + struct HrSample { int64_t ts; double hr; }; + std::vector hr_history_; + + // ── Hashrate spike/drop detection ────────────────────────────── + + int check_hashrate(ShareTracker& tracker, const uint256& best, int32_t height) + { + int alerts = 0; + int32_t lookbehind = std::min(height - 1, + static_cast(3600 / PoolConfig::share_period())); + if (lookbehind < 2) + return 0; + + auto aps = tracker.get_pool_attempts_per_second(best, lookbehind); + double real_att_s = static_cast(aps.GetLow64()); + + auto now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + hr_history_.push_back({now, real_att_s}); + + // Keep 1 hour + auto cutoff = now - 3600; + hr_history_.erase( + std::remove_if(hr_history_.begin(), hr_history_.end(), + [cutoff](const HrSample& s) { return s.ts < cutoff; }), + hr_history_.end()); + + if (hr_history_.size() < 3) + return 0; + + double sum = 0; + for (auto& s : hr_history_) sum += s.hr; + double avg_hr = sum / static_cast(hr_history_.size()); + + if (avg_hr > 0) + { + double ratio = real_att_s / avg_hr; + if (ratio > HASHRATE_SPIKE_FACTOR) + { + LOG_WARNING << "[MONITOR-HASHRATE] ALERT spike=" << ratio + << "x current=" << fmt_hashrate(real_att_s) << "H/s" + << " avg_1h=" << fmt_hashrate(avg_hr) << "H/s"; + ++alerts; + } + else if (ratio < HASHRATE_DROP_FACTOR) + { + LOG_WARNING << "[MONITOR-HASHRATE] ALERT drop=" << ratio + << "x current=" << fmt_hashrate(real_att_s) << "H/s" + << " avg_1h=" << fmt_hashrate(avg_hr) << "H/s"; + ++alerts; + } + else if (cycle_ % 10 == 0) + { + LOG_INFO << "[MONITOR-HASHRATE] ok ratio=" << ratio + << " current=" << fmt_hashrate(real_att_s) << "H/s" + << " avg_1h=" << fmt_hashrate(avg_hr) << "H/s" + << " samples=" << hr_history_.size(); + } + } + return alerts; + } + + // ── Per-address work concentration ───────────────────────────── + + int check_concentration(ShareTracker& tracker, const uint256& best, int32_t height) + { + int alerts = 0; + + struct Window { const char* label; int32_t depth; }; + std::vector windows; + windows.push_back({"short", std::min(height, 100)}); + windows.push_back({"medium", std::min(height, 720)}); + if (height >= static_cast(PoolConfig::real_chain_length())) + windows.push_back({"full", std::min(height, + static_cast(PoolConfig::real_chain_length()))}); + + for (auto& [label, depth] : windows) + { + std::map, double> addr_work; + double total_work = 0; + + auto chain_view = tracker.chain.get_chain(best, depth); + for (auto [hash, data] : chain_view) + { + double w = 0; + std::vector addr_bytes; + data.share.invoke([&](auto* obj) { + auto target = chain::bits_to_target(obj->m_bits); + auto att = chain::target_to_average_attempts(target); + w = static_cast(att.GetLow64()); + addr_bytes = get_share_script(obj); + }); + addr_work[addr_bytes] += w; + total_work += w; + } + + if (total_work <= 0) + continue; + + for (auto& [addr, work] : addr_work) + { + double pct = 100.0 * work / total_work; + // Convert scriptPubKey to human-readable address + std::string addr_str = core::script_to_address( + addr, true /*is_litecoin*/, PoolConfig::is_testnet); + if (addr_str.empty()) { + // Fallback: show truncated script hex for non-standard scripts + for (size_t i = 0; i < std::min(addr.size(), size_t(15)); ++i) { + char buf[3]; + snprintf(buf, sizeof(buf), "%02x", addr[i]); + addr_str += buf; + } + addr_str = "script:" + addr_str; + } + if (pct >= CONCENTRATION_ALERT_PCT) + { + LOG_WARNING << "[MONITOR-CONC] ALERT addr=" << addr_str + << " pct=" << pct << "%" + << " window=" << label << "(" << depth << ")"; + ++alerts; + } + else if (pct >= CONCENTRATION_WARN_PCT) + { + LOG_WARNING << "[MONITOR-CONC] WARN addr=" << addr_str + << " pct=" << pct << "%" + << " window=" << label << "(" << depth << ")"; + } + } + + // Every 10th cycle, log top-3 miners + if (cycle_ % 10 == 0) + { + std::vector, double>> sorted_addrs( + addr_work.begin(), addr_work.end()); + std::sort(sorted_addrs.begin(), sorted_addrs.end(), + [](auto& a, auto& b) { return a.second > b.second; }); + + std::ostringstream top3; + int count = 0; + for (auto& [a, w] : sorted_addrs) + { + if (count >= 3) break; + if (count > 0) top3 << " "; + std::string display = core::script_to_address( + a, true, PoolConfig::is_testnet); + if (display.empty()) { + for (size_t i = 0; i < std::min(a.size(), size_t(8)); ++i) { + char buf[3]; snprintf(buf, sizeof(buf), "%02x", a[i]); + display += buf; + } + } + top3 << display << ":" << (100.0 * w / total_work) << "%"; + ++count; + } + LOG_INFO << "[MONITOR-CONC] top3 window=" << label << "(" << depth << ") " << top3.str(); + } + } + return alerts; + } + + // ── Share gap / emergency decay detection ────────────────────── + + int check_share_gap(ShareTracker& tracker, const uint256& best) + { + int alerts = 0; + uint32_t ts = 0; + tracker.chain.get_share(best).invoke([&](auto* obj) { + ts = obj->m_timestamp; + }); + + auto now = static_cast(std::time(nullptr)); + uint32_t gap = (now > ts) ? (now - ts) : 0; + auto emergency_threshold = PoolConfig::share_period() * 20; + + if (gap > emergency_threshold) + { + ++emergency_count_; + LOG_WARNING << "[MONITOR-EMERGENCY] ALERT gap=" << gap + << "s threshold=" << emergency_threshold + << "s emergency_count=" << emergency_count_; + ++alerts; + } + else if (gap > PoolConfig::share_period() * 10) + { + LOG_WARNING << "[MONITOR-EMERGENCY] WARN gap=" << gap + << "s (approaching threshold=" << emergency_threshold << "s)"; + } + return alerts; + } + + // ── Difficulty anomaly detection ─────────────────────────────── + + int check_difficulty(ShareTracker& tracker, const uint256& best, int32_t height) + { + int alerts = 0; + int32_t lookbehind = std::min(height - 1, + static_cast(PoolConfig::TARGET_LOOKBEHIND)); + if (lookbehind < 2) + return 0; + + auto aps = tracker.get_pool_attempts_per_second( + best, lookbehind, /*min_work=*/true); + if (aps.IsNull()) + return 0; + + // expected_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()) + return 0; + uint288 expected_288 = two_256 / divisor; + + // actual_target = max_target from best share + uint256 actual_target; + tracker.chain.get_share(best).invoke([&](auto* obj) { + actual_target = chain::bits_to_target(obj->m_max_bits); + }); + + double expected_d = static_cast(expected_288.GetLow64()); + double actual_d = static_cast(actual_target.GetLow64()); + if (expected_d <= 0) + return 0; + + double ratio = actual_d / expected_d; + + if (ratio > DIFF_ANOMALY_FACTOR) + { + LOG_WARNING << "[MONITOR-DIFF] ALERT target_ratio=" << ratio + << " (actual easier than expected by " << ((ratio - 1) * 100) << "%)"; + ++alerts; + } + else if (ratio < 1.0 / DIFF_ANOMALY_FACTOR) + { + LOG_WARNING << "[MONITOR-DIFF] ALERT target_ratio=" << ratio + << " (actual harder than expected by " << ((1.0 / ratio - 1) * 100) << "%)"; + ++alerts; + } + else if (cycle_ % 10 == 0) + { + LOG_INFO << "[MONITOR-DIFF] ok target_ratio=" << ratio + << " pool=" << fmt_hashrate(static_cast(aps.GetLow64())) << "H/s"; + } + return alerts; + } +}; + +} // namespace btc diff --git a/src/impl/btc/protocol_actual.cpp b/src/impl/btc/protocol_actual.cpp new file mode 100644 index 000000000..9e6917620 --- /dev/null +++ b/src/impl/btc/protocol_actual.cpp @@ -0,0 +1,274 @@ +#include "node.hpp" +#include "share.hpp" + +#include +#include +#include + +#include + +namespace btc +{ + +void Actual::handle_message(std::unique_ptr rmsg, peer_ptr peer) +{ + btc::Handler::result_t result; + try + { + result = m_handler.parse(rmsg); + } catch (const std::exception& ec) + { + LOG_WARNING << "Failed to parse message '" << rmsg->m_command << "' from " + << peer->addr().to_string() << ": " << ec.what(); + return; + } + + try + { + std::visit([&](auto& msg){ handle(std::move(msg), peer); }, result); + } + catch (const std::exception& ec) + { + LOG_WARNING << "Handler error for '" << rmsg->m_command << "' from " + << peer->addr().to_string() << ": " << ec.what(); + return; + } +} + +void Actual::HANDLER(addrs) +{ + for (auto& addr : msg->m_addrs) + { + addr.m_timestamp = std::min((uint64_t) core::timestamp(), addr.m_timestamp); + got_addr(addr.m_endpoint, addr.m_services, addr.m_timestamp); + + if ((core::random::random_float(0, 1) < 0.8) && (!m_connections.empty())) + { + auto wpeer = core::random::random_choice(m_connections); + auto rmsg = message_addrs::make_raw({addr}); + wpeer->write(std::move(rmsg)); + } + } +} + +void Actual::HANDLER(addrme) +{ + if (peer->addr().address() == "127.0.0.1") + { + if (!m_peers.empty() && (core::random::random_float(0, 1) < 0.8)) + { + auto random_peer = core::random::random_choice(m_peers); + auto rmsg = btc::message_addrme::make_raw(msg->m_port); + random_peer->write(std::move(rmsg)); + } + } else + { + auto endpoint = NetService{peer->addr().address(), msg->m_port}; + got_addr(endpoint, peer->m_other_services, core::timestamp()); + if (!m_peers.empty() && (core::random::random_float(0, 1) < 0.8)) + { + auto random_peer = core::random::random_choice(m_peers); + auto rmsg = btc::message_addrs::make_raw({addr_record_t{peer->m_other_services, endpoint, core::timestamp()} }); + random_peer->write(std::move(rmsg)); + } + } +} + +void Actual::HANDLER(ping) +{ +} + +void Actual::HANDLER(getaddrs) +{ + if (msg->m_count > 100) + msg->m_count = 100; + + std::vector addrs; + for (const auto& pair : get_good_peers(msg->m_count)) + { + addrs.push_back({pair.value.m_service, pair.addr, pair.value.m_last_seen}); + } + + auto rmsg = message_addrs::make_raw({addrs}); + peer->write(std::move(rmsg)); +} + +void Actual::HANDLER(shares) +{ + btc::HandleSharesData result; + + for (auto wrappedshare : msg->m_shares) + { + // Save a copy of the original raw bytes before deserialization consumes them + chain::RawShare raw_copy(wrappedshare.type, + BaseScript(wrappedshare.contents.m_data)); + + btc::ShareType share; + try + { + share = btc::load_share(wrappedshare, peer->addr()); + } + catch(const std::exception& e) + { + LOG_WARNING << "Failed to load share (type=" << wrappedshare.type + << ") from " << peer->addr().to_string() << ": " << e.what(); + continue; + } + + std::vector txs; + share.ACTION + ({ + if constexpr (share_t::version >= 13 && share_t::version < 34) + for (auto tx_hash : obj->m_tx_info.m_new_transaction_hashes) + { + auto it = m_known_txs.find(tx_hash); + if (it != m_known_txs.end()) + { + txs.emplace_back(it->second); + } + else + { + LOG_WARNING << "Peer referenced unknown transaction " << tx_hash.ToString(); + } + } + }); + + result.add(share, txs, raw_copy); + } + + processing_shares(result, peer->addr()); +} + +void Actual::HANDLER(sharereq) +{ + auto shares = handle_get_share(msg->m_hashes, msg->m_parents, msg->m_stops, peer->addr()); + + std::vector rshares; + + try + { + for (auto& share : shares) + { + rshares.emplace_back(share.version(), pack(share)); + } + auto reply_msg = message_sharereply::make_raw(msg->m_id, btc::ShareReplyResult::good, rshares); + peer->write(std::move(reply_msg)); + } + catch (const std::invalid_argument &e) + { + auto reply_msg = message_sharereply::make_raw(msg->m_id, btc::ShareReplyResult::too_long, {}); + peer->write(std::move(reply_msg)); + } +} + +void Actual::HANDLER(sharereply) +{ + btc::ShareReplyData result; + if (msg->m_result == ShareReplyResult::good) + { + result.m_items.reserve(msg->m_shares.size()); + result.m_raw_items.reserve(msg->m_shares.size()); + for (auto& rshare : msg->m_shares) + { + try + { + auto share = btc::load_share(rshare, peer->addr()); + result.m_items.push_back(share); + result.m_raw_items.push_back(rshare); + } + catch(const std::exception& e) + { + LOG_WARNING << "Failed to deserialize share (type=" << rshare.type + << ") from " << peer->addr().to_string() << ": " << e.what(); + continue; + } + } + } + got_share_reply(msg->m_id, result); +} + +void Actual::HANDLER(bestblock) +{ + auto header_hash = Hash(pack(msg->m_header).get_span()); + LOG_INFO << "[Pool] New best block from peer " << peer->addr().to_string() + << ": " << header_hash.ToString(); + + // p2pool does NOT relay bestblock — each node broadcasts only from its own + // block source (bitcoind_work.changed → best_block_header.changed). + // Relaying creates an amplification loop: A→B→C→A with alternating hashes. + if (m_on_bestblock) m_on_bestblock(header_hash); +} + +void Actual::HANDLER(have_tx) +{ + peer->m_remote_txs.insert(msg->m_tx_hashes.begin(), msg->m_tx_hashes.end()); + if (peer->m_remote_txs.size() > 10000) + { + peer->m_remote_txs.erase(peer->m_remote_txs.begin(), std::next(peer->m_remote_txs.begin(), peer->m_remote_txs.size() - 10000)); + } +} + +void Actual::HANDLER(losing_tx) +{ + std::set losing_txs; + losing_txs.insert(msg->m_tx_hashes.begin(), msg->m_tx_hashes.end()); + + std::set diff_txs; + std::set_difference(peer->m_remote_txs.begin(), peer->m_remote_txs.end(), + losing_txs.begin(), losing_txs.end(), + std::inserter(diff_txs, diff_txs.begin())); + + peer->m_remote_txs = diff_txs; +} + +void Actual::HANDLER(remember_tx) +{ + // Phase 1: tx_hashes — peer tells us to remember these by hash (must be in known_txs) + for (auto tx_hash : msg->m_tx_hashes) + { + if (peer->m_remembered_txs.contains(tx_hash)) + { + LOG_WARNING << "Peer referenced transaction twice: " << tx_hash.ToString(); + continue; + } + + auto it = m_known_txs.find(tx_hash); + if (it != m_known_txs.end()) + { + peer->m_remembered_txs.insert_or_assign(tx_hash, it->second); + } + else + { + LOG_WARNING << "Peer referenced unknown transaction " << tx_hash.ToString(); + } + } + + // Phase 2: txs — peer sends full transactions (compute hash, store) + for (auto& tx : msg->m_txs) + { + auto packed = pack(coin::TX_WITH_WITNESS(tx)); + auto tx_hash = Hash(packed.get_span()); + + if (peer->m_remembered_txs.contains(tx_hash)) + { + LOG_WARNING << "Peer sent duplicate transaction: " << tx_hash.ToString(); + continue; + } + + coin::Transaction full_tx(tx); + peer->m_remembered_txs.insert_or_assign(tx_hash, full_tx); + + if (!m_known_txs.contains(tx_hash)) + m_known_txs.emplace(tx_hash, std::move(full_tx)); + } +} + +void Actual::HANDLER(forget_tx) +{ + for (auto tx_hash : msg->m_tx_hashes) + { + peer->m_remembered_txs.erase(tx_hash); + } +} + +} // namespace btc diff --git a/src/impl/btc/protocol_legacy.cpp b/src/impl/btc/protocol_legacy.cpp new file mode 100644 index 000000000..3f515efa4 --- /dev/null +++ b/src/impl/btc/protocol_legacy.cpp @@ -0,0 +1,310 @@ +#include "node.hpp" +#include "share.hpp" + +#include +#include +#include + +namespace btc +{ + +void Legacy::handle_message(std::unique_ptr rmsg, peer_ptr peer) +{ + btc::Handler::result_t result; + try + { + result = m_handler.parse(rmsg); + } catch (const std::exception& ec) + { + LOG_WARNING << "Failed to parse message '" << rmsg->m_command << "' from " + << peer->addr().to_string() << ": " << ec.what(); + return; + } + + try + { + std::visit([&](auto& msg){ handle(std::move(msg), peer); }, result); + } + catch (const std::exception& ec) + { + LOG_WARNING << "Handler error for '" << rmsg->m_command << "' from " + << peer->addr().to_string() << ": " << ec.what(); + return; + } +} + +void Legacy::HANDLER(addrs) +{ + for (auto& addr : msg->m_addrs) + { + addr.m_timestamp = std::min((uint64_t) core::timestamp(), addr.m_timestamp); + got_addr(addr.m_endpoint, addr.m_services, addr.m_timestamp); + + if ((core::random::random_float(0, 1) < 0.8) && (!m_connections.empty())) + { + auto wpeer = core::random::random_choice(m_connections); + auto rmsg = message_addrs::make_raw({addr}); + wpeer->write(std::move(rmsg)); + } + } +} + +void Legacy::HANDLER(addrme) +{ + if (peer->addr().address() == "127.0.0.0") + { + if (m_peers.empty() && (core::random::random_float(0, 1) < 0.8)) + { + auto random_peer = core::random::random_choice(m_peers); + auto rmsg = btc::message_addrme::make_raw(msg->m_port); + random_peer->write(std::move(rmsg)); + } + } else + { + auto endpoint = NetService{peer->addr().address(), msg->m_port}; + got_addr(endpoint, peer->m_other_services, core::timestamp()); + if (m_peers.empty() && (core::random::random_float(0, 1) < 0.8)) + { + auto random_peer = core::random::random_choice(m_peers); + auto rmsg = btc::message_addrs::make_raw({addr_record_t{peer->m_other_services, endpoint, core::timestamp()} }); + random_peer->write(std::move(rmsg)); + } + } +} + +void Legacy::HANDLER(ping) +{ +} + +void Legacy::HANDLER(getaddrs) +{ + if (msg->m_count > 100) + msg->m_count = 100; + + std::vector addrs; + for (const auto& pair : get_good_peers(msg->m_count)) + { + addrs.push_back({pair.value.m_service, pair.addr, pair.value.m_last_seen}); + } + + auto rmsg = message_addrs::make_raw({addrs}); + peer->write(std::move(rmsg)); +} + +void Legacy::HANDLER(shares) +{ + try { + btc::HandleSharesData result; //share, txs + + for (auto wrappedshare : msg->m_shares) + { + btc::ShareType share; + try + { + share = btc::load_share(wrappedshare, peer->addr()); + } + catch(const std::exception& e) + { + LOG_WARNING << "Failed to load share (type=" << wrappedshare.type + << ") from " << peer->addr().to_string() << ": " << e.what(); + continue; + } + + std::vector txs; + share.ACTION + ({ + if constexpr (share_t::version >= 13 && share_t::version < 34) + for (auto tx_hash : obj->m_tx_info.m_new_transaction_hashes) + { + auto it = m_known_txs.find(tx_hash); + if (it != m_known_txs.end()) + { + txs.emplace_back(it->second); + } + else + { + LOG_WARNING << "Peer referenced unknown transaction " << tx_hash.ToString(); + } + } + }); + + result.add(share, txs); + } + + processing_shares(result, peer->addr()); + } catch (const std::exception& e) { + LOG_ERROR << "[Pool] shares handler exception: " << e.what(); + } +} + +void Legacy::HANDLER(sharereq) +{ + // Debug: log what's being requested + { + static int dbg_req = 0; + if (dbg_req < 20) + { + ++dbg_req; + std::string req_hashes; + for (auto& h : msg->m_hashes) req_hashes += h.ToString().substr(0, 16) + " "; + LOG_WARNING << "SHAREREQ #" << dbg_req << " from " << peer->addr().to_string() + << " hashes=[" << req_hashes << "] parents=" << msg->m_parents + << " stops=" << msg->m_stops.size(); + } + } + + auto shares = handle_get_share(msg->m_hashes, msg->m_parents, msg->m_stops, peer->addr()); + + // Debug: log what we're returning + { + static int dbg_rep = 0; + if (dbg_rep < 20) + { + ++dbg_rep; + LOG_WARNING << "SHAREREPLY #" << dbg_rep << " returning " << shares.size() << " shares"; + for (size_t i = 0; i < std::min(shares.size(), (size_t)3); ++i) + { + LOG_WARNING << " share[" << i << "] hash=" << shares[i].hash().ToString().substr(0, 16); + } + } + } + + std::vector rshares; + + try + { + for (auto& share : shares) + { + rshares.emplace_back(share.version(), pack(share)); + } + auto reply_msg = message_sharereply::make_raw(msg->m_id, btc::ShareReplyResult::good, rshares); + peer->write(std::move(reply_msg)); + } + catch (const std::invalid_argument &e) + { + // Serialization overflow: the packed shares exceeded the P2P message + // size limit (32 MB). Reply with too_long so the peer requests a + // smaller batch. This is the correct behavior per Python p2pool. + LOG_WARNING << "Share reply too large, sending too_long: " << e.what(); + auto reply_msg = message_sharereply::make_raw(msg->m_id, btc::ShareReplyResult::too_long, {}); + peer->write(std::move(reply_msg)); + } +} + +void Legacy::HANDLER(sharereply) +{ + btc::ShareReplyData result; + if (msg->m_result == ShareReplyResult::good) + { + result.m_items.reserve(msg->m_shares.size()); + result.m_raw_items.reserve(msg->m_shares.size()); + for (auto& rshare : msg->m_shares) + { + try + { + auto share = btc::load_share(rshare, peer->addr()); + result.m_items.push_back(share); + result.m_raw_items.push_back(rshare); + } + catch(const std::exception& e) + { + LOG_WARNING << "Failed to deserialize share (type=" << rshare.type + << ") from " << peer->addr().to_string() << ": " << e.what(); + continue; + } + } + } + // Resolve the async request that originally sent the sharereq + got_share_reply(msg->m_id, result); +} + +void Legacy::HANDLER(bestblock) +{ + try { + auto header_hash = Hash(pack(msg->m_header).get_span()); + LOG_INFO << "[Pool] New best block from peer " << peer->addr().to_string() + << ": " << header_hash.ToString(); + + // p2pool does NOT relay bestblock — each node broadcasts only from its own + // block source (bitcoind_work.changed → best_block_header.changed). + // Relaying creates an amplification loop: A→B→C→A with alternating hashes. + if (m_on_bestblock) m_on_bestblock(header_hash); + } catch (const std::exception& e) { + LOG_WARNING << "[Pool] bestblock handler exception: " << e.what(); + } +} + +void Legacy::HANDLER(have_tx) +{ + peer->m_remote_txs.insert(msg->m_tx_hashes.begin(), msg->m_tx_hashes.end()); + if (peer->m_remote_txs.size() > 10000) + { + peer->m_remote_txs.erase(peer->m_remote_txs.begin(), std::next(peer->m_remote_txs.begin(), peer->m_remote_txs.size() - 10000)); + } +} + +void Legacy::HANDLER(losing_tx) +{ + //remove all msg->txs hashes from remote_tx_hashes + std::set losing_txs; + losing_txs.insert(msg->m_tx_hashes.begin(), msg->m_tx_hashes.end()); + + std::set diff_txs; + std::set_difference(peer->m_remote_txs.begin(), peer->m_remote_txs.end(), + losing_txs.begin(), losing_txs.end(), + std::inserter(diff_txs, diff_txs.begin())); + + peer->m_remote_txs = diff_txs; +} + +void Legacy::HANDLER(remember_tx) +{ + // Phase 1: tx_hashes — peer tells us to remember these by hash (must be in known_txs) + for (auto tx_hash : msg->m_tx_hashes) + { + if (peer->m_remembered_txs.contains(tx_hash)) + { + LOG_WARNING << "Peer referenced transaction twice: " << tx_hash.ToString(); + continue; + } + + auto it = m_known_txs.find(tx_hash); + if (it != m_known_txs.end()) + { + peer->m_remembered_txs.insert_or_assign(tx_hash, it->second); + } + else + { + LOG_WARNING << "Peer referenced unknown transaction " << tx_hash.ToString(); + } + } + + // Phase 2: txs — peer sends full transactions (compute hash, store) + for (auto& tx : msg->m_txs) + { + auto packed = pack(coin::TX_WITH_WITNESS(tx)); + auto tx_hash = Hash(packed.get_span()); + + if (peer->m_remembered_txs.contains(tx_hash)) + { + LOG_WARNING << "Peer sent duplicate transaction: " << tx_hash.ToString(); + continue; + } + + coin::Transaction full_tx(tx); + peer->m_remembered_txs.insert_or_assign(tx_hash, full_tx); + + if (!m_known_txs.contains(tx_hash)) + m_known_txs.emplace(tx_hash, std::move(full_tx)); + } +} + +void Legacy::HANDLER(forget_tx) +{ + for (auto tx_hash : msg->m_tx_hashes) + { + peer->m_remembered_txs.erase(tx_hash); + } +} + +} // namespace btc \ No newline at end of file diff --git a/src/impl/btc/redistribute.hpp b/src/impl/btc/redistribute.hpp new file mode 100644 index 000000000..f85ff153e --- /dev/null +++ b/src/impl/btc/redistribute.hpp @@ -0,0 +1,499 @@ +#pragma once + +// Redistribute V2: advanced redistribution policy for shares from miners +// with empty/invalid/broken stratum credentials. +// +// V1 modes (4): +// pplns : distribute by PPLNS weight proportionally (default) +// fee : 100% to node operator +// boost : give to active stratum miners with ZERO PPLNS shares +// donate : 100% to donation script +// +// V2 enhancements: +// Graduated boost : weight by uptime × pseudoshares × difficulty +// Hybrid mode : --redistribute boost:70,donate:20,fee:10 +// Threshold boost : boost "unlucky" miners with < 10% expected PPLNS weight +// Explicit opt-in : miners signal via stratum password "boost:true" +// +// Consensus-safe: only affects pubkey_hash stamped into shares on this node. +// +// Port of p2pool-v36 work.py --redistribute (commit de76224a) + FUTURE.md V2 spec. + +#include "config_pool.hpp" +#include "share_tracker.hpp" +#include "share_check.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace btc +{ + +enum class RedistributeMode +{ + PPLNS, // distribute by PPLNS weight proportionally (default) + FEE, // 100% to node operator + BOOST, // give to active miners with zero/low PPLNS shares + DONATE // 100% to donation script +}; + +inline RedistributeMode parse_single_mode(const std::string& s) +{ + if (s == "fee") return RedistributeMode::FEE; + if (s == "boost") return RedistributeMode::BOOST; + if (s == "donate") return RedistributeMode::DONATE; + return RedistributeMode::PPLNS; +} + +inline const char* redistribute_mode_str(RedistributeMode m) +{ + switch (m) + { + case RedistributeMode::FEE: return "fee"; + case RedistributeMode::BOOST: return "boost"; + case RedistributeMode::DONATE: return "donate"; + default: return "pplns"; + } +} + +// --- V2: Hybrid mode weight entry --- +struct HybridWeight +{ + RedistributeMode mode; + uint32_t weight; +}; + +// Parse hybrid mode string: "boost:70,donate:20,fee:10" or single "boost" +inline std::vector parse_redistribute_spec(const std::string& spec) +{ + std::vector result; + if (spec.empty()) + { + result.push_back({RedistributeMode::PPLNS, 100}); + return result; + } + + // Check for hybrid format (contains ':') + if (spec.find(':') != std::string::npos) + { + std::istringstream ss(spec); + std::string token; + while (std::getline(ss, token, ',')) + { + auto colon = token.find(':'); + if (colon != std::string::npos) + { + auto mode_str = token.substr(0, colon); + auto weight_str = token.substr(colon + 1); + auto mode = parse_single_mode(mode_str); + uint32_t w = 0; + try { w = static_cast(std::stoul(weight_str)); } catch (...) {} + if (w > 0) + result.push_back({mode, w}); + } + } + } + + // Single mode fallback + if (result.empty()) + result.push_back({parse_single_mode(spec), 100}); + + return result; +} + +// Backward-compatible: returns the primary mode (first/only entry) +inline RedistributeMode parse_redistribute_mode(const std::string& spec) +{ + auto weights = parse_redistribute_spec(spec); + return weights.empty() ? RedistributeMode::PPLNS : weights[0].mode; +} + +// Format hybrid weights for display +inline std::string format_hybrid_weights(const std::vector& weights) +{ + if (weights.size() == 1) + return redistribute_mode_str(weights[0].mode); + std::ostringstream oss; + for (size_t i = 0; i < weights.size(); ++i) { + if (i > 0) oss << ","; + oss << redistribute_mode_str(weights[i].mode) << ":" << weights[i].weight; + } + return oss.str(); +} + +struct RedistributeResult +{ + uint160 pubkey_hash; + uint8_t pubkey_type = 0; // 0=P2PKH, 1=P2WPKH, 2=P2SH +}; + +// --- V2: Graduated boost entry with scoring --- +struct GraduatedMinerInfo +{ + uint160 pubkey_hash; + uint8_t pubkey_type = 0; + double uptime_hours = 0; // connection duration (capped at 24) + uint64_t pseudoshares = 0; // accepted pseudoshare count + double difficulty = 0; // current stratum difficulty + bool opt_in_boost = false; // explicit opt-in via password +}; + +// --- V2: Stratum password options --- +struct StratumPasswordOpts +{ + bool boost = false; // boost:true + double min_diff = 0; // d=N +}; + +inline StratumPasswordOpts parse_stratum_password(const std::string& password) +{ + StratumPasswordOpts opts; + if (password.empty()) return opts; + + std::istringstream ss(password); + std::string token; + while (std::getline(ss, token, ',')) + { + auto eq = token.find(':'); + if (eq == std::string::npos) eq = token.find('='); + if (eq == std::string::npos) continue; + + auto key = token.substr(0, eq); + auto val = token.substr(eq + 1); + + if (key == "boost") + opts.boost = (val == "true" || val == "1" || val == "yes"); + else if (key == "d") + try { opts.min_diff = std::stod(val); } catch (...) {} + } + return opts; +} + +class Redistributor +{ +public: + Redistributor() = default; + + // V1: single mode + void set_mode(RedistributeMode mode) + { + hybrid_weights_.clear(); + hybrid_weights_.push_back({mode, 100}); + } + RedistributeMode mode() const + { + return hybrid_weights_.empty() ? RedistributeMode::PPLNS : hybrid_weights_[0].mode; + } + + // V2: hybrid mode + void set_hybrid_weights(const std::vector& weights) + { + hybrid_weights_ = weights; + } + const std::vector& hybrid_weights() const { return hybrid_weights_; } + + // V2: threshold ratio (default 0.1 = 10% of expected weight) + void set_threshold_ratio(double ratio) { threshold_ratio_ = ratio; } + + // Set the node operator's payout identity (for "fee" mode) + void set_operator_identity(const uint160& hash, uint8_t type) + { + operator_hash_ = hash; + operator_type_ = type; + } + + // Set the donation script identity (for "donate" mode) + void set_donation_identity(const uint160& hash, uint8_t type) + { + donation_hash_ = hash; + donation_type_ = type; + } + + // Pick a (pubkey_hash, pubkey_type) for shares from unnamed/broken miners. + RedistributeResult pick(ShareTracker& tracker, const uint256& best_share_hash) + { + if (hybrid_weights_.empty()) + return {operator_hash_, operator_type_}; + + // V2: Hybrid mode — roll random, pick mode by weight + RedistributeMode selected_mode; + if (hybrid_weights_.size() == 1) + { + selected_mode = hybrid_weights_[0].mode; + } + else + { + uint32_t total_weight = 0; + for (auto& hw : hybrid_weights_) total_weight += hw.weight; + std::uniform_int_distribution dist(0, total_weight - 1); + uint32_t r = dist(rng_); + uint32_t cumul = 0; + selected_mode = hybrid_weights_.back().mode; + for (auto& hw : hybrid_weights_) + { + cumul += hw.weight; + if (r < cumul) { selected_mode = hw.mode; break; } + } + } + + return pick_for_mode(selected_mode, tracker, best_share_hash); + } + + // Register a callback that returns connected miner stats (V2 graduated). + using graduated_miners_fn = std::function()>; + void set_graduated_miners_fn(graduated_miners_fn fn) { graduated_miners_fn_ = std::move(fn); } + + // V1 compat: register simple connected miners callback + using connected_miners_fn = std::function()>; + void set_connected_miners_fn(connected_miners_fn fn) { connected_miners_fn_ = std::move(fn); } + + // V2: pool hashrate callback (for threshold boost) + using pool_hashrate_fn = std::function; + void set_pool_hashrate_fn(pool_hashrate_fn fn) { pool_hashrate_fn_ = std::move(fn); } + +private: + std::vector hybrid_weights_ = {{RedistributeMode::PPLNS, 100}}; + double threshold_ratio_ = 0.1; // 10% of expected weight + uint160 operator_hash_; + uint8_t operator_type_ = 0; + uint160 donation_hash_; + uint8_t donation_type_ = 2; // P2SH for combined donation + std::mt19937 rng_{std::random_device{}()}; + + // PPLNS cache + struct PplnsEntry { std::vector script; uint160 hash; uint8_t type; uint64_t weight; }; + std::vector pplns_cache_; + int64_t pplns_cache_ts_ = 0; + + graduated_miners_fn graduated_miners_fn_; + connected_miners_fn connected_miners_fn_; + pool_hashrate_fn pool_hashrate_fn_; + + RedistributeResult pick_for_mode(RedistributeMode mode, ShareTracker& tracker, const uint256& best) + { + switch (mode) + { + case RedistributeMode::FEE: + return {operator_hash_, operator_type_}; + + case RedistributeMode::DONATE: + return {donation_hash_, donation_type_}; + + case RedistributeMode::BOOST: + return pick_boost(tracker, best); + + case RedistributeMode::PPLNS: + default: + return pick_pplns(tracker, best); + } + } + + // V2: graduated boost with threshold support + RedistributeResult pick_boost(ShareTracker& tracker, const uint256& best) + { + // Try V2 graduated boost first + if (graduated_miners_fn_) + { + auto miners = graduated_miners_fn_(); + refresh_pplns_cache(tracker, best); + + // Build PPLNS address set + weight map + std::set pplns_addrs; + std::map pplns_weight_map; + uint64_t total_pplns_weight = 0; + for (auto& e : pplns_cache_) + { + pplns_addrs.insert(e.hash); + pplns_weight_map[e.hash] = e.weight; + total_pplns_weight += e.weight; + } + + // Score each miner + struct ScoredMiner { RedistributeResult result; double score; }; + std::vector candidates; + + for (auto& m : miners) + { + bool is_zero_share = (pplns_addrs.find(m.pubkey_hash) == pplns_addrs.end()); + bool is_threshold_eligible = false; + + // V2: threshold boost — check luck ratio + if (!is_zero_share && total_pplns_weight > 0 && m.difficulty > 0) + { + double actual_weight = static_cast(pplns_weight_map[m.pubkey_hash]); + double actual_ratio = actual_weight / static_cast(total_pplns_weight); + + // Estimate expected ratio from miner's hashrate + double pool_hr = pool_hashrate_fn_ ? pool_hashrate_fn_() : 0; + if (pool_hr > 0) + { + double miner_hr = m.pseudoshares * m.difficulty / std::max(m.uptime_hours * 3600.0, 1.0); + double expected_ratio = miner_hr / pool_hr; + double luck_ratio = (expected_ratio > 0) ? (actual_ratio / expected_ratio) : 1.0; + is_threshold_eligible = (luck_ratio < threshold_ratio_); + } + } + + // Eligible: zero-share miners, threshold-eligible miners, or opt-in miners + if (is_zero_share || is_threshold_eligible || m.opt_in_boost) + { + // V2: graduated score = uptime × pseudoshares × difficulty + double uptime_w = std::min(m.uptime_hours, 24.0); + double pseudo_w = static_cast(m.pseudoshares + 1); + double diff_w = std::max(m.difficulty, 0.001); + double score = uptime_w * pseudo_w * diff_w; + + // Threshold-eligible miners get inverse-luck bonus + if (is_threshold_eligible && total_pplns_weight > 0) + { + double actual = static_cast(pplns_weight_map[m.pubkey_hash]); + double ratio = actual / static_cast(total_pplns_weight); + score *= 1.0 / std::max(ratio, 0.001); // unluckier = higher score + } + + if (score > 0) + candidates.push_back({{m.pubkey_hash, m.pubkey_type}, score}); + } + } + + if (!candidates.empty()) + { + // Weighted random selection by score + double total_score = 0; + for (auto& c : candidates) total_score += c.score; + std::uniform_real_distribution dist(0, total_score); + double r = dist(rng_); + double cumul = 0; + for (auto& c : candidates) + { + cumul += c.score; + if (r < cumul) + return c.result; + } + return candidates.back().result; + } + } + + // V1 fallback: simple zero-share boost + if (connected_miners_fn_) + { + auto connected = connected_miners_fn_(); + refresh_pplns_cache(tracker, best); + std::set pplns_addrs; + for (auto& e : pplns_cache_) pplns_addrs.insert(e.hash); + + std::vector zero_miners; + for (auto& m : connected) + { + if (pplns_addrs.find(m.pubkey_hash) == pplns_addrs.end()) + zero_miners.push_back(m); + } + if (!zero_miners.empty()) + { + std::uniform_int_distribution dist(0, zero_miners.size() - 1); + return zero_miners[dist(rng_)]; + } + } + + // Final fallback: PPLNS + return pick_pplns(tracker, best); + } + + RedistributeResult pick_pplns(ShareTracker& tracker, const uint256& best) + { + refresh_pplns_cache(tracker, best); + + if (pplns_cache_.empty()) + return {operator_hash_, operator_type_}; + + uint64_t total = 0; + for (auto& e : pplns_cache_) total += e.weight; + if (total == 0) + return {operator_hash_, operator_type_}; + + std::uniform_int_distribution dist(0, total - 1); + uint64_t r = dist(rng_); + uint64_t cumulative = 0; + for (auto& e : pplns_cache_) + { + cumulative += e.weight; + if (r < cumulative) + return {e.hash, e.type}; + } + return {pplns_cache_.back().hash, pplns_cache_.back().type}; + } + + void refresh_pplns_cache(ShareTracker& tracker, const uint256& best) + { + auto now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + if (now - pplns_cache_ts_ < 30 && !pplns_cache_.empty()) + return; + + pplns_cache_.clear(); + pplns_cache_ts_ = now; + + if (best.IsNull()) + return; + + auto [height, last] = tracker.chain.get_height_and_last(best); + int32_t depth = std::min(height, static_cast(PoolConfig::real_chain_length())); + if (depth < 1) + return; + + struct AccumEntry { std::vector script; uint160 hash; uint8_t type; uint64_t weight; }; + std::map, AccumEntry> addr_work; + auto chain_view = tracker.chain.get_chain(best, depth); + for (auto [hash, data] : chain_view) + { + std::vector script; + uint160 pk_hash; + uint8_t pk_type = 0; + uint64_t work = 0; + data.share.invoke([&](auto* obj) { + script = get_share_script(obj); + auto target = chain::bits_to_target(obj->m_bits); + auto att = chain::target_to_average_attempts(target); + work = att.GetLow64(); + if constexpr (requires { obj->m_pubkey_type; }) { + pk_hash = obj->m_pubkey_hash; + pk_type = obj->m_pubkey_type; + } else if constexpr (requires { obj->m_pubkey_hash; }) { + pk_hash = obj->m_pubkey_hash; + pk_type = 0; + } else { + if (script.size() == 25 && script[0] == 0x76 && script[1] == 0xa9) + std::memcpy(pk_hash.data(), script.data() + 3, 20); + else if (script.size() >= 22 && script[0] == 0x00 && script[1] == 0x14) + std::memcpy(pk_hash.data(), script.data() + 2, 20); + else if (script.size() >= 20) + std::memcpy(pk_hash.data(), script.data(), 20); + } + }); + auto& entry = addr_work[script]; + entry.script = script; + entry.hash = pk_hash; + entry.type = pk_type; + entry.weight += work; + } + + pplns_cache_.reserve(addr_work.size()); + for (auto& [_, e] : addr_work) + pplns_cache_.push_back({e.script, e.hash, e.type, e.weight}); + } +}; + +} // namespace btc diff --git a/src/impl/btc/share.hpp b/src/impl/btc/share.hpp new file mode 100644 index 000000000..2b4fcb953 --- /dev/null +++ b/src/impl/btc/share.hpp @@ -0,0 +1,316 @@ +#pragma once + +#include "coin/block.hpp" +#include "share_types.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace btc +{ + +// min_header [small_block_header_type] +// +// share_info_type: +// share_data: +// prev_hash +// coinbase +// nonce +// address[>=34] or pubkey_hash[<34] +// subsidy +// donation +// stale_info +// desired_version +// +// segwit_data [if segwit_activated] +// +// if version < 34: +// new_transaction_hashes +// transaction_hash_refs +// +// far_share_hash +// max_bits +// bits +// timestamp +// absheight +// abswork +// +// ref_merkle_link +// last_txout_nonce +// hash_link +// merkle_link + +template +struct BaseShare : chain::BaseShare +{ + coin::SmallBlockHeaderType m_min_header; + // prev_hash + BaseScript m_coinbase; // coinbase + uint32_t m_nonce; // nonce + // [x]address[>=34] or [x]pubkey_hash[<34] + uint64_t m_subsidy; // subsidy + uint16_t m_donation; // donation + btc::StaleInfo m_stale_info; // stale_info + uint64_t m_desired_version; // desired_version + // + // [x] segwit_data [if segwit_activated] + // + uint256 m_far_share_hash; // far_share_hash + uint32_t m_max_bits; // max_bits; bitcoin_data.FloatingIntegerType() + uint32_t m_bits; // bits; bitcoin_data.FloatingIntegerType() + uint32_t m_timestamp; // timestamp + uint32_t m_absheight; // absheight + uint128 m_abswork; // abswork + + // ref_merkle_link + MerkleLink m_ref_merkle_link; + // last_txout_nonce + uint64_t m_last_txout_nonce; + // hash_link + HashLinkType m_hash_link; + // merkle_link + MerkleLink m_merkle_link; + + NetService peer_addr; // WHERE? + + BaseShare() {} + BaseShare(const uint256& hash, const uint256& prev_hash) : chain::BaseShare(hash, prev_hash) {} + +}; + +struct Share : BaseShare<17> +{ + uint160 m_pubkey_hash; + std::optional m_segwit_data; + btc::ShareTxInfo m_tx_info; // new_transaction_hashes; transaction_hash_refs + + Share() {} + Share(const uint256& hash, const uint256& prev_hash) : BaseShare<17>(hash, prev_hash) {} + +}; + +struct NewShare : BaseShare<33> +{ + uint160 m_pubkey_hash; + std::optional m_segwit_data; + btc::ShareTxInfo m_tx_info; // new_transaction_hashes; transaction_hash_refs + + NewShare() {} + NewShare(const uint256& hash, const uint256& prev_hash) : BaseShare<33>(hash, prev_hash) {} +}; + +namespace types +{ + +struct DataSegwitShare +{ + BaseScript m_address; // Todo (check): VarStrType + std::optional m_segwit_data; +}; + +} // namespace types + +struct SegwitMiningShare : BaseShare<34>, types::DataSegwitShare +{ + SegwitMiningShare() {} + SegwitMiningShare(const uint256& hash, const uint256& prev_hash) : BaseShare<34>(hash, prev_hash) {} +}; + +struct PaddingBugfixShare : BaseShare<35>, types::DataSegwitShare +{ + PaddingBugfixShare() {} + PaddingBugfixShare(const uint256& hash, const uint256& prev_hash) : BaseShare<35>(hash, prev_hash) {} +}; + +struct MergedMiningShare : BaseShare<36> +{ + uint160 m_pubkey_hash; + uint8_t m_pubkey_type{0}; // 0=P2PKH, 1=P2WPKH, 2=P2SH + std::optional m_segwit_data; + std::vector m_merged_addresses; // empty = none + std::vector m_merged_coinbase_info; // empty = none + uint256 m_merged_payout_hash; // zero = none + V36HashLinkType m_hash_link; // shadows BaseShare::m_hash_link + BaseScript m_message_data; // empty = none + + MergedMiningShare() {} + MergedMiningShare(const uint256& hash, const uint256& prev_hash) : BaseShare<36>(hash, prev_hash) {} +}; + +struct Formatter +{ + SHARE_FORMATTER() + { + // small_block_header_type: + READWRITE(obj->m_min_header); + // share_info_type: + READWRITE( + obj->m_prev_hash, + obj->m_coinbase, + obj->m_nonce + ); + + // Address handling — version-dependent + if constexpr (version >= 36) + { + READWRITE(obj->m_pubkey_hash); // IntType(160) + READWRITE(obj->m_pubkey_type); // IntType(8) + } + else if constexpr (version >= 34) + { + READWRITE(obj->m_address); + } + else + { + READWRITE(obj->m_pubkey_hash); // pubkey_hash + } + + // Subsidy — V36 uses VarInt, others use fixed uint64 + if constexpr (version >= 36) + { + READWRITE(VarInt(obj->m_subsidy)); + } + else + { + READWRITE(obj->m_subsidy); + } + + READWRITE( + obj->m_donation, + Using>>(obj->m_stale_info), + VarInt(obj->m_desired_version) + ); + + if constexpr (is_segwit_activated(version)) + { + READWRITE(Optional(obj->m_segwit_data, SegwitDataDefault)); + } + + // V36: merged_addresses (after segwit_data, before far_share_hash) + if constexpr (version >= 36) + { + READWRITE(obj->m_merged_addresses); + } + + if constexpr (version < 34) + { + READWRITE(obj->m_tx_info); + } + + READWRITE( + obj->m_far_share_hash, + obj->m_max_bits, + obj->m_bits, + obj->m_timestamp, + obj->m_absheight + ); + + // Abswork — V36 uses VarInt-encoded uint64, others use fixed uint128 + if constexpr (version >= 36) + { + READWRITE(Using(obj->m_abswork)); + } + else + { + READWRITE(obj->m_abswork); + } + + // V36: merged_coinbase_info + merged_payout_hash (after abswork) + if constexpr (version >= 36) + { + READWRITE(obj->m_merged_coinbase_info); + READWRITE(obj->m_merged_payout_hash); + } + + // ref_merkle_link + READWRITE( + MERKLE_LINK_SMALL(obj->m_ref_merkle_link) + ); + // last_txout_nonce + READWRITE(obj->m_last_txout_nonce); + // hash_link (V36: V36HashLinkType with extra_data; others: HashLinkType) + READWRITE(obj->m_hash_link); + // merkle_link + READWRITE( + MERKLE_LINK_SMALL(obj->m_merkle_link) + ); + + // V36: message_data (at the end) + if constexpr (version >= 36) + { + READWRITE(obj->m_message_data); + } + } +}; + +using ShareType = chain::ShareVariants; + +inline ShareType load_share(chain::RawShare& rshare, NetService peer_addr) +{ + auto stream = rshare.contents.as_stream(); + auto share = ShareType::load(rshare.type, stream); + share.ACTION({ obj->peer_addr = peer_addr; }); + return share; +} + +template +inline ShareType load_share(int64_t version, StreamType& is, NetService peer_addr) +{ + auto share = ShareType::load(version, is); + share.ACTION({ obj->peer_addr = peer_addr; }); + return share; +} + +struct ShareHasher +{ + // this used to call `GetCheapHash()` in uint256, which was later moved; the + // cheap hash function simply calls ReadLE64() however, so the end result is + // identical + size_t operator()(const uint256& hash) const + { + return hash.GetLow64(); + } +}; + +class ShareIndex : public chain::ShareIndex +{ + using base_index = chain::ShareIndex; + +public: + // Per-share fields (NOT accumulated — each share stores only its own values). + // Accumulated values are computed on-the-fly by walking m_shares[tail]. + uint288 work; // target_to_average_attempts(bits) + uint288 min_work; // target_to_average_attempts(max_bits) + + // Per-share metadata + int64_t time_seen{0}; + int32_t naughty{0}; + bool is_block_solution{false}; // pow_hash <= block_target (set during init_verify) + uint256 pow_hash; // SHA256d hash, cached at reception (for block scan) + + ShareIndex() : base_index(), work(0), min_work(0) {} + + template ShareIndex(ShareT* share) : base_index(share) + { + work = chain::target_to_average_attempts(chain::bits_to_target(share->m_bits)); + min_work = chain::target_to_average_attempts(chain::bits_to_target(share->m_max_bits)); + time_seen = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + } +}; + +struct ShareChain : chain::ShareChain +{ + +}; + +} // namespace btc + diff --git a/src/impl/btc/share_check.hpp b/src/impl/btc/share_check.hpp new file mode 100644 index 000000000..51d837b3b --- /dev/null +++ b/src/impl/btc/share_check.hpp @@ -0,0 +1,3279 @@ +#pragma once + +// P2: Share verification — check_hash_link, check_merkle_link, share init/check +// Ported from legacy sharechains/data.cpp + sharechains/share.cpp + +#include "config_pool.hpp" +#include "share.hpp" +#include "share_messages.hpp" +#include "share_types.hpp" + +#include +#include +#include +#include +#include +#include +#include +// (LTC's btclibs/crypto/scrypt.h removed — BTC PoW is SHA256d via core/hash.hpp) +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace btc +{ + +// P2Pool witness nonce: '[P2Pool]' repeated 4 times = 32 bytes +// Used for witness commitment: SHA256d(wtxid_merkle_root || P2POOL_WITNESS_NONCE) +static const unsigned char P2POOL_WITNESS_NONCE[32] = { + 0x5b, 0x50, 0x32, 0x50, 0x6f, 0x6f, 0x6c, 0x5d, + 0x5b, 0x50, 0x32, 0x50, 0x6f, 0x6f, 0x6c, 0x5d, + 0x5b, 0x50, 0x32, 0x50, 0x6f, 0x6f, 0x6c, 0x5d, + 0x5b, 0x50, 0x32, 0x50, 0x6f, 0x6f, 0x6c, 0x5d, +}; + +// Compute P2Pool witness commitment hash from raw wtxid merkle root. +// Returns SHA256d(root || '[P2Pool]'*4) +inline uint256 compute_p2pool_witness_commitment(const uint256& wtxid_merkle_root) { + uint256 nonce; + std::memcpy(nonce.data(), P2POOL_WITNESS_NONCE, 32); + return Hash(wtxid_merkle_root, nonce); +} + +// ============================================================================ +// check_hash_link() +// +// Restores SHA256 mid-state from hash_link, continues hashing with +// (data + extra), then double-SHA256 finalises to the gentx_hash. +// +// Legacy: sharechains/data.cpp check_hash_link() +// ============================================================================ +template +inline uint256 check_hash_link(const HashLinkT& hash_link, + const std::vector& data, + const std::vector& const_ending = {}) +{ + const uint64_t extra_length = hash_link.m_length % 64; // 512/8 = 64 + + // hash_link.extra_data handling: + // Python reference (check_hash_link in p2pool/data.py): + // extra = (extra_data + const_ending)[len(extra_data) + len(const_ending) - extra_length:] + // This takes the LAST extra_length bytes of (extra_data + const_ending), + // i.e. extra_data first, then const_ending tail — matching the original + // byte order in the coinbase prefix (after the full SHA256 blocks). + std::vector extra; + if constexpr (requires { hash_link.m_extra_data.m_data; }) + { + // V36HashLinkType: extra_data is BaseScript (VarStr) + extra.assign(hash_link.m_extra_data.m_data.begin(), + hash_link.m_extra_data.m_data.end()); + if (extra.size() < extra_length) + { + // Append const_ending tail AFTER extra_data (matching Python's order) + auto needed = extra_length - extra.size(); + if (const_ending.size() >= needed) + extra.insert(extra.end(), const_ending.end() - needed, const_ending.end()); + } + } + else + { + // Pre-V36: extra_data always empty, use const_ending tail + extra.assign(const_ending.begin(), const_ending.end()); + if (extra.size() > extra_length) + extra.erase(extra.begin(), extra.begin() + (extra.size() - extra_length)); + } + if (extra.size() != extra_length) + throw std::runtime_error("check_hash_link: extra size mismatch"); + + // Restore SHA256 mid-state from hash_link.m_state (32 bytes, big-endian) + const auto& state_bytes = hash_link.m_state.m_data; + uint32_t init_state[8] = { + ReadBE32(state_bytes.data() + 0), + ReadBE32(state_bytes.data() + 4), + ReadBE32(state_bytes.data() + 8), + ReadBE32(state_bytes.data() + 12), + ReadBE32(state_bytes.data() + 16), + ReadBE32(state_bytes.data() + 20), + ReadBE32(state_bytes.data() + 24), + ReadBE32(state_bytes.data() + 28), + }; + + // Continue hashing from mid-state: Write(data) fills the partial + // block (extra is already in buf via the constructor), then processes + // remaining full blocks. Single SHA256 pass. + unsigned char out1[CSHA256::OUTPUT_SIZE]; + CSHA256(init_state, extra, hash_link.m_length) + .Write(data.data(), data.size()) + .Finalize(out1); + + // Second SHA256 pass → double-SHA256 + unsigned char out2[CSHA256::OUTPUT_SIZE]; + CSHA256().Write(out1, CSHA256::OUTPUT_SIZE).Finalize(out2); + + // Write raw double-SHA256 output directly into uint256 + // (matches Bitcoin Core's Hash() which does the same) + uint256 result; + std::memcpy(result.data(), out2, 32); + return result; +} + +// ============================================================================ +// prefix_to_hash_link() +// +// Forward computation of hash_link: given a coinbase prefix (everything up to +// and including the const_ending), capture the SHA256 mid-state so that +// check_hash_link() can resume and produce the coinbase txid. +// +// Python reference (p2pool): +// def prefix_to_hash_link(prefix, const_ending=''): +// x = sha256(prefix) +// return dict(state=x.state, extra_data=x.buf[:max(0,len(x.buf)-len(const_ending))], +// length=x.length//8) +// ============================================================================ +inline V36HashLinkType prefix_to_hash_link( + const std::vector& prefix, + const std::vector& const_ending) +{ + // Feed the entire prefix into a CSHA256 + CSHA256 hasher; + hasher.Write(prefix.data(), prefix.size()); + + V36HashLinkType result; + + // Extract mid-state as big-endian bytes (matches check_hash_link's ReadBE32) + result.m_state.m_data.resize(32); + for (int i = 0; i < 8; ++i) { + WriteBE32(result.m_state.m_data.data() + i * 4, hasher.s[i]); + } + + // extra_data = buf[0 .. bufsize - const_ending_len] + size_t bufsize = hasher.bytes % 64; + size_t extra_len = (bufsize > const_ending.size()) ? (bufsize - const_ending.size()) : 0; + result.m_extra_data.m_data.assign(hasher.buf, hasher.buf + extra_len); + + // length = total bytes processed so far + result.m_length = hasher.bytes; + + return result; +} + +// prefix_to_hash_link for V35: returns HashLinkType (no extra_data field) +inline HashLinkType prefix_to_hash_link_v35( + const std::vector& prefix, + const std::vector& const_ending) +{ + auto v36_link = prefix_to_hash_link(prefix, const_ending); + HashLinkType result; + result.m_state = v36_link.m_state; + result.m_length = v36_link.m_length; + // V35: extra_data is always empty (FixedStrType(0)) — the donation script + // is long enough to consume the entire SHA256 buffer tail. + return result; +} + +// ============================================================================ +// check_merkle_link() +// +// Walk a Merkle branch to compute the root from a given tip_hash. +// +// Legacy: libcoind/data.cpp check_merkle_link() +// ============================================================================ +inline uint256 check_merkle_link(const uint256& tip_hash, const MerkleLink& link) +{ + if (link.m_branch.size() > 0 && + link.m_index >= (1u << link.m_branch.size())) + throw std::invalid_argument("check_merkle_link: index too large"); + + uint256 cur = tip_hash; + for (size_t i = 0; i < link.m_branch.size(); ++i) + { + // Combine: if bit i of index is set, branch[i] is on the left + PackStream ps; + if ((link.m_index >> i) & 1) + { + ps << link.m_branch[i]; + ps << cur; + } + else + { + ps << cur; + ps << link.m_branch[i]; + } + + // double-SHA256 of the 64-byte concatenation + auto sp = std::span( + reinterpret_cast(ps.data()), ps.size()); + cur = Hash(sp); + } + return cur; +} + +// ============================================================================ +// compute_gentx_before_refhash() +// +// Computes the constant ending bytes that appear after the coinbase outputs +// and before the ref_hash in the serialised coinbase transaction. +// +// Legacy: networks/network.cpp "init gentx_before_refhash" +// Formula: VarStr(DONATION_SCRIPT) + int64(0) + VarStr(0x6a28 + int256(0) + int64(0))[:3] +// ============================================================================ +inline std::vector compute_gentx_before_refhash(int64_t share_version) +{ + std::vector result; + + // 1. VarStr(DONATION_SCRIPT) + auto donation_script = PoolConfig::get_donation_script(share_version); + { + PackStream s; + BaseScript bs; + bs.m_data = donation_script; + s << bs; + auto* p = reinterpret_cast(s.data()); + result.insert(result.end(), p, p + s.size()); + } + + // 2. int64(0) + { + uint64_t zero64 = 0; + auto* p = reinterpret_cast(&zero64); + result.insert(result.end(), p, p + 8); + } + + // 3. VarStr(0x6a 0x28 + int256(0) + int64(0)) — but only the first 3 bytes + { + PackStream inner; + // raw bytes: OP_RETURN (0x6a) + PUSH_40 (0x28) + unsigned char prefix[2] = {0x6a, 0x28}; + inner.write(std::span(reinterpret_cast(prefix), 2)); + // 32 zero bytes (uint256(0)) + uint256 zero256; + inner << zero256; + // 8 zero bytes (uint64(0)) + uint64_t zero64 = 0; + inner.write(std::span(reinterpret_cast(&zero64), 8)); + + // Pack as VarStr + PackStream outer; + BaseScript bs; + bs.m_data.resize(inner.size()); + std::memcpy(bs.m_data.data(), inner.data(), inner.size()); + outer << bs; + + // Take only the first 3 bytes + auto* p = reinterpret_cast(outer.data()); + result.insert(result.end(), p, p + std::min(3, outer.size())); + } + + return result; +} + +// ============================================================================ +// pubkey_hash_to_address() +// +// Convert (pubkey_hash, pubkey_type) to a Litecoin address string. +// Used for V35 share_data.address field (VarStr). +// pubkey_type: 0=P2PKH, 1=P2WPKH, 2=P2SH (same as V36 encoding) +// ============================================================================ +inline std::string pubkey_hash_to_address(const uint160& pubkey_hash, uint8_t pubkey_type) +{ + bool testnet = PoolConfig::is_testnet; + if (pubkey_type == 0) { + // P2PKH: Base58Check with version byte + uint8_t ver = testnet ? 0x6f : 0x30; + std::vector payload(21); + payload[0] = ver; + std::memcpy(payload.data() + 1, pubkey_hash.data(), 20); + return EncodeBase58Check({payload.data(), payload.size()}); + } else if (pubkey_type == 1) { + // P2WPKH: Bech32 segwit v0 — BTC mainnet "bc", testnet "tb" + std::string hrp = testnet ? "tb" : "bc"; + std::vector prog(20); + std::memcpy(prog.data(), pubkey_hash.data(), 20); + return bech32::encode_segwit(hrp, 0, prog); + } else if (pubkey_type == 2) { + // P2SH: Base58Check with version byte + uint8_t ver = testnet ? 0xc4 : 0x32; + std::vector payload(21); + payload[0] = ver; + std::memcpy(payload.data() + 1, pubkey_hash.data(), 20); + return EncodeBase58Check({payload.data(), payload.size()}); + } + // Fallback: P2PKH + uint8_t ver = testnet ? 0x6f : 0x30; + std::vector payload(21); + payload[0] = ver; + std::memcpy(payload.data() + 1, pubkey_hash.data(), 20); + return EncodeBase58Check({payload.data(), payload.size()}); +} + +// ============================================================================ +// compute_ref_hash_for_work() +// +// Computes the p2pool ref_hash for a set of share fields. Used at Stratum +// work generation time to build the OP_RETURN commitment per connection. +// +// Parameters mirror the share fields that feed into the ref_stream. +// Returns (ref_hash, last_txout_nonce). +// ============================================================================ +struct RefHashParams { + int64_t share_version{36}; // 35 or 36 — determines serialization format + uint256 prev_share; + std::vector coinbase_scriptSig; + uint32_t share_nonce{0}; + // V36: pubkey_hash + pubkey_type; V35: address (string bytes) + uint160 pubkey_hash; + uint8_t pubkey_type{0}; + std::string address; // V35: base58/bech32 address string + uint64_t subsidy{0}; + uint16_t donation{50}; + uint8_t stale_info{0}; + uint64_t desired_version{36}; + bool has_segwit{false}; + SegwitData segwit_data; + std::vector merged_addresses; // V36 only + uint256 far_share_hash; + uint32_t max_bits{0}; + uint32_t bits{0}; + uint32_t timestamp{0}; + uint32_t absheight{0}; + uint128 abswork; + std::vector merged_coinbase_info; // V36: per-chain DOGE header + merkle proof + uint256 merged_payout_hash; // V36 only + BaseScript message_data; // V36 PossiblyNoneType(b'', VarStrType()) +}; + +inline std::pair compute_ref_hash_for_work(const RefHashParams& p) +{ + PackStream ref_stream; + + // IDENTIFIER + { + auto hex = PoolConfig::identifier_hex(); + for (size_t i = 0; i + 1 < hex.size(); i += 2) { + unsigned char byte = static_cast( + std::stoul(hex.substr(i, 2), nullptr, 16)); + ref_stream.write(std::span( + reinterpret_cast(&byte), 1)); + } + } + + ref_stream << p.prev_share; + + // coinbase as VarStr + { + BaseScript bs; + bs.m_data = p.coinbase_scriptSig; + ref_stream << bs; + } + + ref_stream << p.share_nonce; + + if (p.share_version >= 36) { + // V36: pubkey_hash (uint160) + pubkey_type (uint8) + ref_stream << p.pubkey_hash; + ref_stream << p.pubkey_type; + ::Serialize(ref_stream, VarInt(p.subsidy)); + } else if (p.share_version >= 34) { + // V34-V35: address as VarStr + BaseScript addr_bs; + addr_bs.m_data.assign(p.address.begin(), p.address.end()); + ref_stream << addr_bs; + ref_stream << p.subsidy; // fixed uint64 + } else { + // Pre-V34: pubkey_hash only + ref_stream << p.pubkey_hash; + ref_stream << p.subsidy; // fixed uint64 + } + + ref_stream << p.donation; + ref_stream << p.stale_info; + ::Serialize(ref_stream, VarInt(p.desired_version)); + + if (p.has_segwit) + ref_stream << p.segwit_data; + + // V36: merged_addresses (after segwit_data, before far_share_hash) + if (p.share_version >= 36) + ref_stream << p.merged_addresses; + + ref_stream << p.far_share_hash; + ref_stream << p.max_bits; + ref_stream << p.bits; + ref_stream << p.timestamp; + ref_stream << p.absheight; + + if (p.share_version >= 36) { + ::Serialize(ref_stream, Using(p.abswork)); + ref_stream << p.merged_coinbase_info; + ref_stream << p.merged_payout_hash; + // V36 ref_type includes message_data as PossiblyNoneType(b'', VarStrType()) + ref_stream << p.message_data; + } else { + // Pre-V36: abswork as fixed uint128 + ref_stream << p.abswork; + } + + auto ref_span = std::span( + reinterpret_cast(ref_stream.data()), ref_stream.size()); + uint256 ref_hash = Hash(ref_span); + + { + static int rfn_log = 0; + static int rfn_v36_log = 0; + bool should_log = (rfn_log < 3) || (p.share_version >= 36 && rfn_v36_log < 5); + if (should_log) { + rfn_log++; + if (p.share_version >= 36) rfn_v36_log++; + static const char* HX = "0123456789abcdef"; + std::string hex; + auto* rd = reinterpret_cast(ref_stream.data()); + for (size_t i = 0; i < ref_stream.size(); ++i) { hex += HX[rd[i]>>4]; hex += HX[rd[i]&0xf]; } + LOG_INFO << "[REF-FN] v=" << p.share_version + << " ref_packed_len=" << ref_stream.size() + << " ref_hash=" << ref_hash.GetHex() + << " absheight=" << p.absheight << " prev=" << p.prev_share.GetHex().substr(0,16); + LOG_INFO << "[REF-FN-FULL] " << hex; + } + } + + // Generate a random-ish last_txout_nonce + uint64_t nonce = static_cast(std::time(nullptr)) ^ + (static_cast(p.timestamp) << 32) ^ + (static_cast(p.absheight) << 16); + + return {ref_hash, nonce}; +} + +// Thread-local state from share_init_verify — read by attempt_verify() without re-computing scrypt. +inline thread_local bool g_last_init_is_block = false; +inline thread_local uint256 g_last_pow_hash; // scrypt hash of the share header + +// ============================================================================ +// share_init_verify() +// +// Performs the init()-phase verification of a share: +// 1. Basic field validation (coinbase size, merkle branch lengths) +// 2. Compute hash_link_data from ref serialisation +// 3. check_hash_link → gentx_hash +// 4. check_merkle_link → merkle_root +// 5. Build full block header +// 6. Compute hash (double-SHA256 of header) +// 7. Verify pow_hash <= target +// +// Returns the share hash (double-SHA256 of the reconstructed header). +// Throws on any validation failure. +// +// NOTE: The full GenerateShareTransaction reconstruction from check() is +// deferred to a later PR (P2.5). This function covers the PoW + hash-link +// verification path from legacy Share::init(). +// ============================================================================ +template +uint256 share_init_verify(const ShareT& share, bool check_pow = true) +{ + // --- Basic validation --- + if (share.m_coinbase.size() < 2 || share.m_coinbase.size() > 100) + throw std::invalid_argument("bad coinbase size"); + + if (share.m_merkle_link.m_branch.size() > 16) + throw std::invalid_argument("merkle branch too long"); + + constexpr int64_t ver = ShareT::version; + + if constexpr (ver >= btc::SEGWIT_ACTIVATION_VERSION) + { + if constexpr (requires { share.m_segwit_data; }) + { + if (share.m_segwit_data.has_value()) + { + if (share.m_segwit_data->m_txid_merkle_link.m_branch.size() > 16) + throw std::invalid_argument("segwit txid merkle branch too long"); + } + } + } + + // --- Compute ref_hash --- + // RefType serialisation: IDENTIFIER + share_info fields + segwit_data + // Then hash256 it, then check_merkle_link with ref_merkle_link + // + // For now we serialise the minimal fields the same way the legacy code + // does (share_data + share_info + optional segwit_data) via a PackStream. + PackStream ref_stream; + + // IDENTIFIER bytes (8 bytes from IDENTIFIER_HEX) + { + auto hex = PoolConfig::identifier_hex(); + for (size_t i = 0; i + 1 < hex.size(); i += 2) + { + unsigned char byte = static_cast( + std::stoul(hex.substr(i, 2), nullptr, 16)); + ref_stream.write(std::span( + reinterpret_cast(&byte), 1)); + } + } + + // share_info serialisation — we re-serialise the share's share_info fields + // through the same Formatter path that was used to decode them. + // For ref_hash we need: share_data + share_info fields + segwit_data + // We serialise the relevant fields into the ref_stream. + { + // prev_hash + ref_stream << share.m_prev_hash; + // coinbase + ref_stream << share.m_coinbase; + // nonce (uint32_t LE) + ref_stream << share.m_nonce; + + // address or pubkey_hash — V34/V35 use m_address (VarStr), + // V36+ uses m_pubkey_hash (uint160) + m_pubkey_type (uint8_t), + // pre-V34 uses m_pubkey_hash only. + if constexpr (requires { share.m_address; }) + ref_stream << share.m_address; + else if constexpr (requires { share.m_pubkey_type; }) + { + ref_stream << share.m_pubkey_hash; + ref_stream << share.m_pubkey_type; + } + else + ref_stream << share.m_pubkey_hash; + + // subsidy: VarInt for V36+, raw uint64_t LE for older + if constexpr (ver >= 36) + ::Serialize(ref_stream, VarInt(share.m_subsidy)); + else + ref_stream << share.m_subsidy; + + ref_stream << share.m_donation; + // stale_info as EnumType> — single byte + { + uint8_t si = static_cast(share.m_stale_info); + ref_stream << si; + } + // desired_version as VarInt + { + uint64_t dv = share.m_desired_version; + ::Serialize(ref_stream, VarInt(dv)); + } + + // segwit_data (optional) + if constexpr (requires { share.m_segwit_data; }) + { + if constexpr (ver >= btc::SEGWIT_ACTIVATION_VERSION) + { + // PossiblyNoneType: ALWAYS serialize (p2pool writes default when None) + if (share.m_segwit_data.has_value()) { + ref_stream << share.m_segwit_data.value(); + } else { + std::vector empty_branch; + ref_stream << empty_branch; + uint256 zero_root; + ref_stream << zero_root; + } + } + } + + // merged_addresses (V36+) + if constexpr (ver >= 36) + { + if constexpr (requires { share.m_merged_addresses; }) + ref_stream << share.m_merged_addresses; + } + + // tx info (pre-v34) + if constexpr (ver < 34) + { + if constexpr (requires { share.m_tx_info; }) + ref_stream << share.m_tx_info; + } + + // far_share_hash, max_bits, bits, timestamp, absheight + ref_stream << share.m_far_share_hash; + ref_stream << share.m_max_bits; + ref_stream << share.m_bits; + ref_stream << share.m_timestamp; + ref_stream << share.m_absheight; + + // abswork: AbsworkV36Format for V36+, raw uint128 LE for older + if constexpr (ver >= 36) + { + if constexpr (requires { share.m_abswork; }) + ::Serialize(ref_stream, Using(share.m_abswork)); + } + else + { + ref_stream << share.m_abswork; + } + + // V36+ merged mining commitment fields + if constexpr (ver >= 36) + { + if constexpr (requires { share.m_merged_coinbase_info; }) + ref_stream << share.m_merged_coinbase_info; + if constexpr (requires { share.m_merged_payout_hash; }) + ref_stream << share.m_merged_payout_hash; + } + } + + // V36 ref_type includes message_data as PossiblyNoneType(b'', VarStrType()) + // When m_message_data is empty, BaseScript serialises as varint(0) = 0x00. + if constexpr (ver >= 36) + { + if constexpr (requires { share.m_message_data; }) + ref_stream << share.m_message_data; + } + + // hash256 of the ref_type serialisation + auto ref_span = std::span( + reinterpret_cast(ref_stream.data()), ref_stream.size()); + uint256 hash_ref = Hash(ref_span); + + // check_merkle_link with ref_merkle_link + uint256 ref_hash = check_merkle_link(hash_ref, share.m_ref_merkle_link); + + // --- Build hash_link_data --- + // hash_link_data = ref_hash bytes + pack(last_txout_nonce, LE64) + pack(0, LE32) + std::vector hash_link_data; + hash_link_data.insert(hash_link_data.end(), ref_hash.data(), ref_hash.data() + 32); + { + // last_txout_nonce as little-endian uint64 + uint64_t nonce = share.m_last_txout_nonce; + auto* p = reinterpret_cast(&nonce); + hash_link_data.insert(hash_link_data.end(), p, p + 8); + } + { + // trailing zero uint32 + uint32_t zero = 0; + auto* p = reinterpret_cast(&zero); + hash_link_data.insert(hash_link_data.end(), p, p + 4); + } + + auto gentx_before_refhash = compute_gentx_before_refhash(ver); + + // --- check_hash_link → gentx_hash --- + uint256 gentx_hash = check_hash_link(share.m_hash_link, hash_link_data, gentx_before_refhash); + + // Diagnostic: compare hash_link-derived gentx_hash with expected + // Only log for self-validation (check_pow=true means local share) + if (check_pow) { + static int verify_diag = 0; + if (verify_diag < 10) { + LOG_INFO << "[verify-diag] gentx_hash(hash_link)=" << gentx_hash.GetHex() + << " hash_link_data_len=" << hash_link_data.size(); + ++verify_diag; + } + } + + // --- Merkle root --- + // For segwit-activated shares, use segwit_data.txid_merkle_link; otherwise merkle_link + uint256 merkle_root; + if constexpr (ver >= btc::SEGWIT_ACTIVATION_VERSION) + { + if constexpr (requires { share.m_segwit_data; }) + { + if (share.m_segwit_data.has_value()) + merkle_root = check_merkle_link(gentx_hash, share.m_segwit_data->m_txid_merkle_link); + else + merkle_root = check_merkle_link(gentx_hash, share.m_merkle_link); + } + else + { + merkle_root = check_merkle_link(gentx_hash, share.m_merkle_link); + } + } + else + { + merkle_root = check_merkle_link(gentx_hash, share.m_merkle_link); + } + + // --- Reconstruct full block header and compute hash --- + // BlockHeaderType: version(int32) + previous_block + merkle_root + timestamp + bits + nonce + // Note: the full block header uses fixed 4-byte version (not VarInt like SmallBlockHeaderType) + PackStream header_stream; + { + uint32_t hdr_version = static_cast(share.m_min_header.m_version); + header_stream << hdr_version; + } + header_stream << share.m_min_header.m_previous_block; + header_stream << merkle_root; + header_stream << share.m_min_header.m_timestamp; + header_stream << share.m_min_header.m_bits; + header_stream << share.m_min_header.m_nonce; + + // hash = double-SHA256 of the header (the share's identity) + auto hdr_span = std::span( + reinterpret_cast(header_stream.data()), header_stream.size()); + uint256 share_hash = Hash(hdr_span); + + // --- PoW check (SHA256d) --- + // For Bitcoin POW_FUNC is SHA256d, identical to the block identity hash. + // (LTC was scrypt(1024,1,1,256); BTC's pow_hash == share_hash via Hash(hdr_span).) + if (check_pow) + { + uint256 target = chain::bits_to_target(share.m_bits); + if (target.IsNull()) + throw std::invalid_argument("share target is zero"); + if (target > PoolConfig::max_target()) + throw std::invalid_argument("share target exceeds MAX_TARGET"); + auto max_target = chain::bits_to_target(share.m_max_bits); + if (!max_target.IsNull() && target > max_target) + throw std::invalid_argument("share target exceeds max_target — too easy"); + + // BTC PoW: SHA256d(header). share_hash was already computed above. + uint256 pow_hash = share_hash; + g_last_pow_hash = pow_hash; // cache for attempt_verify merged check + + if (pow_hash > target) + { + // Expected for stratum pseudoshares: VARDIFF target is much lower + // than share target, so most submissions won't meet PoW. + // Only a real share (1 in ~20000 pseudoshares) passes this check. + LOG_TRACE << "PoW below share target: bits=" << share.m_bits + << " target=" << target.GetHex().substr(0,32) + << " pow_hash=" << pow_hash.GetHex().substr(0,32); + throw std::invalid_argument("share PoW hash does not meet target"); + } + + // Block detection: check if share's scrypt hash also meets the BLOCK target. + // min_header.m_bits = block difficulty from GBT (much harder than share target). + // When pow_hash <= block_target, this share IS a solved block! + uint256 block_target = chain::bits_to_target(share.m_min_header.m_bits); + g_last_init_is_block = false; + if (!block_target.IsNull() && pow_hash <= block_target) { + g_last_init_is_block = true; + static uint256 s_last_block_share; + if (share_hash != s_last_block_share) { + s_last_block_share = share_hash; + LOG_INFO << "[BLOCK] Peer share meets block target" + << " hash=" << share_hash.GetHex().substr(0,16); + } + } + } + + return share_hash; +} + +// ============================================================================ +// Helper: convert pubkey_hash + type to full scriptPubKey +// ============================================================================ +inline std::vector pubkey_hash_to_script(const uint160& hash, uint8_t type = 0) +{ + std::vector script; + auto h = hash.GetChars(); // little-endian (internal storage order) + switch (type) + { + case 1: // P2WPKH: OP_0 <20> + // Use GetChars() output directly — same as P2PKH and P2SH. + // The bytes in m_pubkey_hash are the raw witness program as + // deserialized from the wire. No reversal needed. + script.reserve(22); + script.push_back(0x00); + script.push_back(0x14); + script.insert(script.end(), h.begin(), h.end()); + break; + case 2: // P2SH: OP_HASH160 <20> OP_EQUAL + script.reserve(23); + script.push_back(0xa9); + script.push_back(0x14); + script.insert(script.end(), h.begin(), h.end()); + script.push_back(0x87); + break; + default: // P2PKH: OP_DUP OP_HASH160 <20> OP_EQUALVERIFY OP_CHECKSIG + script.reserve(25); + script.push_back(0x76); + script.push_back(0xa9); + script.push_back(0x14); + script.insert(script.end(), h.begin(), h.end()); + script.push_back(0x88); + script.push_back(0xac); + break; + } + return script; +} + +// ============================================================================ +// Normalize a parent chain script to merged chain P2PKH script. +// +// P2WPKH (00 14 ) → P2PKH (76 a9 14 88 ac) [same pubkey_hash] +// P2PKH (76 a9 14 88 ac) → passed through +// P2SH (a9 14 87) → P2SH (passed through) +// P2WSH (00 20 ) → empty (unconvertible) +// P2TR (51 20 ) → empty (unconvertible) +// +// Returns empty vector for unconvertible scripts (Tier 3: redistributed). +// Matches Python data.py:build_canonical_merged_coinbase() conversion logic. +// ============================================================================ +inline std::vector normalize_script_for_merged( + const std::vector& script) +{ + // P2PKH (25 bytes: 76 a9 14 <20> 88 ac) — already correct + if (script.size() == 25 && script[0] == 0x76 && script[1] == 0xa9 && + script[2] == 0x14 && script[23] == 0x88 && script[24] == 0xac) + return script; + + // P2WPKH (22 bytes: 00 14 <20>) — convert to P2PKH using same hash + if (script.size() == 22 && script[0] == 0x00 && script[1] == 0x14) + { + std::vector p2pkh; + p2pkh.reserve(25); + p2pkh.push_back(0x76); // OP_DUP + p2pkh.push_back(0xa9); // OP_HASH160 + p2pkh.push_back(0x14); // PUSH 20 + p2pkh.insert(p2pkh.end(), script.begin() + 2, script.end()); // + p2pkh.push_back(0x88); // OP_EQUALVERIFY + p2pkh.push_back(0xac); // OP_CHECKSIG + return p2pkh; + } + + // P2SH (23 bytes: a9 14 <20> 87) — pass through (DOGE supports P2SH) + if (script.size() == 23 && script[0] == 0xa9 && script[1] == 0x14 && + script[22] == 0x87) + return script; + + // P2WSH (34 bytes: 00 20 <32>) or P2TR (34 bytes: 51 20 <32>) — unconvertible + return {}; +} + +// MERGED: prefix for weight map keys — matches p2pool's 'MERGED:' + hex string. +// Keeps Tier 1/1.5 (explicit DOGE script) keys separate from raw LTC script keys +// in the same weight map, preventing V35+V36 weight collapse for the same miner. +// 7 bytes: 0x4d 0x45 0x52 0x47 0x45 0x44 0x3a = "MERGED:" +inline constexpr std::array MERGED_KEY_PREFIX = { + 0x4d, 0x45, 0x52, 0x47, 0x45, 0x44, 0x3a +}; + +// Prepend MERGED: prefix to a script for use as a weight map key. +inline std::vector make_merged_key( + const std::vector& script) +{ + std::vector key; + key.reserve(MERGED_KEY_PREFIX.size() + script.size()); + key.insert(key.end(), MERGED_KEY_PREFIX.begin(), MERGED_KEY_PREFIX.end()); + key.insert(key.end(), script.begin(), script.end()); + return key; +} + +// Check if a weight key has the MERGED: prefix. +inline bool is_merged_key(const std::vector& key) +{ + return key.size() > MERGED_KEY_PREFIX.size() && + std::equal(MERGED_KEY_PREFIX.begin(), MERGED_KEY_PREFIX.end(), + key.begin()); +} + +// Strip MERGED: prefix, returning the raw script bytes. +// Caller must check is_merged_key() first. +inline std::vector strip_merged_key( + const std::vector& key) +{ + return std::vector( + key.begin() + MERGED_KEY_PREFIX.size(), key.end()); +} + +// Resolve a weight map key to a DOGE-compatible scriptPubKey. +// MERGED:-prefixed keys: strip prefix, use directly (already a DOGE script). +// Raw keys: autoconvert (P2WPKH→P2PKH, P2PKH/P2SH pass through). +// Returns empty if unconvertible (P2WSH, P2TR, etc.). +inline std::vector resolve_merged_payout_script( + const std::vector& key) +{ + if (is_merged_key(key)) + return strip_merged_key(key); + return normalize_script_for_merged(key); +} + +// ============================================================================ +// Helper: extract full scriptPubKey from a share variant +// ============================================================================ +inline std::vector get_share_script(const auto* obj) +{ + if constexpr (requires { obj->m_pubkey_type; }) + return pubkey_hash_to_script(obj->m_pubkey_hash, obj->m_pubkey_type); + else if constexpr (requires { obj->m_address; }) + { + // V34/V35: m_address contains a human-readable address string. + // Convert to scriptPubKey for PPLNS weight computation. + std::string addr_str(obj->m_address.m_data.begin(), obj->m_address.m_data.end()); + auto script = core::address_to_script(addr_str); + if (!script.empty()) + return script; + // Fallback: return raw bytes (shouldn't happen with valid addresses) + return obj->m_address.m_data; + } + else + return pubkey_hash_to_script(obj->m_pubkey_hash, 0); +} + +// ============================================================================ +// generate_share_transaction() +// +// Reconstructs the expected coinbase transaction from a share's fields and +// the PPLNS weights computed from the share chain. Returns the expected +// gentx txid (double-SHA256 of the non-witness serialised transaction). +// +// This is the C++ port of p2pool v36's generate_transaction() / check(). +// +// The coinbase structure is: +// tx_ins: [ { prev_output: 0...0:ffffffff, script: coinbase } ] +// tx_outs: [ segwit_commitment?, +// ...pplns_payout_outputs..., +// donation_output, +// op_return_commitment ] +// lock_time: 0 +// +// Reference: frstrtr/p2pool-merged-v36 p2pool/data.py generate_transaction() +// ============================================================================ +template +uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker, bool dump_diag = false, bool v36_active = false) +{ + auto gst_t0 = std::chrono::steady_clock::now(); + constexpr int64_t ver = ShareT::version; + // p2pool selects PPLNS formula by runtime AutoRatchet state, not compile-time + // share version. When v36_active is true (AutoRatchet ACTIVATED/CONFIRMED), + // use v36 PPLNS even for v35 shares. Ref: p2pool data.py:879, work.py:759. + const bool use_v36_pplns = v36_active || (ver >= 36); + const uint64_t subsidy = share.m_subsidy; + const uint16_t donation = share.m_donation; + + // Debug: log the PPLNS formula selection for cross-impl comparison + { + static int gst_pplns_log = 0; + if (gst_pplns_log++ % 50 == 0) { + LOG_DEBUG_DIAG << "[GST-PPLNS] v36_active=" << v36_active + << " use_v36_pplns=" << use_v36_pplns + << " ver=" << ver + << " start=" << share.m_prev_hash.GetHex().substr(0, 16) + << " subsidy=" << subsidy + << " donation=" << donation; + } + } + + // --- 1. Compute PPLNS weights with full scriptPubKey keys --- + // Walk from share's prev_hash (parent) backward through the chain. + // This matches the Python: weights are computed relative to the share's parent. + + auto prev_hash = share.m_prev_hash; + std::map, uint288> weights; + uint288 total_weight; + uint288 total_donation_weight; + + if (!prev_hash.IsNull() && tracker.chain.contains(prev_hash)) + { + // p2pool data.py:762-764 — refuse to compute PPLNS with insufficient depth. + // Without this guard, attempt_verify() (which allows CHAIN_LENGTH+1) can + // trigger a PPLNS walk that terminates early, producing wrong coinbase + // amounts and causing persistent GENTX-MISMATCH during bootstrap. + auto chain_len = static_cast(PoolConfig::real_chain_length()); + { + auto pplns_height = tracker.chain.get_height(prev_hash); + auto pplns_last = tracker.chain.get_last(prev_hash); + if (!(pplns_height >= chain_len || pplns_last.IsNull())) + throw std::invalid_argument( + "share chain not long enough for PPLNS verification (height=" + + std::to_string(pplns_height) + " need=" + + std::to_string(chain_len) + ")"); + } + + // block_target from block header bits (matches Python: self.header['bits'].target) + auto block_target = chain::bits_to_target(share.m_min_header.m_bits); + auto max_weight = chain::target_to_average_attempts(block_target) + * PoolConfig::SPREAD * 65535; + + // PPLNS formula selected by runtime v36_active (AutoRatchet state), + // not compile-time share version. Ref: p2pool data.py:879, work.py:759. + if (use_v36_pplns) { + // V36 PPLNS: exponential depth-decay, walk from parent + uint288 unlimited_weight; + unlimited_weight.SetHex("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + auto result = tracker.get_v36_decayed_cumulative_weights(prev_hash, chain_len, unlimited_weight); + weights = std::move(result.weights); + total_weight = result.total_weight; + total_donation_weight = result.total_donation_weight; + } else { + // Pre-V36 PPLNS: flat cumulative weights (no decay) + // CRITICAL: Walk from GRANDPARENT for HEIGHT-1 shares. + // p2pool data.py:884-885: + // _pplns_start = previous_share.share_data['previous_share_hash'] + // _pplns_max_shares = max(0, min(height, REAL_CHAIN_LENGTH) - 1) + uint256 pplns_start; + tracker.chain.get(prev_hash).share.invoke([&](auto* s) { + pplns_start = s->m_prev_hash; // grandparent + }); + auto available = tracker.chain.get_height(prev_hash); + auto walk_count = static_cast( + std::max(0, std::min(chain_len, available) - 1)); + + if (!pplns_start.IsNull() && tracker.chain.contains(pplns_start) && walk_count > 0) { + auto result = tracker.get_cumulative_weights(pplns_start, walk_count, max_weight); + weights = std::move(result.weights); + total_weight = result.total_weight; + total_donation_weight = result.total_donation_weight; + } + } + } + + // --- 2. Convert weights to exact integer payout amounts --- + // Python formula: + // Pre-V36: amounts[script] = subsidy * (199 * weight) / (200 * total_weight) + // amounts[finder] += subsidy // 200 + // V36: amounts[script] = subsidy * weight / total_weight + // donation = subsidy - sum(amounts) + + auto gst_t1 = std::chrono::steady_clock::now(); // after PPLNS walk + std::map, uint64_t> amounts; + + // Periodic dump of PPLNS weights for cross-impl comparison + { + static auto last_amt_dump = std::chrono::steady_clock::now() - std::chrono::seconds(60); + auto now_d = std::chrono::steady_clock::now(); + if (now_d - last_amt_dump > std::chrono::seconds(30) && weights.size() >= 2) { + last_amt_dump = now_d; + LOG_DEBUG_DIAG << "[PPLNS-AMT] subsidy=" << subsidy + << " total_weight=" << total_weight.GetLow64() + << " don_weight=" << total_donation_weight.GetLow64() + << " addrs=" << weights.size() + << " prev=" << prev_hash.GetHex().substr(0, 16); + for (auto& [s, w] : weights) { + uint64_t a = (total_weight.IsNull()) ? 0 : + (uint288(subsidy) * w / total_weight).GetLow64(); + LOG_DEBUG_DIAG << "[PPLNS-AMT] weight=" << w.GetLow64() + << " amount=" << a; + } + } + } + + if (!total_weight.IsNull()) + { + for (auto& [script, weight] : weights) + { + uint64_t amount; + if (use_v36_pplns) + { + // V36: amounts[script] = subsidy * weight / total_weight + uint288 num = uint288(subsidy) * weight; + amount = (num / total_weight).GetLow64(); + } + else + { + // Pre-V36: amounts[script] = subsidy * (199 * weight) / (200 * total_weight) + uint288 num = uint288(subsidy) * (weight * 199); + uint288 den = total_weight * 200; + amount = (num / den).GetLow64(); + } + if (amount > 0) + amounts[script] = amount; + } + } + + // Pre-V36: add 0.5% finder fee to share creator + if (!use_v36_pplns) + { + auto finder_script = get_share_script(&share); + amounts[finder_script] += subsidy / 200; + } + + // Donation output = subsidy minus sum of all payout amounts + uint64_t sum_amounts = 0; + for (auto& [s, a] : amounts) + sum_amounts += a; + uint64_t donation_amount = (subsidy > sum_amounts) ? (subsidy - sum_amounts) : 0; + + // Dump amounts for cross-impl debugging + if (dump_diag) { + LOG_DEBUG_DIAG << "[GST-AMOUNTS] subsidy=" << subsidy << " addrs=" << amounts.size() + << " sum=" << sum_amounts << " donation=" << donation_amount + << " prev=" << prev_hash.GetHex().substr(0,16); + for (auto& [s, a] : amounts) { + static const char* HX = "0123456789abcdef"; + std::string sh; for (size_t i = 0; i < std::min(s.size(), size_t(10)); ++i) { sh += HX[s[i]>>4]; sh += HX[s[i]&0xf]; } + LOG_DEBUG_DIAG << "[GST-AMOUNTS] " << sh << "... = " << a; + } + } + + // V36 consensus: donation output must carry >= 1 satoshi (a60f7f7f) + if (use_v36_pplns) { + if (donation_amount < 1 && subsidy > 0 && !amounts.empty()) { + // Deduct 1 sat from the largest miner payout + // Deterministic tiebreak: (amount, script) — largest script wins when equal + auto largest = std::max_element(amounts.begin(), amounts.end(), + [](const auto& a, const auto& b) { + if (a.second != b.second) return a.second < b.second; + return a.first < b.first; + }); + if (largest != amounts.end() && largest->second > 0) { + largest->second -= 1; + sum_amounts -= 1; + donation_amount = subsidy - sum_amounts; + } + } + } + + // --- 3. Build sorted output list --- + auto gst_t2 = std::chrono::steady_clock::now(); // after amounts + // Python: sorted(dests, key=lambda a: (amounts[a], a))[-4000:] + // = ascending by (amount, script), keep last 4000 (highest amounts) + std::vector, uint64_t>> payout_outputs( + amounts.begin(), amounts.end()); + std::sort(payout_outputs.begin(), payout_outputs.end(), + [](const auto& a, const auto& b) { + if (a.second != b.second) return a.second < b.second; // asc by amount + return a.first < b.first; // asc by script for tie-breaking + }); + + // Keep last MAX_OUTPUTS (highest amounts), matching Python's [-4000:] + constexpr size_t MAX_OUTPUTS = 4000; + if (payout_outputs.size() > MAX_OUTPUTS) + payout_outputs.erase(payout_outputs.begin(), payout_outputs.end() - MAX_OUTPUTS); + + // --- 4. Serialise the coinbase transaction --- + // Non-witness serialization (for txid computation): + // version(4) + vin_count(varint) + vin + vout_count(varint) + vouts + locktime(4) + PackStream tx; + + // tx version = 1 + uint32_t tx_version = 1; + tx.write(std::span(reinterpret_cast(&tx_version), 4)); + + // vin count = 1 + { + unsigned char one = 1; + tx.write(std::span(reinterpret_cast(&one), 1)); + } + + // vin[0]: prev_output = 0...0:ffffffff, script = coinbase, sequence = 0 + { + // prev_hash (32 zero bytes) + uint256 zero_hash; + tx << zero_hash; + // prev_index (0xffffffff) + uint32_t prev_idx = 0xffffffff; + tx.write(std::span(reinterpret_cast(&prev_idx), 4)); + // script (VarStr) + tx << share.m_coinbase; + // sequence (0xffffffff — standard coinbase sequence, matches Python) + uint32_t seq = 0xffffffff; + tx.write(std::span(reinterpret_cast(&seq), 4)); + } + + // Count total outputs + size_t n_outs = payout_outputs.size() + 1 /* donation */ + 1 /* OP_RETURN commitment */; + // Segwit commitment output (if applicable) + bool has_segwit = false; + if constexpr (ver >= btc::SEGWIT_ACTIVATION_VERSION) + { + if constexpr (requires { share.m_segwit_data; }) + { + if (share.m_segwit_data.has_value()) + { + has_segwit = true; + n_outs += 1; + } + } + } + + // vout count (varint — for < 253 outputs, it's a single byte) + if (n_outs < 253) + { + uint8_t cnt = static_cast(n_outs); + tx.write(std::span(reinterpret_cast(&cnt), 1)); + } + else + { + uint8_t marker = 0xfd; + tx.write(std::span(reinterpret_cast(&marker), 1)); + uint16_t cnt = static_cast(n_outs); + tx.write(std::span(reinterpret_cast(&cnt), 2)); + } + + // Helper to write a single tx_out: value(8LE) + script(VarStr) + auto write_txout = [&](uint64_t value, const std::vector& script) { + tx.write(std::span(reinterpret_cast(&value), 8)); + BaseScript bs; + bs.m_data = script; + tx << bs; + }; + + // Segwit commitment output (value=0, script=OP_RETURN + witness_commitment) + if (has_segwit) + { + if constexpr (requires { share.m_segwit_data; }) + { + if (share.m_segwit_data.has_value()) + { + // witness commitment: 0x6a24aa21a9ed + SHA256d(wtxid_merkle_root || '[P2Pool]'*4) + std::vector wscript; + wscript.push_back(0x6a); // OP_RETURN + wscript.push_back(0x24); // PUSH 36 + wscript.push_back(0xaa); + wscript.push_back(0x21); + wscript.push_back(0xa9); + wscript.push_back(0xed); + auto& sd = share.m_segwit_data.value(); + uint256 commitment = compute_p2pool_witness_commitment(sd.m_wtxid_merkle_root); + auto commitment_bytes = commitment.GetChars(); + wscript.insert(wscript.end(), commitment_bytes.begin(), commitment_bytes.end()); + write_txout(0, wscript); + if (dump_diag) { + LOG_INFO << "[WC-GST] wtxid_root=" << sd.m_wtxid_merkle_root.GetHex() + << " commitment=" << commitment.GetHex(); + } + } + } + } + + // PPLNS payout outputs + for (auto& [script, amount] : payout_outputs) + write_txout(amount, script); + + // Donation output — V35 shares always use P2PK DONATION_SCRIPT, + // V36 shares always use P2SH COMBINED_DONATION_SCRIPT. + // Each share was created with the donation script matching its version. + auto donation_script = PoolConfig::get_donation_script(ver); + write_txout(donation_amount, donation_script); + + // OP_RETURN commitment: value=0, script = 0x6a28 + ref_hash(32) + last_txout_nonce(8) + { + // We need the ref_hash — recompute it from the share the same way share_init_verify does + PackStream ref_stream; + + // IDENTIFIER bytes + { + auto hex = PoolConfig::identifier_hex(); + for (size_t i = 0; i + 1 < hex.size(); i += 2) + { + unsigned char byte = static_cast( + std::stoul(hex.substr(i, 2), nullptr, 16)); + ref_stream.write(std::span( + reinterpret_cast(&byte), 1)); + } + } + + // share_info fields (same as share_init_verify) + { + ref_stream << share.m_prev_hash; + ref_stream << share.m_coinbase; + ref_stream << share.m_nonce; + + if constexpr (requires { share.m_address; }) + ref_stream << share.m_address; + else if constexpr (requires { share.m_pubkey_type; }) + { + ref_stream << share.m_pubkey_hash; + ref_stream << share.m_pubkey_type; + } + else + ref_stream << share.m_pubkey_hash; + + if constexpr (ver >= 36) + ::Serialize(ref_stream, VarInt(share.m_subsidy)); + else + ref_stream << share.m_subsidy; + + ref_stream << share.m_donation; + { + uint8_t si = static_cast(share.m_stale_info); + ref_stream << si; + } + ::Serialize(ref_stream, VarInt(share.m_desired_version)); + + if constexpr (requires { share.m_segwit_data; }) + { + if constexpr (ver >= btc::SEGWIT_ACTIVATION_VERSION) + { + // PossiblyNoneType: ALWAYS serialize (p2pool writes default when None). + // Must match share_init_verify — both paths must produce identical ref_hash. + if (share.m_segwit_data.has_value()) { + ref_stream << share.m_segwit_data.value(); + } else { + std::vector empty_branch; + ref_stream << empty_branch; + uint256 zero_root; + ref_stream << zero_root; + } + } + } + + if constexpr (ver >= 36) + { + if constexpr (requires { share.m_merged_addresses; }) + ref_stream << share.m_merged_addresses; + } + + if constexpr (ver < 34) + { + if constexpr (requires { share.m_tx_info; }) + ref_stream << share.m_tx_info; + } + + ref_stream << share.m_far_share_hash; + ref_stream << share.m_max_bits; + ref_stream << share.m_bits; + ref_stream << share.m_timestamp; + ref_stream << share.m_absheight; + + if constexpr (ver >= 36) + { + if constexpr (requires { share.m_abswork; }) + ::Serialize(ref_stream, Using(share.m_abswork)); + } + else + { + ref_stream << share.m_abswork; + } + + if constexpr (ver >= 36) + { + if constexpr (requires { share.m_merged_coinbase_info; }) + ref_stream << share.m_merged_coinbase_info; + if constexpr (requires { share.m_merged_payout_hash; }) + ref_stream << share.m_merged_payout_hash; + } + } + + // V36 ref_type includes message_data (must match verify_share) + if constexpr (ver >= 36) + { + if constexpr (requires { share.m_message_data; }) + ref_stream << share.m_message_data; + } + + auto ref_span = std::span( + reinterpret_cast(ref_stream.data()), ref_stream.size()); + uint256 hash_ref = Hash(ref_span); + uint256 ref_hash = check_merkle_link(hash_ref, share.m_ref_merkle_link); + + // Build OP_RETURN script: 0x6a 0x28 + ref_hash(32) + last_txout_nonce(8) + std::vector op_return_script; + op_return_script.push_back(0x6a); // OP_RETURN + op_return_script.push_back(0x28); // PUSH 40 bytes + op_return_script.insert(op_return_script.end(), ref_hash.data(), ref_hash.data() + 32); + { + uint64_t nonce = share.m_last_txout_nonce; + auto* p = reinterpret_cast(&nonce); + op_return_script.insert(op_return_script.end(), p, p + 8); + } + write_txout(0, op_return_script); + } + + // lock_time = 0 + { + uint32_t locktime = 0; + tx.write(std::span(reinterpret_cast(&locktime), 4)); + } + + // --- 5. Compute txid (double-SHA256 of non-witness serialization) --- + auto tx_span = std::span( + reinterpret_cast(tx.data()), tx.size()); + auto txid = Hash(tx_span); + + // V36 hash_link cross-check: compute prefix hash_link from our coinbase + // and compare with the share's stored hash_link. If states differ, + // the prefix (outputs/amounts) differs from what p2pool built. + if (dump_diag && use_v36_pplns) + { + if constexpr (requires { share.m_hash_link.m_extra_data; }) + { + auto gbr = compute_gentx_before_refhash(ver); + // prefix = full coinbase minus last 44 bytes (ref_hash 32 + nonce 8 + locktime 4) + size_t prefix_len = tx.size() - 44; + std::vector prefix( + reinterpret_cast(tx.data()), + reinterpret_cast(tx.data()) + prefix_len); + auto computed_hl = prefix_to_hash_link(prefix, gbr); + + bool state_match = (computed_hl.m_state.m_data == share.m_hash_link.m_state.m_data); + bool extra_match = (computed_hl.m_extra_data.m_data == share.m_hash_link.m_extra_data.m_data); + bool len_match = (computed_hl.m_length == share.m_hash_link.m_length); + + static const char* HXD = "0123456789abcdef"; + auto hex_fn = [&](const auto& v) { + std::string h; for (auto b : v) { h += HXD[(uint8_t)b >> 4]; h += HXD[(uint8_t)b & 0xf]; } return h; + }; + + LOG_WARNING << "[HASHLINK-CMP] state_match=" << (state_match ? "YES" : "NO") + << " extra_match=" << (extra_match ? "YES" : "NO") + << " len_match=" << (len_match ? "YES" : "NO") + << " c2pool_len=" << computed_hl.m_length + << " share_len=" << share.m_hash_link.m_length; + if (!state_match) { + LOG_WARNING << "[HASHLINK-CMP] c2pool_state=" << hex_fn(computed_hl.m_state.m_data); + LOG_WARNING << "[HASHLINK-CMP] share_state =" << hex_fn(share.m_hash_link.m_state.m_data); + } + if (!extra_match) { + LOG_WARNING << "[HASHLINK-CMP] c2pool_extra=" << hex_fn(computed_hl.m_extra_data.m_data) + << " (" << computed_hl.m_extra_data.m_data.size() << " bytes)"; + LOG_WARNING << "[HASHLINK-CMP] share_extra =" << hex_fn(share.m_hash_link.m_extra_data.m_data) + << " (" << share.m_hash_link.m_extra_data.m_data.size() << " bytes)"; + } + // Also dump prefix length and the last 60 bytes for comparison + LOG_WARNING << "[HASHLINK-CMP] prefix_len=" << prefix_len + << " gbr_len=" << gbr.size() + << " prefix_tail=" << hex_fn(std::vector( + prefix.end() - std::min(prefix.size(), size_t(60)), prefix.end())); + } + } + + // One-time full coinbase hex dump for cross-implementation debugging + { + static int coinbase_dump_count = 0; + if (coinbase_dump_count++ < 3) { + const char* HX = "0123456789abcdef"; + auto to_hex_fn = [&](const unsigned char* p, size_t len) { + std::string h; h.reserve(len * 2); + for (size_t i = 0; i < len; ++i) { h += HX[p[i] >> 4]; h += HX[p[i] & 0xf]; } + return h; + }; + auto* cp = reinterpret_cast(tx.data()); + LOG_INFO << "[COINBASE-HEX] len=" << tx.size() + << " txid=" << txid.GetHex() + << " share=" << share.m_hash.GetHex().substr(0, 16) + << " hex=" << to_hex_fn(cp, tx.size()); + } + } + + if (dump_diag) + { + const char* HX = "0123456789abcdef"; + auto to_hex = [&](const unsigned char* p, size_t len) { + std::string h; h.reserve(len * 2); + for (size_t i = 0; i < len; ++i) { h += HX[p[i] >> 4]; h += HX[p[i] & 0xf]; } + return h; + }; + + auto* cp = reinterpret_cast(tx.data()); + LOG_WARNING << "[GENTX-DIAG] coinbase_len=" << tx.size() << " txid=" << txid.GetHex(); + LOG_WARNING << "[GENTX-DIAG] coinbase_hex=" << to_hex(cp, tx.size()); + LOG_WARNING << "[GENTX-DIAG] pplns_outputs=" << payout_outputs.size() + << " donation_amount=" << donation_amount + << " n_outs=" << n_outs + << " has_segwit=" << has_segwit; + LOG_WARNING << "[GENTX-DIAG] total_weight=" << total_weight.GetHex() + << " total_don_weight=" << total_donation_weight.GetHex(); + for (size_t i = 0; i < payout_outputs.size(); ++i) { + auto& [s, a] = payout_outputs[i]; + LOG_WARNING << "[GENTX-DIAG] payout[" << i << "] amount=" << a + << " script=" << to_hex(s.data(), s.size()); + } + // First 5 + last 5 shares in PPLNS walk (for cross-impl comparison) + if (!share.m_prev_hash.IsNull() && tracker.chain.contains(share.m_prev_hash)) { + auto cl = std::min(tracker.chain.get_height(share.m_prev_hash), + static_cast(PoolConfig::real_chain_length())); + LOG_WARNING << "[GENTX-DIAG] PPLNS walk_count=" << cl + << " total_weight=" << total_weight.GetHex() + << " total_don_weight=" << total_donation_weight.GetHex() + << " n_addrs=" << weights.size(); + // First 5 shares + auto wv = tracker.chain.get_chain(share.m_prev_hash, std::min(cl, int32_t(5))); + int si = 0; + for (auto [h, d] : wv) { + auto hash_copy = h; // Apple Clang: structured bindings can't be captured in lambdas + d.share.invoke([&, hash_copy](auto* obj) { + auto att = chain::target_to_average_attempts(chain::bits_to_target(obj->m_bits)); + auto sc = get_share_script(obj); + LOG_WARNING << "[GENTX-DIAG] walk[" << si << "] hash=" << hash_copy.ToString().substr(0,16) + << " bits=0x" << std::hex << obj->m_bits + << " max_bits=0x" << obj->m_max_bits << std::dec + << " att=" << att.GetHex() + << " don=" << obj->m_donation + << " script=" << to_hex(sc.data(), sc.size()); + }); + ++si; + } + // Last 5 shares in walk (tail of PPLNS window) + if (cl > 10) { + auto full_view = tracker.chain.get_chain(share.m_prev_hash, cl); + int si2 = 0; + for (auto [h, d] : full_view) { + if (si2 >= cl - 5) { + auto hash_copy = h; // Apple Clang: structured bindings can't be captured + d.share.invoke([&, hash_copy](auto* obj) { + auto att = chain::target_to_average_attempts(chain::bits_to_target(obj->m_bits)); + auto sc = get_share_script(obj); + LOG_WARNING << "[GENTX-DIAG] walk_tail[" << si2 << "/" << cl << "] hash=" << hash_copy.ToString().substr(0,16) + << " bits=0x" << std::hex << obj->m_bits + << " max_bits=0x" << obj->m_max_bits << std::dec + << " att=" << att.GetHex() + << " don=" << obj->m_donation + << " script=" << to_hex(sc.data(), sc.size()); + }); + } + ++si2; + } + LOG_WARNING << "[GENTX-DIAG] walk iterated " << si2 << " shares (expected " << cl << ")"; + } + } + } + + { + auto gst_t3 = std::chrono::steady_clock::now(); + static int64_t sp = 0, sa = 0, sc = 0, sn = 0; + sp += std::chrono::duration_cast(gst_t1 - gst_t0).count(); + sa += std::chrono::duration_cast(gst_t2 - gst_t1).count(); + sc += std::chrono::duration_cast(gst_t3 - gst_t2).count(); + ++sn; + if (sn % 50 == 0) + LOG_INFO << "[GST-SPLIT] pplns=" << (sp/sn) << "us amounts=" << (sa/sn) + << "us coinbase=" << (sc/sn) << "us count=" << sn; + } + return txid; +} + +// ============================================================================ +// verify_merged_coinbase_commitment() +// +// Full 7-step chain verification of merged coinbase (Python data.py:329-458). +// Ensures the merged coinbase committed in the share actually matches the +// canonical PPLNS construction. Without this, a node could commit valid +// m_merged_payout_hash (weights match) but build a DOGE coinbase that pays +// differently — the merkle proof would be for the real (malicious) coinbase. +// +// Verification chain: +// 1. Re-derive canonical DOGE coinbase from PPLNS weights +// 2. canonical_txid = hash256(canonical_coinbase) +// 3. check_merkle_link(canonical_txid, coinbase_merkle_link) == header.merkle_root +// 4. hash256(header) == doge_block_hash +// 5. doge_block_hash matches aux_merkle_root in LTC coinbase mm_data +// +// Returns empty string on success, error message on failure. +// ============================================================================ +template +std::string verify_merged_coinbase_commitment( + const ShareT& share, TrackerT& tracker) +{ + if constexpr (ShareT::version < 36) + return {}; + if constexpr (!requires { share.m_merged_coinbase_info; }) + return {}; + + // No merged coinbase info → nothing to verify + if (share.m_merged_coinbase_info.empty()) + return {}; + + // Need enough chain history for reliable PPLNS verification + if (share.m_prev_hash.IsNull() || !tracker.chain.contains(share.m_prev_hash)) + return {}; + auto height = tracker.chain.get_height(share.m_prev_hash); + if (height < static_cast(PoolConfig::real_chain_length())) + return {}; // Insufficient depth — skip (match Python behavior) + + auto block_target = chain::bits_to_target(share.m_bits); + auto max_weight = chain::target_to_average_attempts(block_target) + * 65535 * PoolConfig::SPREAD; + int32_t chain_len = std::min(height, + static_cast(PoolConfig::real_chain_length())); + + // Parse mm_data from LTC coinbase scriptSig + const auto& coinbase = share.m_coinbase.m_data; + static const uint8_t MM_MAGIC[] = {0xfa, 0xbe, 0x6d, 0x6d}; + auto mm_pos = std::search(coinbase.begin(), coinbase.end(), + std::begin(MM_MAGIC), std::end(MM_MAGIC)); + if (mm_pos == coinbase.end()) { + return "merged_coinbase_info present but no mm_data marker in coinbase scriptSig"; + } + size_t mm_offset = std::distance(coinbase.begin(), mm_pos) + 4; + if (coinbase.size() - mm_offset < 40) + return "mm_data too short in coinbase scriptSig"; + + // aux_merkle_root: 32 bytes big-endian + uint256 aux_merkle_root; + { + const uint8_t* p = coinbase.data() + mm_offset; + // MM root is stored big-endian in the coinbase — reverse for internal uint256 + uint8_t* dst = reinterpret_cast(aux_merkle_root.begin()); + for (int i = 31; i >= 0; --i) + dst[i] = *p++; + } + uint32_t aux_size = 0; + { + const uint8_t* p = coinbase.data() + mm_offset + 32; + aux_size = p[0] | (p[1] << 8) | (p[2] << 16) | (p[3] << 24); + } + + // Get finder script for canonical coinbase construction + auto finder_script = get_share_script(&share); + auto finder_merged = normalize_script_for_merged(finder_script); + + for (const auto& info : share.m_merged_coinbase_info) { + uint32_t chain_id = info.m_chain_id; + + // Step 1: Get PPLNS weights for this merged chain + auto mw = tracker.get_merged_cumulative_weights( + share.m_prev_hash, chain_len, max_weight, chain_id); + + if (mw.weights.empty() || mw.total_weight.IsNull()) + continue; // No V36 shares → can't verify + + // Build payout list (same logic as payout_provider) + auto donation_script = PoolConfig::get_donation_script(36); + uint64_t coinbase_value = info.m_coinbase_value; + + // Convert weights to integer payouts + std::map, uint64_t> output_amounts; + uint64_t total_distributed = 0; + double total_d = mw.total_weight.IsNull() ? 0.0 + : static_cast(mw.total_weight.GetLow64()); + for (auto& [key, weight] : mw.weights) { + auto script = resolve_merged_payout_script(key); + if (script.empty()) continue; + double frac = weight.IsNull() ? 0.0 + : static_cast(weight.GetLow64()) / total_d; + uint64_t amount = static_cast(coinbase_value * frac); + if (amount > 0) { + output_amounts[script] += amount; + total_distributed += amount; + } + } + uint64_t donation_amount = coinbase_value - total_distributed; + // Donation >= 1 satoshi + if (donation_amount < 1 && !output_amounts.empty()) { + // Deterministic tiebreak: (amount, script) — largest script wins when equal + auto it = std::max_element(output_amounts.begin(), output_amounts.end(), + [](auto& a, auto& b) { + if (a.second != b.second) return a.second < b.second; + return a.first < b.first; + }); + it->second -= 1; + donation_amount += 1; + } + + // Coinbase reconstruction removed: the merged_payout_hash check + // (Step 1 above) already verifies PPLNS weight correctness via the + // skip list. Reconstructing the full coinbase TX to verify merkle_root + // is redundant and fails on cross-implementation shares because: + // - scriptSig differs ("/c2pool/" vs "/P2Pool/") + // - OP_RETURN text differs + // - THE state_root presence differs + // - Float vs integer rounding in amount calculation + // All of these change txid → merkle_root without affecting PPLNS fairness. + + // Step 2: Verify header structure and extract block hash + if (info.m_block_header.m_data.size() < 80) + return "merged block header too short"; + + // Step 3: hash256(header) == doge_block_hash + auto hdr_span = std::span( + info.m_block_header.m_data.data(), 80); + uint256 doge_block_hash = Hash(hdr_span); + + // Step 6: Verify doge_block_hash against aux_merkle_root + if (aux_size == 1) { + // Single merged chain: aux_merkle_root == block_hash + if (doge_block_hash != aux_merkle_root) { + return "merged block hash " + doge_block_hash.GetHex() + + " != aux_merkle_root " + aux_merkle_root.GetHex() + + " for chain " + std::to_string(chain_id); + } + } + // Multi-chain: would need aux tree reconstruction (future) + } + + return {}; +} + +// ============================================================================ +// share_check() +// +// The check()-phase verification after init: +// 1. Timestamp not too far in the future +// 2. Version counting (stub — version upgrade enforcement) +// 3. Transaction hash resolution (for pre-v34 shares) +// 4. GenerateShareTransaction reconstruction & comparison +// 5. Merged payout hash + coinbase commitment verification +// +// Returns true if the share passes all checks. +// Throws on validation failure. +// ============================================================================ +template +bool share_check(const ShareT& share, + const uint256& share_hash, + const uint256& gentx_hash, + TrackerT& tracker) +{ + // 1. Timestamp check — must not be more than 600s in the future + auto now = static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); + if (share.m_timestamp > now + 600) + throw std::invalid_argument("share timestamp is too far in the future"); + + // 2. Version counting — AutoRatchet upgrade enforcement + // p2pool data.py:1400-1414: version switch validation only at BOUNDARIES + // (when share.VERSION != parent.VERSION). Shares that match their parent's + // version are always valid — they were correct when created. + // Previous code rejected ALL v35 shares retroactively after 95% activation. + { + auto lookbehind = static_cast(PoolConfig::chain_length()); + if (tracker.chain.contains(share.m_hash) && + !share.m_prev_hash.IsNull() && tracker.chain.contains(share.m_prev_hash)) + { + // Get parent's share version + int64_t parent_version = 0; + tracker.chain.get_share(share.m_prev_hash).invoke([&](auto* obj) { + parent_version = std::remove_pointer_t::version; + }); + + // Only enforce version obsolescence at version BOUNDARIES + if (share.version != parent_version) + { + auto height = tracker.chain.get_height(share.m_hash); + if (height >= lookbehind) + { + if (tracker.should_punish_version(share.m_hash, share.version, lookbehind)) + throw std::invalid_argument("share version too old — newer version has 95%+ activation"); + } + } + } + } + + // 3. GenerateShareTransaction reconstruction & comparison + // Rebuild the expected coinbase from PPLNS weights and share fields, + // then verify its txid matches the gentx_hash from share_init_verify(). + // p2pool check(): v36_active = (self.VERSION >= 36) + // Use the SHARE's own version, not the tracker's runtime AutoRatchet state. + // This ensures V35 shares always verify with V35 PPLNS formula, even after + // the AutoRatchet transitions to ACTIVATED. + constexpr int64_t share_ver = ShareT::version; + bool v36_active = (share_ver >= 36); + if (!share.m_prev_hash.IsNull() && tracker.chain.contains(share.m_prev_hash)) + { + uint256 expected_gentx = generate_share_transaction(share, tracker, false, v36_active); + if (expected_gentx != gentx_hash) + { + LOG_WARNING << "GENTX-MISMATCH detail:" + << " share=" << share_hash.ToString().substr(0,16) + << " ver=" << share.version + << " subsidy=" << share.m_subsidy + << " donation=" << share.m_donation + << " prev=" << share.m_prev_hash.ToString().substr(0,16); + LOG_WARNING << " expected_gentx=" << expected_gentx.ToString().substr(0,32) + << " actual_gentx=" << gentx_hash.ToString().substr(0,32); + + auto chain_len = std::min( + tracker.chain.get_height(share.m_prev_hash), + static_cast(PoolConfig::real_chain_length())); + // V35: log grandparent + height-1 window info + uint256 gp_hash; + if (tracker.chain.contains(share.m_prev_hash)) + tracker.chain.get(share.m_prev_hash).share.invoke([&](auto* s){ gp_hash = s->m_prev_hash; }); + int32_t v35_walk = std::max(0, chain_len - 1); + LOG_WARNING << " PPLNS chain_len=" << chain_len + << " v35_walk=" << v35_walk + << " grandparent=" << (gp_hash.IsNull() ? "null" : gp_hash.GetHex().substr(0,16)) + << " prev_height=" << tracker.chain.get_height(share.m_prev_hash) + << " real_chain_length=" << PoolConfig::real_chain_length(); + + // Compare share target: what c2pool computes vs what the share has + { + static int target_diag = 0; + if (target_diag++ < 10) { + auto [cst_max_bits, cst_bits] = tracker.compute_share_target( + share.m_prev_hash, share.m_timestamp, uint256()); + auto share_aps = tracker.get_pool_attempts_per_second( + share.m_prev_hash, PoolConfig::TARGET_LOOKBEHIND, true); + LOG_WARNING << "[GENTX-TARGET] share_bits=0x" << std::hex << share.m_bits + << " share_max_bits=0x" << share.m_max_bits + << " c2pool_bits=0x" << cst_bits + << " c2pool_max_bits=0x" << cst_max_bits << std::dec + << " aps_min=" << share_aps.GetLow64() + << " prev=" << share.m_prev_hash.GetHex().substr(0,16); + + // APS walk component dump for cross-impl comparison + int32_t dist = PoolConfig::TARGET_LOOKBEHIND; + auto near_hash = share.m_prev_hash; + auto far_hash = near_hash; + int32_t actual_dist = 0; + for (int32_t i = 0; i < dist - 1; ++i) { + if (far_hash.IsNull() || !tracker.chain.contains(far_hash)) break; + auto* idx = tracker.chain.get_index(far_hash); + far_hash = idx ? idx->tail : uint256(); + ++actual_dist; + } + uint32_t near_ts = 0, far_ts = 0; + if (tracker.chain.contains(near_hash)) + tracker.chain.get_share(near_hash).invoke([&](auto* s){ near_ts = s->m_timestamp; }); + if (!far_hash.IsNull() && tracker.chain.contains(far_hash)) + tracker.chain.get_share(far_hash).invoke([&](auto* s){ far_ts = s->m_timestamp; }); + + // Compare skip-list far vs naive-walk far + auto skip_far = tracker.chain.get_nth_parent_via_skip( + share.m_prev_hash, dist - 1); + bool skip_match = (!far_hash.IsNull() && skip_far == far_hash); + + // Also get delta via TrackerView for comparison + uint288 delta_min_work; + int32_t delta_height = 0; + if (!skip_far.IsNull() && tracker.chain.contains(skip_far)) { + auto dv = tracker.chain.get_delta(share.m_prev_hash, skip_far); + delta_min_work = dv.min_work; + delta_height = dv.height; + } + + LOG_WARNING << "[GENTX-APS] near=" << near_hash.GetHex().substr(0,16) + << " far_naive=" << (far_hash.IsNull() ? "null" : far_hash.GetHex().substr(0,16)) + << " far_skip=" << (skip_far.IsNull() ? "null" : skip_far.GetHex().substr(0,16)) + << " skip_match=" << (skip_match ? "YES" : "NO") + << " actual_dist=" << actual_dist + << " expected_dist=" << (dist - 1) + << " timespan=" << (int32_t(near_ts) - int32_t(far_ts)) + << " near_ts=" << near_ts << " far_ts=" << far_ts + << " chain_height=" << tracker.chain.get_height(share.m_prev_hash) + << " delta_h=" << delta_height + << " delta_min_work=" << delta_min_work.GetLow64(); + + // Previous share's max_target (clamp reference) + uint256 prev_max_target; + tracker.chain.get_share(share.m_prev_hash).invoke([&](auto* obj) { + prev_max_target = chain::bits_to_target(obj->m_max_bits); + }); + LOG_WARNING << "[GENTX-CLAMP] prev_max_bits=0x" << std::hex + << chain::target_to_bits_upper_bound(prev_max_target) << std::dec + << " clamp_lo=" << chain::target_to_bits_upper_bound(prev_max_target * 9 / 10) + << " clamp_hi=" << chain::target_to_bits_upper_bound(prev_max_target * 11 / 10); + } + } + // --- Detailed diagnostics: re-run with full dump --- + static int s_diag_count = 0; + if (s_diag_count++ < 5) + { + LOG_WARNING << "[GENTX-DIAG] Re-running generate_share_transaction with full dump (v36_active=" << v36_active << "):"; + generate_share_transaction(share, tracker, true, v36_active); + + // Per-share PPLNS walk dump — compare with p2pool's [PARENT-PPLNS] output. + // Uses same parameters as generate_share_transaction's V36 path. + if (v36_active && !share.m_prev_hash.IsNull()) { + auto diag_chain_len = static_cast(PoolConfig::real_chain_length()); + LOG_WARNING << "[GENTX-DIAG] Per-share V36 PPLNS walk from prev=" + << share.m_prev_hash.GetHex().substr(0, 16) << ":"; + tracker.dump_v36_pplns_walk(share.m_prev_hash, diag_chain_len); + } + } + + throw std::invalid_argument("GenerateShareTransaction mismatch — coinbase does not match PPLNS payouts"); + } + } + + // 4. V36+ merged_payout_hash verification + // Verify that the share's committed merged PPLNS hash matches what we + // independently compute from the share chain. Without this, a malicious + // node could steal all merged chain (DOGE) rewards while appearing honest + // on the parent chain (LTC payouts are consensus-enforced via gentx above). + if constexpr (ShareT::version >= 36) + { + if constexpr (requires { share.m_merged_payout_hash; }) + { + if (!share.m_merged_payout_hash.IsNull() && + !share.m_prev_hash.IsNull() && + tracker.chain.contains(share.m_prev_hash)) + { + // Use BLOCK target (from header bits), not share target. + // p2pool: block_target = self.header['bits'].target + auto block_target = chain::bits_to_target(share.m_min_header.m_bits); + auto expected_hash = tracker.compute_merged_payout_hash( + share.m_prev_hash, block_target); + + if (!expected_hash.IsNull() && share.m_merged_payout_hash != expected_hash) + { + LOG_WARNING << "merged_payout_hash REJECT: claimed " + << share.m_merged_payout_hash.GetHex() + << " != expected " << expected_hash.GetHex(); + throw std::invalid_argument( + "merged_payout_hash mismatch — merged chain reward theft attempt"); + } + } + } + } + + // 5. V36+ merged coinbase commitment verification (7-step chain) + // Verifies the actual merged coinbase matches canonical PPLNS construction. + if constexpr (ShareT::version >= 36) + { + auto mcv_err = verify_merged_coinbase_commitment(share, tracker); + if (!mcv_err.empty()) + throw std::invalid_argument("merged coinbase verification: " + mcv_err); + } + + return true; +} + +// ============================================================================ +// verify_share() +// +// Combined entry point: runs both init-phase and check-phase verification. +// Returns the computed share hash. +// ============================================================================ +template +uint256 verify_share(const ShareT& share, TrackerT& tracker) +{ + auto vt0 = std::chrono::steady_clock::now(); + // share_init_verify computes gentx_hash along the way — we need it + // for the GenerateShareTransaction comparison in share_check. + // Skip scrypt PoW re-check when hash was already computed in Phase 1 + // (processing_shares offloads scrypt to m_verify_pool; no need to repeat). + uint256 hash = share_init_verify(share, share.m_hash.IsNull()); + auto vt1 = std::chrono::steady_clock::now(); + + // Verify recomputed hash matches stored hash (informational). + // For locally created shares the hash was set during create_local_share; + // a mismatch means the header reconstruction diverged (e.g., genesis PPLNS + // race). The share_check phase uses share.m_hash for chain lookups. + if (!share.m_hash.IsNull() && hash != share.m_hash) { + static int hash_mismatch_log = 0; + if (hash_mismatch_log++ < 10) + LOG_WARNING << "[verify_share] hash mismatch: recomputed=" + << hash.GetHex().substr(0, 16) + << " stored=" << share.m_hash.GetHex().substr(0, 16); + } + + // Re-derive gentx_hash for the check phase + constexpr int64_t ver = ShareT::version; + auto gentx_before_refhash = compute_gentx_before_refhash(ver); + + // Rebuild ref_hash + hash_link_data the same way init does + PackStream ref_stream; + { + auto hex = PoolConfig::identifier_hex(); + for (size_t i = 0; i + 1 < hex.size(); i += 2) + { + unsigned char byte = static_cast( + std::stoul(hex.substr(i, 2), nullptr, 16)); + ref_stream.write(std::span( + reinterpret_cast(&byte), 1)); + } + } + { + ref_stream << share.m_prev_hash; + ref_stream << share.m_coinbase; + ref_stream << share.m_nonce; + + if constexpr (requires { share.m_address; }) + ref_stream << share.m_address; + else if constexpr (requires { share.m_pubkey_type; }) + { + ref_stream << share.m_pubkey_hash; + ref_stream << share.m_pubkey_type; + } + else + ref_stream << share.m_pubkey_hash; + + if constexpr (ver >= 36) + ::Serialize(ref_stream, VarInt(share.m_subsidy)); + else + ref_stream << share.m_subsidy; + + ref_stream << share.m_donation; + { + uint8_t si = static_cast(share.m_stale_info); + ref_stream << si; + } + ::Serialize(ref_stream, VarInt(share.m_desired_version)); + + if constexpr (requires { share.m_segwit_data; }) + { + if constexpr (ver >= btc::SEGWIT_ACTIVATION_VERSION) + { + // PossiblyNoneType: ALWAYS serialize (p2pool writes default when None) + if (share.m_segwit_data.has_value()) { + ref_stream << share.m_segwit_data.value(); + } else { + std::vector empty_branch; + ref_stream << empty_branch; + uint256 zero_root; + ref_stream << zero_root; + } + } + } + + if constexpr (ver >= 36) + { + if constexpr (requires { share.m_merged_addresses; }) + ref_stream << share.m_merged_addresses; + } + + if constexpr (ver < 34) + { + if constexpr (requires { share.m_tx_info; }) + ref_stream << share.m_tx_info; + } + + ref_stream << share.m_far_share_hash; + ref_stream << share.m_max_bits; + ref_stream << share.m_bits; + ref_stream << share.m_timestamp; + ref_stream << share.m_absheight; + + if constexpr (ver >= 36) + { + if constexpr (requires { share.m_abswork; }) + ::Serialize(ref_stream, Using(share.m_abswork)); + } + else + { + ref_stream << share.m_abswork; + } + + if constexpr (ver >= 36) + { + if constexpr (requires { share.m_merged_coinbase_info; }) + ref_stream << share.m_merged_coinbase_info; + if constexpr (requires { share.m_merged_payout_hash; }) + ref_stream << share.m_merged_payout_hash; + } + } + + // V36 ref_type includes message_data + if constexpr (ver >= 36) + { + if constexpr (requires { share.m_message_data; }) + ref_stream << share.m_message_data; + } + + auto ref_span = std::span( + reinterpret_cast(ref_stream.data()), ref_stream.size()); + uint256 hash_ref = Hash(ref_span); + + std::vector hash_link_data; + { + uint256 ref_hash = check_merkle_link(hash_ref, share.m_ref_merkle_link); + hash_link_data.insert(hash_link_data.end(), ref_hash.data(), ref_hash.data() + 32); + uint64_t nonce = share.m_last_txout_nonce; + auto* p = reinterpret_cast(&nonce); + hash_link_data.insert(hash_link_data.end(), p, p + 8); + uint32_t zero = 0; + auto* z = reinterpret_cast(&zero); + hash_link_data.insert(hash_link_data.end(), z, z + 4); + } + + uint256 gentx_hash = check_hash_link(share.m_hash_link, hash_link_data, gentx_before_refhash); + + // V36+: Validate message_data (reject shares with invalid encrypted messages) + if constexpr (ver >= 36) + { + if constexpr (requires { share.m_message_data; }) + { + auto err = validate_message_data(share.m_message_data.m_data); + if (!err.empty()) + throw std::invalid_argument("share " + err); + } + } + + share_check(share, hash, gentx_hash, tracker); + { + auto vt2 = std::chrono::steady_clock::now(); + auto init_us = std::chrono::duration_cast(vt1 - vt0).count(); + auto check_us = std::chrono::duration_cast(vt2 - vt1).count(); + static int64_t s_init = 0, s_check = 0, s_cnt = 0; + s_init += init_us; s_check += check_us; ++s_cnt; + if (s_cnt % 50 == 0) + LOG_INFO << "[VERIFY-SPLIT] init_avg=" << (s_init/s_cnt) + << "us check_avg=" << (s_check/s_cnt) << "us count=" << s_cnt; + } + return hash; +} + +// ============================================================================ +// create_local_share_v35() +// +// Constructs a PaddingBugfixShare (V35) from locally-generated block data. +// This is the V35 counterpart of create_local_share (V36). +// Key differences from V36: +// - Uses m_address (string) instead of m_pubkey_hash + m_pubkey_type +// - No merged_addresses, merged_coinbase_info, merged_payout_hash +// - Fixed uint64 subsidy (not VarInt) +// - Fixed uint128 abswork (not VarInt) +// - HashLinkType (no extra_data) instead of V36HashLinkType +// - DONATION_SCRIPT (P2PK 67b) instead of COMBINED_DONATION_SCRIPT (P2SH 23b) +// ============================================================================ +template +uint256 create_local_share_v35( + TrackerT& tracker, + const coin::SmallBlockHeaderType& min_header, + const BaseScript& coinbase, + uint64_t subsidy, + const uint256& prev_share, + const std::vector& merkle_branches, + const std::vector& payout_script, + uint16_t donation = 50, + StaleInfo stale_info = StaleInfo::none, + bool segwit_active = false, + const std::string& witness_commitment_hex = {}, + const std::vector& actual_coinbase_bytes = {}, + const uint256& witness_root = uint256(), + uint32_t override_max_bits = 0, + uint32_t override_bits = 0, + uint32_t frozen_absheight = 0, + uint128 frozen_abswork = uint128(), + uint256 frozen_far_share_hash = uint256(), + uint32_t frozen_timestamp = 0, + bool has_frozen = false, + const std::vector& frozen_merkle_branches = {}, + const uint256& frozen_witness_root = uint256(), + uint64_t desired_version = 36) +{ + PaddingBugfixShare share; + share.m_min_header = min_header; + share.m_coinbase = coinbase; + share.m_subsidy = subsidy; + share.m_prev_hash = prev_share; + share.m_donation = donation; + share.m_stale_info = stale_info; + share.m_desired_version = desired_version; + + // Timestamp: clip to at least previous_share.timestamp + 1 + share.m_timestamp = min_header.m_timestamp; + if (!prev_share.IsNull() && tracker.chain.contains(prev_share)) { + uint32_t prev_ts = 0; + tracker.chain.get(prev_share).share.invoke([&](auto* prev) { + prev_ts = prev->m_timestamp; + }); + if (share.m_timestamp <= prev_ts) + share.m_timestamp = prev_ts + 1; + } + + // Compute share target + auto desired_target = chain::bits_to_target(min_header.m_bits); + auto [share_max_bits, share_bits] = tracker.compute_share_target( + prev_share, share.m_timestamp, desired_target); + share.m_max_bits = share_max_bits; + share.m_bits = share_bits; + share.m_nonce = 0; + + // V35: address as string (VarStr), convert from payout_script + { + uint160 pubkey_hash; + uint8_t pubkey_type = 0; + if (payout_script.size() == 25 && payout_script[0] == 0x76) { + std::memcpy(pubkey_hash.data(), payout_script.data() + 3, 20); + pubkey_type = 0; + } else if (payout_script.size() == 23 && payout_script[0] == 0xa9) { + std::memcpy(pubkey_hash.data(), payout_script.data() + 2, 20); + pubkey_type = 2; + } else if (payout_script.size() == 22 && payout_script[0] == 0x00) { + std::memcpy(pubkey_hash.data(), payout_script.data() + 2, 20); + pubkey_type = 1; + } else if (payout_script.size() >= 20) { + std::memcpy(pubkey_hash.data(), payout_script.data(), 20); + } + std::string addr_str = pubkey_hash_to_address(pubkey_hash, pubkey_type); + share.m_address.m_data.assign(addr_str.begin(), addr_str.end()); + { + auto roundtrip = core::address_to_script(addr_str); + static const char* H = "0123456789abcdef"; + std::string ps_hex, rt_hex; + for (auto b : payout_script) { ps_hex += H[b>>4]; ps_hex += H[b&0xf]; } + for (auto b : roundtrip) { rt_hex += H[b>>4]; rt_hex += H[b&0xf]; } + LOG_INFO << "[V35-ADDR] type=" << (int)pubkey_type + << " addr=" << addr_str + << " payout_script=" << ps_hex + << " roundtrip=" << rt_hex + << " match=" << (payout_script == roundtrip ? "YES" : "NO"); + } + } + + // Chain position: absheight and abswork + if (!prev_share.IsNull() && tracker.chain.contains(prev_share)) { + tracker.chain.get(prev_share).share.invoke([&](auto* prev) { + share.m_absheight = prev->m_absheight + 1; + }); + { + auto current_attempts = chain::target_to_average_attempts( + chain::bits_to_target(share.m_bits)); + uint128 prev_abswork; + tracker.chain.get(prev_share).share.invoke([&](auto* prev) { + prev_abswork = prev->m_abswork; + }); + share.m_abswork = prev_abswork + uint128(current_attempts.GetLow64()); + } + // far_share_hash: 99th ancestor + { + auto [prev_height, last] = tracker.chain.get_height_and_last(prev_share); + if (last.IsNull() && prev_height < 99) { + share.m_far_share_hash = uint256(); + } else { + try { + share.m_far_share_hash = tracker.chain.get_nth_parent_key(prev_share, 99); + } catch (const std::exception&) { + share.m_far_share_hash = uint256(); + } + } + } + } else { + share.m_absheight = 1; + share.m_abswork = uint128(chain::target_to_average_attempts( + chain::bits_to_target(share.m_bits)).GetLow64()); + share.m_far_share_hash = uint256(); + } + + // Apply frozen fields from template time + if (has_frozen) { + share.m_absheight = frozen_absheight; + share.m_abswork = frozen_abswork; + share.m_far_share_hash = frozen_far_share_hash; + share.m_timestamp = frozen_timestamp; + if (override_max_bits) share.m_max_bits = override_max_bits; + if (override_bits) share.m_bits = override_bits; + } + + // Random last_txout_nonce + share.m_last_txout_nonce = static_cast(std::time(nullptr)) ^ + (static_cast(min_header.m_nonce) << 32); + + // ref_merkle_link: empty + share.m_ref_merkle_link.m_branch.clear(); + share.m_ref_merkle_link.m_index = 0; + // merkle_link: from Stratum + share.m_merkle_link.m_branch = merkle_branches; + share.m_merkle_link.m_index = 0; + + // Segwit data + if (segwit_active && !witness_commitment_hex.empty()) + { + SegwitData sd; + sd.m_txid_merkle_link.m_branch = (has_frozen && !frozen_merkle_branches.empty()) + ? frozen_merkle_branches : merkle_branches; + sd.m_txid_merkle_link.m_index = 0; + // Priority: frozen > direct. The zero root (0x00..00) is VALID for + // coinbase-only blocks — p2pool uses it to compute the OP_RETURN + // witness commitment. Never treat IsNull() as "not set" here. + sd.m_wtxid_merkle_root = !frozen_witness_root.IsNull() + ? frozen_witness_root : witness_root; + share.m_segwit_data = sd; + } + + // --- Compute ref_hash (V35 format) --- + PackStream ref_stream; + { + auto hex = PoolConfig::identifier_hex(); + for (size_t i = 0; i + 1 < hex.size(); i += 2) { + unsigned char byte = static_cast( + std::stoul(hex.substr(i, 2), nullptr, 16)); + ref_stream.write(std::span( + reinterpret_cast(&byte), 1)); + } + } + ref_stream << share.m_prev_hash; + ref_stream << share.m_coinbase; + ref_stream << share.m_nonce; + // V35: address as VarStr + ref_stream << share.m_address; + // V35: subsidy as fixed uint64 + ref_stream << share.m_subsidy; + ref_stream << share.m_donation; + { uint8_t si = static_cast(share.m_stale_info); ref_stream << si; } + ::Serialize(ref_stream, VarInt(share.m_desired_version)); + // segwit_data + if (share.m_segwit_data.has_value()) { + ref_stream << share.m_segwit_data.value(); + } else { + std::vector empty_branch; + ref_stream << empty_branch; + uint256 zero_root; + ref_stream << zero_root; + } + // V35: NO merged_addresses + ref_stream << share.m_far_share_hash; + ref_stream << share.m_max_bits; + ref_stream << share.m_bits; + ref_stream << share.m_timestamp; + ref_stream << share.m_absheight; + // V35: abswork as fixed uint128 + ref_stream << share.m_abswork; + // V35: NO merged_coinbase_info, NO merged_payout_hash, NO message_data + + auto ref_span_v = std::span( + reinterpret_cast(ref_stream.data()), ref_stream.size()); + uint256 hash_ref = Hash(ref_span_v); + uint256 ref_hash = check_merkle_link(hash_ref, share.m_ref_merkle_link); + + // Use ref_hash from actual coinbase if available + if (!actual_coinbase_bytes.empty() && actual_coinbase_bytes.size() > 44) { + uint256 coinbase_ref_hash; + std::memcpy(coinbase_ref_hash.data(), + actual_coinbase_bytes.data() + actual_coinbase_bytes.size() - 44, 32); + ref_hash = coinbase_ref_hash; + } + + // --- Derive hash_link (V35: HashLinkType, no extra_data) --- + auto gentx_before_refhash = compute_gentx_before_refhash(int64_t(35)); + + std::vector coinbase_bytes_for_hashlink; + if (!actual_coinbase_bytes.empty()) { + coinbase_bytes_for_hashlink = actual_coinbase_bytes; + } else { + // Fallback: reconstruct coinbase from V35 PPLNS walk + // V35: flat weights, grandparent start, height-1 window, 199/200 formula, finder fee + // Reference: p2pool data.py lines 878-965 + std::map, uint288> weights; + uint288 total_weight; + + if (!prev_share.IsNull() && tracker.chain.contains(prev_share)) + { + // V35: start from grandparent (prev_share.prev_hash) + uint256 pplns_start; + tracker.chain.get(prev_share).share.invoke([&](auto* s) { + pplns_start = s->m_prev_hash; + }); + + if (!pplns_start.IsNull()) { + auto height = tracker.chain.get_height(prev_share); + int32_t max_shares = std::max(0, std::min(height, + static_cast(PoolConfig::real_chain_length())) - 1); + auto block_target = chain::bits_to_target(share.m_min_header.m_bits); + auto desired_weight = chain::target_to_average_attempts(block_target) + * uint288(PoolConfig::SPREAD) * uint288(65535); + // Flat weight accumulation (not decayed) + auto result = tracker.get_cumulative_weights(pplns_start, max_shares, desired_weight); + weights = std::move(result.weights); + total_weight = result.total_weight; + } + } + + std::map, uint64_t> amounts; + if (!total_weight.IsNull()) { + for (auto& [script, weight] : weights) { + // V35: 99.5% to PPLNS — subsidy * 199 * weight / (200 * total_weight) + uint64_t amount = (uint288(subsidy) * uint288(199) * weight + / (uint288(200) * total_weight)).GetLow64(); + if (amount > 0) amounts[script] = amount; + } + } + // V35: add 0.5% finder fee to the share creator's payout script + amounts[payout_script] = (amounts.count(payout_script) ? amounts[payout_script] : 0) + + subsidy / 200; + uint64_t sum_amounts = 0; + for (auto& [s, a] : amounts) sum_amounts += a; + // V35: no minimum donation enforcement (unlike v36) + uint64_t donation_amount = (subsidy > sum_amounts) ? (subsidy - sum_amounts) : 0; + + std::vector, uint64_t>> payout_outputs( + amounts.begin(), amounts.end()); + std::sort(payout_outputs.begin(), payout_outputs.end(), + [](const auto& a, const auto& b) { + if (a.second != b.second) return a.second < b.second; + return a.first < b.first; + }); + if (payout_outputs.size() > 4000) + payout_outputs.erase(payout_outputs.begin(), payout_outputs.end() - 4000); + + PackStream gentx; + { uint32_t v = 1; gentx.write(std::span(reinterpret_cast(&v), 4)); } + { unsigned char one = 1; gentx.write(std::span(reinterpret_cast(&one), 1)); } + { uint256 z; gentx << z; } + { uint32_t idx = 0xffffffff; gentx.write(std::span(reinterpret_cast(&idx), 4)); } + gentx << share.m_coinbase; + { uint32_t seq = 0xffffffff; gentx.write(std::span(reinterpret_cast(&seq), 4)); } + size_t n_outs = payout_outputs.size() + 1 + 1; + bool has_segwit_fb = share.m_segwit_data.has_value(); + if (has_segwit_fb) n_outs += 1; + if (n_outs < 253) { uint8_t cnt = (uint8_t)n_outs; gentx.write(std::span(reinterpret_cast(&cnt), 1)); } + else { uint8_t m = 0xfd; gentx.write(std::span(reinterpret_cast(&m), 1)); uint16_t cnt = (uint16_t)n_outs; gentx.write(std::span(reinterpret_cast(&cnt), 2)); } + auto write_txout = [&](uint64_t value, const std::vector& script) { + gentx.write(std::span(reinterpret_cast(&value), 8)); + BaseScript bs; bs.m_data = script; gentx << bs; + }; + if (has_segwit_fb) { + auto& sd = share.m_segwit_data.value(); + std::vector wscript = {0x6a, 0x24, 0xaa, 0x21, 0xa9, 0xed}; + uint256 commitment = compute_p2pool_witness_commitment(sd.m_wtxid_merkle_root); + auto cb = commitment.GetChars(); + wscript.insert(wscript.end(), cb.begin(), cb.end()); + write_txout(0, wscript); + } + for (auto& [script, amount] : payout_outputs) write_txout(amount, script); + // V35: use pre-V36 DONATION_SCRIPT + write_txout(donation_amount, PoolConfig::get_donation_script(int64_t(35))); + { std::vector op; op.push_back(0x6a); op.push_back(0x28); + op.insert(op.end(), ref_hash.data(), ref_hash.data() + 32); + uint64_t n = share.m_last_txout_nonce; auto* p = reinterpret_cast(&n); + op.insert(op.end(), p, p + 8); write_txout(0, op); } + { uint32_t lt = 0; gentx.write(std::span(reinterpret_cast(<), 4)); } + + coinbase_bytes_for_hashlink.assign( + reinterpret_cast(gentx.data()), + reinterpret_cast(gentx.data()) + gentx.size()); + } + + // Compute hash_link (V35: HashLinkType) + constexpr size_t suffix_len = 32 + 8 + 4; + if (coinbase_bytes_for_hashlink.size() > suffix_len) { + std::vector prefix( + coinbase_bytes_for_hashlink.begin(), coinbase_bytes_for_hashlink.end() - suffix_len); + share.m_hash_link = prefix_to_hash_link_v35(prefix, gentx_before_refhash); + + size_t nonce_offset = coinbase_bytes_for_hashlink.size() - 4 - 8; + uint64_t extracted_nonce = 0; + std::memcpy(&extracted_nonce, coinbase_bytes_for_hashlink.data() + nonce_offset, 8); + share.m_last_txout_nonce = extracted_nonce; + } + + // --- Compute share hash (block header double-SHA256) --- + PackStream header_stream; + { uint32_t v = static_cast(min_header.m_version); + header_stream << v; } + header_stream << min_header.m_previous_block; + + uint256 gentx_hash_for_header; + if (!actual_coinbase_bytes.empty()) { + auto actual_span = std::span( + actual_coinbase_bytes.data(), actual_coinbase_bytes.size()); + gentx_hash_for_header = Hash(actual_span); + } else { + auto cb_span = std::span( + coinbase_bytes_for_hashlink.data(), coinbase_bytes_for_hashlink.size()); + gentx_hash_for_header = Hash(cb_span); + } + uint256 merkle_root = check_merkle_link(gentx_hash_for_header, share.m_merkle_link); + + header_stream << merkle_root; + header_stream << min_header.m_timestamp; + header_stream << min_header.m_bits; + header_stream << min_header.m_nonce; + + auto hdr_span = std::span( + reinterpret_cast(header_stream.data()), header_stream.size()); + + uint256 share_hash = Hash(hdr_span); + share.m_hash = share_hash; + + // PoW check against share target — BTC pow_hash = SHA256d(header) = share_hash. + { + uint256 target = chain::bits_to_target(share.m_bits); + if (!target.IsNull()) { + uint256 pow_hash = share_hash; + + if (pow_hash > target) { + // Diagnostic so the silent-ZERO return path is visible. + // Bitaxe-class miners @ ~1.7 TH/s hit this on virtually + // every submission against BTC mainnet sharechain + // difficulty (~2e8), so without a log it looks like + // create_local_share is "broken" when in fact the share + // simply doesn't clear the actual share target. + static std::atomic miss_diag{0}; + if (miss_diag.fetch_add(1) < 20) { + LOG_INFO << "[V35-SHARE-MISS] pow=" << pow_hash.GetHex().substr(0, 16) + << " target=" << target.GetHex().substr(0, 16) + << " bits=0x" << std::hex << share.m_bits << std::dec + << " diff_share=" << chain::target_to_difficulty(target) + << " diff_pow=" << chain::target_to_difficulty(pow_hash) + << " absheight=" << share.m_absheight; + } + return uint256(); // didn't meet share target + } + LOG_INFO << "[Pool] V35 SHARE CREATED! pow=" << pow_hash.GetHex().substr(0, 16) + << " target=" << target.GetHex().substr(0, 16) + << " diff=" << chain::target_to_difficulty(target); + } + } + + // Add to tracker + auto* heap_share = new PaddingBugfixShare(share); + tracker.add(heap_share); + LOG_INFO << "create_local_share_v35: added share " << share_hash.GetHex() + << " height=" << share.m_absheight + << " prev=" << prev_share.GetHex().substr(0, 16) << "..."; + + return share_hash; +} + +// ============================================================================ +// create_local_share() +// +// Constructs a share (V35 PaddingBugfixShare or V36 MergedMiningShare) from +// locally-generated block data and adds it to the share tracker. +// Returns the share hash (block header double-SHA256). +// +// Parameters: +// tracker — the ShareTracker to insert the new share into +// min_header — parsed SmallBlockHeaderType from the found block +// coinbase — the p2pool coinbase scriptSig (BIP34 height + pool marker) +// subsidy — block reward (coinbasevalue) +// prev_share — previous best share hash from the tracker +// merkle_branches — Stratum merkle branches (coinbase txid → merkle root) +// payout_script — finder's scriptPubKey +// donation — donation bps (e.g. 50 = 0.5%) +// merged_addrs — optional merged mining addresses +// share_version — 35 or 36 (default 36) +// +// This builds the p2pool coinbase in the same format as +// generate_share_transaction() and computes the hash_link so remote peers +// can verify the share. +// ============================================================================ +template +uint256 create_local_share( + TrackerT& tracker, + const coin::SmallBlockHeaderType& min_header, + const BaseScript& coinbase, + uint64_t subsidy, + const uint256& prev_share, + const std::vector& merkle_branches, + const std::vector& payout_script, + uint16_t donation = 50, + const std::vector& merged_addrs = {}, + StaleInfo stale_info = StaleInfo::none, + bool segwit_active = false, + const std::string& witness_commitment_hex = {}, + const std::vector& message_data = {}, + const std::vector& actual_coinbase_bytes = {}, + const uint256& witness_root = uint256(), + uint32_t override_max_bits = 0, + uint32_t override_bits = 0, + // Frozen share fields from template time — when set, override computed values + // to ensure ref_hash matches the one embedded in the coinbase. + uint32_t frozen_absheight = 0, + uint128 frozen_abswork = uint128(), + uint256 frozen_far_share_hash = uint256(), + uint32_t frozen_timestamp = 0, + uint256 frozen_merged_payout_hash = uint256(), + bool has_frozen = false, + const std::vector& frozen_merkle_branches = {}, + const uint256& frozen_witness_root = uint256(), + const std::vector& frozen_merged_coinbase_info = {}, + int64_t share_version = 35, + uint64_t desired_version = 35) +{ + // V35 path: delegate to version-specific implementation + if (share_version <= 35) + return create_local_share_v35( + tracker, min_header, coinbase, subsidy, prev_share, merkle_branches, + payout_script, donation, stale_info, segwit_active, witness_commitment_hex, + actual_coinbase_bytes, witness_root, override_max_bits, override_bits, + frozen_absheight, frozen_abswork, frozen_far_share_hash, frozen_timestamp, + has_frozen, frozen_merkle_branches, frozen_witness_root, desired_version); + + MergedMiningShare share; + share.m_min_header = min_header; + share.m_coinbase = coinbase; + share.m_subsidy = subsidy; + share.m_prev_hash = prev_share; + share.m_donation = donation; + share.m_stale_info = stale_info; + share.m_desired_version = desired_version; + + // Timestamp: clip to at least previous_share.timestamp + 1 (matches Python) + share.m_timestamp = min_header.m_timestamp; + if (!prev_share.IsNull() && tracker.chain.contains(prev_share)) { + uint32_t prev_ts = 0; + tracker.chain.get(prev_share).share.invoke([&](auto* prev) { + prev_ts = prev->m_timestamp; + }); + if (share.m_timestamp <= prev_ts) + share.m_timestamp = prev_ts + 1; + } + + // Compute pool-level share target AFTER timestamp clipping (matches ref_hash_fn). + auto desired_target = chain::bits_to_target(min_header.m_bits); + auto [share_max_bits, share_bits] = tracker.compute_share_target( + prev_share, share.m_timestamp, desired_target); + share.m_max_bits = share_max_bits; + share.m_bits = share_bits; + + share.m_nonce = 0; // share commitment nonce (not block nonce) + share.m_merged_addresses = merged_addrs; + + // Diagnostic: confirm merged_addresses are populated for DOGE skiplist Tier 1 + { + LOG_INFO << "[create_local_share] merged_addresses=" << merged_addrs.size(); + for (const auto& entry : merged_addrs) { + auto to_hex = [](const std::vector& s) { + static const char* H = "0123456789abcdef"; + std::string r; for (auto b : s) { r += H[b>>4]; r += H[b&0xf]; } return r; + }; + LOG_INFO << "[create_local_share] chain_id=" << entry.m_chain_id + << " script=" << to_hex(entry.m_script.m_data); + } + } + + // Embed encrypted message_data (from create_message_data()) if provided + if (!message_data.empty()) + share.m_message_data.m_data = message_data; + + // Compute merged_payout_hash: deterministic hash of V36-only PPLNS + // weight distribution so peers can verify merged mining payouts. + if (!prev_share.IsNull() && tracker.chain.contains(prev_share)) + { + auto block_target = chain::bits_to_target(min_header.m_bits); + share.m_merged_payout_hash = tracker.compute_merged_payout_hash( + prev_share, block_target); + } + + // Payout identity — extract pubkey_hash + pubkey_type from scriptPubKey. + // Must match p2pool V36: 0=P2PKH, 1=P2WPKH, 2=P2SH. + if (payout_script.size() >= 20) { + if (payout_script.size() == 25 && + payout_script[0] == 0x76 && payout_script[1] == 0xa9 && + payout_script[2] == 0x14 && payout_script[23] == 0x88 && + payout_script[24] == 0xac) { + // P2PKH: 76 a9 14 88 ac + std::memcpy(share.m_pubkey_hash.data(), payout_script.data() + 3, 20); + share.m_pubkey_type = 0; + } else if (payout_script.size() == 23 && + payout_script[0] == 0xa9 && payout_script[1] == 0x14 && + payout_script[22] == 0x87) { + // P2SH: a9 14 87 + std::memcpy(share.m_pubkey_hash.data(), payout_script.data() + 2, 20); + share.m_pubkey_type = 2; + } else if (payout_script.size() == 22 && + payout_script[0] == 0x00 && payout_script[1] == 0x14) { + // P2WPKH: 00 14 + // P2WPKH: store raw witness program bytes directly. + // No reversal — c2pool uses raw bytes throughout, unlike p2pool's + // IntType(160) LE integer convention. The wire serialization of + // uint160 preserves the byte order from memcpy. + std::memcpy(share.m_pubkey_hash.data(), payout_script.data() + 2, 20); + share.m_pubkey_type = 1; + } else { + // Fallback: store first 20 bytes as P2PKH + std::memcpy(share.m_pubkey_hash.data(), payout_script.data(), 20); + share.m_pubkey_type = 0; + } + } + + // Chain position: absheight and abswork from previous share + if (!prev_share.IsNull() && tracker.chain.contains(prev_share)) { + auto [prev_height, last] = tracker.chain.get_height_and_last(prev_share); + tracker.chain.get(prev_share).share.invoke([&](auto* prev) { + share.m_absheight = prev->m_absheight + 1; + }); + + // abswork: prev_abswork + target_to_average_attempts(THIS share's bits) + // Python: abswork = (prev.abswork + target_to_average_attempts(bits.target)) % 2^128 + { + auto current_attempts = chain::target_to_average_attempts( + chain::bits_to_target(share.m_bits)); + uint128 prev_abswork; + tracker.chain.get(prev_share).share.invoke([&](auto* prev) { + prev_abswork = prev->m_abswork; + }); + share.m_abswork = prev_abswork + uint128(current_attempts.GetLow64()); + } + + // far_share_hash: 99th ancestor (matches Python: get_nth_parent_hash(prev_hash, 99)) + if (last.IsNull() && prev_height < 99) { + // Chain is complete and shorter than 99 → None (zero) + share.m_far_share_hash = uint256(); + } else { + share.m_far_share_hash = tracker.chain.get_nth_parent_key(prev_share, 99); + } + } else { + // Genesis: p2pool always does (prev_absheight + 1), (prev_abswork + aps) + // With prev=None: absheight = 0 + 1 = 1, abswork = 0 + aps(bits) + share.m_absheight = 1; + share.m_abswork = uint128(chain::target_to_average_attempts( + chain::bits_to_target(share.m_bits)).GetLow64()); + share.m_far_share_hash = uint256(); + } + + // Override with frozen fields from template time (if available). + // These must match what ref_hash_fn computed when building the coinbase. + if (has_frozen) { + LOG_INFO << "[frozen] Applying: absheight=" << frozen_absheight + << " bits=" << std::hex << override_bits + << " max_bits=" << override_max_bits << std::dec + << " ts=" << frozen_timestamp; + share.m_absheight = frozen_absheight; + share.m_abswork = frozen_abswork; + share.m_far_share_hash = frozen_far_share_hash; + share.m_timestamp = frozen_timestamp; + share.m_merged_payout_hash = frozen_merged_payout_hash; + if (override_max_bits) share.m_max_bits = override_max_bits; + if (override_bits) share.m_bits = override_bits; + // Deserialize frozen merged_coinbase_info blob → vector + if (!frozen_merged_coinbase_info.empty()) { + try { + PackStream ps; + ps.write(std::span( + reinterpret_cast(frozen_merged_coinbase_info.data()), + frozen_merged_coinbase_info.size())); + ps >> share.m_merged_coinbase_info; + } catch (const std::exception& e) { + LOG_WARNING << "[frozen] Failed to deserialize merged_coinbase_info (" + << frozen_merged_coinbase_info.size() << " bytes): " << e.what(); + } + } + } + + // Random last_txout_nonce for OP_RETURN uniqueness + share.m_last_txout_nonce = static_cast(std::time(nullptr)) ^ + (static_cast(min_header.m_nonce) << 32); + + // --- Build the p2pool coinbase in the same format as generate_share_transaction --- + // This is needed to compute hash_link and to verify the share locally. + // The coinbase format is: version(4) + vin(1 input) + vout(outputs...) + locktime(4) + // + // For the hash_link, we need to split the coinbase at the ref_hash boundary: + // prefix = everything up to (and including) gentx_before_refhash + // suffix = ref_hash + last_txout_nonce + locktime (= hash_link_data) + // + // We compute generate_share_transaction's coinbase from the share fields, + // then extract the prefix and compute the hash_link. + + // ref_merkle_link: empty branch (ref_hash = hash_ref directly) + share.m_ref_merkle_link.m_branch.clear(); + share.m_ref_merkle_link.m_index = 0; + + // merkle_link: from Stratum merkle branches + share.m_merkle_link.m_branch = merkle_branches; + share.m_merkle_link.m_index = 0; + + // Populate segwit_data for V36 when segwit is active + if (segwit_active && !witness_commitment_hex.empty()) + { + SegwitData sd; + // txid_merkle_link: use FROZEN merkle branches from template time if available. + // The ref_hash was computed with these branches at template time. If new transactions + // arrived between template creation and share submission, the branches change → + // ref_hash mismatch → p2pool rejects the share as "PoW invalid". + sd.m_txid_merkle_link.m_branch = (has_frozen && !frozen_merkle_branches.empty()) + ? frozen_merkle_branches : merkle_branches; + sd.m_txid_merkle_link.m_index = 0; + // Debug: log only when frozen/current diverge (indicates the fix is active) + if (has_frozen && !frozen_merkle_branches.empty() && + frozen_merkle_branches.size() != merkle_branches.size()) { + LOG_INFO << "[segwit-freeze] branches changed: frozen=" + << frozen_merkle_branches.size() + << " current=" << merkle_branches.size(); + } + // wtxid_merkle_root: use frozen witness root if available. + // Priority: frozen > direct witness_root > zero (SegwitDataDefault). + // NEVER extract from witness_commitment_hex — that contains the + // p2pool commitment (Hash(root, nonce)), not the raw root. + // Storing it in m_wtxid_merkle_root causes generate_share_transaction + // to double-hash: Hash(Hash(root, nonce), nonce) → GENTX mismatch. + // Priority: frozen > direct. Zero root (0x00..00) is VALID — it's + // the correct witness root for coinbase-only blocks. p2pool uses it + // to compute SHA256d(0x00..00 || '[P2Pool]'*4) as the OP_RETURN commitment. + sd.m_wtxid_merkle_root = !frozen_witness_root.IsNull() + ? frozen_witness_root : witness_root; + share.m_segwit_data = sd; + } + + // --- Compute the ref_hash --- + PackStream ref_stream; + { + auto hex = PoolConfig::identifier_hex(); + for (size_t i = 0; i + 1 < hex.size(); i += 2) { + unsigned char byte = static_cast( + std::stoul(hex.substr(i, 2), nullptr, 16)); + ref_stream.write(std::span( + reinterpret_cast(&byte), 1)); + } + } + ref_stream << share.m_prev_hash; + ref_stream << share.m_coinbase; + ref_stream << share.m_nonce; + ref_stream << share.m_pubkey_hash; + ref_stream << share.m_pubkey_type; + ::Serialize(ref_stream, VarInt(share.m_subsidy)); + ref_stream << share.m_donation; + { uint8_t si = static_cast(share.m_stale_info); ref_stream << si; } + ::Serialize(ref_stream, VarInt(share.m_desired_version)); + // segwit_data: PossiblyNoneType in p2pool — ALWAYS serialize. + // When None, write default: {txid_merkle_link: {branch: [], index: 0}, wtxid_merkle_root: 0} + // = varint(0) [empty branch list] + uint256(0) [wtxid_merkle_root] = 33 bytes. + if (share.m_segwit_data.has_value()) { + ref_stream << share.m_segwit_data.value(); + } else { + // Write PossiblyNoneType default: empty branch list + zero wtxid_merkle_root + std::vector empty_branch; + ref_stream << empty_branch; // varint(0) + uint256 zero_root; + ref_stream << zero_root; // 32 zero bytes + } + ref_stream << share.m_merged_addresses; + ref_stream << share.m_far_share_hash; + ref_stream << share.m_max_bits; + ref_stream << share.m_bits; + ref_stream << share.m_timestamp; + ref_stream << share.m_absheight; + ::Serialize(ref_stream, Using(share.m_abswork)); + ref_stream << share.m_merged_coinbase_info; + ref_stream << share.m_merged_payout_hash; + // V36 ref_type includes message_data (empty BaseScript → varint(0) = 0x00) + ref_stream << share.m_message_data; + + auto ref_span_v = std::span( + reinterpret_cast(ref_stream.data()), ref_stream.size()); + uint256 hash_ref = Hash(ref_span_v); + uint256 ref_hash = check_merkle_link(hash_ref, share.m_ref_merkle_link); + + // Dump ref_stream for cross-impl comparison (always, for diagnostics) + { + if (!actual_coinbase_bytes.empty()) { + static const char* HX = "0123456789abcdef"; + std::string full_hex; + auto* rd = reinterpret_cast(ref_stream.data()); + for (size_t i = 0; i < ref_stream.size(); ++i) { full_hex += HX[rd[i]>>4]; full_hex += HX[rd[i]&0xf]; } + LOG_INFO << "[REF-HASH] ref_packed_len=" << ref_stream.size() + << " ref_hash=" << hash_ref.GetHex() + << " prev=" << share.m_prev_hash.GetHex().substr(0, 16) + << " abs=" << share.m_absheight + << " bits=" << std::hex << share.m_bits + << " maxbits=" << share.m_max_bits << std::dec; + LOG_INFO << "[REF-HASH-FULL] " << full_hex; + } + } + + // If we have actual coinbase bytes, extract the ref_hash from the coinbase. + // The coinbase has ref_hash embedded at position [len-44 : len-44+32]. + // This is the ref_hash computed at template time (by ref_hash_fn) — it + // uses the FROZEN tracker state, not the current state which may have changed. + // Using the coinbase's ref_hash ensures hash_link consistency. + // Always use ref_hash from the actual coinbase (frozen at template creation time). + // The recomputed ref_hash may differ because share chain state changed between + // template creation and share submission (absheight, far_share_hash, bits, etc.). + // This is the EXACT same approach as p2pool: the gentx is captured once in a + // closure and never re-derived. + if (!actual_coinbase_bytes.empty() && actual_coinbase_bytes.size() > 44) { + uint256 coinbase_ref_hash; + std::memcpy(coinbase_ref_hash.data(), + actual_coinbase_bytes.data() + actual_coinbase_bytes.size() - 44, 32); + ref_hash = coinbase_ref_hash; + } + + // Diagnostic: log ref_hash match/mismatch (first few only to avoid log spam) + { + static int ref_diag = 0; + bool match = (hash_ref == ref_hash); + if (!match || ref_diag < 3) { + LOG_INFO << "[ref_stream] total=" << ref_stream.size() + << " ref_hash_match=" << (match ? "YES" : "NO"); + ++ref_diag; + } + } + + // --- Derive hash_link from the actual mined coinbase --- + // Python p2pool captures the gentx at template creation time in a closure + // and NEVER re-computes PPLNS. We follow the same pattern: the coinbase + // bytes from refresh_work() are the single source of truth. + // + // When actual_coinbase_bytes is provided (normal mining path), we use it + // directly. This eliminates the race where the share chain changes between + // template creation and share submission, which caused GENTX mismatches + // and p2pool peer bans. + auto gentx_before_refhash = compute_gentx_before_refhash(int64_t(36)); + + std::vector coinbase_bytes_for_hashlink; + if (!actual_coinbase_bytes.empty()) { + // Use the actual mined coinbase — matches exactly what the miner solved. + coinbase_bytes_for_hashlink = actual_coinbase_bytes; + } else { + // Fallback (no actual bytes): must reconstruct from PPLNS. + // This path is only used in unit tests or if the caller doesn't + // provide actual_coinbase_bytes. + std::map, uint288> weights; + uint288 total_weight; + + if (!prev_share.IsNull() && tracker.chain.contains(prev_share)) + { + // Pass REAL_CHAIN_LENGTH — walk naturally stops at chain end. + auto chain_len = static_cast(PoolConfig::real_chain_length()); + auto block_target = chain::bits_to_target(share.m_min_header.m_bits); + auto max_weight = chain::target_to_average_attempts(block_target) + * PoolConfig::SPREAD * 65535; + auto result = tracker.get_v36_decayed_cumulative_weights(prev_share, chain_len, max_weight); + weights = std::move(result.weights); + total_weight = result.total_weight; + } + + std::map, uint64_t> amounts; + if (!total_weight.IsNull()) { + for (auto& [script, weight] : weights) { + uint64_t amount = (uint288(subsidy) * weight / total_weight).GetLow64(); + if (amount > 0) + amounts[script] = amount; + } + } + uint64_t sum_amounts = 0; + for (auto& [s, a] : amounts) sum_amounts += a; + uint64_t donation_amount = (subsidy > sum_amounts) ? (subsidy - sum_amounts) : 0; + if (donation_amount < 1 && subsidy > 0 && !amounts.empty()) { + // Deterministic tiebreak: (amount, script) — largest script wins when equal + auto largest = std::max_element(amounts.begin(), amounts.end(), + [](const auto& a, const auto& b) { + if (a.second != b.second) return a.second < b.second; + return a.first < b.first; + }); + if (largest != amounts.end() && largest->second > 0) { + largest->second -= 1; sum_amounts -= 1; + donation_amount = subsidy - sum_amounts; + } + } + + std::vector, uint64_t>> payout_outputs( + amounts.begin(), amounts.end()); + std::sort(payout_outputs.begin(), payout_outputs.end(), + [](const auto& a, const auto& b) { + if (a.second != b.second) return a.second < b.second; + return a.first < b.first; + }); + if (payout_outputs.size() > 4000) + payout_outputs.erase(payout_outputs.begin(), payout_outputs.end() - 4000); + + PackStream gentx; + { uint32_t v = 1; gentx.write(std::span(reinterpret_cast(&v), 4)); } + { unsigned char one = 1; gentx.write(std::span(reinterpret_cast(&one), 1)); } + { uint256 z; gentx << z; } + { uint32_t idx = 0xffffffff; gentx.write(std::span(reinterpret_cast(&idx), 4)); } + gentx << share.m_coinbase; + { uint32_t seq = 0xffffffff; gentx.write(std::span(reinterpret_cast(&seq), 4)); } + size_t n_outs = payout_outputs.size() + 1 + 1; + bool has_segwit_fb = share.m_segwit_data.has_value(); + if (has_segwit_fb) n_outs += 1; + if (n_outs < 253) { uint8_t cnt = (uint8_t)n_outs; gentx.write(std::span(reinterpret_cast(&cnt), 1)); } + else { uint8_t m = 0xfd; gentx.write(std::span(reinterpret_cast(&m), 1)); uint16_t cnt = (uint16_t)n_outs; gentx.write(std::span(reinterpret_cast(&cnt), 2)); } + auto write_txout = [&](uint64_t value, const std::vector& script) { + gentx.write(std::span(reinterpret_cast(&value), 8)); + BaseScript bs; bs.m_data = script; gentx << bs; + }; + if (has_segwit_fb) { + auto& sd = share.m_segwit_data.value(); + std::vector wscript = {0x6a, 0x24, 0xaa, 0x21, 0xa9, 0xed}; + uint256 commitment = compute_p2pool_witness_commitment(sd.m_wtxid_merkle_root); + auto cb = commitment.GetChars(); + wscript.insert(wscript.end(), cb.begin(), cb.end()); + write_txout(0, wscript); + } + for (auto& [script, amount] : payout_outputs) write_txout(amount, script); + write_txout(donation_amount, PoolConfig::get_donation_script(int64_t(36))); + { std::vector op; op.push_back(0x6a); op.push_back(0x28); + op.insert(op.end(), ref_hash.data(), ref_hash.data() + 32); + uint64_t n = share.m_last_txout_nonce; auto* p = reinterpret_cast(&n); + op.insert(op.end(), p, p + 8); write_txout(0, op); } + { uint32_t lt = 0; gentx.write(std::span(reinterpret_cast(<), 4)); } + + coinbase_bytes_for_hashlink.assign( + reinterpret_cast(gentx.data()), + reinterpret_cast(gentx.data()) + gentx.size()); + } + + // The split point: everything before ref_hash + last_txout_nonce + locktime + // = coinbase minus last (32 + 8 + 4) = 44 bytes + constexpr size_t suffix_len = 32 + 8 + 4; // ref_hash + last_txout_nonce + locktime + if (coinbase_bytes_for_hashlink.size() > suffix_len) { + std::vector prefix( + coinbase_bytes_for_hashlink.begin(), coinbase_bytes_for_hashlink.end() - suffix_len); + share.m_hash_link = prefix_to_hash_link(prefix, gentx_before_refhash); + + // Verify hash_link round-trip: does check_hash_link(hash_link, suffix) == Hash(full)? + { + static int hl_diag = 0; + if (hl_diag < 5) { + std::vector suffix( + coinbase_bytes_for_hashlink.end() - suffix_len, + coinbase_bytes_for_hashlink.end()); + uint256 hl_result = check_hash_link(share.m_hash_link, suffix, gentx_before_refhash); + auto full_span = std::span( + coinbase_bytes_for_hashlink.data(), coinbase_bytes_for_hashlink.size()); + uint256 direct_result = Hash(full_span); + bool match = (hl_result == direct_result); + + // Check if prefix ends with const_ending + size_t ce_len = gentx_before_refhash.size(); + bool ce_match = (prefix.size() >= ce_len) && + std::equal(gentx_before_refhash.begin(), gentx_before_refhash.end(), + prefix.end() - ce_len); + + LOG_INFO << "[hash_link-roundtrip] MATCH=" << (match ? "YES" : "NO") + << " CE_match=" << (ce_match ? "YES" : "NO") + << " prefix=" << prefix.size() + << " cb_total=" << coinbase_bytes_for_hashlink.size() + << " CE=" << ce_len; + + if (!match || !ce_match) { + LOG_WARNING << "[hash_link-roundtrip] MISMATCH! direct=" << direct_result.GetHex() + << " hashlink=" << hl_result.GetHex(); + // Dump boundary bytes for debugging + static const char* HX = "0123456789abcdef"; + if (prefix.size() >= ce_len) { + std::string pfx_tail, ce_hex; + for (size_t i = prefix.size() - ce_len; i < prefix.size(); ++i) { + pfx_tail += HX[prefix[i] >> 4]; + pfx_tail += HX[prefix[i] & 0xf]; + } + for (size_t i = 0; i < ce_len; ++i) { + ce_hex += HX[gentx_before_refhash[i] >> 4]; + ce_hex += HX[gentx_before_refhash[i] & 0xf]; + } + LOG_WARNING << "[hash_link-roundtrip] prefix_tail=" << pfx_tail; + LOG_WARNING << "[hash_link-roundtrip] expected_CE=" << ce_hex; + } + } + ++hl_diag; + } + } + + // Extract last_txout_nonce (8 bytes before locktime) + size_t nonce_offset = coinbase_bytes_for_hashlink.size() - 4 - 8; + uint64_t extracted_nonce = 0; + std::memcpy(&extracted_nonce, coinbase_bytes_for_hashlink.data() + nonce_offset, 8); + share.m_last_txout_nonce = extracted_nonce; + { + static int nonce_log = 0; + if (nonce_log++ < 5) { + static const char* HX = "0123456789abcdef"; + std::string nonce_hex; + for (int i = 0; i < 8; ++i) { + uint8_t b = coinbase_bytes_for_hashlink[nonce_offset + i]; + nonce_hex += HX[b>>4]; nonce_hex += HX[b&0xf]; + } + LOG_INFO << "[NONCE-EXTRACT] offset=" << nonce_offset + << " nonce_hex=" << nonce_hex + << " nonce_u64=0x" << std::hex << extracted_nonce << std::dec + << " cb_total=" << coinbase_bytes_for_hashlink.size() + << " src=" << (actual_coinbase_bytes.empty() ? "reconstructed" : "actual_mined"); + // Dump FULL actual coinbase hex for byte-by-byte comparison with p2pool + std::string full_cb_hex; + for (size_t i = 0; i < coinbase_bytes_for_hashlink.size(); ++i) { + full_cb_hex += HX[coinbase_bytes_for_hashlink[i]>>4]; + full_cb_hex += HX[coinbase_bytes_for_hashlink[i]&0xf]; + } + LOG_INFO << "[ACTUAL-CB] hex=" << full_cb_hex; + } + } + } + + // --- Compute share hash --- + // Build the full 80-byte block header and double-SHA256 it + PackStream header_stream; + { uint32_t v = static_cast(min_header.m_version); + header_stream << v; } + header_stream << min_header.m_previous_block; + + // Compute merkle root for the block header. + // The MINER hashed the coinbase with the REAL extranonce2, so we must use + // actual_coinbase_bytes for the merkle root in the block header. + uint256 gentx_hash_for_header; + if (!actual_coinbase_bytes.empty()) { + auto actual_span = std::span( + actual_coinbase_bytes.data(), actual_coinbase_bytes.size()); + gentx_hash_for_header = Hash(actual_span); + } else { + // Use the coinbase bytes we already computed for hash_link (either actual or reconstructed) + auto cb_span = std::span( + coinbase_bytes_for_hashlink.data(), coinbase_bytes_for_hashlink.size()); + gentx_hash_for_header = Hash(cb_span); + } + // For the BLOCK HEADER merkle root, always use the actual job merkle branches + // (share.m_merkle_link), NOT the frozen segwit_data.txid_merkle_link. + // The frozen branches are for the ref_hash/share_info serialization only. + // The block header must match the actual transactions in the template. + uint256 merkle_root = check_merkle_link(gentx_hash_for_header, share.m_merkle_link); + + header_stream << merkle_root; + header_stream << min_header.m_timestamp; + header_stream << min_header.m_bits; + header_stream << min_header.m_nonce; + + auto hdr_span = std::span( + reinterpret_cast(header_stream.data()), header_stream.size()); + + // Diagnostic: dump header for PoW debugging + { + static int diag_count = 0; + if (diag_count < 5 && !actual_coinbase_bytes.empty()) { + std::string hdr_hex; + static const char* HX = "0123456789abcdef"; + for (size_t i = 0; i < hdr_span.size(); ++i) { + hdr_hex += HX[hdr_span[i] >> 4]; + hdr_hex += HX[hdr_span[i] & 0xf]; + } + LOG_INFO << "[create_local_share-diag] header(80)=" << hdr_hex; + LOG_INFO << "[create_local_share-diag] gentx_hash=" << gentx_hash_for_header.GetHex(); + LOG_INFO << "[create_local_share-diag] merkle_root=" << merkle_root.GetHex(); + LOG_INFO << "[create_local_share-diag] prev_block=" << min_header.m_previous_block.GetHex(); + LOG_INFO << "[create_local_share-diag] nonce=" << min_header.m_nonce + << " timestamp=" << min_header.m_timestamp + << " bits=" << std::hex << min_header.m_bits << std::dec; + ++diag_count; + } + } + + uint256 share_hash = Hash(hdr_span); + + // Set the share's identity hash + share.m_hash = share_hash; + + // Self-validation: PoW check against share target. + // Also run share_init_verify to confirm peers will accept this share + // (same check they'll run when they receive it). + // BTC: pow_hash = SHA256d(header) = share_hash already computed. + { + uint256 target = chain::bits_to_target(share.m_bits); + if (!target.IsNull()) { + uint256 pow_hash = share_hash; + + if (pow_hash > target) { + // Expected: most stratum pseudoshares don't meet share target + return uint256(); + } + LOG_INFO << "[Pool] REAL SHARE CREATED! pow=" << pow_hash.GetHex().substr(0, 16) + << " target=" << target.GetHex().substr(0, 16) + << " diff=" << chain::target_to_difficulty(target); + // Dump raw 80-byte header for byte-level comparison with p2pool + { + static int hdr_dump = 0; + if (hdr_dump++ < 5) { + static const char* HX = "0123456789abcdef"; + std::string hex; + auto* rd = reinterpret_cast(hdr_span.data()); + for (size_t i = 0; i < hdr_span.size() && i < 80; ++i) { + hex += HX[rd[i]>>4]; hex += HX[rd[i]&0xf]; + } + LOG_INFO << "[HDR-DUMP] " << hex + << " hash=" << share.m_hash.GetHex().substr(0, 16); + } + } + // Log fields for comparison with p2pool's SHARE-REJECT + { + static int sl = 0; + if (sl++ < 5) { + LOG_INFO << "[SHARE-FIELDS] header_bits=" << std::hex << share.m_min_header.m_bits + << " share_bits=" << share.m_bits + << " max_bits=" << share.m_max_bits << std::dec + << " absheight=" << share.m_absheight + << " ts=" << share.m_timestamp + << " donation=" << share.m_donation + << " segwit=" << (share.m_segwit_data.has_value() ? "YES" : "NO") + << " prev=" << share.m_prev_hash.GetHex().substr(0, 16); + } + } + + // Cross-check: run the SAME verification that peers will run. + // If this fails, peers will reject the share as "PoW invalid". + // Cross-check: verify hash_link round-trip with the COINBASE ref_hash. + // The coinbase ref_hash was frozen at template time. If check_hash_link + // with this ref_hash produces the same gentx_hash as direct Hash(coinbase), + // peers will accept the share. + { + auto gentx_before_refhash_xc = compute_gentx_before_refhash(int64_t(36)); + + // Use the ref_hash from the coinbase (same as what hash_link was built with) + std::vector xc_data; + xc_data.insert(xc_data.end(), ref_hash.data(), ref_hash.data() + 32); + { uint64_t n = share.m_last_txout_nonce; + auto* p = reinterpret_cast(&n); + xc_data.insert(xc_data.end(), p, p + 8); } + { uint32_t z = 0; + auto* p = reinterpret_cast(&z); + xc_data.insert(xc_data.end(), p, p + 4); } + + uint256 xc_gentx = check_hash_link(share.m_hash_link, xc_data, gentx_before_refhash_xc); + + if (xc_gentx != gentx_hash_for_header) { + // Cross-check failure is diagnostic only — p2pool has no such check. + // The share was already constructed with consistent hash_link + coinbase. + // Mismatch here means gentx_before_refhash() doesn't match the frozen + // template (PPLNS changed between template and submission). This is + // expected during genesis or when chain state changes rapidly. + // The share is still valid — peers verify via their own check_hash_link. + static int xc_warn = 0; + if (xc_warn++ < 10) + LOG_WARNING << "[Pool] Cross-check mismatch (non-blocking)" + << " hl_gentx=" << xc_gentx.GetHex().substr(0, 16) + << " direct_gentx=" << gentx_hash_for_header.GetHex().substr(0, 16); + } else { + LOG_INFO << "[Pool] Cross-check PASSED"; + } + } + } + } + + // One-time hex dump of the share_info portion for wire format debugging + { + static int wire_dump = 0; + if (wire_dump++ < 1) { + // Pack just the share_info fields to see exact wire bytes + // Dump share_data fields manually for wire format comparison + { + PackStream sd_pack; + // Serialize share_data portion (same as share.hpp lines 155-190) + // prev_hash (uint256 = 32 bytes) + sd_pack << share.m_prev_hash; + // coinbase (VarStr) + sd_pack << share.m_coinbase; + // nonce (uint32) + sd_pack.write(std::span( + reinterpret_cast(&share.m_nonce), 4)); + auto sd_span = sd_pack.get_span(); + auto* sp = reinterpret_cast(sd_span.data()); + std::string sd_hex; + for (size_t i = 0; i < sd_span.size(); ++i) { + static const char* H = "0123456789abcdef"; + sd_hex += H[sp[i] >> 4]; sd_hex += H[sp[i] & 0xf]; + } + LOG_INFO << "[WIRE-DUMP] share_data_head(" << sd_span.size() + << "): " << sd_hex.substr(0, 160); + LOG_INFO << "[WIRE-DUMP] m_coinbase(" << share.m_coinbase.size() + << "): " << sd_hex.substr(64, std::min(size_t(100), sd_hex.size()-64)); + } + LOG_INFO << "[WIRE-DUMP] m_bits=0x" << std::hex << share.m_bits + << " m_max_bits=0x" << share.m_max_bits << std::dec + << " m_timestamp=" << share.m_timestamp + << " m_absheight=" << share.m_absheight + << " m_donation=" << share.m_donation + << " m_subsidy=" << share.m_subsidy; + // Dump the share_info fields as they would be serialized + PackStream info_pack; + // far_share_hash (PossiblyNoneType(0, IntType(256))) + if (share.m_far_share_hash.IsNull()) { + uint8_t z = 0; info_pack.write(std::span(reinterpret_cast(&z), 1)); + } else { + uint8_t one = 1; info_pack.write(std::span(reinterpret_cast(&one), 1)); + info_pack.write(std::span(reinterpret_cast(share.m_far_share_hash.data()), 32)); + } + // max_bits, bits, timestamp, absheight (4 bytes each, LE) + info_pack.write(std::span(reinterpret_cast(&share.m_max_bits), 4)); + info_pack.write(std::span(reinterpret_cast(&share.m_bits), 4)); + info_pack.write(std::span(reinterpret_cast(&share.m_timestamp), 4)); + info_pack.write(std::span(reinterpret_cast(&share.m_absheight), 4)); + auto info_span = info_pack.get_span(); + std::string info_hex; + auto* ip = reinterpret_cast(info_span.data()); + for (size_t i = 0; i < info_span.size(); ++i) { + static const char* H = "0123456789abcdef"; + info_hex += H[ip[i] >> 4]; info_hex += H[ip[i] & 0xf]; + } + LOG_INFO << "[WIRE-DUMP] share_info_tail=" << info_hex; + } + } + + // Add to tracker (heap-allocate; ShareChain takes ownership via raw pointer) + auto* heap_share = new MergedMiningShare(share); + tracker.add(heap_share); + LOG_INFO << "create_local_share: added share " << share_hash.GetHex() + << " height=" << share.m_absheight + << " prev=" << prev_share.GetHex().substr(0, 16) << "..."; + + // Cross-check: call generate_share_transaction on the share we just created + // to verify p2pool would produce the same gentx_hash. + { + static int xcheck_count = 0; + if (true) { // Always cross-check (was: xcheck_count < 5) + uint256 verify_hash = generate_share_transaction(*heap_share, tracker, true, (MergedMiningShare::version >= 36)); + bool xcheck_ok = (verify_hash == gentx_hash_for_header); + if (xcheck_ok) { + LOG_INFO << "[Pool] Cross-check PASSED"; + } else { + LOG_WARNING << "[Pool] Cross-check FAILED! mined_gentx=" << gentx_hash_for_header.GetHex() + << " verify_gentx=" << verify_hash.GetHex(); + // Dump actual mined coinbase hex for byte-level comparison + if (!actual_coinbase_bytes.empty()) { + static const char* H = "0123456789abcdef"; + std::string mined_hex; + mined_hex.reserve(actual_coinbase_bytes.size() * 2); + for (unsigned char b : actual_coinbase_bytes) { mined_hex += H[b>>4]; mined_hex += H[b&0xf]; } + LOG_WARNING << "[XCHECK-MINED] len=" << actual_coinbase_bytes.size() + << " hex=" << mined_hex; + } + } + ++xcheck_count; + } + } + + return share_hash; +} + +} // namespace btc diff --git a/src/impl/btc/share_messages.hpp b/src/impl/btc/share_messages.hpp new file mode 100644 index 000000000..c1b154a0e --- /dev/null +++ b/src/impl/btc/share_messages.hpp @@ -0,0 +1,579 @@ +// share_messages.hpp — V36 share-embedded messaging: decryption + validation +// +// Port of p2pool/share_messages.py (consensus-critical portions). +// Messages are embedded in V36 shares' message_data field, included +// in the ref_hash computation (PoW-protected). +// +// Validation path for incoming shares: +// 1. If message_data is empty → valid (no messages) +// 2. Decrypt outer envelope using DONATION_AUTHORITY_PUBKEYS +// 3. If decryption fails (MAC mismatch) → REJECT share +// 4. Parse inner envelope: version, flags, msg_count, messages +// 5. If inner envelope is malformed or empty → REJECT share +// 6. Verify ECDSA signatures via libsecp256k1 + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace btc { + +// ============================================================================ +// Authority public keys (compressed secp256k1) +// From COMBINED_DONATION_REDEEM_SCRIPT: +// OP_1 PUSH33 PUSH33 OP_2 OP_CHECKMULTISIG +// ============================================================================ + +using AuthorityPubkey = std::array; + +inline const AuthorityPubkey& DONATION_PUBKEY_FORRESTV() +{ + static const AuthorityPubkey k = { + 0x03, 0xff, 0xd0, 0x3d, 0xe4, 0x4a, 0x6e, 0x11, + 0xb9, 0x91, 0x7f, 0x3a, 0x29, 0xf9, 0x44, 0x32, + 0x83, 0xd9, 0x87, 0x1c, 0x9d, 0x74, 0x3e, 0xf3, + 0x0d, 0x5e, 0xdd, 0xcd, 0x37, 0x09, 0x4b, 0x64, + 0xd1 + }; + return k; +} + +inline const AuthorityPubkey& DONATION_PUBKEY_MAINTAINER() +{ + static const AuthorityPubkey k = { + 0x02, 0xfe, 0x65, 0x78, 0xf8, 0x02, 0x1a, 0x7d, + 0x46, 0x67, 0x87, 0x82, 0x7b, 0x3f, 0x26, 0x43, + 0x7a, 0xef, 0x88, 0x27, 0x9e, 0xf3, 0x80, 0xaf, + 0x32, 0x6f, 0x87, 0xec, 0x36, 0x26, 0x33, 0x29, + 0x3a + }; + return k; +} + +inline const std::array& DONATION_AUTHORITY_PUBKEYS() +{ + static const std::array keys = { + &DONATION_PUBKEY_FORRESTV(), + &DONATION_PUBKEY_MAINTAINER(), + }; + return keys; +} + +// ============================================================================ +// Constants +// ============================================================================ + +// Message types +constexpr uint8_t MSG_NODE_STATUS = 0x01; +constexpr uint8_t MSG_MINER_MESSAGE = 0x02; +constexpr uint8_t MSG_POOL_ANNOUNCE = 0x03; +constexpr uint8_t MSG_VERSION_SIGNAL = 0x04; +constexpr uint8_t MSG_MERGED_STATUS = 0x05; +constexpr uint8_t MSG_EMERGENCY = 0x10; +constexpr uint8_t MSG_TRANSITION_SIGNAL = 0x20; + +// Flags +constexpr uint8_t FLAG_HAS_SIGNATURE = 0x01; +constexpr uint8_t FLAG_BROADCAST = 0x02; +constexpr uint8_t FLAG_PERSISTENT = 0x04; +constexpr uint8_t FLAG_PROTOCOL_AUTHORITY = 0x08; + +// Limits +constexpr size_t MAX_MESSAGE_PAYLOAD = 220; +constexpr size_t MAX_MESSAGES_PER_SHARE = 3; +constexpr size_t MAX_TOTAL_MESSAGE_BYTES = 512; + +// Encryption envelope +constexpr uint8_t ENCRYPTED_ENVELOPE_VERSION = 0x01; +constexpr size_t ENCRYPTION_NONCE_SIZE = 16; +constexpr size_t ENCRYPTION_MAC_SIZE = 32; +constexpr size_t ENCRYPTION_HEADER_SIZE = 1 + ENCRYPTION_NONCE_SIZE + ENCRYPTION_MAC_SIZE; // 49 + +// ============================================================================ +// Parsed message (lightweight — just what we need for validation) +// ============================================================================ + +struct ShareMessage +{ + uint8_t msg_type{0}; + uint8_t flags{0}; + uint8_t wire_flags{0}; // original flags for hash + uint32_t timestamp{0}; + std::vector payload; + std::array signing_id{}; + std::vector signature; + + bool has_signature() const { return (flags & FLAG_HAS_SIGNATURE) != 0; } + + // Unpack one message from data at offset. Returns new offset or nullopt on failure. + static std::optional unpack(const unsigned char* data, size_t len, + size_t offset, ShareMessage& out) + { + if (len - offset < 8) return std::nullopt; + + std::memcpy(&out.msg_type, data + offset, 1); + std::memcpy(&out.wire_flags, data + offset + 1, 1); + out.flags = out.wire_flags & ~FLAG_PROTOCOL_AUTHORITY; + + uint32_t ts; + std::memcpy(&ts, data + offset + 2, 4); // LE + out.timestamp = ts; + + uint16_t payload_len; + std::memcpy(&payload_len, data + offset + 6, 2); // LE + offset += 8; + + if (payload_len > MAX_MESSAGE_PAYLOAD) return std::nullopt; + if (len - offset < payload_len) return std::nullopt; + out.payload.assign(data + offset, data + offset + payload_len); + offset += payload_len; + + // signing_id (20 bytes) + if (len - offset < 20) return std::nullopt; + std::memcpy(out.signing_id.data(), data + offset, 20); + offset += 20; + + // sig_len + signature + if (len - offset < 1) return std::nullopt; + uint8_t sig_len = data[offset++]; + if (len - offset < sig_len) return std::nullopt; + out.signature.assign(data + offset, data + offset + sig_len); + offset += sig_len; + + return offset; + } +}; + +// ============================================================================ +// secp256k1 context (singleton, thread-safe after init) +// ============================================================================ + +inline const secp256k1_context* get_secp256k1_context() +{ + static const secp256k1_context* ctx = + secp256k1_context_create(SECP256K1_CONTEXT_VERIFY | SECP256K1_CONTEXT_SIGN); + return ctx; +} + +// ============================================================================ +// Crypto helpers +// ============================================================================ + +// HMAC-SHA256(key, data) → 32 bytes +inline std::array hmac_sha256( + const unsigned char* key, size_t keylen, + const unsigned char* data, size_t datalen) +{ + std::array result; + CHMAC_SHA256(key, keylen).Write(data, datalen).Finalize(result.data()); + return result; +} + +// Counter-mode SHA256 stream generator +inline void generate_stream(const unsigned char* enc_key, + unsigned char* out, size_t length) +{ + size_t produced = 0; + uint32_t counter = 0; + while (produced < length) + { + unsigned char block_input[36]; // 32 (key) + 4 (counter LE) + std::memcpy(block_input, enc_key, 32); + std::memcpy(block_input + 32, &counter, 4); // LE on little-endian host + + unsigned char block[32]; + CSHA256().Write(block_input, 36).Finalize(block); + + size_t copy = std::min(32, length - produced); + std::memcpy(out + produced, block, copy); + produced += copy; + ++counter; + } +} + +// Double-SHA256 of message content for ECDSA signing/verification. +// Matches Python: SHA256(SHA256(pack(' compute_message_hash( + uint8_t msg_type, uint8_t wire_flags, uint32_t timestamp, + const unsigned char* payload, size_t payload_len) +{ + unsigned char header[6]; + header[0] = msg_type; + header[1] = wire_flags; + std::memcpy(header + 2, ×tamp, 4); // LE + + unsigned char first[32]; + CSHA256().Write(header, 6).Write(payload, payload_len).Finalize(first); + + std::array result; + CSHA256().Write(first, 32).Finalize(result.data()); + return result; +} + +// Verify ECDSA signature against a compressed secp256k1 public key. +// pubkey_compressed: 33 bytes (0x02/0x03 prefix) +// msghash32: 32-byte double-SHA256 (from compute_message_hash) +// sig_der: DER-encoded ECDSA signature +// Returns true if signature is valid. +inline bool ecdsa_verify(const unsigned char* pubkey_compressed, size_t pubkey_len, + const unsigned char* msghash32, + const unsigned char* sig_der, size_t sig_len) +{ + const auto* ctx = get_secp256k1_context(); + + secp256k1_pubkey pubkey; + if (!secp256k1_ec_pubkey_parse(ctx, &pubkey, pubkey_compressed, pubkey_len)) + return false; + + secp256k1_ecdsa_signature sig; + if (!secp256k1_ecdsa_signature_parse_der(ctx, &sig, sig_der, sig_len)) + return false; + + // Normalize to lower-S form (secp256k1_ecdsa_verify requires this) + secp256k1_ecdsa_signature_normalize(ctx, &sig, &sig); + + return secp256k1_ecdsa_verify(ctx, &sig, msghash32, &pubkey) == 1; +} + +// ============================================================================ +// Decryption result +// ============================================================================ + +struct DecryptResult +{ + std::vector inner_data; + const AuthorityPubkey* authority_pubkey{nullptr}; // which key succeeded +}; + +// Decrypt encrypted envelope. Returns nullopt if all authority keys fail MAC. +inline std::optional decrypt_message_data( + const unsigned char* data, size_t len) +{ + if (len < ENCRYPTION_HEADER_SIZE + 1) return std::nullopt; + + if (data[0] != ENCRYPTED_ENVELOPE_VERSION) return std::nullopt; + + const unsigned char* nonce = data + 1; + const unsigned char* mac_recv = data + 1 + ENCRYPTION_NONCE_SIZE; + const unsigned char* ciphertext = data + ENCRYPTION_HEADER_SIZE; + size_t ct_len = len - ENCRYPTION_HEADER_SIZE; + + if (ct_len == 0) return std::nullopt; + + for (const auto* pubkey_ptr : DONATION_AUTHORITY_PUBKEYS()) + { + // enc_key = HMAC-SHA256(pubkey, nonce) + auto enc_key = hmac_sha256( + pubkey_ptr->data(), pubkey_ptr->size(), nonce, ENCRYPTION_NONCE_SIZE); + + // MAC = HMAC-SHA256(enc_key, ciphertext) + auto mac_computed = hmac_sha256( + enc_key.data(), enc_key.size(), ciphertext, ct_len); + + // Constant-time compare + unsigned char diff = 0; + for (size_t i = 0; i < 32; ++i) diff |= mac_computed[i] ^ mac_recv[i]; + if (diff != 0) continue; // Wrong key + + // MAC matches — decrypt (XOR with stream) + DecryptResult result; + result.inner_data.resize(ct_len); + generate_stream(enc_key.data(), result.inner_data.data(), ct_len); + for (size_t i = 0; i < ct_len; ++i) + result.inner_data[i] ^= ciphertext[i]; + result.authority_pubkey = pubkey_ptr; + return result; + } + + return std::nullopt; +} + +// ============================================================================ +// Unpack result +// ============================================================================ + +struct UnpackResult +{ + std::vector messages; + const AuthorityPubkey* authority_pubkey{nullptr}; + bool decrypted{false}; +}; + +// Main entry point: decrypt + parse inner envelope. +// Returns empty result on failure; caller checks fields. +inline UnpackResult unpack_share_messages(const unsigned char* data, size_t len) +{ + UnpackResult result; + + if (!data || len < ENCRYPTION_HEADER_SIZE + 4) + return result; + + auto dec = decrypt_message_data(data, len); + if (!dec.has_value()) + return result; // Decryption failed → signing_key_info = nullptr + + result.decrypted = true; + result.authority_pubkey = dec->authority_pubkey; + + const auto& inner = dec->inner_data; + if (inner.size() < 4) + return result; + + uint8_t inner_version = inner[0]; + uint8_t inner_flags = inner[1]; + uint8_t msg_count = inner[2]; + uint8_t ann_len = inner[3]; + size_t offset = 4; + + if (inner_version != 1) + return result; // Unknown version — skip gracefully + + // Skip signing key announcement if present + if ((inner_flags & 0x01) && ann_len > 0) + offset += ann_len; + + if (offset > inner.size()) + return result; + + // Cap message count + if (msg_count > MAX_MESSAGES_PER_SHARE) + msg_count = MAX_MESSAGES_PER_SHARE; + + for (uint8_t i = 0; i < msg_count; ++i) + { + ShareMessage msg; + auto new_offset = ShareMessage::unpack(inner.data(), inner.size(), offset, msg); + if (!new_offset.has_value()) break; + result.messages.push_back(std::move(msg)); + offset = *new_offset; + } + + return result; +} + +// Convenience: validate message_data from a share. +// Returns empty string on success; returns error reason on failure. +// Empty message_data is always valid. +inline std::string validate_message_data(const std::vector& message_data) +{ + if (message_data.empty()) + return {}; // No messages — always valid + + auto result = unpack_share_messages(message_data.data(), message_data.size()); + + if (!result.decrypted) + return "message_data failed decryption against all " + "COMBINED_DONATION_SCRIPT authority keys"; + + if (result.authority_pubkey == nullptr) + return "message_data decrypted but authority_pubkey unknown"; + + // Check authority_pubkey is one we recognise + bool known = false; + for (const auto* pk : DONATION_AUTHORITY_PUBKEYS()) + { + if (pk == result.authority_pubkey) { known = true; break; } + } + if (!known) + return "message_data authority_pubkey not in COMBINED_DONATION_SCRIPT"; + + if (result.messages.empty()) + return "message_data decrypted but contains no valid messages"; + + // Verify each message has a valid ECDSA signature from the authority key + // that encrypted the envelope. This is defense-in-depth on top of the + // MAC-based decryption (which already proves authority created the envelope). + for (const auto& msg : result.messages) + { + if (!msg.has_signature() || msg.signature.empty()) + return "message contains unsigned message (type 0x" + + std::to_string(msg.msg_type) + ") inside encrypted envelope"; + + auto msg_hash = compute_message_hash( + msg.msg_type, msg.wire_flags, msg.timestamp, + msg.payload.data(), msg.payload.size()); + + if (!ecdsa_verify(result.authority_pubkey->data(), result.authority_pubkey->size(), + msg_hash.data(), msg.signature.data(), msg.signature.size())) + return "message ECDSA signature verification failed (type 0x" + + std::to_string(msg.msg_type) + ")"; + } + + return {}; // All checks passed +} + +// ============================================================================ +// Message creation: signing, packing, encryption +// ============================================================================ + +// ECDSA sign a 32-byte hash with a 32-byte private key. +// Returns DER-encoded signature, empty on failure. +inline std::vector ecdsa_sign( + const unsigned char* msghash32, + const unsigned char* seckey32) +{ + const auto* ctx = get_secp256k1_context(); + + secp256k1_ecdsa_signature sig; + if (!secp256k1_ecdsa_sign(ctx, &sig, msghash32, seckey32, nullptr, nullptr)) + return {}; + + unsigned char der[72]; + size_t der_len = sizeof(der); + if (!secp256k1_ecdsa_signature_serialize_der(ctx, der, &der_len, &sig)) + return {}; + + return {der, der + der_len}; +} + +// HASH160 = RIPEMD160(SHA256(data)) — standard Bitcoin hash160. +inline std::array hash160( + const unsigned char* data, size_t len) +{ + unsigned char sha256_buf[32]; + CSHA256().Write(data, len).Finalize(sha256_buf); + + std::array result; + CRIPEMD160().Write(sha256_buf, 32).Finalize(result.data()); + return result; +} + +// Serialize a ShareMessage to wire format. +// Wire: [type:1][flags:1][timestamp:4 LE][payload_len:2 LE][payload:N] +// [signing_id:20][sig_len:1][signature:M] +inline std::vector pack_message(const ShareMessage& msg) +{ + std::vector buf; + buf.reserve(8 + msg.payload.size() + 20 + 1 + msg.signature.size()); + + buf.push_back(msg.msg_type); + buf.push_back(msg.wire_flags); + + uint32_t ts = msg.timestamp; + buf.push_back(static_cast(ts)); + buf.push_back(static_cast(ts >> 8)); + buf.push_back(static_cast(ts >> 16)); + buf.push_back(static_cast(ts >> 24)); + + uint16_t pl = static_cast(msg.payload.size()); + buf.push_back(static_cast(pl)); + buf.push_back(static_cast(pl >> 8)); + + buf.insert(buf.end(), msg.payload.begin(), msg.payload.end()); + buf.insert(buf.end(), msg.signing_id.begin(), msg.signing_id.end()); + + buf.push_back(static_cast(msg.signature.size())); + buf.insert(buf.end(), msg.signature.begin(), msg.signature.end()); + + return buf; +} + +// Encrypt inner envelope data with an authority public key. +// Returns encrypted blob: [0x01][nonce:16][mac:32][ciphertext:N] +inline std::vector encrypt_message_envelope( + const std::vector& inner, + const AuthorityPubkey& authority_pubkey) +{ + // 16-byte random nonce + std::array nonce; + std::random_device rd; + for (size_t i = 0; i < ENCRYPTION_NONCE_SIZE; i += 4) + { + auto val = rd(); + auto bytes = std::min(4, ENCRYPTION_NONCE_SIZE - i); + std::memcpy(nonce.data() + i, &val, bytes); + } + + // enc_key = HMAC-SHA256(authority_pubkey, nonce) + auto enc_key = hmac_sha256( + authority_pubkey.data(), authority_pubkey.size(), + nonce.data(), nonce.size()); + + // stream = counter-mode SHA256 + std::vector stream(inner.size()); + generate_stream(enc_key.data(), stream.data(), inner.size()); + + // ciphertext = inner XOR stream + std::vector ciphertext(inner.size()); + for (size_t i = 0; i < inner.size(); ++i) + ciphertext[i] = inner[i] ^ stream[i]; + + // mac = HMAC-SHA256(enc_key, ciphertext) + auto mac = hmac_sha256( + enc_key.data(), enc_key.size(), + ciphertext.data(), ciphertext.size()); + + // Assemble: [0x01][nonce:16][mac:32][ciphertext] + std::vector result; + result.reserve(1 + ENCRYPTION_NONCE_SIZE + ENCRYPTION_MAC_SIZE + ciphertext.size()); + result.push_back(ENCRYPTED_ENVELOPE_VERSION); + result.insert(result.end(), nonce.begin(), nonce.end()); + result.insert(result.end(), mac.begin(), mac.end()); + result.insert(result.end(), ciphertext.begin(), ciphertext.end()); + return result; +} + +// Create encrypted message_data blob for embedding in a V36 share. +// +// seckey: 32-byte private key +// authority_pubkey: 33-byte compressed pubkey corresponding to seckey +// messages: ShareMessages with msg_type, wire_flags, timestamp, payload set. +// signature and signing_id are computed and filled in. +// +// Returns the complete encrypted message_data blob, or empty on failure. +inline std::vector create_message_data( + const unsigned char* seckey, + const AuthorityPubkey& authority_pubkey, + std::vector& messages) +{ + if (messages.empty() || messages.size() > MAX_MESSAGES_PER_SHARE) + return {}; + + // Compute signing_id = HASH160(authority_pubkey) + auto signing_id = hash160(authority_pubkey.data(), authority_pubkey.size()); + + // Sign each message and serialize + std::vector packed_messages; + for (auto& msg : messages) + { + msg.signing_id = signing_id; + + auto msg_hash = compute_message_hash( + msg.msg_type, msg.wire_flags, msg.timestamp, + msg.payload.data(), msg.payload.size()); + + msg.signature = ecdsa_sign(msg_hash.data(), seckey); + if (msg.signature.empty()) + return {}; + + auto packed = pack_message(msg); + packed_messages.insert(packed_messages.end(), packed.begin(), packed.end()); + } + + // Inner envelope: [version=1][flags=0][msg_count][reserved=0] + packed_messages + std::vector inner; + inner.reserve(4 + packed_messages.size()); + inner.push_back(0x01); + inner.push_back(0x00); + inner.push_back(static_cast(messages.size())); + inner.push_back(0x00); + inner.insert(inner.end(), packed_messages.begin(), packed_messages.end()); + + if (inner.size() > MAX_TOTAL_MESSAGE_BYTES) + return {}; + + return encrypt_message_envelope(inner, authority_pubkey); +} + +} // namespace btc diff --git a/src/impl/btc/share_tracker.hpp b/src/impl/btc/share_tracker.hpp new file mode 100644 index 000000000..a945dc267 --- /dev/null +++ b/src/impl/btc/share_tracker.hpp @@ -0,0 +1,2830 @@ +#pragma once + +// Portable 128-bit multiply-shift: (a * b) >> shift +// GCC/Clang have __uint128_t; MSVC uses _umul128 intrinsic. +// shift must be in [1, 63] to avoid undefined behavior from 64-bit shifts. +#ifdef _MSC_VER +#include +inline uint64_t mul128_shift(uint64_t a, uint64_t b, unsigned shift) { + uint64_t hi; + uint64_t lo = _umul128(a, b, &hi); + if (shift == 0) return lo; // (a*b) >> 0 — just the low 64 bits + if (shift >= 64) return hi >> (shift - 64); + return (hi << (64 - shift)) | (lo >> shift); +} +#else +inline uint64_t mul128_shift(uint64_t a, uint64_t b, unsigned shift) { + return static_cast((static_cast<__uint128_t>(a) * b) >> shift); +} +#endif + +#include "share.hpp" +#include "share_check.hpp" +#include "config_pool.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace btc +{ + +struct StaleCounts +{ + uint64_t orphan_count = 0; + uint64_t doa_count = 0; + uint64_t total = 0; +}; + +// --- Scoring types --- + +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. Standard < comparison. + 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, .back() = best. + 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; + } +}; + +// --- Result types --- + +struct TrackerThinkResult +{ + uint256 best; + std::vector> desired; + std::set bad_peer_addresses; + bool punish_aggressively{false}; + // Top-5 scored heads from Phase 4 — used by clean_tracker() to protect + // the best chains from head pruning (p2pool node.py:363). + std::vector top5_heads; +}; + +struct CumulativeWeights +{ + std::map, uint288> weights; + uint288 total_weight; + uint288 total_donation_weight; +}; + +// ── Dense PPLNS Ring Buffer ────────────────────────────────────────────── +// Stores PPLNS-relevant data for each share in the sliding window as a +// contiguous array. Eliminates hash map pointer-chasing during PPLNS walks: +// 8640 sequential reads over ~500KB (fits L2 cache) vs 8640 random hash map +// lookups. ~10x faster per PPLNS computation. +// +// The precomputed decay table guarantees bit-exact results: it uses the same +// iterative truncation as the walk-based code (decay_fp[d+1] = (decay_fp[d] +// * decay_per) >> PRECISION), just stored in a table instead of recomputed. + +struct PPLNSEntry { + uint288 att; // target_to_average_attempts(bits_to_target(bits)) + uint32_t donation{0}; // share donation field + std::vector script; // payout script (variable-length) +}; + +class DensePPLNSRing { +public: + 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; + + // Precomputed decay table: decay_table[d] = iterative decay factor at depth d. + // Computed once, reused for every PPLNS walk. Thread-safe after init. + static std::vector s_decay_table; + static uint64_t s_decay_per; + static bool s_table_initialized; + + static void init_decay_table(uint32_t chain_len) { + if (s_table_initialized && s_decay_table.size() == chain_len) + return; + uint32_t half_life = std::max(chain_len / 4, uint32_t(1)); + s_decay_per = DECAY_SCALE - (DECAY_SCALE * LN2_MICRO) / (uint64_t(1000000) * half_life); + s_decay_table.resize(chain_len); + s_decay_table[0] = DECAY_SCALE; + for (uint32_t d = 1; d < chain_len; ++d) + s_decay_table[d] = mul128_shift(s_decay_table[d-1], s_decay_per, DECAY_PRECISION); + s_table_initialized = true; + } + + // Deque of share entries: index 0 = depth 0 (shallowest/newest in window). + // Deque gives O(1) push/pop at both ends for forward and backward slides. + std::deque m_entries; + uint256 m_tip_hash; // hash of the share whose prev_hash starts this window + int32_t m_chain_len{0}; // target window size + + // Build ring from chain data. O(chain_len) hash map lookups — done once. + // After rebuild, m_entries[0] = share at start (depth 0), + // m_entries[n-1] = deepest share in window. + template + void rebuild(ChainT& chain, const uint256& start, int32_t chain_len) { + init_decay_table(static_cast(chain_len)); + m_entries.clear(); + m_tip_hash = start; + m_chain_len = chain_len; + + auto cur = start; + while (!cur.IsNull() && chain.contains(cur) + && static_cast(m_entries.size()) < chain_len) + { + PPLNSEntry entry; + chain.get_share(cur).invoke([&](auto* obj) { + entry.att = chain::target_to_average_attempts( + chain::bits_to_target(obj->m_bits)); + entry.donation = obj->m_donation; + entry.script = get_share_script(obj); + }); + m_entries.push_back(std::move(entry)); + + auto* idx = chain.get_index(cur); + cur = idx ? idx->tail : uint256(); + } + } + + // Slide window forward: new share enters at front (depth 0), oldest drops. + // Used when extending verification toward the chain tip. + void slide_forward(PPLNSEntry entering) { + m_entries.push_front(std::move(entering)); + if (static_cast(m_entries.size()) > m_chain_len) + m_entries.pop_back(); + } + + // Slide window backward: shallowest share drops, new deeper share added. + // Used by think() Phase 2 which walks backward via get_chain(last, N). + // Each successive share to verify needs PPLNS shifted 1 step deeper. + void slide_backward(PPLNSEntry deeper_entry) { + if (!m_entries.empty()) + m_entries.pop_front(); + m_entries.push_back(std::move(deeper_entry)); + } + + // Compute V36 decayed PPLNS weights from ring buffer. + // Bit-exact with the walk-based code: uses precomputed s_decay_table. + // Sequential-ish memory access (deque blocks) — ~80μs vs ~500μs hash map. + CumulativeWeights compute_v36_weights() const { + CumulativeWeights result; + int32_t n = static_cast(m_entries.size()); + for (int32_t d = 0; d < n; ++d) { + auto& e = m_entries[d]; + uint288 decayed_att = (e.att * uint288(s_decay_table[d])) >> DECAY_PRECISION; + auto addr_w = decayed_att * static_cast(65535 - e.donation); + auto don_w = decayed_att * e.donation; + auto this_total = addr_w + don_w; + result.weights[e.script] += addr_w; + result.total_weight += this_total; + result.total_donation_weight += don_w; + } + return result; + } + + bool empty() const { return m_entries.empty(); } + int32_t size() const { return static_cast(m_entries.size()); } +}; + +// ── Head-Level Incremental PPLNS ───────────────────────────────────────── +// Maintains a DensePPLNSRing + cached CumulativeWeights per active head. +// When verifying consecutive shares along a chain: +// 1. rebuild() at first share's parent: O(chain_len) — one hash map walk +// 2. extend() for each subsequent share: O(1) ring push + O(chain_len) dense recompute +// Total for N shares: O(chain_len + N × chain_len) sequential ops +// vs current: O(N × chain_len) random hash map ops — ~10x faster per op. + +class HeadPPLNS { + DensePPLNSRing m_ring; + CumulativeWeights m_cached; + bool m_dirty{true}; + +public: + // Cold start: build ring from chain. O(chain_len) hash map lookups. + template + void rebuild(ChainT& chain, const uint256& start, int32_t chain_len) { + m_ring.rebuild(chain, start, chain_len); + m_dirty = true; + } + + // Extend forward: new share enters at depth 0. O(1) ring update. + // Used when verifying toward chain tip (new shares extending verified head). + template + void extend_forward(ChainT& chain, const uint256& new_share_hash) { + PPLNSEntry entry; + chain.get_share(new_share_hash).invoke([&](auto* obj) { + entry.att = chain::target_to_average_attempts( + chain::bits_to_target(obj->m_bits)); + entry.donation = obj->m_donation; + entry.script = get_share_script(obj); + }); + m_ring.slide_forward(std::move(entry)); + m_dirty = true; + } + + // Extend backward: depth-0 drops, new deeper share added. O(1) ring update. + // Used by think() Phase 2 which walks backward via get_chain(last, N). + template + void extend_backward(ChainT& chain, const uint256& deeper_share_hash) { + PPLNSEntry entry; + chain.get_share(deeper_share_hash).invoke([&](auto* obj) { + entry.att = chain::target_to_average_attempts( + chain::bits_to_target(obj->m_bits)); + entry.donation = obj->m_donation; + entry.script = get_share_script(obj); + }); + m_ring.slide_backward(std::move(entry)); + m_dirty = true; + } + + // Get cached weights. Recomputes from ring if dirty. + // O(chain_len) dense sequential reads, ~50μs. + const CumulativeWeights& weights() { + if (m_dirty) { + m_cached = m_ring.compute_v36_weights(); + m_dirty = false; + } + return m_cached; + } + + bool valid() const { return !m_ring.empty(); } + int32_t window_size() const { return m_ring.size(); } + const DensePPLNSRing& ring() const { return m_ring; } +}; + +// --- ShareTracker --- + +class ShareTracker +{ +public: + ShareChain chain; + ShareChain verified; + + + // Set by think() Phase 2 when verification budget is exhausted. + // Checked by run_think() to schedule a deferred continuation. + bool m_think_needs_continue{false}; + +private: + // Retry counter for log throttling only — p2pool retries every think() + // with no limit. Counter is cleared on successful verification or + // when the share is removed from the chain (on_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(); + } + +public: + // Callback fired when a share passes verification (for LevelDB persistence) + std::function m_on_share_verified; + // Callback fired when a verified share meets the block target (found block) + std::function m_on_block_found; + // Callback fired when a verified share meets a merged chain target (DOGE block) + // Args: share_hash, pow_hash (for target comparison by caller) + std::function m_on_merged_block_check; + // Callback fired for every verified share with its difficulty + miner script. + // Used to track best share difficulty for dashboard display. + std::function m_on_share_difficulty; + + // Scan the verified best chain for block solutions after startup. + // Uses cached pow_hash from index (stored during original attempt_verify). + // No scrypt recomputation needed — O(1) per share. + void scan_chain_for_blocks(const uint256& tip, int max_shares) + { + if (tip.IsNull() || max_shares <= 0) return; + int scanned = 0, found_ltc = 0, found_merged = 0, no_pow = 0; + uint256 pos = tip; + for (int i = 0; i < max_shares && !pos.IsNull(); ++i) { + if (!chain.contains(pos)) break; + auto* idx = chain.get_index(pos); + if (!idx) break; + + chain.get(pos).share.invoke([&](auto* s) { + ++scanned; + uint256 pow = idx->pow_hash; + if (pow.IsNull()) { ++no_pow; pos = s->m_prev_hash; return; } + + // Check LTC block target + uint256 block_target = chain::bits_to_target(s->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_ltc; } + } + // Check merged targets (V36 shares carry exact data in m_merged_coinbase_info) + if (m_on_merged_block_check) + m_on_merged_block_check(pos, pow); + + pos = s->m_prev_hash; + }); + } + LOG_INFO << "[BLOCK-SCAN] Scanned " << scanned << "/" << max_shares + << " shares: " << found_ltc << " LTC block(s), " + << no_pow << " without cached pow_hash"; + } + + ShareTracker() { + // p2pool SubsetTracker pattern: verified shares navigation + // through the MAIN tracker's skip list. + // SubsetTracker.get_nth_parent_hash = subset_of.get_nth_parent_hash + verified.set_parent_chain(&chain); + + // Connect weight skip list invalidation to chain's removed signal. + // p2pool: WeightsSkipList subscribes to tracker.removed via watch_weakref. + // Without this, pruned shares leave stale entries in weight caches. + 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(); + } + + // -- Add share to the main chain -- + template + void add(ShareT* share) + { + if (!chain.contains(share->m_hash)) + { + try_register_merged_addr(share); + chain.add(share); + } + } + + void add(ShareType share) + { + auto h = share.hash(); + if (!chain.contains(h)) + { + // Register before chain.add() which may move the variant + share.invoke([this](auto* obj) { try_register_merged_addr(obj); }); + chain.add(share); + } + } + + // -- Attempt to verify a share -- + // Returns true if share is verified (already or newly). + // P2: share.check() will be wired here; for now we accept shares + // that have sufficient chain depth. + bool attempt_verify(const uint256& share_hash) + { + if (verified.contains(share_hash)) + return true; + + // p2pool has NO permanently-unverifiable concept — it retries + // share.check() every think() call. Shares that failed during a + // temporary fork may succeed once the fork resolves and the PPLNS + // walk changes. Skipping re-verification created permanent gaps + // in the verified chain → fragmentation (52 verified_tails). + + // NO parent-in-verified filter. p2pool doesn't have one. + // p2pool's attempt_verify has only the guard (height < CL+1 && unrooted). + // score() uses verified for height/work/chain (matching p2pool), + // so fragmentation in the raw chain tracker doesn't affect scoring. + + // p2pool: height, last = self.get_height_and_last(share.hash) + // p2pool's get_height uses get_delta_to_last() which walks through + // SubsetTracker's cached deltas — equivalent to our acc cache. + // Using acc cache height (not O(n) walk) matches p2pool's pattern + // and gives correct height after pruning. + auto acc_height = chain.get_acc_height(share_hash); + auto last = chain.get_last(share_hash); + + // p2pool: 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 to fill the gap. + // DO NOT bypass this guard even if the parent is verified — + // generate_share_transaction needs CHAIN_LENGTH ancestors for correct + // PPLNS. Verifying with 3 ancestors produces wrong coinbase → GENTX-MISMATCH. + // Phase 2 naturally extends verification when parents arrive. + if (acc_height < static_cast(PoolConfig::chain_length()) + 1 && !last.IsNull()) + { + return false; + } + + // P2: init-phase verification (hash-link, merkle, PoW) + check-phase + try + { + auto t0 = std::chrono::steady_clock::now(); + auto& share_var = chain.get_share(share_hash); + share_var.ACTION({ + auto computed_hash = verify_share(*obj, *this); + (void)computed_hash; + }); + { + auto dt = std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0).count(); + static int64_t total_us = 0, count = 0; + total_us += dt; ++count; + if (count % 50 == 0) + LOG_INFO << "[VERIFY-PERF] last=" << dt << "us avg=" + << (total_us/count) << "us count=" << count; + } + } + catch (const std::exception& e) + { + // Counter for log throttling only — p2pool retries every think(). + auto& cnt = m_verify_fail_count[share_hash]; + ++cnt; + // Log first 3 failures verbosely, then every 10th to avoid spam. + 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 pow_hash on the index (for restart block scan — avoids recomputing scrypt) + if (!g_last_pow_hash.IsNull()) { + auto* idx = chain.get_index(share_hash); + if (idx) idx->pow_hash = g_last_pow_hash; + } + + // Add to verified chain + auto& share_var = chain.get_share(share_hash); + if (!verified.contains(share_hash)) + verified.add(share_var); + + // Notify LevelDB persistence layer + if (m_on_share_verified) + m_on_share_verified(share_hash); + + // Block detection: if share_init_verify flagged this share as a block solution, + // fire the callback. Matches p2pool's tracker.verified.added watcher (node.py:289). + 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 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); + }); + } + + // Merged block detection: check ALL verified shares against DOGE target. + // A share can meet the DOGE target without meeting the LTC target + // (DOGE difficulty is much lower). The pow_hash was cached by share_init_verify. + if (m_on_merged_block_check && !g_last_pow_hash.IsNull()) { + m_on_merged_block_check(share_hash, g_last_pow_hash); + } + + // Naughty propagation: if parent is naughty, increment (up to 6 generations) + // Python data.py:1432-1438 — 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; // reset after 6 generations + } + } + } + } + + // Block weight/size check: Python data.py:1508-1511 checks gentx + txs + // against BLOCK_MAX_WEIGHT/SIZE, but only when other_txs is available. + // For V34+ (including V36), other_txs is always None (shares use + // transaction_hash_refs, not embedded txs), so Python SKIPS this check. + // Therefore this is intentionally not computed for V36 shares. + // The coinbase correctness is already verified by share_check() via + // generate_share_transaction comparison. + + return true; + } + + // -- Score a chain from share_hash to CHAIN_LENGTH*15/16 ancestor -- + // Returns (chain_len, hashrate_score) — higher is better. + // p2pool data.py:2335-2347 — uses self.verified for ALL operations. + // May throw if the chain is concurrently modified. + TailScore score(const uint256& share_hash, + const std::function& block_rel_height_func) + { + uint288 score_res; + + // p2pool: head_height = self.verified.get_height(share_hash) + // Must use VERIFIED height — using chain inflates height with + // unverified shares, causing short verified chains to tie on + // chain_len with long chains and win on hashrate tiebreak. + auto head_height = verified.get_acc_height(share_hash); + if (head_height < static_cast(PoolConfig::chain_length())) + return {head_height, score_res}; + + // p2pool: end_point = self.verified.get_nth_parent_hash( + // share_hash, self.net.CHAIN_LENGTH*15//16) + // SubsetTracker delegates to parent's skip list (shared navigation). + auto end_point = verified.get_nth_parent_via_skip(share_hash, + (PoolConfig::chain_length() * 15) / 16); + + // p2pool: self.verified.get_chain(end_point, self.net.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 (0=tip, -N=behind, -1e9=unknown). + // When p2pool can't resolve a block, it computes score = work / (1e9 * 150) + // — tiny but non-zero, so both chains get similar scores and the one + // with more work wins (stable). c2pool must match: use a very large + // confirmation count so the score is tiny but non-zero, preventing + // oscillation where short chains beat long chains simply because the + // long chain's old blocks are unresolvable. + if (!block_height.has_value() || block_height.value() <= 0) + block_height = 1000000; // ~1M confirmations → time_span ≈ 150M seconds + + // p2pool: self.verified.get_delta(share_hash, end_point).work + auto total_work = verified.get_delta_work(share_hash, end_point); + + // p2pool: (0 - block_height + 1) * BLOCK_PERIOD + // c2pool confirmations: 1=tip → 150s, 4 → 600s (matches p2pool). + auto time_span = block_height.value() * 150; // LTC BLOCK_PERIOD = 150s + 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 and punishment -- + // bootstrap_mode: when true, removes verification budget limit so the + // entire chain is verified in one call. Used during initial sync when + // stratum isn't serving work — no IO needs the tracker lock. + // Matches p2pool where think() runs synchronously on the reactor and + // blocks everything else until verification completes. + TrackerThinkResult think(const std::function& block_rel_height_func, + const uint256& previous_block, + uint32_t bits, + bool bootstrap_mode = false) + { + // p2pool: desired is a set of (peer_addr, hash, max_timestamp, min_target) + // The timestamp is used to filter stale requests at return time. + struct DesiredEntry { + NetService peer; + uint256 hash; + uint32_t max_timestamp{0}; // max timestamp from chain head's recent shares + }; + std::vector desired; + std::set desired_hashes; // p2pool: desired = set() — dedup by hash + std::set bad_peer_addresses; + + // Phase 1: Verify unverified heads, remove bad shares. + // Exact translation of p2pool data.py:2077-2108. + // For each unverified head: walk backward, try to verify. + // If verification fails: remove the share (it's bad). + // If no verification possible and chain unrooted: request parents. + std::vector bads; + { + // Snapshot heads — we'll modify chain during iteration + auto heads_snapshot = chain.get_heads(); + int p1_skipped = 0, p1_walk0 = 0, p1_walked = 0, p1_verified = 0, p1_caught = 0; + { + static int p1_log = 0; + if (p1_log++ % 10 == 0) + LOG_INFO << "[think-P1] raw_heads=" << heads_snapshot.size() + << " verified_heads=" << verified.get_heads().size(); + } + for (auto& [head_hash, tail_hash] : heads_snapshot) + { + if (verified.get_heads().contains(head_hash)) { + ++p1_skipped; + continue; + } + + if (!chain.contains(head_hash)) continue; + + auto [head_height, last] = chain.get_height_and_last(head_hash); + + // get_height now returns accumulated height (including parent + // chain for new_fork shares). get_last follows segments to + // the true last. No special fork detection needed. + auto walk_count = last.IsNull() + ? head_height + : std::min(5, std::max(0, head_height - static_cast(PoolConfig::chain_length()))); + + if (walk_count <= 0) { + ++p1_walk0; + // p2pool: when walk_count=0, get_chain returns nothing, + // no shares added to bads. for/else requests parents. + // Do NOT remove height-1 unrooted heads as "stumps" — + // they're new peer shares whose parents haven't arrived yet. + // clean_tracker() eats truly stale heads after 300s. + if (!last.IsNull()) { + // Option A: skip parent requests for chains already in the + // pruning zone (height >= 2*CHAIN_LENGTH+10). These parents + // would be immediately re-pruned by clean_tracker. + auto CL_prune = static_cast(PoolConfig::chain_length()); + if (head_height >= 2 * CL_prune + 10) { + static int prune_skip_log = 0; + if (prune_skip_log++ % 20 == 0) + LOG_INFO << "[think-P1] pruning-zone skip: head=" + << head_hash.GetHex().substr(0,16) + << " height=" << head_height + << " threshold=" << (2*CL_prune+10); + } else if (!desired_hashes.count(last)) { + // Option C: dedup by hash (p2pool: desired = set()) + 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; + } + + ++p1_walked; + 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; + ++p1_verified; + break; + } + // p2pool data.py:2215: bads.append(share.hash) + // ALL failing shares go into bads — p2pool has no + // permanently-unverifiable filter. remove() returns + // false for mid-chain shares (NotImplementedError in + // p2pool), which is caught below. + bads.push_back(hash); + } + } catch (const std::exception& ex) { + ++p1_caught; + LOG_WARNING << "[think-P1] exception walking head " << head_hash.GetHex().substr(0,16) + << " height=" << head_height << " walk=" << walk_count + << ": " << ex.what(); + continue; + } + + // Python for/else: if loop completed without break AND unrooted + if (!verified_one && !last.IsNull()) + { + // Option A: skip if chain already in pruning zone + auto CL_prune = static_cast(PoolConfig::chain_length()); + if (head_height >= 2 * CL_prune + 10) { + static int prune_skip2_log = 0; + if (prune_skip2_log++ % 20 == 0) + LOG_INFO << "[think-P1] pruning-zone skip (for/else): head=" + << head_hash.GetHex().substr(0,16) + << " height=" << head_height; + } else if (!desired_hashes.count(last)) { + // Option C: dedup by 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); + desired.push_back({peer, last, head_ts}); + } + } + } + + // Diagnostic: per-cycle summary + { + static int p1_sum_log = 0; + if (p1_sum_log++ % 5 == 0) + LOG_INFO << "[think-P1-summary] heads=" << heads_snapshot.size() + << " skipped_verified=" << p1_skipped + << " walk0=" << p1_walk0 + << " walked=" << p1_walked + << " verified=" << p1_verified + << " caught=" << p1_caught + << " bads=" << bads.size(); + } + } + + // Phase 1.5 removed — was NOT in p2pool and caused GENTX-MISMATCH. + // It tried to forward-propagate verification from verified parents to + // unverified children, but children on stub forks (chain_len < CHAIN_LENGTH) + // got verified with truncated PPLNS → wrong coinbase. + // p2pool's Phase 2 naturally handles forward extension via rooted chains. + + // Remove bad shares (p2pool data.py:2224-2236). + // p2pool tries self.remove(bad) for ALL bads — catches + // NotImplementedError for mid-chain shares (have dependents). + // c2pool's chain.remove() returns false for that case. + // NO leaf-only filter — p2pool doesn't have one. + { + int removed_count = 0; + for (const auto& bad : bads) + { + if (verified.contains(bad)) + continue; // p2pool: raise ValueError (should never happen) + 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); + + // p2pool data.py:2233-2236: + // try: self.remove(bad) + // except NotImplementedError: pass + // chain.remove() returns false for mid-chain shares + // (equivalent to NotImplementedError). + 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. + // Budget-limited: verify up to THINK_VERIFY_BUDGET shares per call to + // prevent blocking the io_context for tens of seconds. Remaining shares + // are picked up by the next run_think() call (deferred via post()). + // p2pool avoids this problem by persisting verified status — we do too + // now, so budgeting is a safety net for cold starts only. + constexpr int THINK_VERIFY_BUDGET = 100; + int budget_remaining = bootstrap_mode ? INT_MAX : THINK_VERIFY_BUDGET; + m_think_needs_continue = false; + { + static int p2_skip_log = 0; + if (p2_skip_log++ % 20 == 0) + LOG_INFO << "[think-P2-iter] verified_heads=" << verified.get_heads().size() + << " chain=" << chain.size() << " verified=" << verified.size(); + } + // Sort verified heads by work (descending) so the best chain gets + // budget priority. p2pool iterates in arbitrary order but has no + // budget — with our budget, a low-scored side chain can starve the + // best chain if it goes first. + 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; // highest work first + }); + for (auto& [head_hash, tail_hash] : sorted_vheads) + { + if (budget_remaining <= 0) { + m_think_needs_continue = true; + break; + } + + if (!chain.contains(head_hash)) { + static int skip1 = 0; + if (skip1++ < 5) LOG_WARNING << "[think-P2] skip: head not in chain " << head_hash.GetHex().substr(0,16); + continue; + } + + auto [head_height, last_hash] = verified.get_height_and_last(head_hash); + if (!chain.contains(last_hash)) { + static int skip2 = 0; + if (skip2++ < 5) LOG_WARNING << "[think-P2] skip: last not in chain " << last_hash.GetHex().substr(0,16) + << " head_height=" << head_height; + continue; + } + + auto [last_height, last_last_hash] = chain.get_height_and_last(last_hash); + + // p2pool data.py:2098-2103 EXACTLY: + // want = max(self.net.CHAIN_LENGTH - head_height, 0) + // can = max(last_height - 1 - self.net.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); + + { + static int p2_log = 0; + if (p2_log++ % 20 == 0) + LOG_INFO << "[think-P2] head_height=" << head_height + << " last_height=" << last_height + << " last_rooted=" << last_last_hash.IsNull() + << " to_get=" << to_get; + } + + if (to_get > 0) + { + // ── Carry-forward verification with HeadPPLNS ────────── + // Build the PPLNS ring buffer once at the first share's + // prev_hash, then slide backward for each subsequent share. + // The ring buffer result is primed into the decayed cache so + // attempt_verify() → generate_share_transaction() → + // get_v36_decayed_cumulative_weights() hits O(1) cache + // instead of O(chain_len) hash map walk. + 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; + LOG_INFO << "[think-P2] budget exhausted after " << p2_verified_count + << " verifications, deferring remainder"; + break; + } + + // Prime PPLNS cache from ring buffer (if V36 and chain long enough) + // Derive from share version (matches p2pool check(): VERSION >= 36), + // not the removed mutable global. + uint256 prev_hash; + int share_ver = 0; + data.share.invoke([&](auto* obj) { + prev_hash = obj->m_prev_hash; + share_ver = obj->version; + }); + if (share_ver >= 36) { + + if (!prev_hash.IsNull() && chain.contains(prev_hash)) { + if (!pplns_active) { + // First share: full ring build — O(chain_len) hash map lookups + auto ring_t0 = std::chrono::steady_clock::now(); + head_pplns.rebuild(chain, prev_hash, CL_i32); + auto ring_us = std::chrono::duration_cast( + std::chrono::steady_clock::now() - ring_t0).count(); + pplns_active = true; + LOG_INFO << "[PPLNS-RING] built: window=" << head_pplns.window_size() + << " chain_len=" << CL_i32 << " build_time=" << ring_us << "us"; + } else { + // Subsequent shares: slide PPLNS window one step deeper. + // The new window drops the shallowest entry and adds the + // share at depth (chain_len - 1) from the new start. + // + // FIX: deep_hash IS the correct new tail entry (at depth + // chain_len-1 from new start). The previous code took + // deep_hash.tail (one step FURTHER), causing an off-by-one + // that shifted the tail entry one position too deep each + // extension — accumulating PPLNS weight errors and causing + // GENTX mismatches for all V36 shares. + auto ring_depth = head_pplns.window_size(); + if (ring_depth > 0) { + 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 { + // Can't extend — rebuild from scratch + head_pplns.rebuild(chain, prev_hash, CL_i32); + } + } + } + + // Prime the existing decayed cache with ring buffer result + if (pplns_active && head_pplns.valid()) { + auto prime_t0 = std::chrono::steady_clock::now(); + auto& w = const_cast(head_pplns).weights(); + prime_pplns_cache(prev_hash, CL_i32, w); + auto prime_us = std::chrono::duration_cast( + std::chrono::steady_clock::now() - prime_t0).count(); + static int prime_log = 0; + if (prime_log++ % 20 == 0) + LOG_INFO << "[PPLNS-RING] cache primed: addrs=" << w.weights.size() + << " total_weight=" << w.total_weight.GetLow64() + << " compute=" << prime_us << "us" + << " share=" << p2_verified_count; + } + } + } + + // p2pool has no budget — it verifies all shares synchronously. + // With our budget, don't count already-verified shares against it. + // Head B's walk through Head A's verified territory should be free. + bool was_already_verified = verified.contains(hash); + if (!attempt_verify(hash)) + break; + ++p2_verified_count; + if (!was_already_verified) { + --budget_remaining; + } + // Throttled progress log: contabo freeze-diag (2026-05-24) caught + // this loop wedging the io_context for 2-7.5s at a time when emit + // was every 50 entries (173 lines per think cycle on an 8639-share + // chain). Boost::log internal mutex starves the io_context handler + // that runs the same think cycle. Bumped to every 1000 (~9 lines/ + // cycle); the loop runs at >1 verify/ms so a line/sec cadence is + // plenty for diagnostics. + if (p2_verified_count % 1000 == 0) + LOG_INFO << "[think-P2] verifying: " << p2_verified_count << "/" << to_get + << " new=" << (was_already_verified ? "no" : "yes") + << " budget=" << budget_remaining + << " verified_total=" << verified.size(); + } + } + + // Request more shares if verified chain is short + if (head_height < static_cast(PoolConfig::chain_length()) && !last_last_hash.IsNull()) + { + // Option A: check MAIN chain height (not verified height). + // If main chain is already in pruning zone, the unverified + // shares exist — they just need verification, not more parents. + auto main_ht = chain.get_height(head_hash); + auto CL_prune = static_cast(PoolConfig::chain_length()); + if (main_ht >= 2 * CL_prune + 10) { + static int p2_prune_log = 0; + if (p2_prune_log++ % 20 == 0) + LOG_INFO << "[think-P2] pruning-zone skip: head=" + << head_hash.GetHex().substr(0,16) + << " verified_ht=" << head_height + << " main_ht=" << main_ht; + } else if (!desired_hashes.count(last_last_hash)) { + // Option C: dedup by 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 + // p2pool: decorated_tails = sorted((self.score( + // max(self.verified.tails[tail_hash], key=self.verified.get_work), ... + // Uses VERIFIED.get_work (verified TrackerView), NOT chain.get_work. + // SubsetTracker.get_work walks verified items only. + std::vector> decorated_tails; + for (auto& [tail_hash, head_hashes] : verified.get_tails()) + { + // p2pool: max(verified.tails[tail_hash], key=verified.get_work) + 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}); + } catch (const std::exception&) { + // Chain was concurrently modified (trim removed an + // ancestor). Skip this tail — will be 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; + } + + // Debug: log scoring when multiple tails compete + if (decorated_tails.size() > 1) { + static int score_log = 0; + if (score_log++ % 10 == 0) { + for (auto& dt : decorated_tails) { + LOG_INFO << "[SCORE-TAIL] tail=" << dt.hash.GetHex().substr(0,16) + << " chain_len=" << dt.score.chain_len + << " hashrate=" << dt.score.hashrate.IsNull() + << (dt.hash == best_tail ? " ← WINNER" : ""); + } + } + } + + // Phase 4: Score heads within the best tail — pick the best head + 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 { + // p2pool Phase 4: + // self.verified.get_work(self.verified.get_nth_parent_hash(h, min(5, self.verified.get_height(h)))) + // verified.get_height uses verified TrackerView + // verified.get_nth_parent_hash uses SHARED skip list (main tracker) + // verified.get_work uses verified TrackerView + 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; + + // Punish heads: version obsolescence OR naughty (invalid block) + int32_t reason = 0; + { + auto share_version = chain.get_share(hh).version(); + auto lookbehind = static_cast(PoolConfig::chain_length()); + if (should_punish_version(hh, share_version, lookbehind)) + reason = 1; + auto* idx = chain.get_index(hh); + if (idx && idx->naughty > 0) + reason = std::max(reason, idx->naughty); + } + + // p2pool: sort key = (work - min(punish,1)*ata(target), -reason, -time_seen) + // peer_addr is None is COMMENTED OUT in p2pool — no is_local dimension. + // Punishment deducted from work: a punished share is mathematically behind. + uint288 adjusted_work = work_score; + if (reason > 0) { + // Deduct one share's worth of attempts (min(reason,1) * ata(target)) + auto* share_idx = chain.get_index(hh); + if (share_idx) + adjusted_work = adjusted_work - share_idx->work; + } + 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 this head, retry next cycle + } + } + std::sort(decorated_heads.begin(), decorated_heads.end()); + std::sort(traditional_sort.begin(), traditional_sort.end()); + } + + // p2pool: self.punish = punish value from Phase 5 (walk-back result) + bool punish_aggressively = !traditional_sort.empty() && traditional_sort.back().score.neg_reason != 0; + + // Phase 5: Determine best share — p2pool data.py:2142-2166 + // Walk back through punished shares, then find best non-naughty descendent. + uint256 best; + int32_t punish_val = 0; + if (!decorated_heads.empty()) + best = decorated_heads.back().hash; + + if (!best.IsNull() && chain.contains(best)) + { + // Check if best share should be punished + auto* best_idx = chain.get_index(best); + if (best_idx && best_idx->naughty > 0) + { + // Walk back through punished shares + 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); + + // p2pool: if not punish, find best descendent + if (best_idx && best_idx->naughty == 0) + { + // Find deepest non-naughty child via reverse map + 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); + best = desc_hash; + break; + } + } + } + punish_val = (best_idx && best_idx->naughty > 0) ? best_idx->naughty : 0; + } + + // Phase 6: Compute cutoffs for desired shares filtering + 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; + } + + // Filter desired by timestamp cutoff — p2pool data.py:2374 EXACTLY: + // return best, [(peer_addr, hash) for peer_addr, hash, ts, targ in desired + // if ts >= timestamp_cutoff] + // This prevents requesting shares from stale/pruned chain tails that + // peers no longer have. Without this, we hammer peers with unanswerable + // requests and they eventually drop us. + std::vector> desired_result; + for (auto& d : desired) { + if (d.max_timestamp >= timestamp_cutoff) { + desired_result.emplace_back(d.peer, d.hash); + } else { + static int filter_log = 0; + if (filter_log++ % 10 == 0) + LOG_INFO << "[think-DESIRED] filtered stale request: hash=" + << d.hash.GetHex().substr(0,16) + << " ts=" << d.max_timestamp + << " cutoff=" << timestamp_cutoff + << " age=" << (timestamp_cutoff - d.max_timestamp + 3600) << "s"; + } + } + + // Extract top-5 scored heads for clean_tracker (p2pool node.py:363) + 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)}; + } + + // -- Pool hashrate estimation -- + /// Pool hashrate estimation — matches p2pool get_pool_attempts_per_second exactly. + /// Uses skip list O(log n) for ancestor lookup + TrackerView delta cache O(1) for work sum. + /// + /// p2pool (data.py:2489-2499): + /// near = tracker.items[previous_share_hash] + /// far = tracker.items[tracker.get_nth_parent_hash(previous_share_hash, dist - 1)] + /// attempts = tracker.get_delta(near.hash, far.hash).min_work (if min_work) + /// = tracker.get_delta(near.hash, far.hash).work (otherwise) + /// time = near.timestamp - far.timestamp + /// return attempts // time (integer=True in generate_transaction) + uint288 get_pool_attempts_per_second(const uint256& share_hash, int32_t dist, bool use_min_work = false) + { + if (dist < 2 || !chain.contains(share_hash)) + return uint288(0); + + // p2pool: far = tracker.items[tracker.get_nth_parent_hash(share_hash, dist - 1)] + auto far_hash = chain.get_nth_parent_via_skip(share_hash, dist - 1); + if (far_hash.IsNull() || !chain.contains(far_hash)) + return uint288(0); + + // Verify skip list vs naive walk (periodic — detect stale pointers) + { + static int skip_verify = 0; + if (skip_verify++ % 100 == 0) { + try { + auto naive_far = chain.get_nth_parent_key(share_hash, dist - 1); + if (naive_far != far_hash) { + LOG_ERROR << "[SKIP-MISMATCH] dist=" << dist + << " skip=" << far_hash.GetHex().substr(0,16) + << " naive=" << naive_far.GetHex().substr(0,16) + << " near=" << share_hash.GetHex().substr(0,16); + } + } catch (...) {} + } + } + + // p2pool: tracker.get_delta(near.hash, far.hash) — O(1) via TrackerView + auto delta = chain.get_delta(share_hash, far_hash); + uint288 attempts = use_min_work ? delta.min_work : delta.work; + + // p2pool: time = near.timestamp - far.timestamp; if time <= 0: time = 1 + uint32_t near_ts = 0, far_ts = 0; + chain.get_share(share_hash).invoke([&](auto* obj) { near_ts = obj->m_timestamp; }); + chain.get_share(far_hash).invoke([&](auto* obj) { far_ts = obj->m_timestamp; }); + + int32_t time_span = static_cast(near_ts) - static_cast(far_ts); + if (time_span <= 0) time_span = 1; + + return attempts / uint288(time_span); + } + + // -- Share target computation -- + // Computes max_bits and bits for a new share, matching p2pool-v36 + // BaseShare.generate_transaction(): + // 1. Derive pre_target from pool hashrate estimate + // 2. Clamp to ±10% of previous share's max_target + // 3. Apply emergency time-based decay (death spiral prevention) + // 4. Clamp to [MIN_TARGET, MAX_TARGET] + // Returns {max_bits, 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: network-specific share difficulty floor + // Mainnet: 2^236 - 1, Testnet: 2^256/20 - 1 (from PoolConfig) + const uint256 MAX_TARGET = PoolConfig::max_target(); + + if (prev_share_hash.IsNull() || !chain.contains(prev_share_hash)) + { + // Genesis or unknown prev: use MAX_TARGET for max_bits, + // clip desired_target to [MAX_TARGET/30, MAX_TARGET] for bits. + // Matches p2pool generate_transaction (data.py:746-774). + auto pre_target3 = MAX_TARGET; + auto max_bits = chain::target_to_bits_upper_bound(pre_target3); + // No round-up guard needed — truncation matches p2pool's actual behavior + 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); + // No round-up guard needed — truncation matches p2pool's actual behavior + return {max_bits, bits}; + } + + // Use accumulated height from skip list cache — O(1) and correct + // even after pruning (get_height walks until chain end, stops at + // pruned tail → returns short value → triggers MAX_TARGET fallback). + 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)) + { + // Collapse detection: many shares exist but best chain is short + auto total_shares = chain.size(); + if (total_shares > 2 * PoolConfig::chain_length() + && acc_height < static_cast(PoolConfig::TARGET_LOOKBEHIND)) { + static int collapse_warn = 0; + if (collapse_warn++ < 20) { + // Walk raw chain to find actual contiguous height + int32_t walked = 0; + auto cur = prev_share_hash; + while (!cur.IsNull() && chain.contains(cur) && walked < 1000) { + ++walked; + auto* idx = chain.get_index(cur); + cur = idx ? idx->tail : uint256(); + } + LOG_WARNING << "[COLLAPSE-DETECT] Chain structurally broken:" + << " total_shares=" << total_shares + << " acc_height=" << acc_height + << " walked_height=" << walked + << " tails=" << chain.get_tails().size() + << " heads=" << chain.get_heads().size() + << " TARGET_LOOKBEHIND=" << PoolConfig::TARGET_LOOKBEHIND + << " prev=" << prev_share_hash.GetHex().substr(0,16); + } + } + auto pre_target3 = MAX_TARGET; + auto max_bits = chain::target_to_bits_upper_bound(pre_target3); + // No round-up guard needed — truncation matches p2pool's actual behavior + 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); + // No round-up guard needed — truncation matches p2pool's actual behavior + return {max_bits, bits}; + } + + // Step 1: Derive target from pool hashrate. + // Use prev_share_hash directly — all shares counted equally. + // p2pool's algorithm: aps from the entire chain, no filtering. + auto aps = get_pool_attempts_per_second(prev_share_hash, + PoolConfig::TARGET_LOOKBEHIND, /*min_work=*/true); + + // Full APS diagnostic for cross-implementation comparison. + // Dumps all inputs so p2pool's values can be compared. + { + static int cst_diag = 0; + if (cst_diag++ % 50 == 0) { + auto far_hash = chain.get_nth_parent_via_skip(prev_share_hash, + static_cast(PoolConfig::TARGET_LOOKBEHIND) - 1); + uint32_t near_ts = 0, far_ts = 0; + chain.get_share(prev_share_hash).invoke([&](auto* obj) { near_ts = obj->m_timestamp; }); + if (!far_hash.IsNull() && chain.contains(far_hash)) + chain.get_share(far_hash).invoke([&](auto* obj) { far_ts = obj->m_timestamp; }); + auto delta = (!far_hash.IsNull() && chain.contains(far_hash)) + ? chain.get_delta(prev_share_hash, far_hash) + : decltype(chain.get_delta(prev_share_hash, prev_share_hash)){}; + LOG_INFO << "[CST-APS] aps=" << aps.GetLow64() + << " height=" << acc_height + << " prev=" << prev_share_hash.GetHex().substr(0,16) + << " far=" << (far_hash.IsNull() ? "null" : far_hash.GetHex().substr(0,16)) + << " near_ts=" << near_ts << " far_ts=" << far_ts + << " timespan=" << (int32_t(near_ts) - int32_t(far_ts)) + << " delta_h=" << delta.height + << " delta_min_work=" << delta.min_work.GetLow64() + << " delta_work=" << delta.work.GetLow64(); + } + } + + 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); + // Clamp to 256-bit range + 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: Get 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) + // Phase 1b from p2pool-v36: doubles target every SHARE_PERIOD * 10 + // seconds past the threshold of SHARE_PERIOD * 20 seconds since last share. + 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; + // 2^halvings with linear interpolation for fractional part + uint256 eased = prev_max_target; + if (halvings < 256) + eased <<= halvings; + else + eased = MAX_TARGET; + // Linear interpolation: eased = eased * (half_life + remainder) / half_life + 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 + // pre_target2 = clip(pre_target, (clamp_ref * 9/10, clamp_ref * 11/10)) + // p2pool: target * 9 // 10 (multiply first, then divide) + 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 [MIN_TARGET, MAX_TARGET] + // Ensure target is never zero (would produce bits=0 → "share target is zero" error) + 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); + + // No round-up guard needed — truncation matches p2pool's actual behavior + + // Apply 1.67% share cap: desired_target cannot be easier than + // pool_target / 0.0167 ≈ pool_target * 60. This ensures no single + // miner can produce more than ~1.67% of all shares (anti-spam). + // P2pool ref: work.py line 2402: hashrate * SHARE_PERIOD / 0.0167 + uint256 cap_target = pre_target3; // default: pool target (1 share/period) + // cap_target represents the easiest target a single miner should use. + // At pool target difficulty, a miner with all pool hashrate produces + // 1 share/period. At pre_target3 (the pool target), that's 100%. + // desired_target is already clipped to [pre_target3//30, pre_target3] + // so the cap is inherently enforced by the upper clamp to pre_target3. + + // DUST threshold: if the pool share target is too hard for tiny miners, + // allow shares at up to 30x easier difficulty (pre_target3 * 30 would be + // out of bounds, so the 30x range [pre_target3//30, pre_target3] is the + // full allowed range — this is already how p2pool works). + // The key fix is that desired_target = MAX_TARGET (from caller), which + // gets clipped to pre_target3 here — shares at pool difficulty. + // Tiny miners simply find them less often, proportional to hashrate. + + // 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); + + // Periodic full-chain diagnostic for cross-implementation comparison. + // Dumps all intermediate values so p2pool's computation can be matched. + { + static int cst_full = 0; + if (cst_full++ % 200 == 0) { + LOG_INFO << "[CST-FULL] max_bits=0x" << std::hex << max_bits + << " bits=0x" << bits << std::dec + << " aps=" << aps.GetLow64() + << " pre_target_bits=0x" << std::hex + << chain::target_to_bits_upper_bound(pre_target) + << " clamp_ref_bits=0x" + << chain::target_to_bits_upper_bound(clamp_ref_target) + << " lo_bits=0x" << chain::target_to_bits_upper_bound(lo) + << " hi_bits=0x" << chain::target_to_bits_upper_bound(hi) + << " pre2_bits=0x" << chain::target_to_bits_upper_bound(pre_target2) + << " pre3_bits=0x" << chain::target_to_bits_upper_bound(pre_target3) + << std::dec + << " height=" << acc_height + << " prev=" << prev_share_hash.GetHex().substr(0,16); + } + } + return {max_bits, bits}; + } + + // -- Minimum viable hashrate for dashboard display -- + // Returns the minimum hashrate (H/s) needed to produce at least 1 share + // per PPLNS window. With DUST (30x range), tiny miners get easier shares. + struct MinerThresholds { + double min_hashrate_normal; // H/s at pool share difficulty + double min_hashrate_dust; // H/s at 30x easier (DUST) + double min_payout_ltc; // LTC per share at pool difficulty + double pool_hashrate; // current pool H/s estimate + }; + + MinerThresholds get_miner_thresholds(const uint256& prev_share_hash, + uint64_t block_subsidy_sat) + { + MinerThresholds t{}; + if (prev_share_hash.IsNull() || !chain.contains(prev_share_hash)) + return t; + + auto [height, last] = chain.get_height_and_last(prev_share_hash); + if (height < 2) return t; + + auto lookback = std::min(height, + static_cast(PoolConfig::TARGET_LOOKBEHIND)); + auto aps = get_pool_attempts_per_second(prev_share_hash, + lookback, /*min_work=*/true); + + double pool_hr = 0; + { + uint288 aps_288 = aps; + // Convert uint288 to double (approximate) + for (int i = uint288::WIDTH - 1; i >= 0; --i) { + pool_hr = pool_hr * 4294967296.0 + aps_288.pn[i]; + } + } + t.pool_hashrate = pool_hr; + + double share_period = static_cast(PoolConfig::share_period()); + double chain_length = static_cast(PoolConfig::real_chain_length()); + + // min_hashrate = pool_share_att / window = pool_hr / chain_length + t.min_hashrate_normal = pool_hr / chain_length; + t.min_hashrate_dust = t.min_hashrate_normal / 30.0; // 30x DUST range + + // min_payout = block_subsidy / chain_length (one share in full window) + t.min_payout_ltc = static_cast(block_subsidy_sat) / 1e8 / chain_length; + + return t; + } + + // -- 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) + { + if (start.IsNull()) + return {}; + + ensure_weights_skiplist(); + auto sl_result = m_weights_skiplist->query(start, max_shares, desired_weight); + return CumulativeWeights{ + std::move(sl_result.weights), + sl_result.total_weight, + sl_result.total_donation_weight + }; + } + + // -- V36 PPLNS with exponential depth-decay -- + // Matches Python: get_decayed_cumulative_weights() + // half_life = CHAIN_LENGTH // 4 + // Each share's weight is multiplied by 2^(-depth/half_life) + // Fixed-point arithmetic with 40-bit precision. + // + // Result is cached keyed by (start_hash, max_shares) — invalidated + // when the chain head changes. This makes repeated calls for the + // same share (e.g. during verify_share + get_expected_payouts) O(1). + CumulativeWeights get_v36_decayed_cumulative_weights( + const uint256& start, int32_t max_shares, const uint288& desired_weight) + { + if (start.IsNull()) + return {}; + + // Cache: keyed by (start_hash, max_shares, desired_weight). + // Same inputs always produce same outputs (deterministic walk). + // Invalidated when chain head changes (new shares added/removed). + // No consensus risk — cached result is byte-identical to fresh computation. + if (m_decayed_cache_valid && m_decayed_cache_start == start + && m_decayed_cache_shares == max_shares + && m_decayed_cache_desired == desired_weight) + return m_decayed_cache_result; + + 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); + + CumulativeWeights result; + int32_t share_count = 0; + uint64_t decay_fp = DECAY_SCALE; // starts at 1.0 + + // Single-pass walk matching p2pool's while loop in + // get_decayed_cumulative_weights. No pre-collection needed. + auto cur = start; + while (!cur.IsNull() && chain.contains(cur) && share_count < max_shares) + { + chain.get_share(cur).invoke([&](auto* obj) { + auto att = chain::target_to_average_attempts( + chain::bits_to_target(obj->m_bits)); + 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 this_total = addr_w + don_w; // = decayed_att * 65535 + + if (result.total_weight + this_total > desired_weight) { + auto remaining = desired_weight - result.total_weight; + if (!this_total.IsNull()) { + addr_w = addr_w * remaining / this_total; + don_w = don_w * remaining / this_total; + } + this_total = remaining; + } + + auto script = get_share_script(obj); + result.weights[script] += addr_w; + result.total_weight += this_total; + result.total_donation_weight += don_w; + }); + + ++share_count; + if (result.total_weight >= desired_weight) + break; + + decay_fp = mul128_shift(decay_fp, decay_per, DECAY_PRECISION); + + auto* idx = chain.get_index(cur); + cur = idx ? idx->tail : uint256(); + } + + // Cache result (single-entry, invalidated on chain change) + m_decayed_cache_start = start; + m_decayed_cache_shares = max_shares; + m_decayed_cache_desired = desired_weight; + m_decayed_cache_result = result; + m_decayed_cache_valid = true; + + return result; + } + + // -- Diagnostic: per-share V36 PPLNS walk dump -- + // Walks from start_hash backward, logging every share's contribution. + // Output matches p2pool's [PARENT-PPLNS] stderr format for diff comparison. + // Call only from GENTX mismatch handler — never on the hot path. + 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; + + // Script hex prefix (first 10 bytes, like p2pool's key.encode('hex')[:40]) + 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(); + } + + // Summary matching p2pool's [PARENT-PPLNS] format + 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 << "%"; + } + + // Chain gap detection: check if walk terminated early (not at chain bottom) + 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."; + } + } + + // -- Expected payouts from PPLNS weights -- + // Uses exact integer arithmetic matching generate_share_transaction(): + // V36: amount = (uint288(subsidy) * weight / total_weight).GetLow64() + // donation = subsidy - sum(amounts) + std::map, double> + get_expected_payouts(const uint256& best_share_hash, const uint256& block_target, uint64_t subsidy, + const std::vector& donation_script) + { + // Pass REAL_CHAIN_LENGTH as max_shares — the walk naturally stops + // when the chain ends, matching p2pool's direct iteration pattern. + // Do NOT use cached get_height() which can be stale in multi-threaded context. + auto chain_len = static_cast(PoolConfig::real_chain_length()); + // V36: remove desired_weight cap — exponential decay handles windowing. + // See generate_share_transaction() for detailed rationale. + uint288 unlimited_weight; + unlimited_weight.SetHex("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + { + static int ep_log = 0; + if (ep_log++ % 20 == 0) { + LOG_DEBUG_DIAG << "[EP-PPLNS] v36 start=" << best_share_hash.GetHex().substr(0, 16) + << " chain_len=" << chain_len + << " subsidy=" << subsidy; + } + } + auto [weights, total_weight, donation_weight] = get_v36_decayed_cumulative_weights(best_share_hash, chain_len, unlimited_weight); + + std::map, double> result; + uint64_t sum = 0; + + if (!total_weight.IsNull()) + { + for (const auto& [script, weight] : weights) + { + // Exact integer division matching generate_share_transaction (V36) + uint64_t amount = (uint288(subsidy) * weight / total_weight).GetLow64(); + if (amount > 0) + { + result[script] = static_cast(amount); + sum += amount; + } + } + } + + // Remainder goes to donation (matches generate_share_transaction) + uint64_t donation_amount = (subsidy > sum) ? (subsidy - sum) : 0; + + // V36 consensus: donation output must carry >= 1 satoshi (a60f7f7f) + if (donation_amount < 1 && subsidy > 0 && !result.empty()) { + // Deterministic tiebreak: (amount, script) — largest script wins when equal + auto largest = std::max_element(result.begin(), result.end(), + [](const auto& a, const auto& b) { + if (a.second != b.second) return a.second < b.second; + return a.first < b.first; + }); + if (largest != result.end() && largest->second >= 1.0) { + largest->second -= 1.0; + sum -= 1; + donation_amount = subsidy - sum; + } + } + + result[donation_script] = (result.contains(donation_script) ? result[donation_script] : 0.0) + + static_cast(donation_amount); + + return result; + } + + // -- V35 PPLNS expected payouts -- + // Flat (non-decayed) weights, GRANDPARENT start, height-1 window. + // Returns amounts WITHOUT finder fee — caller adds subsidy/200 to the + // share creator's script. Donation absorbs the remainder. + // Reference: p2pool data.py lines 878-965 + std::map, double> + get_v35_expected_payouts(const uint256& best_share_hash, const uint256& block_target, uint64_t subsidy, + const std::vector& donation_script) + { + // V35: PPLNS starts from the GRANDPARENT (prev_share.prev_hash) + // Reference: data.py line 884 + uint256 pplns_start; + if (!best_share_hash.IsNull() && chain.contains(best_share_hash)) { + chain.get(best_share_hash).share.invoke([&](auto* s) { + pplns_start = s->m_prev_hash; // grandparent + }); + } + + if (pplns_start.IsNull()) { + // No grandparent: all subsidy to donation + std::map, double> result; + result[donation_script] = static_cast(subsidy); + return result; + } + + // V35: max_shares = max(0, min(height, REAL_CHAIN_LENGTH) - 1) + // Reference: data.py line 885 + auto height = chain.get_height(best_share_hash); + int32_t max_shares = std::max(0, std::min(height, static_cast(PoolConfig::real_chain_length())) - 1); + + // V35: desired_weight = 65535 * SPREAD * target_to_average_attempts(block_target) + // Reference: data.py line 886 + uint288 desired_weight = chain::target_to_average_attempts(block_target) + * uint288(PoolConfig::SPREAD) * uint288(65535); + + { + static int ep35_log = 0; + if (ep35_log++ % 20 == 0) { + LOG_DEBUG_DIAG << "[EP-PPLNS] v35 start=" << pplns_start.GetHex().substr(0, 16) + << " max_shares=" << max_shares + << " desired_w=" << desired_weight.GetLow64() + << " subsidy=" << subsidy + << " best=" << best_share_hash.GetHex().substr(0, 16); + } + } + // Flat weight accumulation with hard cap (existing get_cumulative_weights) + auto [weights, total_weight, donation_weight] = get_cumulative_weights(pplns_start, max_shares, desired_weight); + + std::map, double> result; + uint64_t sum = 0; + + if (!total_weight.IsNull()) + { + for (const auto& [script, weight] : weights) + { + // V35: 99.5% to PPLNS — subsidy * 199 * weight / (200 * total_weight) + // Reference: data.py line 924 + uint64_t amount = (uint288(subsidy) * uint288(199) * weight / (uint288(200) * total_weight)).GetLow64(); + if (amount > 0) + { + result[script] = static_cast(amount); + sum += amount; + } + } + } + + // Remainder goes to donation (includes the ~0.5% finder fee portion; + // caller subtracts subsidy/200 for the finder and assigns it per-connection) + // V35: NO minimum donation enforcement (unlike v36) + uint64_t donation_amount = (subsidy > sum) ? (subsidy - sum) : 0; + result[donation_script] = (result.contains(donation_script) ? result[donation_script] : 0.0) + + static_cast(donation_amount); + + // Periodic diagnostic dump for cross-impl comparison + { + static int v35_dump = 0; + if (v35_dump++ < 10 || v35_dump % 60 == 0) { + LOG_DEBUG_DIAG << "[V35-PPLNS] subsidy=" << subsidy << " addrs=" << weights.size() + << " total_w=" << total_weight.GetLow64() + << " max_shares=" << max_shares << " sum=" << sum + << " donation=" << donation_amount + << " prev=" << best_share_hash.GetHex().substr(0, 16) + << " grandparent=" << pplns_start.GetHex().substr(0, 16); + } + } + + return result; + } + + // -- Stale share proportion -- + float get_average_stale_prop(const uint256& share_hash, uint64_t lookbehind) + { + auto height = chain.get_height(share_hash); + auto actual_lookbehind = std::min(static_cast(lookbehind), height); + if (actual_lookbehind <= 0) + return 0.0f; + + float stale_count = 0; + auto view = chain.get_chain(share_hash, actual_lookbehind); + for (auto [hash, data] : view) + { + StaleInfo si = StaleInfo::none; + data.share.invoke([&](auto* obj) { si = obj->m_stale_info; }); + if (si != StaleInfo::none) + stale_count += 1.0f; + } + + return stale_count / (stale_count + static_cast(actual_lookbehind)); + } + + // -- Stale share counts by type -- + StaleCounts get_stale_counts(const uint256& share_hash, uint64_t lookbehind) + { + StaleCounts counts; + auto height = chain.get_height(share_hash); + auto actual_lookbehind = std::min(static_cast(lookbehind), height); + if (actual_lookbehind <= 0) + return counts; + + auto view = chain.get_chain(share_hash, actual_lookbehind); + for (auto [hash, data] : view) + { + StaleInfo si = StaleInfo::none; + data.share.invoke([&](auto* obj) { si = obj->m_stale_info; }); + if (si == StaleInfo::orphan) + counts.orphan_count++; + else if (si == StaleInfo::doa) + counts.doa_count++; + } + counts.total = counts.orphan_count + counts.doa_count; + return counts; + } + + // -- Stale change callback registration -- + using stale_callback_t = std::function; + + void subscribe_stale_change(stale_callback_t cb) + { + m_stale_callbacks.push_back(std::move(cb)); + } + + void notify_stale_change(const uint256& share_hash, StaleInfo info) + { + for (auto& cb : m_stale_callbacks) + cb(share_hash, info); + } + + // -- Version counting for AutoRatchet upgrade coordination -- + // Walks back `lookbehind` shares from `share_hash` and counts + // how many desire each version. Returns map of version → count. + // Python ref: tracker.get_desired_version_counts(...) + std::map get_desired_version_counts(const uint256& share_hash, int32_t lookbehind) + { + std::map counts; + if (!chain.contains(share_hash)) + return counts; + auto height = chain.get_height(share_hash); + auto actual = std::min(lookbehind, height); + if (actual <= 0) + return counts; + + auto view = chain.get_chain(share_hash, actual); + for (auto [hash, data] : view) + { + uint64_t dv = 0; + data.share.invoke([&](auto* obj) { dv = obj->m_desired_version; }); + counts[dv]++; + } + return counts; + } + + // -- Merged mining: per-chain PPLNS weights -- + // For a specific aux chain_id, walk the share chain and accumulate PPLNS + // weights for V36-signaling shares. Uses O(log n) skip list. + 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); + + // [DOGE-PPLNS] Per-address breakdown — shows whether autoconverted + // addresses appear in merged PPLNS weights. Log once per new height. + { + static int32_t s_last_doge_height = -1; + auto h = chain.get_height(start); + if (h != s_last_doge_height) { + s_last_doge_height = h; + auto to_dec = [](const uint288& val) -> std::string { + if (val.IsNull()) return "0"; + uint288 tmp = val; std::string r; + 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); + } + r.push_back('0' + static_cast(rem)); + } + std::reverse(r.begin(), r.end()); + return r; + }; + auto to_hex = [](const std::vector& s) -> std::string { + static const char* H = "0123456789abcdef"; + std::string r; r.reserve(s.size() * 2); + for (auto b : s) { r += H[b >> 4]; r += H[b & 0xf]; } + return r; + }; + // Classify script type for diagnostics + auto classify = [](const std::vector& s) -> const char* { + if (is_merged_key(s)) return "MERGED"; + if (s.size() == 25 && s[0] == 0x76 && s[1] == 0xa9) return "P2PKH"; + if (s.size() == 23 && s[0] == 0xa9) return "P2SH"; + if (s.size() == 22 && s[0] == 0x00 && s[1] == 0x14) return "P2WPKH-RAW"; + if (s.size() == 34 && s[0] == 0x00 && s[1] == 0x20) return "P2WSH-RAW"; + if (s.size() == 34 && s[0] == 0x51 && s[1] == 0x20) return "P2TR-RAW"; + return "OTHER"; + }; + LOG_INFO << "[DOGE-PPLNS] chain_id=" << target_chain_id + << " height=" << h + << " max_shares=" << max_shares + << " addrs=" << result.weights.size() + << " total_w=" << to_dec(result.total_weight) + << " don_w=" << to_dec(result.total_donation_weight) + << " start=" << start.GetHex().substr(0, 16); + for (const auto& [key, w] : result.weights) { + double pct = 0; + if (!result.total_weight.IsNull()) + pct = (w * 10000 / result.total_weight).GetLow64() / 100.0; + auto hex = to_hex(key); + LOG_INFO << "[DOGE-PPLNS] " << classify(key) + << " " << hex.substr(0, 40) + << " w=" << to_dec(w) + << " pct=" << std::fixed << std::setprecision(2) << pct << "%"; + } + } + } + + return {std::move(result.weights), result.total_weight, result.total_donation_weight}; + } + + // -- V36-only unified merged weights (no chain_id) -- + // Accumulates PPLNS weights for V36-signaling shares ONLY, keyed by + // parent chain address. Uses O(log n) skip list. + CumulativeWeights get_v36_merged_weights( + const uint256& start, int32_t max_shares, const uint288& desired_weight) + { + if (start.IsNull()) + return {}; + + ensure_v36_skiplist(); + auto result = m_v36_weights_skiplist->query(start, max_shares, desired_weight); + return {std::move(result.weights), result.total_weight, result.total_donation_weight}; + } + + // -- compute_merged_payout_hash -- + // Deterministic hash of V36-only PPLNS weight distribution. + // Committed into V36 shares so peers can verify that the share creator's + // merged mining payouts match the expected distribution. + // + // Format: sorted "addr_hex:weight|...|T:total|D:donation" → SHA256d + // Returns zero uint256 if no V36 shares in window. + // + // Python ref: p2pool/data.py compute_merged_payout_hash() + uint256 compute_merged_payout_hash( + const uint256& prev_share_hash, const uint256& block_target) + { + if (prev_share_hash.IsNull()) + return uint256{}; + + // Use RAW chain — matches p2pool's compute_merged_payout_hash() + // which uses tracker (raw), not tracker.verified. + // Using verified chain caused zero returns when prev_share wasn't + // yet verified → ref_hash mismatch → GENTX-FAIL. + if (!chain.contains(prev_share_hash)) + return uint256{}; + + auto height = chain.get_height(prev_share_hash); + if (height == 0) + return uint256{}; + + // No chain depth guard — p2pool computes merged_payout_hash for ANY + // height > 0 using chain_length = min(height, REAL_CHAIN_LENGTH). + // The previous guard (height < CHAIN_LENGTH → return zero) caused + // ref_hash mismatch because p2pool computed a real hash while c2pool + // returned zero. + + // Unlimited desired_weight — V36 exponential decay handles windowing. + uint288 unlimited_weight; + unlimited_weight.SetHex("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + auto chain_len = std::min(height, + static_cast(PoolConfig::real_chain_length())); + + // Walk RAW chain (not verified) — matches p2pool's compute_merged_payout_hash + // which uses tracker (raw). Using verified chain causes hash mismatch when + // the verified chain is shorter (during sync or with missing ancestors). + 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 { + chain::WeightsDelta delta; + if (!chain.contains(hash)) return delta; + delta.share_count = 1; + chain.get_share(hash).invoke([&](auto* obj) { + if (obj->m_desired_version < 36) return; + auto target = chain::bits_to_target(obj->m_bits); + auto att = chain::target_to_average_attempts(target); + delta.total_weight = att * 65535; + delta.total_donation_weight = att * static_cast(obj->m_donation); + auto raw_script = get_share_script(obj); + if (raw_script.empty()) return; + // Normalize P2WPKH→P2PKH for merged chain compatibility. + // P2TR/P2WSH → empty → skipped (unconvertible). + auto script = normalize_script_for_merged(raw_script); + if (script.empty()) return; + delta.weights[script] = att * static_cast(65535 - obj->m_donation); + }); + return delta; + }, + 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{}; + + // Convert uint288 to decimal string, matching Python's '%d' formatting + auto to_decimal = [](const uint288& val) -> std::string { + if (val.IsNull()) return "0"; + uint288 tmp = val; + std::string result; + 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); + } + result.push_back('0' + static_cast(rem)); + } + std::reverse(result.begin(), result.end()); + return result; + }; + + // Convert script bytes to hex string for deterministic serialization. + // V36 consensus: sort and serialize by script hex (not address string). + // Matches p2pool's compute_merged_payout_hash() which uses script.encode('hex'). + 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) + // Format: "script_hex1:weight1|script_hex2:weight2|...|T:total|D:donation" + 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); + } + // Append total and donation + payload += "|T:"; + payload += to_decimal(total_weight); + payload += "|D:"; + payload += to_decimal(donation_weight); + + // SHA256d (hash256 in p2pool) + auto span = std::span( + reinterpret_cast(payload.data()), payload.size()); + auto hash_result = Hash(span); + + // Per-address merged PPLNS breakdown — log once per new chain height + // for death valley comparison between p2pool and c2pool. + { + static int32_t s_last_log_height = -1; + if (height != s_last_log_height) { + s_last_log_height = 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); + for (const auto& [script_hex, w] : sorted_by_script) { + double pct = 0; + if (!total_weight.IsNull()) { + pct = (w * 10000 / total_weight).GetLow64() / 100.0; + } + LOG_INFO << "[MERGED-PPLNS] " << script_hex.substr(0, 40) + << " w=" << to_decimal(w) + << " pct=" << std::fixed << std::setprecision(2) << pct << "%"; + } + LOG_INFO << "[MERGED-PPLNS] hash=" << hash_result.GetHex(); + + // DOGE payout weights (chain_id=98, with Tier 1/1.5/2 resolution) + // This is what actually determines DOGE coinbase outputs. + // During death valley, compare address count and keys vs [MERGED-PPLNS]. + constexpr uint32_t DOGE_CHAIN_ID = 98; + uint288 doge_unlimited; + doge_unlimited.SetHex("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + auto doge_result = get_merged_cumulative_weights( + prev_share_hash, chain_len, doge_unlimited, DOGE_CHAIN_ID); + auto classify_doge = [](const std::vector& s) -> const char* { + if (is_merged_key(s)) return "MERGED"; + if (s.size() == 25 && s[0] == 0x76 && s[1] == 0xa9) return "P2PKH"; + if (s.size() == 23 && s[0] == 0xa9) return "P2SH"; + if (s.size() == 22 && s[0] == 0x00 && s[1] == 0x14) return "P2WPKH-RAW"; + if (s.size() == 34 && s[0] == 0x00 && s[1] == 0x20) return "P2WSH-RAW"; + if (s.size() == 34 && s[0] == 0x51 && s[1] == 0x20) return "P2TR-RAW"; + return "OTHER"; + }; + auto doge_miner_w = doge_result.total_weight - doge_result.total_donation_weight; + LOG_INFO << "[DOGE-PAYOUT] height=" << height + << " addrs=" << doge_result.weights.size() + << " total_w=" << to_decimal(doge_result.total_weight) + << " don_w=" << to_decimal(doge_result.total_donation_weight); + for (const auto& [script, w] : doge_result.weights) { + double pct = 0; + if (!doge_miner_w.IsNull()) + pct = (w * 10000 / doge_miner_w).GetLow64() / 100.0; + LOG_INFO << "[DOGE-PAYOUT] " << classify_doge(script) + << " " << script_to_hex(script).substr(0, 50) + << " w=" << to_decimal(w) + << " pct=" << std::fixed << std::setprecision(2) << pct << "%"; + } + } + } + + return hash_result; + } + + // -- Merged mining: per-chain expected payouts -- + // Given an aux chain's subsidy and chain_id, computes the expected payout + // distribution using merged PPLNS weights. + // Uses INTEGER arithmetic matching p2pool's build_canonical_merged_coinbase + // exactly — no floating point anywhere. + // + // p2pool algorithm: + // grand_total = total_weight (already includes donation_weight) + // donation_amount = coinbase_value * donation_weight // grand_total + // miners_reward = coinbase_value - donation_amount + // accepted_total = sum of convertible miner weights (== total_weight - donation_weight here) + // per_miner = miners_reward * w // accepted_total + // rounding_remainder = miners_reward - sum(per_miner) + // final_donation = donation_amount + rounding_remainder + std::map, uint64_t> + get_merged_expected_payouts(const uint256& best_share_hash, + const uint256& block_target, + uint64_t subsidy, + uint32_t chain_id, + const std::vector& donation_script, + const std::vector& operator_ltc_script = {}, + const std::vector& operator_merged_script = {}) + { + auto chain_len = std::min(chain.get_height(best_share_hash), + static_cast(PoolConfig::real_chain_length())); + // Unlimited desired_weight — exponential decay handles windowing. + uint288 unlimited_weight; + unlimited_weight.SetHex("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + + auto [weights, total_weight, donation_weight] = + get_merged_cumulative_weights(best_share_hash, chain_len, unlimited_weight, chain_id); + + std::map, uint64_t> result; + + if (total_weight.IsNull() || subsidy == 0) + return result; + + // Integer division matching p2pool exactly (using uint288 throughout + // to avoid uint64 truncation — total_weight routinely exceeds 2^64): + // donation_amount = subsidy * donation_weight // total_weight + uint288 subsidy288(subsidy); + uint64_t donation_amount = 0; + if (!donation_weight.IsNull()) { + donation_amount = (subsidy288 * donation_weight / total_weight).GetLow64(); + } + + // finder_fee = 0 (CANONICAL_MERGED_FINDER_FEE_PER_MILLE = 0 in p2pool) + uint64_t miners_reward = subsidy - donation_amount; + + // p2pool data.py:220-269: accepted_total_weight is the sum of ONLY + // convertible weights (after filtering unconvertible P2WSH/P2TR). + // NOT total_weight - donation_weight (which includes unconvertible). + // First pass: resolve scripts and compute accepted_total + struct ResolvedEntry { + std::vector script; + uint288 weight; + }; + std::vector resolved; + uint288 accepted_total; + for (const auto& [key, weight] : weights) { + std::vector script; + if (!operator_ltc_script.empty() && !operator_merged_script.empty() && key == operator_ltc_script) { + script = operator_merged_script; + } else { + script = resolve_merged_payout_script(key); + } + if (script.empty()) continue; // Unconvertible (P2WSH, P2TR) — skip + resolved.push_back({std::move(script), weight}); + accepted_total = accepted_total + weight; + } + + // Second pass: distribute miners_reward proportionally (uint288 arithmetic) + uint64_t total_distributed = 0; + uint288 miners_reward288(miners_reward); + for (const auto& entry : resolved) { + uint64_t amount = !accepted_total.IsNull() + ? (miners_reward288 * entry.weight / accepted_total).GetLow64() + : 0; + if (amount > 0) { + result[entry.script] += amount; + total_distributed += amount; + } + } + + // Rounding remainder → donation (integer division truncates) + // Guard against uint64 underflow: total_distributed can exceed miners_reward + // when per-miner rounding accumulates beyond the pool (rare with many miners). + uint64_t remainder = (total_distributed <= miners_reward) ? (miners_reward - total_distributed) : 0; + uint64_t final_donation = donation_amount + remainder; + + // V36 CONSENSUS RULE: Donation must be >= 1 satoshi + if (final_donation < 1 && static_cast(subsidy) > 0 && !result.empty()) { + // Deduct from largest miner (deterministic tiebreak by script) + auto largest = std::max_element(result.begin(), result.end(), + [](const auto& a, const auto& b) { + if (a.second != b.second) return a.second < b.second; + return a.first < b.first; + }); + if (largest != result.end()) { + largest->second -= 1; + final_donation += 1; + } + } + + result[donation_script] = (result.contains(donation_script) ? result[donation_script] : 0ULL) + + static_cast(final_donation); + + return result; + } + + // Returns true if shares at `share_version` should be punished because + // a newer version has reached the 95% activation threshold. + // Python ref: share.check() version_after_check logic + bool should_punish_version(const uint256& share_hash, int64_t share_version, int32_t lookbehind) + { + if (!chain.contains(share_hash)) + return false; + auto counts = get_desired_version_counts(share_hash, lookbehind); + auto height = chain.get_height(share_hash); + auto actual = std::min(lookbehind, height); + if (actual <= 0) + return false; + + // Check if any version higher than share_version has >= 95% support + for (auto& [ver, count] : counts) + { + if (static_cast(ver) > share_version) + { + if (count * 100 >= actual * 95) // 95% threshold + return true; + } + } + return false; + } + +private: + std::vector m_stale_callbacks; + + // -- Skip list caches for O(log n) weight queries -- + std::optional m_weights_skiplist; + std::optional m_v36_weights_skiplist; + std::unordered_map m_merged_skiplists; + + // -- Retroactive merged address lookup table -- + // Maps (chain_id) → (parent_script → explicit_merged_script). + // Populated incrementally as V36 shares with explicit merged_addresses + // are added. Used as "Tier 1.5" in the merged skip list lambda: + // when a V36 share has empty merged_addresses (activation-boundary race), + // look up the same miner's explicit address from their other shares. + std::unordered_map, std::vector>> m_miner_merged_addr; + + // Register explicit merged addresses from a share into the lookup table. + // If a NEW miner→address mapping is discovered, invalidates the merged + // skip list for that chain_id so affected shares get recomputed. + template + void try_register_merged_addr(ShareT* share) + { + if constexpr (requires { share->m_merged_addresses; }) + { + if (share->m_desired_version < 36) return; + if (share->m_merged_addresses.empty()) return; + auto parent_script = get_share_script(share); + if (parent_script.empty()) return; + for (const auto& entry : share->m_merged_addresses) + { + if (entry.m_script.m_data.empty()) continue; + auto& lookup = m_miner_merged_addr[entry.m_chain_id]; + bool any_new = false; + // Register under raw script (primary key) + // p2pool v0.14.5: lookup[item.new_script] = script + auto [it, inserted] = lookup.emplace(parent_script, entry.m_script.m_data); + if (inserted) any_new = true; + // Also register under normalized P2PKH form so + // Tier 1.5 catches shares with different script encoding. + // p2pool v0.14.5: lookup[normalized] = script + auto normalized = normalize_script_for_merged(parent_script); + if (!normalized.empty() && normalized != parent_script) { + auto [it2, ins2] = lookup.emplace(normalized, entry.m_script.m_data); + if (ins2) any_new = true; + } + if (any_new) + { + // New mapping — stale skip list entries used auto-convert + // for this miner; recreate to use the explicit address. + m_merged_skiplists.erase(entry.m_chain_id); + auto to_hex_short = [](const std::vector& s, size_t n = 20) { + static const char* H = "0123456789abcdef"; + std::string r; for (size_t i = 0; i < std::min(s.size(), n); ++i) { r += H[s[i]>>4]; r += H[s[i]&0xf]; } + return r; + }; + LOG_INFO << "[MERGED-REG] NEW chain_id=" << entry.m_chain_id + << " parent=" << to_hex_short(parent_script) + << " merged=" << to_hex_short(entry.m_script.m_data) + << " — skiplist invalidated"; + } + } + } + } + + // Previous-share lambda for RAW chain (work templates, general PPLNS) + auto make_previous_fn() + { + return [this](const uint256& hash) -> uint256 { + if (!chain.contains(hash)) return uint256{}; + return chain.get_index(hash)->tail; + }; + } + + // Previous-share lambda for VERIFIED chain (consensus hash computation). + // Ensures compute_merged_payout_hash only walks shares that all peers + // agree on — prevents c2pool's own unverified shares from polluting + // the weight distribution and causing consensus divergence. + auto make_verified_previous_fn() + { + return [this](const uint256& hash) -> uint256 { + if (!verified.contains(hash)) return uint256{}; + return verified.get_index(hash)->tail; + }; + } + + void ensure_weights_skiplist() + { + if (m_weights_skiplist) + return; + m_weights_skiplist.emplace( + [this](const uint256& hash) -> chain::WeightsDelta { + chain::WeightsDelta delta; + if (!chain.contains(hash)) return delta; + delta.share_count = 1; + chain.get_share(hash).invoke([&](auto* obj) { + auto target = chain::bits_to_target(obj->m_bits); + auto att = chain::target_to_average_attempts(target); + delta.total_weight = att * 65535; + delta.total_donation_weight = att * static_cast(obj->m_donation); + auto addr_bytes = get_share_script(obj); + delta.weights[addr_bytes] = att * static_cast(65535 - obj->m_donation); + }); + return delta; + }, + make_previous_fn() + ); + } + + void ensure_v36_skiplist() + { + if (m_v36_weights_skiplist) + return; + m_v36_weights_skiplist.emplace( + [this](const uint256& hash) -> chain::WeightsDelta { + chain::WeightsDelta delta; + if (!chain.contains(hash)) return delta; + delta.share_count = 1; + chain.get_share(hash).invoke([&](auto* obj) { + if (obj->m_desired_version < 36) return; + auto target = chain::bits_to_target(obj->m_bits); + auto att = chain::target_to_average_attempts(target); + auto raw_script = get_share_script(obj); + if (raw_script.empty()) return; + // Normalize P2WPKH→P2PKH for merged chain compatibility. + // P2TR/P2WSH → empty → skipped (unconvertible). + auto script = normalize_script_for_merged(raw_script); + if (script.empty()) return; + // Only set total_weight/donation for convertible scripts + // (matching p2pool: unconvertible → (1, {}, 0, 0)) + delta.total_weight = att * 65535; + delta.total_donation_weight = att * static_cast(obj->m_donation); + delta.weights[script] = att * static_cast(65535 - obj->m_donation); + }); + return delta; + }, + make_previous_fn() + ); + } + + 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 { + chain::WeightsDelta delta; + if (!chain.contains(hash)) return delta; + delta.share_count = 1; + chain.get_share(hash).invoke([&](auto* obj) { + if (obj->m_desired_version < 36) return; + auto target = chain::bits_to_target(obj->m_bits); + auto att = chain::target_to_average_attempts(target); + // NOTE: total_weight and donation_weight are set AFTER + // convertibility check below. p2pool returns (1, {}, 0, 0) + // for unconvertible scripts — zero total, zero donation. + // Setting them here would inflate the denominator. + + std::vector weight_key; + const char* tier_name = "raw:v35"; + // p2pool gates Tier 1 + 1.5 on share.VERSION >= 36. + // V35-format shares always use raw parent script as key, + // even if desired_version >= 36. This keeps V35 and V36 + // weight entries separate per miner during the mixed window. + if constexpr (requires { obj->m_merged_addresses; }) + { + tier_name = "T3:skip"; + // Tier 1: explicit merged_addresses for this chain + for (const auto& entry : obj->m_merged_addresses) + { + if (entry.m_chain_id == chain_id) + { + weight_key = make_merged_key(entry.m_script.m_data); + tier_name = "T1:explicit"; + break; + } + } + // Tier 1.5: retroactive lookup — same miner's + // explicit merged address from their other shares. + // p2pool v0.14.5: try raw, then normalized P2PKH form. + if (weight_key.empty()) + { + auto parent_script = get_share_script(obj); + auto table_it = m_miner_merged_addr.find(chain_id); + if (table_it != m_miner_merged_addr.end()) + { + auto miner_it = table_it->second.find(parent_script); + if (miner_it == table_it->second.end()) { + auto norm = normalize_script_for_merged(parent_script); + if (!norm.empty() && norm != parent_script) + miner_it = table_it->second.find(norm); + } + if (miner_it != table_it->second.end()) { + weight_key = make_merged_key(miner_it->second); + tier_name = "T1.5:retro"; + } + } + // Tier 2: normalize P2WPKH→P2PKH for merged + // chain compatibility. P2TR/P2WSH → empty → skipped. + if (weight_key.empty()) + weight_key = normalize_script_for_merged(parent_script); + } + } + else + { + // V35-format share: normalize P2WPKH→P2PKH for merged + // chain compatibility. P2TR/P2WSH → empty → skipped. + weight_key = normalize_script_for_merged(get_share_script(obj)); + } + // Per-share tier diagnostic + // Log every T1/T1.5/MERGED hit and every 200th otherwise + { + static int s_tier_log_ctr = 0; + bool is_merged = is_merged_key(weight_key); + bool is_p2wpkh = !is_merged && weight_key.size() == 22 && + weight_key[0] == 0x00 && weight_key[1] == 0x14; + if (is_merged || is_p2wpkh || s_tier_log_ctr++ % 200 == 0) { + auto to_hex_short = [](const std::vector& s, size_t n = 20) { + static const char* H = "0123456789abcdef"; + std::string r; for (size_t i = 0; i < std::min(s.size(), n); ++i) { r += H[s[i]>>4]; r += H[s[i]&0xf]; } + return r; + }; + auto parent_script = get_share_script(obj); + LOG_INFO << "[DOGE-TIER] " << tier_name + << " chain_id=" << chain_id + << " ver=" << obj->version + << " dv=" << obj->m_desired_version + << " parent=" << to_hex_short(parent_script) + << " key=" << to_hex_short(weight_key) + << " bits=0x" << std::hex << obj->m_bits << std::dec; + // For V36 shares, log merged_addresses status + if constexpr (requires { obj->m_merged_addresses; }) { + LOG_INFO << "[DOGE-TIER] merged_addrs=" << obj->m_merged_addresses.size() + << " ver=" << obj->version; + for (const auto& entry : obj->m_merged_addresses) { + LOG_INFO << "[DOGE-TIER] chain_id=" << entry.m_chain_id + << " script_len=" << entry.m_script.m_data.size() + << " script=" << to_hex_short(entry.m_script.m_data); + } + } + } + } + // Tier 3: unconvertible — skip, weight redistributed + // p2pool: return (1, {}, 0, 0) — share counts but zero weight + if (weight_key.empty()) return; + // Only set total_weight/donation for convertible scripts + // (matching p2pool MergedWeightsSkipList.get_delta) + delta.total_weight = att * 65535; + delta.total_donation_weight = att * static_cast(obj->m_donation); + delta.weights[weight_key] = att * static_cast(65535 - obj->m_donation); + }); + return delta; + }, + make_previous_fn() + ) + ); + return new_it->second; + } + + void invalidate_weight_caches(const uint256& hash) + { + if (m_weights_skiplist) m_weights_skiplist->forget(hash); + if (m_v36_weights_skiplist) m_v36_weights_skiplist->forget(hash); + for (auto& [_, sl] : m_merged_skiplists) sl.forget(hash); + m_decayed_cache_valid = false; // chain changed — decayed cache stale + } + + // ── Dense PPLNS cache priming ───────────────────────────────────── + // Pre-populate the decayed weights cache from a HeadPPLNS ring buffer. + // Called before each attempt_verify() in think() Phase 2 so that + // generate_share_transaction() → get_v36_decayed_cumulative_weights() + // hits the O(1) cache instead of doing an O(chain_len) walk. + // + // The ring buffer uses the precomputed decay table which is BIT-EXACT + // with the iterative walk (same truncation at each step). This is NOT + // an approximation — it produces identical CumulativeWeights. + 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 unlimited desired_weight + m_decayed_cache_desired.SetHex( + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + m_decayed_cache_result = weights; + m_decayed_cache_valid = true; + } + + // -- Decayed weights result cache -- + // Avoids recomputing the O(chain_length) walk when the same + // (start, max_shares) is queried multiple times between chain changes + // (e.g. verify_share + get_expected_payouts for the same share). + bool m_decayed_cache_valid{false}; + uint256 m_decayed_cache_start; + int32_t m_decayed_cache_shares{0}; + uint288 m_decayed_cache_desired; + CumulativeWeights m_decayed_cache_result; +}; + +} // namespace btc diff --git a/src/impl/btc/share_types.hpp b/src/impl/btc/share_types.hpp new file mode 100644 index 000000000..d055d40ac --- /dev/null +++ b/src/impl/btc/share_types.hpp @@ -0,0 +1,196 @@ +#pragma once + +#include +#include +#include + +namespace btc +{ + +const uint64_t SEGWIT_ACTIVATION_VERSION = 17; + +constexpr bool is_segwit_activated(uint64_t version) +{ + return version >= SEGWIT_ACTIVATION_VERSION; +} + +enum StaleInfo +{ + none = 0, + orphan = 253, + doa = 254 +}; + +struct MerkleLinkParams +{ + const bool allow_index; + + SER_PARAMS_OPFUNC +}; + +constexpr static MerkleLinkParams MERKLE_LINK_SMALL {.allow_index = false}; +constexpr static MerkleLinkParams MERKLE_LINK_FULL {.allow_index = true}; + +struct MerkleLink +{ + std::vector m_branch; + uint32_t m_index{0}; + + MerkleLink() { } + + template + void UnserializeMerkleLink(StreamType& s, const MerkleLinkParams& params) + { + s >> m_branch; + if (params.allow_index) + s >> m_index; + } + + template + void SerializeMerkleLink(StreamType& s, const MerkleLinkParams& params) const + { + s << m_branch; + if (params.allow_index) + s << m_index; + } + + template + inline void Serialize(StreamType& os) const + { + SerializeMerkleLink(os, os.GetParams()); + } + + template + inline void Unserialize(StreamType& is) + { + UnserializeMerkleLink(is, is.GetParams()); + } +}; + +struct SegwitData +{ + MerkleLink m_txid_merkle_link; + uint256 m_wtxid_merkle_root; + + SegwitData() {} + SegwitData(MerkleLink txid_merkle_link, uint256 wtxid) : m_txid_merkle_link(txid_merkle_link), m_wtxid_merkle_root(wtxid) { } + + SERIALIZE_METHODS(SegwitData) { READWRITE(MERKLE_LINK_SMALL(obj.m_txid_merkle_link), obj.m_wtxid_merkle_root); } +}; + +struct SegwitDataDefault +{ + static SegwitData get() + { + // Sentinel must match p2pool's PossiblyNoneType sentinel: + // dict(txid_merkle_link=dict(branch=[], index=0), wtxid_merkle_root=0) + // Using all-0xff caused p2pool to interpret "None" as a valid wtxid root, + // producing different witness commitment → different coinbase txid. + return SegwitData{{}, uint256()}; // zero = None sentinel + } +}; + +struct TxHashRefs +{ + uint64_t m_share_count; + uint64_t m_tx_count; + + TxHashRefs() = default; + TxHashRefs(uint64_t share, uint64_t tx) : m_share_count(share), m_tx_count(tx) {} + + SERIALIZE_METHODS(TxHashRefs) { READWRITE(VarInt(obj.m_share_count), VarInt(obj.m_tx_count)); } +}; + +struct ShareTxInfo +{ + std::vector m_new_transaction_hashes; + std::vector m_transaction_hash_refs; //pack.ListType(pack.VarIntType(), 2)), # pairs of share_count, tx_count + + ShareTxInfo() = default; + + ShareTxInfo(const auto& new_tx_hashes, const auto& tx_hash_refs) + : m_new_transaction_hashes(new_tx_hashes), m_transaction_hash_refs(tx_hash_refs) { } + + SERIALIZE_METHODS(ShareTxInfo) { READWRITE(obj.m_new_transaction_hashes, obj.m_transaction_hash_refs); } +}; + +struct HashLinkType +{ + FixedStrType<32> m_state; //pack.FixedStrType(32) + // FixedStrType<0> m_extra_data; //pack.FixedStrType(0) # bit of a hack, but since the donation script is at the end, const_ending is long enough to always make this empty + uint64_t m_length; //pack.VarIntType() + + SERIALIZE_METHODS(HashLinkType) { READWRITE(obj.m_state, /*obj.m_extra_data,*/ VarInt(obj.m_length)); } +}; + +// V36 hash link — extra_data becomes VarStr (was FixedStr(0) pre-V36) +struct V36HashLinkType +{ + FixedStrType<32> m_state; + BaseScript m_extra_data; // VarStr in V36 + uint64_t m_length; + + SERIALIZE_METHODS(V36HashLinkType) { READWRITE(obj.m_state, obj.m_extra_data, VarInt(obj.m_length)); } +}; + +// V36 merged mining: per-chain address entry +struct MergedAddressEntry +{ + uint32_t m_chain_id; + BaseScript m_script; + + SERIALIZE_METHODS(MergedAddressEntry) { READWRITE(obj.m_chain_id, obj.m_script); } +}; + +// V36 merged mining: per-chain coinbase verification entry +struct MergedCoinbaseEntry +{ + uint32_t m_chain_id; + uint64_t m_coinbase_value; + uint32_t m_block_height; + FixedStrType<80> m_block_header; + MerkleLink m_coinbase_merkle_link; + BaseScript m_coinbase_script; // V36: actual scriptSig (allows custom tags + THE state_root) + + template + void Serialize(StreamType& os) const + { + ::Serialize(os, m_chain_id); + ::Serialize(os, Using(m_coinbase_value)); + ::Serialize(os, Using(m_block_height)); + ::Serialize(os, m_block_header); + ParamPackStream pstream{MERKLE_LINK_SMALL, os}; + ::Serialize(pstream, m_coinbase_merkle_link); + ::Serialize(os, m_coinbase_script); + } + + template + void Unserialize(StreamType& is) + { + ::Unserialize(is, m_chain_id); + ::Unserialize(is, Using(m_coinbase_value)); + ::Unserialize(is, Using(m_block_height)); + ::Unserialize(is, m_block_header); + ParamPackStream pstream{MERKLE_LINK_SMALL, is}; + ::Unserialize(pstream, m_coinbase_merkle_link); + ::Unserialize(is, m_coinbase_script); + } +}; + +// V36: abswork is VarInt-encoded on the wire but stored as uint128 +struct AbsworkV36Format +{ + template + static void Write(StreamType& os, const uint128& value) + { + WriteCompactSize(os, value.GetLow64()); + } + + template + static void Read(StreamType& is, uint128& value) + { + value = uint128(ReadCompactSize(is, false)); + } +}; + +} // namespace btc diff --git a/src/impl/btc/stratum/CMakeLists.txt b/src/impl/btc/stratum/CMakeLists.txt new file mode 100644 index 000000000..2fc020c33 --- /dev/null +++ b/src/impl/btc/stratum/CMakeLists.txt @@ -0,0 +1,20 @@ +# btc::stratum module — concrete IWorkSource implementation for c2pool-btc. +# Bridges core::StratumServer (coin-agnostic protocol layer) to BTC's +# template_builder + sharechain + B5 submit path. Stage 4 of B4-stratum. + +add_library(btc_stratum + work_source.hpp work_source.cpp +) + +target_link_libraries(btc_stratum + core + btc_coin + btclibs + nlohmann_json::nlohmann_json + ${Boost_LIBRARIES} +) + +# Include parent so #include resolves +target_include_directories(btc_stratum PUBLIC + ${CMAKE_SOURCE_DIR}/src +) diff --git a/src/impl/btc/stratum/work_source.cpp b/src/impl/btc/stratum/work_source.cpp new file mode 100644 index 000000000..54ef5f10a --- /dev/null +++ b/src/impl/btc/stratum/work_source.cpp @@ -0,0 +1,1034 @@ +// btc::stratum::BTCWorkSource — Stage 4a skeleton. +// +// All IWorkSource methods are stubbed to safe defaults. Subsequent +// sub-stages flesh them out: +// Stage 4b: read-only getters (config, prevhash, generation, workers) +// Stage 4c: work generation (template, merkle branches, coinbase) +// Stage 4d: mining_submit hot path (PoW classify + B5 dispatch) +// +// The skeleton is intentionally non-functional but compiles, instantiates, +// and lets us validate the wiring in main_btc.cpp end-to-end before +// implementing the substantive logic. + +#include +#include + +#include +#include +#include // build_template + merkle_hash_pair +#include + +#include // address_to_script (for share write path) +#include +#include +#include // chain::target_to_difficulty +#include // HexStr, ParseHex + +#include +#include +#include + +namespace { + +// ── Byte-stream helpers used by coinbase + header construction ────────────── +// All encodings are little-endian (Bitcoin wire format). Push helpers append +// to a std::vector for incremental building; the result becomes the +// raw serialized form ready for HexStr() conversion. + +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); + } +} + +// BIP 34 minimally-encoded height push for the coinbase scriptSig. +// Returns: [opcode_pushbytes_n][n bytes height_LE], where n is the smallest +// number of bytes needed to encode the height with the high bit clear (script +// integer convention — sign-bit safety prevents the value from being parsed +// as negative). +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; +} + +// Parse a BE hex string into a uint32_t. Stratum sends ntime/nonce/version +// as 8-char BE hex; sscanf(%x) is enough. +inline uint32_t parse_be_hex_u32(const std::string& s) { + uint32_t v = 0; + std::sscanf(s.c_str(), "%x", &v); + return v; +} + +// BIP 141 witness reserved value: 32 zero bytes embedded as the only +// stack item in the coinbase witness. The witness commitment in the +// coinbase OP_RETURN is computed against this exact value. +constexpr std::array WITNESS_RESERVED_VALUE{}; // all zeros + +// BIP 141 witness commitment magic: OP_RETURN OP_PUSHBYTES_36 + "aa21a9ed". +// Followed by 32 bytes of commitment hash for total OP_RETURN script of 38 bytes. +constexpr std::array WITNESS_COMMIT_HEADER = { + 0x6a, 0x24, 0xaa, 0x21, 0xa9, 0xed +}; + +} // anonymous namespace + +namespace btc::stratum { + +BTCWorkSource::BTCWorkSource(btc::coin::HeaderChain& chain, + btc::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 << "[BTC-STRATUM] BTCWorkSource constructed" + << " (testnet=" << is_testnet_ + << " min_diff=" << config_.min_difficulty + << " max_diff=" << config_.max_difficulty + << " target_time=" << config_.target_time + << "s vardiff=" << (config_.vardiff_enabled ? "on" : "off") << ")"; +} + +BTCWorkSource::~BTCWorkSource() = default; + +// ───────────────────────────────────────────────────────────────────────────── +// IWorkSource: config + read-only state — Stage 4b will fill these in. +// ───────────────────────────────────────────────────────────────────────────── + +const core::stratum::StratumConfig& BTCWorkSource::get_stratum_config() const +{ + return config_; +} + +std::function BTCWorkSource::get_best_share_hash_fn() const +{ + std::lock_guard lk(best_share_mutex_); + return best_share_hash_fn_; // empty function until set_best_share_hash_fn() called +} + +std::string BTCWorkSource::get_current_gbt_prevhash() const +{ + // BE display-hex of the current bitcoind chain tip. Stratum sessions + // use this both as the `prevhash` field in mining.notify and as the + // dedup key for `clean_jobs` detection (when prevhash changes, all + // outstanding jobs are invalidated and miners reset). + // + // Empty string if HeaderChain has no tip yet (uninitialized / pre-IBD). + auto tip = chain_.tip(); + if (!tip) return {}; + return tip->block_hash.GetHex(); +} + +uint64_t BTCWorkSource::get_work_generation() const +{ + return work_generation_.load(std::memory_order_relaxed); +} + +bool BTCWorkSource::has_merged_chain(uint32_t /*chain_id*/) const +{ + // BTC MVP: no merged mining (the LTC MM rig is DOGE — none for BTC currently). + return false; +} + +// ───────────────────────────────────────────────────────────────────────────── +// IWorkSource: per-connection bookkeeping — minimal but real now. +// ───────────────────────────────────────────────────────────────────────────── + +void BTCWorkSource::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 << "[BTC-STRATUM] worker registered: session=" << session_id + << " user=" << info.username + << " worker=" << info.worker_name + << " endpoint=" << info.remote_endpoint; +} + +void BTCWorkSource::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 << "[BTC-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 BTCWorkSource::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 — Stage 4c will fill these in. +// ───────────────────────────────────────────────────────────────────────────── + +nlohmann::json BTCWorkSource::get_current_work_template() const +{ + // TemplateBuilder::build_template already shapes the result as a + // GBT-style nlohmann::json in WorkData::m_data — exactly what stratum + // sessions expect (previousblockhash, bits, version, curtime, mintime, + // height, coinbasevalue, transactions[]). Return that directly. + // + // Returns an empty object if HeaderChain isn't past genesis yet — the + // session will skip work-push and retry on next poll. + auto wd = btc::coin::TemplateBuilder::build_template(chain_, mempool_, is_testnet_); + if (!wd) return nlohmann::json::object(); + return wd->m_data; +} + +std::vector BTCWorkSource::get_stratum_merkle_branches() const +{ + // Stratum merkle branches: at each level, the SIBLING of the left-most + // node (the one that descends from the coinbase). The miner reconstructs + // the merkle root by: + // hash_0 = SHA256d(coinbase_txid || branch[0]) + // hash_1 = SHA256d(hash_0 || branch[1]) + // ...continue until single hash → merkle_root + // + // Algorithm matches Bitcoin Core's merkle.cpp: at every level, if the + // count is odd, duplicate the last element. The branches list is + // typically log2(N) entries for N transactions. + auto wd = btc::coin::TemplateBuilder::build_template(chain_, mempool_, is_testnet_); + if (!wd || wd->m_hashes.empty()) return {}; + + // wd->m_hashes[0] is the coinbase placeholder — the actual coinbase + // hash doesn't matter for branch computation since the miner provides + // their own coinbase. We only need the structure. + std::vector level = wd->m_hashes; + std::vector branches; + while (level.size() > 1) { + // Right-sibling of the left-most node = level[1]. + // Wire encoding: hex of LE-internal bytes (NOT GetHex() which is + // BE display). Matches cgminer convention + LTC's working + // compute_merkle_branches (web_server.cpp:1299). The miner does + // hex2bin on this string and uses bytes directly in SHA256d, so + // the bytes on the wire MUST be the same LE-internal bytes the + // pool used to build the merkle tree. GetHex() reverses them and + // produces a totally different merkle root in the miner's view. + branches.push_back(HexStr(std::span(level[1].data(), 32))); + + // Ascend: place a placeholder for the next-level coinbase combo, + // then hash subsequent pairs (duplicate last on odd count). + std::vector next; + next.reserve((level.size() + 2) / 2); + next.push_back(uint256::ZERO); // placeholder for combo of (cb, level[1]) + 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(btc::coin::merkle_hash_pair(l, r)); + } + level = std::move(next); + } + return branches; +} + +std::pair BTCWorkSource::get_coinbase_parts() const +{ + // Fallback coinbase split with no payout output (the per-connection + // builder produces the real one). Used only when stratum sessions + // ask for default parts before authorize completes — they then skip + // mining.notify until build_connection_coinbase becomes available. + return { {}, {} }; +} + +core::stratum::CoinbaseResult BTCWorkSource::build_connection_coinbase( + const uint256& prev_share_hash, + const std::string& /*extranonce1_hex*/, + const std::vector& payout_script, + const std::vector>>& /*merged_addrs*/) const +{ + // c2pool BTC v35 coinbase layout (Phase 8b — full PPLNS + ref_hash). + // + // Reference: core/web_server.cpp::MiningInterface::build_coinbase_parts + // (lines 1576-1735). We port that algorithm with BTC v35 simplifications + // (no DOGE merged-mining, no state_root V37 prep, no MWEB): + // + // ScriptSig (no extranonce — c2pool puts it in OP_RETURN nonce instead): + // [BIP 34 height push — incl. opcode prefix] + // [/c2pool-btc/ tag with 1-byte push opcode prefix] + // + // Outputs: + // ── (segwit witness commitment FIRST if active — TODO 8c) + // ── PPLNS payouts (sorted asc by amount, asc by script) + // + finder fee subsidy/200 added to miner, deducted from donation + // ── OP_RETURN: 0 sats, script = 6a 28 [ref_hash 32B] [nonce 8B-slot] + // The 8-byte nonce slot is filled at submit time by + // extranonce1(4) || extranonce2(4) — see c2pool's hash_link + // deterministic-coinbase scheme. + // + // coinb1 = entire coinbase tx UP TO AND INCLUDING ref_hash (NOT the + // 8B nonce slot) + // coinb2 = "00000000" (just locktime) + // + // If pplns_fn_ or ref_hash_fn_ are unset (cold start, ShareTracker not + // ready) the coinbase degrades gracefully: single-output paying full + // subsidy to the miner, no OP_RETURN. Valid BTC block but no c2pool + // sharechain credit. + + auto wd = btc::coin::TemplateBuilder::build_template(chain_, mempool_, is_testnet_); + 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}); + + // GBT block-target bits (NOT the share-target bits — that's in nbits). + 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 = wd->m_data.value("curtime", uint32_t{0}); + + // Detect segwit activation. GBT exposes this in two redundant ways: + // a "rules" array containing "!segwit" or "segwit", or the presence of + // a non-empty "default_witness_commitment" (which TemplateBuilder doesn't + // currently emit, so we go off "rules"). For BTC mainnet segwit has been + // active since 2017, so for our purposes this is effectively always true + // post-IBD. We still gate on it to remain correct for testnet edges and + // synthetic test fixtures. + bool segwit_active = false; + if (auto it = wd->m_data.find("rules"); it != wd->m_data.end() && it->is_array()) { + for (const auto& r : *it) { + if (!r.is_string()) continue; + auto s = r.get(); + if (s == "segwit" || s == "!segwit") { segwit_active = true; break; } + } + } + + // Snapshot callbacks under the lock (invoke them unlocked). + 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 assembly (always the same — coinbase deterministic) ── + auto bip34 = bip34_height_push(height); + static const std::string POOL_TAG = "/c2pool-btc/"; + + std::vector scriptsig; + scriptsig.insert(scriptsig.end(), bip34.begin(), bip34.end()); + if (!POOL_TAG.empty() && POOL_TAG.size() < 76) { + // Push opcode: for 1-75 bytes, the opcode IS the length. + 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 << "[BTC-STRATUM] pplns_fn threw: " << e.what() + << " — falling back to single-output coinbase"; + payouts.clear(); + } + } + + // Drop any empty-script entries from the PPLNS map BEFORE applying the + // finder fee. Empty payout_script means the share's miner had an + // unrecognized/unsupported address (e.g. bech32 P2WSH that the stratum + // address validator couldn't decode). Their sats would otherwise become + // an unspendable 0-script output in the coinbase. Drop them — the value + // flows back to the donation residual via the post-PPLNS rebalance below. + { + 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) { + LOG_INFO << "[BTC-STRATUM] PPLNS: dropped " << dropped_n + << " empty-script entries (" << dropped_value + << " sats reabsorbed to donation)"; + // Reabsorb the dropped value into donation so total payouts == subsidy. + if (!donation_script.empty()) + payouts[donation_script] += static_cast(dropped_value); + } + } + + // v35 finder fee: 0.5% of subsidy goes to the share-finder (this miner), + // deducted from donation. Reference: c2pool_refactored.cpp wiring + + // share_tracker.hpp v35 PPLNS docs ("amounts WITHOUT finder fee — caller + // adds subsidy/200 to the share creator's script"). Conditional on having + // a non-empty payout_script — otherwise we'd reintroduce the empty-output + // bug we just filtered. And the deduction-from-donation must succeed + // (donation must hold ≥ finder_fee), else we'd inflate total > subsidy. + 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; + } + // else: donation can't cover the fee — skip silently. Total stays + // at subsidy (per get_v35_expected_payouts post-condition). + } + } + + // Degraded fallback: full subsidy → miner (only if miner address is OK). + if (payouts.empty() && !payout_script.empty()) { + payouts[payout_script] = static_cast(coinbasevalue); + } + + // ── Sort outputs: by amount asc, then by script asc (matches LTC) ── + std::vector, uint64_t>> outputs; + outputs.reserve(payouts.size()); + for (const auto& [script, amount_d] : payouts) { + // Round to satoshi. Anything < 1 sat is dust — drop it (matches + // Bitcoin Core dust-out-policy + LTC payout manager behaviour). + // Empty scripts already filtered above but defense-in-depth. + 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 + chain-walked frozen_ref values (Phase 12) ── + // The ref_hash_fn lambda walks the share tracker for the actual + // network share target (bits/max_bits) plus the chain-position + // fields (absheight/abswork/far_share_hash) and the clipped + // timestamp. We update the share_bits_/share_max_bits_ atomics so + // stratum_server's pool_difficulty gate sees the live network share + // difficulty (was forever 0 before Phase 12 → mining_submit was + // never called for ordinary pseudoshares → chain-share creation + // never triggered). + 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 << "[BTC-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(); + + // ── BIP 141 witness commitment (Output 0 if segwit active) ── + // + // Required for mainnet block acceptance since segwit activation + // (2017-08). bitcoind validates: SHA256d(witness_root || reserved_value) + // == commitment_hash present in coinbase OP_RETURN with magic aa21a9ed. + // + // witness_root is the merkle root of WTXIDs of all txs in the block. + // The coinbase WTXID is conventionally 32 zero bytes (BIP 141), so it + // takes the leftmost slot. Other txs' wtxids come from the GBT + // template's "hash" field (TemplateBuilder line 226 emits this). + // + // For coinbase-only templates (no other txs) the witness root is just + // the coinbase wtxid (zero), so commitment = SHA256d(zero32 || zero32). + std::vector witness_commitment_script; // empty if segwit not active + uint256 witness_root_uint; + if (segwit_active) { + std::vector wtxids; + wtxids.reserve(1 + (wd->m_txs.size())); + wtxids.push_back(uint256::ZERO); // coinbase wtxid placeholder + if (auto txs_field = wd->m_data.find("transactions"); + txs_field != wd->m_data.end() && txs_field->is_array()) + { + for (const auto& t : *txs_field) { + if (!t.is_object()) continue; + if (auto h = t.find("hash"); h != t.end() && h->is_string()) { + uint256 wt; wt.SetHex(h->get().c_str()); + wtxids.push_back(wt); + } + } + } + // Bitcoin Core merkle: pad odd levels by duplicating last. + std::vector level = std::move(wtxids); + while (level.size() > 1) { + std::vector next; + next.reserve((level.size() + 1) / 2); + for (size_t i = 0; 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(btc::coin::merkle_hash_pair(l, r)); + } + level = std::move(next); + } + witness_root_uint = level.empty() ? uint256::ZERO : level[0]; + + // commitment_hash = SHA256d(witness_root || witness_reserved_value) + std::array commit_in; + std::memcpy(commit_in.data(), witness_root_uint.data(), 32); + std::memcpy(commit_in.data() + 32, WITNESS_RESERVED_VALUE.data(), 32); + uint256 commit_hash = Hash(std::span(commit_in.data(), 64)); + + // Build OP_RETURN script: 0x6a 0x24 [aa21a9ed] [commit 32B] = 38 bytes total + witness_commitment_script.reserve(38); + witness_commitment_script.insert(witness_commitment_script.end(), + WITNESS_COMMIT_HEADER.begin(), WITNESS_COMMIT_HEADER.end()); + witness_commitment_script.insert(witness_commitment_script.end(), + commit_hash.data(), commit_hash.data() + 32); + } + + // ── Assemble coinb1: full tx up to (and including) ref_hash ── + // Output count = [witness commitment if segwit] + [PPLNS outputs] + [OP_RETURN ref_hash if any] + const size_t output_count = (segwit_active ? 1 : 0) + + outputs.size() + + (emit_op_return ? 1 : 0); + + std::vector coinb1; + push_u32_le(coinb1, /*tx version*/ 1); // c2pool reference uses version 1 + 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); + + // Output 0: BIP 141 witness commitment (FIRST so it's stable across + // PPLNS reordering — bitcoind scans for the LAST aa21a9ed commitment + // but stable position helps reproducibility). + if (segwit_active) { + push_u64_le(coinb1, /*sats*/ 0); + push_varint(coinb1, witness_commitment_script.size()); + coinb1.insert(coinb1.end(), + witness_commitment_script.begin(), witness_commitment_script.end()); + } + + // PPLNS / payout outputs (already sorted) + for (const auto& [script, amount] : outputs) { + push_u64_le(coinb1, amount); + push_varint(coinb1, script.size()); + coinb1.insert(coinb1.end(), script.begin(), script.end()); + } + + // Output last: OP_RETURN with c2pool ref_hash + 8B nonce slot. + // Script: 6a (OP_RETURN) 28 (PUSH_40) ref_hash(32) nonce(8) — total 42 bytes. + // The 8B nonce comes from extranonce1+extranonce2 between coinb1 and coinb2. + if (emit_op_return) { + push_u64_le(coinb1, /*sats*/ 0); + 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] + } + + // ── coinb2: locktime only ── + std::vector coinb2; + push_u32_le(coinb2, 0u); // locktime = 0 + + core::stratum::CoinbaseResult result; + result.coinb1 = HexStr(std::span(coinb1.data(), coinb1.size())); + result.coinb2 = HexStr(std::span(coinb2.data(), coinb2.size())); + + // ── Snapshot — frozen state matching this coinbase ── + auto& snap = result.snapshot; + snap.subsidy = coinbasevalue; + snap.segwit_active = segwit_active; + snap.witness_root = witness_root_uint; + if (!witness_commitment_script.empty()) { + snap.witness_commitment_hex = HexStr(std::span( + witness_commitment_script.data(), witness_commitment_script.size())); + } + snap.frozen_ref.share_version = 35; // jtoomim BTC v35 + snap.frozen_ref.desired_version = 35; + // Phase 12: source bits/max_bits/absheight/abswork/far_share_hash + // from the ref_hash_fn result (which already walked the tracker for + // the same values to feed compute_ref_hash_for_work). With these + // populated, has_frozen=true in the create_share lambda properly + // overrides create_local_share_v35's in-function tracker walk — + // the override values are now correct (matching peers), instead of + // forcing absheight=0 + share.m_bits=hardcoded-diff-1. + 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); + } + auto txs_field = wd->m_data.find("transactions"); + if (txs_field != wd->m_data.end() && txs_field->is_array()) { + // a1 (shared_ptr + lazy materialize): build the per-tx hex vector ONCE + // and hand the snapshot a shared_ptr to it, so the copies made along + // CoinbaseResult -> JobSnapshot -> JobEntry become refcount bumps, not + // deep copies of the full mempool tx hex (the H5 churn site). + auto txd = std::make_shared>(); + for (const auto& t : *txs_field) { + if (t.is_object() && t.contains("data") && t["data"].is_string()) + txd->push_back(t["data"].get()); + } + snap.tx_data = std::move(txd); + } + snap.merkle_branches = std::move(branches); + + return result; +} + +// ───────────────────────────────────────────────────────────────────────────── +// IWorkSource: share submission — Stage 4d (the hot path). +// ───────────────────────────────────────────────────────────────────────────── + +nlohmann::json BTCWorkSource::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) +{ + // Stratum-style JSON-RPC error payload (false + [code, message, null]). + auto reject = [](int code, const char* msg) { + return nlohmann::json::array({ + false, nlohmann::json::array({code, msg, nullptr}) + }); + }; + + if (!job) { + LOG_WARNING << "[BTC-STRATUM] submit reject (no JobSnapshot): user=" << username + << " job=" << job_id; + return reject(21, "Job not found"); + } + + // ── Reconstruct full 80-byte block header from JobSnapshot + miner inputs ── + // + // 1. Coinbase = coinb1 ‖ extranonce1 ‖ extranonce2 ‖ coinb2 + // 2. coinbase_txid = SHA256d(coinbase) — non-witness for this stub + // 3. merkle_root = ascend through frozen merkle branches starting from txid + // 4. header = version(LE) ‖ prev_hash(LE) ‖ merkle_root ‖ ntime(LE) + // ‖ nbits(LE) ‖ nonce(LE) (80 bytes total) + // 5. pow_hash = SHA256d(header) + // 6. Compare to block target + share target → classify + + 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 the stratum merkle branches. Branches are wire-formatted as + // LE-internal bytes (see get_stratum_merkle_branches comment) — must + // be parsed with ParseHex+memcpy, NOT SetHex (which reverses bytes). + 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 = btc::coin::merkle_hash_pair(merkle_root, b); + } + + // Build the 80-byte header (all little-endian little-endian). + // prev_hash arrives in BE display-hex; reverse to internal byte order. + auto prevhash_be = ParseHex(job->gbt_prevhash); + std::vector prevhash_le(prevhash_be.rbegin(), prevhash_be.rend()); + + // 80-byte block header. version comes from the JobSnapshot as a uint32 + // (decimal). TODO(version-rolling): when BIP 310 is wired, the miner's + // submitted version may differ from job->version within POOL_VERSION_MASK + // — accept and use the miner's version then. + 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())); + + // Decode share target (separate from block target — pre-this-fix we + // were comparing pow_hash against block target which is network + // difficulty, so every share got rejected as low-diff. The miner + // submits when their hash beats the per-session set_difficulty + // target; we validate against the per-job share_bits (compact form + // set by IWorkSource via get_share_bits()). + uint256 share_target; + if (job->share_bits != 0) { + share_target.SetCompact(job->share_bits); + } else { + // Fallback for jobs frozen before share_bits was set — be + // permissive: use the loosest possible target so PoW classifier + // doesn't silently reject everything. + 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)) { + // pow_hash <= block_target → BLOCK FOUND. + uint32_t height = 0; + if (auto tip = chain_.tip(); tip) height = tip->height + 1; + + LOG_WARNING << "[BTC-STRATUM-BLOCK] *** BLOCK FOUND *** user=" << username + << " height~=" << height + << " pow_hash=" << pow_hex_short + << " job=" << job_id; + + // Build the full serialized block: header ‖ tx_count ‖ coinbase ‖ other_txs. + // For segwit-active templates the coinbase MUST be serialized in BIP 144 + // form with the 32-byte witness reserved value as its single witness + // stack item — bitcoind validates the OP_RETURN aa21a9ed commitment by + // hashing (witness_root || reserved_value), and a missing witness here + // makes that hash mismatch → block rejected as bad-witness-merkle-match. + // + // BIP 144 witness format inserts a marker(0x00) + flag(0x01) right + // after the 4-byte version, and witness data right before the + // 4-byte locktime. Coinbase witness = 1 stack item, 32 zero bytes. + std::vector coinbase_serialized = coinbase; + if (job->segwit_active) { + // Insert marker+flag after version (offset 4) + const std::array marker_flag = {0x00, 0x01}; + coinbase_serialized.insert(coinbase_serialized.begin() + 4, + marker_flag.begin(), marker_flag.end()); + // Append witness BEFORE locktime (last 4 bytes): + // [stack_count = 1][item_len = 0x20][32 zero bytes] + std::array witness_bytes{}; + witness_bytes[0] = 0x01; // stack_count + witness_bytes[1] = 0x20; // item_len = 32 + // bytes [2..33] already zero from default-init = WITNESS_RESERVED_VALUE + coinbase_serialized.insert(coinbase_serialized.end() - 4, + witness_bytes.begin(), witness_bytes.end()); + } + + // a1: lazy materialize the tx hex at submit time (the only reader). + 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_serialized.size() + txs.size() * 256); + block_bytes.insert(block_bytes.end(), header.begin(), header.end()); + + push_varint(block_bytes, 1 + txs.size()); // total tx count + block_bytes.insert(block_bytes.end(), + coinbase_serialized.begin(), coinbase_serialized.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()); + } + + if (submit_block_fn_) { + try { + submit_block_fn_(block_bytes, height); + } catch (const std::exception& e) { + LOG_WARNING << "[BTC-STRATUM-BLOCK] submit_block_fn threw: " << e.what(); + } + } else { + LOG_WARNING << "[BTC-STRATUM-BLOCK] no submit_block_fn wired — block not broadcast"; + } + + // Update worker stats (block-find counts as accepted) + { + std::lock_guard lk(workers_mutex_); + for (auto& [_, w] : workers_) { + if (w.username == username) { w.accepted++; break; } + } + } + return nlohmann::json(true); + } + + if (!(pow_hash > share_target)) { + // pow_hash <= share_target → share meets sharechain target. + // Phase 11: dispatch to create_share_fn_ which builds a v35 + // PaddingBugfixShare, adds it to btc::ShareTracker, broadcasts + // to peers, and bumps the local best. If the callback is unset + // (degraded mode) we just log the acceptance — miner gets a + // success reply but the share doesn't earn sharechain credit. + + CreateShareFn create_fn; + { + std::lock_guard lk(callback_mutex_); + create_fn = create_share_fn_; + } + + uint256 share_hash; + if (create_fn) { + // Reconstruct the miner's payout_script from the username. + // address_to_script handles bech32 (P2WPKH/P2WSH) + base58 + // (P2PKH/P2SH). Empty script (unsupported address format) + // means the share can still be ADDED locally but won't carry + // a payout — peers will reject it on consensus check, which + // we tolerate during dev. + 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 << "[BTC-STRATUM-SHARE] create_share_fn threw: " + << e.what() << " — share not added"; + } + } + + if (!share_hash.IsNull()) { + LOG_INFO << "[BTC-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) { + // Callback was wired but couldn't add (tracker busy, prev_share + // unknown, PoW recheck failed inside create_local_share, etc.). + // Miner still gets a success reply since their PoW was valid. + LOG_INFO << "[BTC-STRATUM-SHARE] accepted (deferred) user=" << username + << " pow_hash=" << pow_hex_short + << " job=" << job_id; + } else { + // No callback wired — degraded mode (proxy without sharechain). + LOG_INFO << "[BTC-STRATUM-SHARE] accepted (no-tracker) user=" << username + << " pow_hash=" << pow_hex_short + << " job=" << job_id; + } + + { + std::lock_guard lk(workers_mutex_); + for (auto& [_, w] : workers_) { + 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& [_, w] : workers_) { + if (w.username == username) { w.rejected++; break; } + } + } + return reject(23, "Low difficulty share"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// BTC-specific control surface +// ───────────────────────────────────────────────────────────────────────────── + +void BTCWorkSource::set_best_share_hash_fn(std::function fn) +{ + std::lock_guard lk(best_share_mutex_); + best_share_hash_fn_ = std::move(fn); +} + +void BTCWorkSource::set_pplns_fn(PplnsFn fn) +{ + std::lock_guard lk(callback_mutex_); + pplns_fn_ = std::move(fn); +} + +void BTCWorkSource::set_ref_hash_fn(RefHashFn fn) +{ + std::lock_guard lk(callback_mutex_); + ref_hash_fn_ = std::move(fn); +} + +void BTCWorkSource::set_create_share_fn(CreateShareFn fn) +{ + std::lock_guard lk(callback_mutex_); + create_share_fn_ = std::move(fn); +} + +double BTCWorkSource::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 +{ + // Mirror of MiningInterface::calculate_share_difficulty (web_server.cpp) + // but with SHA256d instead of scrypt for the PoW step. This is the + // function whose return value gates pseudoshare acceptance in + // stratum_server.cpp's handle_submit — getting this wrong (falling + // back to LTC's scrypt) makes EVERY BTC submission look like garbage + // diff and reject at the vardiff gate. Bitaxe testing 2026-05-01. + + // Reconstruct full coinbase: coinb1 || en1 || en2 || coinb2 + 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())); + + // Ascend stratum merkle branches. Branches are wire-formatted as LE + // internal byte order (despite looking like BE display hex) — the + // miner does `hex2bin` and feeds bytes directly into SHA256d. Match + // LTC's reconstruct_merkle_root (web_server.cpp:1330) which parses + // via ParseHex + memcpy, NOT SetHex (which would reverse bytes and + // produce a totally different merkle root than the miner computed). + 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 = btc::coin::merkle_hash_pair(merkle_root, b); + } + + // Build 80-byte header. prevhash arrives as BE display hex; reverse + // to LE for header bytes. + 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; + + // SHA256d the header → pow_hash + uint256 pow_hash = Hash(std::span(header.data(), header.size())); + + // diff = max_target / pow_hash (max_target = bitcoin diff-1 target) + if (pow_hash.IsNull()) return 0.0; + double diff = chain::target_to_difficulty(pow_hash); + + // Diagnostic — only first 5 to avoid spam. Detailed dump so we can + // reproduce the bitaxe's expected hash off-line and pinpoint the + // header-reconstruction bug (coinbase txid? merkle ascent? prevhash + // byte-order? version-rolling convention?). + { + static std::atomic diag{0}; + if (diag.fetch_add(1) < 5) { + static const char* HX = "0123456789abcdef"; + auto to_hex = [&](const std::vector& v) { + std::string s; s.reserve(v.size()*2); + for (auto b : v) { s += HX[b>>4]; s += HX[b&0xf]; } + return s; + }; + std::string hdr_hex; for (auto b : header) { hdr_hex += HX[b>>4]; hdr_hex += HX[b&0xf]; } + LOG_INFO << "[BTC-DIFF] hdr=" << hdr_hex + << " pow=" << pow_hash.GetHex().substr(0,16) + << " diff=" << diff + << " ver=" << version << " ntime=" << ntime + << " nbits=" << nbits_hex << " nonce=" << nonce + << " en2=" << extranonce2; + LOG_INFO << "[BTC-DIFF-CB] coinb1=" << coinb1 + << " en1=" << extranonce1 + << " en2=" << extranonce2 + << " coinb2=" << coinb2; + LOG_INFO << "[BTC-DIFF-CB] coinbase_full=" << to_hex(coinbase) + << " coinbase_txid_LE=" << HexStr(std::span(coinbase_txid.data(), 32)) + << " coinbase_txid_BE=" << coinbase_txid.GetHex(); + for (size_t i = 0; i < merkle_branches.size(); ++i) { + LOG_INFO << "[BTC-DIFF-MR] step=" << i + << " branch=" << merkle_branches[i]; + } + LOG_INFO << "[BTC-DIFF-MR] merkle_root_LE=" + << HexStr(std::span(merkle_root.data(), 32)) + << " prevhash_in=" << prevhash_hex; + } + } + return diff; +} + +void BTCWorkSource::set_donation_script(std::vector script) +{ + std::lock_guard lk(callback_mutex_); + donation_script_ = std::move(script); +} + +} // namespace btc::stratum diff --git a/src/impl/btc/stratum/work_source.hpp b/src/impl/btc/stratum/work_source.hpp new file mode 100644 index 000000000..02ca1794b --- /dev/null +++ b/src/impl/btc/stratum/work_source.hpp @@ -0,0 +1,272 @@ +#pragma once + +// btc::stratum::BTCWorkSource — concrete `core::stratum::IWorkSource` +// implementation for c2pool-btc. +// +// Responsibility: bridge the coin-agnostic `core::StratumServer` (TCP, +// JSON-RPC, sessions, vardiff, rate monitor) to BTC-specific work +// generation + share validation. Produces stratum jobs from the local +// header chain + mempool via `btc::coin::TemplateBuilder::build_template`, +// validates submitted shares with SHA256d PoW, and dispatches mainnet-hit +// blocks to the B5 submit_block_p2p callback wired in main_btc.cpp. +// +// Lifetime: holds non-owning references to `HeaderChain` and `Mempool` +// — main_btc.cpp owns those for the process lifetime, BTCWorkSource 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 from B5). +// +// 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 4a skeleton): +// - All work-generation / submit methods return defaults or empty +// results. Subsequent sub-stages (4b/4c/4d) implement the read-only +// getters, the work assembly, and the share-validation hot path. +// - 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 btc::coin { +class HeaderChain; +class Mempool; +} // namespace btc::coin + +namespace btc::stratum { + +class BTCWorkSource : public core::stratum::IWorkSource +{ +public: + /// Callback invoked when `mining_submit` validates a submission whose + /// SHA256d PoW meets BTC mainnet difficulty. main_btc.cpp wires this + /// to a lambda that calls `coin_node.get_p2p()->submit_block_raw(bytes)` + /// + adds to the B5 pending_submits map. Raw-bytes form keeps + /// BTCWorkSource decoupled from the BlockType serialization details. + 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_btc.cpp wires + /// this to a lambda that calls + /// `p2p_node->tracker().get_v35_expected_payouts(...)` under a + /// TrackerReadGuard. 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. Phase 12: + /// returns the full `core::stratum::RefHashResult` (already used by + /// LTC for the same purpose) instead of just (ref_hash, nonce). The + /// extra fields (bits, max_bits, absheight, abswork, far_share_hash, + /// timestamp) are fed straight into snap.frozen_ref so create_local + /// _share_v35 can override its in-function compute_share_target + /// safely (has_frozen=true again) — and the work source updates its + /// share_bits_/share_max_bits_ atomics so stratum_server's + /// pool_difficulty gate becomes non-zero, finally letting + /// mining_submit be called for ordinary pseudoshares. + /// + /// Inputs: `block_bits` is the BTC mainnet GBT block target (passed + /// to compute_share_target as desired_target). `timestamp` is the + /// candidate share timestamp; the lambda may clip it forward to + /// `prev->m_timestamp + 1` and report the clipped value back via + /// the result's `timestamp` field. + 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_btc.cpp wires + /// this to a lambda that: + /// 1. Acquires `unique_lock(p2p_node->tracker_mutex(), try_to_lock)` + /// — non-blocking; returns uint256::ZERO if compute thread busy + /// 2. Calls btc::create_local_share() (templated on TrackerT) which + /// builds a v35 PaddingBugfixShare and tracker.add()s it + /// 3. On non-zero return, calls p2p_node->broadcast_share(hash) + /// to announce to peers + notify_local_share(hash) to bump + /// 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.). + /// mining_submit reports either share-accepted with the hash or + /// share-deferred to the miner. + /// + /// The full_coinbase is the reconstructed coinb1||en1||en2||coinb2 + /// (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)>; + + BTCWorkSource(btc::coin::HeaderChain& chain, + btc::coin::Mempool& mempool, + bool is_testnet, + SubmitBlockFn submit_fn, + core::stratum::StratumConfig config = {}); + ~BTCWorkSource() override; + + BTCWorkSource(const BTCWorkSource&) = delete; + BTCWorkSource& operator=(const BTCWorkSource&) = 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 (BTC = SHA256d) ──────────────────────── + 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; + + // ── BTC-specific control surface (called from main_btc.cpp) ────────── + + /// Increment work_generation. Called when the bitcoind 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_btc.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 BTC + /// 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 BTC). + 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-btc as + /// a stratum proxy without sharechain participation. + void set_create_share_fn(CreateShareFn fn); + + /// Set the donation script (bytes of the c2pool donation + /// scriptPubKey — typically a P2PKH or P2WPKH for the c2pool donation + /// address). 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) + btc::coin::HeaderChain& chain_; + btc::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 (Phase 12). + 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_btc.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 4c populates these. + mutable std::mutex template_mutex_; + // ... cache fields land here in stage 4c +}; + +} // namespace btc::stratum diff --git a/src/impl/btc/test/CMakeLists.txt b/src/impl/btc/test/CMakeLists.txt new file mode 100644 index 000000000..66527890f --- /dev/null +++ b/src/impl/btc/test/CMakeLists.txt @@ -0,0 +1,11 @@ +if (BUILD_TESTING AND GTest_FOUND) + add_executable(share_test share_test.cpp) + target_link_libraries(share_test PRIVATE + GTest::gtest_main GTest::gtest + core sharechain + ) + + include(GoogleTest) + include_directories(${gtest_SOURCE_DIR}/include ${gtest_SOURCE_DIR}) + gtest_add_tests(share_test "" AUTO) +endif() \ No newline at end of file diff --git a/src/impl/btc/test/share_test.cpp b/src/impl/btc/test/share_test.cpp new file mode 100644 index 000000000..86823c08c --- /dev/null +++ b/src/impl/btc/test/share_test.cpp @@ -0,0 +1,27 @@ + // PackStream stream_share; + // stream_share.from_hex("21fd0702fe02000020617dfa46bf73eb96548e0b039a647d35b387ed0cb1a6e51c80092175857d3f5b3ac4ff62f1a9001bc0254dda4d00fe065ba137d8e108ef134db29b7e33f46327f13626975c0c2a190082018f3d04d03aee002cfabe6d6d21102609e852babee96639fbb3b65588bbcc419720fec56da52e47120c4804a501000000000000000a5f5f6332706f6f6c5f5ffc88aa669a2cd3c1310067ae7e47a7869b330d30b691c61b46fb483b0a0000000000002102f24e44938c7bde43245d2a17c7fe424fbebc63f05317dfdace08a95a2f10d5efe4248d9eb63c2de431a93f0c94e857920cb3f70163dba595de7720e6cc014203517d2164368b766e6b9d0598510a7bbfc9882940ebfe3f65bb72173c3dbf105802f24e44938c7bde43245d2a17c7fe424fbebc63f05317dfdace08a95a2f10d5ef1025a29236b072d75cde8637584a3ed2fe0bcd4aadb5824b61cc42b4414e143d020000000195cbb26f405ead27fcd8cf84155cfcfab722a0cfdb3a2c735fce15fb19bc8ae4ffff0f1e8888001e3bc4ff62cc210000df798a25160000000000000000000000000000000001000000986dae33074d8c439a5dc61c2019a86726ebd1cf0eb0240582ccc0b249d12ba7fd9c0102f24e44938c7bde43245d2a17c7fe424fbebc63f05317dfdace08a95a2f10d5efe4248d9eb63c2de431a93f0c94e857920cb3f70163dba595de7720e6cc014203"); + +#include + +#include +#include +#include + +// struct FakeBlock +// { + +// }; + +TEST(LTC_share_test, Init) +{ + PackStream stream_share; + // stream_share.from_hex("21fd0702fe02000020617dfa46bf73eb96548e0b039a647d35b387ed0cb1a6e51c80092175857d3f5b3ac4ff62f1a9001bc0254dda4d00fe065ba137d8e108ef134db29b7e33f46327f13626975c0c2a190082018f3d04d03aee002cfabe6d6d21102609e852babee96639fbb3b65588bbcc419720fec56da52e47120c4804a501000000000000000a5f5f6332706f6f6c5f5ffc88aa669a2cd3c1310067ae7e47a7869b330d30b691c61b46fb483b0a0000000000002102f24e44938c7bde43245d2a17c7fe424fbebc63f05317dfdace08a95a2f10d5efe4248d9eb63c2de431a93f0c94e857920cb3f70163dba595de7720e6cc014203517d2164368b766e6b9d0598510a7bbfc9882940ebfe3f65bb72173c3dbf105802f24e44938c7bde43245d2a17c7fe424fbebc63f05317dfdace08a95a2f10d5ef1025a29236b072d75cde8637584a3ed2fe0bcd4aadb5824b61cc42b4414e143d020000000195cbb26f405ead27fcd8cf84155cfcfab722a0cfdb3a2c735fce15fb19bc8ae4ffff0f1e8888001e3bc4ff62cc210000df798a25160000000000000000000000000000000001000000986dae33074d8c439a5dc61c2019a86726ebd1cf0eb0240582ccc0b249d12ba7fd9c0102f24e44938c7bde43245d2a17c7fe424fbebc63f05317dfdace08a95a2f10d5efe4248d9eb63c2de431a93f0c94e857920cb3f70163dba595de7720e6cc014203"); + stream_share.from_hex("23fd9601fe00000020654f11363698fc9a54e43f126f294bd1a33b650148e8b6bb532fc08500cb6966e8103066140b041db0022a77e3af9c1de80a16583bed2a6179b63ed410b890b113cfd0fcd68bafa4096779b90503fd823100731a92d3226d6839617a4b44785235374766374a575a756e6e43324a7a37325351747746544b68dec14025000000000000fe2302a41fb37f52f6747afbbeae61462feaa40b8b3655f8fb7af60843111101ec5f958e93b9a76bb46536bf807b1caef9635f432d982bd907eb5050130b6ec00aeabc2bb9ca34c5f1ba0bd332fc3d217d9853754fe42797e32cf9ddddcab6f66ab8056f1b64efa2157281c406fc6a5d9de6db5e2adf63c86646a4edc91c51f86d74c707c0221e8828011ef310306675b3210073990593df0d00000000000000000000000100000000000000c357550d5a390b342f665a3d853c039a626b803bb37976c20ba0b5ee5a56fceedc0220e67c088987582af73218c99820276bbf0004c5c18f7dd691f9c4326bfd9930d5567a6d109fec00f4eca887c42e80ddaa57df9bda8db8b277110a50a9a268b6"); + + chain::RawShare rshare; + stream_share >> rshare; + + std::cout << rshare.type << std::endl; + + auto share = btc::load_share(rshare, NetService{"0.0.0.0", 0}); +} \ No newline at end of file diff --git a/src/impl/btc/test/template_parity_test.cpp b/src/impl/btc/test/template_parity_test.cpp new file mode 100644 index 000000000..e650c287c --- /dev/null +++ b/src/impl/btc/test/template_parity_test.cpp @@ -0,0 +1,196 @@ +// B6 template parity check: c2pool-btc algorithmic functions vs Bitcoin +// Core consensus oracles. This is a standalone smoke-style harness — not +// a full gtest fixture — so it can be built and run without restoring the +// btc/test subdir's CMake plumbing. +// +// Build (from repo root): +// g++ -std=gnu++20 -I src -I src/btclibs -I /usr/include \ +// src/impl/btc/test/template_parity_test.cpp \ +// -o /tmp/btc_template_parity +// Run: +// /tmp/btc_template_parity +// +// Failing cases print PASS/FAIL with computed vs expected, return non-zero +// exit on any failure. + +#include +#include + +#include +#include +#include +#include + +using btc::coin::get_block_subsidy; +using btc::coin::compute_merkle_root; +using btc::coin::calculate_next_work_required; +using btc::coin::BTCChainParams; + +static int g_fails = 0; + +static void check(bool ok, const char* label) { + std::printf(" %s %s\n", ok ? "PASS" : "FAIL", label); + if (!ok) ++g_fails; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Subsidy parity — oracle values are well-known consensus constants from +// Bitcoin Core's GetBlockSubsidy() across each halving boundary. +// ───────────────────────────────────────────────────────────────────────────── +static void test_subsidy() +{ + constexpr uint64_t COIN = 100'000'000ULL; + struct Case { uint32_t h; uint64_t expected; const char* note; }; + Case cases[] = { + { 0, 50 * COIN, "genesis: 50 BTC" }, + { 1, 50 * COIN, "block 1: 50 BTC" }, + { 209'999, 50 * COIN, "last of epoch 0: 50 BTC" }, + { 210'000, 25 * COIN, "first of epoch 1: 25 BTC" }, + { 419'999, 25 * COIN, "last of epoch 1: 25 BTC" }, + { 420'000, 1'250'000'000ULL, "first of epoch 2: 12.5 BTC"}, + { 629'999, 1'250'000'000ULL, "last of epoch 2: 12.5 BTC" }, + { 630'000, 625'000'000ULL, "first of epoch 3: 6.25 BTC"}, + { 839'999, 625'000'000ULL, "last of epoch 3: 6.25 BTC" }, + { 840'000, 312'500'000ULL, "first of epoch 4: 3.125 BTC (current)"}, + {1'050'000, 156'250'000ULL, "epoch 5: 1.5625 BTC" }, + {13'440'000, 0, "post-64th halving: 0 BTC" }, + }; + + std::printf("== subsidy parity ==\n"); + for (auto& c : cases) { + uint64_t got = get_block_subsidy(c.h); + char label[256]; + std::snprintf(label, sizeof(label), + "h=%u get=%llu exp=%llu (%s)", + c.h, (unsigned long long)got, (unsigned long long)c.expected, c.note); + check(got == c.expected, label); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Merkle root parity. Oracle: Bitcoin mainnet genesis block has exactly +// 1 transaction (coinbase txid 4a5e1e4baab89f3a32518a88c31bc87f618f76673e +// 2cc77ab2127b7afdeda33b). The Merkle root of a 1-tx tree is the txid +// itself. Block header's merkle_root field for genesis matches. +// +// Reference: Bitcoin Core src/kernel/chainparams.cpp CMainParams genesis. +// ───────────────────────────────────────────────────────────────────────────── +static void test_merkle_root() +{ + std::printf("== merkle root parity ==\n"); + + // 1-tx tree (genesis case) + { + uint256 coinbase_txid; + coinbase_txid.SetHex("4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b"); + std::vector txids = {coinbase_txid}; + uint256 root = compute_merkle_root(txids); + check(root == coinbase_txid, "1-tx tree (genesis): root == txid"); + } + + // 2-tx tree: root = SHA256d(txid1 || txid2) + { + uint256 t1, t2; + t1.SetHex("aa11111111111111111111111111111111111111111111111111111111111111"); + t2.SetHex("bb22222222222222222222222222222222222222222222222222222222222222"); + std::vector v = {t1, t2}; + uint256 got = compute_merkle_root(v); + // Hand-computed expected: + // concat 32+32, SHA256d, see Bitcoin Core consensus/merkle.cpp + // For this test we just verify it's NOT one of the inputs (sanity) + // and matches itself across two calls (deterministic). + std::vector v2 = {t1, t2}; + uint256 got2 = compute_merkle_root(v2); + check(got == got2, "2-tx: deterministic"); + check(got != t1 && got != t2, "2-tx: not pass-through"); + } + + // 3-tx tree: must duplicate last (Bitcoin Core's + // ComputeMerkleRoot(): if odd count, duplicate last element) + // This means root(3-tx) = root(4-tx with last duplicated). + { + uint256 a, b, c; + a.SetHex("1111111111111111111111111111111111111111111111111111111111111111"); + b.SetHex("2222222222222222222222222222222222222222222222222222222222222222"); + c.SetHex("3333333333333333333333333333333333333333333333333333333333333333"); + std::vector v3 = {a, b, c}; + std::vector v4 = {a, b, c, c}; + uint256 r3 = compute_merkle_root(v3); + uint256 r4 = compute_merkle_root(v4); + check(r3 == r4, "3-tx: duplicates last → root(3) == root(4-with-dup)"); + } + + // Empty input → uint256::ZERO (per our docstring) + { + std::vector empty; + uint256 r = compute_merkle_root(empty); + check(r == uint256::ZERO, "empty tree: root == 0"); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// DAA parity — calculate_next_work_required() vs Bitcoin Core's actual on- +// chain retarget result. Oracle data captured live from bitcoind 28.1.0 +// on .40 (BTC mainnet) on 2026-04-29. +// +// For a retarget at height B (where B % 2016 == 0): +// tip_bits = bits at block B-1 (last of prev period) +// tip_time = time at block B-1 +// first_time = time at block B-2016 (BTC uses interval-1 = 2015 back-step, +// and block B-2016 == block (B-1)-2015) +// expected = bits at block B (the retarget result we're verifying) +// ───────────────────────────────────────────────────────────────────────────── +static void test_daa() +{ + std::printf("== DAA parity (live mainnet retargets) ==\n"); + + auto params = BTCChainParams::mainnet(); + + struct Case { + uint32_t boundary_height; + uint32_t tip_bits; // block (boundary-1) + int64_t tip_time; + int64_t first_time; // block (boundary-2016) + uint32_t expected_bits; // block boundary itself + }; + + Case cases[] = { + // Most recent retarget (height 945504): captured 2026-04-29 from + // bitcoind on .40. Difficulty crept up — actual_timespan slightly + // shorter than target (2 weeks = 1209600 s), so bits dropped from + // 0x17020684 → 0x17021369 (smaller target = harder). + // Wait — 21369 > 20684 in numeric, but in compact-target encoding, + // bits with same exponent (0x17) are compared by mantissa. + // 0x21369 (135529) > 0x20684 (132996) means the new TARGET is + // LARGER, so EASIER. Let's verify our impl agrees with bitcoind. + { 945504, 0x17020684, 1776448209, 1775208520, 0x17021369 }, + // One retarget earlier (height 943488): + // 0x17021a91 → 0x17020684 (slightly easier) + { 943488, 0x17021a91, 1775208233, 1774043659, 0x17020684 }, + }; + + for (auto& c : cases) { + uint32_t got = calculate_next_work_required( + c.tip_bits, c.tip_time, c.first_time, params); + char label[256]; + std::snprintf(label, sizeof(label), + "retarget@%u: tip_bits=0x%08x get=0x%08x exp=0x%08x dt=%lld", + c.boundary_height, c.tip_bits, got, c.expected_bits, + (long long)(c.tip_time - c.first_time)); + check(got == c.expected_bits, label); + } +} + +int main() +{ + test_subsidy(); + test_merkle_root(); + test_daa(); + + std::printf("\n"); + if (g_fails == 0) + std::printf("ALL PARITY CHECKS PASSED.\n"); + else + std::printf("FAILED %d parity check(s).\n", g_fails); + return g_fails; +} diff --git a/src/impl/btc/whale_departure.hpp b/src/impl/btc/whale_departure.hpp new file mode 100644 index 000000000..db41c6765 --- /dev/null +++ b/src/impl/btc/whale_departure.hpp @@ -0,0 +1,193 @@ +#pragma once + +// Phase 1c: Whale departure recovery — non-consensus local heuristic. +// +// When a large miner (whale) suddenly leaves the P2Pool network, share +// difficulty remains calibrated for the whale's hashrate, causing remaining +// miners to produce shares very slowly (~2 min/share instead of 15s). +// +// This detector tracks pool hashrate in a 30-minute rolling window and +// triggers when the current hashrate drops below 50% of the average. +// When active, desired_share_target should be overridden to 2^256-1 +// (clamped to pre_target3 — the easiest consensus-allowed difficulty). +// +// This is non-consensus: only affects what OUR node mines. +// Structured log lines: [WHALE-DEPARTURE] and [WHALE-RECOVERY]. +// +// Port of p2pool-v36 work.py Phase 1c + 1c.1 (commits 77d3c7d0 + 546c5382). + +#include "config_pool.hpp" +#include "share_tracker.hpp" + +#include + +#include +#include +#include +#include +#include + +namespace btc +{ + +// Forward: fmt_hashrate is defined in pool_monitor.hpp. +// Re-declare if used standalone; the linker will pick either. +inline std::string whale_fmt_hr(double n) +{ + static const char* suffixes[] = {"", "k", "M", "G", "T", "P"}; + for (const char* s : suffixes) + { + if (std::abs(n) < 1000.0) + { + std::ostringstream os; + os.precision(1); + os << std::fixed << n << s; + return os.str(); + } + n /= 1000.0; + } + std::ostringstream os; + os.precision(1); + os << std::fixed << n << "E"; + return os.str(); +} + +class WhaleDepartureDetector +{ +public: + WhaleDepartureDetector() = default; + + // Returns true if whale departure is currently active. + bool is_active() const { return active_; } + + // Call periodically (every ~5-30s) with a reference to the tracker and best hash. + // Returns true if departure recovery mode should be active. + bool detect(ShareTracker& tracker, const uint256& best_share_hash, + const std::string& trigger_source = "timer") + { + if (best_share_hash.IsNull()) + return false; + + auto [height, last] = tracker.chain.get_height_and_last(best_share_hash); + int32_t lookbehind = std::min(height - 1, + static_cast(PoolConfig::TARGET_LOOKBEHIND)); + if (lookbehind < 2) + return false; + + auto now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + + // Share gap + uint32_t best_ts = 0; + tracker.chain.get_share(best_share_hash).invoke([&](auto* obj) { + best_ts = obj->m_timestamp; + }); + auto share_gap = (now > best_ts) ? (now - best_ts) : int64_t(0); + + // Sample pool hashrate at fixed cadence + double att_s = last_att_s_; + if ((now - last_sample_ts_) >= sample_interval_ || att_s <= 0) + { + auto aps = tracker.get_pool_attempts_per_second(best_share_hash, lookbehind); + att_s = static_cast(aps.GetLow64()); + last_att_s_ = att_s; + last_sample_ts_ = now; + hr_samples_.push_back({now, att_s}); + } + + if (att_s <= 0) + return active_; + + // Trim to rolling window + auto cutoff = now - hr_window_; + hr_samples_.erase( + std::remove_if(hr_samples_.begin(), hr_samples_.end(), + [cutoff](const HrSample& s) { return s.ts < cutoff; }), + hr_samples_.end()); + + bool enough = hr_samples_.size() >= 10; + if (!enough && !active_) + return false; + + double sum = 0; + for (auto& s : hr_samples_) sum += s.hr; + double avg_hr = hr_samples_.empty() ? 0 : sum / static_cast(hr_samples_.size()); + + if (avg_hr <= 0 && !active_) + return false; + + double baseline_hr = active_ ? baseline_hr_ : avg_hr; + if (baseline_hr <= 0) + baseline_hr = (avg_hr > 0) ? avg_hr : att_s; + double ratio = (baseline_hr > 0) ? att_s / baseline_hr : 1.0; + + // Secondary signal: long wall-clock share drought while below baseline + bool gap_trigger = share_gap >= static_cast( + PoolConfig::share_period() * gap_trigger_periods_); + + if (active_) + { + // Recovery: require sustained improvement + if (ratio >= recovery_threshold_) + { + active_ = false; + baseline_hr_ = 0; + auto duration = now - departure_ts_; + LOG_INFO << "[WHALE-RECOVERY] OFF src=" << trigger_source + << " ratio=" << ratio + << " gap=" << share_gap << "s" + << " duration=" << duration << "s" + << " current=" << whale_fmt_hr(att_s) << "H/s" + << " baseline=" << whale_fmt_hr(baseline_hr) << "H/s"; + } + else if (now - log_interval_ > 30) + { + log_interval_ = now; + LOG_INFO << "[WHALE-DEPARTURE] ACTIVE src=" << trigger_source + << " current=" << whale_fmt_hr(att_s) << "H/s" + << " baseline=" << whale_fmt_hr(baseline_hr) << "H/s" + << " avg_30m=" << whale_fmt_hr(avg_hr) << "H/s" + << " ratio=" << ratio + << " gap=" << share_gap << "s" + << " recover>" << (recovery_threshold_ * 100) << "%"; + } + } + else + { + // Detection: trigger when hashrate drops below threshold + if ((enough && ratio <= drop_threshold_) || + (gap_trigger && ratio <= 0.90)) + { + active_ = true; + departure_ts_ = now; + baseline_hr_ = baseline_hr; + LOG_WARNING << "[WHALE-DEPARTURE] DETECTED src=" << trigger_source + << " ratio=" << ratio + << " gap=" << share_gap << "s" + << " current=" << whale_fmt_hr(att_s) << "H/s" + << " baseline=" << whale_fmt_hr(baseline_hr) << "H/s" + << " avg_30m=" << whale_fmt_hr(avg_hr) << "H/s"; + } + } + + return active_; + } + +private: + struct HrSample { int64_t ts; double hr; }; + std::vector hr_samples_; + + int64_t hr_window_ = 1800; // 30 min rolling window + bool active_ = false; + int64_t departure_ts_ = 0; + double baseline_hr_ = 0; + double drop_threshold_ = 0.50; // trigger at 50% drop + double recovery_threshold_ = 0.75; // recover at 75% of baseline + int64_t log_interval_ = 0; + int64_t last_sample_ts_ = 0; + int64_t sample_interval_ = 5; // seconds between samples + double last_att_s_ = 0; + uint32_t gap_trigger_periods_ = 8; // share gap trigger +}; + +} // namespace btc diff --git a/src/impl/ltc/share_tracker.hpp b/src/impl/ltc/share_tracker.hpp index b1acfb8c4..37fd87b28 100644 --- a/src/impl/ltc/share_tracker.hpp +++ b/src/impl/ltc/share_tracker.hpp @@ -1011,7 +1011,14 @@ class ShareTracker if (!was_already_verified) { --budget_remaining; } - if (p2_verified_count % 50 == 0) + // Throttled progress log: contabo freeze-diag (2026-05-24) caught + // this loop wedging the io_context for 2-7.5s at a time when emit + // was every 50 entries (173 lines per think cycle on an 8639-share + // chain). Boost::log internal mutex starves the io_context handler + // that runs the same think cycle. Bumped to every 1000 (~9 lines/ + // cycle); the loop runs at >1 verify/ms so a line/sec cadence is + // plenty for diagnostics. + if (p2_verified_count % 1000 == 0) LOG_INFO << "[think-P2] verifying: " << p2_verified_count << "/" << to_get << " new=" << (was_already_verified ? "no" : "yes") << " budget=" << budget_remaining