From 01de543111e3082700f31b5d7235c6fff6422d68 Mon Sep 17 00:00:00 2001 From: frstrtr Date: Fri, 19 Jun 2026 14:10:49 +0000 Subject: [PATCH] nmc: P1 PB embedded mempool (re-homed btc mirror) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds nmc::coin::Mempool — a byte-faithful mirror of src/impl/btc/coin/ mempool.hpp re-homed into namespace nmc::coin so the NMC coin tree stays self-contained (no btc::coin symbols). Namecoin is a Bitcoin fork: same wire format, 21M money cap, 100-block coinbase maturity (NMC_LIMITS, instantiated locally from the public core::coin::ChainLimits; no core edit). Feeds the P1 PC template builder next. Coverage: +3 KATs in the existing (CI-allowlisted) nmc_auxpow_merkle_test (add/contains/remove + dedup, feerate-ordered selection, confirmed-block cleanup). Suite 66 -> 69 green. Fence held: src/impl/nmc/ only, no build.yml / src/core / bitcoin_family change. Reference: namecoin-core (Bitcoin fork) txmempool.cpp / amount.h MAX_MONEY, consensus.h COINBASE_MATURITY. --- src/impl/nmc/coin/CMakeLists.txt | 1 + src/impl/nmc/coin/coin_smoke.cpp | 1 + src/impl/nmc/coin/mempool.hpp | 745 +++++++++++++++++++++++ src/impl/nmc/test/auxpow_merkle_test.cpp | 76 +++ 4 files changed, 823 insertions(+) create mode 100644 src/impl/nmc/coin/mempool.hpp diff --git a/src/impl/nmc/coin/CMakeLists.txt b/src/impl/nmc/coin/CMakeLists.txt index 85e446b50..0737ace3c 100644 --- a/src/impl/nmc/coin/CMakeLists.txt +++ b/src/impl/nmc/coin/CMakeLists.txt @@ -6,6 +6,7 @@ set(nmc_coin_interface transaction.hpp transaction.cpp block.hpp + mempool.hpp header_chain.hpp coin_smoke.cpp ) diff --git a/src/impl/nmc/coin/coin_smoke.cpp b/src/impl/nmc/coin/coin_smoke.cpp index f69d727cf..4e94c2c53 100644 --- a/src/impl/nmc/coin/coin_smoke.cpp +++ b/src/impl/nmc/coin/coin_smoke.cpp @@ -8,6 +8,7 @@ #include "header_chain.hpp" #include "block.hpp" +#include "mempool.hpp" namespace nmc { namespace coin { 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/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