Skip to content

Commit 982aea1

Browse files
committed
Fix PPLNS: remove desired_weight cap for V36 decayed walks
On low-difficulty networks (testnet) where share difficulty exceeds block difficulty, the PPLNS desired_weight cap (65535 * SPREAD * block_att) covers only ~2 shares. With V36 exponential decay, the first share fills most of the cap, causing blocks to pay only the most recent miner instead of distributing across the full window. The cap was designed for pre-V36 non-decayed PPLNS to bound total weight. With exponential decay (half-life = CHAIN_LENGTH/4), old shares naturally contribute negligibly — the cap is redundant. Fix: pass unlimited desired_weight for V36 decayed walks in both generate_share_transaction() and get_expected_payouts(). Pre-V36 paths unchanged. Matches p2pool-merged-v36 commit 4b738ba8. Also adds test_decay_pplns (5 tests) verifying C++ decay computation matches Python p2pool exactly, and GENTX-MISMATCH diagnostic dump.
1 parent b093999 commit 982aea1

5 files changed

Lines changed: 324 additions & 10 deletions

File tree

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ jobs:
8787
test_header_chain test_mempool test_template_builder \
8888
test_doge_chain test_compact_blocks \
8989
test_multiaddress_pplns test_pplns_stress \
90-
test_hash_link \
90+
test_hash_link test_decay_pplns \
9191
test_phase0_live test_phase1_live test_phase2_live \
9292
test_phase3_live test_phase4_embedded test_phase4_live \
9393
-j$(nproc)

src/impl/ltc/share_check.hpp

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -768,7 +768,7 @@ inline std::vector<unsigned char> get_share_script(const auto* obj)
768768
// Reference: frstrtr/p2pool-merged-v36 p2pool/data.py generate_transaction()
769769
// ============================================================================
770770
template <typename ShareT, typename TrackerT>
771-
uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker)
771+
uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker, bool dump_diag = false)
772772
{
773773
constexpr int64_t ver = ShareT::version;
774774
const uint64_t subsidy = share.m_subsidy;
@@ -795,8 +795,13 @@ uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker)
795795
* PoolConfig::SPREAD * 65535;
796796

797797
// V36: use exponential depth-decay (matching Python's get_decayed_cumulative_weights)
798+
// Remove desired_weight cap — exponential decay naturally limits old shares'
799+
// contribution. The cap is harmful on low-difficulty networks (testnet) where
800+
// block_att < share_att, truncating the PPLNS window to ~2 shares.
798801
if constexpr (ver >= 36) {
799-
auto result = tracker.get_v36_decayed_cumulative_weights(prev_hash, chain_len, max_weight);
802+
uint288 unlimited_weight;
803+
unlimited_weight.SetHex("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff");
804+
auto result = tracker.get_v36_decayed_cumulative_weights(prev_hash, chain_len, unlimited_weight);
800805
weights = std::move(result.weights);
801806
total_weight = result.total_weight;
802807
total_donation_weight = result.total_donation_weight;
@@ -1156,7 +1161,53 @@ uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker)
11561161
// --- 5. Compute txid (double-SHA256 of non-witness serialization) ---
11571162
auto tx_span = std::span<const unsigned char>(
11581163
reinterpret_cast<const unsigned char*>(tx.data()), tx.size());
1159-
return Hash(tx_span);
1164+
auto txid = Hash(tx_span);
1165+
1166+
if (dump_diag)
1167+
{
1168+
const char* HX = "0123456789abcdef";
1169+
auto to_hex = [&](const unsigned char* p, size_t len) {
1170+
std::string h; h.reserve(len * 2);
1171+
for (size_t i = 0; i < len; ++i) { h += HX[p[i] >> 4]; h += HX[p[i] & 0xf]; }
1172+
return h;
1173+
};
1174+
1175+
auto* cp = reinterpret_cast<const unsigned char*>(tx.data());
1176+
LOG_WARNING << "[GENTX-DIAG] coinbase_len=" << tx.size() << " txid=" << txid.GetHex();
1177+
LOG_WARNING << "[GENTX-DIAG] coinbase_hex=" << to_hex(cp, tx.size());
1178+
LOG_WARNING << "[GENTX-DIAG] pplns_outputs=" << payout_outputs.size()
1179+
<< " donation_amount=" << donation_amount
1180+
<< " n_outs=" << n_outs
1181+
<< " has_segwit=" << has_segwit;
1182+
LOG_WARNING << "[GENTX-DIAG] total_weight=" << total_weight.GetHex()
1183+
<< " total_don_weight=" << total_donation_weight.GetHex();
1184+
for (size_t i = 0; i < payout_outputs.size(); ++i) {
1185+
auto& [s, a] = payout_outputs[i];
1186+
LOG_WARNING << "[GENTX-DIAG] payout[" << i << "] amount=" << a
1187+
<< " script=" << to_hex(s.data(), s.size());
1188+
}
1189+
// First 5 shares in walk
1190+
if (!share.m_prev_hash.IsNull() && tracker.chain.contains(share.m_prev_hash)) {
1191+
auto cl = std::min(tracker.chain.get_height(share.m_prev_hash),
1192+
static_cast<int32_t>(PoolConfig::real_chain_length()));
1193+
auto wv = tracker.chain.get_chain(share.m_prev_hash, std::min(cl, int32_t(5)));
1194+
int si = 0;
1195+
for (auto& [h, d] : wv) {
1196+
d.share.invoke([&](auto* obj) {
1197+
auto att = chain::target_to_average_attempts(chain::bits_to_target(obj->m_bits));
1198+
auto sc = get_share_script(obj);
1199+
LOG_WARNING << "[GENTX-DIAG] walk[" << si << "] hash=" << h.ToString().substr(0,16)
1200+
<< " bits=0x" << std::hex << obj->m_bits << std::dec
1201+
<< " att=" << att.GetHex()
1202+
<< " don=" << obj->m_donation
1203+
<< " script=" << to_hex(sc.data(), sc.size());
1204+
});
1205+
++si;
1206+
}
1207+
}
1208+
}
1209+
1210+
return txid;
11601211
}
11611212

