diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4774eec8d..302ae43ec 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -81,7 +81,7 @@ jobs: test_mweb_builder \ test_address_resolution test_compute_share_target \ test_utxo test_dgb_subsidy test_dgb_coinbase_value dgb_share_test dgb_redistribute_test dgb_block_assembly_test dgb_witness_commitment_test dgb_header_sample_build_test dgb_header_ingest_test dgb_mempool_ingest_test \ - dgb_gentx_coinbase_test dgb_connection_coinbase_test dgb_pplns_payout_split_test nmc_auxpow_merkle_test nmc_template_builder_test nmc_auxpow_wire_test nmc_reconstruct_won_block_test nmc_mempool_name_test nmc_block_broadcast_test dgb_gentx_share_path_test dgb_conn_pplns_producer_test dgb_other_tx_resolver_test \ + dgb_gentx_coinbase_test dgb_connection_coinbase_test dgb_pplns_payout_split_test nmc_auxpow_merkle_test nmc_template_builder_test nmc_auxpow_wire_test nmc_reconstruct_won_block_test nmc_mempool_name_test nmc_block_broadcast_test nmc_host_dualpath_test dgb_gentx_share_path_test dgb_conn_pplns_producer_test dgb_other_tx_resolver_test \ dgb_other_tx_assembler_test dgb_reconstruct_won_block_test dgb_reconstruct_closure_test dgb_gentx_unpack_test dgb_work_source_test dgb_template_builder_test dgb_embedded_coin_node_test dgb_embedded_tx_select_test dgb_template_other_txs_test dgb_coinbase_value_parity_test dgb_submit_classify_test dgb_aux_parent_coinbase_parity_test dgb_template_capture_test dgb_aux_doge_db_commitment_bind_test dgb_aux_doge_dc_proof_test dgb_aux_doge_bind_parsers_test dgb_compact_blocks_bip152_parity_test \ rpc_request_test softfork_check_test genesis_check_test algo_select_test digishield_walk_test header_chain_test \ dgb_coin_node_seam_test dgb_block_broadcast_test dgb_won_block_dispatch_test dgb_forced_won_share_dualpath_test dgb_scrypt_pow_test dgb_nonce_grinder_test dgb_regrind_block_test dgb_won_block_finalize_test dgb_share_target_genesis_test dgb_pool_msg_wire_test dgb_get_shares_walk_test dgb_download_stops_test dgb_think_p1_walk_bounds_test dgb_think_p1_desired_emit_test dgb_think_p6_desired_cutoff_test dgb_think_p4_head_keys_test dgb_think_p3_best_head_test dgb_g1_oracle_byte_parity_test dgb_think_p2_walk_bounds_test dgb_expected_time_to_block_test dgb_tail_score_endpoints_test dgb_pool_attempts_per_second_test dgb_pool_efficiency_test dgb_think_p5_best_share_punish_test dgb_auto_ratchet_tail_guard_test dgb_binomial_conf_interval_test dgb_desired_version_tally_test dgb_get_height_and_last_endpoints_test dgb_chain_walk_window_test dgb_redistribute_delegate_ghal_test dgb_share_weight_decay_test dgb_naughty_propagation_test dgb_hash_format_parity_test v37_test \ @@ -213,7 +213,7 @@ jobs: test_mweb_builder \ test_address_resolution test_compute_share_target \ test_utxo test_dgb_subsidy test_dgb_coinbase_value dgb_share_test dgb_redistribute_test dgb_block_assembly_test dgb_witness_commitment_test dgb_header_sample_build_test dgb_header_ingest_test dgb_mempool_ingest_test \ - dgb_gentx_coinbase_test dgb_connection_coinbase_test dgb_pplns_payout_split_test nmc_auxpow_merkle_test nmc_template_builder_test nmc_auxpow_wire_test nmc_reconstruct_won_block_test nmc_mempool_name_test nmc_block_broadcast_test dgb_gentx_share_path_test dgb_conn_pplns_producer_test dgb_other_tx_resolver_test \ + dgb_gentx_coinbase_test dgb_connection_coinbase_test dgb_pplns_payout_split_test nmc_auxpow_merkle_test nmc_template_builder_test nmc_auxpow_wire_test nmc_reconstruct_won_block_test nmc_mempool_name_test nmc_block_broadcast_test nmc_host_dualpath_test dgb_gentx_share_path_test dgb_conn_pplns_producer_test dgb_other_tx_resolver_test \ dgb_other_tx_assembler_test dgb_reconstruct_won_block_test dgb_reconstruct_closure_test dgb_gentx_unpack_test dgb_work_source_test dgb_template_builder_test dgb_embedded_coin_node_test dgb_embedded_tx_select_test dgb_template_other_txs_test dgb_coinbase_value_parity_test dgb_submit_classify_test dgb_aux_parent_coinbase_parity_test dgb_template_capture_test dgb_aux_doge_db_commitment_bind_test dgb_aux_doge_dc_proof_test dgb_aux_doge_bind_parsers_test dgb_compact_blocks_bip152_parity_test \ rpc_request_test softfork_check_test genesis_check_test algo_select_test digishield_walk_test header_chain_test \ dgb_coin_node_seam_test dgb_block_broadcast_test dgb_won_block_dispatch_test dgb_forced_won_share_dualpath_test dgb_scrypt_pow_test dgb_nonce_grinder_test dgb_regrind_block_test dgb_won_block_finalize_test dgb_share_target_genesis_test dgb_pool_msg_wire_test dgb_get_shares_walk_test dgb_download_stops_test dgb_think_p1_walk_bounds_test dgb_think_p1_desired_emit_test dgb_think_p6_desired_cutoff_test dgb_think_p4_head_keys_test dgb_think_p3_best_head_test dgb_g1_oracle_byte_parity_test dgb_think_p2_walk_bounds_test dgb_expected_time_to_block_test dgb_tail_score_endpoints_test dgb_pool_attempts_per_second_test dgb_pool_efficiency_test dgb_think_p5_best_share_punish_test dgb_auto_ratchet_tail_guard_test dgb_binomial_conf_interval_test dgb_desired_version_tally_test dgb_get_height_and_last_endpoints_test dgb_chain_walk_window_test dgb_redistribute_delegate_ghal_test dgb_share_weight_decay_test dgb_naughty_propagation_test dgb_hash_format_parity_test test_coin_broadcaster test_multiaddress_pplns test_pplns_stress \ diff --git a/src/impl/nmc/test/CMakeLists.txt b/src/impl/nmc/test/CMakeLists.txt index 3a581aa8e..fe6057148 100644 --- a/src/impl/nmc/test/CMakeLists.txt +++ b/src/impl/nmc/test/CMakeLists.txt @@ -80,6 +80,19 @@ if (BUILD_TESTING AND GTest_FOUND) target_link_libraries(nmc_block_broadcast_test PRIVATE c2pool_payout c2pool_merged_mining c2pool_hashrate c2pool_storage) + # PE item 3 (host half): host-level dual-path won-aux broadcaster regression + # lock. Reconstructs the run-loop binding (set_block_relay -> submit_block_raw + # + set_fallback_backend -> AuxChainRPC::submit_aux_block) with fakes and + # locks DELIVERY through both legs -- net-new vs the presence-only helper KAT + # above. Same header-only dispatcher, so the same SCC (core + c2pool_* object + # libs); no nmc_coin (no transaction ctor). + add_executable(nmc_host_dualpath_test nmc_host_dualpath_test.cpp) + target_link_libraries(nmc_host_dualpath_test PRIVATE + GTest::gtest_main GTest::gtest + core nlohmann_json::nlohmann_json) + target_link_libraries(nmc_host_dualpath_test PRIVATE + c2pool_payout c2pool_merged_mining c2pool_hashrate c2pool_storage) + include(GoogleTest) include_directories(${gtest_SOURCE_DIR}/include ${gtest_SOURCE_DIR}) gtest_add_tests(nmc_auxpow_merkle_test "" AUTO) @@ -88,4 +101,5 @@ if (BUILD_TESTING AND GTest_FOUND) gtest_add_tests(nmc_reconstruct_won_block_test "" AUTO) gtest_add_tests(nmc_mempool_name_test "" AUTO) gtest_add_tests(nmc_block_broadcast_test "" AUTO) + gtest_add_tests(nmc_host_dualpath_test "" AUTO) endif() diff --git a/src/impl/nmc/test/nmc_host_dualpath_test.cpp b/src/impl/nmc/test/nmc_host_dualpath_test.cpp new file mode 100644 index 000000000..c79462f2d --- /dev/null +++ b/src/impl/nmc/test/nmc_host_dualpath_test.cpp @@ -0,0 +1,241 @@ +// --------------------------------------------------------------------------- +// nmc host-level dual-path won-aux broadcaster regression-lock (PE item 3, +// HOST-wiring half). NET-NEW vs the helper-level nmc_block_broadcast_test: +// that test hands raw std::function sinks straight to broadcast_won_aux_block +// and only checks sink PRESENCE. This one reconstructs the actual NMC run-loop +// binding -- exactly as src/c2pool/c2pool_refactored.cpp does -- and locks +// DELIVERY through both legs: +// PRIMARY : backend->set_block_relay([merged_broadcasters, chain_id]{...}) +// -> merged_broadcasters[chain_id]->submit_block_raw(bytes) +// (host site c2pool_refactored.cpp:5276, returns relayed peers) +// FALLBACK : mm_manager->set_fallback_backend(chain_id, AuxChainRPC) +// -> AuxChainRPC::submit_aux_block(hash_hex, auxpow_hex) +// (host site c2pool_refactored.cpp:5290 / merged_mining.cpp:454) +// +// Why a separate lock: a future refactor CAN silently drop the RPC fallback +// (delete set_fallback_backend), drop the P2P relay (delete set_block_relay), +// or MIS-KEY merged_broadcasters by chain_id -- and the helper test would stay +// green because the dispatcher's p2p_sent flag is presence-based, not delivery- +// based. host_dual_path_delivered() below asserts both networks were ACTUALLY +// reached, so each of those regressions turns this RED. That is the whole point +// of item 3's host-level lock vs the dispatcher contract slice. +// +// Test-only, fake sinks, zero consensus surface: no PoW hash, share format, aux +// commitment, template, or PPLNS math is touched. p2pool-merged-v36 surface: +// NONE. Per-coin isolation: src/impl/nmc/ only; consumes coin/block_broadcast.hpp +// (core/log.hpp only) -- pulls no btc/ or dgb/ symbol. +// +// MUST appear in BOTH test/CMakeLists.txt AND the build.yml --target allowlist +// or it becomes a #137/#143-style NOT_BUILT sentinel that reds master. +// --------------------------------------------------------------------------- + +#include + +#include +#include +#include +#include + +#include "../coin/block_broadcast.hpp" + +namespace { + +// Canonical won-aux payload (shared across cases). +const std::vector kBytes(120, 0x42); +const std::string kHashHex = "47589169f94e3e77bf4da8067e76b4417b021f0eb10760995671856f21b8d4b4"; +const std::string kAuxpowHex = "0011223344556677"; + +// NMC aux chain_id SSOT (NMC_AUXPOW_CHAIN_ID=0x0001) vs DOGE's 0x0062 -- the +// keys the run-loop registers merged_broadcasters under. Used to exercise the +// host map-keying guard (a mis-key relays to nobody). +constexpr uint32_t kNmcChainId = 0x0001; +constexpr uint32_t kDogeChainId = 0x0062; + +// Fake of the embedded multi-peer P2P broadcaster registered in +// merged_broadcasters. The host set_block_relay closure calls submit_block_raw, +// which returns the relayed peer count (host site c2pool_refactored.cpp:5281). +struct FakeP2pBroadcaster { + std::size_t peers = 4; + int calls = 0; + std::vector last_block; + std::size_t submit_block_raw(const std::vector& b) { + ++calls; + last_block = b; + return peers; + } +}; + +// Fake of c2pool::merged::AuxChainRPC, the fallback backend the host binds via +// set_fallback_backend. submit_aux_block fires submitauxblock and returns true +// on daemon accept OR harmless duplicate (merged_mining.cpp:454). +struct FakeAuxChainRpc { + bool accept = true; + int calls = 0; + std::string saw_hash, saw_auxpow; + bool submit_aux_block(const std::string& h, const std::string& a) { + ++calls; + saw_hash = h; + saw_auxpow = a; + return accept; + } +}; + +// Reconstruct the NMC host dual-path wiring exactly as the run-loop does, then +// fire a won-aux block through nmc::coin::broadcast_won_aux_block. +// bind_p2p_relay == false models a refactor dropping backend->set_block_relay +// fallback == nullptr models dropping mm_manager->set_fallback_backend +// The P2P closure mirrors the host's lazy merged_broadcasters[chain_id] lookup +// AND its not-found guard (return 0 / relay to nobody) verbatim. +nmc::coin::AuxBlockBroadcast host_dispatch_won_aux( + std::map& merged_broadcasters, + std::uint32_t chain_id, + bool bind_p2p_relay, + FakeAuxChainRpc* fallback, + const std::vector& bytes, + const std::string& hash_hex, + const std::string& auxpow_hex) +{ + nmc::coin::P2pRelaySink p2p; // empty unless host bound set_block_relay + if (bind_p2p_relay) { + p2p = [&merged_broadcasters, chain_id](const std::vector& b) { + auto it = merged_broadcasters.find(chain_id); + if (it == merged_broadcasters.end()) return; // host guard: no peer reached + it->second->submit_block_raw(b); + }; + } + nmc::coin::AuxRpcSink rpc; // empty unless host bound set_fallback_backend + if (fallback) { + rpc = [fallback](const std::string& h, const std::string& a) { + return fallback->submit_aux_block(h, a); + }; + } + return nmc::coin::broadcast_won_aux_block(p2p, rpc, bytes, hash_hex, auxpow_hex); +} + +// The host dual-path invariant: a correctly-wired won-aux dispatch reaches BOTH +// networks. Presence of a sink is NOT enough -- a mis-keyed merged_broadcasters +// lookup leaves bc.p2p_sent==true yet relays to nobody, so we require the +// embedded broadcaster to have been ACTUALLY invoked (>=1 relay call) AND the +// submitauxblock fallback to have been invoked and acked. Dropping either +// binding (or mis-keying the map) flips this false -- that is the lock. +bool host_dual_path_delivered(const nmc::coin::AuxBlockBroadcast& bc, + int p2p_relay_calls, + int rpc_calls) { + return bc.p2p_sent && p2p_relay_calls >= 1 && bc.rpc_ok && rpc_calls >= 1; +} + +} // namespace + +// 1) CANONICAL HOST WIRING -- both legs bound and correctly chain_id-keyed. +// This is the positive lock: it goes RED if production drops a binding or +// mis-keys the broadcaster map. Both networks are actually reached and each +// leg is handed the exact payload. +TEST(NmcHostDualPath, BindsBothLegsAndDeliversToBothNetworks) { + FakeP2pBroadcaster p2p; + FakeAuxChainRpc rpc; + std::map merged_broadcasters{{kNmcChainId, &p2p}}; + + auto bc = host_dispatch_won_aux(merged_broadcasters, kNmcChainId, + /*bind_p2p_relay=*/true, &rpc, + kBytes, kHashHex, kAuxpowHex); + + // Both legs DELIVERED -- the host-level invariant. + EXPECT_TRUE(host_dual_path_delivered(bc, p2p.calls, rpc.calls)); + + // Primary P2P leg actually relayed the exact block to the peer broadcaster. + EXPECT_EQ(p2p.calls, 1); + EXPECT_EQ(p2p.last_block, kBytes); + EXPECT_TRUE(bc.p2p_sent); + + // Fallback RPC leg fired ALWAYS with the exact (hash, auxpow) payload. + EXPECT_EQ(rpc.calls, 1); + EXPECT_EQ(rpc.saw_hash, kHashHex); + EXPECT_EQ(rpc.saw_auxpow, kAuxpowHex); + EXPECT_TRUE(bc.rpc_ok); + + EXPECT_TRUE(bc.any()); + EXPECT_STREQ(bc.landed_first, "p2p"); // primary won the race +} + +// 2) REGRESSION: a refactor deletes backend->set_block_relay. The P2P sink is +// never bound; only the submitauxblock fallback carries the block. The host +// invariant must catch the missing primary leg. +TEST(NmcHostDualPath, DropP2pRelayBindingIsCaught) { + FakeP2pBroadcaster p2p; + FakeAuxChainRpc rpc; + std::map merged_broadcasters{{kNmcChainId, &p2p}}; + + auto bc = host_dispatch_won_aux(merged_broadcasters, kNmcChainId, + /*bind_p2p_relay=*/false, &rpc, + kBytes, kHashHex, kAuxpowHex); + + EXPECT_FALSE(host_dual_path_delivered(bc, p2p.calls, rpc.calls)); // LOCK fires + EXPECT_FALSE(bc.p2p_sent); + EXPECT_EQ(p2p.calls, 0); // embedded peer never reached + EXPECT_TRUE(bc.rpc_ok); // fallback alone saved the won block + EXPECT_EQ(rpc.calls, 1); + EXPECT_TRUE(bc.any()); + EXPECT_STREQ(bc.landed_first, "rpc"); +} + +// 3) REGRESSION: a refactor deletes mm_manager->set_fallback_backend. The RPC +// fallback is never bound; only the embedded P2P relay carries. The host +// invariant must catch the missing fallback leg. +TEST(NmcHostDualPath, DropFallbackBindingIsCaught) { + FakeP2pBroadcaster p2p; + std::map merged_broadcasters{{kNmcChainId, &p2p}}; + + auto bc = host_dispatch_won_aux(merged_broadcasters, kNmcChainId, + /*bind_p2p_relay=*/true, /*fallback=*/nullptr, + kBytes, kHashHex, kAuxpowHex); + + EXPECT_FALSE(host_dual_path_delivered(bc, p2p.calls, /*rpc_calls=*/0)); // LOCK fires + EXPECT_FALSE(bc.rpc_ok); + EXPECT_TRUE(bc.p2p_sent); // primary alone carried + EXPECT_EQ(p2p.calls, 1); + EXPECT_EQ(p2p.last_block, kBytes); + EXPECT_TRUE(bc.any()); + EXPECT_STREQ(bc.landed_first, "p2p"); +} + +// 4) REGRESSION (host map-keying): both legs are bound, but the run-loop +// registers the embedded broadcaster under the wrong chain_id (here DOGE's +// 0x0062 while dispatching the NMC 0x0001 win). The dispatcher's p2p_sent +// stays TRUE -- the sink is present -- yet submit_block_raw is NEVER reached, +// so the won block silently fails to relay over P2P. Only a DELIVERY-level +// invariant catches this; the helper-level presence check cannot. The +// fallback still saves the block, which is exactly why this is insidious. +TEST(NmcHostDualPath, MiskeyedChainIdSilentlyDropsP2pButLockCatchesIt) { + FakeP2pBroadcaster p2p; + FakeAuxChainRpc rpc; + std::map merged_broadcasters{{kDogeChainId, &p2p}}; + + auto bc = host_dispatch_won_aux(merged_broadcasters, kNmcChainId, + /*bind_p2p_relay=*/true, &rpc, + kBytes, kHashHex, kAuxpowHex); + + EXPECT_TRUE(bc.p2p_sent); // sink PRESENT -- helper test would pass here + EXPECT_EQ(p2p.calls, 0); // ...but the peer broadcaster was never hit + EXPECT_FALSE(host_dual_path_delivered(bc, p2p.calls, rpc.calls)); // LOCK fires + EXPECT_TRUE(bc.rpc_ok); // fallback masked the silent P2P drop + EXPECT_EQ(rpc.calls, 1); +} + +// 5) REGRESSION (both bindings dropped): neither leg bound -> never silent-drop. +// The dispatcher screams (lost-subsidy ERROR) and reports any()==false; no +// sink is invoked. +TEST(NmcHostDualPath, NeitherLegBoundNeverSilentDrops) { + FakeP2pBroadcaster p2p; + std::map merged_broadcasters{{kNmcChainId, &p2p}}; + + auto bc = host_dispatch_won_aux(merged_broadcasters, kNmcChainId, + /*bind_p2p_relay=*/false, /*fallback=*/nullptr, + kBytes, kHashHex, kAuxpowHex); + + EXPECT_FALSE(host_dual_path_delivered(bc, p2p.calls, /*rpc_calls=*/0)); + EXPECT_FALSE(bc.any()); + EXPECT_FALSE(bc.p2p_sent); + EXPECT_FALSE(bc.rpc_ok); + EXPECT_EQ(p2p.calls, 0); + EXPECT_STREQ(bc.landed_first, "none"); +}