Skip to content

Commit f6491c9

Browse files
authored
Merge pull request #355 from frstrtr/btc/f11-donation-exclude
btc(v36 F11): canonical donation-script output exclusion + value-invariance KAT
2 parents 8b8324d + 8e0104c commit f6491c9

5 files changed

Lines changed: 257 additions & 17 deletions

File tree

.github/workflows/build.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ jobs:
6868
--target test_hardening test_share_messages test_coin_broadcaster \
6969
test_redistribute_address test_redistribute test_auto_ratchet \
7070
test_stratum_extensions \
71-
core_test sharechain_test share_test \
71+
core_test sharechain_test share_test btc_share_test \
7272
test_threading test_weights \
7373
test_header_chain test_mempool test_template_builder \
7474
test_doge_chain test_compact_blocks test_dash_x11_kat \
@@ -201,7 +201,7 @@ jobs:
201201
test_hardening test_share_messages \
202202
test_redistribute_address test_redistribute test_auto_ratchet \
203203
test_stratum_extensions \
204-
core_test sharechain_test share_test \
204+
core_test sharechain_test share_test btc_share_test \
205205
test_threading test_weights \
206206
test_header_chain test_mempool test_template_builder \
207207
test_doge_chain test_compact_blocks test_dash_x11_kat \
@@ -537,9 +537,9 @@ jobs:
537537
# btc-embedded branch involvement and never fired on master, so removing it
538538
# changes no live coverage.
539539
#
540-
# Deferred btc test wiring (unchanged, owned by the btc-heap-opt arc):
541-
# - src/impl/btc/test/share_test.cpp — add_subdirectory(test) disabled in
542-
# src/impl/btc/CMakeLists.txt (still references LTC types).
540+
# Deferred btc test wiring (owned by the btc-heap-opt arc):
541+
# - src/impl/btc/test/share_test.cpp — NOW WIRED as the btc_share_test target
542+
# (F11 value-invariance KAT); uniquely named to avoid CMP0002 vs ltc share_test.
543543
# - src/impl/btc/test/template_parity_test.cpp — standalone consensus
544544
# oracle harness; needs proper CMake target wiring (nlohmann_json,
545545
# leveldb_store, core/log) to be CI-reliable.

