diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index aff400c59..dce99e54e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -114,7 +114,7 @@ jobs: core_test sharechain_test share_test btc_share_test \ 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_mempool test_dash_simplifiedmns test_dash_quorum test_dash_quorum_root test_dash_mn_state test_dash_embedded_gbt test_dash_smldiff test_dash_p2p_messages test_dash_p2p_connection test_dash_p2p_node test_dash_node_interface test_dash_config test_dash_broadcaster test_dash_broadcaster_full test_dash_share_hash_link test_dash_block_relay test_dash_rpc_request test_dash_rpc_conf test_dash_block_producer test_dash_embedded_relay_e2e test_dash_block_relay_plan test_dash_version_activation_latch test_dash_block_relay_binding test_dash_block_relay_dual_arm test_dash_coinbase_parity test_dash_coinbase_muldiv test_dash_donation_combined test_dash_g3_assembled test_dash_work_target test_dash_work_job_targets test_dash_stratum_binding test_dash_job_notify_roundtrip test_dash_cb_payee test_dash_stratum_notify_roundtrip test_dash_poolnode_messages test_dash_peer test_dash_share_tracker test_dash_node test_dash_share_messages test_dash_stratum_extranonce_split test_dash_stratum_submit_reassembly test_dash_difficulty_parity test_dash_auto_ratchet test_dash_min_protocol_gate test_dash_work_source test_dash_node_coin_state test_dash_coin_state_maintainer test_dash_node_embedded_wire test_dash_node_reception_wire test_dash_get_work test_dash_stratum_work_source \ + 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_mempool test_dash_simplifiedmns test_dash_quorum test_dash_quorum_root test_dash_mn_state test_dash_embedded_gbt test_dash_underfill_guard test_dash_smldiff test_dash_p2p_messages test_dash_p2p_connection test_dash_p2p_node test_dash_node_interface test_dash_config test_dash_broadcaster test_dash_broadcaster_full test_dash_share_hash_link test_dash_block_relay test_dash_rpc_request test_dash_rpc_conf test_dash_block_producer test_dash_embedded_relay_e2e test_dash_block_relay_plan test_dash_version_activation_latch test_dash_block_relay_binding test_dash_block_relay_dual_arm test_dash_coinbase_parity test_dash_coinbase_muldiv test_dash_donation_combined test_dash_g3_assembled test_dash_work_target test_dash_work_job_targets test_dash_stratum_binding test_dash_job_notify_roundtrip test_dash_cb_payee test_dash_stratum_notify_roundtrip test_dash_poolnode_messages test_dash_peer test_dash_share_tracker test_dash_node test_dash_share_messages test_dash_stratum_extranonce_split test_dash_stratum_submit_reassembly test_dash_difficulty_parity test_dash_auto_ratchet test_dash_min_protocol_gate test_dash_work_source test_dash_node_coin_state test_dash_coin_state_maintainer test_dash_node_embedded_wire test_dash_node_reception_wire test_dash_get_work test_dash_stratum_work_source \ test_multiaddress_pplns test_pplns_stress \ test_hash_link test_decay_pplns \ test_pplns_consensus \ @@ -272,7 +272,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_mempool test_dash_simplifiedmns test_dash_quorum test_dash_quorum_root test_dash_mn_state test_dash_embedded_gbt test_dash_smldiff test_dash_p2p_messages test_dash_p2p_connection test_dash_p2p_node test_dash_node_interface test_dash_config test_dash_broadcaster test_dash_broadcaster_full test_dash_share_hash_link test_dash_block_relay test_dash_rpc_request test_dash_rpc_conf test_dash_block_producer test_dash_embedded_relay_e2e test_dash_block_relay_plan test_dash_version_activation_latch test_dash_block_relay_binding test_dash_block_relay_dual_arm test_dash_coinbase_parity test_dash_coinbase_muldiv test_dash_donation_combined test_dash_g3_assembled test_dash_work_target test_dash_work_job_targets test_dash_stratum_binding test_dash_job_notify_roundtrip test_dash_cb_payee test_dash_stratum_notify_roundtrip test_dash_poolnode_messages test_dash_peer test_dash_share_tracker test_dash_node test_dash_share_messages test_dash_stratum_extranonce_split test_dash_stratum_submit_reassembly test_dash_difficulty_parity test_dash_auto_ratchet test_dash_min_protocol_gate test_dash_work_source test_dash_node_coin_state test_dash_coin_state_maintainer test_dash_node_embedded_wire test_dash_node_reception_wire test_dash_get_work test_dash_stratum_work_source \ + test_dash_header_chain test_dash_block_replay test_dash_conformance test_dash_subsidy test_dash_mempool test_dash_simplifiedmns test_dash_quorum test_dash_quorum_root test_dash_mn_state test_dash_embedded_gbt test_dash_underfill_guard test_dash_smldiff test_dash_p2p_messages test_dash_p2p_connection test_dash_p2p_node test_dash_node_interface test_dash_config test_dash_broadcaster test_dash_broadcaster_full test_dash_share_hash_link test_dash_block_relay test_dash_rpc_request test_dash_rpc_conf test_dash_block_producer test_dash_embedded_relay_e2e test_dash_block_relay_plan test_dash_version_activation_latch test_dash_block_relay_binding test_dash_block_relay_dual_arm test_dash_coinbase_parity test_dash_coinbase_muldiv test_dash_donation_combined test_dash_g3_assembled test_dash_work_target test_dash_work_job_targets test_dash_stratum_binding test_dash_job_notify_roundtrip test_dash_cb_payee test_dash_stratum_notify_roundtrip test_dash_poolnode_messages test_dash_peer test_dash_share_tracker test_dash_node test_dash_share_messages test_dash_stratum_extranonce_split test_dash_stratum_submit_reassembly test_dash_difficulty_parity test_dash_auto_ratchet test_dash_min_protocol_gate test_dash_work_source test_dash_node_coin_state test_dash_coin_state_maintainer test_dash_node_embedded_wire test_dash_node_reception_wire test_dash_get_work test_dash_stratum_work_source \ 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/embedded_gbt.hpp b/src/impl/dash/coin/embedded_gbt.hpp index f6502c4c3..b60d59b06 100644 --- a/src/impl/dash/coin/embedded_gbt.hpp +++ b/src/impl/dash/coin/embedded_gbt.hpp @@ -52,6 +52,36 @@ namespace dash { 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 DASH +// embedded GBT path. 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 (bucket-2, standardize cross-coin) pinned to +// the legacy p2pool near-empty floor (~50 kB), identical to LTC/DOGE. +// DASH counts base_size bytes (no segwit), so "bytes" here are the same +// serialized-tx bytes the mempool byte cap and dashcore's 2 MB limit 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; +} + /// Build the GBT-equivalent fields we currently know how to compute /// for a block at height (prev_height+1). Returns a partially-filled /// DashWorkData; missing fields documented above. @@ -73,7 +103,13 @@ inline DashWorkData build_embedded_workdata( // unchanged (SAFE-ADDITIVE); the G1 golden KAT pins it, and a real // BIP9-deployment-aware value can later be threaded in without touching // the default header projection. - uint32_t version = 0x20000000u) + uint32_t version = 0x20000000u, + // Seam: optional underfill-guard observation point. 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. + bool* underfill_tripped = nullptr) { DashWorkData w; w.m_height = prev_height + 1; @@ -104,12 +140,39 @@ inline DashWorkData build_embedded_workdata( w.m_txs.reserve(selected.size()); w.m_tx_hashes.reserve(selected.size()); w.m_tx_fees.reserve(selected.size()); + uint64_t selected_bytes = 0; // wire bytes packed into this template (underfill guard) for (auto& s : selected) { + selected_bytes += s.base_size; w.m_txs.emplace_back(s.tx); w.m_tx_hashes.push_back(dash::coin::dash_txid(s.tx)); w.m_tx_fees.push_back(s.fee); } + // ── Underfill guard ───────────────────────────────────────────── + // Do not silently treat a near-empty DASH template as healthy when the + // DASH mempool held fee-paying backlog that should have filled it. We + // cannot fabricate transactions, so surface loudly (WARNING) for + // contabo-prod-watch / the operator rather than shipping a false-empty + // block as normal. Genuinely empty mempools never trip. Mirrors the + // LTC/DOGE TemplateBuilder guard; additive only — masternode payment / + // CbTx / superblock projection below is untouched either way. + { + const uint64_t mempool_bytes = static_cast(mempool.byte_size()); + const uint64_t mempool_fees = mempool.total_known_fees(); + const bool tripped = underfill_guard_trips(selected_bytes, + mempool_bytes, + mempool_fees); + if (underfill_tripped) *underfill_tripped = tripped; + if (tripped) { + LOG_WARNING << "[GBT-EMB] UNDERFILL: selected " + << selected.size() << " tx / " << selected_bytes + << "B into template while mempool holds " << mempool.size() + << " tx / " << mempool_bytes << "B (" << mempool_fees + << " sat fees) — near-empty block on a non-empty " + << "mempool; template-fill regression, gates cutover."; + } + } + // Platform Credit Pool burn (DIP-0027): emit OP_RETURN payment // FIRST (matches dashcore GetBlockTxOuts ordering at payments.cpp:55). // Payee uses the "!hex" raw-script convention; OP_RETURN single byte = 0x6a. diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index ca8961c3c..e68779c24 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -504,6 +504,23 @@ if (BUILD_TESTING AND GTest_FOUND) target_link_libraries(test_dash_embedded_gbt PRIVATE c2pool_payout c2pool_hashrate c2pool_merged_mining) # OBJECT-lib SCC direct-naming (#22/#39) gtest_add_tests(test_dash_embedded_gbt "" AUTO) + # DASH underfill guard: port of the LTC/DOGE "near-empty template on a + # non-empty mempool" template-builder guard to the DASH embedded GBT path + # (embedded_gbt.hpp), for the mining-hotel deployment. Predicate KATs at + # the pinned 50 kB floor/slack + build_embedded_workdata wiring via the + # SAFE-ADDITIVE underfill_tripped seam (log-only guard; masternode + # burn/payee projection asserted unchanged). Same link set as the + # embedded_gbt capstone. + add_executable(test_dash_underfill_guard test_dash_underfill_guard.cpp) + target_link_libraries(test_dash_underfill_guard PRIVATE + GTest::gtest_main GTest::gtest + dash_x11 core + nlohmann_json::nlohmann_json + ${Boost_LIBRARIES} + ) + 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) + # 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_dash_underfill_guard.cpp b/test/test_dash_underfill_guard.cpp new file mode 100644 index 000000000..299e5205c --- /dev/null +++ b/test/test_dash_underfill_guard.cpp @@ -0,0 +1,336 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +/// DASH 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 DASH embedded GBT path (embedded_gbt.hpp), +/// for the mining-hotel deployment. +/// +/// 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) build_embedded_workdata() wiring — the guard evaluates over the +/// REAL Mempool queries (byte_size / total_known_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: fee-known bulk backlog whose inputs the +/// stale-input guard rejects at selection time — selection goes +/// empty while the pool still reports the fee-paying backlog. +/// DASH specifics (platform burn / MN payee projection) are +/// asserted UNCHANGED when the guard trips (additive-only). +/// +/// Setup helpers mirror test_dash_embedded_gbt.cpp so the fixture semantics +/// stay identical to the capstone KAT. + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include + +using dash::coin::DashWorkData; +using dash::coin::MNState; +using dash::coin::MnStateMachine; +using dash::coin::Mempool; +using dash::coin::MutableTransaction; +using dash::coin::build_embedded_workdata; +using dash::coin::underfill_guard_trips; +using dash::coin::UNDERFILL_MIN_FILL_BYTES; +using dash::coin::UNDERFILL_BACKLOG_SLACK; +using dash::coin::compute_dash_block_reward_post_v20; +using dash::coin::compute_dash_mn_payment_post_v20; +using dash::coin::compute_dash_platform_reward_post_v20_mn_rr; +using ::core::coin::UTXOViewCache; +using ::core::coin::Outpoint; +using ::core::coin::Coin; +using ::bitcoin_family::coin::TxIn; +using ::bitcoin_family::coin::TxOut; + +// Dash mainnet base58 version bytes (chainparams.cpp PUBKEY_ADDRESS=76, +// SCRIPT_ADDRESS=16) — same pins as test_dash_embedded_gbt.cpp. +static constexpr uint8_t DASH_PUBKEY_VER = 76; +static constexpr uint8_t DASH_P2SH_VER = 16; + +// Past V20 + MN_RR so the platform burn is active (mainnet steady state). +static constexpr uint32_t H = 2'400'000; + +// ─── helpers (mirrored from test_dash_embedded_gbt.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 std::vector p2pkh_script(uint8_t hashseed) { + std::vector s; + s.push_back(0x76); // OP_DUP + s.push_back(0xa9); // OP_HASH160 + s.push_back(0x14); // push 20 + for (int i = 0; i < 20; ++i) s.push_back(static_cast(hashseed + i)); + s.push_back(0x88); // OP_EQUALVERIFY + s.push_back(0xac); // OP_CHECKSIG + return s; +} + +static uint256 mint_hash(uint32_t seed) { + MutableTransaction t; + t.version = 1; t.type = 0; + t.locktime = 0x51000000u ^ seed; + auto ps = ::pack(t); + return ::Hash(ps.get_span()); +} + +static MutableTransaction make_spend(const uint256& prev, uint32_t idx, + int64_t out_value, uint32_t salt) { + MutableTransaction tx; + tx.version = 1; tx.type = 0; 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 base_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 = 1; tx.type = 0; 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 MnStateMachine single_mn(const std::vector& payout) { + MNState s; + s.isValid = true; + s.nRegisteredHeight = 2'300'000; + s.nLastPaidHeight = 0; + s.scriptPayout.m_data = payout; + MnStateMachine m; + m.load(std::vector>{{raw256(0x01), s}}); + return m; +} + +// ════════════════════════════════════════════════════════════════════════ +// (1) Predicate KATs — the exact LTC/DOGE boolean, pinned at boundaries. +// ════════════════════════════════════════════════════════════════════════ + +TEST(DashUnderfillGuard, ThresholdsMatchTheCrossCoinPins) { + // The v36-native shared thresholds — same values LTC/DOGE 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(DashUnderfillGuard, 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(DashUnderfillGuard, EmptyMempoolNeverTrips) { + // A genuinely empty mempool → an empty template is legitimate. + EXPECT_FALSE(underfill_guard_trips(0, 0, 0)); +} + +TEST(DashUnderfillGuard, 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(DashUnderfillGuard, 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(DashUnderfillGuard, 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(DashUnderfillGuard, 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_embedded_workdata wiring — real Mempool, real template build. +// ════════════════════════════════════════════════════════════════════════ + +TEST(DashUnderfillGuard, BuildTripsWhenSelectionGoesEmptyOnFeePayingBacklog) { + // 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 = mint_hash(101); + funded.add_coin(Outpoint(prev, 0), + Coin(1'000'000, {}, /*height=*/1, /*cb=*/false)); + Mempool mp; + mp.set_utxo(&funded); + // ~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)); + ASSERT_GT(mp.byte_size(), UNDERFILL_MIN_FILL_BYTES + UNDERFILL_BACKLOG_SLACK); + ASSERT_GT(mp.total_known_fees(), 0u); + + // Now swap in 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_known_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(1'990'000).first.empty()) + << "precondition: stale-input guard must empty the selection"; + + auto payout = p2pkh_script(0x30); + auto mnstates = single_mn(payout); + + bool tripped = false; + auto w = build_embedded_workdata( + H - 1, raw256(0xAB), mnstates, mp, + /*bits=*/0x1b104be3u, /*mtp=*/1'700'000'000u, + DASH_PUBKEY_VER, DASH_P2SH_VER, + /*curtime=*/1'700'000'123u, /*version=*/0x20000000u, &tripped); + + EXPECT_TRUE(tripped) + << "near-empty template on a fee-paying non-empty mempool must trip"; + + // Guard is ADDITIVE (log-only): the DASH-specific projection is intact. + EXPECT_TRUE(w.m_txs.empty()); + int64_t reward = compute_dash_block_reward_post_v20(H); + int64_t platform_reward = compute_dash_platform_reward_post_v20_mn_rr(H); + int64_t mn_payment = compute_dash_mn_payment_post_v20(reward) + - platform_reward; + EXPECT_EQ(w.m_coinbase_value, static_cast(reward)); // no fees selected + ASSERT_EQ(w.m_packed_payments.size(), 2u); // burn + MN payee + EXPECT_EQ(w.m_packed_payments[0].payee, "!6a"); // DIP-0027 burn first + EXPECT_EQ(w.m_packed_payments[0].amount, + static_cast(platform_reward)); + EXPECT_EQ(w.m_packed_payments[1].amount, + static_cast(mn_payment)); +} + +TEST(DashUnderfillGuard, BuildDoesNotTripOnEmptyMempool) { + // Empty mempool → empty template is legitimate; guard stays quiet. + Mempool mp; + auto payout = p2pkh_script(0x40); + auto mnstates = single_mn(payout); + + bool tripped = true; // pre-set opposite to prove the seam writes false + auto w = build_embedded_workdata( + H - 1, raw256(0x10), mnstates, mp, + 0x1b104be3u, 1'700'000'000u, DASH_PUBKEY_VER, DASH_P2SH_VER, + 1'700'000'123u, 0x20000000u, &tripped); + + EXPECT_FALSE(tripped) << "an empty mempool must never trip the guard"; + EXPECT_TRUE(w.m_txs.empty()); + ASSERT_EQ(w.m_packed_payments.size(), 2u); // projection unchanged +} + +TEST(DashUnderfillGuard, 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. + UTXOViewCache utxo(nullptr); + uint256 prev = mint_hash(102); + utxo.add_coin(Outpoint(prev, 0), Coin(100'000, {}, 1, false)); + Mempool mp; + mp.set_utxo(&utxo); + auto tx = make_spend(prev, 0, 90'000, /*salt=*/2); // fee = 10'000 + ASSERT_TRUE(mp.add_tx(tx)); + + auto payout = p2pkh_script(0x50); + auto mnstates = single_mn(payout); + + bool tripped = true; + auto w = build_embedded_workdata( + H - 1, raw256(0x20), mnstates, mp, + 0x1b104be3u, 1'700'000'000u, DASH_PUBKEY_VER, DASH_P2SH_VER, + 1'700'000'123u, 0x20000000u, &tripped); + + EXPECT_FALSE(tripped) << "a fully drained small mempool must not trip"; + ASSERT_EQ(w.m_txs.size(), 1u); // the tx WAS selected + EXPECT_EQ(w.m_tx_fees[0], 10'000u); +} + +TEST(DashUnderfillGuard, DefaultSeamLeavesExistingCallersUnchanged) { + // Omitting the trailing seam (every existing caller) builds the exact + // same template as passing it — SAFE-ADDITIVE, field-for-field. + UTXOViewCache utxo(nullptr); + uint256 prev = mint_hash(103); + utxo.add_coin(Outpoint(prev, 0), Coin(100'000, {}, 1, false)); + Mempool mp; + mp.set_utxo(&utxo); + ASSERT_TRUE(mp.add_tx(make_spend(prev, 0, 90'000, /*salt=*/3))); + + auto payout = p2pkh_script(0x55); + auto mnstates = single_mn(payout); + const uint32_t PINNED_CURTIME = 1'700'000'123u; + + auto legacy = build_embedded_workdata( + H - 1, raw256(0x60), mnstates, mp, + 0x1b104be3u, 1'700'000'000u, DASH_PUBKEY_VER, DASH_P2SH_VER, + PINNED_CURTIME); + bool tripped = true; + auto seamed = build_embedded_workdata( + H - 1, raw256(0x60), mnstates, mp, + 0x1b104be3u, 1'700'000'000u, DASH_PUBKEY_VER, DASH_P2SH_VER, + PINNED_CURTIME, 0x20000000u, &tripped); + + EXPECT_FALSE(tripped); + EXPECT_EQ(legacy.m_height, seamed.m_height); + EXPECT_EQ(legacy.m_previous_block, seamed.m_previous_block); + EXPECT_EQ(legacy.m_bits, seamed.m_bits); + EXPECT_EQ(legacy.m_mintime, seamed.m_mintime); + EXPECT_EQ(legacy.m_version, seamed.m_version); + EXPECT_EQ(legacy.m_curtime, seamed.m_curtime); + EXPECT_EQ(legacy.m_coinbase_value, seamed.m_coinbase_value); + EXPECT_EQ(legacy.m_payment_amount, seamed.m_payment_amount); + EXPECT_EQ(legacy.m_txs.size(), seamed.m_txs.size()); + EXPECT_EQ(legacy.m_packed_payments.size(), seamed.m_packed_payments.size()); +}