diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index dce99e54e..c7381d805 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -110,7 +110,7 @@ jobs: cmake --build build_ci \ --target test_hardening test_share_messages test_coin_broadcaster \ test_redistribute_address test_redistribute test_auto_ratchet \ - test_stratum_extensions test_stratum_hotel_interim \ + test_stratum_extensions test_stratum_hotel_interim test_btc_underfill_guard test_dgb_underfill_guard \ core_test sharechain_test share_test btc_share_test \ test_threading test_weights \ test_header_chain test_mempool test_template_builder \ @@ -267,7 +267,7 @@ jobs: boost_sentinel \ test_hardening test_share_messages \ test_redistribute_address test_redistribute test_auto_ratchet \ - test_stratum_extensions test_stratum_hotel_interim \ + test_stratum_extensions test_stratum_hotel_interim test_btc_underfill_guard test_dgb_underfill_guard \ core_test sharechain_test share_test btc_share_test \ test_threading test_weights \ test_header_chain test_mempool test_template_builder \ @@ -394,7 +394,7 @@ jobs: bch_embedded_block_broadcast_test bch_coin_node_seam_test \ bch_asert_kat_test \ bch_muldiv_kat_test \ - bch_g1_oracle_byte_parity_test bch_g1_share_serialization_parity_test bch_g0_canonical_pin_test bch_g2_ratchet_gate_kat_test \ + bch_g1_oracle_byte_parity_test bch_g1_share_serialization_parity_test bch_g0_canonical_pin_test bch_g2_ratchet_gate_kat_test test_bch_underfill_guard \ -j8 - name: Run BCH tests @@ -496,7 +496,7 @@ jobs: bch_embedded_block_broadcast_test bch_coin_node_seam_test \ bch_asert_kat_test \ bch_muldiv_kat_test \ - bch_g1_oracle_byte_parity_test bch_g1_share_serialization_parity_test bch_g0_canonical_pin_test bch_g2_ratchet_gate_kat_test \ + bch_g1_oracle_byte_parity_test bch_g1_share_serialization_parity_test bch_g0_canonical_pin_test bch_g2_ratchet_gate_kat_test test_bch_underfill_guard \ -j8 - name: Run BCH tests under sanitizers diff --git a/src/impl/bch/coin/template_builder.hpp b/src/impl/bch/coin/template_builder.hpp index 6eb2b82ab..58e69530c 100644 --- a/src/impl/bch/coin/template_builder.hpp +++ b/src/impl/bch/coin/template_builder.hpp @@ -115,6 +115,38 @@ 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 BCH +// embedded template path, in the DASH free-predicate form +// (src/impl/dash/coin/embedded_gbt.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. BCH serializes txs in a single canonical form +// (no segwit), so "bytes" here are the same serialized-tx bytes the mempool +// byte cap and the ABLA byte budget count. +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 a BCH block template (WorkData) from a validated HeaderChain and @@ -126,11 +158,17 @@ class TemplateBuilder { // sourced per-network from abla.hpp (see build_template / file head). static constexpr uint32_t COINBASE_RESERVE = 1'000u; // bytes for coinbase + /// 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 — it never alters the template. static std::optional build_template( const HeaderChain& chain, const Mempool& pool, bool is_testnet = false, - const abla::State* tip_state = nullptr) + const abla::State* tip_state = nullptr, + bool* underfill_tripped = nullptr) { auto t0 = std::chrono::steady_clock::now(); @@ -202,9 +240,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(stx.tx); // single canonical form + selected_bytes += packed.get_span().size(); std::string hex_data = HexStr(packed.get_span()); nlohmann::json entry; @@ -223,6 +263,31 @@ 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 TemplateBuilder guard; additive only — CTOR ordering + // above and the GBT JSON below are 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-BCH] 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 block on a non-empty " + << "mempool; template-fill regression, gates cutover."; + } + } + // ── GBT-compatible JSON ──────────────────────────────────────────── nlohmann::json data; data["version"] = static_cast(block_version); diff --git a/src/impl/btc/coin/template_builder.hpp b/src/impl/btc/coin/template_builder.hpp index e0cc7d76c..73d91f830 100644 --- a/src/impl/btc/coin/template_builder.hpp +++ b/src/impl/btc/coin/template_builder.hpp @@ -184,6 +184,36 @@ 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 BTC +// embedded template path, in the DASH free-predicate form +// (src/impl/dash/coin/embedded_gbt.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. +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 LTC block template from a validated HeaderChain and Mempool. @@ -218,10 +248,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 — 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(); @@ -278,9 +314,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()); @@ -302,6 +340,31 @@ 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 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-BTC] 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 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/dgb/coin/embedded_tx_select.cpp b/src/impl/dgb/coin/embedded_tx_select.cpp index 7fa644ff3..2c043fbbd 100644 --- a/src/impl/dgb/coin/embedded_tx_select.cpp +++ b/src/impl/dgb/coin/embedded_tx_select.cpp @@ -19,23 +19,27 @@ #include // pack #include // Hash (sha256d) +#include // LOG_WARNING (underfill guard) #include // HexStr +#include #include #include namespace dgb::coin { -EmbeddedTxSource make_mempool_tx_source(Mempool& pool, uint32_t max_weight) +EmbeddedTxSource make_mempool_tx_source(Mempool& pool, uint32_t max_weight, + bool* underfill_tripped) { - return [&pool, max_weight]() -> EmbeddedTxSelection { + return [&pool, max_weight, underfill_tripped]() -> EmbeddedTxSelection { auto [selected, total_fees] = pool.get_sorted_txs_with_fees(max_weight); EmbeddedTxSelection out; out.total_fees = total_fees; nlohmann::json tx_array = nlohmann::json::array(); + uint64_t selected_bytes = 0; // wire bytes packed into this template (underfill guard) for (const auto& stx : selected) { // data = with-witness serialization hex (what a daemon submits) @@ -43,6 +47,7 @@ EmbeddedTxSource make_mempool_tx_source(Mempool& pool, uint32_t max_weight) // hash = wtxid (with-witness sha256d) for the witness merkle tree const uint256 txid = compute_txid(stx.tx); auto packed = pack(TX_WITH_WITNESS(stx.tx)); + selected_bytes += packed.get_span().size(); const std::string hex_data = HexStr(packed.get_span()); const uint256 wtxid = Hash(packed.get_span()); @@ -57,6 +62,34 @@ EmbeddedTxSource make_mempool_tx_source(Mempool& pool, uint32_t max_weight) tx_array.push_back(std::move(entry)); } + // ── Underfill guard ──────────────────────────────────────────────── + // Do not silently treat a near-empty selection as healthy when the + // mempool held fee-paying backlog that should have filled it. This is + // the ONE mempool-visible point feeding BOTH build_work_template + // callers (stratum DGBWorkSource + embedded CoinNodeInterface), so the + // guard evaluates here — predicate + thresholds in template_builder.hpp + // (underfill_guard_trips), mirroring LTC/DOGE semantics exactly. We + // cannot fabricate transactions, so surface loudly (WARNING) for the + // operator rather than shipping a false-empty block as normal. + // Genuinely empty mempools never trip. Log-only; the selection above + // 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-DGB] make_mempool_tx_source UNDERFILL: selected " + << selected.size() << " tx / " << selected_bytes + << "B into template while mempool holds " << pool.size() + << " tx / " << mempool_bytes << "B (" << mempool_fees + << " sat fees) — near-empty block on a non-empty " + << "mempool; template-fill regression, gates cutover."; + } + } + out.transactions = std::move(tx_array); return out; }; diff --git a/src/impl/dgb/coin/embedded_tx_select.hpp b/src/impl/dgb/coin/embedded_tx_select.hpp index 92608248f..f078120f4 100644 --- a/src/impl/dgb/coin/embedded_tx_select.hpp +++ b/src/impl/dgb/coin/embedded_tx_select.hpp @@ -36,6 +36,16 @@ namespace dgb::coin // // `pool` is captured by reference -- it MUST outlive the returned source (the // embedded node and its mempool share a lifetime). -EmbeddedTxSource make_mempool_tx_source(Mempool& pool, uint32_t max_weight); +// +// underfill_tripped: optional underfill-guard observation seam (see +// template_builder.hpp underfill_guard_trips). Each invocation of the returned +// source evaluates the LTC/DOGE "near-empty template on a non-empty mempool" +// guard over the REAL pool queries (byte_size / total_fees) and, when the +// pointer is non-null, writes the trip boolean through it so the KAT can pin +// the wiring without a log scraper. Defaults to nullptr so every existing +// caller is byte-for-byte unchanged (SAFE-ADDITIVE); the guard itself is +// log-only (WARNING) and never alters the selection. +EmbeddedTxSource make_mempool_tx_source(Mempool& pool, uint32_t max_weight, + bool* underfill_tripped = nullptr); } // namespace dgb::coin \ No newline at end of file diff --git a/src/impl/dgb/coin/template_builder.hpp b/src/impl/dgb/coin/template_builder.hpp index e4f993972..3f6715d48 100644 --- a/src/impl/dgb/coin/template_builder.hpp +++ b/src/impl/dgb/coin/template_builder.hpp @@ -34,6 +34,44 @@ namespace dgb namespace coin { +// ── 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 DGB +// embedded template path, in the DASH free-predicate form +// (src/impl/dash/coin/embedded_gbt.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. +// +// DGB placement note: build_work_template() below is a pure SHAPING function +// with no mempool access (transactions[] is a caller-supplied pass-through), +// so the guard cannot evaluate here. It evaluates in the ONE place with +// mempool visibility that feeds BOTH build_work_template callers (the stratum +// DGBWorkSource and the embedded CoinNodeInterface path): +// make_mempool_tx_source() in embedded_tx_select.cpp. Only the predicate + +// thresholds live in this header (guard-weight TU safe: only). +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; +} + // --- CoinNodeInterface ------------------------------------------------------ // Abstract interface for obtaining work and submitting blocks; lets the // concrete CoinNode swap between RPC (legacy), embedded, or hybrid sources diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index e68779c24..9c1954d57 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -521,6 +521,64 @@ if (BUILD_TESTING AND GTest_FOUND) target_link_libraries(test_dash_underfill_guard PRIVATE c2pool_payout c2pool_hashrate c2pool_merged_mining) # OBJECT-lib SCC direct-naming (#22/#39) gtest_add_tests(test_dash_underfill_guard "" AUTO) + # BTC underfill guard: port of the LTC/DOGE "near-empty template on a + # non-empty mempool" template-builder guard to the BTC embedded template + # path (src/impl/btc/coin/template_builder.hpp). 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). Chain fixture = in-memory fast-start checkpoint (the BCH + # embedded_getwork_test shape). Link set mirrors test_template_builder + # (the LTC twin) with the btc OBJECT libs. MUST also be in the build.yml + # --target allowlist or CTest reports a NOT_BUILT sentinel. + add_executable(test_btc_underfill_guard test_btc_underfill_guard.cpp) + target_link_libraries(test_btc_underfill_guard PRIVATE + GTest::gtest_main GTest::gtest + core btc + nlohmann_json::nlohmann_json + ${Boost_LIBRARIES} + ) + target_link_libraries(test_btc_underfill_guard PRIVATE c2pool_payout c2pool_merged_mining c2pool_hashrate c2pool_storage btc_coin btclibs pool sharechain ${SECP256K1_LIBRARIES}) # OBJECT-lib SCC direct-naming (#22/#39), btc twin + gtest_add_tests(test_btc_underfill_guard "" AUTO) + + # DGB underfill guard: same LTC/DOGE guard ported to the DGB embedded + # template path. DGB's build_work_template() is a pure shaper with no + # mempool access, so the guard evaluates in make_mempool_tx_source() + # (coin/embedded_tx_select.cpp) — the one mempool-visible point feeding + # BOTH template callers (stratum work source + EmbeddedCoinNode); + # predicate/thresholds pinned in coin/template_builder.hpp. Compiles the + # tx serialization codec, so it links the same proven SCC set as + # dgb_embedded_tx_select_test. MUST also be in the build.yml --target + # allowlist or CTest reports a NOT_BUILT sentinel. + add_executable(test_dgb_underfill_guard test_dgb_underfill_guard.cpp) + target_link_libraries(test_dgb_underfill_guard PRIVATE + GTest::gtest_main GTest::gtest + core dgb dgb_stratum + c2pool_payout c2pool_merged_mining c2pool_hashrate c2pool_storage + dgb_coin pool sharechain + nlohmann_json::nlohmann_json + ${Boost_LIBRARIES} + ) + gtest_add_tests(test_dgb_underfill_guard "" AUTO) + + # BCH underfill guard: same LTC/DOGE guard ported to the BCH embedded + # template path (src/impl/bch/coin/template_builder.hpp; ABLA floor + # sizelimit + CTOR body asserted unchanged when the guard trips). Gated on + # COIN_BCH exactly like the bch_coin library it links (src/impl/bch is a + # -DCOIN_BCH=ON leg; the target cannot exist outside it). MUST also be in + # the build.yml BCH-leg --target allowlist or CTest reports a NOT_BUILT + # sentinel. + if(COIN_BCH) + add_executable(test_bch_underfill_guard test_bch_underfill_guard.cpp) + target_link_libraries(test_bch_underfill_guard PRIVATE + GTest::gtest_main GTest::gtest + core bch_coin + nlohmann_json::nlohmann_json + ${Boost_LIBRARIES} + ) + target_link_libraries(test_bch_underfill_guard PRIVATE c2pool_payout c2pool_merged_mining c2pool_hashrate c2pool_storage btclibs pool sharechain) # OBJECT-lib SCC direct-naming (#22/#39) + gtest_add_tests(test_bch_underfill_guard "" AUTO) + endif() + # DASH embedded-vs-dashd work-source selector (S8 embedded_gbt live-wire # capstone): select_dash_work() prefers the locally-assembled embedded # template (build_embedded_workdata) and falls back to dashd diff --git a/test/test_bch_underfill_guard.cpp b/test/test_bch_underfill_guard.cpp new file mode 100644 index 000000000..c942e72d4 --- /dev/null +++ b/test/test_bch_underfill_guard.cpp @@ -0,0 +1,290 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +/// BCH 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 BCH embedded template path +/// (src/impl/bch/coin/template_builder.hpp), mirroring 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 it is LOG-ONLY +/// (WARNING): it never mutates the template, never blocks work. +/// +/// 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. BCH specifics (ABLA floor +/// sizelimit, CTOR body) are asserted UNCHANGED when the guard trips +/// (additive-only). +/// +/// Chain fixture: an in-memory HeaderChain seeded from a synthetic fast-start +/// checkpoint at H — the exact shape src/impl/bch/test/embedded_getwork_test.cpp +/// uses. H is far below the mainnet ASERT anchor (661'647), so build_template +/// takes its documented bits-fallback branch (synthetic seed bits=0 → +/// pow_limit); no ASERT math, no PoW to mine. build_template() is called +/// directly (not through EmbeddedCoinNode::getwork()), so the is_synced() +/// gate does not apply. + +#include + +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include + +using bch::coin::BCHChainParams; +using bch::coin::HeaderChain; +using bch::coin::Mempool; +using bch::coin::MutableTransaction; +using bch::coin::TemplateBuilder; +using bch::coin::TxIn; +using bch::coin::TxOut; +using bch::coin::get_block_subsidy; +using bch::coin::underfill_guard_trips; +using bch::coin::UNDERFILL_MIN_FILL_BYTES; +using bch::coin::UNDERFILL_BACKLOG_SLACK; +using ::core::coin::UTXOViewCache; +using ::core::coin::Outpoint; +using ::core::coin::Coin; + +// Fast-start checkpoint height, far below the mainnet ASERT anchor (661'647) +// → build_template stays on the bits-fallback branch (no ASERT walk). +static constexpr uint32_t H = 100'000; + +// ─── helpers (mirrored from test_dash_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. +// (BCH: single canonical serialization — no witness form.) +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; +} + +/// In-memory chain seeded from a synthetic fast-start checkpoint at H +/// (the embedded_getwork_test fixture shape; HeaderChain is non-copyable). +static std::unique_ptr make_checkpoint_chain() { + BCHChainParams p = BCHChainParams::mainnet(); + p.fast_start_checkpoint = BCHChainParams::Checkpoint{H, raw256(0xC0)}; + auto chain = std::make_unique(p); + EXPECT_TRUE(chain->init()); + return chain; +} + +// ════════════════════════════════════════════════════════════════════════ +// (1) Predicate KATs — the exact LTC/DOGE boolean, pinned at boundaries. +// ════════════════════════════════════════════════════════════════════════ + +TEST(BchUnderfillGuard, ThresholdsMatchTheCrossCoinPins) { + // The v36-native shared thresholds — same values LTC/DOGE/DASH 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(BchUnderfillGuard, 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(BchUnderfillGuard, EmptyMempoolNeverTrips) { + // A genuinely empty mempool → an empty template is legitimate. + EXPECT_FALSE(underfill_guard_trips(0, 0, 0)); +} + +TEST(BchUnderfillGuard, 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(BchUnderfillGuard, 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(BchUnderfillGuard, 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(BchUnderfillGuard, 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(BchUnderfillGuard, BuildTripsWhenSelectionGoesEmptyOnFeePayingBacklog) { + auto chain = make_checkpoint_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(8'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, + /*tip_state=*/nullptr, &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 BCH-specific projection is intact. + EXPECT_TRUE(wd->m_data["transactions"].empty()); + EXPECT_EQ(wd->m_data["height"].get(), static_cast(H + 1)); + EXPECT_EQ(wd->m_data["coinbasevalue"].get(), + static_cast(get_block_subsidy(H + 1))); // no fees selected + // ABLA floor budget untouched (no tracker wired → 32 MB activation floor). + EXPECT_EQ(wd->m_data["sizelimit"].get(), + static_cast( + bch::coin::abla::floor_block_size_limit(/*is_testnet=*/false))); +} + +TEST(BchUnderfillGuard, BuildDoesNotTripOnEmptyMempool) { + // Empty mempool → empty template is legitimate; guard stays quiet. + auto chain = make_checkpoint_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, + /*tip_state=*/nullptr, &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(BchUnderfillGuard, 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_checkpoint_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 + + bool tripped = true; + auto wd = TemplateBuilder::build_template(*chain, mp, /*is_testnet=*/false, + /*tip_state=*/nullptr, &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); + // BCH: no wtxid — "hash" must equal "txid" (guard did not disturb the + // CTOR/serialization path). + EXPECT_EQ(wd->m_data["transactions"][0]["hash"], + wd->m_data["transactions"][0]["txid"]); +} + +TEST(BchUnderfillGuard, 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 BCH builder), so only time-independent fields + // compare. + auto chain = make_checkpoint_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)); + + auto legacy = TemplateBuilder::build_template(*chain, mp); + bool tripped = true; + auto seamed = TemplateBuilder::build_template(*chain, mp, /*is_testnet=*/false, + /*tip_state=*/nullptr, &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["sizelimit"], seamed->m_data["sizelimit"]); + EXPECT_EQ(legacy->m_data["mintime"], seamed->m_data["mintime"]); + EXPECT_EQ(legacy->m_data["transactions"], seamed->m_data["transactions"]); +} diff --git a/test/test_btc_underfill_guard.cpp b/test/test_btc_underfill_guard.cpp new file mode 100644 index 000000000..ba98276a7 --- /dev/null +++ b/test/test_btc_underfill_guard.cpp @@ -0,0 +1,278 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +/// BTC 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 BTC embedded template path +/// (src/impl/btc/coin/template_builder.hpp), mirroring 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 it is LOG-ONLY +/// (WARNING): it never mutates the template, never blocks work. +/// +/// 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 BTC template projection +/// (height / coinbasevalue / GBT shape) is asserted UNCHANGED when the +/// guard trips (additive-only). +/// +/// Chain fixture: an in-memory HeaderChain seeded from a synthetic fast-start +/// checkpoint at H (the same shape the BCH embedded_getwork_test uses). The +/// synthetic tip carries bits=0, so build_template takes its documented +/// pow_limit fallback; H+1 is not a 2016-retarget boundary, so no ancestor +/// walk is required. build_template() is called directly (not through +/// EmbeddedCoinNode::getwork()), so the is_synced() gate does not apply. + +#include + +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include + +using btc::coin::BTCChainParams; +using btc::coin::HeaderChain; +using btc::coin::Mempool; +using btc::coin::MutableTransaction; +using btc::coin::TemplateBuilder; +using btc::coin::TxIn; +using btc::coin::TxOut; +using btc::coin::get_block_subsidy; +using btc::coin::underfill_guard_trips; +using btc::coin::UNDERFILL_MIN_FILL_BYTES; +using btc::coin::UNDERFILL_BACKLOG_SLACK; +using ::core::coin::UTXOViewCache; +using ::core::coin::Outpoint; +using ::core::coin::Coin; + +// Fast-start checkpoint height. (H + 1) % 2016 != 0 → no retarget window +// walk; synthetic seed bits=0 → documented pow_limit fallback branch. +static constexpr uint32_t H = 100'000; + +// ─── helpers (mirrored from test_dash_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; +} + +/// In-memory chain seeded from a synthetic fast-start checkpoint at H +/// (HeaderChain is non-copyable → unique_ptr, like test_template_builder.cpp). +static std::unique_ptr make_checkpoint_chain() { + BTCChainParams p = BTCChainParams::mainnet(); + p.fast_start_checkpoint = BTCChainParams::Checkpoint{H, raw256(0xC0)}; + auto chain = std::make_unique(p); + EXPECT_TRUE(chain->init()); + return chain; +} + +// ════════════════════════════════════════════════════════════════════════ +// (1) Predicate KATs — the exact LTC/DOGE boolean, pinned at boundaries. +// ════════════════════════════════════════════════════════════════════════ + +TEST(BtcUnderfillGuard, ThresholdsMatchTheCrossCoinPins) { + // The v36-native shared thresholds — same values LTC/DOGE/DASH 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(BtcUnderfillGuard, 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(BtcUnderfillGuard, EmptyMempoolNeverTrips) { + // A genuinely empty mempool → an empty template is legitimate. + EXPECT_FALSE(underfill_guard_trips(0, 0, 0)); +} + +TEST(BtcUnderfillGuard, 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(BtcUnderfillGuard, 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(BtcUnderfillGuard, 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(BtcUnderfillGuard, 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(BtcUnderfillGuard, BuildTripsWhenSelectionGoesEmptyOnFeePayingBacklog) { + auto chain = make_checkpoint_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(), static_cast(H + 1)); + EXPECT_EQ(wd->m_data["coinbasevalue"].get(), + static_cast(get_block_subsidy(H + 1))); // no fees selected +} + +TEST(BtcUnderfillGuard, BuildDoesNotTripOnEmptyMempool) { + // Empty mempool → empty template is legitimate; guard stays quiet. + auto chain = make_checkpoint_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(BtcUnderfillGuard, 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_checkpoint_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 + + 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(BtcUnderfillGuard, 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 BTC builder), so only time-independent fields + // compare. + auto chain = make_checkpoint_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)); + + 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"]); +} diff --git a/test/test_dgb_underfill_guard.cpp b/test/test_dgb_underfill_guard.cpp new file mode 100644 index 000000000..5a227f8ce --- /dev/null +++ b/test/test_dgb_underfill_guard.cpp @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +/// DGB template-path underfill guard — port of the LTC/DOGE guard +/// (src/impl/ltc/coin/template_builder.hpp / src/impl/doge/coin/ +/// template_builder.hpp) to the DGB embedded template path, mirroring the +/// DASH KAT (test/test_dash_underfill_guard.cpp). +/// +/// DGB placement: dgb::coin::build_work_template() is a pure SHAPING function +/// with no mempool access (transactions[] is a caller-supplied pass-through), +/// so the guard evaluates in make_mempool_tx_source() +/// (coin/embedded_tx_select.cpp) — the ONE mempool-visible point that feeds +/// BOTH build_work_template callers (stratum DGBWorkSource + +/// EmbeddedCoinNode). Predicate + thresholds live in coin/template_builder.hpp +/// (underfill_guard_trips), identical to LTC/DOGE/DASH. +/// +/// What the guard defends against: the tx selector returning a near-empty +/// selection (< 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 it +/// is LOG-ONLY (WARNING): it never mutates the selection, never blocks work. +/// +/// 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) make_mempool_tx_source() wiring — the guard evaluates over the REAL +/// Mempool queries (byte_size / total_fees) inside the production +/// selection shaper, 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 GBT selection shape +/// ({data,txid,hash,fee} + total_fees) is asserted UNCHANGED +/// (additive-only). + +#include + +#include +#include +#include +#include + +#include +#include + +#include +#include +#include + +using dgb::coin::EmbeddedTxSelection; +using dgb::coin::Mempool; +using dgb::coin::MutableTransaction; +using dgb::coin::TxIn; +using dgb::coin::TxOut; +using dgb::coin::make_mempool_tx_source; +using dgb::coin::underfill_guard_trips; +using dgb::coin::UNDERFILL_MIN_FILL_BYTES; +using dgb::coin::UNDERFILL_BACKLOG_SLACK; +using ::core::coin::UTXOViewCache; +using ::core::coin::Outpoint; +using ::core::coin::Coin; + +// Selection budget — same figure the production callers pass +// (dgb::PoolConfig::BLOCK_MAX_WEIGHT; see template_other_txs_test.cpp). +static constexpr uint32_t MAX_WEIGHT = 4'000'000u; + +// ─── helpers (mirrored from test_dash_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; +} + +// ════════════════════════════════════════════════════════════════════════ +// (1) Predicate KATs — the exact LTC/DOGE boolean, pinned at boundaries. +// ════════════════════════════════════════════════════════════════════════ + +TEST(DgbUnderfillGuard, ThresholdsMatchTheCrossCoinPins) { + // The v36-native shared thresholds — same values LTC/DOGE/DASH 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(DgbUnderfillGuard, 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(DgbUnderfillGuard, EmptyMempoolNeverTrips) { + // A genuinely empty mempool → an empty template is legitimate. + EXPECT_FALSE(underfill_guard_trips(0, 0, 0)); +} + +TEST(DgbUnderfillGuard, 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(DgbUnderfillGuard, 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(DgbUnderfillGuard, 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(DgbUnderfillGuard, 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) make_mempool_tx_source wiring — real Mempool, real production shaper. +// ════════════════════════════════════════════════════════════════════════ + +TEST(DgbUnderfillGuard, SourceTripsWhenSelectionGoesEmptyOnFeePayingBacklog) { + // 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(MAX_WEIGHT).first.empty()) + << "precondition: stale-input guard must empty the selection"; + + bool tripped = false; + EmbeddedTxSelection sel = make_mempool_tx_source(mp, MAX_WEIGHT, &tripped)(); + + EXPECT_TRUE(tripped) + << "near-empty selection on a fee-paying non-empty mempool must trip"; + + // Guard is ADDITIVE (log-only): the selection shape is intact. + EXPECT_TRUE(sel.transactions.empty()); + EXPECT_EQ(sel.total_fees, 0u); // nothing selected → nothing folded +} + +TEST(DgbUnderfillGuard, SourceDoesNotTripOnEmptyMempool) { + // Empty mempool → empty selection is legitimate; guard stays quiet. + Mempool mp; + + bool tripped = true; // pre-set opposite to prove the seam writes false + EmbeddedTxSelection sel = make_mempool_tx_source(mp, MAX_WEIGHT, &tripped)(); + + EXPECT_FALSE(tripped) << "an empty mempool must never trip the guard"; + EXPECT_TRUE(sel.transactions.empty()); + EXPECT_EQ(sel.total_fees, 0u); +} + +TEST(DgbUnderfillGuard, SourceDoesNotTripWhenSmallPoolIsFullyDrained) { + // One small fee-known tx, selected as normal: selection is near-empty but + // the pool is drained (no backlog beyond slack) → healthy, no trip. + 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 + + bool tripped = true; + EmbeddedTxSelection sel = make_mempool_tx_source(mp, MAX_WEIGHT, &tripped)(); + + EXPECT_FALSE(tripped) << "a fully drained small mempool must not trip"; + ASSERT_EQ(sel.transactions.size(), 1u); // the tx WAS selected + EXPECT_EQ(sel.total_fees, 10'000u); + // The GBT entry shape build_work_template passes through verbatim. + EXPECT_TRUE(sel.transactions[0].contains("data")); + EXPECT_TRUE(sel.transactions[0].contains("txid")); + EXPECT_TRUE(sel.transactions[0].contains("hash")); + EXPECT_EQ(sel.transactions[0]["fee"].get(), 10'000); +} + +TEST(DgbUnderfillGuard, DefaultSeamLeavesExistingCallersUnchanged) { + // Omitting the trailing seam (every existing caller: main_dgb.cpp + + // stratum work_source.cpp) yields the exact same selection as passing + // it — SAFE-ADDITIVE, field-for-field. + 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)); + + EmbeddedTxSelection legacy = make_mempool_tx_source(mp, MAX_WEIGHT)(); + bool tripped = true; + EmbeddedTxSelection seamed = make_mempool_tx_source(mp, MAX_WEIGHT, &tripped)(); + + EXPECT_FALSE(tripped); + EXPECT_EQ(legacy.total_fees, seamed.total_fees); + EXPECT_EQ(legacy.transactions, seamed.transactions); +}