diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c7381d805..305834488 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -123,7 +123,7 @@ jobs: test_mweb_builder \ test_address_resolution test_compute_share_target test_web_honesty_regression \ test_utxo test_dgb_subsidy test_dgb_coinbase_value dgb_share_test dgb_redistribute_test dgb_block_assembly_test dgb_witness_commitment_test dgb_header_sample_build_test dgb_header_ingest_test dgb_mempool_ingest_test \ - dgb_gentx_coinbase_test dgb_connection_coinbase_test dgb_pplns_payout_split_test nmc_auxpow_merkle_test nmc_template_builder_test nmc_auxpow_wire_test nmc_reconstruct_won_block_test nmc_mempool_name_test nmc_block_broadcast_test nmc_host_dualpath_test nmc_fallback_path_conformance_test dgb_gentx_share_path_test dgb_conn_pplns_producer_test dgb_other_tx_resolver_test \ + dgb_gentx_coinbase_test dgb_connection_coinbase_test dgb_pplns_payout_split_test nmc_auxpow_merkle_test nmc_template_builder_test nmc_underfill_guard_test nmc_auxpow_wire_test nmc_reconstruct_won_block_test nmc_mempool_name_test nmc_block_broadcast_test nmc_host_dualpath_test nmc_fallback_path_conformance_test dgb_gentx_share_path_test dgb_conn_pplns_producer_test dgb_other_tx_resolver_test \ dgb_other_tx_assembler_test dgb_reconstruct_won_block_test dgb_reconstruct_closure_test dgb_gentx_unpack_test dgb_work_source_test dgb_template_builder_test dgb_embedded_coin_node_test dgb_embedded_tx_select_test dgb_template_other_txs_test dgb_coinbase_value_parity_test dgb_submit_classify_test dgb_aux_parent_coinbase_parity_test dgb_template_capture_test dgb_aux_doge_db_commitment_bind_test dgb_aux_doge_mm_commitment_test dgb_aux_doge_dc_proof_test dgb_aux_doge_bind_parsers_test dgb_compact_blocks_bip152_parity_test dgb_aux_dual_target_select_test dgb_aux_broadcast_path_election_test dgb_aux_doge_submit_test dgb_aux_doge_embed_livewire_test dgb_aux_doge_dc_layout_verifier_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 dgb_forced_won_share_dualpath_test dgb_scrypt_pow_test dgb_nonce_grinder_test dgb_regrind_block_test dgb_won_block_finalize_test dgb_share_target_genesis_test dgb_share_target_retarget_test dgb_share_bits_oracle_pin_test dgb_pool_msg_wire_test dgb_get_shares_walk_test dgb_download_stops_test dgb_think_p1_walk_bounds_test dgb_think_p1_desired_emit_test dgb_think_p6_desired_cutoff_test dgb_think_p4_head_keys_test dgb_think_p3_best_head_test dgb_g1_oracle_byte_parity_test dgb_think_p2_walk_bounds_test dgb_expected_time_to_block_test dgb_tail_score_endpoints_test dgb_pool_attempts_per_second_test dgb_pool_efficiency_test dgb_think_p5_best_share_punish_test dgb_auto_ratchet_tail_guard_test dgb_auto_ratchet_sim_test dgb_binomial_conf_interval_test dgb_desired_version_tally_test dgb_min_protocol_ratchet_test dgb_get_height_and_last_endpoints_test dgb_chain_walk_window_test dgb_redistribute_delegate_ghal_test dgb_share_weight_decay_test dgb_naughty_propagation_test dgb_hash_format_parity_test dgb_emergency_decay_saturation_test dgb_arith256_muldiv_kat_test v37_test \ @@ -280,7 +280,7 @@ jobs: test_mweb_builder \ test_address_resolution test_compute_share_target test_web_honesty_regression \ test_utxo test_dgb_subsidy test_dgb_coinbase_value dgb_share_test dgb_redistribute_test dgb_block_assembly_test dgb_witness_commitment_test dgb_header_sample_build_test dgb_header_ingest_test dgb_mempool_ingest_test \ - dgb_gentx_coinbase_test dgb_connection_coinbase_test dgb_pplns_payout_split_test nmc_auxpow_merkle_test nmc_template_builder_test nmc_auxpow_wire_test nmc_reconstruct_won_block_test nmc_mempool_name_test nmc_block_broadcast_test nmc_host_dualpath_test nmc_fallback_path_conformance_test dgb_gentx_share_path_test dgb_conn_pplns_producer_test dgb_other_tx_resolver_test \ + dgb_gentx_coinbase_test dgb_connection_coinbase_test dgb_pplns_payout_split_test nmc_auxpow_merkle_test nmc_template_builder_test nmc_underfill_guard_test nmc_auxpow_wire_test nmc_reconstruct_won_block_test nmc_mempool_name_test nmc_block_broadcast_test nmc_host_dualpath_test nmc_fallback_path_conformance_test dgb_gentx_share_path_test dgb_conn_pplns_producer_test dgb_other_tx_resolver_test \ dgb_other_tx_assembler_test dgb_reconstruct_won_block_test dgb_reconstruct_closure_test dgb_gentx_unpack_test dgb_work_source_test dgb_template_builder_test dgb_embedded_coin_node_test dgb_embedded_tx_select_test dgb_template_other_txs_test dgb_coinbase_value_parity_test dgb_submit_classify_test dgb_aux_parent_coinbase_parity_test dgb_template_capture_test dgb_aux_doge_db_commitment_bind_test dgb_aux_doge_mm_commitment_test dgb_aux_doge_dc_proof_test dgb_aux_doge_bind_parsers_test dgb_compact_blocks_bip152_parity_test dgb_aux_dual_target_select_test dgb_aux_broadcast_path_election_test dgb_aux_doge_submit_test dgb_aux_doge_embed_livewire_test dgb_aux_doge_dc_layout_verifier_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 dgb_forced_won_share_dualpath_test dgb_scrypt_pow_test dgb_nonce_grinder_test dgb_regrind_block_test dgb_won_block_finalize_test dgb_share_target_genesis_test dgb_share_target_retarget_test dgb_share_bits_oracle_pin_test dgb_pool_msg_wire_test dgb_get_shares_walk_test dgb_download_stops_test dgb_think_p1_walk_bounds_test dgb_think_p1_desired_emit_test dgb_think_p6_desired_cutoff_test dgb_think_p4_head_keys_test dgb_think_p3_best_head_test dgb_g1_oracle_byte_parity_test dgb_think_p2_walk_bounds_test dgb_expected_time_to_block_test dgb_tail_score_endpoints_test dgb_pool_attempts_per_second_test dgb_pool_efficiency_test dgb_think_p5_best_share_punish_test dgb_auto_ratchet_tail_guard_test dgb_auto_ratchet_sim_test dgb_binomial_conf_interval_test dgb_desired_version_tally_test dgb_min_protocol_ratchet_test dgb_get_height_and_last_endpoints_test dgb_chain_walk_window_test dgb_redistribute_delegate_ghal_test dgb_share_weight_decay_test dgb_naughty_propagation_test dgb_hash_format_parity_test dgb_emergency_decay_saturation_test dgb_arith256_muldiv_kat_test test_coin_broadcaster test_multiaddress_pplns test_pplns_stress \ diff --git a/src/impl/nmc/coin/template_builder.hpp b/src/impl/nmc/coin/template_builder.hpp index e927e6db7..e02a178a8 100644 --- a/src/impl/nmc/coin/template_builder.hpp +++ b/src/impl/nmc/coin/template_builder.hpp @@ -131,6 +131,40 @@ inline std::string bits_to_hex(uint32_t bits) { return std::string(buf); } +// Underfill guard (v36 cutover deploy path) +// +// Port of the LTC/DOGE template-builder guard (src/impl/ltc/coin/ +// template_builder.hpp, src/impl/doge/coin/template_builder.hpp) to the NMC +// embedded template path, in the DASH free-predicate form +// (src/impl/dash/coin/embedded_gbt.hpp) — same shape as the BTC port +// (src/impl/btc/coin/template_builder.hpp). Detects the "near-empty template +// on a non-empty mempool" regression: the tx selector returns almost no +// transactions even though the local mempool holds a substantial fee-paying +// backlog. c2pool-side template-fill safety net — NOT the byte-parity KAT +// axis; thresholds are the v36-native shared structure (standardize +// cross-coin) pinned to the legacy p2pool near-empty floor (~50 kB), +// identical to LTC/DOGE/DASH/BTC. NMC is BTC's merge-mined aux child, so a +// near-empty aux block does not waste parent PoW (severity LOW) — this port +// completes the all-coin underfill matrix. +inline constexpr uint64_t UNDERFILL_MIN_FILL_BYTES = 50'000ull; // < this = near-empty block +inline constexpr uint64_t UNDERFILL_BACKLOG_SLACK = 50'000ull; // unselected fee-paying material that should have filled it + +/// Pure trip predicate — the exact boolean the LTC/DOGE guards evaluate. +/// Factored out so the KAT can pin it without a log scraper: +/// near_empty : template packed fewer bytes than the near-empty floor +/// has_backlog : the mempool holds fee-paying material (known fees > 0) +/// well beyond what was selected (> selected + slack) +/// Genuinely empty (or fee-unknown-only) mempools never trip. +inline bool underfill_guard_trips(uint64_t selected_bytes, + uint64_t mempool_bytes, + uint64_t mempool_known_fees) +{ + const bool near_empty = selected_bytes < UNDERFILL_MIN_FILL_BYTES; + const bool has_backlog = mempool_known_fees > 0 + && mempool_bytes > selected_bytes + UNDERFILL_BACKLOG_SLACK; + return near_empty && has_backlog; +} + // TemplateBuilder /// Builds an NMC block template from a validated HeaderChain and Mempool. @@ -165,10 +199,16 @@ class TemplateBuilder { /// Build a WorkData template from the current chain tip + mempool. /// Returns std::nullopt if the chain has no tip yet (not synced to genesis). + /// underfill_tripped: optional underfill-guard observation seam. Defaults + /// to nullptr so every existing caller is byte-for-byte unchanged + /// (SAFE-ADDITIVE); the guard KAT passes a bool to pin the wiring without + /// a log scraper. The guard itself is log-only (WARNING), exactly like + /// LTC/DOGE/BTC — it never alters the template. static std::optional build_template( const HeaderChain& chain, const Mempool& pool, - bool is_testnet = false) + bool is_testnet = false, + bool* underfill_tripped = nullptr) { (void)is_testnet; // reserved for future per-network rules auto t0 = std::chrono::steady_clock::now(); @@ -224,9 +264,11 @@ class TemplateBuilder { std::vector tx_objects; std::vector tx_hashes; + uint64_t selected_bytes = 0; // wire bytes packed into this template (underfill guard) for (const auto& stx : selected_txs) { uint256 txid = compute_txid(stx.tx); auto packed = pack(TX_WITH_WITNESS(stx.tx)); + selected_bytes += packed.get_span().size(); std::string hex_data = HexStr(packed.get_span()); // wtxid = SHA256d of witness serialization (for witness merkle tree) uint256 wtxid = Hash(packed.get_span()); @@ -245,6 +287,32 @@ class TemplateBuilder { tx_hashes.push_back(txid); } + // Underfill guard + // + // Do not silently treat a near-empty template as healthy when the + // mempool held fee-paying backlog that should have filled it. We cannot + // fabricate transactions, so we surface loudly (WARNING) for + // contabo-prod-watch / the operator rather than shipping a false-empty + // block as normal. Genuinely empty mempools never trip this. Mirrors + // the LTC/DOGE/BTC TemplateBuilder guard; additive only — the GBT JSON + // below is untouched either way. + { + const uint64_t mempool_bytes = static_cast(pool.byte_size()); + const uint64_t mempool_fees = pool.total_fees(); + const bool tripped = underfill_guard_trips(selected_bytes, + mempool_bytes, + mempool_fees); + if (underfill_tripped) *underfill_tripped = tripped; + if (tripped) { + LOG_WARNING << "[EMB-NMC] TemplateBuilder UNDERFILL: selected " + << selected_txs.size() << " tx / " << selected_bytes + << "B into template while mempool holds " << pool.size() + << " tx / " << mempool_bytes << "B (" << mempool_fees + << " sat fees) — near-empty aux block on a non-empty " + << "mempool; template-fill regression, gates cutover."; + } + } + // Build GBT-compatible JSON nlohmann::json data; data["version"] = static_cast(block_version); diff --git a/src/impl/nmc/test/CMakeLists.txt b/src/impl/nmc/test/CMakeLists.txt index 0c5566aa9..62321a26a 100644 --- a/src/impl/nmc/test/CMakeLists.txt +++ b/src/impl/nmc/test/CMakeLists.txt @@ -27,6 +27,8 @@ if (BUILD_TESTING AND GTest_FOUND) # 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). + # The underfill guard KAT that used to be compiled into this target now + # lives in its own nmc_underfill_guard_test target below (btc/dash/dgb symmetry). add_executable(nmc_template_builder_test nmc_template_builder_test.cpp) target_link_libraries(nmc_template_builder_test PRIVATE GTest::gtest_main GTest::gtest @@ -35,6 +37,24 @@ if (BUILD_TESTING AND GTest_FOUND) c2pool_payout c2pool_merged_mining c2pool_hashrate c2pool_storage) target_link_libraries(nmc_template_builder_test PRIVATE nmc_coin) + # nmc_underfill_guard_test: port of the LTC/DOGE "near-empty template on a + # non-empty mempool" template-builder guard to the NMC embedded template + # path (coin/template_builder.hpp), matching the BTC/DGB/DASH ports. Predicate + # KATs at the pinned 50 kB floor/slack + build_template wiring via the + # SAFE-ADDITIVE underfill_tripped seam (log-only guard; GBT projection asserted + # unchanged). Standalone target mirroring btc/dash/dgb *_underfill_guard_test + # for symmetry; same SCC as the sibling template_builder test (core + nmc_coin + # for the out-of-line transaction ctor). MUST be listed in the build.yml + # --target allowlist below (a registered target absent from that allowlist + # reds master as a NOT_BUILT sentinel: the #724/#728 lesson). + add_executable(nmc_underfill_guard_test nmc_underfill_guard_test.cpp) + target_link_libraries(nmc_underfill_guard_test PRIVATE + GTest::gtest_main GTest::gtest + core nlohmann_json::nlohmann_json) + target_link_libraries(nmc_underfill_guard_test PRIVATE + c2pool_payout c2pool_merged_mining c2pool_hashrate c2pool_storage) + target_link_libraries(nmc_underfill_guard_test PRIVATE nmc_coin) + # P1 card 237: wire-vector KAT. Round-trips the canonical CAuxPow layout # against a REAL Namecoin mainnet block (757000). Same SCC as the sibling # tests (core + nmc_coin for the out-of-line MutableTransaction ctor). @@ -110,6 +130,7 @@ if (BUILD_TESTING AND GTest_FOUND) 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) + gtest_add_tests(nmc_underfill_guard_test "" AUTO) gtest_add_tests(nmc_auxpow_wire_test "" AUTO) gtest_add_tests(nmc_reconstruct_won_block_test "" AUTO) gtest_add_tests(nmc_mempool_name_test "" AUTO) diff --git a/src/impl/nmc/test/nmc_underfill_guard_test.cpp b/src/impl/nmc/test/nmc_underfill_guard_test.cpp new file mode 100644 index 000000000..e6603f219 --- /dev/null +++ b/src/impl/nmc/test/nmc_underfill_guard_test.cpp @@ -0,0 +1,311 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +/// NMC template-builder underfill guard — port of the LTC/DOGE guard +/// (src/impl/ltc/coin/template_builder.hpp / src/impl/doge/coin/ +/// template_builder.hpp) to the NMC embedded template path +/// (src/impl/nmc/coin/template_builder.hpp), mirroring the BTC KAT +/// (test/test_btc_underfill_guard.cpp) and the DASH KAT +/// (test/test_dash_underfill_guard.cpp). +/// +/// What the guard defends against: the tx selector returning a near-empty +/// template (< UNDERFILL_MIN_FILL_BYTES packed) while the local mempool holds +/// a substantial fee-paying backlog (> selected + UNDERFILL_BACKLOG_SLACK +/// bytes with known fees > 0) — the "false-empty block on a non-empty +/// mempool" template-fill regression. Like LTC/DOGE/BTC it is LOG-ONLY +/// (WARNING): it never mutates the template, never blocks work. NMC is BTC's +/// merge-mined aux child, so a near-empty aux block does not waste parent PoW +/// (severity LOW) — this KAT completes the all-coin underfill matrix. +/// +/// Two axes, mirroring the LTC/DOGE guard semantics exactly: +/// (1) underfill_guard_trips() predicate KATs — the exact boolean the +/// LTC/DOGE guards evaluate, pinned at the thresholds/boundaries. +/// (2) TemplateBuilder::build_template() wiring — the guard evaluates over +/// the REAL Mempool queries (byte_size / total_fees) inside the +/// template build, observed via the SAFE-ADDITIVE trailing +/// `bool* underfill_tripped` seam (defaulted nullptr; no caller +/// changed). The trip scenario is produced through a genuine mempool +/// path: a fee-known bulk backlog whose inputs the selection-time +/// stale-input guard rejects — selection goes empty while the pool +/// still reports the fee-paying backlog. The NMC template projection +/// (height / coinbasevalue / GBT shape) is asserted UNCHANGED when the +/// guard trips (additive-only). +/// +/// Chain fixture: the seeded in-memory HeaderChain the sibling +/// nmc_template_builder_test uses (genesis + one child header, tip height 1, +/// bits 0x1d00ffff → no retarget, no fallback). build_template() is called +/// directly (not through EmbeddedCoinNode::getwork()), so the is_synced() +/// gate does not apply. +/// +/// Per-coin isolation: src/impl/nmc/ only; btc tree consumed READ-ONLY. +/// Compiled INTO the already-allowlisted nmc_template_builder_test target +/// (second source — gtest_add_tests AUTO scans all target sources), so the +/// build.yml --target allowlist needs no edit; a NEW target absent from that +/// allowlist would red master as a NOT_BUILT sentinel (the #724/#728 lesson). + +#include + +#include +#include +#include +#include +#include + +#include +#include + +#include "../coin/header_chain.hpp" +#include "../coin/mempool.hpp" +#include "../coin/template_builder.hpp" +#include "../coin/transaction.hpp" + +namespace { + +using nmc::coin::BlockHeaderType; +using nmc::coin::HeaderChain; +using nmc::coin::Mempool; +using nmc::coin::MutableTransaction; +using nmc::coin::NMCChainParams; +using nmc::coin::TemplateBuilder; +using nmc::coin::TxIn; +using nmc::coin::TxOut; +using nmc::coin::block_hash; +using nmc::coin::get_block_subsidy; +using nmc::coin::underfill_guard_trips; +using nmc::coin::UNDERFILL_MIN_FILL_BYTES; +using nmc::coin::UNDERFILL_BACKLOG_SLACK; +using ::core::coin::UTXOViewCache; +using ::core::coin::Outpoint; +using ::core::coin::Coin; + +// ─── helpers (mirrored from test_btc_underfill_guard.cpp) ─────────────────── + +static uint256 raw256(uint8_t base) { + uint256 h; + std::array p{}; + for (size_t i = 0; i < 32; ++i) p[i] = static_cast(base + i); + std::memcpy(h.data(), p.data(), 32); + return h; +} + +static MutableTransaction make_spend(const uint256& prev, uint32_t idx, + int64_t out_value, uint32_t salt) { + MutableTransaction tx; + tx.version = 2; tx.locktime = salt; + TxIn in; in.prevout.hash = prev; in.prevout.index = idx; + in.sequence = 0xffffffffu; + tx.vin.push_back(in); + TxOut o; o.value = out_value; + tx.vout.push_back(o); + return tx; +} + +// A deliberately BULKY spend: one funded input, n_outputs zero-value +// empty-script outputs (~9 wire bytes each). Zero-value outputs make the +// whole input a KNOWN fee, and the output fan inflates the serialized size +// past the near-empty floor without needing thousands of separate txs. +static MutableTransaction make_bulk_spend(const uint256& prev, uint32_t idx, + size_t n_outputs, uint32_t salt) { + MutableTransaction tx; + tx.version = 2; tx.locktime = salt; + TxIn in; in.prevout.hash = prev; in.prevout.index = idx; + in.sequence = 0xffffffffu; + tx.vin.push_back(in); + tx.vout.reserve(n_outputs); + for (size_t i = 0; i < n_outputs; ++i) { + TxOut o; o.value = 0; // zero-value → entire input value is fee + tx.vout.push_back(o); + } + return tx; +} + +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; +} + +/// Seeded in-memory chain: genesis + one child header (tip height 1 → the +/// template builds for height 2). Same fixture shape as the sibling +/// nmc_template_builder_test SeededChainYieldsWorkData (HeaderChain is +/// non-copyable → unique_ptr, like the BTC KAT fixture). +static std::unique_ptr make_seeded_chain() { + NMCChainParams p = NMCChainParams::mainnet(); + p.aux_chain_id = 1; + p.auxpow_activation_height = 19200; // TEST-only pin (plain headers admit) + auto chain = std::make_unique(p); + auto now = static_cast(std::time(nullptr)); + uint256 z; z.SetNull(); + BlockHeaderType g = plain_header(z, 0x1d00ffffu, 1, now - 100); + EXPECT_TRUE(chain->add_header(g)); + BlockHeaderType c = plain_header(block_hash(g), 0x1d00ffffu, 2, now - 50); + EXPECT_TRUE(chain->add_header(c)); + EXPECT_EQ(chain->height(), 1u); + return chain; +} + +// ════════════════════════════════════════════════════════════════════════ +// (1) Predicate KATs — the exact LTC/DOGE boolean, pinned at boundaries. +// ════════════════════════════════════════════════════════════════════════ + +TEST(NmcUnderfillGuard, ThresholdsMatchTheCrossCoinPins) { + // The v36-native shared thresholds — same values LTC/DOGE/DASH/BTC pin + // (the legacy p2pool near-empty floor, ~50 kB). A drift here breaks + // cross-coin standardization and must be a conscious, reviewed change. + EXPECT_EQ(UNDERFILL_MIN_FILL_BYTES, 50'000ull); + EXPECT_EQ(UNDERFILL_BACKLOG_SLACK, 50'000ull); +} + +TEST(NmcUnderfillGuard, TripsOnNearEmptyTemplateWithFeePayingBacklog) { + // Nothing selected, 200 kB of fee-paying mempool → the regression shape. + EXPECT_TRUE(underfill_guard_trips(/*selected=*/0, + /*mempool=*/200'000, + /*known_fees=*/1)); +} + +TEST(NmcUnderfillGuard, EmptyMempoolNeverTrips) { + // A genuinely empty mempool → an empty template is legitimate. + EXPECT_FALSE(underfill_guard_trips(0, 0, 0)); +} + +TEST(NmcUnderfillGuard, FeeUnknownBacklogNeverTrips) { + // Bytes present but NO known fees (fee_known=false txs are excluded from + // selection by design — they'd poison coinbasevalue). Not a regression. + EXPECT_FALSE(underfill_guard_trips(0, 200'000, /*known_fees=*/0)); +} + +TEST(NmcUnderfillGuard, WellFilledTemplateNeverTrips) { + // At/above the near-empty floor the template is healthy regardless of + // how much backlog remains (a full block on a deep mempool is normal). + EXPECT_FALSE(underfill_guard_trips(UNDERFILL_MIN_FILL_BYTES, + 10'000'000, 5'000)); + EXPECT_TRUE(underfill_guard_trips(UNDERFILL_MIN_FILL_BYTES - 1, + 10'000'000, 5'000)); +} + +TEST(NmcUnderfillGuard, SmallDrainedMempoolNeverTrips) { + // Tiny mempool fully drained into the template: near-empty, but there is + // no backlog beyond the slack — the guard must stay quiet. + EXPECT_FALSE(underfill_guard_trips(/*selected=*/300, + /*mempool=*/300, + /*known_fees=*/100)); +} + +TEST(NmcUnderfillGuard, BacklogSlackBoundaryIsStrict) { + // has_backlog requires mempool_bytes STRICTLY > selected + slack — + // mirrors the LTC/DOGE comparison operator exactly. + EXPECT_FALSE(underfill_guard_trips(0, UNDERFILL_BACKLOG_SLACK, 1)); + EXPECT_TRUE (underfill_guard_trips(0, UNDERFILL_BACKLOG_SLACK + 1, 1)); +} + +// ════════════════════════════════════════════════════════════════════════ +// (2) build_template wiring — real HeaderChain + Mempool, real build. +// ════════════════════════════════════════════════════════════════════════ + +TEST(NmcUnderfillGuard, BuildTripsWhenSelectionGoesEmptyOnFeePayingBacklog) { + auto chain = make_seeded_chain(); + + // Seed a funded UTXO so the bulk tx enters the pool with a KNOWN fee + // (1'000'000 sat — all outputs zero-value) and lands in the feerate index. + UTXOViewCache funded(nullptr); + uint256 prev = raw256(0x66); + funded.add_coin(Outpoint(prev, 0), + Coin(1'000'000, {}, /*height=*/1, /*cb=*/false)); + Mempool mp; + // ~9 wire bytes per zero-value empty-script output → 12'000 outputs + // (~108 kB) is comfortably past floor+slack. Assert instead of assuming. + auto bulk = make_bulk_spend(prev, 0, /*n_outputs=*/12'000, /*salt=*/1); + ASSERT_TRUE(mp.add_tx(bulk, &funded)); + ASSERT_GT(mp.byte_size(), UNDERFILL_MIN_FILL_BYTES + UNDERFILL_BACKLOG_SLACK); + ASSERT_GT(mp.total_fees(), 0u); + + // Now wire an EMPTY UTXO view: get_sorted_txs_with_fees()'s stale-input + // guard rejects the tx (input not in UTXO, no parent in pool) → selection + // returns NOTHING while byte_size()/total_fees() still report the + // fee-paying backlog. This reproduces the tip-change/stale-window shape + // of the template-fill regression. + UTXOViewCache empty(nullptr); + mp.set_utxo(&empty); + ASSERT_TRUE(mp.get_sorted_txs_with_fees(4'000'000).first.empty()) + << "precondition: stale-input guard must empty the selection"; + + bool tripped = false; + auto wd = TemplateBuilder::build_template(*chain, mp, + /*is_testnet=*/false, &tripped); + ASSERT_TRUE(wd.has_value()); + EXPECT_TRUE(tripped) + << "near-empty template on a fee-paying non-empty mempool must trip"; + + // Guard is ADDITIVE (log-only): the GBT projection is intact. + EXPECT_TRUE(wd->m_data["transactions"].empty()); + EXPECT_EQ(wd->m_data["height"].get(), 2); + EXPECT_EQ(wd->m_data["coinbasevalue"].get(), + static_cast(get_block_subsidy(2u))); // no fees selected +} + +TEST(NmcUnderfillGuard, BuildDoesNotTripOnEmptyMempool) { + // Empty mempool → empty template is legitimate; guard stays quiet. + auto chain = make_seeded_chain(); + Mempool mp; + + bool tripped = true; // pre-set opposite to prove the seam writes false + auto wd = TemplateBuilder::build_template(*chain, mp, + /*is_testnet=*/false, &tripped); + ASSERT_TRUE(wd.has_value()); + EXPECT_FALSE(tripped) << "an empty mempool must never trip the guard"; + EXPECT_TRUE(wd->m_data["transactions"].empty()); +} + +TEST(NmcUnderfillGuard, BuildDoesNotTripWhenSmallPoolIsFullyDrained) { + // One small fee-known tx, selected as normal: template is near-empty but + // the pool is drained (no backlog beyond slack) → healthy, no trip. + auto chain = make_seeded_chain(); + UTXOViewCache utxo(nullptr); + uint256 prev = raw256(0x21); + utxo.add_coin(Outpoint(prev, 0), Coin(100'000, {}, 1, false)); + Mempool mp; + ASSERT_TRUE(mp.add_tx(make_spend(prev, 0, 90'000, /*salt=*/2), &utxo)); // fee = 10'000 + mp.set_utxo(&utxo); + + bool tripped = true; + auto wd = TemplateBuilder::build_template(*chain, mp, + /*is_testnet=*/false, &tripped); + ASSERT_TRUE(wd.has_value()); + EXPECT_FALSE(tripped) << "a fully drained small mempool must not trip"; + ASSERT_EQ(wd->m_data["transactions"].size(), 1u); // the tx WAS selected + EXPECT_EQ(wd->m_data["transactions"][0]["fee"].get(), 10'000); +} + +TEST(NmcUnderfillGuard, DefaultSeamLeavesExistingCallersUnchanged) { + // Omitting the trailing seam (every existing caller) builds the same + // template as passing it — SAFE-ADDITIVE. curtime is wall-clock (no + // injection seam on the NMC builder), so only time-independent fields + // compare. + auto chain = make_seeded_chain(); + UTXOViewCache utxo(nullptr); + uint256 prev = raw256(0x31); + utxo.add_coin(Outpoint(prev, 0), Coin(100'000, {}, 1, false)); + Mempool mp; + ASSERT_TRUE(mp.add_tx(make_spend(prev, 0, 90'000, /*salt=*/3), &utxo)); + mp.set_utxo(&utxo); + + auto legacy = TemplateBuilder::build_template(*chain, mp); + bool tripped = true; + auto seamed = TemplateBuilder::build_template(*chain, mp, + /*is_testnet=*/false, &tripped); + ASSERT_TRUE(legacy.has_value()); + ASSERT_TRUE(seamed.has_value()); + EXPECT_FALSE(tripped); + EXPECT_EQ(legacy->m_data["height"], seamed->m_data["height"]); + EXPECT_EQ(legacy->m_data["previousblockhash"], seamed->m_data["previousblockhash"]); + EXPECT_EQ(legacy->m_data["bits"], seamed->m_data["bits"]); + EXPECT_EQ(legacy->m_data["version"], seamed->m_data["version"]); + EXPECT_EQ(legacy->m_data["coinbasevalue"], seamed->m_data["coinbasevalue"]); + EXPECT_EQ(legacy->m_data["mintime"], seamed->m_data["mintime"]); + EXPECT_EQ(legacy->m_data["transactions"], seamed->m_data["transactions"]); +} + +} // namespace