From cf03204830736e59f9fa5c5fcf741b39ca6d2e67 Mon Sep 17 00:00:00 2001 From: frstrtr Date: Fri, 19 Jun 2026 11:49:32 +0000 Subject: [PATCH] dash(S7): land in-memory mempool leaf (storage + UTXO-fee + feerate selection) Adds src/impl/dash/coin/mempool.hpp: thread-safe in-memory Dash mempool with UTXO-derived fee computation, LRU size-cap + expiry eviction, double-spend conflict removal on block-connect, and the feerate-sorted get_sorted_txs_with_fees() selection consumed by the embedded GBT template builder. Adapted from src/impl/ltc/coin/mempool.hpp with the Dash simplifications (no segwit/weight/wtxid index; special-tx stored as opaque blobs). Header-only over block/transaction/utxo_adapter + core/coin (utxo_view_cache); no ltc/pool SCC dependency. test_dash_mempool.cpp adds 9 KATs (add/dup/remove, fee=in-out from UTXO, fee-unknown without UTXO, recompute-after-UTXO, LRU eviction, block-connect conflict eviction, highest-feerate-first selection), registered in the CI --target lists so it builds under dash-linux-only. Direct prerequisite for embedded_gbt (S7 main); dashd RPC fallback path untouched. --- .github/workflows/build.yml | 4 +- src/impl/dash/coin/mempool.hpp | 488 +++++++++++++++++++++++++++++++++ test/CMakeLists.txt | 14 + test/test_dash_mempool.cpp | 256 +++++++++++++++++ 4 files changed, 760 insertions(+), 2 deletions(-) create mode 100644 src/impl/dash/coin/mempool.hpp create mode 100644 test/test_dash_mempool.cpp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 76c689611..288b7a1c4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -59,7 +59,7 @@ jobs: test_threading test_weights \ test_header_chain test_mempool test_template_builder \ test_doge_chain test_compact_blocks test_dash_x11_kat \ - test_dash_header_chain test_dash_block_replay test_dash_conformance test_dash_subsidy \ + test_dash_header_chain test_dash_block_replay test_dash_conformance test_dash_subsidy test_dash_mempool \ test_multiaddress_pplns test_pplns_stress \ test_hash_link test_decay_pplns \ test_pplns_consensus \ @@ -193,7 +193,7 @@ jobs: test_threading test_weights \ test_header_chain test_mempool test_template_builder \ test_doge_chain test_compact_blocks test_dash_x11_kat \ - test_dash_header_chain test_dash_block_replay test_dash_conformance test_dash_subsidy \ + test_dash_header_chain test_dash_block_replay test_dash_conformance test_dash_subsidy test_dash_mempool \ test_hash_link test_decay_pplns \ test_pplns_consensus \ test_v36_script_sorting test_v36_cross_impl_refhash \ diff --git a/src/impl/dash/coin/mempool.hpp b/src/impl/dash/coin/mempool.hpp new file mode 100644 index 000000000..ba124c87c --- /dev/null +++ b/src/impl/dash/coin/mempool.hpp @@ -0,0 +1,488 @@ +#pragma once + +/// Phase C-MEMPOOL step 1: in-memory Dash mempool. +/// +/// Adapted from src/impl/ltc/coin/mempool.hpp (~735 LOC) with these +/// Dash-specific simplifications: +/// - No segwit → no TX_WITH_WITNESS / TX_NO_WITNESS distinction; just +/// `pack(tx)` and `dash::coin::dash_txid()`. +/// - No weight calculation → just base_size = sizeof serialized tx. +/// - No wtxid index (BIP 152 v2 never reaches Dash; we already pin +/// CMPCTBLOCKS_VERSION=1 in Phase U). +/// - Special-tx (type 1-4 ProTx, type 5 CCbTx, type 6 quorum +/// commitment) are stored as opaque MutableTransaction blobs; +/// consumers (Phase C-PAY's state machine, future block-template +/// builder) decode their own way. +/// +/// Step 1 SCOPE: storage layer + UTXO fee + LRU size-cap eviction + +/// confirm-eviction + double-spend conflict detection. All thread-safe +/// via single std::mutex. +/// +/// Step 2 ADDS: +/// - Feerate-sorted index (m_feerate_index) maintained on add/remove +/// - get_sorted_txs_with_fees(max_bytes): highest-feerate-first +/// selection up to a byte cap, with stale-input guard. Phase +/// C-TEMPLATE prerequisite. +/// - recompute_unknown_fees(utxo): re-attempts fee computation for +/// entries marked fee_known=false (typically after a block +/// connect added their inputs to UTXO). +/// +/// DEFERRED (later steps): +/// - BIP 35 mempool initial sync drain (step 3 — wire already in +/// p2p_node.hpp, no consumer yet) +/// - revalidate_inputs (evict txs whose inputs got spent without +/// us catching it via remove_for_block conflict path) +/// - Orphan-children removal after conflict eviction +/// - Lightweight summary API for the explorer/dashboard + +#include "block.hpp" +#include "transaction.hpp" +#include "utxo_adapter.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dash { +namespace coin { + +struct MempoolEntry { + MutableTransaction tx; + uint256 txid; + uint32_t base_size{0}; + uint64_t fee{0}; // satoshi (computed from UTXO when available) + bool fee_known{false}; + time_t time_added{0}; + + double feerate_satvb() const { + return (fee_known && base_size > 0) + ? static_cast(fee) / base_size + : 0.0; + } +}; + +class Mempool { +public: + static constexpr size_t DEFAULT_MAX_BYTES = 300ULL * 1024 * 1024; + static constexpr time_t DEFAULT_EXPIRY_SECS = 14 * 24 * 3600; + + explicit Mempool(size_t max_bytes = DEFAULT_MAX_BYTES, + time_t expiry_sec = DEFAULT_EXPIRY_SECS) + : m_max_bytes(max_bytes) + , m_expiry_sec(expiry_sec) + {} + + Mempool(const Mempool&) = delete; + Mempool& operator=(const Mempool&) = delete; + + void set_utxo(::core::coin::UTXOViewCache* u) { m_utxo.store(u); } + + // ── Mutation ──────────────────────────────────────────────────── + + /// Add a transaction. Returns false if already known. When `utxo` + /// is passed (or set_utxo was called), attempts fee computation; + /// falls back to fee_known=false on missing-input. + bool add_tx(const MutableTransaction& tx) + { + return add_tx(tx, m_utxo.load()); + } + + bool add_tx(const MutableTransaction& tx, + ::core::coin::UTXOViewCache* utxo) + { + uint256 txid = dash_txid(tx); + + std::lock_guard lock(m_mutex); + if (m_pool.count(txid)) return false; + + MempoolEntry entry; + entry.tx = tx; + entry.txid = txid; + auto packed = ::pack(tx); + entry.base_size = static_cast(packed.size()); + entry.time_added = std::time(nullptr); + + compute_fee_locked(entry, utxo); + + // LRU eviction if over size cap. + int evicted = 0; + while (m_total_bytes + entry.base_size > m_max_bytes + && !m_time_index.empty()) { + evict_one_locked(m_time_index.begin()->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; + + // Spent-outputs index for double-spend conflict detection. + for (const auto& vin : stored.tx.vin) { + m_spent_outputs[std::make_pair( + vin.prevout.hash, vin.prevout.index)] = txid; + } + + // Feerate-sorted index (step 2) — only if fee was known. + // Unknown-fee txs sit out of the sorted view until + // recompute_unknown_fees() resolves them after a UTXO update. + // Uses negative feerate as the multimap key so begin() = best. + if (stored.fee_known) { + m_feerate_index.emplace(stored.feerate_satvb(), txid); + } + + // Periodic stats — every 100 entries + first 5 + every eviction. + if (m_pool.size() % 100 == 0 || m_pool.size() <= 5 || evicted > 0) { + LOG_INFO << "[MEMPOOL] add txid=" << txid.GetHex().substr(0, 16) + << " size=" << m_pool.size() + << " bytes=" << m_total_bytes << "/" << m_max_bytes + << " base=" << stored.base_size + << " fee=" << (stored.fee_known + ? std::to_string(stored.fee) : "?") + << (evicted > 0 ? " evict=" + std::to_string(evicted) : ""); + } + return true; + } + + void remove_tx(const uint256& txid) + { + std::lock_guard lock(m_mutex); + remove_tx_locked(txid); + } + + /// Eviction on block confirm. Two phases: + /// 1. Remove every confirmed tx by txid. + /// 2. Remove mempool txs that spend the same outputs as confirmed + /// txs (double-spend conflicts). + void remove_for_block(const dash::coin::BlockType& block) + { + std::lock_guard lock(m_mutex); + int removed = 0, conflicts = 0; + + // Phase 1 + for (const auto& mtx : block.m_txs) { + uint256 txid = dash_txid(mtx); + if (m_pool.count(txid)) ++removed; + remove_tx_locked(txid); + } + + // Phase 2 + 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()) continue; + auto conflict_txid = it->second; + if (m_pool.count(conflict_txid)) { + LOG_INFO << "[MEMPOOL] removing conflict tx " + << conflict_txid.GetHex().substr(0, 16) + << " (spends same input as confirmed tx " + << dash_txid(mtx).GetHex().substr(0, 16) << ")"; + ++conflicts; + } + remove_tx_locked(conflict_txid); + } + } + + if (removed > 0 || conflicts > 0) { + LOG_INFO << "[MEMPOOL] block cleanup removed=" << removed + << " conflicts=" << conflicts + << " remaining=" << m_pool.size(); + } + } + + void evict_expired() + { + time_t cutoff = std::time(nullptr) - m_expiry_sec; + std::lock_guard lock(m_mutex); + int evicted = 0; + while (!m_time_index.empty() + && m_time_index.begin()->first < cutoff) { + evict_one_locked(m_time_index.begin()->second); + ++evicted; + } + if (evicted > 0) { + LOG_INFO << "[MEMPOOL] expiry sweep evicted " << evicted + << " entries (older than " << (m_expiry_sec / 3600) + << "h), remaining=" << m_pool.size(); + } + } + + void clear() + { + std::lock_guard lock(m_mutex); + m_pool.clear(); + m_time_index.clear(); + m_spent_outputs.clear(); + m_feerate_index.clear(); + m_total_bytes = 0; + } + + /// Re-attempt fee computation for entries marked fee_known=false. + /// Call after a block-connect: the block's outputs are now in + /// UTXO and may resolve previously-unknown inputs. Returns the + /// number of newly-resolved entries. + int recompute_unknown_fees(::core::coin::UTXOViewCache* utxo) + { + if (!utxo) return 0; + std::lock_guard lock(m_mutex); + int resolved = 0, still_unknown = 0; + uint64_t resolved_fees = 0; + for (auto& [txid, entry] : m_pool) { + if (entry.fee_known) continue; + if (compute_fee_locked(entry, utxo)) { + m_feerate_index.emplace(entry.feerate_satvb(), txid); + resolved_fees += entry.fee; + ++resolved; + } else { + ++still_unknown; + } + } + if (resolved > 0 || still_unknown > 0) { + LOG_INFO << "[MEMPOOL] fee revalidation: resolved=" << resolved + << " still_unknown=" << still_unknown + << " resolved_fees=" << resolved_fees << " sat" + << " pool_size=" << m_pool.size(); + } + return resolved; + } + + // ── 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; + } + + uint64_t total_known_fees() const + { + std::lock_guard lock(m_mutex); + uint64_t sum = 0; + for (const auto& [_, e] : m_pool) { + if (e.fee_known) sum += e.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; + } + + 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 keyed by txid. Used (eventually) + /// by BIP 152 compact-block reconstruction + by block-template + /// builder in Phase C-TEMPLATE. + std::map all_txs_map() const + { + std::lock_guard lock(m_mutex); + std::map out; + for (const auto& [txid, e] : m_pool) out[txid] = e.tx; + return out; + } + + /// Phase C-TEMPLATE prerequisite — fee-aware tx selection. + /// Returns transactions sorted by feerate (highest first) up to + /// `max_bytes` of base-size budget. Transactions with unknown + /// fees are EXCLUDED — they'd poison the coinbasevalue if we + /// included them at fee=0 vs dashd's GBT (which always knows + /// fees because it sees the full mempool with full UTXO). + /// + /// Stale-input guard: re-checks each candidate's inputs against + /// the live UTXO + parent-mempool chain before including. Catches + /// the brief window between tip-change and full_block arrival + /// where remove_for_block hasn't yet evicted now-double-spent + /// txs. + /// + /// Returns (selected, total_fees_sat). + struct SelectedTx { + MutableTransaction tx; + uint64_t fee{0}; + uint32_t base_size{0}; + }; + + std::pair, uint64_t> + get_sorted_txs_with_fees(uint32_t max_bytes) const + { + std::lock_guard lock(m_mutex); + std::vector result; + uint64_t total_fees = 0; + uint32_t total_bytes = 0; + auto* utxo = m_utxo.load(); + + for (const auto& [feerate, txid] : m_feerate_index) { + (void)feerate; + auto pit = m_pool.find(txid); + if (pit == m_pool.end()) continue; + const auto& entry = pit->second; + if (!entry.fee_known) continue; + if (total_bytes + entry.base_size > max_bytes) continue; + + // Stale-input guard. + if (utxo) { + bool 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)) continue; + // Parent-mempool chain (CPFP). + auto parent = m_pool.find(vin.prevout.hash); + if (parent != m_pool.end() + && vin.prevout.index < parent->second.tx.vout.size()) { + continue; + } + ok = false; + break; + } + if (!ok) continue; + } + + total_bytes += entry.base_size; + total_fees += entry.fee; + result.push_back({entry.tx, entry.fee, entry.base_size}); + } + return {std::move(result), total_fees}; + } + +private: + mutable std::mutex m_mutex; + std::map m_pool; + std::multimap m_time_index; + std::map, uint256> m_spent_outputs; + // Step 2: feerate-sorted index. greater means begin() = + // highest feerate, so iteration is best-first. + std::multimap> m_feerate_index; + size_t m_total_bytes{0}; + size_t m_max_bytes; + time_t m_expiry_sec; + std::atomic<::core::coin::UTXOViewCache*> m_utxo{nullptr}; + + void evict_one_locked(const uint256& txid) + { + remove_tx_locked(txid); + } + + void remove_tx_locked(const uint256& txid) + { + auto it = m_pool.find(txid); + if (it == m_pool.end()) return; + + // Drop from time index. + auto trng = m_time_index.equal_range(it->second.time_added); + for (auto rit = trng.first; rit != trng.second; ++rit) { + if (rit->second == txid) { + m_time_index.erase(rit); + break; + } + } + + // Drop from feerate index (only present if fee was known). + if (it->second.fee_known) { + auto frng = m_feerate_index.equal_range(it->second.feerate_satvb()); + for (auto rit = frng.first; rit != frng.second; ++rit) { + if (rit->second == txid) { + m_feerate_index.erase(rit); + break; + } + } + } + + // Drop from spent-outputs index. + for (const auto& vin : it->second.tx.vin) { + auto key = std::make_pair(vin.prevout.hash, vin.prevout.index); + auto sit = m_spent_outputs.find(key); + if (sit != m_spent_outputs.end() && sit->second == txid) { + m_spent_outputs.erase(sit); + } + } + + m_total_bytes -= it->second.base_size; + m_pool.erase(it); + } + + /// Compute fee = sum(input_values) - sum(output_values). + /// Inputs come from UTXO set (confirmed); falls back to parent + /// mempool tx outputs (CPFP / chain-of-unconfirmed). Sets + /// entry.fee_known on success. + bool compute_fee_locked(MempoolEntry& entry, + ::core::coin::UTXOViewCache* utxo) + { + if (!utxo) { + entry.fee_known = false; + return false; + } + uint64_t in_sum = 0, out_sum = 0; + 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)) { + in_sum += static_cast(coin.value); + continue; + } + // Try parent mempool tx (CPFP). + auto pit = m_pool.find(vin.prevout.hash); + if (pit != m_pool.end() + && vin.prevout.index < pit->second.tx.vout.size()) { + in_sum += static_cast( + pit->second.tx.vout[vin.prevout.index].value); + continue; + } + entry.fee_known = false; + entry.fee = 0; + return false; + } + for (const auto& vout : entry.tx.vout) { + out_sum += static_cast(vout.value); + } + if (in_sum < out_sum) { + // Negative fee — invalid tx; mark unknown so we don't + // poison block templates with garbage values. + entry.fee_known = false; + entry.fee = 0; + return false; + } + entry.fee = in_sum - out_sum; + entry.fee_known = true; + return true; + } +}; + +} // namespace coin +} // namespace dash diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 7008c6332..d30115fd9 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -269,6 +269,20 @@ if (BUILD_TESTING AND GTest_FOUND) target_link_libraries(test_dash_subsidy PRIVATE c2pool_payout c2pool_hashrate c2pool_merged_mining) # OBJECT-lib SCC direct-naming (#22/#39): core stratum/web_server TUs gtest_add_tests(test_dash_subsidy "" AUTO) + # DASH in-memory mempool (S7 leaf): storage + UTXO-fee + LRU eviction + + # double-spend conflict removal + feerate-sorted selection. Header-only over + # block/transaction/utxo_adapter + core/coin; no ltc/pool SCC dependency. + # Direct prerequisite for the embedded_gbt template builder (S7 main). + add_executable(test_dash_mempool test_dash_mempool.cpp) + target_link_libraries(test_dash_mempool PRIVATE + GTest::gtest_main GTest::gtest + dash_x11 core + nlohmann_json::nlohmann_json + ${Boost_LIBRARIES} + ) + target_link_libraries(test_dash_mempool PRIVATE c2pool_payout c2pool_hashrate c2pool_merged_mining) # OBJECT-lib SCC direct-naming (#22/#39) + gtest_add_tests(test_dash_mempool "" AUTO) + add_executable(test_compact_blocks test_compact_blocks.cpp) target_link_libraries(test_compact_blocks PRIVATE GTest::gtest_main GTest::gtest diff --git a/test/test_dash_mempool.cpp b/test/test_dash_mempool.cpp new file mode 100644 index 000000000..615892703 --- /dev/null +++ b/test/test_dash_mempool.cpp @@ -0,0 +1,256 @@ +/// Phase C-MEMPOOL step 1+2 — Dash in-memory mempool unit tests +/// +/// Exercises the storage layer, UTXO-fee computation, LRU size-cap +/// eviction, double-spend conflict removal on block-connect, and the +/// feerate-sorted selection used by the embedded GBT template builder +/// (Phase C-TEMPLATE prerequisite, S7). +/// +/// The mempool was adapted from src/impl/ltc/coin/mempool.hpp with the +/// Dash simplifications (no segwit, no weight, no wtxid index). These +/// tests pin the behaviour that the embedded_gbt builder depends on: +/// - add/dup-reject/remove +/// - fee = sum(inputs) - sum(outputs) from the UTXO view +/// - fee_known=false when inputs are not in the UTXO (kept out of the +/// sorted view so they cannot poison coinbasevalue) +/// - size-cap eviction is LRU (oldest time_added first) +/// - remove_for_block evicts confirmed txs AND their double-spend +/// conflicts +/// - get_sorted_txs_with_fees returns highest-feerate-first + +#include + +#include +#include + +#include +#include + +#include + +using dash::coin::Mempool; +using dash::coin::MutableTransaction; +using dash::coin::dash_txid; +using dash::coin::BlockType; +using ::core::coin::UTXOViewCache; +using ::core::coin::Outpoint; +using ::core::coin::Coin; +using ::bitcoin_family::coin::TxIn; +using ::bitcoin_family::coin::TxOut; + +// ─── Fixture helpers ───────────────────────────────────────────────────────── + +// A distinct uint256 minted from a small integer (via the canonical +// pack+Hash of a locktime-varied empty tx), used as a prevout hash. +static uint256 mint_hash(uint32_t seed) { + MutableTransaction t; + t.version = 1; + t.type = 0; + t.locktime = 0x51000000u ^ seed; // keep the seeds out of the tx-fixture range + auto ps = ::pack(t); + return ::Hash(ps.get_span()); +} + +static TxIn make_input(const uint256& prev_hash, uint32_t prev_index) { + TxIn in; + in.prevout.hash = prev_hash; + in.prevout.index = prev_index; + in.sequence = 0xffffffffu; + return in; +} + +static TxOut make_output(int64_t value) { + TxOut out; + out.value = value; + return out; +} + +// Build a spending tx: one input (prev_hash:prev_index) and one output +// of `out_value`. `salt` perturbs the locktime so otherwise-identical +// txs get distinct txids. +static MutableTransaction make_spend(const uint256& prev_hash, + uint32_t prev_index, + int64_t out_value, + uint32_t salt = 0) { + MutableTransaction tx; + tx.version = 1; + tx.type = 0; + tx.locktime = salt; + tx.vin.push_back(make_input(prev_hash, prev_index)); + tx.vout.push_back(make_output(out_value)); + return tx; +} + +static MutableTransaction make_empty(uint32_t locktime) { + MutableTransaction tx; + tx.version = 1; + tx.type = 0; + tx.locktime = locktime; + return tx; +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +TEST(DashMempool, AddContainsAndSize) +{ + Mempool mp; + auto tx = make_empty(1); + uint256 txid = dash_txid(tx); + + EXPECT_TRUE(mp.add_tx(tx)); + EXPECT_TRUE(mp.contains(txid)); + EXPECT_EQ(mp.size(), 1u); + EXPECT_GT(mp.byte_size(), 0u); +} + +TEST(DashMempool, DuplicateRejected) +{ + Mempool mp; + auto tx = make_empty(2); + EXPECT_TRUE(mp.add_tx(tx)); + EXPECT_FALSE(mp.add_tx(tx)) + << "second add of the same txid must be rejected"; + EXPECT_EQ(mp.size(), 1u); +} + +TEST(DashMempool, RemoveTx) +{ + Mempool mp; + auto tx = make_empty(3); + uint256 txid = dash_txid(tx); + mp.add_tx(tx); + ASSERT_TRUE(mp.contains(txid)); + + mp.remove_tx(txid); + EXPECT_FALSE(mp.contains(txid)); + EXPECT_EQ(mp.size(), 0u); + EXPECT_EQ(mp.byte_size(), 0u); +} + +TEST(DashMempool, FeeUnknownWithoutUtxo) +{ + Mempool mp; + uint256 prev = mint_hash(10); + auto tx = make_spend(prev, 0, 90'000, /*salt=*/1); + + EXPECT_TRUE(mp.add_tx(tx)); // no UTXO set + auto entry = mp.get_entry(dash_txid(tx)); + ASSERT_TRUE(entry.has_value()); + EXPECT_FALSE(entry->fee_known) + << "without a UTXO view the input value is unknown"; + EXPECT_EQ(mp.total_known_fees(), 0u); +} + +TEST(DashMempool, FeeComputedFromUtxo) +{ + UTXOViewCache utxo(nullptr); + uint256 prev = mint_hash(20); + utxo.add_coin(Outpoint(prev, 0), Coin(100'000, {}, /*height=*/1, /*cb=*/false)); + + Mempool mp; + mp.set_utxo(&utxo); + + auto tx = make_spend(prev, 0, 90'000, /*salt=*/1); + EXPECT_TRUE(mp.add_tx(tx)); + + auto entry = mp.get_entry(dash_txid(tx)); + ASSERT_TRUE(entry.has_value()); + EXPECT_TRUE(entry->fee_known); + EXPECT_EQ(entry->fee, 10'000u) // 100000 in - 90000 out + << "fee must equal sum(inputs) - sum(outputs)"; + EXPECT_EQ(mp.total_known_fees(), 10'000u); +} + +TEST(DashMempool, RecomputeUnknownFeesAfterUtxoArrives) +{ + Mempool mp; + uint256 prev = mint_hash(30); + auto tx = make_spend(prev, 0, 80'000, /*salt=*/1); + EXPECT_TRUE(mp.add_tx(tx)); // fee unknown (no UTXO yet) + ASSERT_FALSE(mp.get_entry(dash_txid(tx))->fee_known); + + UTXOViewCache utxo(nullptr); + utxo.add_coin(Outpoint(prev, 0), Coin(100'000, {}, 1, false)); + + EXPECT_EQ(mp.recompute_unknown_fees(&utxo), 1) + << "the now-resolvable input must flip exactly one entry to known"; + EXPECT_TRUE(mp.get_entry(dash_txid(tx))->fee_known); + EXPECT_EQ(mp.total_known_fees(), 20'000u); // 100000 - 80000 +} + +TEST(DashMempool, LruEvictionOnSizeCap) +{ + // Empty txs serialize to a handful of bytes each; cap the pool so + // the third add forces eviction of the oldest entry. + auto t1 = make_empty(101); + auto t2 = make_empty(102); + auto probe = ::pack(t1).size(); + ASSERT_GT(probe, 0u); + + // Room for exactly two of these txs, not three. + Mempool mp(/*max_bytes=*/probe * 2 + 1); + uint256 id1 = dash_txid(t1), id2 = dash_txid(t2); + + EXPECT_TRUE(mp.add_tx(t1)); + EXPECT_TRUE(mp.add_tx(t2)); + auto t3 = make_empty(103); + uint256 id3 = dash_txid(t3); + EXPECT_TRUE(mp.add_tx(t3)); + + EXPECT_EQ(mp.size(), 2u) << "size cap must hold the pool at two entries"; + EXPECT_LE(mp.byte_size(), probe * 2 + 1); + EXPECT_FALSE(mp.contains(id1)) << "oldest (t1) must be evicted first (LRU)"; + EXPECT_TRUE(mp.contains(id2)); + EXPECT_TRUE(mp.contains(id3)); +} + +TEST(DashMempool, RemoveForBlockEvictsConfirmedAndConflicts) +{ + Mempool mp; + uint256 prev = mint_hash(40); + + // Two txs that spend the SAME outpoint — a double-spend pair. + auto tx_a = make_spend(prev, 0, 90'000, /*salt=*/1); + auto tx_b = make_spend(prev, 0, 80'000, /*salt=*/2); + EXPECT_TRUE(mp.add_tx(tx_a)); + EXPECT_TRUE(mp.add_tx(tx_b)); + EXPECT_EQ(mp.size(), 2u); + + // A block confirms tx_a. tx_b spends the same input → conflict. + BlockType block; + block.m_version = 1; + block.m_bits = 0x1d00ffff; + block.m_timestamp = 1700000000; + block.m_nonce = 1; + block.m_txs.push_back(tx_a); + + mp.remove_for_block(block); + EXPECT_FALSE(mp.contains(dash_txid(tx_a))) << "confirmed tx removed"; + EXPECT_FALSE(mp.contains(dash_txid(tx_b))) << "double-spend conflict removed"; + EXPECT_EQ(mp.size(), 0u); +} + +TEST(DashMempool, SortedSelectionHighestFeerateFirst) +{ + UTXOViewCache utxo(nullptr); + uint256 prev_hi = mint_hash(50); + uint256 prev_lo = mint_hash(51); + utxo.add_coin(Outpoint(prev_hi, 0), Coin(100'000, {}, 1, false)); + utxo.add_coin(Outpoint(prev_lo, 0), Coin(100'000, {}, 1, false)); + + Mempool mp; + mp.set_utxo(&utxo); + + // Same input value, same shape ⇒ same base_size; the larger fee is + // the higher feerate. + auto tx_hi = make_spend(prev_hi, 0, 90'000, /*salt=*/1); // fee 10000 + auto tx_lo = make_spend(prev_lo, 0, 99'000, /*salt=*/2); // fee 1000 + EXPECT_TRUE(mp.add_tx(tx_lo)); // add low-fee first to prove sorting, not insertion order + EXPECT_TRUE(mp.add_tx(tx_hi)); + + auto [selected, total_fees] = mp.get_sorted_txs_with_fees(/*max_bytes=*/1u << 20); + ASSERT_EQ(selected.size(), 2u); + EXPECT_EQ(dash_txid(selected[0].tx), dash_txid(tx_hi)) + << "highest feerate must come first"; + EXPECT_EQ(dash_txid(selected[1].tx), dash_txid(tx_lo)); + EXPECT_EQ(total_fees, 11'000u); +}