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