src/impl/btc/CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@ add_library(btc
1212

1313
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
1414

15-
# add_subdirectory(test) # B0: disabledshare_test.cpp references LTC types, restore in later phase
15+
add_subdirectory(test) # btc_share_test enabledF11 value-invariance KAT (twin of ltc share_test, uniquely named to avoid CMP0002)

src/impl/btc/share_check.hpp

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -928,6 +928,38 @@ inline std::vector<unsigned char> get_share_script(const auto* obj)
928928
//
929929
// Reference: frstrtr/p2pool-merged-v36 p2pool/data.py generate_transaction()
930930
// ============================================================================
931+
// ---------------------------------------------------------------------------
932+
// F11: canonical exclude-then-append donation handling for the payout sort.
933+
//
934+
// Mirrors p2pool data.py generate_transaction: the per-miner payout dests
935+
// exclude BOTH donation scripts; any COMBINED_DONATION_SCRIPT-keyed weight is
936+
// folded into the single donation-last output, and any DONATION_SCRIPT (P2PK)
937+
// keyed weight is dropped. Value-invariant: the COMBINED weight is moved (not
938+
// destroyed) into the donation output; dropping the P2PK key is value-neutral
939+
// only because that key never accrues weight in canonical v36 operation.
940+
//
941+
// Guarded by test/f11_donation_invariance_test.cpp.
942+
// ---------------------------------------------------------------------------
943+
template <typename AmountsMap>
944+
inline std::vector<std::pair<std::vector<unsigned char>, uint64_t>>
945+
build_payout_outputs_excluding_donation(
946+
const AmountsMap& amounts,
947+
const std::vector<unsigned char>& combined_donation_script,
948+
const std::vector<unsigned char>& p2pk_donation_script,
949+
uint64_t& donation_amount)
950+
{
951+
if (auto it = amounts.find(combined_donation_script); it != amounts.end())
952+
donation_amount += it->second;
953+
std::vector<std::pair<std::vector<unsigned char>, uint64_t>> payout_outputs;
954+
payout_outputs.reserve(amounts.size());
955+
for (const auto& kv : amounts) {
956+
if (kv.first == combined_donation_script || kv.first == p2pk_donation_script)
957+
continue;
958+
payout_outputs.emplace_back(kv.first, kv.second);
959+
}
960+
return payout_outputs;
961+
}
962+
931963
template <typename ShareT, typename TrackerT>
932964
uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker, bool dump_diag = false, bool v36_active = false)
933965
{
@@ -1117,8 +1149,16 @@ uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker, bool
11171149
auto gst_t2 = std::chrono::steady_clock::now(); // after amounts
11181150
// Python: sorted(dests, key=lambda a: (amounts[a], a))[-4000:]
11191151
// = ascending by (amount, script), keep last 4000 (highest amounts)
1120-
std::vector<std::pair<std::vector<unsigned char>, uint64_t>> payout_outputs(
1121-
amounts.begin(), amounts.end());
1152+
// F11: canonical exclude-then-append donation handling (p2pool data.py
1153+
// generate_transaction). The per-miner dests list excludes BOTH donation
1154+
// scripts; a COMBINED_DONATION_SCRIPT-keyed weight folds into the single
1155+
// donation-last output, and any DONATION_SCRIPT-keyed weight is dropped.
1156+
const std::vector<unsigned char> combined_donation_script(
1157+
core::donation::COMBINED_DONATION_SCRIPT.begin(), core::donation::COMBINED_DONATION_SCRIPT.end());
1158+
const std::vector<unsigned char> p2pk_donation_script(
1159+
core::donation::DONATION_SCRIPT.begin(), core::donation::DONATION_SCRIPT.end());
1160+
auto payout_outputs = build_payout_outputs_excluding_donation(
1161+
amounts, combined_donation_script, p2pk_donation_script, donation_amount);
11221162
std::sort(payout_outputs.begin(), payout_outputs.end(),
11231163
[](const auto& a, const auto& b) {
11241164
if (a.second != b.second) return a.second < b.second; // asc by amount
@@ -2390,8 +2430,16 @@ uint256 create_local_share_v35(
23902430
// V35: no minimum donation enforcement (unlike v36)
23912431
uint64_t donation_amount = (subsidy > sum_amounts) ? (subsidy - sum_amounts) : 0;
23922432

2393-
std::vector<std::pair<std::vector<unsigned char>, uint64_t>> payout_outputs(
2394-
amounts.begin(), amounts.end());
2433+
// F11: canonical exclude-then-append donation handling (p2pool data.py
2434+
// generate_transaction). The per-miner dests list excludes BOTH donation
2435+
// scripts; a COMBINED_DONATION_SCRIPT-keyed weight folds into the single
2436+
// donation-last output, and any DONATION_SCRIPT-keyed weight is dropped.
2437+
const std::vector<unsigned char> combined_donation_script(
2438+
core::donation::COMBINED_DONATION_SCRIPT.begin(), core::donation::COMBINED_DONATION_SCRIPT.end());
2439+
const std::vector<unsigned char> p2pk_donation_script(
2440+
core::donation::DONATION_SCRIPT.begin(), core::donation::DONATION_SCRIPT.end());
2441+
auto payout_outputs = build_payout_outputs_excluding_donation(
2442+
amounts, combined_donation_script, p2pk_donation_script, donation_amount);
23952443
std::sort(payout_outputs.begin(), payout_outputs.end(),
23962444
[](const auto& a, const auto& b) {
23972445
if (a.second != b.second) return a.second < b.second;
@@ -2944,8 +2992,16 @@ uint256 create_local_share(
29442992
}
29452993
}
29462994

2947-
std::vector<std::pair<std::vector<unsigned char>, uint64_t>> payout_outputs(
2948-
amounts.begin(), amounts.end());
2995+
// F11: canonical exclude-then-append donation handling (p2pool data.py
2996+
// generate_transaction). The per-miner dests list excludes BOTH donation
2997+
// scripts; a COMBINED_DONATION_SCRIPT-keyed weight folds into the single
2998+
// donation-last output, and any DONATION_SCRIPT-keyed weight is dropped.
2999+
const std::vector<unsigned char> combined_donation_script(
3000+
core::donation::COMBINED_DONATION_SCRIPT.begin(), core::donation::COMBINED_DONATION_SCRIPT.end());
3001+
const std::vector<unsigned char> p2pk_donation_script(
3002+
core::donation::DONATION_SCRIPT.begin(), core::donation::DONATION_SCRIPT.end());
3003+
auto payout_outputs = build_payout_outputs_excluding_donation(
3004+
amounts, combined_donation_script, p2pk_donation_script, donation_amount);
29493005
std::sort(payout_outputs.begin(), payout_outputs.end(),
29503006
[](const auto& a, const auto& b) {
29513007
if (a.second != b.second) return a.second < b.second;

src/impl/btc/test/CMakeLists.txt

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
if (BUILD_TESTING AND GTest_FOUND)
2-
add_executable(share_test share_test.cpp)
3-
target_link_libraries(share_test PRIVATE
2+
# btc twin of ltc share_test — uniquely named to avoid the CMP0002 target
3+
# collision with src/impl/ltc/test (both subdirs build in the same tree).
4+
add_executable(btc_share_test share_test.cpp f11_donation_invariance_test.cpp)
5+
target_link_libraries(btc_share_test PRIVATE
46
GTest::gtest_main GTest::gtest
5-
core sharechain
7+
core btc
68
)
9+
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
710

811
include(GoogleTest)
912
include_directories(${gtest_SOURCE_DIR}/include ${gtest_SOURCE_DIR})
10-
gtest_add_tests(share_test "" AUTO)
11-
endif()
13+
gtest_add_tests(btc_share_test "" AUTO)
14+
endif()
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// F11 value-invariance KAT — guards the canonical exclude-then-append donation
2+
// handling in the BTC v36 payout sort (src/impl/btc/share_check.hpp,
3+
// build_payout_outputs_excluding_donation).
4+
//
5+
// Backfills the coverage gap that let PR-0 S1 (133ae6bc) silently revert the
6+
// LTC F11 (18dd9457) on a stale base: there was no test asserting the parity
7+
// arithmetic, so the regression shipped unnoticed. This BTC twin asserts the
8+
// same invariant set against its own helper + the core::donation SSOT bytes.
9+
//
10+
// Invariant under test (mirrors p2pool data.py generate_transaction):
11+
// - per-miner payout dests EXCLUDE both donation scripts (COMBINED P2SH + P2PK)
12+
// - COMBINED_DONATION_SCRIPT-keyed weight FOLDS into the single donation-last
13+
// output (moved, not destroyed)
14+
// - DONATION_SCRIPT (P2PK)-keyed weight is DROPPED — value-neutral ONLY because
15+
// that key never accrues weight in canonical v36 operation
16+
// - total coinbase value out == subsidy (no value created/destroyed)
17+
//
18+
// Coordinated with the LTC source (ltc/test/f11_donation_invariance_test.cpp);
19+
// only the donation-constant source (core::donation SSOT) and the helper coin
20+
// namespace (btc::) differ.
21+
22+
#include <gtest/gtest.h>
23+
24+
#include <map>
25+
#include <vector>
26+
#include <cstdint>
27+
28+
#include <core/donation.hpp>
29+
#include <impl/btc/share_check.hpp>
30+
31+
namespace {
32+
33+
using Script = std::vector<unsigned char>;
34+
using Amounts = std::map<Script, uint64_t>;
35+
36+
Script combined_script() {
37+
return Script(core::donation::COMBINED_DONATION_SCRIPT.begin(),
38+
core::donation::COMBINED_DONATION_SCRIPT.end());
39+
}
40+
Script p2pk_script() {
41+
return Script(core::donation::DONATION_SCRIPT.begin(),
42+
core::donation::DONATION_SCRIPT.end());
43+
}
44+
// Distinct miner payout scripts (P2PKH-shaped; bytes are arbitrary but != donation).
45+
Script miner(unsigned char tag) {
46+
return Script{0x76, 0xa9, 0x14, tag, tag, tag, tag, tag, tag, tag, tag, tag, tag,
47+
tag, tag, tag, tag, tag, tag, tag, tag, tag, tag, 0x88, 0xac};
48+
}
49+
50+
uint64_t sum_amounts(const Amounts& a) {
51+
uint64_t s = 0; for (auto& kv : a) s += kv.second; return s;
52+
}
53+
uint64_t sum_outputs(const std::vector<std::pair<Script, uint64_t>>& o) {
54+
uint64_t s = 0; for (auto& kv : o) s += kv.second; return s;
55+
}
56+
bool contains_script(const std::vector<std::pair<Script, uint64_t>>& o, const Script& s) {
57+
for (auto& kv : o) if (kv.first == s) return true;
58+
return false;
59+
}
60+
61+
// Reproduces the production pre-fold accounting at each gentx site:
62+
// donation_amount = subsidy - sum(amounts), then the helper folds COMBINED in.
63+
struct FoldResult {
64+
std::vector<std::pair<Script, uint64_t>> payout_outputs;
65+
uint64_t donation_amount;
66+
};
67+
FoldResult run_fold(const Amounts& amounts, uint64_t subsidy) {
68+
uint64_t sa = sum_amounts(amounts);
69+
uint64_t donation_amount = (subsidy > sa) ? (subsidy - sa) : 0;
70+
auto outs = btc::build_payout_outputs_excluding_donation(
71+
amounts, combined_script(), p2pk_script(), donation_amount);
72+
return {outs, donation_amount};
73+
}
74+
75+
} // namespace
76+
77+
// Canonical case: COMBINED weight present, no P2PK weight. The fold must be
78+
// fully value-invariant — every satoshi of subsidy is accounted for.
79+
TEST(BTC_F11_DonationInvariance, CombinedFoldedNoP2PK) {
80+
const uint64_t subsidy = 1000;
81+
Amounts amounts{
82+
{miner(0x01), 100},
83+
{miner(0x02), 250},
84+
{miner(0x03), 75},
85+
{combined_script(), 40}, // donation weight keyed by COMBINED P2SH
86+
};
87+
88+
auto r = run_fold(amounts, subsidy);
89+
90+
// per-miner dests exclude the donation script
91+
EXPECT_EQ(r.payout_outputs.size(), 3u);
92+
EXPECT_FALSE(contains_script(r.payout_outputs, combined_script()));
93+
EXPECT_FALSE(contains_script(r.payout_outputs, p2pk_script()));
94+
EXPECT_TRUE(contains_script(r.payout_outputs, miner(0x01)));
95+
EXPECT_TRUE(contains_script(r.payout_outputs, miner(0x02)));
96+
EXPECT_TRUE(contains_script(r.payout_outputs, miner(0x03)));
97+
98+
// known answer: donation-last output grew by exactly the COMBINED weight
99+
// donation_initial = 1000 - (100+250+75+40) = 535 ; +40 folded = 575
100+
EXPECT_EQ(r.donation_amount, 575u);
101+
EXPECT_EQ(sum_outputs(r.payout_outputs), 425u);
102+
103+
// VALUE INVARIANCE: per-miner outputs + donation-last == subsidy
104+
EXPECT_EQ(sum_outputs(r.payout_outputs) + r.donation_amount, subsidy);
105+
}
106+
107+
// Both donation scripts present; P2PK at canonical weight 0. Both excluded from
108+
// per-miner dests; dropping the 0-weight P2PK key is value-neutral.
109+
TEST(BTC_F11_DonationInvariance, BothDonationScriptsExcluded_P2PKZeroWeight) {
110+
const uint64_t subsidy = 5000;
111+
Amounts amounts{
112+
{miner(0x0a), 1200},
113+
{miner(0x0b), 800},
114+
{combined_script(), 333},
115+
{p2pk_script(), 0}, // canonical: P2PK never accrues weight
116+
};
117+
118+
auto r = run_fold(amounts, subsidy);
119+
120+
EXPECT_EQ(r.payout_outputs.size(), 2u);
121+
EXPECT_FALSE(contains_script(r.payout_outputs, combined_script()));
122+
EXPECT_FALSE(contains_script(r.payout_outputs, p2pk_script()));
123+
124+
// donation_initial = 5000 - (1200+800+333+0) = 2667 ; +333 = 3000
125+
EXPECT_EQ(r.donation_amount, 3000u);
126+
EXPECT_EQ(sum_outputs(r.payout_outputs) + r.donation_amount, subsidy);
127+
}
128+
129+
// No donation keys at all: the fold is an identity over the miner set and the
130+
// donation-last output is unchanged.
131+
TEST(BTC_F11_DonationInvariance, NoDonationKeys_Identity) {
132+
const uint64_t subsidy = 2000;
133+
Amounts amounts{
134+
{miner(0x21), 600},
135+
{miner(0x22), 400},
136+
};
137+
138+
auto r = run_fold(amounts, subsidy);
139+
140+
EXPECT_EQ(r.payout_outputs.size(), 2u);
141+
EXPECT_EQ(r.donation_amount, 1000u); // 2000 - 1000, no fold
142+
EXPECT_EQ(sum_outputs(r.payout_outputs) + r.donation_amount, subsidy);
143+
}
144+
145+
// Boundary documentation: if the P2PK key DID carry weight (non-canonical),
146+
// dropping it would destroy exactly that many satoshis. This locks the reason
147+
// the canonical invariant requires P2PK weight == 0, so a future change that
148+
// starts keying weight on the P2PK script cannot pass silently.
149+
TEST(BTC_F11_DonationInvariance, DroppedP2PKWeightLeaksExactlyThatWeight) {
150+
const uint64_t subsidy = 1000;
151+
const uint64_t p2pk_weight = 30; // NON-canonical, for the boundary proof
152+
Amounts amounts{
153+
{miner(0x31), 200},
154+
{combined_script(), 50},
155+
{p2pk_script(), p2pk_weight},
156+
};
157+
158+
auto r = run_fold(amounts, subsidy);
159+
160+
// COMBINED still folds correctly; P2PK weight is dropped (not folded).
161+
EXPECT_FALSE(contains_script(r.payout_outputs, p2pk_script()));
162+
// Total is short by EXACTLY the dropped P2PK weight — value is conserved
163+
// iff p2pk_weight == 0.
164+
EXPECT_EQ(sum_outputs(r.payout_outputs) + r.donation_amount, subsidy - p2pk_weight);
165+
}
166+
167+
// Pin the actual consensus donation bytes so a constant change trips this test.
168+
TEST(BTC_F11_DonationInvariance, RealDonationConstantsPinned) {
169+
auto c = combined_script();
170+
ASSERT_EQ(c.size(), 23u); // P2SH: OP_HASH160 <20> OP_EQUAL
171+
EXPECT_EQ(c.front(), 0xa9);
172+
EXPECT_EQ(c[1], 0x14);
173+
EXPECT_EQ(c.back(), 0x87);
174+
175+
auto p = p2pk_script();
176+
ASSERT_EQ(p.size(), 67u); // P2PK: OP_PUSHBYTES_65 <65> OP_CHECKSIG
177+
EXPECT_EQ(p.front(), 0x41);
178+
EXPECT_EQ(p.back(), 0xac);
179+
180+
EXPECT_NE(c, p);
181+
}

0 commit comments

Comments
 (0)