Skip to content

Commit b534c85

Browse files
authored
Merge pull request #349 from frstrtr/ltc-doge/f11-reapply-value-invariance-kat
ltc(v36): re-apply F11 canonical donation fold + value-invariance KAT
2 parents 1b73a41 + 31a0c2e commit b534c85

3 files changed

Lines changed: 234 additions & 7 deletions

File tree

src/impl/ltc/share_check.hpp

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -929,6 +929,39 @@ inline std::vector<unsigned char> get_share_script(const auto* obj)
929929
//
930930
// Reference: frstrtr/p2pool-merged-v36 p2pool/data.py generate_transaction()
931931
// ============================================================================
932+
// ---------------------------------------------------------------------------
933+
// F11: canonical exclude-then-append donation handling for the payout sort.
934+
//
935+
// Mirrors p2pool data.py generate_transaction: the per-miner payout dests
936+
// exclude BOTH donation scripts; any COMBINED_DONATION_SCRIPT-keyed weight is
937+
// folded into the single donation-last output, and any DONATION_SCRIPT (P2PK)
938+
// keyed weight is dropped. Value-invariant: the COMBINED weight is moved (not
939+
// destroyed) into the donation output; dropping the P2PK key is value-neutral
940+
// only because that key never accrues weight in canonical v36 operation.
941+
//
942+
// Re-applied after PR-0 S1 (133ae6bc) overwrote the original F11 (18dd9457) on
943+
// a stale base. Guarded by test/f11_donation_invariance_test.cpp.
944+
// ---------------------------------------------------------------------------
945+
template <typename AmountsMap>
946+
inline std::vector<std::pair<std::vector<unsigned char>, uint64_t>>
947+
build_payout_outputs_excluding_donation(
948+
const AmountsMap& amounts,
949+
const std::vector<unsigned char>& combined_donation_script,
950+
const std::vector<unsigned char>& p2pk_donation_script,
951+
uint64_t& donation_amount)
952+
{
953+
if (auto it = amounts.find(combined_donation_script); it != amounts.end())
954+
donation_amount += it->second;
955+
std::vector<std::pair<std::vector<unsigned char>, uint64_t>> payout_outputs;
956+
payout_outputs.reserve(amounts.size());
957+
for (const auto& kv : amounts) {
958+
if (kv.first == combined_donation_script || kv.first == p2pk_donation_script)
959+
continue;
960+
payout_outputs.emplace_back(kv.first, kv.second);
961+
}
962+
return payout_outputs;
963+
}
964+
932965
template <typename ShareT, typename TrackerT>
933966
uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker, const core::CoinParams& params, bool dump_diag = false, bool v36_active = false)
934967
{
@@ -1118,8 +1151,12 @@ uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker, const
11181151
auto gst_t2 = std::chrono::steady_clock::now(); // after amounts
11191152
// Python: sorted(dests, key=lambda a: (amounts[a], a))[-4000:]
11201153
// = ascending by (amount, script), keep last 4000 (highest amounts)
1121-
std::vector<std::pair<std::vector<unsigned char>, uint64_t>> payout_outputs(
1122-
amounts.begin(), amounts.end());
1154+
// F11: exclude BOTH donation scripts from per-miner dests; fold COMBINED
1155+
// weight into donation-last output (p2pool data.py generate_transaction).
1156+
const std::vector<unsigned char> combined_donation_script = params.donation_script_func(36);
1157+
const std::vector<unsigned char> p2pk_donation_script = params.donation_script_func(35);
1158+
auto payout_outputs = build_payout_outputs_excluding_donation(
1159+
amounts, combined_donation_script, p2pk_donation_script, donation_amount);
11231160
std::sort(payout_outputs.begin(), payout_outputs.end(),
11241161
[](const auto& a, const auto& b) {
11251162
if (a.second != b.second) return a.second < b.second; // asc by amount
@@ -2391,8 +2428,12 @@ uint256 create_local_share_v35(
23912428
// V35: no minimum donation enforcement (unlike v36)
23922429
uint64_t donation_amount = (subsidy > sum_amounts) ? (subsidy - sum_amounts) : 0;
23932430

2394-
std::vector<std::pair<std::vector<unsigned char>, uint64_t>> payout_outputs(
2395-
amounts.begin(), amounts.end());
2431+
// F11: exclude BOTH donation scripts from per-miner dests; fold COMBINED
2432+
// weight into donation-last output (p2pool data.py generate_transaction).
2433+
const std::vector<unsigned char> combined_donation_script = params.donation_script_func(36);
2434+
const std::vector<unsigned char> p2pk_donation_script = params.donation_script_func(35);
2435+
auto payout_outputs = build_payout_outputs_excluding_donation(
2436+
amounts, combined_donation_script, p2pk_donation_script, donation_amount);
23962437
std::sort(payout_outputs.begin(), payout_outputs.end(),
23972438
[](const auto& a, const auto& b) {
23982439
if (a.second != b.second) return a.second < b.second;
@@ -2931,8 +2972,12 @@ uint256 create_local_share(
29312972
}
29322973
}
29332974

2934-
std::vector<std::pair<std::vector<unsigned char>, uint64_t>> payout_outputs(
2935-
amounts.begin(), amounts.end());
2975+
// F11: exclude BOTH donation scripts from per-miner dests; fold COMBINED
2976+
// weight into donation-last output (p2pool data.py generate_transaction).
2977+
const std::vector<unsigned char> combined_donation_script = params.donation_script_func(36);
2978+
const std::vector<unsigned char> p2pk_donation_script = params.donation_script_func(35);
2979+
auto payout_outputs = build_payout_outputs_excluding_donation(
2980+
amounts, combined_donation_script, p2pk_donation_script, donation_amount);
29362981
std::sort(payout_outputs.begin(), payout_outputs.end(),
29372982
[](const auto& a, const auto& b) {
29382983
if (a.second != b.second) return a.second < b.second;

src/impl/ltc/test/CMakeLists.txt

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

0 commit comments

Comments
 (0)