diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4afe7f2d5..c195337f8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -68,7 +68,7 @@ jobs: --target test_hardening test_share_messages test_coin_broadcaster \ test_redistribute_address test_redistribute test_auto_ratchet \ test_stratum_extensions \ - core_test sharechain_test share_test \ + core_test sharechain_test share_test btc_share_test \ test_threading test_weights \ test_header_chain test_mempool test_template_builder \ test_doge_chain test_compact_blocks test_dash_x11_kat \ @@ -201,7 +201,7 @@ jobs: test_hardening test_share_messages \ test_redistribute_address test_redistribute test_auto_ratchet \ test_stratum_extensions \ - core_test sharechain_test share_test \ + core_test sharechain_test share_test btc_share_test \ test_threading test_weights \ test_header_chain test_mempool test_template_builder \ test_doge_chain test_compact_blocks test_dash_x11_kat \ @@ -537,9 +537,9 @@ jobs: # btc-embedded branch involvement and never fired on master, so removing it # changes no live coverage. # - # Deferred btc test wiring (unchanged, owned by the btc-heap-opt arc): - # - src/impl/btc/test/share_test.cpp — add_subdirectory(test) disabled in - # src/impl/btc/CMakeLists.txt (still references LTC types). + # Deferred btc test wiring (owned by the btc-heap-opt arc): + # - src/impl/btc/test/share_test.cpp — NOW WIRED as the btc_share_test target + # (F11 value-invariance KAT); uniquely named to avoid CMP0002 vs ltc share_test. # - src/impl/btc/test/template_parity_test.cpp — standalone consensus # oracle harness; needs proper CMake target wiring (nlohmann_json, # leveldb_store, core/log) to be CI-reliable. diff --git a/src/impl/btc/CMakeLists.txt b/src/impl/btc/CMakeLists.txt index 162604d17..63daf94d6 100644 --- a/src/impl/btc/CMakeLists.txt +++ b/src/impl/btc/CMakeLists.txt @@ -12,4 +12,4 @@ add_library(btc target_link_libraries(btc core pool sharechain btc_coin btclibs c2pool_storage c2pool_hashrate ${SECP256K1_LIBRARIES}) # OBJECT-lib SCC direct-naming (#22/#39) — vardiff/HashrateTracker link fix -# add_subdirectory(test) # B0: disabled — share_test.cpp references LTC types, restore in later phase \ No newline at end of file +add_subdirectory(test) # btc_share_test enabled — F11 value-invariance KAT (twin of ltc share_test, uniquely named to avoid CMP0002) \ No newline at end of file diff --git a/src/impl/btc/share_check.hpp b/src/impl/btc/share_check.hpp index 255b8f424..809de30e0 100644 --- a/src/impl/btc/share_check.hpp +++ b/src/impl/btc/share_check.hpp @@ -928,6 +928,38 @@ inline std::vector get_share_script(const auto* obj) // // Reference: frstrtr/p2pool-merged-v36 p2pool/data.py generate_transaction() // ============================================================================ +// --------------------------------------------------------------------------- +// F11: canonical exclude-then-append donation handling for the payout sort. +// +// Mirrors p2pool data.py generate_transaction: the per-miner payout dests +// exclude BOTH donation scripts; any COMBINED_DONATION_SCRIPT-keyed weight is +// folded into the single donation-last output, and any DONATION_SCRIPT (P2PK) +// keyed weight is dropped. Value-invariant: the COMBINED weight is moved (not +// destroyed) into the donation output; dropping the P2PK key is value-neutral +// only because that key never accrues weight in canonical v36 operation. +// +// Guarded by test/f11_donation_invariance_test.cpp. +// --------------------------------------------------------------------------- +template +inline std::vector, uint64_t>> +build_payout_outputs_excluding_donation( + const AmountsMap& amounts, + const std::vector& combined_donation_script, + const std::vector& p2pk_donation_script, + uint64_t& donation_amount) +{ + if (auto it = amounts.find(combined_donation_script); it != amounts.end()) + donation_amount += it->second; + std::vector, uint64_t>> payout_outputs; + payout_outputs.reserve(amounts.size()); + for (const auto& kv : amounts) { + if (kv.first == combined_donation_script || kv.first == p2pk_donation_script) + continue; + payout_outputs.emplace_back(kv.first, kv.second); + } + return payout_outputs; +} + template uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker, bool dump_diag = false, bool v36_active = false) { @@ -1117,8 +1149,16 @@ uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker, bool auto gst_t2 = std::chrono::steady_clock::now(); // after amounts // Python: sorted(dests, key=lambda a: (amounts[a], a))[-4000:] // = ascending by (amount, script), keep last 4000 (highest amounts) - std::vector, uint64_t>> payout_outputs( - amounts.begin(), amounts.end()); + // F11: canonical exclude-then-append donation handling (p2pool data.py + // generate_transaction). The per-miner dests list excludes BOTH donation + // scripts; a COMBINED_DONATION_SCRIPT-keyed weight folds into the single + // donation-last output, and any DONATION_SCRIPT-keyed weight is dropped. + const std::vector combined_donation_script( + core::donation::COMBINED_DONATION_SCRIPT.begin(), core::donation::COMBINED_DONATION_SCRIPT.end()); + const std::vector p2pk_donation_script( + core::donation::DONATION_SCRIPT.begin(), core::donation::DONATION_SCRIPT.end()); + auto payout_outputs = build_payout_outputs_excluding_donation( + amounts, combined_donation_script, p2pk_donation_script, donation_amount); std::sort(payout_outputs.begin(), payout_outputs.end(), [](const auto& a, const auto& b) { if (a.second != b.second) return a.second < b.second; // asc by amount @@ -2390,8 +2430,16 @@ uint256 create_local_share_v35( // V35: no minimum donation enforcement (unlike v36) uint64_t donation_amount = (subsidy > sum_amounts) ? (subsidy - sum_amounts) : 0; - std::vector, uint64_t>> payout_outputs( - amounts.begin(), amounts.end()); + // F11: canonical exclude-then-append donation handling (p2pool data.py + // generate_transaction). The per-miner dests list excludes BOTH donation + // scripts; a COMBINED_DONATION_SCRIPT-keyed weight folds into the single + // donation-last output, and any DONATION_SCRIPT-keyed weight is dropped. + const std::vector combined_donation_script( + core::donation::COMBINED_DONATION_SCRIPT.begin(), core::donation::COMBINED_DONATION_SCRIPT.end()); + const std::vector p2pk_donation_script( + core::donation::DONATION_SCRIPT.begin(), core::donation::DONATION_SCRIPT.end()); + auto payout_outputs = build_payout_outputs_excluding_donation( + amounts, combined_donation_script, p2pk_donation_script, donation_amount); std::sort(payout_outputs.begin(), payout_outputs.end(), [](const auto& a, const auto& b) { if (a.second != b.second) return a.second < b.second; @@ -2944,8 +2992,16 @@ uint256 create_local_share( } } - std::vector, uint64_t>> payout_outputs( - amounts.begin(), amounts.end()); + // F11: canonical exclude-then-append donation handling (p2pool data.py + // generate_transaction). The per-miner dests list excludes BOTH donation + // scripts; a COMBINED_DONATION_SCRIPT-keyed weight folds into the single + // donation-last output, and any DONATION_SCRIPT-keyed weight is dropped. + const std::vector combined_donation_script( + core::donation::COMBINED_DONATION_SCRIPT.begin(), core::donation::COMBINED_DONATION_SCRIPT.end()); + const std::vector p2pk_donation_script( + core::donation::DONATION_SCRIPT.begin(), core::donation::DONATION_SCRIPT.end()); + auto payout_outputs = build_payout_outputs_excluding_donation( + amounts, combined_donation_script, p2pk_donation_script, donation_amount); std::sort(payout_outputs.begin(), payout_outputs.end(), [](const auto& a, const auto& b) { if (a.second != b.second) return a.second < b.second; diff --git a/src/impl/btc/test/CMakeLists.txt b/src/impl/btc/test/CMakeLists.txt index 66527890f..511cf8840 100644 --- a/src/impl/btc/test/CMakeLists.txt +++ b/src/impl/btc/test/CMakeLists.txt @@ -1,11 +1,14 @@ if (BUILD_TESTING AND GTest_FOUND) - add_executable(share_test share_test.cpp) - target_link_libraries(share_test PRIVATE + # 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) + target_link_libraries(btc_share_test PRIVATE GTest::gtest_main GTest::gtest - core sharechain + core btc ) + target_link_libraries(btc_share_test PRIVATE c2pool_payout c2pool_merged_mining c2pool_hashrate c2pool_storage btc_coin btclibs pool sharechain ${SECP256K1_LIBRARIES}) # OBJECT-lib SCC direct-naming (#22/#39), btc twin include(GoogleTest) include_directories(${gtest_SOURCE_DIR}/include ${gtest_SOURCE_DIR}) - gtest_add_tests(share_test "" AUTO) -endif() \ No newline at end of file + gtest_add_tests(btc_share_test "" AUTO) +endif() diff --git a/src/impl/btc/test/f11_donation_invariance_test.cpp b/src/impl/btc/test/f11_donation_invariance_test.cpp new file mode 100644 index 000000000..7dfc5efed --- /dev/null +++ b/src/impl/btc/test/f11_donation_invariance_test.cpp @@ -0,0 +1,181 @@ +// F11 value-invariance KAT — guards the canonical exclude-then-append donation +// handling in the BTC v36 payout sort (src/impl/btc/share_check.hpp, +// build_payout_outputs_excluding_donation). +// +// Backfills the coverage gap that let PR-0 S1 (133ae6bc) silently revert the +// LTC F11 (18dd9457) on a stale base: there was no test asserting the parity +// arithmetic, so the regression shipped unnoticed. This BTC twin asserts the +// same invariant set against its own helper + the core::donation SSOT bytes. +// +// Invariant under test (mirrors p2pool data.py generate_transaction): +// - per-miner payout dests EXCLUDE both donation scripts (COMBINED P2SH + P2PK) +// - COMBINED_DONATION_SCRIPT-keyed weight FOLDS into the single donation-last +// output (moved, not destroyed) +// - DONATION_SCRIPT (P2PK)-keyed weight is DROPPED — value-neutral ONLY because +// that key never accrues weight in canonical v36 operation +// - total coinbase value out == subsidy (no value created/destroyed) +// +// Coordinated with the LTC source (ltc/test/f11_donation_invariance_test.cpp); +// only the donation-constant source (core::donation SSOT) and the helper coin +// namespace (btc::) differ. + +#include + +#include +#include +#include + +#include +#include + +namespace { + +using Script = std::vector; +using Amounts = std::map; + +Script combined_script() { + return Script(core::donation::COMBINED_DONATION_SCRIPT.begin(), + core::donation::COMBINED_DONATION_SCRIPT.end()); +} +Script p2pk_script() { + return Script(core::donation::DONATION_SCRIPT.begin(), + core::donation::DONATION_SCRIPT.end()); +} +// Distinct miner payout scripts (P2PKH-shaped; bytes are arbitrary but != donation). +Script miner(unsigned char tag) { + return Script{0x76, 0xa9, 0x14, tag, tag, tag, tag, tag, tag, tag, tag, tag, tag, + tag, tag, tag, tag, tag, tag, tag, tag, tag, tag, 0x88, 0xac}; +} + +uint64_t sum_amounts(const Amounts& a) { + uint64_t s = 0; for (auto& kv : a) s += kv.second; return s; +} +uint64_t sum_outputs(const std::vector>& o) { + uint64_t s = 0; for (auto& kv : o) s += kv.second; return s; +} +bool contains_script(const std::vector>& o, const Script& s) { + for (auto& kv : o) if (kv.first == s) return true; + return false; +} + +// Reproduces the production pre-fold accounting at each gentx site: +// donation_amount = subsidy - sum(amounts), then the helper folds COMBINED in. +struct FoldResult { + std::vector> payout_outputs; + uint64_t donation_amount; +}; +FoldResult run_fold(const Amounts& amounts, uint64_t subsidy) { + uint64_t sa = sum_amounts(amounts); + uint64_t donation_amount = (subsidy > sa) ? (subsidy - sa) : 0; + auto outs = btc::build_payout_outputs_excluding_donation( + amounts, combined_script(), p2pk_script(), donation_amount); + return {outs, donation_amount}; +} + +} // namespace + +// Canonical case: COMBINED weight present, no P2PK weight. The fold must be +// fully value-invariant — every satoshi of subsidy is accounted for. +TEST(BTC_F11_DonationInvariance, CombinedFoldedNoP2PK) { + const uint64_t subsidy = 1000; + Amounts amounts{ + {miner(0x01), 100}, + {miner(0x02), 250}, + {miner(0x03), 75}, + {combined_script(), 40}, // donation weight keyed by COMBINED P2SH + }; + + auto r = run_fold(amounts, subsidy); + + // per-miner dests exclude the donation script + EXPECT_EQ(r.payout_outputs.size(), 3u); + EXPECT_FALSE(contains_script(r.payout_outputs, combined_script())); + EXPECT_FALSE(contains_script(r.payout_outputs, p2pk_script())); + EXPECT_TRUE(contains_script(r.payout_outputs, miner(0x01))); + EXPECT_TRUE(contains_script(r.payout_outputs, miner(0x02))); + EXPECT_TRUE(contains_script(r.payout_outputs, miner(0x03))); + + // known answer: donation-last output grew by exactly the COMBINED weight + // donation_initial = 1000 - (100+250+75+40) = 535 ; +40 folded = 575 + EXPECT_EQ(r.donation_amount, 575u); + EXPECT_EQ(sum_outputs(r.payout_outputs), 425u); + + // VALUE INVARIANCE: per-miner outputs + donation-last == subsidy + EXPECT_EQ(sum_outputs(r.payout_outputs) + r.donation_amount, subsidy); +} + +// Both donation scripts present; P2PK at canonical weight 0. Both excluded from +// per-miner dests; dropping the 0-weight P2PK key is value-neutral. +TEST(BTC_F11_DonationInvariance, BothDonationScriptsExcluded_P2PKZeroWeight) { + const uint64_t subsidy = 5000; + Amounts amounts{ + {miner(0x0a), 1200}, + {miner(0x0b), 800}, + {combined_script(), 333}, + {p2pk_script(), 0}, // canonical: P2PK never accrues weight + }; + + auto r = run_fold(amounts, subsidy); + + EXPECT_EQ(r.payout_outputs.size(), 2u); + EXPECT_FALSE(contains_script(r.payout_outputs, combined_script())); + EXPECT_FALSE(contains_script(r.payout_outputs, p2pk_script())); + + // donation_initial = 5000 - (1200+800+333+0) = 2667 ; +333 = 3000 + EXPECT_EQ(r.donation_amount, 3000u); + EXPECT_EQ(sum_outputs(r.payout_outputs) + r.donation_amount, subsidy); +} + +// No donation keys at all: the fold is an identity over the miner set and the +// donation-last output is unchanged. +TEST(BTC_F11_DonationInvariance, NoDonationKeys_Identity) { + const uint64_t subsidy = 2000; + Amounts amounts{ + {miner(0x21), 600}, + {miner(0x22), 400}, + }; + + auto r = run_fold(amounts, subsidy); + + EXPECT_EQ(r.payout_outputs.size(), 2u); + EXPECT_EQ(r.donation_amount, 1000u); // 2000 - 1000, no fold + EXPECT_EQ(sum_outputs(r.payout_outputs) + r.donation_amount, subsidy); +} + +// Boundary documentation: if the P2PK key DID carry weight (non-canonical), +// dropping it would destroy exactly that many satoshis. This locks the reason +// the canonical invariant requires P2PK weight == 0, so a future change that +// starts keying weight on the P2PK script cannot pass silently. +TEST(BTC_F11_DonationInvariance, DroppedP2PKWeightLeaksExactlyThatWeight) { + const uint64_t subsidy = 1000; + const uint64_t p2pk_weight = 30; // NON-canonical, for the boundary proof + Amounts amounts{ + {miner(0x31), 200}, + {combined_script(), 50}, + {p2pk_script(), p2pk_weight}, + }; + + auto r = run_fold(amounts, subsidy); + + // COMBINED still folds correctly; P2PK weight is dropped (not folded). + EXPECT_FALSE(contains_script(r.payout_outputs, p2pk_script())); + // Total is short by EXACTLY the dropped P2PK weight — value is conserved + // iff p2pk_weight == 0. + EXPECT_EQ(sum_outputs(r.payout_outputs) + r.donation_amount, subsidy - p2pk_weight); +} + +// Pin the actual consensus donation bytes so a constant change trips this test. +TEST(BTC_F11_DonationInvariance, RealDonationConstantsPinned) { + auto c = combined_script(); + ASSERT_EQ(c.size(), 23u); // P2SH: OP_HASH160 <20> OP_EQUAL + EXPECT_EQ(c.front(), 0xa9); + EXPECT_EQ(c[1], 0x14); + EXPECT_EQ(c.back(), 0x87); + + auto p = p2pk_script(); + ASSERT_EQ(p.size(), 67u); // P2PK: OP_PUSHBYTES_65 <65> OP_CHECKSIG + EXPECT_EQ(p.front(), 0x41); + EXPECT_EQ(p.back(), 0xac); + + EXPECT_NE(c, p); +}