diff --git a/src/impl/btc/stratum/finder_fee.hpp b/src/impl/btc/stratum/finder_fee.hpp new file mode 100644 index 000000000..9e4a3ad15 --- /dev/null +++ b/src/impl/btc/stratum/finder_fee.hpp @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +#pragma once + +// btc::stratum::v35_finder_fee_split — integer-exact v35 block-finder fee. +// +// The mined-block coinbase moves 0.5% of the coinbase value from the donation +// residual to the share-finder's payout output. This mirrors the p2pool +// reference (data.py generate_transaction): +// amounts[finder] += share_data['subsidy'] // 200 # 0.5%, floor, unconditional +// where p2pool's `subsidy` is the block-template coinbasevalue (subsidy + +// fees) — so the c2pool base is coinbasevalue (matching core/web_server.cpp). +// +// The consensus share gentx already computes this integer-exactly; this SSOT +// keeps the block-coinbase money path identical: floor division, NO float, so +// there is no sub-satoshi rounding divergence on a won block. +// +// Returns the satoshis actually moved: floor(coinbasevalue / 200), capped at +// the donation available so the coinbase stays balanced (total == subsidy) and +// no output goes negative. The cap only binds in a pathological rounding edge — +// in canonical operation the donation residual (~0.5%) covers the fee (~0.5%), +// so the full fee is always applied (unconditional; never all-or-nothing +// skipped as the prior float path did when donation < fee). + +#include +#include + +namespace btc::stratum { + +inline uint64_t v35_finder_fee_split(uint64_t coinbasevalue, uint64_t donation_sats) +{ + const uint64_t finder_fee = coinbasevalue / 200; // integer floor == subsidy//200 + return std::min(finder_fee, donation_sats); +} + +} // namespace btc::stratum diff --git a/src/impl/btc/stratum/work_source.cpp b/src/impl/btc/stratum/work_source.cpp index c8819f856..87c1fc8af 100644 --- a/src/impl/btc/stratum/work_source.cpp +++ b/src/impl/btc/stratum/work_source.cpp @@ -13,6 +13,7 @@ #include #include // H5 tx_data memo seam (work_source.cpp:634 churn fix) +#include // D2 v35_finder_fee_split integer-exact SSOT #include #include @@ -446,23 +447,24 @@ core::stratum::CoinbaseResult BTCWorkSource::build_connection_coinbase( } } - // v35 finder fee: 0.5% of subsidy goes to the share-finder (this miner), - // deducted from donation. Reference: c2pool_refactored.cpp wiring + - // share_tracker.hpp v35 PPLNS docs ("amounts WITHOUT finder fee — caller - // adds subsidy/200 to the share creator's script"). Conditional on having - // a non-empty payout_script — otherwise we'd reintroduce the empty-output - // bug we just filtered. And the deduction-from-donation must succeed - // (donation must hold ≥ finder_fee), else we'd inflate total > subsidy. - if (!payouts.empty() && coinbasevalue > 0 && !payout_script.empty()) { - const double finder_fee = static_cast(coinbasevalue) / 200.0; - if (!donation_script.empty()) { - auto it = payouts.find(donation_script); - if (it != payouts.end() && it->second >= finder_fee) { - it->second -= finder_fee; - payouts[payout_script] += finder_fee; - } - // else: donation can't cover the fee — skip silently. Total stays - // at subsidy (per get_v35_expected_payouts post-condition). + // v35 finder fee: 0.5% of the coinbase value goes to the share-finder + // (this miner), moved from the donation residual to the finder's output. + // Integer floor division (subsidy//200) via the v35_finder_fee_split SSOT — + // NO float on the money path (see finder_fee.hpp). Matches the p2pool + // reference (data.py generate_transaction: amounts[finder] += subsidy//200, + // unconditional) and the core/web_server.cpp preview path. The split is + // capped at the donation available, so it is applied unconditionally + // (never all-or-nothing skipped) while total stays == subsidy and no output + // goes negative. The map holds doubles, but the moved value is an exact + // integer satoshi count (<= 2^53, exact in double). + if (!payouts.empty() && coinbasevalue > 0 && !payout_script.empty() + && !donation_script.empty()) { + auto it = payouts.find(donation_script); + if (it != payouts.end()) { + const uint64_t donation_sats = static_cast(it->second); + const uint64_t taken = v35_finder_fee_split(coinbasevalue, donation_sats); + it->second -= static_cast(taken); + payouts[payout_script] += static_cast(taken); } } diff --git a/src/impl/btc/test/CMakeLists.txt b/src/impl/btc/test/CMakeLists.txt index 313462d35..2cbf0521a 100644 --- a/src/impl/btc/test/CMakeLists.txt +++ b/src/impl/btc/test/CMakeLists.txt @@ -1,7 +1,7 @@ if (BUILD_TESTING AND GTest_FOUND) # 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 f10b_version_punish_removal_test.cpp g01_share_format_parity_test.cpp auto_ratchet_sim_test.cpp block_broadcast_guard_test.cpp block_broadcast_connect_test.cpp regtest_sharechain_isolation_test.cpp stratum_merkle_branch_test.cpp witness_commitment_merkle_test.cpp) + add_executable(btc_share_test share_test.cpp f11_donation_invariance_test.cpp f10b_version_punish_removal_test.cpp g01_share_format_parity_test.cpp auto_ratchet_sim_test.cpp block_broadcast_guard_test.cpp block_broadcast_connect_test.cpp regtest_sharechain_isolation_test.cpp stratum_merkle_branch_test.cpp witness_commitment_merkle_test.cpp finder_fee_integer_exact_test.cpp) target_link_libraries(btc_share_test PRIVATE GTest::gtest_main GTest::gtest core btc diff --git a/src/impl/btc/test/finder_fee_integer_exact_test.cpp b/src/impl/btc/test/finder_fee_integer_exact_test.cpp new file mode 100644 index 000000000..0dc255d6a --- /dev/null +++ b/src/impl/btc/test/finder_fee_integer_exact_test.cpp @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// D2 finder-fee integer-exactness KAT. +// +// Pins the mined-block coinbase block-finder fee split to the p2pool reference +// as INTEGER floor division, closing the latent satoshi-rounding divergence +// that the prior float path (coinbasevalue/200.0) carried onto the money path +// of a won block in src/impl/btc/stratum/work_source.cpp. +// +// Reference (jtoomim p2pool data.py generate_transaction, verified against +// ~/p2pool-jtoomim@ece15b0): +// amounts = {script: subsidy*(199*w)//(200*W) ...} # 99.5% by weight +// amounts[finder] += subsidy // 200 # 0.5% finder fee, floor, unconditional +// amounts[donation] += subsidy - sum(amounts) # residual balances to subsidy +// p2pool's `subsidy` is the block-template coinbasevalue (subsidy + fees), so +// the c2pool base is coinbasevalue — matching src/core/web_server.cpp:1888. +// +// The consensus share gentx already matched this; D2 makes the block-coinbase +// path identical (integer floor, no float, unconditional application). + +#include + +#include +#include + +#include + +using btc::stratum::v35_finder_fee_split; + +namespace { + +// The jtoomim reference operation: integer floor division, subsidy//200. +uint64_t reference_finder_fee(uint64_t coinbasevalue) { return coinbasevalue / 200; } + +} // namespace + +// When the donation residual covers the fee (the canonical case: donation +// ~0.5% >= finder fee ~0.5%), the moved amount equals the integer reference +// exactly — for both round and odd coinbase values. +TEST(BtcFinderFeeD2, MatchesJtoomimIntegerFormula) { + const uint64_t big_donation = 1'000'000'000ULL; // always covers the fee + for (uint64_t cbv : {0ULL, 199ULL, 200ULL, 312'500'000ULL, 312'500'100ULL, + 312'500'199ULL, 512'345'678ULL, 625'000'001ULL}) { + EXPECT_EQ(v35_finder_fee_split(cbv, big_donation), reference_finder_fee(cbv)) + << "cbv=" << cbv; + } +} + +// Sub-200 coinbase -> floor(cbv/200)==0 -> finder gets nothing; no negative +// output, no phantom satoshi. +TEST(BtcFinderFeeD2, BelowThresholdIsZero) { + EXPECT_EQ(v35_finder_fee_split(0ULL, 1000ULL), 0ULL); + EXPECT_EQ(v35_finder_fee_split(199ULL, 1000ULL), 0ULL); + EXPECT_EQ(v35_finder_fee_split(200ULL, 1000ULL), 1ULL); +} + +// Donation cap: never move more than the donation holds, so the coinbase stays +// balanced (total == subsidy) and the donation output never goes negative. +TEST(BtcFinderFeeD2, CappedByDonation) { + // Fee would be 1'562'500 but the donation only holds 1'000. + EXPECT_EQ(v35_finder_fee_split(312'500'199ULL, 1'000ULL), 1'000ULL); + // Exact-cover boundary and one satoshi short of it. + EXPECT_EQ(v35_finder_fee_split(312'500'000ULL, 1'562'500ULL), 1'562'500ULL); + EXPECT_EQ(v35_finder_fee_split(312'500'000ULL, 1'562'499ULL), 1'562'499ULL); +} + +// Divergence witness: at odd coinbase values the OLD float path carried a +// fractional satoshi (coinbasevalue/200.0) onto the money path, and naive +// float-rounding disagreed with the integer floor by a full satoshi — exactly +// the divergence D2 closes. +TEST(BtcFinderFeeD2, FloatPathDivergesFromInteger) { + const uint64_t cbv = 312'500'199ULL; + const double float_fee = static_cast(cbv) / 200.0; // 1562500.995 + // The old float fee carried a sub-satoshi fraction: + EXPECT_NE(float_fee, std::trunc(float_fee)); + // Rounding that float disagrees with the integer floor by one satoshi: + EXPECT_EQ(static_cast(std::llround(float_fee)), 1'562'501ULL); + EXPECT_EQ(v35_finder_fee_split(cbv, 1'000'000'000ULL), 1'562'500ULL); + EXPECT_NE(static_cast(std::llround(float_fee)), + v35_finder_fee_split(cbv, 1'000'000'000ULL)); +}