diff --git a/src/impl/dash/coin/mn_list_ingest.hpp b/src/impl/dash/coin/mn_list_ingest.hpp new file mode 100644 index 000000000..82bf8ef89 --- /dev/null +++ b/src/impl/dash/coin/mn_list_ingest.hpp @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +#pragma once +// =========================================================================== +// dash reception wire (leg 4 of 4) -- interfaces::Node::mn_list_update -> +// maintainer. +// +// Legs 1-3 wired the mempool-relay (new_tx), tip-advance (new_tip) and block- +// connect (block_connected) events into CoinStateMaintainer. This CLOSES the +// wire with the mnlistdiff RESYNC leg: every full masternode-set snapshot +// dashd delivers is routed to CoinStateMaintainer::on_mn_list_update(), which +// REPLACES the DMN set the embedded coinbase pays wholesale (the authoritative +// bulk feed), as opposed to leg 3s on_block_connected() which folds per-block +// special-tx deltas INCREMENTALLY between snapshots. Together the two keep the +// masternode set both correct at each snapshot and current between them. +// +// PAYLOAD: on_mn_list_update() takes the full projected set as a +// vector>; leg 1s scope note called out that this leg +// "has no source event on interfaces::Node at all". That source event +// (mn_list_update, carrying MnListUpdate) is the interface-shape addition this +// leg makes -- purely additive, dash interface only (single-coin), mirroring +// leg 2s TipAdvance and leg 3s BlockConnected. An EMPTY snapshot is forwarded +// verbatim: on_mn_list_update() treats it as a set-gap and demotes the bundle +// to the retained dashd fallback rather than backing a template with no payee. +// +// LIFETIME: the handler captures maint by reference, so maint (and the +// NodeCoinState it drives) MUST outlive node. The returned EventDisposable lets +// a caller tear the subscription down explicitly; it does NOT auto-dispose on +// drop -- identical contract to wire_mempool_ingest / wire_tip_ingest / +// wire_block_connect_ingest. +// +// SCC: pulls node_interface.hpp (which now carries MnListUpdate -> MNState via +// mn_state_machine.hpp) and coin_state_maintainer.hpp, so include this header +// ONLY from a TU that already links the full dash codec (main_dash + this leg +// KAT), never a guard-weight TU. +// =========================================================================== +#include +#include + +#include + +#include "node_interface.hpp" // dash::interfaces::Node (mn_list_update feed) + MnListUpdate +#include "coin_state_maintainer.hpp" // dash::coin::CoinStateMaintainer::on_mn_list_update + +namespace c2pool::dash +{ + +// Subscribe maint to node.mn_list_update: every mnlistdiff snapshot RESYNCS the +// DMN set via on_mn_list_update(mnstates), which republishes when the set is +// non-empty (MN-readiness met) or demotes the node-held bundle to the dashd +// fallback when the snapshot is empty (set-gap). Returns the subscription +// handle so the caller controls teardown. +inline std::shared_ptr +wire_mn_list_ingest(::dash::interfaces::Node& node, + ::dash::coin::CoinStateMaintainer& maint) +{ + return node.mn_list_update.subscribe( + [&maint](const ::dash::interfaces::MnListUpdate& u) + { + maint.on_mn_list_update(u.mnstates); + }); +} + +} // namespace c2pool::dash diff --git a/src/impl/dash/coin/node_interface.hpp b/src/impl/dash/coin/node_interface.hpp index dd530fd25..f55796ee6 100644 --- a/src/impl/dash/coin/node_interface.hpp +++ b/src/impl/dash/coin/node_interface.hpp @@ -3,12 +3,14 @@ #include "block.hpp" #include "transaction.hpp" +#include "mn_state_machine.hpp" #include #include #include #include +#include #include namespace dash @@ -40,7 +42,7 @@ struct TipAdvance /// special-tx height is a chain-position input apply_block cannot recover from /// the block alone). block_connected pairs the two so the reception wire feeds /// apply_block the exact (block, height) apply_block expects -- purely additive, -/// dash interface only (leg 2's new_tip added TipAdvance for the same reason a +/// dash interface only (leg 2s new_tip added TipAdvance for the same reason a /// bare best_block_hash was insufficient). struct BlockConnected { @@ -48,6 +50,23 @@ struct BlockConnected uint32_t height{0}; }; +/// Reception path payload (mnlistdiff): the AUTHORITATIVE masternode-set +/// snapshot the embedded coinbase pays. dashd (protx diff / the qdata +/// mnlistdiff message) yields the full projected DMN set as +/// (proTxHash -> MNState) pairs; CoinStateMaintainer::on_mn_list_update() +/// takes exactly that vector. This is the bulk RESYNC feed -- distinct from +/// leg 3s block_connected, which folds per-block special txs INCREMENTALLY +/// between snapshots. An empty vector is a set-gap signal (see on_mn_list_ +/// update): it cannot back a payee, so it demotes the bundle to the dashd +/// fallback rather than publishing a template with a phantom payment. Purely +/// additive, dash interface only -- carries dash::coin::MNState, so this +/// header now pulls mn_state_machine.hpp (dash-coin scoped, no cross-coin +/// reach), matching the codec weight it already takes from block.hpp. +struct MnListUpdate +{ + std::vector> mnstates; +}; + struct Node { Variable best_block_hash; @@ -70,6 +89,14 @@ struct Node // fallback rather than backing a template with a phantom payee. Event block_connected; + // Reception path: fires when dashd delivers a full mnlistdiff snapshot, + // carrying the authoritative projected DMN set (see MnListUpdate). The + // reception wire subscribes CoinStateMaintainer::on_mn_list_update to this + // so the masternode set the embedded coinbase pays is bulk-RESYNCED off the + // real feed (block_connected only folds per-block deltas between snapshots); + // an empty snapshot demotes the bundle to the dashd fallback. + Event mn_list_update; + // SPV A1 (parity audit): fires when dashd announces a ChainLock has // been aggregated for a block. Carries {block_hash, height}. // Consumers (e.g. block-find submit handler) can consult diff --git a/test/test_dash_node_reception_wire.cpp b/test/test_dash_node_reception_wire.cpp index b66d4c208..16c549a02 100644 --- a/test/test_dash_node_reception_wire.cpp +++ b/test/test_dash_node_reception_wire.cpp @@ -32,6 +32,7 @@ #include // c2pool::dash::wire_mempool_ingest #include // c2pool::dash::wire_tip_ingest #include // c2pool::dash::wire_block_connect_ingest +#include // c2pool::dash::wire_mn_list_ingest #include #include #include @@ -55,6 +56,7 @@ using c2pool::dash::wire_mempool_ingest; using c2pool::dash::wire_tip_ingest; using c2pool::dash::wire_block_connect_ingest; +using c2pool::dash::wire_mn_list_ingest; using dash::coin::CoinStateMaintainer; using dash::coin::NodeCoinState; using dash::coin::WorkSource; @@ -377,3 +379,96 @@ TEST(DashReceptionWire, DisposeStopsBlockConnectIngest) { EXPECT_EQ(st.mnstates().size(), 1u) << "after dispose, a connected block must not be applied"; EXPECT_TRUE(m.live()) << "after dispose, the armed bundle must stay live"; } + +// ======================================================================== +// Leg 4: an mn_list_update snapshot fired on the interface RESYNCS the DMN set +// THROUGH THE WIRE -- no direct on_mn_list_update() poke. With a tip already +// present, the wired mnlistdiff snapshot arms MN-readiness and select_work +// flips to the embedded arm. This is the authoritative bulk feed (leg 3's +// block_connected only folds per-block deltas between these snapshots). +// ======================================================================== +TEST(DashReceptionWire, MnListUpdateRelaySeedsMnSetThroughWire) { + UTXOViewCache utxo(nullptr); + dash::interfaces::Node node; + NodeCoinState st; + st.mempool().set_utxo(&utxo); + CoinStateMaintainer m(st); + + auto sub = wire_mn_list_ingest(node, m); + ASSERT_TRUE(sub) << "wire must return a live subscription handle"; + + // Tip arrives first (reception is async); the MN list is the gating event. + m.on_new_tip(H - 1, PREV_HASH, BITS, MTP, DASH_PUBKEY_VER, DASH_P2SH_VER, CURTIME, VERSION); + EXPECT_FALSE(m.live()) << "tip alone must not arm the bundle -- MN list absent"; + ASSERT_EQ(st.mnstates().size(), 0u); + + // mnlistdiff snapshot fired on the interface (NOT a direct maintainer poke). + dash::interfaces::MnListUpdate u; + u.mnstates = single_mn(p2pkh_script(0x30)); + node.mn_list_update.happened(u); + + EXPECT_EQ(st.mnstates().size(), 1u) << "mnlistdiff relay (via wire) must seed the DMN set"; + ASSERT_TRUE(m.live()) << "tip + MN (via wire) must arm the embedded bundle"; + + bool fb = false; + WorkSelection sel = st.select_work([&]() { fb = true; return dash::coin::DashWorkData{}; }); + EXPECT_EQ(sel.source, WorkSource::Embedded); + EXPECT_FALSE(fb) << "embedded arm must not invoke the dashd fallback"; +} + +// ======================================================================== +// An EMPTY mnlistdiff snapshot fired on the wire is a set-gap: on_mn_list_update +// clears MN-readiness and demotes an armed bundle to the retained dashd +// fallback rather than backing a template with no masternode payee. +// ======================================================================== +TEST(DashReceptionWire, EmptyMnListRelayDemotesThroughWire) { + UTXOViewCache utxo(nullptr); + dash::interfaces::Node node; + NodeCoinState st; + st.mempool().set_utxo(&utxo); + CoinStateMaintainer m(st); + + auto sub = wire_mn_list_ingest(node, m); + + // Arm the bundle: tip + a non-empty snapshot delivered on the wire. + m.on_new_tip(H - 1, PREV_HASH, BITS, MTP, DASH_PUBKEY_VER, DASH_P2SH_VER, CURTIME, VERSION); + dash::interfaces::MnListUpdate seed; + seed.mnstates = single_mn(p2pkh_script(0x30)); + node.mn_list_update.happened(seed); + ASSERT_TRUE(m.live()); + ASSERT_EQ(st.mnstates().size(), 1u); + + // An empty snapshot fired on the wire -> set-gap -> demote. + dash::interfaces::MnListUpdate empty; // empty.mnstates == {} + node.mn_list_update.happened(empty); + + EXPECT_EQ(st.mnstates().size(), 0u) << "empty snapshot must clear the DMN set"; + EXPECT_FALSE(m.live()) << "empty mnlistdiff (set-gap) must demote the live bundle"; + + bool fb = false; + WorkSelection sel = st.select_work([&]() { fb = true; return dash::coin::DashWorkData{}; }); + EXPECT_EQ(sel.source, WorkSource::DashdFallback); + EXPECT_TRUE(fb) << "after an empty snapshot, get_work must fall back to dashd"; +} + +// ======================================================================== +// Disposing the mn-list handle tears the subscription down: a later snapshot is +// not applied, so a stale mnlistdiff feed cannot silently re-seed the DMN set. +// ======================================================================== +TEST(DashReceptionWire, DisposeStopsMnListIngest) { + UTXOViewCache utxo(nullptr); + dash::interfaces::Node node; + NodeCoinState st; + st.mempool().set_utxo(&utxo); + CoinStateMaintainer m(st); + + auto sub = wire_mn_list_ingest(node, m); + sub->dispose(); + + dash::interfaces::MnListUpdate u; + u.mnstates = single_mn(p2pkh_script(0x30)); + node.mn_list_update.happened(u); + + EXPECT_EQ(st.mnstates().size(), 0u) + << "after dispose, an mnlistdiff snapshot must not seed the set"; +}