Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/impl/btc/stratum/finder_fee.hpp
Original file line number Diff line number Diff line change
@@ -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 <algorithm>
#include <cstdint>

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
36 changes: 19 additions & 17 deletions src/impl/btc/stratum/work_source.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

#include <impl/btc/stratum/work_source.hpp>
#include <impl/btc/stratum/tx_data_memo.hpp> // H5 tx_data memo seam (work_source.cpp:634 churn fix)
#include <impl/btc/stratum/finder_fee.hpp> // D2 v35_finder_fee_split integer-exact SSOT
#include <memory>

#include <impl/btc/coin/header_chain.hpp>
Expand Down Expand Up @@ -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<double>(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<uint64_t>(it->second);
const uint64_t taken = v35_finder_fee_split(coinbasevalue, donation_sats);
it->second -= static_cast<double>(taken);
payouts[payout_script] += static_cast<double>(taken);
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/impl/btc/test/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
80 changes: 80 additions & 0 deletions src/impl/btc/test/finder_fee_integer_exact_test.cpp
Original file line number Diff line number Diff line change
@@ -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 <gtest/gtest.h>

#include <cmath>
#include <cstdint>

#include <impl/btc/stratum/finder_fee.hpp>

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<double>(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<uint64_t>(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<uint64_t>(std::llround(float_fee)),
v35_finder_fee_split(cbv, 1'000'000'000ULL));
}
Loading