diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 288b7a1c4..31257b361 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -68,7 +68,7 @@ jobs: test_mweb_builder \ test_address_resolution test_compute_share_target \ test_utxo test_dgb_subsidy test_dgb_coinbase_value dgb_share_test dgb_block_assembly_test \ - dgb_gentx_coinbase_test nmc_auxpow_merkle_test dgb_gentx_share_path_test dgb_other_tx_resolver_test \ + dgb_gentx_coinbase_test nmc_auxpow_merkle_test nmc_template_builder_test dgb_gentx_share_path_test dgb_other_tx_resolver_test \ dgb_other_tx_assembler_test dgb_reconstruct_won_block_test dgb_gentx_unpack_test dgb_work_source_test \ rpc_request_test softfork_check_test genesis_check_test algo_select_test digishield_walk_test header_chain_test \ dgb_coin_node_seam_test dgb_block_broadcast_test dgb_won_block_dispatch_test \ @@ -201,7 +201,7 @@ jobs: test_mweb_builder \ test_address_resolution test_compute_share_target \ test_utxo test_dgb_subsidy test_dgb_coinbase_value dgb_share_test dgb_block_assembly_test \ - dgb_gentx_coinbase_test nmc_auxpow_merkle_test dgb_gentx_share_path_test dgb_other_tx_resolver_test \ + dgb_gentx_coinbase_test nmc_auxpow_merkle_test nmc_template_builder_test dgb_gentx_share_path_test dgb_other_tx_resolver_test \ dgb_other_tx_assembler_test dgb_reconstruct_won_block_test dgb_gentx_unpack_test dgb_work_source_test \ rpc_request_test softfork_check_test genesis_check_test algo_select_test digishield_walk_test header_chain_test \ dgb_coin_node_seam_test dgb_block_broadcast_test dgb_won_block_dispatch_test \ diff --git a/src/impl/nmc/coin/CMakeLists.txt b/src/impl/nmc/coin/CMakeLists.txt index 85e446b50..23f32dbb0 100644 --- a/src/impl/nmc/coin/CMakeLists.txt +++ b/src/impl/nmc/coin/CMakeLists.txt @@ -6,7 +6,10 @@ set(nmc_coin_interface transaction.hpp transaction.cpp block.hpp + mempool.hpp header_chain.hpp + rpc_data.hpp + template_builder.hpp coin_smoke.cpp ) diff --git a/src/impl/nmc/coin/aux_chain_embedded.hpp b/src/impl/nmc/coin/aux_chain_embedded.hpp new file mode 100644 index 000000000..7b2fa3f9f --- /dev/null +++ b/src/impl/nmc/coin/aux_chain_embedded.hpp @@ -0,0 +1,172 @@ +#pragma once + +/// P1 PD: Embedded NMC (Namecoin) backend for merge-mining under a BTC parent. +/// +/// Re-homed analog of src/impl/doge/coin/aux_chain_embedded.hpp (DOGE-under-LTC), +/// adapted to the NMC coin tree (BTC parent, SHA256d). Implements +/// c2pool::merged::IAuxChainBackend over the embedded NMC HeaderChain + +/// TemplateBuilder, so the merged-mining manager can pull NMC aux-work and run +/// the DUAL-TARGET check (share-target for PPLNS, aux-network-target for a real +/// Namecoin block) WITHOUT a namecoind RPC dependency. +/// +/// Build-side responsibilities (PD), the complement of the header_chain verify +/// side (P1c/P1d/P1f): +/// get_work_template() -> EmbeddedCoinNode::getwork() -> TemplateBuilder +/// Surfaces: +/// * block_hash - the NMC block hash to COMMIT in the parent BTC +/// coinbase (the aux-merkle leaf the verifier later +/// walks via aux_merkle_root()); +/// * target - the NMC PoW target (dual-target: a found share is a +/// real NMC block only if parent-PoW <= this target); +/// * chain_id - NMCChainParams::aux_chain_id (consensus source, the +/// SAME field the verify side pins). +/// submit_block() -> P2P broadcast via CoinBroadcaster (embedded mode); +/// submit_aux_block() -> fallback (submitauxblock) path - logged only, +/// embedded mode has no daemon to RPC. +/// +/// Per-coin isolation: src/impl/nmc/ only; btc tree consumed READ-ONLY. No +/// core/ or btc/ header is modified. The merged_mining IAuxChainBackend contract +/// is a shared src/c2pool seam consumed READ-ONLY (same one doge implements). + +#include "header_chain.hpp" +#include "template_builder.hpp" +#include "mempool.hpp" + +#include + +#include + +#include +#include + +namespace nmc { +namespace coin { + +class AuxChainEmbedded : public c2pool::merged::IAuxChainBackend +{ +public: + AuxChainEmbedded( + HeaderChain& chain, + Mempool& pool, + const NMCChainParams& params, + const c2pool::merged::AuxChainConfig& config, + bool testnet = false) + : m_chain(chain) + , m_pool(pool) + , m_params(params) + , m_config(config) + , m_embedded(chain, pool, testnet) + {} + + bool connect() override { + // Embedded backend is always "connected" - no namecoind socket. + LOG_INFO << "[MM:" << m_config.symbol << "] Embedded NMC backend ready (no daemon needed)"; + return true; + } + + c2pool::merged::AuxWork get_work_template() override { + // Sync gate: no aux-work until the NMC header chain is caught up AND any + // UTXO/coinbase-maturity gate wired on the embedded node is satisfied. + if (!m_embedded.is_synced()) { + LOG_DEBUG_COIND << "[MM:" << m_config.symbol << "] Embedded: not synced" + << " (height=" << m_chain.height() << "), returning empty work"; + return {}; + } + + rpc::WorkData wd; + try { + wd = m_embedded.getwork(); + } catch (const std::exception& e) { + LOG_DEBUG_COIND << "[MM:" << m_config.symbol << "] Embedded: getwork threw (" + << e.what() << "), returning empty work"; + return {}; + } + + c2pool::merged::AuxWork work; + work.height = wd.m_data.value("height", 0); + work.coinbase_value = wd.m_data.value("coinbasevalue", uint64_t(0)); + // chain_id: the consensus source is the params field the VERIFY side + // pins (header_chain aux_chain_id), NOT the transport-layer config copy. + // Unpinned (-1 sentinel) never reaches here: is_synced() is false under + // placeholder genesis, so {} is returned above. + work.chain_id = static_cast(m_params.aux_chain_id); + work.block_template = wd.m_data; + + if (wd.m_data.contains("previousblockhash")) + work.prev_block_hash = wd.m_data["previousblockhash"].get(); + + // Dual-target: aux PoW target derived from the template bits. A found + // share is a REAL NMC block only when the parent-chain PoW satisfies it. + if (wd.m_data.contains("bits")) { + uint32_t bits = static_cast( + std::stoul(wd.m_data["bits"].get(), nullptr, 16)); + work.target.SetCompact(bits); + } + + // Aux block hash committed in the parent BTC coinbase (the merge-mining + // leaf the verifier later proves via aux_merkle_root()). + auto tip = m_chain.tip(); + if (tip.has_value()) + work.block_hash = tip->block_hash; + + return work; + } + + c2pool::merged::AuxWork create_aux_block(const std::string& /*address*/) override { + // Embedded mode is always multiaddress (GBT-shaped) - createauxblock and + // getblocktemplate collapse to the same embedded template path. + return get_work_template(); + } + + bool submit_block(const std::string& block_hex) override { + // Embedded mode: P2P relay handled by CoinBroadcaster. Cache for + // get_block_hex() retrieval and log the 80-byte header prefix. + LOG_INFO << "[MM:" << m_config.symbol << "] Embedded: block submitted (" + << block_hex.size() / 2 << " bytes)" + << " header=" << block_hex.substr(0, std::min(size_t(160), block_hex.size())); + m_last_block_hex = block_hex; + return true; + } + + bool submit_aux_block(const uint256& block_hash, const std::string& auxpow_hex) override { + // Fallback (submitauxblock) path: embedded mode has no daemon to RPC, + // the frozen-block submit_block() + P2P relay is the primary route. + LOG_WARNING << "[MM:" << m_config.symbol << "] Embedded: submit_aux_block called" + << " (fallback path) hash=" << block_hash.GetHex().substr(0, 16) << "..." + << " auxpow=" << auxpow_hex.size() / 2 << " bytes" + << " - no daemon to submit to, relying on P2P relay"; + return true; + } + + std::string get_block_hex(const std::string& /*block_hash*/) override { + if (!m_last_block_hex.empty()) + return m_last_block_hex; + return {}; + } + + std::string get_best_block_hash() override { + auto tip = m_chain.tip(); + return tip.has_value() ? tip->block_hash.GetHex() : ""; + } + + std::vector getpeerinfo() override { + // No daemon peers - the P2P broadcaster owns its own peer discovery. + return {}; + } + + const c2pool::merged::AuxChainConfig& config() const override { return m_config; } + + /// Expose embedded node for UTXO maturity gate wiring. + EmbeddedCoinNode& embedded_node() { return m_embedded; } + +private: + HeaderChain& m_chain; + Mempool& m_pool; + NMCChainParams m_params; + c2pool::merged::AuxChainConfig m_config; + EmbeddedCoinNode m_embedded; + std::string m_last_block_hex; // cached for get_block_hex() after submit +}; + +} // namespace coin +} // namespace nmc diff --git a/src/impl/nmc/coin/coin_smoke.cpp b/src/impl/nmc/coin/coin_smoke.cpp index f69d727cf..248a2dcb2 100644 --- a/src/impl/nmc/coin/coin_smoke.cpp +++ b/src/impl/nmc/coin/coin_smoke.cpp @@ -8,6 +8,9 @@ #include "header_chain.hpp" #include "block.hpp" +#include "mempool.hpp" +#include "rpc_data.hpp" +#include "template_builder.hpp" namespace nmc { namespace coin { @@ -35,6 +38,17 @@ void nmc_coin_p0_smoke() std::vector aux_chains; // nmc-local list (fence #4) aux_chains.push_back(aux_slot); (void)aux_chains; + + // P1 PC: force-compile the embedded template builder + work-data types. + Mempool pool; + (void)pool.size(); + (void)get_block_subsidy(0u); + (void)compute_merkle_root(std::vector{}); + auto wd = TemplateBuilder::build_template(chain, pool, /*is_testnet=*/false); + (void)wd; // nullopt on an empty chain -- structural compile-check only + EmbeddedCoinNode node(chain, pool, /*testnet=*/false); + (void)node.getblockchaininfo(); + (void)node.is_synced(); } } // namespace coin diff --git a/src/impl/nmc/coin/header_chain.hpp b/src/impl/nmc/coin/header_chain.hpp index 72936629a..ef42ca89d 100644 --- a/src/impl/nmc/coin/header_chain.hpp +++ b/src/impl/nmc/coin/header_chain.hpp @@ -42,11 +42,13 @@ #include #include // chain::bits_to_target (parent-PoW, step 4) #include // P1f: core::LevelDBStore persistence +#include // P1 PC: core::coin::DEFAULT_MAX_TIP_AGE (is_synced) #include #include #include #include +#include #include #include #include @@ -688,6 +690,116 @@ inline uint256 get_block_proof(uint32_t bits) { return (~target / (target + uint256::ONE)) + uint256::ONE; } +// NMC Difficulty Retarget +// Mirror of btc::coin::calculate_next_work_required / get_next_work_required +// (src/impl/btc/coin/header_chain.hpp). Namecoin is a SHA256d Bitcoin fork and +// shares Bitcoin's 2016-block retarget window / 10-minute spacing, so the +// retarget algorithm is identical. Kept local to the NMC lane (the btc tree is +// read-only; core/ is the exceptional SSOT) rather than cross-including the btc +// header. Adapted from Bitcoin Core pow.cpp (MIT license). + +/// 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 NMCChainParams& 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; + + // Intermediate uint256 can overflow by 1 bit (same guard as btc/ltc). + 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 NMCChainParams& 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 a 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 (and Namecoin) always go back `interval - 1` blocks (= 2015 for + // the 2016-block retarget window). The Litecoin "Art Forz" off-by-one is + // not part of Namecoin's history. + int64_t blocks_to_go_back = interval - 1; + + 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 ───────────────────────────────────────────────────────────── /// Header-only chain skeleton for embedded NMC. Mirrors the public surface of @@ -894,6 +1006,44 @@ class HeaderChain { return 0; } + /// P1 PC: look up a header by absolute height by walking the tip's ancestry + /// through m_previous_block. NMC keeps NO height index (mirror of + /// get_locator()'s note) so a height lookup walks parent links down the + /// BEST chain. Returns nullopt when the height is above the tip or the walk + /// hits a dangling parent. The TemplateBuilder's difficulty retarget needs + /// at most `interval` ancestors, so this O(depth) walk is bounded in + /// practice. Caller must NOT hold m_mutex (this acquires it). + std::optional get_header_by_height(uint32_t h) const { + std::lock_guard lock(m_mutex); + if (m_tip.IsNull()) return std::nullopt; + auto it = m_index.find(m_tip); + if (it == m_index.end()) return std::nullopt; + if (h > it->second.height) return std::nullopt; // above the tip + // Walk parent links from the tip down to height h. + uint256 cursor = m_tip; + while (true) { + auto cit = m_index.find(cursor); + if (cit == m_index.end()) return std::nullopt; // dangling link + if (cit->second.height == h) return cit->second; + if (cit->second.height == 0) return std::nullopt; // reached root, not found + cursor = cit->second.header.m_previous_block; + } + } + + /// P1 PC: whether the chain tip is recent enough to be considered synced + /// with the network (mirror of btc::coin::HeaderChain::is_synced). Uses the + /// shared core::coin::DEFAULT_MAX_TIP_AGE (24h) gate: a chain whose tip is + /// older than that is still in IBD. An empty chain is never synced. + bool is_synced() const { + std::lock_guard lock(m_mutex); + if (m_tip.IsNull()) return false; + auto it = m_index.find(m_tip); + if (it == m_index.end()) return false; + auto now = static_cast(std::time(nullptr)); + uint32_t age = now - it->second.header.m_timestamp; + return age < core::coin::DEFAULT_MAX_TIP_AGE; + } + const NMCChainParams& params() const { return m_params; } private: diff --git a/src/impl/nmc/coin/mempool.hpp b/src/impl/nmc/coin/mempool.hpp new file mode 100644 index 000000000..f8fd849d6 --- /dev/null +++ b/src/impl/nmc/coin/mempool.hpp @@ -0,0 +1,745 @@ +#pragma once + +/// NMC (Namecoin) Mempool — embedded merge-mined tx pool (P1 PB). +/// +/// Re-homed mirror of src/impl/btc/coin/mempool.hpp into namespace nmc::coin so +/// the NMC coin tree stays self-contained (no btc::coin symbols). Namecoin is a +/// Bitcoin fork: same wire format, same 21M money cap, 100-block maturity. +/// 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 nmc { +namespace coin { + +// Namecoin shares Bitcoin's consensus money params: MAX_MONEY = 21M * 1e8 sat, +// 100-block coinbase maturity, no pegout. Reference: namecoin-core amount.h +// MAX_MONEY, consensus.h COINBASE_MATURITY. +static constexpr core::coin::ChainLimits NMC_LIMITS = {2'100'000'000'000'000LL, 100, 0}; + +// ─── 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 = NMC_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-NMC] 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-NMC] 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-NMC] 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-NMC] 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-NMC] 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 nmc diff --git a/src/impl/nmc/coin/rpc_data.hpp b/src/impl/nmc/coin/rpc_data.hpp new file mode 100644 index 000000000..a0ababe6f --- /dev/null +++ b/src/impl/nmc/coin/rpc_data.hpp @@ -0,0 +1,48 @@ +#pragma once + +// NMC (Namecoin) RPC work-data type. +// +// Re-homed mirror of src/impl/btc/coin/rpc_data.hpp into namespace nmc::coin / +// nmc::coin::rpc so the NMC coin tree is self-contained and does not pull +// btc::coin symbols. WorkData is the block-template payload produced by the +// embedded TemplateBuilder (P1 PC) and consumed downstream (share creation, +// Stratum) exactly as the GBT JSON path was. No btc header is modified. + +#include + +#include "transaction.hpp" + +#include + +namespace nmc +{ + +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); } +}; + +} // namespace rpc + +} // namespace coin + +} // namespace nmc diff --git a/src/impl/nmc/coin/template_builder.hpp b/src/impl/nmc/coin/template_builder.hpp new file mode 100644 index 000000000..1e02e8fbf --- /dev/null +++ b/src/impl/nmc/coin/template_builder.hpp @@ -0,0 +1,357 @@ +#pragma once + +/// P1 PC: NMC (Namecoin) Template Builder +/// +/// Re-homed mirror of src/impl/btc/coin/template_builder.hpp into namespace +/// nmc::coin. Builds block templates (WorkData) natively from a validated +/// HeaderChain + Mempool, removing the getblocktemplate RPC dependency for the +/// embedded merge-mined Namecoin chain. +/// +/// Provides: +/// get_block_subsidy() - Namecoin halving schedule (50 NMC, halving every +/// 210,000 blocks - identical to Bitcoin) +/// compute_merkle_root() - SHA256d-based Merkle tree (Bitcoin/Namecoin compatible) +/// TemplateBuilder - static build_template() -> WorkData +/// CoinNodeInterface - abstract interface (getwork / submit_block / getblockchaininfo) +/// EmbeddedCoinNode - concrete implementation backed by HeaderChain + Mempool +/// +/// SCOPE: PC is the STRUCTURAL template builder only. The merge-mining +/// specifics (aux commitment in the parent coinbase, dual-target submission) +/// are a LATER phase (PD) and are NOT implemented here. +/// +/// Per-coin isolation: src/impl/nmc/ only; btc tree consumed READ-ONLY (this is +/// an independent NMC-local re-home, fence #4). No btc/core header is modified. + +#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 +#include + +namespace nmc { +namespace coin { + +// NMC Subsidy + +/// NMC block subsidy (miner reward) in satoshis at a given block height. +/// Initial subsidy: 50 NMC = 5,000,000,000 satoshis. +/// Halving: every 210,000 blocks (Namecoin shares Bitcoin's halving schedule). +/// Subsidy drops to 0 after 64 halvings (never in practice). +/// Reference: ref/namecoin / Bitcoin validation.cpp GetBlockSubsidy() - +/// Namecoin inherits Bitcoin's GetBlockSubsidy verbatim. +inline uint64_t get_block_subsidy(uint32_t height) { + static constexpr uint64_t COIN = 100'000'000ULL; // satoshis per NMC + static constexpr uint64_t INITIAL_SUBSIDY = 50ULL * COIN; // 50 NMC + 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/Namecoin 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. + virtual bool is_synced() const { return false; } +}; + +// Helpers + +/// Format a compact bits value as an 8-character lowercase hex string, +/// matching the format namecoind 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 NMC block template from a validated HeaderChain and Mempool. +/// +/// The resulting WorkData is layout-compatible with the GBT JSON that the +/// legacy RPC getwork() path returned, so all downstream code 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 + included fees in satoshis +/// 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 - 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. + 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-NMC] 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 + 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 + if (stx.fee_known) + entry["fee"] = static_cast(stx.fee); + else + entry["fee"] = nullptr; // JSON null -> consumer 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); + // Namecoin shares Bitcoin's rule set (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-NMC] 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-NMC] 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-NMC] 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-NMC] 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 the broadcaster, not through + /// this interface. This override is intentionally empty. + 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-NMC] 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. + 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 nmc diff --git a/src/impl/nmc/test/CMakeLists.txt b/src/impl/nmc/test/CMakeLists.txt index c00731fb1..07e0e40db 100644 --- a/src/impl/nmc/test/CMakeLists.txt +++ b/src/impl/nmc/test/CMakeLists.txt @@ -24,7 +24,19 @@ if (BUILD_TESTING AND GTest_FOUND) # nmc_coin lib (transaction.cpp). Still no node/pool/share libs (none exist). target_link_libraries(nmc_auxpow_merkle_test PRIVATE nmc_coin) + # P1 PC: embedded template builder KATs. Header-only builder + # (template_builder.hpp / rpc_data.hpp); links the same SCC the sibling + # merkle test does (core + nmc_coin for the out-of-line transaction ctor). + add_executable(nmc_template_builder_test nmc_template_builder_test.cpp) + target_link_libraries(nmc_template_builder_test PRIVATE + GTest::gtest_main GTest::gtest + core nlohmann_json::nlohmann_json) + target_link_libraries(nmc_template_builder_test PRIVATE + c2pool_payout c2pool_merged_mining c2pool_hashrate c2pool_storage) + target_link_libraries(nmc_template_builder_test PRIVATE nmc_coin) + include(GoogleTest) include_directories(${gtest_SOURCE_DIR}/include ${gtest_SOURCE_DIR}) gtest_add_tests(nmc_auxpow_merkle_test "" AUTO) + gtest_add_tests(nmc_template_builder_test "" AUTO) endif() diff --git a/src/impl/nmc/test/auxpow_merkle_test.cpp b/src/impl/nmc/test/auxpow_merkle_test.cpp index 1f026482f..be9be6387 100644 --- a/src/impl/nmc/test/auxpow_merkle_test.cpp +++ b/src/impl/nmc/test/auxpow_merkle_test.cpp @@ -34,6 +34,7 @@ #include #include "../coin/header_chain.hpp" +#include "../coin/mempool.hpp" namespace { @@ -1378,4 +1379,79 @@ TEST(NmcAuxPowPersist, IndexEntryDiskV1RoundTripsAuxPowBlob) EXPECT_FALSE(pback.to_entry().auxpow.has_value()); } + +// ─── P1 PB: NMC mempool (re-homed btc mirror) ─────────────────────────────── +// Pins nmc::coin::Mempool basics against the nmc::coin transaction/block types: +// add/contains/remove + duplicate rejection, feerate-ordered selection, and +// confirmed-block cleanup. The pool is a byte-faithful mirror of the btc pool +// re-homed into namespace nmc::coin (no btc::coin symbols). + +static nmc::coin::MutableTransaction mempool_tx(uint8_t seed, int64_t out_value) { + nmc::coin::MutableTransaction tx; + tx.version = 2; + tx.locktime = 0; + nmc::coin::TxIn in; + in.prevout.hash = leaf_of(seed); + in.prevout.index = 0; + in.sequence = 0xffffffffu; + tx.vin.push_back(in); + nmc::coin::TxOut out; + out.value = out_value; + tx.vout.push_back(out); + return tx; +} + +TEST(NmcMempool, AddContainsRemoveAndDedup) { + nmc::coin::Mempool pool; + auto tx = mempool_tx(0x11, 5000); + uint256 txid = nmc::coin::compute_txid(tx); + + EXPECT_TRUE(pool.add_tx(tx)); + EXPECT_TRUE(pool.contains(txid)); + EXPECT_EQ(pool.size(), 1u); + EXPECT_FALSE(pool.add_tx(tx)); // duplicate rejected + EXPECT_EQ(pool.size(), 1u); + + pool.remove_tx(txid); + EXPECT_FALSE(pool.contains(txid)); + EXPECT_EQ(pool.size(), 0u); +} + +TEST(NmcMempool, FeerateOrderedSelection) { + nmc::coin::Mempool pool; + auto lo = mempool_tx(0x21, 1000); + auto hi = mempool_tx(0x22, 1000); + uint256 lo_id = nmc::coin::compute_txid(lo); + uint256 hi_id = nmc::coin::compute_txid(hi); + ASSERT_TRUE(pool.add_tx(lo)); + ASSERT_TRUE(pool.add_tx(hi)); + // Equal weight, different fees -> higher fee is the higher feerate. + pool.set_tx_fee(lo_id, 1000); + pool.set_tx_fee(hi_id, 9000); + + auto sel = pool.get_sorted_txs_with_fees(4000000u); + ASSERT_EQ(sel.first.size(), 2u); + EXPECT_EQ(sel.first[0].fee, 9000u); // highest feerate first + EXPECT_EQ(sel.first[1].fee, 1000u); + EXPECT_EQ(sel.second, 10000u); + EXPECT_EQ(pool.total_fees(), 10000u); +} + +TEST(NmcMempool, RemoveForBlockClearsConfirmed) { + nmc::coin::Mempool pool; + auto a = mempool_tx(0x31, 2000); + auto b = mempool_tx(0x32, 2000); + uint256 a_id = nmc::coin::compute_txid(a); + ASSERT_TRUE(pool.add_tx(a)); + ASSERT_TRUE(pool.add_tx(b)); + EXPECT_EQ(pool.size(), 2u); + + nmc::coin::BlockType block; + block.m_txs.push_back(a); // a confirmed in this block + pool.remove_for_block(block); + + EXPECT_FALSE(pool.contains(a_id)); + EXPECT_EQ(pool.size(), 1u); // b remains +} + } // namespace diff --git a/src/impl/nmc/test/nmc_template_builder_test.cpp b/src/impl/nmc/test/nmc_template_builder_test.cpp new file mode 100644 index 000000000..574090685 --- /dev/null +++ b/src/impl/nmc/test/nmc_template_builder_test.cpp @@ -0,0 +1,322 @@ +// --------------------------------------------------------------------------- +// nmc::coin::TemplateBuilder KATs (P1 PC - embedded template builder). +// +// Re-homed mirror of the BTC template-builder coverage, fenced to src/impl/nmc/. +// Pins the three structural pieces the embedded NMC block-template path is built +// on: +// * get_block_subsidy() - Namecoin's (= Bitcoin's) 50-coin / 210,000-block +// halving schedule, at and around the boundaries; +// * compute_merkle_root() - the SHA256d ComputeMerkleRoot walk for 0 / 1 / 2 / +// odd-count leaf lists, with roots re-derived +// INDEPENDENTLY here (not by calling the function) +// so a fold/duplicate bug is caught, not mirrored; +// * TemplateBuilder::build_template() - end-to-end on a small seeded +// HeaderChain + Mempool: a present tip yields a +// GBT-shaped WorkData; an empty chain yields nullopt. +// +// PC is the STRUCTURAL builder only. Phase PD (the embedded IAuxChainBackend +// build-side: dual-target + the aux block-hash committed in the parent +// coinbase) is exercised by the NmcAuxChainEmbedded suite at the end. +// +// Per-coin isolation: src/impl/nmc/ only; btc tree consumed READ-ONLY. MUST +// appear in BOTH test/CMakeLists.txt AND the build.yml --target allowlist or it +// becomes a NOT_BUILT sentinel that reds master. +// --------------------------------------------------------------------------- + +#include + +#include +#include +#include +#include + +#include +#include +#include + +#include "../coin/header_chain.hpp" +#include "../coin/mempool.hpp" +#include "../coin/template_builder.hpp" +#include "../coin/aux_chain_embedded.hpp" + +#include + +namespace { + +using nmc::coin::HeaderChain; +using nmc::coin::Mempool; +using nmc::coin::NMCChainParams; +using nmc::coin::BlockHeaderType; +using nmc::coin::TemplateBuilder; +using nmc::coin::compute_merkle_root; +using nmc::coin::get_block_subsidy; +using nmc::coin::block_hash; +using nmc::coin::AuxChainEmbedded; + +// Build NMC params with a TEST-only pinned activation height so plain headers +// below it ADMIT (production stays the -1 sentinel). Mirror of the auxpow test +// fixture's params_activation(). +static NMCChainParams params_pinned() +{ + NMCChainParams p = NMCChainParams::mainnet(); + p.aux_chain_id = 1; + p.auxpow_activation_height = 19200; // TEST-only pin + return p; +} + +static BlockHeaderType plain_header(const uint256& prev, uint32_t bits, + uint32_t nonce, uint32_t ts) +{ + BlockHeaderType h{}; + h.m_version = 1; + h.m_previous_block = prev; + h.m_bits = bits; + h.m_nonce = nonce; + h.m_timestamp = ts; + return h; +} + +// Independent reference combine: double-SHA256 of l||r over the 64-byte +// concatenation, computed WITHOUT touching compute_merkle_root. +static uint256 combine(const uint256& l, const uint256& r) +{ + auto sl = std::span(l.data(), 32); + auto sr = std::span(r.data(), 32); + return Hash(sl, sr); +} + +static uint256 leaf_of(unsigned char b) +{ + uint256 u; u.SetNull(); + *(u.begin()) = b; + return u; +} + +// ── get_block_subsidy ────────────────────────────────────────────────────── + +TEST(NmcTemplateSubsidy, InitialAndHalvingBoundaries) +{ + constexpr uint64_t COIN = 100000000ULL; + EXPECT_EQ(get_block_subsidy(0u), 50ULL * COIN); + EXPECT_EQ(get_block_subsidy(1u), 50ULL * COIN); + EXPECT_EQ(get_block_subsidy(209999u), 50ULL * COIN); // last pre-halving block + EXPECT_EQ(get_block_subsidy(210000u), 25ULL * COIN); // first halving + EXPECT_EQ(get_block_subsidy(419999u), 25ULL * COIN); + EXPECT_EQ(get_block_subsidy(420000u), 12ULL * COIN + COIN / 2); // 12.5 NMC + EXPECT_EQ(get_block_subsidy(630000u), 6ULL * COIN + COIN / 4); // 6.25 NMC +} + +TEST(NmcTemplateSubsidy, SubsidyReachesZeroAfter64Halvings) +{ + constexpr uint32_t INTERVAL = 210000u; + // 63 halvings: still > 0 (50 BTC >> 63 == 0 actually, but >>63 of 5e9 is 0). + // The contract is: >= 64 halvings -> exactly 0. + EXPECT_EQ(get_block_subsidy(64u * INTERVAL), 0ULL); + EXPECT_EQ(get_block_subsidy(100u * INTERVAL), 0ULL); +} + +// ── compute_merkle_root ──────────────────────────────────────────────────── + +TEST(NmcTemplateMerkle, EmptyListIsZero) +{ + EXPECT_EQ(compute_merkle_root({}), uint256::ZERO); +} + +TEST(NmcTemplateMerkle, SingleLeafIsItself) +{ + uint256 a = leaf_of(0x11); + EXPECT_EQ(compute_merkle_root({a}), a); +} + +TEST(NmcTemplateMerkle, TwoLeavesFoldOnce) +{ + uint256 a = leaf_of(0x11), b = leaf_of(0x22); + EXPECT_EQ(compute_merkle_root({a, b}), combine(a, b)); +} + +TEST(NmcTemplateMerkle, OddCountDuplicatesLast) +{ + // Three leaves: last is duplicated -> root = combine(combine(a,b), combine(c,c)). + uint256 a = leaf_of(0x11), b = leaf_of(0x22), c = leaf_of(0x33); + uint256 want = combine(combine(a, b), combine(c, c)); + EXPECT_EQ(compute_merkle_root({a, b, c}), want); +} + +TEST(NmcTemplateMerkle, FourLeavesBalancedTree) +{ + uint256 a = leaf_of(0x11), b = leaf_of(0x22), + c = leaf_of(0x33), d = leaf_of(0x44); + uint256 want = combine(combine(a, b), combine(c, d)); + EXPECT_EQ(compute_merkle_root({a, b, c, d}), want); +} + +// ── TemplateBuilder::build_template ───────────────────────────────────────── + +TEST(NmcTemplateBuild, EmptyChainReturnsNullopt) +{ + HeaderChain chain(params_pinned()); // no headers added + Mempool pool; + auto wd = TemplateBuilder::build_template(chain, pool, /*is_testnet=*/false); + EXPECT_FALSE(wd.has_value()); +} + +TEST(NmcTemplateBuild, SeededChainYieldsWorkData) +{ + HeaderChain chain(params_pinned()); + Mempool pool; + + // Seed a small chain with a recent timestamp so the tip is "fresh". + auto now = static_cast(std::time(nullptr)); + uint256 z; z.SetNull(); + BlockHeaderType g = plain_header(z, 0x1d00ffffu, 1, now - 100); + ASSERT_TRUE(chain.add_header(g)); + BlockHeaderType c = plain_header(block_hash(g), 0x1d00ffffu, 2, now - 50); + ASSERT_TRUE(chain.add_header(c)); + ASSERT_EQ(chain.height(), 1u); + + auto wd = TemplateBuilder::build_template(chain, pool, /*is_testnet=*/false); + ASSERT_TRUE(wd.has_value()); + + const auto& d = wd->m_data; + // Next block builds on the tip: height = tip.height + 1 = 2. + EXPECT_EQ(d.at("height").get(), 2); + EXPECT_EQ(d.at("previousblockhash").get(), block_hash(c).GetHex()); + // No mempool txs -> coinbasevalue == subsidy(2) == 50 NMC. + EXPECT_EQ(d.at("coinbasevalue").get(), + static_cast(get_block_subsidy(2u))); + EXPECT_TRUE(d.at("transactions").is_array()); + EXPECT_EQ(d.at("transactions").size(), 0u); + // bits hex is 8 lowercase hex chars; tip carried 0x1d00ffff which is below + // the retarget interval so next_bits == tip_bits. + EXPECT_EQ(d.at("bits").get(), std::string("1d00ffff")); + // Version floored to the BIP9 base. + EXPECT_GE(static_cast(d.at("version").get()), + TemplateBuilder::BIP9_BASE_VERSION); + EXPECT_EQ(d.at("rules"), nlohmann::json::array({"segwit"})); +} + +TEST(NmcTemplateBuild, StaleTipReportsNotSynced) +{ + // A chain whose tip is older than DEFAULT_MAX_TIP_AGE is not synced, but + // build_template still produces a template (sync gating lives in the node + // wrapper, not the builder). This pins that separation. + HeaderChain chain(params_pinned()); + Mempool pool; + uint256 z; z.SetNull(); + BlockHeaderType g = plain_header(z, 0x1d00ffffu, 1, /*ts=*/1000000); // ancient + ASSERT_TRUE(chain.add_header(g)); + EXPECT_FALSE(chain.is_synced()); + auto wd = TemplateBuilder::build_template(chain, pool, /*is_testnet=*/false); + EXPECT_TRUE(wd.has_value()); +} + + +// -- P1 PD: AuxChainEmbedded (embedded IAuxChainBackend build-side) ---------- +// +// Pins the merge-mining aux-work the BTC parent pulls from the embedded NMC +// node: dual-target (aux PoW target from template bits), the aux block-hash +// committed in the parent coinbase (= NMC tip), and the consensus chain_id +// (= NMCChainParams::aux_chain_id, the SAME field the verify side pins). Sync +// gating: an empty chain yields empty work (no daemon, no leak of a stale tip). + +static c2pool::merged::AuxChainConfig nmc_aux_config() +{ + c2pool::merged::AuxChainConfig cfg; + cfg.symbol = "NMC"; + cfg.chain_id = 1; + return cfg; +} + +// Seed a synced two-header chain (fresh tip) and return its tip hash via out-param. +static void seed_synced_chain(HeaderChain& chain, uint256& tip_hash_out) +{ + auto now = static_cast(std::time(nullptr)); + uint256 z; z.SetNull(); + BlockHeaderType g = plain_header(z, 0x1d00ffffu, 1, now - 100); + ASSERT_TRUE(chain.add_header(g)); + BlockHeaderType c = plain_header(block_hash(g), 0x1d00ffffu, 2, now - 50); + ASSERT_TRUE(chain.add_header(c)); + tip_hash_out = block_hash(c); +} + +TEST(NmcAuxChainEmbedded, SyncedChainYieldsAuxWork) +{ + HeaderChain chain(params_pinned()); + Mempool pool; + uint256 tip_hash; + seed_synced_chain(chain, tip_hash); + ASSERT_TRUE(chain.is_synced()); + + AuxChainEmbedded backend(chain, pool, params_pinned(), nmc_aux_config(), + /*testnet=*/false); + ASSERT_TRUE(backend.connect()); + + auto work = backend.get_work_template(); + + // Aux block-hash committed in the parent coinbase == NMC tip. + EXPECT_EQ(work.block_hash, tip_hash); + // chain_id is the consensus params value (1), not left at the default 0. + EXPECT_EQ(work.chain_id, 1u); + // Next block builds on the tip: height == 2. + EXPECT_EQ(work.height, 2); + EXPECT_EQ(work.coinbase_value, get_block_subsidy(2u)); + EXPECT_EQ(work.prev_block_hash, tip_hash.GetHex()); + + // Dual-target: aux PoW target re-derived INDEPENDENTLY from bits 0x1d00ffff. + uint256 expected_target; expected_target.SetCompact(0x1d00ffffu); + EXPECT_EQ(work.target, expected_target); + EXPECT_FALSE(work.target == uint256::ZERO); + + EXPECT_EQ(backend.get_best_block_hash(), tip_hash.GetHex()); + EXPECT_EQ(backend.config().symbol, std::string("NMC")); +} + +TEST(NmcAuxChainEmbedded, EmptyChainYieldsEmptyWork) +{ + HeaderChain chain(params_pinned()); // no headers -> not synced + Mempool pool; + ASSERT_FALSE(chain.is_synced()); + + AuxChainEmbedded backend(chain, pool, params_pinned(), nmc_aux_config()); + auto work = backend.get_work_template(); + + // Sync gate fired: no template fields leaked. + EXPECT_TRUE(work.block_template.is_null()); + EXPECT_EQ(work.height, 0); + EXPECT_EQ(work.coinbase_value, 0u); + EXPECT_EQ(backend.get_best_block_hash(), std::string("")); +} + +TEST(NmcAuxChainEmbedded, CreateAuxBlockMatchesGetWorkTemplate) +{ + HeaderChain chain(params_pinned()); + Mempool pool; + uint256 tip_hash; + seed_synced_chain(chain, tip_hash); + + AuxChainEmbedded backend(chain, pool, params_pinned(), nmc_aux_config()); + // Embedded mode is always multiaddress: createauxblock == getblocktemplate. + auto a = backend.create_aux_block("ignored-address"); + auto b = backend.get_work_template(); + EXPECT_EQ(a.block_hash, b.block_hash); + EXPECT_EQ(a.target, b.target); + EXPECT_EQ(a.chain_id, b.chain_id); + EXPECT_EQ(a.height, b.height); +} + +TEST(NmcAuxChainEmbedded, SubmitBlockCachesHexForRetrieval) +{ + HeaderChain chain(params_pinned()); + Mempool pool; + AuxChainEmbedded backend(chain, pool, params_pinned(), nmc_aux_config()); + + EXPECT_EQ(backend.get_block_hex("any"), std::string("")); + const std::string hex(200, 'a'); + EXPECT_TRUE(backend.submit_block(hex)); + EXPECT_EQ(backend.get_block_hex("any"), hex); + // No daemon peers in embedded mode. + EXPECT_TRUE(backend.getpeerinfo().empty()); +} + + +} // namespace