diff --git a/src/impl/btc/coin/block_broadcast.hpp b/src/impl/btc/coin/block_broadcast.hpp index 1c5dc973a..94c4932e5 100644 --- a/src/impl/btc/coin/block_broadcast.hpp +++ b/src/impl/btc/coin/block_broadcast.hpp @@ -21,14 +21,35 @@ namespace coin // Returns true iff the block reached AT LEAST ONE sink. A false return means // the block reached NEITHER sink: the caller MUST treat that as a lost // subsidy and scream (never silent-drop a won block). +// +// BOTH legs are guarded so a throwing sink can never bypass the fallback or +// escape this function: a THROWING relay_p2p() is a relay-FAILED mode, not a +// reason to skip the fallback -- without the guard the exception would unwind +// PAST this function, bypassing the submitblock fallback and silently dropping +// the won block (the exact hole the fallback exists to close). A throwing +// submit_rpc() (last-resort sink) collapses to a definite false return so the +// caller's documented "reached neither sink, scream" path fires instead of an +// exception escaping the won-block handler. inline bool broadcast_block_with_fallback( const std::function& relay_p2p, const std::function& submit_rpc) { - if (relay_p2p && relay_p2p()) + bool p2p_ok = false; + try { + p2p_ok = (relay_p2p && relay_p2p()); + } catch (...) { + p2p_ok = false; // relay threw -> treat as relay-failed + } + if (p2p_ok) return true; // primary succeeded -> no double-broadcast - if (submit_rpc) - return submit_rpc(); // fallback path + + if (submit_rpc) { + try { + return submit_rpc(); // fallback path + } catch (...) { + return false; // last-resort sink threw -> caller screams + } + } return false; // reached neither sink } diff --git a/src/impl/btc/test/CMakeLists.txt b/src/impl/btc/test/CMakeLists.txt index fafd20db6..e6642ebd2 100644 --- a/src/impl/btc/test/CMakeLists.txt +++ b/src/impl/btc/test/CMakeLists.txt @@ -1,7 +1,7 @@ if (BUILD_TESTING AND GTest_FOUND) # btc twin of ltc share_test — uniquely named to avoid the CMP0002 target # collision with src/impl/ltc/test (both subdirs build in the same tree). - add_executable(btc_share_test share_test.cpp f11_donation_invariance_test.cpp f10b_version_punish_removal_test.cpp g01_share_format_parity_test.cpp auto_ratchet_sim_test.cpp) + add_executable(btc_share_test share_test.cpp f11_donation_invariance_test.cpp f10b_version_punish_removal_test.cpp g01_share_format_parity_test.cpp auto_ratchet_sim_test.cpp block_broadcast_guard_test.cpp) target_link_libraries(btc_share_test PRIVATE GTest::gtest_main GTest::gtest core btc diff --git a/src/impl/btc/test/block_broadcast_guard_test.cpp b/src/impl/btc/test/block_broadcast_guard_test.cpp new file mode 100644 index 000000000..8d96856f0 --- /dev/null +++ b/src/impl/btc/test/block_broadcast_guard_test.cpp @@ -0,0 +1,91 @@ +// --------------------------------------------------------------------------- +// btc::coin::broadcast_block_with_fallback dual-path guard KATs. +// +// Offline contract slice locking the won-block fallback orchestration the BTC +// run-loop depends on: P2P relay is PRIMARY, submitblock RPC is the FALLBACK +// (fired ONLY when P2P is unavailable / relay-failed -- deliberately NOT +// always-both). The function's doc-comment promises a false return is the +// ONLY "reached neither sink" signal and that a won block is NEVER +// silent-dropped. A THROWING relay_p2p() sink violated that promise: the +// exception unwound PAST the function, bypassing the submitblock fallback +// entirely and silently dropping the won block -- the exact hole the fallback +// exists to close. These KATs lock BOTH legs guarded (mirror of the NMC #468 +// leg-guard fix in nmc_block_broadcast_test.cpp). +// +// Rides the already-allowlisted btc_share_test executable, so no build.yml +// --target allowlist change is needed and there is no #137-style NOT_BUILT +// sentinel risk. p2pool-merged-v36 surface: NONE. +// --------------------------------------------------------------------------- + +#include + +#include + +#include "../coin/block_broadcast.hpp" + +// 1) Primary succeeds -> true, and the submitblock fallback is NOT invoked +// (the deliberate no-double-broadcast rule). +TEST(BtcBlockBroadcastGuard, P2pSuccessSkipsFallback) { + bool rpc_called = false; + auto relay = [] { return true; }; + auto submit = [&] { rpc_called = true; return true; }; + + EXPECT_TRUE(btc::coin::broadcast_block_with_fallback(relay, submit)); + EXPECT_FALSE(rpc_called); // primary won -> no double submit +} + +// 2) Primary relay-fails (returns false) -> submitblock fallback fires. +TEST(BtcBlockBroadcastGuard, P2pFalseFiresFallback) { + int rpc_calls = 0; + auto relay = [] { return false; }; + auto submit = [&] { ++rpc_calls; return true; }; + + EXPECT_TRUE(btc::coin::broadcast_block_with_fallback(relay, submit)); + EXPECT_EQ(rpc_calls, 1); +} + +// 3) PRIMARY-LEG GUARD (the fix): a THROWING relay sink is a relay-failed mode, +// not a reason to skip the fallback. The submitblock RPC STILL fires, the +// won block reaches the node, and the dispatcher does NOT propagate the +// throw. Before the guard this exception unwound past the function and the +// block was silently dropped. +TEST(BtcBlockBroadcastGuard, ThrowingP2pStillFiresFallback) { + int rpc_calls = 0; + auto relay = []() -> bool { throw std::runtime_error("p2p down mid-relay"); }; + auto submit = [&] { ++rpc_calls; return true; }; + + bool ok = false; + EXPECT_NO_THROW(ok = btc::coin::broadcast_block_with_fallback(relay, submit)); + EXPECT_TRUE(ok); // fallback carried the won block + EXPECT_EQ(rpc_calls, 1); // fallback fired despite the throw +} + +// 4) Throwing primary with NO fallback sink -> definite false (reached neither +// sink, the caller's documented "scream / lost-subsidy" path), never a throw. +TEST(BtcBlockBroadcastGuard, ThrowingP2pNoFallbackReturnsFalse) { + auto relay = []() -> bool { throw std::runtime_error("p2p down"); }; + + bool ok = true; + EXPECT_NO_THROW(ok = btc::coin::broadcast_block_with_fallback(relay, /*submit_rpc=*/{})); + EXPECT_FALSE(ok); +} + +// 5) FALLBACK-LEG GUARD: when submitblock itself throws there is no further +// sink, so the throw collapses to a definite false return -> the caller's +// "reached neither sink, scream" contract fires instead of an exception +// escaping the won-block handler. +TEST(BtcBlockBroadcastGuard, ThrowingFallbackReturnsFalse) { + auto relay = [] { return false; }; + auto submit = []() -> bool { throw std::runtime_error("submitblock rpc threw"); }; + + bool ok = true; + EXPECT_NO_THROW(ok = btc::coin::broadcast_block_with_fallback(relay, submit)); + EXPECT_FALSE(ok); +} + +// 6) Neither sink live -> safe false no-op (no throw, no silent-drop). +TEST(BtcBlockBroadcastGuard, NoSinkIsSafeFalse) { + bool ok = true; + EXPECT_NO_THROW(ok = btc::coin::broadcast_block_with_fallback({}, {})); + EXPECT_FALSE(ok); +}