Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/impl/btc/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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: disabledshare_test.cpp references LTC types, restore in later phase
add_subdirectory(test) # btc_share_test enabledF11 value-invariance KAT (twin of ltc share_test, uniquely named to avoid CMP0002)
68 changes: 62 additions & 6 deletions src/impl/btc/share_check.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,38 @@ inline std::vector<unsigned char> 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 <typename AmountsMap>
inline std::vector<std::pair<std::vector<unsigned char>, uint64_t>>
build_payout_outputs_excluding_donation(
const AmountsMap& amounts,
const std::vector<unsigned char>& combined_donation_script,
const std::vector<unsigned char>& p2pk_donation_script,
uint64_t& donation_amount)
{
if (auto it = amounts.find(combined_donation_script); it != amounts.end())
donation_amount += it->second;
std::vector<std::pair<std::vector<unsigned char>, 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 <typename ShareT, typename TrackerT>
uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker, bool dump_diag = false, bool v36_active = false)
{
Expand Down Expand Up @@ -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<std::pair<std::vector<unsigned char>, 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<unsigned char> combined_donation_script(
core::donation::COMBINED_DONATION_SCRIPT.begin(), core::donation::COMBINED_DONATION_SCRIPT.end());
const std::vector<unsigned char> 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
Expand Down Expand Up @@ -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<std::pair<std::vector<unsigned char>, 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<unsigned char> combined_donation_script(
core::donation::COMBINED_DONATION_SCRIPT.begin(), core::donation::COMBINED_DONATION_SCRIPT.end());
const std::vector<unsigned char> 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;
Expand Down Expand Up @@ -2944,8 +2992,16 @@ uint256 create_local_share(
}
}

std::vector<std::pair<std::vector<unsigned char>, 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<unsigned char> combined_donation_script(
core::donation::COMBINED_DONATION_SCRIPT.begin(), core::donation::COMBINED_DONATION_SCRIPT.end());
const std::vector<unsigned char> 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;
Expand Down
13 changes: 8 additions & 5 deletions src/impl/btc/test/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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()
gtest_add_tests(btc_share_test "" AUTO)
endif()
181 changes: 181 additions & 0 deletions src/impl/btc/test/f11_donation_invariance_test.cpp
Original file line number Diff line number Diff line change
@@ -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 <gtest/gtest.h>

#include <map>
#include <vector>
#include <cstdint>

#include <core/donation.hpp>
#include <impl/btc/share_check.hpp>

namespace {

using Script = std::vector<unsigned char>;
using Amounts = std::map<Script, uint64_t>;

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<std::pair<Script, uint64_t>>& o) {
uint64_t s = 0; for (auto& kv : o) s += kv.second; return s;
}
bool contains_script(const std::vector<std::pair<Script, uint64_t>>& 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<std::pair<Script, uint64_t>> 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);
}
Loading