11621213
// ============================================================================
@@ -1361,14 +1412,22 @@ bool share_check(const ShareT& share,
13611412
<< " prev=" << share.m_prev_hash.ToString().substr(0,16);
13621413
LOG_WARNING << " expected_gentx=" << expected_gentx.ToString().substr(0,32)
13631414
<< " actual_gentx=" << gentx_hash.ToString().substr(0,32);
1364-
1415+
13651416
auto chain_len = std::min(
13661417
tracker.chain.get_height(share.m_prev_hash),
13671418
static_cast<int32_t>(PoolConfig::real_chain_length()));
13681419
LOG_WARNING << " PPLNS chain_len=" << chain_len
13691420
<< " prev_height=" << tracker.chain.get_height(share.m_prev_hash)
13701421
<< " real_chain_length=" << PoolConfig::real_chain_length();
13711422

1423+
// --- Detailed diagnostics: re-run with full dump ---
1424+
static int s_diag_count = 0;
1425+
if (s_diag_count++ < 2)
1426+
{
1427+
LOG_WARNING << "[GENTX-DIAG] Re-running generate_share_transaction with full dump:";
1428+
generate_share_transaction(share, tracker, true);
1429+
}
1430+
13721431
throw std::invalid_argument("GenerateShareTransaction mismatch — coinbase does not match PPLNS payouts");
13731432
}
13741433
}

