From 5071ec65673ec52d53f320e4972ac35be13a3979 Mon Sep 17 00:00:00 2001 From: frstrtr Date: Thu, 25 Jun 2026 01:13:17 +0000 Subject: [PATCH 1/2] =?UTF-8?q?dash(s8):=20broadcaster=5Ffull=20won-block?= =?UTF-8?q?=20keystone=20=E2=80=94=20dual-arm=20relay=20(#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresh-branch re-cut of the broadcaster_full keystone onto current master (supersedes #456, whose stale multi-coin base would revert landed btc/dgb/web work on merge). Dash-lane-only: embedded-P2P + dashd submitblock dual-arm won-block broadcaster, 7/7 KATs. src/impl/dash/broadcaster_full.hpp — dual-arm keystone test/test_dash_broadcaster_full.cpp — 7/7 won-block KATs test/CMakeLists.txt — test target registration .github/workflows/build.yml — CI allowlist (test_dash_broadcaster_full) --- .github/workflows/build.yml | 4 +- src/impl/dash/broadcaster_full.hpp | 179 ++++++++++++++++++++++ test/CMakeLists.txt | 10 ++ test/test_dash_broadcaster_full.cpp | 229 ++++++++++++++++++++++++++++ 4 files changed, 420 insertions(+), 2 deletions(-) create mode 100644 src/impl/dash/broadcaster_full.hpp create mode 100644 test/test_dash_broadcaster_full.cpp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9d71bd27f..47a7f4240 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -72,7 +72,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_share_hash_link test_dash_block_relay test_dash_rpc_request test_dash_rpc_conf test_dash_block_producer \ + 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_multiaddress_pplns test_pplns_stress \ test_hash_link test_decay_pplns \ test_pplns_consensus \ @@ -205,7 +205,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_share_hash_link test_dash_block_relay test_dash_rpc_request test_dash_rpc_conf test_dash_block_producer \ + 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_hash_link test_decay_pplns \ test_pplns_consensus \ test_v36_script_sorting test_v36_cross_impl_refhash \ diff --git a/src/impl/dash/broadcaster_full.hpp b/src/impl/dash/broadcaster_full.hpp new file mode 100644 index 000000000..bc66843ee --- /dev/null +++ b/src/impl/dash/broadcaster_full.hpp @@ -0,0 +1,179 @@ +#pragma once + +// Phase S8 — DashBroadcasterFull: the won-block KEYSTONE (dual-path #83). +// +// One rung above the DashBroadcaster peer-pool LEAF (broadcaster.hpp, #405): +// +// p2p_connection -> p2p_node -> broadcaster -> [broadcaster_full] +// +// The leaf is a PURE peer-pool + discovery + fan-out *scaffold* whose +// submit_block_raw_all() only iterates the LIVE slots invoking an injectable +// no-op hook. This keystone closes the loop: it routes a WON block to the +// network over BOTH arms of the dual-path gate every other coin already passes: +// +// ARM A — EMBEDDED P2P FAN-OUT +// on_block_found() drives the leaf's submit_block_raw_all(), whose fan-out +// hook is wired here to the per-slot submit seam (m_peer_submit). The block +// is pushed in parallel to every LIVE dashd P2P slot for low-latency +// propagation below what a single submitblock can reach. +// +// ARM B — submitblock RPC FALLBACK (dashd authoritative; NEVER removed) +// on_block_found() also hands the packed block to the injected submitblock +// RPC callback (m_rpc_submit), so the block reaches the network through the +// local dashd even when the embedded peer-pool is empty/cold. This arm is +// the standing safety net per the dashd-RPC-fallback invariant: it is +// attempted whenever configured, independent of the embedded arm. +// +// reached_network() is TRUE iff at least one arm placed the block on the wire +// (>=1 embedded peer reached OR the RPC submit succeeded). A won block with +// zero live peers AND no RPC arm returns reached_network()==false — the caller +// MUST treat that as "block not relayed" rather than silently dropping it. +// +// 3-WAY RECONCILE NOTE (honest): the dash-spv-embedded reference +// broadcaster_full.hpp ports c2pool-ltc's CoinBroadcaster verbatim and depends +// on c2pool/merged/coin_peer_manager.hpp, coin/node_interface.hpp, +// coin/transaction.hpp, coin/block.hpp and NodeP2P::submit_block — NONE of +// which are on master. A wholesale copy would (1) fail to compile, (2) drag in +// the shared merged/ tree, breaking the single-coin DASH isolation fence, and +// (3) regress the newer master broadcaster.hpp(#405)/p2p_node/config. So this +// keystone is reconciled against what master ACTUALLY carries: it composes the +// #405 DashBroadcaster leaf's FanOutHook/submit_block_raw_all seam (embedded +// arm) and an injectable submitblock-RPC callback (dashd arm). The concrete +// per-slot NodeP2P::submit_block_raw and the live getpeerinfo RPC tick land +// when those node methods exist on master; the seams here are exactly where +// they bolt in. Header-only, single dash tree, NO src/core / NO merged/ adds. + +#include "broadcaster.hpp" +#include "config.hpp" + +#include +#include +#include +#include + +#include + +namespace dash +{ + +// Won-block keystone: wraps the DashBroadcaster peer-pool and routes a found +// block to the network over the embedded-P2P and submitblock-RPC arms. +class DashBroadcasterFull +{ +public: + // Per-slot block submit (embedded arm). Pushes the packed block to ONE live + // dashd P2P slot. Defaults to a no-op: the concrete NodeP2P::submit_block_raw + // lands on master separately; this seam is where it bolts in. Tests inject a + // capturing fn to prove each live peer received the exact bytes. + using PeerSubmitFn = + std::function /*block_bytes*/)>; + + // submitblock RPC fallback (dashd arm). Returns true iff dashd accepted the + // block. Defaults unset; when unset the RPC arm is simply not attempted (the + // dashd-RPC fallback is opt-in by wiring, never removed). main_dash wires + // this to the NodeRPC submitblock call. + using RpcSubmitFn = std::function; + + // Outcome of a won-block relay attempt across both arms. + struct Outcome + { + size_t peers_reached{0}; // live embedded P2P slots the block fanned to + bool rpc_attempted{false}; + bool rpc_submitted{false}; // dashd accepted via submitblock + + // The gate predicate: did the won block actually reach the network on + // AT LEAST ONE arm? + bool reached_network() const { return peers_reached > 0 || rpc_submitted; } + }; + + explicit DashBroadcasterFull(DashBroadcaster* pool) + : m_pool(pool) + , m_peer_submit( + [](dash::coin::p2p::NodeP2P&, std::span) {}) + { + // Wire the leaf's fan-out hook to OUR per-slot submit seam so that + // submit_block_raw_all() actually pushes bytes to live peers instead of + // running the leaf's default no-op. + if (m_pool) + m_pool->set_fan_out_hook( + [this](dash::coin::p2p::NodeP2P& slot, + std::span bytes) { + m_peer_submit(slot, bytes); + }); + } + + // ── injection seams (main_dash / tests) ────────────────────────────── + + void set_peer_submit(PeerSubmitFn f) + { + m_peer_submit = std::move(f); + // Re-bind the leaf hook so it forwards to the updated submit fn. + if (m_pool) + m_pool->set_fan_out_hook( + [this](dash::coin::p2p::NodeP2P& slot, + std::span bytes) { + m_peer_submit(slot, bytes); + }); + } + + void set_rpc_submit(RpcSubmitFn f) { m_rpc_submit = std::move(f); } + + bool has_rpc_arm() const { return static_cast(m_rpc_submit); } + + // ── the won-block path ──────────────────────────────────────────────── + + // Relay a WON block (already packed to wire bytes by submit_validator) to + // the network over BOTH arms: + // ARM A: fan out to every live embedded P2P peer (parallel propagation). + // ARM B: hand to dashd via submitblock RPC (authoritative; if wired). + // Both arms are attempted independently — the RPC fallback fires whenever it + // is configured, NOT only when the peer-pool is empty, so a cold pool can + // never silence the authoritative dashd path. Returns the per-arm Outcome. + Outcome on_block_found(std::span block_bytes) + { + Outcome out; + + // ARM A — embedded P2P fan-out via the leaf scaffold. + if (m_pool) + out.peers_reached = m_pool->submit_block_raw_all(block_bytes); + + // ARM B — submitblock RPC fallback (dashd authoritative). + if (m_rpc_submit) { + out.rpc_attempted = true; + out.rpc_submitted = m_rpc_submit(to_hex(block_bytes)); + } + + if (out.reached_network()) + LOG_INFO << "[DashBroadcastFull] won block relayed: peers=" + << out.peers_reached + << " rpc=" << (out.rpc_submitted ? "ok" : "no"); + else + LOG_WARNING << "[DashBroadcastFull] won block NOT relayed — " + "no live peers and no RPC arm! block bytes=" + << block_bytes.size(); + return out; + } + + // Lowercase hex of the packed block, as dashd submitblock expects. + static std::string to_hex(std::span bytes) + { + static const char* k = "0123456789abcdef"; + std::string s; + s.reserve(bytes.size() * 2); + for (unsigned char b : bytes) { + s.push_back(k[b >> 4]); + s.push_back(k[b & 0x0f]); + } + return s; + } + + DashBroadcaster* pool() const { return m_pool; } + +private: + DashBroadcaster* m_pool{}; + PeerSubmitFn m_peer_submit; + RpcSubmitFn m_rpc_submit; +}; + +} // namespace dash diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 508645478..ecbaf3670 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -478,6 +478,16 @@ if (BUILD_TESTING AND GTest_FOUND) nlohmann_json::nlohmann_json) gtest_add_tests(test_dash_rpc_request "" AUTO) + add_executable(test_dash_broadcaster_full test_dash_broadcaster_full.cpp) + target_link_libraries(test_dash_broadcaster_full PRIVATE + GTest::gtest_main GTest::gtest + dash_x11 core + nlohmann_json::nlohmann_json + ${Boost_LIBRARIES} + ) + target_link_libraries(test_dash_broadcaster_full PRIVATE c2pool_payout c2pool_hashrate c2pool_merged_mining) # OBJECT-lib SCC direct-naming (#22/#39) + gtest_add_tests(test_dash_broadcaster_full "" AUTO) + add_executable(test_dash_block_relay test_dash_block_relay.cpp) target_link_libraries(test_dash_block_relay PRIVATE GTest::gtest_main GTest::gtest diff --git a/test/test_dash_broadcaster_full.cpp b/test/test_dash_broadcaster_full.cpp new file mode 100644 index 000000000..d9fa6f33f --- /dev/null +++ b/test/test_dash_broadcaster_full.cpp @@ -0,0 +1,229 @@ +/// Phase S8 — DashBroadcasterFull won-block KEYSTONE KATs (dual-path #83). +/// +/// Exercises src/impl/dash/broadcaster_full.hpp — the keystone that routes a +/// WON block to the network over BOTH arms of the dual-path gate: +/// +/// ARM A — embedded P2P fan-out (DashBroadcaster::submit_block_raw_all) +/// ARM B — submitblock RPC fallback (dashd authoritative; never removed) +/// +/// The gate the integrator verifies: won-block-reaches-network must be proven +/// on EACH arm INDEPENDENTLY (not a single path exercised, no hollow pass). So: +/// +/// (1) EmbeddedArmReachesEachLivePeer — N live slots, NO rpc arm: on_block_found +/// fans the EXACT block bytes to every live peer; reached_network() true. +/// (2) EmbeddedArmSkipsDeadPeers — fan-out hits only LIVE slots. +/// (3) RpcArmReachesNetworkAlone — ZERO live peers, rpc arm wired: +/// on_block_found relays via submitblock with correct hex; true. +/// (4) RpcArmAcceptReject — rpc_submitted tracks dashd's accept/reject. +/// (5) DualArmBothFireIndependently — N live peers AND rpc wired: BOTH arms +/// fire (peers_reached==N AND rpc_submitted) — neither suppresses the other. +/// (6) NoArmIsNotReached — zero peers AND no rpc: reached_network() +/// false (a won block with no network path must NOT silently pass). +/// (7) HexEncodingMatchesDashd — to_hex() byte-parity for submitblock. +/// +/// Sockets are never opened: the slot factory builds bare NodeP2P, liveness is +/// injected, the per-slot submit and the RPC submit are injected capturing fns. + +#include + +#include +#include +#include +#include + +#include + +#include +#include +#include + +using namespace dash; +using nlohmann::json; + +namespace { + +constexpr uint16_t kCanonicalPort = 19999; +constexpr char kPrimaryHost[] = "10.9.9.9"; + +std::unique_ptr make_config() +{ + auto cfg = std::make_unique("dash-s8-broadcaster-full-kat"); + cfg->coin()->m_p2p.address = + NetService{std::string{"127.0.0.1"}, std::to_string(kCanonicalPort)}; + return cfg; +} + +json peer(const std::string& addr) +{ + json p = json::object(); + p["addr"] = addr; + return p; +} + +// Build a leaf broadcaster with a bare-NodeP2P factory and an injected liveness +// predicate, then populate it with `live` live slots + `dead` dead slots via a +// discovery pass. Returns the populated leaf. +struct PoolEnv { + boost::asio::io_context ioc; + std::unique_ptr cfg{make_config()}; + bool all_live{true}; + std::unique_ptr pool; + + explicit PoolEnv(size_t max_peers = 16) + { + pool = std::make_unique( + &ioc, cfg.get(), + NetService{std::string{kPrimaryHost}, std::to_string(kCanonicalPort)}, + max_peers); + pool->set_slot_factory([this](const NetService&) { + return std::make_unique(&ioc); + }); + pool->set_live_predicate( + [this](const dash::coin::p2p::NodeP2P&) { return all_live; }); + } + + // Dial N peers (all live by construction). + void dial(size_t n) + { + json peers = json::array(); + for (size_t i = 0; i < n; ++i) + peers.push_back(peer("10.0.0." + std::to_string(i + 1) + ":19999")); + size_t got = pool->discover(peers); + ASSERT_EQ(got, n); + } +}; + +const std::vector kBlock = {0xde, 0xad, 0xbe, 0xef, 0x01, 0x02}; + +} // namespace + +// (1) Embedded arm alone reaches EVERY live peer with the exact bytes. +TEST(DashBroadcasterFull, EmbeddedArmReachesEachLivePeer) +{ + PoolEnv env; + env.dial(3); + + DashBroadcasterFull full{env.pool.get()}; + + // Capture every per-slot submit (proves fan-out actually delivered bytes). + size_t calls = 0; + std::vector captured; + full.set_peer_submit([&](dash::coin::p2p::NodeP2P&, + std::span b) { + ++calls; + captured.assign(b.begin(), b.end()); // same bytes each call + }); + + ASSERT_FALSE(full.has_rpc_arm()); // embedded arm in isolation + + auto out = full.on_block_found(kBlock); + + EXPECT_EQ(out.peers_reached, 3u); + EXPECT_EQ(calls, 3u); + EXPECT_EQ(captured, kBlock); // exact bytes reached the wire + EXPECT_FALSE(out.rpc_attempted); + EXPECT_TRUE(out.reached_network()); // won block reached network via P2P +} + +// (2) Embedded arm fans out to LIVE slots only. +TEST(DashBroadcasterFull, EmbeddedArmSkipsDeadPeers) +{ + PoolEnv env; + env.dial(2); + + DashBroadcasterFull full{env.pool.get()}; + size_t calls = 0; + full.set_peer_submit( + [&](dash::coin::p2p::NodeP2P&, std::span) { ++calls; }); + + env.all_live = false; // every slot now reads dead + + auto out = full.on_block_found(kBlock); + EXPECT_EQ(out.peers_reached, 0u); + EXPECT_EQ(calls, 0u); + EXPECT_FALSE(out.reached_network()); // no live peer, no rpc -> not relayed +} + +// (3) RPC fallback alone (zero live peers) reaches the network. +TEST(DashBroadcasterFull, RpcArmReachesNetworkAlone) +{ + PoolEnv env; // NO peers dialed -> embedded arm is cold + + DashBroadcasterFull full{env.pool.get()}; + + std::string seen_hex; + full.set_rpc_submit([&](const std::string& hex) { + seen_hex = hex; + return true; // dashd accepted + }); + + ASSERT_TRUE(full.has_rpc_arm()); + + auto out = full.on_block_found(kBlock); + + EXPECT_EQ(out.peers_reached, 0u); // embedded arm empty + EXPECT_TRUE(out.rpc_attempted); + EXPECT_TRUE(out.rpc_submitted); + EXPECT_EQ(seen_hex, "deadbeef0102"); // correct submitblock payload + EXPECT_TRUE(out.reached_network()); // won block reached via dashd +} + +// (4) RPC arm propagates dashd's accept/reject verdict. +TEST(DashBroadcasterFull, RpcArmAcceptReject) +{ + PoolEnv env; + DashBroadcasterFull full{env.pool.get()}; + + full.set_rpc_submit([&](const std::string&) { return false; }); // dashd rejected + auto out = full.on_block_found(kBlock); + EXPECT_TRUE(out.rpc_attempted); + EXPECT_FALSE(out.rpc_submitted); + EXPECT_FALSE(out.reached_network()); // rejected + no peers -> not relayed +} + +// (5) Dual-arm: BOTH arms fire independently; neither suppresses the other. +TEST(DashBroadcasterFull, DualArmBothFireIndependently) +{ + PoolEnv env; + env.dial(4); + + DashBroadcasterFull full{env.pool.get()}; + size_t peer_calls = 0; + bool rpc_called = false; + full.set_peer_submit( + [&](dash::coin::p2p::NodeP2P&, std::span) { ++peer_calls; }); + full.set_rpc_submit([&](const std::string& hex) { + rpc_called = true; + EXPECT_EQ(hex, "deadbeef0102"); + return true; + }); + + auto out = full.on_block_found(kBlock); + + EXPECT_EQ(out.peers_reached, 4u); // ARM A fired to all live peers + EXPECT_EQ(peer_calls, 4u); + EXPECT_TRUE(out.rpc_submitted); // ARM B fired too (not gated on empty pool) + EXPECT_TRUE(rpc_called); + EXPECT_TRUE(out.reached_network()); +} + +// (6) No path at all: a won block with no live peers and no RPC is NOT reached. +TEST(DashBroadcasterFull, NoArmIsNotReached) +{ + PoolEnv env; // no peers + DashBroadcasterFull full{env.pool.get()}; + ASSERT_FALSE(full.has_rpc_arm()); + + auto out = full.on_block_found(kBlock); + EXPECT_EQ(out.peers_reached, 0u); + EXPECT_FALSE(out.rpc_attempted); + EXPECT_FALSE(out.reached_network()); // gate must NOT silently pass +} + +// (7) to_hex byte-parity for the submitblock payload. +TEST(DashBroadcasterFull, HexEncodingMatchesDashd) +{ + std::vector b = {0x00, 0x0f, 0xff, 0x10, 0xa5}; + EXPECT_EQ(DashBroadcasterFull::to_hex(b), "000fff10a5"); + EXPECT_EQ(DashBroadcasterFull::to_hex({}), ""); +} From 1334f73e13766d194ac72a57011317246676f823 Mon Sep 17 00:00:00 2001 From: frstrtr Date: Thu, 25 Jun 2026 04:48:00 +0000 Subject: [PATCH 2/2] =?UTF-8?q?dash(s8):=20guard=20dual-path=20won-block?= =?UTF-8?q?=20legs=20=E2=80=94=20throwing=20arm=20never=20drops=20a=20won?= =?UTF-8?q?=20block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit on_block_found wraps each arm of the dual-path relay in its own try/catch so a fault in one leg can never silently drop a won block AND skip its safety net: ARM A (embedded P2P fan-out): a throwing per-slot relay falls through to the ARM B submitblock RPC fallback; peers_reached stays 0 (fan-out never completed) so reached_network() reflects only the arm that actually landed. ARM B (submitblock RPC): a throwing RPC sink is treated as no-ack and does not mask an ARM A win already recorded (peers_reached stands). Mirror of NMC #468 / DGB #469 / BCH #471. Dash-only, header+test; no master or other-coin tree touched. +2 KATs (ThrowingEmbeddedArmStillFiresRpcFallback, ThrowingRpcDoesNotMaskEmbeddedWin); ctest 9/9 green Linux x86_64. --- src/impl/dash/broadcaster_full.hpp | 34 ++++++++++++++--- test/test_dash_broadcaster_full.cpp | 58 +++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/src/impl/dash/broadcaster_full.hpp b/src/impl/dash/broadcaster_full.hpp index bc66843ee..fc21cac15 100644 --- a/src/impl/dash/broadcaster_full.hpp +++ b/src/impl/dash/broadcaster_full.hpp @@ -47,6 +47,7 @@ #include "config.hpp" #include +#include #include #include #include @@ -134,14 +135,37 @@ class DashBroadcasterFull { Outcome out; - // ARM A — embedded P2P fan-out via the leaf scaffold. - if (m_pool) - out.peers_reached = m_pool->submit_block_raw_all(block_bytes); + // ARM A — embedded P2P fan-out via the leaf scaffold. GUARDED: a + // throwing per-slot relay must NOT prevent the ARM B submitblock RPC + // fallback below — otherwise a P2P-leg fault silently drops a won block + // AND skips its safety net. peers_reached stays 0 unless the fan-out + // returns cleanly. (Mirror of NMC #468 / DGB #469 / BCH #471.) + if (m_pool) { + try { + out.peers_reached = m_pool->submit_block_raw_all(block_bytes); + } catch (const std::exception& e) { + LOG_ERROR << "[DashBroadcastFull] ARM A (embedded P2P fan-out) threw (" + << e.what() << ") — falling through to submitblock RPC fallback."; + } catch (...) { + LOG_ERROR << "[DashBroadcastFull] ARM A (embedded P2P fan-out) threw " + "(non-std) — falling through to submitblock RPC fallback."; + } + } - // ARM B — submitblock RPC fallback (dashd authoritative). + // ARM B — submitblock RPC fallback (dashd authoritative). GUARDED: a + // throwing RPC sink must not propagate and must not mask an ARM A win + // already recorded — rpc_submitted stays false, peers_reached stands. if (m_rpc_submit) { out.rpc_attempted = true; - out.rpc_submitted = m_rpc_submit(to_hex(block_bytes)); + try { + out.rpc_submitted = m_rpc_submit(to_hex(block_bytes)); + } catch (const std::exception& e) { + LOG_ERROR << "[DashBroadcastFull] ARM B (submitblock RPC) threw (" + << e.what() << ") — treated as no-ack, ARM A win (if any) preserved."; + } catch (...) { + LOG_ERROR << "[DashBroadcastFull] ARM B (submitblock RPC) threw " + "(non-std) — treated as no-ack."; + } } if (out.reached_network()) diff --git a/test/test_dash_broadcaster_full.cpp b/test/test_dash_broadcaster_full.cpp index d9fa6f33f..2ae3ef371 100644 --- a/test/test_dash_broadcaster_full.cpp +++ b/test/test_dash_broadcaster_full.cpp @@ -26,6 +26,8 @@ #include +#include + #include #include #include @@ -227,3 +229,59 @@ TEST(DashBroadcasterFull, HexEncodingMatchesDashd) EXPECT_EQ(DashBroadcasterFull::to_hex(b), "000fff10a5"); EXPECT_EQ(DashBroadcasterFull::to_hex({}), ""); } + +// (8) ARM-A LEG GUARD: a throwing embedded P2P fan-out must NOT prevent the +// ARM B submitblock RPC fallback — the won block still reaches the network +// via dashd, peers_reached stays 0 (the fan-out never completed), +// reached_network() is true (NOT silent-dropped), and on_block_found does +// NOT propagate the throw. Mirror of NMC #468 / DGB #469 / BCH #471. +TEST(DashBroadcasterFull, ThrowingEmbeddedArmStillFiresRpcFallback) +{ + PoolEnv env; + env.dial(3); + + DashBroadcasterFull full{env.pool.get()}; + full.set_peer_submit([&](dash::coin::p2p::NodeP2P&, + std::span) { + throw std::runtime_error("embedded peer socket reset mid-fanout"); + }); + bool rpc_called = false; + full.set_rpc_submit([&](const std::string& hex) { + rpc_called = true; + EXPECT_EQ(hex, "deadbeef0102"); + return true; // dashd accepted + }); + + DashBroadcasterFull::Outcome out; + EXPECT_NO_THROW(out = full.on_block_found(kBlock)); + EXPECT_EQ(out.peers_reached, 0u); // ARM A threw -> never counted reached + EXPECT_TRUE(out.rpc_attempted); + EXPECT_TRUE(out.rpc_submitted); // ARM B safety net STILL fired + EXPECT_TRUE(rpc_called); + EXPECT_TRUE(out.reached_network()); // won block was NOT silent-dropped +} + +// (9) ARM-B LEG GUARD: a throwing submitblock RPC sink must not propagate and +// must not mask an ARM A win already recorded — rpc_submitted stays false, +// the P2P win stands (peers_reached==N, reached_network() true). +TEST(DashBroadcasterFull, ThrowingRpcDoesNotMaskEmbeddedWin) +{ + PoolEnv env; + env.dial(2); + + DashBroadcasterFull full{env.pool.get()}; + size_t peer_calls = 0; + full.set_peer_submit( + [&](dash::coin::p2p::NodeP2P&, std::span) { ++peer_calls; }); + full.set_rpc_submit([&](const std::string&) -> bool { + throw std::runtime_error("submitblock client socket reset"); + }); + + DashBroadcasterFull::Outcome out; + EXPECT_NO_THROW(out = full.on_block_found(kBlock)); + EXPECT_EQ(out.peers_reached, 2u); // ARM A win stands + EXPECT_EQ(peer_calls, 2u); + EXPECT_TRUE(out.rpc_attempted); + EXPECT_FALSE(out.rpc_submitted); // ARM B threw -> treated as no-ack + EXPECT_TRUE(out.reached_network()); // P2P win not masked +}