From f2a1f330fcb908e4cd9168a80905880951a7fa33 Mon Sep 17 00:00:00 2001 From: frstrtr Date: Thu, 25 Jun 2026 16:08:37 +0000 Subject: [PATCH] nmc(PF): lock external-daemon fallback-path conformance Phase PF fallback-path half. The V36 external_fallback invariant requires the external-daemon (.140 namecoind submitauxblock) RPC fallback to PERSIST as the retained path alongside the embedded P2P relay. Existing dispatcher and host-wiring KATs bind the fallback leg to an always-ack fake, so neither distinguishes WHICH backend serves it. This adds nmc_fallback_path_conformance_test (3 KATs) locking the one fact PF owns: the fallback MUST be the external namecoind RPC (a daemon that can ack), NOT the embedded backend daemon-less submit_aux_block (structurally false, aux_chain_embedded.hpp:153). Mis-wiring set_fallback_backend to the embedded self-leg leaves the wiring looking present but turns the retained fallback into a dead path -- a won aux block is silently lost when P2P is down. Test 2 makes that regression RED; test 1 proves the external path carries when P2P is down; test 3 locks that the external fallback always-fires alongside a working embedded relay (not optimized away). Test-only, NMC-fenced (src/impl/nmc/ only), zero consensus surface. Registered in test/CMakeLists.txt + gtest_add_tests and the build.yml --target allowlist (both legs) to avoid a #137-style NOT_BUILT sentinel. --- .github/workflows/build.yml | 4 +- src/impl/nmc/test/CMakeLists.txt | 14 ++ .../nmc_fallback_path_conformance_test.cpp | 126 ++++++++++++++++++ 3 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 src/impl/nmc/test/nmc_fallback_path_conformance_test.cpp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c04fbcbf1..8376287db 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 nmc_host_dualpath_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 nmc_fallback_path_conformance_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 nmc_host_dualpath_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 nmc_fallback_path_conformance_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 fe6057148..0c5566aa9 100644 --- a/src/impl/nmc/test/CMakeLists.txt +++ b/src/impl/nmc/test/CMakeLists.txt @@ -93,6 +93,19 @@ if (BUILD_TESTING AND GTest_FOUND) target_link_libraries(nmc_host_dualpath_test PRIVATE c2pool_payout c2pool_merged_mining c2pool_hashrate c2pool_storage) + # PF (fallback-path conformance): locks that the RETAINED external-daemon + # fallback is the .140 namecoind submitauxblock RPC (a real daemon that can + # ack), NOT the embedded backend daemon-less submit_aux_block (structurally + # false). Mis-wiring the fallback to the embedded self-leg silently loses a + # won block when P2P is down -- this test makes that RED. Same header-only + # dispatcher SCC (core + c2pool_* object libs); no nmc_coin (no transaction ctor). + add_executable(nmc_fallback_path_conformance_test nmc_fallback_path_conformance_test.cpp) + target_link_libraries(nmc_fallback_path_conformance_test PRIVATE + GTest::gtest_main GTest::gtest + core nlohmann_json::nlohmann_json) + target_link_libraries(nmc_fallback_path_conformance_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) @@ -102,4 +115,5 @@ if (BUILD_TESTING AND GTest_FOUND) gtest_add_tests(nmc_mempool_name_test "" AUTO) gtest_add_tests(nmc_block_broadcast_test "" AUTO) gtest_add_tests(nmc_host_dualpath_test "" AUTO) + gtest_add_tests(nmc_fallback_path_conformance_test "" AUTO) endif() diff --git a/src/impl/nmc/test/nmc_fallback_path_conformance_test.cpp b/src/impl/nmc/test/nmc_fallback_path_conformance_test.cpp new file mode 100644 index 000000000..39a472571 --- /dev/null +++ b/src/impl/nmc/test/nmc_fallback_path_conformance_test.cpp @@ -0,0 +1,126 @@ +// --------------------------------------------------------------------------- +// nmc PF fallback-path conformance lock (Phase PF, external-daemon-fallback half). +// +// V36 per-coin invariant (v36-master-plan external_fallback): "an EXTERNAL-DAEMON +// FALLBACK (RPC to a real coind / MM-Adapter) MUST PERSIST in every coin's c2pool +// -- embedded is default, external is the RETAINED fallback path. Do NOT remove +// external-daemon code paths." For NMC the retained fallback is the .140 testnet +// namecoind `submitauxblock` RPC, bound by the run-loop as the AuxRpcSink. +// +// NET-NEW vs nmc_block_broadcast_test (dispatcher contract) and +// nmc_host_dualpath_test (host wiring DELIVERY): both of those bind the fallback +// leg to a fake that ALWAYS ACKS, so neither distinguishes WHICH backend the +// fallback is. This file locks the one conformance fact PF owns: the fallback +// MUST be the EXTERNAL namecoind RPC (a real daemon that can ack), and MUST NOT +// be the embedded backend's own submit_aux_block -- which is structurally +// daemon-less and returns false unconditionally (aux_chain_embedded.hpp:153, +// "no daemon to submit to; embedded RPC leg cannot broadcast, returning false"). +// +// The trap: a refactor that "simplifies" set_fallback_backend to point at the +// embedded AuxChainEmbedded::submit_aux_block leaves the wiring LOOKING present +// (a sink is bound, nmc_host_dualpath stays green on its always-ack fake) but +// turns the retained fallback into a dead path: when the embedded P2P relay is +// down, a won aux block is then SILENTLY LOST. This file makes that regression +// RED. Test-only, std::function fakes, zero consensus surface (no PoW hash, +// share format, aux commitment, template, or PPLNS math). 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 "../coin/block_broadcast.hpp" + +namespace { + +const std::vector kBytes(120, 0x42); +const std::string kHashHex = "47589169f94e3e77bf4da8067e76b4417b021f0eb10760995671856f21b8d4b4"; +const std::string kAuxpowHex = "0011223344556677"; + +// Models c2pool::merged::AuxChainRPC -- the EXTERNAL .140 namecoind submitauxblock +// client the run-loop binds via set_fallback_backend. A real daemon ACKS (true) +// on accept OR harmless duplicate (merged_mining.cpp:454). This is the RETAINED +// external fallback the V36 invariant requires. +struct ExternalNamecoindRpc { + int calls = 0; + bool submit_aux_block(const std::string&, const std::string&) { ++calls; return true; } +}; + +// Models AuxChainEmbedded::submit_aux_block (aux_chain_embedded.hpp:153): the +// EMBEDDED backend has no daemon to RPC, so its RPC leg cannot broadcast and +// returns false UNCONDITIONALLY. Wiring THIS as the fallback is the conformance +// violation -- it is not the external daemon, it is the embedded self-leg. +struct EmbeddedSelfRpcLeg { + int calls = 0; + bool submit_aux_block(const std::string&, const std::string&) { ++calls; return false; } +}; + +} // namespace + +// 1) RETAINED EXTERNAL FALLBACK CARRIES when the embedded P2P relay is down. +// The external namecoind submitauxblock saves the won aux block: rpc_ok=true, +// any()=true, it won the race. This is the path the V36 external_fallback +// invariant says must PERSIST. +TEST(NmcPfFallbackPath, ExternalDaemonFallbackCarriesWhenEmbeddedP2pDown) { + ExternalNamecoindRpc ext; + nmc::coin::AuxRpcSink rpc = [&](const std::string& h, const std::string& a) { + return ext.submit_aux_block(h, a); + }; + + // P2P relay sink empty == embedded relay down / no peers route. + auto r = nmc::coin::broadcast_won_aux_block(/*p2p_relay=*/{}, rpc, + kBytes, kHashHex, kAuxpowHex); + EXPECT_FALSE(r.p2p_sent); + EXPECT_TRUE(r.rpc_ok); // external daemon acked + EXPECT_TRUE(r.any()); // won block NOT lost + EXPECT_STREQ(r.landed_first, "rpc"); + EXPECT_EQ(ext.calls, 1); +} + +// 2) CONFORMANCE LOCK: the embedded backend's own submit_aux_block CANNOT serve +// as the fallback. It is daemon-less and returns false unconditionally, so if +// a refactor mis-wires set_fallback_backend to it, then with the embedded P2P +// relay also down the won aux block is SILENTLY LOST (any()=false). This is +// exactly the regression the external_fallback invariant forbids; making it +// RED is the whole point of PF's fallback-path lock. +TEST(NmcPfFallbackPath, EmbeddedSelfLegCannotServeAsFallback_BlockLost) { + EmbeddedSelfRpcLeg self; + nmc::coin::AuxRpcSink mis_wired = [&](const std::string& h, const std::string& a) { + return self.submit_aux_block(h, a); + }; + + auto r = nmc::coin::broadcast_won_aux_block(/*p2p_relay=*/{}, mis_wired, + kBytes, kHashHex, kAuxpowHex); + EXPECT_EQ(self.calls, 1); // the leg was consulted... + EXPECT_FALSE(r.rpc_ok); // ...but structurally cannot ack + EXPECT_FALSE(r.p2p_sent); + EXPECT_FALSE(r.any()); // WON BLOCK LOST -- the forbidden state + EXPECT_STREQ(r.landed_first, "none"); +} + +// 3) PERSISTENCE: the external fallback must stay wired ALONGSIDE a working +// embedded P2P relay, not be optimized away "because embedded works". With +// both present the external submitauxblock STILL fires (always-fire rule), +// so deleting it to "save an RPC" flips calls 1->0 and removes the retained +// safety net -- the regression this asserts against. +TEST(NmcPfFallbackPath, ExternalFallbackPersistsAlongsideWorkingEmbeddedP2p) { + bool relayed = false; + nmc::coin::P2pRelaySink p2p = [&](const std::vector&) { relayed = true; }; + ExternalNamecoindRpc ext; + nmc::coin::AuxRpcSink rpc = [&](const std::string& h, const std::string& a) { + return ext.submit_aux_block(h, a); + }; + + auto r = nmc::coin::broadcast_won_aux_block(p2p, rpc, kBytes, kHashHex, kAuxpowHex); + EXPECT_TRUE(relayed); + EXPECT_TRUE(r.p2p_sent); + EXPECT_TRUE(r.rpc_ok); // retained fallback fired anyway + EXPECT_EQ(ext.calls, 1); // ALWAYS-fired, not skipped because P2P won + EXPECT_STREQ(r.landed_first, "p2p"); // primary still won the race +}