src/impl/ltc/share_tracker.hpp

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -867,11 +867,11 @@ class ShareTracker
867867
{
868868
auto chain_len = std::min(chain.get_height(best_share_hash),
869869
static_cast<int32_t>(PoolConfig::real_chain_length()));
870-
auto max_weight = chain::target_to_average_attempts(block_target)
871-
* PoolConfig::SPREAD * 65535;
872-
873-
// V36: use exponential depth-decay (matching Python's get_decayed_cumulative_weights)
874-
auto [weights, total_weight, donation_weight] = get_v36_decayed_cumulative_weights(best_share_hash, chain_len, max_weight);
870+
// V36: remove desired_weight cap — exponential decay handles windowing.
871+
// See generate_share_transaction() for detailed rationale.
872+
uint288 unlimited_weight;
873+
unlimited_weight.SetHex("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff");
874+
auto [weights, total_weight, donation_weight] = get_v36_decayed_cumulative_weights(best_share_hash, chain_len, unlimited_weight);
875875

876876
std::map<std::vector<unsigned char>, double> result;
877877
uint64_t sum = 0;

test/CMakeLists.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,4 +217,13 @@ if (BUILD_TESTING AND GTest_FOUND)
217217
${Boost_LIBRARIES}
218218
)
219219
gtest_add_tests(test_hash_link "" AUTO)
220+
221+
add_executable(test_decay_pplns test_decay_pplns.cpp)
222+
target_link_libraries(test_decay_pplns PRIVATE
223+
GTest::gtest_main GTest::gtest
224+
core ltc
225+
nlohmann_json::nlohmann_json
226+
${Boost_LIBRARIES}
227+
)
228+
gtest_add_tests(test_decay_pplns "" AUTO)
220229
endif()

