diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6ce39eefa..cbb8eedea 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -102,7 +102,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_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_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_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_multiaddress_pplns test_pplns_stress \ test_hash_link test_decay_pplns \ test_pplns_consensus \ @@ -252,7 +252,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_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_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_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_hash_link test_decay_pplns \ test_pplns_consensus \ test_v36_script_sorting test_v36_cross_impl_refhash \ diff --git a/src/impl/dash/coin/coin_state_maintainer.hpp b/src/impl/dash/coin/coin_state_maintainer.hpp new file mode 100644 index 000000000..c91645e3d --- /dev/null +++ b/src/impl/dash/coin/coin_state_maintainer.hpp @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +#pragma once + +/// Phase C-TEMPLATE step 7 (S8 embedded_gbt live-wire, follow-on to #673): +/// the maintainer that POPULATES the node-held NodeCoinState off the +/// reception + think update path, replacing the set_tip-on-demand pattern +/// where a caller poked NodeCoinState::set_tip() directly. +/// +/// #672 landed select_dash_work() as the branch point; #673 landed +/// NodeCoinState as the node-held bundle whose populated() flips the hot arm. +/// But #673 left publication to whoever calls set_tip() -- fine for the KAT, +/// wrong for a live node: the tip advances on the header/think path, the MN +/// list advances on the mnlistdiff reception path, and mempool txs arrive on +/// the relay path, all ASYNCHRONOUSLY. The bundle must only go live once the +/// prerequisites the embedded template needs are actually present, else the +/// selector would build a template against a stale/empty MN list. +/// +/// CoinStateMaintainer is that ordering gate. It owns no state of its own +/// beyond readiness flags + the last tip params; it drives a NodeCoinState& +/// the node owns. The reception/think slices call the on_*() event methods; +/// the maintainer republishes (calls set_tip) only when BOTH the MN list has +/// been seeded AND a tip has arrived. Until then, and after any invalidating +/// event (reorg / MN-list gap), populated() stays false and select_work() +/// routes to the retained dashd getblocktemplate fallback. +/// +/// STRICTLY single-coin: src/impl/dash/coin/ only, no bitcoin_family / src/core +/// reach. The dashd RPC arm is NEVER removed -- it is the always-reachable +/// safety path and the [GBT-XCHECK] cross-check whenever the bundle is not live. + +#include // NodeCoinState +#include // MNState +#include // MutableTransaction + +#include + +#include +#include +#include + +namespace dash { +namespace coin { + +/// Drives a node-owned NodeCoinState from the async update events the running +/// node observes. Non-copyable (holds a reference to the node's holder). The +/// node constructs exactly one, wired to its NodeCoinState member; the +/// reception (mnlistdiff / mempool) and header/think (tip) slices call the +/// on_*() methods as their respective updates land. +class CoinStateMaintainer { +public: + explicit CoinStateMaintainer(NodeCoinState& state) : m_state(state) {} + CoinStateMaintainer(const CoinStateMaintainer&) = delete; + CoinStateMaintainer& operator=(const CoinStateMaintainer&) = delete; + + /// Reception path (mnlistdiff): replace the masternode set the embedded + /// coinbase pays. An EMPTY list is treated as a gap -- it cannot back a + /// valid payee, so it clears MN-readiness and drops the bundle to fallback + /// rather than publishing a template with no masternode payment. + void on_mn_list_update(std::vector> mnstates) { + m_have_mn = !mnstates.empty(); + m_state.mnstates().load(std::move(mnstates)); + if (!m_have_mn) + demote(); + else + republish(); + } + + /// Reception path (mempool relay): fold a relayed tx into the local + /// mempool. Mempool contents are OPTIONAL for viability -- an empty + /// mempool yields a valid coinbase-only template -- so this never gates + /// publication; it only enriches the next assembled template. Returns the + /// mempool's accept verdict (false = rejected: bad utxo ref / already in). + bool on_mempool_tx(const MutableTransaction& tx) { + return m_state.mempool().add_tx(tx); + } + + /// Header / think path: the chain tip advanced. Stash the params the + /// embedded template needs and mark tip-readiness, then republish if the + /// MN list is also ready. curtime/version left 0 defer to + /// build_embedded_workdata()'s own SAFE-ADDITIVE defaults. + void on_new_tip(uint32_t prev_height, const uint256& prev_hash, + uint32_t bits_for_next, uint32_t mtp_at_tip, + uint8_t address_version, uint8_t address_p2sh_version, + uint32_t curtime = 0, uint32_t version = 0) { + m_prev_height = prev_height; + m_prev_hash = prev_hash; + m_bits_for_next = bits_for_next; + m_mtp_at_tip = mtp_at_tip; + m_address_version = address_version; + m_address_p2sh_version = address_p2sh_version; + m_curtime = curtime; + m_version = version; + m_have_tip = true; + republish(); + } + + /// Reorg / MN-list gap / mempool flush: invalidate the live bundle so the + /// next get_work falls back to dashd until a fresh tip rebuilds it. The + /// stashed tip params are dropped -- a reorg means the old prev_hash is no + /// longer the tip, so we must NOT auto-republish it; a subsequent + /// on_new_tip() re-arms tip-readiness. The MN list (which survives a mere + /// reorg) is left in place. + void on_invalidate() { + m_have_tip = false; + demote(); + } + + /// True iff both prerequisites are met AND the holder is currently live. + bool live() const { return m_state.populated(); } + +private: + // Publish only when both prerequisites are present; otherwise leave the + // holder in whatever (un)published state it is in -- callers reach demote() + // explicitly for the invalidating transitions. + void republish() { + if (m_have_tip && m_have_mn) + m_state.set_tip(m_prev_height, m_prev_hash, m_bits_for_next, + m_mtp_at_tip, m_address_version, m_address_p2sh_version, + m_curtime, m_version); + } + + void demote() { + if (m_state.populated()) + m_state.invalidate(); + } + + NodeCoinState& m_state; + + bool m_have_mn{false}; + bool m_have_tip{false}; + + // Last observed tip params, applied on republish(). + uint32_t m_prev_height{0}; + uint256 m_prev_hash; + uint32_t m_bits_for_next{0}; + uint32_t m_mtp_at_tip{0}; + uint8_t m_address_version{0}; + uint8_t m_address_p2sh_version{0}; + uint32_t m_curtime{0}; + uint32_t m_version{0}; +}; + +} // namespace coin +} // namespace dash diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 20702c38f..ca617ef79 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -534,6 +534,19 @@ if (BUILD_TESTING AND GTest_FOUND) target_link_libraries(test_dash_node_coin_state PRIVATE c2pool_payout c2pool_hashrate c2pool_merged_mining) # OBJECT-lib SCC direct-naming (#22/#39) gtest_add_tests(test_dash_node_coin_state "" AUTO) + # CoinStateMaintainer: drives NodeCoinState off async tip/MN/mempool update + # events (readiness gate: MN+tip required before publish). Same link set as + # test_dash_node_coin_state -- header-only over the work_source selector set. + add_executable(test_dash_coin_state_maintainer test_dash_coin_state_maintainer.cpp) + target_link_libraries(test_dash_coin_state_maintainer PRIVATE + GTest::gtest_main GTest::gtest + dash_x11 core + nlohmann_json::nlohmann_json + ${Boost_LIBRARIES} + ) + target_link_libraries(test_dash_coin_state_maintainer PRIVATE c2pool_payout c2pool_hashrate c2pool_merged_mining) # OBJECT-lib SCC direct-naming (#22/#39) + gtest_add_tests(test_dash_coin_state_maintainer "" AUTO) + # DASH CSimplifiedMNListDiff (mnlistdiff wire, S8.1 leaf): vendored diff # round-trip + dashcore field-layout pin + apply_diff semantics. Header-only # over the SimplifiedMNList leaf + coin/transaction; no ltc/pool SCC dep. diff --git a/test/test_dash_coin_state_maintainer.cpp b/test/test_dash_coin_state_maintainer.cpp new file mode 100644 index 000000000..2bdcc4e14 --- /dev/null +++ b/test/test_dash_coin_state_maintainer.cpp @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +/// Phase C-TEMPLATE step 7 -- CoinStateMaintainer live-update KAT. +/// +/// #673 proved a directly-poked NodeCoinState routes the hot arm. This suite +/// proves the MAINTAINER that populates it off async update events: +/// +/// * the bundle flips has_state=true ONLY once BOTH a tip AND a non-empty +/// MN list have arrived -- in EITHER order (tip-then-MN and MN-then-tip +/// both converge to the same live template); +/// * once live, select_work() returns EXACTLY the DashWorkData a direct +/// build_embedded_workdata() over the same inputs produces (maintainer +/// changes nothing about oracle parity, only WHEN the arm goes live); +/// * an empty MN list (mnlistdiff gap) and a reorg invalidate demote the +/// bundle back to the retained dashd fallback; a reorg drops the tip so +/// a fresh tip is required to re-arm (no auto-republish of a stale prev). +/// +/// Seeding mirrors test_dash_node_coin_state.cpp exactly so the two suites pin +/// the SAME projection. No fabricated oracle values -- "expected" IS an +/// independent build_embedded_workdata() call, compared field-for-field. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +using dash::coin::CoinStateMaintainer; +using dash::coin::NodeCoinState; +using dash::coin::DashWorkData; +using dash::coin::WorkSource; +using dash::coin::WorkSelection; +using dash::coin::MNState; +using dash::coin::Mempool; +using dash::coin::MutableTransaction; +using dash::coin::build_embedded_workdata; +using ::core::coin::UTXOViewCache; +using ::core::coin::Outpoint; +using ::core::coin::Coin; +using ::bitcoin_family::coin::TxIn; +using ::bitcoin_family::coin::TxOut; + +static constexpr uint8_t DASH_PUBKEY_VER = 76; +static constexpr uint8_t DASH_P2SH_VER = 16; +static constexpr uint32_t H = 2'400'000; // past MN_RR: platform burn active + +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{0x76, 0xa9, 0x14}; + for (int i = 0; i < 20; ++i) s.push_back(static_cast(hashseed + i)); + s.push_back(0x88); s.push_back(0xac); + return s; +} + +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; +} + +static std::vector> single_mn(const std::vector& payout) { + MNState s; + s.isValid = true; + s.nRegisteredHeight = 2'300'000; + s.nLastPaidHeight = 0; + s.scriptPayout.m_data = payout; + return std::vector>{{raw256(0x01), s}}; +} + +static void expect_workdata_eq(const DashWorkData& a, const DashWorkData& b) { + EXPECT_EQ(a.m_version, b.m_version); + EXPECT_EQ(a.m_previous_block, b.m_previous_block); + EXPECT_EQ(a.m_height, b.m_height); + EXPECT_EQ(a.m_coinbase_value, b.m_coinbase_value); + EXPECT_EQ(a.m_bits, b.m_bits); + EXPECT_EQ(a.m_curtime, b.m_curtime); + EXPECT_EQ(a.m_mintime, b.m_mintime); + EXPECT_EQ(a.m_payment_amount, b.m_payment_amount); + EXPECT_EQ(a.m_tx_hashes, b.m_tx_hashes); + EXPECT_EQ(a.m_tx_fees, b.m_tx_fees); + EXPECT_EQ(a.m_txs.size(), b.m_txs.size()); +} + +static const uint256 PREV_HASH = raw256(0xAB); +static const uint32_t BITS = 0x1b104be3u; +static const uint32_t MTP = 1'700'000'000u; +static const uint32_t CURTIME = 1'700'000'123u; +static const uint32_t VERSION = 0x20000000u; + +// ════════════════════════════════════════════════════════════════════════ +// tip-first: bundle stays fallback until the MN list also arrives, then flips. +// ════════════════════════════════════════════════════════════════════════ +TEST(DashCoinStateMaintainer, TipBeforeMnStaysFallbackThenMnPublishes) { + NodeCoinState st; + CoinStateMaintainer m(st); + + // Tip arrives first; no MN list yet -> NOT live, routes fallback. + m.on_new_tip(H - 1, PREV_HASH, BITS, MTP, DASH_PUBKEY_VER, DASH_P2SH_VER, CURTIME, VERSION); + EXPECT_FALSE(m.live()); + EXPECT_FALSE(st.make_embedded_work_inputs().viable()); + { + bool fb = false; + WorkSelection sel = st.select_work([&]() { fb = true; return DashWorkData{}; }); + EXPECT_EQ(sel.source, WorkSource::DashdFallback); + EXPECT_TRUE(fb); + } + + // mnlistdiff lands -> prerequisites complete -> bundle flips live. + m.on_mn_list_update(single_mn(p2pkh_script(0x30))); + EXPECT_TRUE(m.live()); + EXPECT_TRUE(st.make_embedded_work_inputs().viable()); + { + bool fb = false; + WorkSelection sel = st.select_work([&]() { fb = true; return DashWorkData{}; }); + EXPECT_EQ(sel.source, WorkSource::Embedded); + EXPECT_FALSE(fb); + EXPECT_EQ(sel.work.m_height, H); + } +} + +// ════════════════════════════════════════════════════════════════════════ +// MN-first then tip: converges to the SAME live template, byte-equal to a +// direct build over the identical inputs (incl. a mempool tx routed through +// the maintainer's on_mempool_tx path). +// ════════════════════════════════════════════════════════════════════════ +TEST(DashCoinStateMaintainer, MnThenTipPublishesByteEqualToDirectBuild) { + UTXOViewCache utxo(nullptr); + uint256 prev = raw256(0x77); + utxo.add_coin(Outpoint(prev, 0), Coin(100'000, {}, 1, false)); + + NodeCoinState st; + st.mempool().set_utxo(&utxo); + CoinStateMaintainer m(st); + + m.on_mn_list_update(single_mn(p2pkh_script(0x30))); + EXPECT_FALSE(m.live()) << "MN alone (no tip) must not publish"; + ASSERT_TRUE(m.on_mempool_tx(make_spend(prev, 0, 90'000, /*salt=*/1))); // fee 10'000 + m.on_new_tip(H - 1, PREV_HASH, BITS, MTP, DASH_PUBKEY_VER, DASH_P2SH_VER, CURTIME, VERSION); + ASSERT_TRUE(m.live()); + + DashWorkData reference = build_embedded_workdata( + H - 1, PREV_HASH, st.mnstates(), st.mempool(), + BITS, MTP, DASH_PUBKEY_VER, DASH_P2SH_VER, CURTIME, VERSION); + + bool fb = false; + WorkSelection sel = st.select_work([&]() { fb = true; return DashWorkData{}; }); + EXPECT_EQ(sel.source, WorkSource::Embedded); + EXPECT_FALSE(fb); + EXPECT_EQ(sel.work.m_height, H); + expect_workdata_eq(sel.work, reference); + // mempool tx actually reached the template (coinbase-only would have 0 fees). + EXPECT_FALSE(reference.m_tx_hashes.empty()); + EXPECT_EQ(sel.work.m_tx_hashes.size(), reference.m_tx_hashes.size()); +} + +// ════════════════════════════════════════════════════════════════════════ +// empty mnlistdiff = gap: demotes a live bundle back to the dashd fallback. +// ════════════════════════════════════════════════════════════════════════ +TEST(DashCoinStateMaintainer, EmptyMnListIsGapDemotesToFallback) { + NodeCoinState st; + CoinStateMaintainer m(st); + m.on_new_tip(H - 1, PREV_HASH, BITS, MTP, DASH_PUBKEY_VER, DASH_P2SH_VER, CURTIME, VERSION); + m.on_mn_list_update(single_mn(p2pkh_script(0x30))); + ASSERT_TRUE(m.live()); + + m.on_mn_list_update({}); // gap: cannot back a payee + EXPECT_FALSE(m.live()); + bool fb = false; + WorkSelection sel = st.select_work([&]() { fb = true; return DashWorkData{}; }); + EXPECT_EQ(sel.source, WorkSource::DashdFallback); + EXPECT_TRUE(fb); +} + +// ════════════════════════════════════════════════════════════════════════ +// reorg: invalidate drops the tip -> fallback; a stale MN refresh must NOT +// auto-republish the old prev; only a fresh tip re-arms the live bundle. +// ════════════════════════════════════════════════════════════════════════ +TEST(DashCoinStateMaintainer, InvalidateReorgRequiresFreshTipToReArm) { + NodeCoinState st; + CoinStateMaintainer m(st); + m.on_mn_list_update(single_mn(p2pkh_script(0x30))); + m.on_new_tip(H - 1, PREV_HASH, BITS, MTP, DASH_PUBKEY_VER, DASH_P2SH_VER, CURTIME, VERSION); + ASSERT_TRUE(m.live()); + + m.on_invalidate(); // reorg + EXPECT_FALSE(m.live()); + + // An MN refresh alone must not resurrect the invalidated tip. + m.on_mn_list_update(single_mn(p2pkh_script(0x31))); + EXPECT_FALSE(m.live()) << "reorg dropped the tip; MN refresh alone must not republish a stale prev"; + + // A fresh tip re-arms the bundle. + m.on_new_tip(H, raw256(0xCD), BITS, MTP, DASH_PUBKEY_VER, DASH_P2SH_VER, CURTIME, VERSION); + EXPECT_TRUE(m.live()); + bool fb = false; + WorkSelection sel = st.select_work([&]() { fb = true; return DashWorkData{}; }); + EXPECT_EQ(sel.source, WorkSource::Embedded); + EXPECT_FALSE(fb); + EXPECT_EQ(sel.work.m_height, H + 1); +}