diff --git a/src/impl/dash/coin/node_interface.hpp b/src/impl/dash/coin/node_interface.hpp index 5ce0491d2..719a9114b 100644 --- a/src/impl/dash/coin/node_interface.hpp +++ b/src/impl/dash/coin/node_interface.hpp @@ -7,6 +7,7 @@ #include #include +#include #include #include @@ -15,6 +16,24 @@ namespace dash namespace interfaces { +/// Header/think path payload: the active chain tip advanced. Carries the exact +/// inputs build_embedded_workdata() needs that a bare best_block_hash cannot -- +/// the height/hash to build the next block ON, the next-block work target +/// (bits), the tip median-time-past for time bounds, and the coin address +/// versions for coinbase-payee encoding. curtime/version default to 0 so the +/// shaper applies its own SAFE-ADDITIVE defaults. +struct TipAdvance +{ + uint32_t prev_height{0}; + uint256 prev_hash; + uint32_t bits_for_next{0}; + uint32_t mtp_at_tip{0}; + uint8_t address_version{0}; + uint8_t address_p2sh_version{0}; + uint32_t curtime{0}; + uint32_t version{0}; +}; + struct Node { Variable best_block_hash; @@ -23,16 +42,22 @@ struct Node Event> new_headers; Event full_block; + // Header/think path: fires when the active chain tip advances, carrying the + // embedded-template params (see TipAdvance). The reception wire subscribes + // CoinStateMaintainer::on_new_tip to this so the node-held bundle arms its + // tip-readiness prerequisite without a direct poke. + Event new_tip; + // 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 // m_chainlocked_blocks to know whether a found block is now // irreversible. Event> new_chainlock; - std::map chainlocked_blocks; // block_hash → height + std::map chainlocked_blocks; // block_hash -> height std::map known_txs; }; } // namespace interfaces -} // namespace dash \ No newline at end of file +} // namespace dash diff --git a/src/impl/dash/coin/tip_ingest.hpp b/src/impl/dash/coin/tip_ingest.hpp new file mode 100644 index 000000000..64640ea42 --- /dev/null +++ b/src/impl/dash/coin/tip_ingest.hpp @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +#pragma once +// =========================================================================== +// dash reception wire (leg 2 of 4) -- interfaces::Node::new_tip -> maintainer. +// +// #672..#685 landed the node-held embedded coin-state bundle + its async +// CoinStateMaintainer; wire_mempool_ingest (leg 1) subscribed the mempool-relay +// event. This leg subscribes the header/think TIP-ADVANCE event: every tip +// advance is routed to CoinStateMaintainer::on_new_tip(), which stashes the tip +// params and marks tip-readiness -- one of the two prerequisites (the MN list +// is the other) that must BOTH be present before the node-held bundle publishes +// and select_work() flips off the retained dashd getblocktemplate fallback. +// +// PAYLOAD: unlike a bare best_block_hash, interfaces::Node::new_tip carries a +// TipAdvance -- the height/hash to build ON, next-block bits, tip MTP, and the +// coin address versions -- exactly build_embedded_workdata() inputs. That +// struct is the interface-shape addition this leg makes; the leg 1 note flagged +// that on_new_tip needs params best_block_hash does not carry, and this is that +// payload, kept to the dash interface only (single-coin, purely additive). +// +// STILL UNWIRED (own slices): on_mn_list_update has no source event on +// interfaces::Node yet; on_block_connected needs the connected block height, +// which full_block does not carry. +// +// 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. +// +// SCC: pulls node_interface.hpp (transaction/block codec via TipAdvance +// siblings), 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 "node_interface.hpp" // dash::interfaces::Node (new_tip feed) + TipAdvance +#include "coin_state_maintainer.hpp" // dash::coin::CoinStateMaintainer::on_new_tip + +namespace c2pool::dash +{ + +// Subscribe maint to node.new_tip: every tip advance stashes the embedded +// template params and marks tip-readiness via on_new_tip(); the maintainer +// republishes the node-held bundle only once the MN list is ALSO seeded, so a +// tip arriving before the first mnlistdiff leaves the bundle on the dashd +// fallback. Returns the subscription handle so the caller controls teardown. +inline std::shared_ptr +wire_tip_ingest(::dash::interfaces::Node& node, + ::dash::coin::CoinStateMaintainer& maint) +{ + return node.new_tip.subscribe( + [&maint](const ::dash::interfaces::TipAdvance& t) + { + maint.on_new_tip(t.prev_height, t.prev_hash, t.bits_for_next, + t.mtp_at_tip, t.address_version, t.address_p2sh_version, + t.curtime, t.version); + }); +} + +} // namespace c2pool::dash diff --git a/test/test_dash_node_reception_wire.cpp b/test/test_dash_node_reception_wire.cpp new file mode 100644 index 000000000..498a4edb4 --- /dev/null +++ b/test/test_dash_node_reception_wire.cpp @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +/// Phase C-TEMPLATE step 9 -- reception-wire KAT (leg 1: mempool relay). +/// +/// #672..#685 landed the node-held embedded coin-state bundle + its async +/// CoinStateMaintainer, and test_dash_coin_state_maintainer / _node_embedded_wire +/// proved that DRIVING the maintainer's on_*() methods flips select_work() to +/// the embedded arm. But every one of those suites poked the maintainer DIRECTLY +/// -- nothing subscribed a live interfaces::Node's reception events to it, so in +/// a running node the arm could never flip on its own. wire_mempool_ingest() +/// (src/impl/dash/coin/mempool_ingest.hpp) closes the FIRST of the four legs: +/// interfaces::Node::new_tx -> CoinStateMaintainer::on_mempool_tx. This suite +/// proves that subscription end-to-end, off the real Event, with no direct poke: +/// +/// * a new_tx relay fired on the interface FOLDS into the maintainer's mempool +/// (size 0 -> 1) -- the reception path, not a test poke, drives the state; +/// * disposing the returned handle tears the subscription down: a later relay +/// is NOT folded (size stays put) -- teardown is honoured; +/// * a relayed tx reaches the assembled embedded template once MN+tip arm the +/// bundle (select_work -> WorkSource::Embedded, tx present in m_tx_hashes), +/// and an on_invalidate() reorg demotes back to the retained dashd fallback. +/// +/// Seeding mirrors test_dash_coin_state_maintainer.cpp exactly so the suites pin +/// the SAME projection. Scope-honest: only the new_tx leg is wired here; the +/// on_block_connected / on_new_tip / on_mn_list_update legs need payload the +/// interface does not yet carry (block height / tip params / a mnlistdiff event) +/// and land in their own slices -- their maintainer methods are still exercised +/// directly here only to arm the bundle for the template-reach assertion. + +#include + +#include // dash::interfaces::Node +#include // c2pool::dash::wire_mempool_ingest +#include // c2pool::dash::wire_tip_ingest +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +using c2pool::dash::wire_mempool_ingest; +using c2pool::dash::wire_tip_ingest; +using dash::coin::CoinStateMaintainer; +using dash::coin::NodeCoinState; +using dash::coin::WorkSource; +using dash::coin::WorkSelection; +using dash::coin::MNState; +using dash::coin::MutableTransaction; +using dash::coin::Transaction; +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 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; + +// ════════════════════════════════════════════════════════════════════════ +// Leg 1: a new_tx relay fired on the interface folds through the maintainer. +// No direct on_mempool_tx() poke -- the Event drives it. +// ════════════════════════════════════════════════════════════════════════ +TEST(DashReceptionWire, NewTxRelayFoldsThroughMaintainer) { + UTXOViewCache utxo(nullptr); + const uint256 prev = raw256(0x77); + utxo.add_coin(Outpoint(prev, 0), Coin(100'000, {}, 1, false)); + + dash::interfaces::Node node; + NodeCoinState st; + st.mempool().set_utxo(&utxo); + CoinStateMaintainer m(st); + + auto sub = wire_mempool_ingest(node, m); + ASSERT_TRUE(sub) << "wire must return a live subscription handle"; + ASSERT_EQ(st.mempool().size(), 0u); + + // Fire the reception event (NOT a direct maintainer poke). + node.new_tx.happened(Transaction(make_spend(prev, 0, 90'000, /*salt=*/1))); + + EXPECT_EQ(st.mempool().size(), 1u) << "new_tx relay must fold into the mempool"; +} + +// ════════════════════════════════════════════════════════════════════════ +// Disposing the handle tears the subscription down: later relays are dropped. +// ════════════════════════════════════════════════════════════════════════ +TEST(DashReceptionWire, DisposeStopsIngest) { + UTXOViewCache utxo(nullptr); + const uint256 a = raw256(0x77); + const uint256 b = raw256(0x66); + utxo.add_coin(Outpoint(a, 0), Coin(100'000, {}, 1, false)); + utxo.add_coin(Outpoint(b, 0), Coin(100'000, {}, 1, false)); + + dash::interfaces::Node node; + NodeCoinState st; + st.mempool().set_utxo(&utxo); + CoinStateMaintainer m(st); + + auto sub = wire_mempool_ingest(node, m); + node.new_tx.happened(Transaction(make_spend(a, 0, 90'000, /*salt=*/1))); + ASSERT_EQ(st.mempool().size(), 1u); + + sub->dispose(); + node.new_tx.happened(Transaction(make_spend(b, 0, 90'000, /*salt=*/2))); + EXPECT_EQ(st.mempool().size(), 1u) << "after dispose, no further relay may fold"; +} + +// ════════════════════════════════════════════════════════════════════════ +// A relayed tx reaches the assembled embedded template once MN+tip arm the +// bundle; an on_invalidate() reorg demotes back to the retained dashd fallback. +// (MN/tip legs are still poked directly -- only new_tx is wired in this slice.) +// ════════════════════════════════════════════════════════════════════════ +TEST(DashReceptionWire, RelayedTxReachesEmbeddedTemplateThenInvalidateDemotes) { + UTXOViewCache utxo(nullptr); + const uint256 prev = raw256(0x77); + utxo.add_coin(Outpoint(prev, 0), Coin(100'000, {}, 1, false)); + + dash::interfaces::Node node; + NodeCoinState st; + st.mempool().set_utxo(&utxo); + CoinStateMaintainer m(st); + auto sub = wire_mempool_ingest(node, m); + + // Reception leg feeds the mempool; MN + tip arm the bundle. + node.new_tx.happened(Transaction(make_spend(prev, 0, 90'000, /*salt=*/1))); // fee 10'000 + 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()); + + 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"; + EXPECT_EQ(sel.work.m_height, H); + ASSERT_EQ(st.mempool().size(), 1u); + EXPECT_EQ(sel.work.m_tx_hashes.size(), 1u) + << "the RELAYED tx must reach the assembled embedded template"; + + // Reorg: on_invalidate drops the tip -> next get_work falls back to dashd. + m.on_invalidate(); + EXPECT_FALSE(m.live()); + bool fb2 = false; + WorkSelection sel2 = st.select_work([&]() { fb2 = true; return dash::coin::DashWorkData{}; }); + EXPECT_EQ(sel2.source, WorkSource::DashdFallback); + EXPECT_TRUE(fb2) << "after invalidate, the retained dashd fallback must run"; +} + +// ════════════════════════════════════════════════════════════════════════ +// Leg 2: a new_tip advance fired on the interface arms the maintainer tip- +// readiness THROUGH THE WIRE -- no direct on_new_tip() poke. A tip arriving +// before the first mnlistdiff must NOT go live (MN list absent); once the MN +// list seeds the other prerequisite the bundle arms and select_work flips to +// the embedded arm, and the WIRED tip params reach the assembled template. +// ════════════════════════════════════════════════════════════════════════ +TEST(DashReceptionWire, NewTipRelayArmsBundleOnceMnSeeded) { + UTXOViewCache utxo(nullptr); + dash::interfaces::Node node; + NodeCoinState st; + st.mempool().set_utxo(&utxo); + CoinStateMaintainer m(st); + + auto sub = wire_tip_ingest(node, m); + ASSERT_TRUE(sub) << "wire must return a live subscription handle"; + + // Tip arrives first (reception is async): tip-readiness is set via the + // wire, but the MN list is still empty, so the bundle stays on dashd. + dash::interfaces::TipAdvance t; + t.prev_height = H - 1; t.prev_hash = PREV_HASH; t.bits_for_next = BITS; + t.mtp_at_tip = MTP; t.address_version = DASH_PUBKEY_VER; + t.address_p2sh_version = DASH_P2SH_VER; t.curtime = CURTIME; t.version = VERSION; + node.new_tip.happened(t); + EXPECT_FALSE(m.live()) << "tip alone must not arm the bundle -- MN list absent"; + + // MN list seeds the second prerequisite -> bundle arms, embedded arm wins. + m.on_mn_list_update(single_mn(p2pkh_script(0x30))); + ASSERT_TRUE(m.live()) << "tip (via wire) + MN 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"; + EXPECT_EQ(sel.work.m_height, H) << "the WIRED tip params must reach the template"; +} + +// ════════════════════════════════════════════════════════════════════════ +// Disposing the tip handle tears the subscription down: a later tip advance is +// not applied, so a post-reorg bundle cannot silently re-arm off a stale feed. +// ════════════════════════════════════════════════════════════════════════ +TEST(DashReceptionWire, DisposeStopsTipIngest) { + UTXOViewCache utxo(nullptr); + dash::interfaces::Node node; + NodeCoinState st; + st.mempool().set_utxo(&utxo); + CoinStateMaintainer m(st); + + // MN present up front; the tip is the gating event under test. + m.on_mn_list_update(single_mn(p2pkh_script(0x30))); + auto sub = wire_tip_ingest(node, m); + sub->dispose(); + + dash::interfaces::TipAdvance t; + t.prev_height = H - 1; t.prev_hash = PREV_HASH; t.bits_for_next = BITS; + t.mtp_at_tip = MTP; t.address_version = DASH_PUBKEY_VER; + t.address_p2sh_version = DASH_P2SH_VER; t.curtime = CURTIME; t.version = VERSION; + node.new_tip.happened(t); + EXPECT_FALSE(m.live()) << "after dispose, a tip advance must not arm the bundle"; +}