test/test_decay_pplns.cpp

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
#include <gtest/gtest.h>
2+
#include <core/uint256.hpp>
3+
#include <cstdint>
4+
#include <map>
5+
#include <string>
6+
#include <vector>
7+
8+
// Replicate the decay PPLNS computation from share_tracker.hpp
9+
// to verify it matches Python exactly.
10+
11+
struct DecayResult {
12+
std::map<std::string, uint288> weights;
13+
uint288 total_weight;
14+
uint288 total_donation_weight;
15+
};
16+
17+
struct ShareData {
18+
uint64_t att;
19+
uint32_t donation;
20+
std::string address;
21+
};
22+
23+
DecayResult compute_decayed_weights(
24+
const std::vector<ShareData>& shares,
25+
int32_t max_shares,
26+
const uint288& desired_weight,
27+
uint32_t chain_length)
28+
{
29+
static constexpr uint64_t DECAY_PRECISION = 40;
30+
static constexpr uint64_t DECAY_SCALE = uint64_t(1) << DECAY_PRECISION;
31+
static constexpr uint64_t LN2_MICRO = 693147;
32+
33+
uint32_t half_life = std::max(chain_length / 4, uint32_t(1));
34+
uint64_t decay_per = DECAY_SCALE - (DECAY_SCALE * LN2_MICRO) / (uint64_t(1000000) * half_life);
35+
36+
DecayResult result;
37+
int32_t share_count = 0;
38+
uint64_t decay_fp = DECAY_SCALE;
39+
40+
for (const auto& share : shares)
41+
{
42+
if (share_count >= max_shares)
43+
break;
44+
45+
uint288 att(share.att);
46+
uint32_t don = share.donation;
47+
48+
uint288 decayed_att = (att * uint288(decay_fp)) >> DECAY_PRECISION;
49+
50+
auto addr_w = decayed_att * static_cast<uint32_t>(65535 - don);
51+
auto don_w = decayed_att * don;
52+
auto this_total = addr_w + don_w;
53+
54+
if (result.total_weight + this_total > desired_weight) {
55+
auto remaining = desired_weight - result.total_weight;
56+
if (!this_total.IsNull()) {
57+
addr_w = addr_w * remaining / this_total;
58+
don_w = don_w * remaining / this_total;
59+
}
60+
this_total = remaining;
61+
}
62+
63+
result.weights[share.address] += addr_w;
64+
result.total_weight += this_total;
65+
result.total_donation_weight += don_w;
66+
67+
++share_count;
68+
if (result.total_weight >= desired_weight)
69+
break;
70+
71+
decay_fp = static_cast<uint64_t>(
72+
(static_cast<__uint128_t>(decay_fp) * decay_per) >> DECAY_PRECISION);
73+
}
74+
75+
return result;
76+
}
77+
78+
TEST(DecayPPLNS, MatchesPythonExactValues)
79+
{
80+
// Same 10-share test case as Python test_output_for_cpp_comparison
81+
std::vector<ShareData> shares = {
82+
{1000000, 50, "addr_A"},
83+
{2000000, 50, "addr_B"},
84+
{1500000, 50, "addr_A"},
85+
{1000000, 50, "addr_B"},
86+
{3000000, 50, "addr_A"},
87+
{1000000, 50, "addr_B"},
88+
{1000000, 50, "addr_A"},
89+
{2000000, 50, "addr_B"},
90+
{1000000, 50, "addr_A"},
91+
{1500000, 50, "addr_B"},
92+
};
93+
94+
uint288 unlimited;
95+
unlimited.SetHex("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff");
96+
97+
auto result = compute_decayed_weights(shares, 10, unlimited, 400);
98+
99+
// Expected values from Python:
100+
// total_weight=953350555395
101+
// total_donation_weight=727359850
102+
// weight[addr_A]=478168850600
103+
// weight[addr_B]=474454344945
104+
EXPECT_EQ(result.total_weight.GetLow64(), uint64_t(953350555395));
105+
EXPECT_EQ(result.total_donation_weight.GetLow64(), uint64_t(727359850));
106+
EXPECT_EQ(result.weights["addr_A"].GetLow64(), uint64_t(478168850600));
107+
EXPECT_EQ(result.weights["addr_B"].GetLow64(), uint64_t(474454344945));
108+
109+
// Verify: weights + donation = total
110+
uint288 sum_weights = result.weights["addr_A"] + result.weights["addr_B"];
111+
EXPECT_EQ(sum_weights + result.total_donation_weight, result.total_weight);
112+
}
113+
114+
TEST(DecayPPLNS, TwoMinersAlternating400Shares)
115+
{
116+
// 400 shares, miners alternate, unlimited weight
117+
std::vector<ShareData> shares;
118+
for (int i = 0; i < 400; ++i)
119+
shares.push_back({1541819, 0, (i % 2 == 0) ? "A" : "B"});
120+
121+
uint288 unlimited;
122+
unlimited.SetHex("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff");
123+
124+
auto result = compute_decayed_weights(shares, 400, unlimited, 400);
125+
126+
// Python: A=6861325693665, B=6813766223280, total=13675091916945
127+
EXPECT_EQ(result.weights["A"].GetLow64(), uint64_t(6861325693665));
128+
EXPECT_EQ(result.weights["B"].GetLow64(), uint64_t(6813766223280));
129+
EXPECT_EQ(result.total_weight.GetLow64(), uint64_t(13675091916945));
130+
131+
// Check payout amounts
132+
uint64_t subsidy = 156250000;
133+
uint64_t amt_A = (uint288(subsidy) * result.weights["A"] / result.total_weight).GetLow64();
134+
uint64_t amt_B = (uint288(subsidy) * result.weights["B"] / result.total_weight).GetLow64();
135+
136+
// Python: A=78396704, B=77853295
137+
EXPECT_EQ(amt_A, uint64_t(78396704));
138+
EXPECT_EQ(amt_B, uint64_t(77853295));
139+
}
140+
141+
TEST(DecayPPLNS, CappedVsUnlimited)
142+
{
143+
// Verify that capped desired_weight truncates to ~2 shares
144+
std::vector<ShareData> shares;
145+
for (int i = 0; i < 400; ++i)
146+
shares.push_back({1541819, 0, (i % 2 == 0) ? "A" : "B"});
147+
148+
// Capped: block_att * SPREAD * 65535
149+
uint64_t block_att = 1048577;
150+
uint288 capped_desired = uint288(65535) * uint288(3) * uint288(block_att);
151+
152+
auto result_cap = compute_decayed_weights(shares, 400, capped_desired, 400);
153+
154+
// Capped total should equal desired_weight exactly (cap was hit)
155+
EXPECT_EQ(result_cap.total_weight, capped_desired);
156+
157+
// With unlimited
158+
uint288 unlimited;
159+
unlimited.SetHex("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff");
160+
auto result_ulim = compute_decayed_weights(shares, 400, unlimited, 400);
161+
162+
// Unlimited total should be much larger
163+
EXPECT_TRUE(result_ulim.total_weight > capped_desired);
164+
165+
// Both should have 2 addresses
166+
EXPECT_EQ(result_cap.weights.size(), 2u);
167+
EXPECT_EQ(result_ulim.weights.size(), 2u);
168+
169+
// Unlimited should give more equal distribution
170+
uint64_t subsidy = 156250000;
171+
uint64_t cap_A = (uint288(subsidy) * result_cap.weights["A"] / result_cap.total_weight).GetLow64();
172+
uint64_t cap_B = (uint288(subsidy) * result_cap.weights["B"] / result_cap.total_weight).GetLow64();
173+
uint64_t ulim_A = (uint288(subsidy) * result_ulim.weights["A"] / result_ulim.total_weight).GetLow64();
174+
uint64_t ulim_B = (uint288(subsidy) * result_ulim.weights["B"] / result_ulim.total_weight).GetLow64();
175+
176+
// Unlimited should be closer to 50/50
177+
int64_t cap_diff = std::abs(int64_t(cap_A) - int64_t(cap_B));
178+
int64_t ulim_diff = std::abs(int64_t(ulim_A) - int64_t(ulim_B));
179+
EXPECT_LT(ulim_diff, cap_diff);
180+
181+
std::cout << "Capped: A=" << cap_A << " B=" << cap_B << " diff=" << cap_diff << std::endl;
182+
std::cout << "Unlimited: A=" << ulim_A << " B=" << ulim_B << " diff=" << ulim_diff << std::endl;
183+
}
184+
185+
TEST(DecayPPLNS, DecayConverges)
186+
{
187+
// Total weight should converge as we add more shares
188+
// (decay makes old shares negligible)
189+
std::vector<ShareData> shares;
190+
for (int i = 0; i < 4000; ++i)
191+
shares.push_back({1541819, 50, "miner"});
192+
193+
uint288 unlimited;
194+
unlimited.SetHex("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff");
195+
196+
auto r400 = compute_decayed_weights(shares, 400, unlimited, 400);
197+
auto r4000 = compute_decayed_weights(shares, 4000, unlimited, 400);
198+
199+
// Ratio should be close to 1.0 (within 10%)
200+
// Python: ratio = 1.065979
201+
double ratio = static_cast<double>(r4000.total_weight.GetLow64()) /
202+
static_cast<double>(r400.total_weight.GetLow64());
203+
EXPECT_GT(ratio, 1.0);
204+
EXPECT_LT(ratio, 1.1);
205+
206+
std::cout << "400 shares: total_weight=" << r400.total_weight.GetLow64() << std::endl;
207+
std::cout << "4000 shares: total_weight=" << r4000.total_weight.GetLow64() << std::endl;
208+
std::cout << "Ratio: " << ratio << std::endl;
209+
}
210+
211+
TEST(DecayPPLNS, PerShareDecayFactors)
212+
{
213+
// Verify individual decay_fp values match Python
214+
static constexpr uint64_t DECAY_PRECISION = 40;
215+
static constexpr uint64_t DECAY_SCALE = uint64_t(1) << DECAY_PRECISION;
216+
static constexpr uint64_t LN2_MICRO = 693147;
217+
218+
uint32_t chain_length = 400;
219+
uint32_t half_life = chain_length / 4; // = 100
220+
uint64_t decay_per = DECAY_SCALE - (DECAY_SCALE * LN2_MICRO) / (uint64_t(1000000) * half_life);
221+
222+
// Python: decay_per=1091890395914 (hex: fe39bd3b0a)
223+
EXPECT_EQ(decay_per, uint64_t(1091890395914));
224+
225+
// Verify first few decay_fp values
226+
uint64_t decay_fp = DECAY_SCALE;
227+
uint64_t expected_fps[] = {
228+
1099511627776, // share[0]
229+
1091890395914, // share[1]
230+
1084321990392, // share[2]
231+
1076806045045, // share[3]
232+
1069342196248, // share[4]
233+
};
234+
235+
for (int i = 0; i < 5; ++i) {
236+
EXPECT_EQ(decay_fp, expected_fps[i]) << "Mismatch at share " << i;
237+
decay_fp = static_cast<uint64_t>(
238+
(static_cast<__uint128_t>(decay_fp) * decay_per) >> DECAY_PRECISION);
239+
}
240+
}
241+
242+
int main(int argc, char** argv)
243+
{
244+
::testing::InitGoogleTest(&argc, argv);
245+
return RUN_ALL_TESTS();
246+
}

0 commit comments

Comments
 (0)