diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7a121ae16..8b59bcd54 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -59,7 +59,7 @@ jobs: test_threading test_weights \ test_header_chain test_mempool test_template_builder \ test_doge_chain test_compact_blocks test_dash_x11_kat \ - test_dash_header_chain test_dash_block_replay test_dash_conformance \ + test_dash_header_chain test_dash_block_replay test_dash_conformance test_dash_subsidy \ test_multiaddress_pplns test_pplns_stress \ test_hash_link test_decay_pplns \ test_pplns_consensus \ @@ -190,7 +190,7 @@ jobs: test_threading test_weights \ test_header_chain test_mempool test_template_builder \ test_doge_chain test_compact_blocks test_dash_x11_kat \ - test_dash_header_chain test_dash_block_replay test_dash_conformance \ + test_dash_header_chain test_dash_block_replay test_dash_conformance test_dash_subsidy \ test_hash_link test_decay_pplns \ test_pplns_consensus \ test_v36_script_sorting test_v36_cross_impl_refhash \ diff --git a/src/impl/dash/coin/subsidy.hpp b/src/impl/dash/coin/subsidy.hpp new file mode 100644 index 000000000..9b69beaf4 --- /dev/null +++ b/src/impl/dash/coin/subsidy.hpp @@ -0,0 +1,176 @@ +#pragma once + +/// Phase C-TEMPLATE step 1: block-subsidy + MN-payment formula vendor. +/// +/// Direct port of dashcore validation.cpp::GetBlockSubsidyHelper + +/// GetMasternodePayment @ cfad414. The functions are pure (no chain +/// state needed), so we can call them on every observed block to do +/// shadow validation against the actual coinbase outputs. +/// +/// We DELIBERATELY simplify by hard-coding the post-V20 / post-BRR +/// path: c2pool-dash's header checkpoint is at h=2,400,000, well past +/// V20Height (1,987,776) and BRRHeight (1,374,912), so we will never +/// observe a block where these aren't active. The pre-V20 / pre-BRR +/// paths (with the 9-step historical MN-payment-increase ladder + the +/// difficulty-driven nSubsidyBase formula) are NOT vendored. If we +/// ever observe a block at h + +namespace dash { +namespace coin { + +inline constexpr int64_t COIN_SAT = 100'000'000LL; + +inline constexpr int DASH_SUBSIDY_HALVING_INTERVAL = 210240; +inline constexpr int DASH_V20_HEIGHT_MAINNET = 1'987'776; +inline constexpr int DASH_BRR_HEIGHT_MAINNET = 1'374'912; +inline constexpr int DASH_MN_RR_HEIGHT_MAINNET = 2'128'896; +inline constexpr int DASH_SUPERBLOCK_CYCLE_MAINNET = 16616; + +/// Returns the BLOCK reward (excluding tx fees) for a block at height +/// `height` on Dash mainnet. Equivalent to dashcore's +/// GetBlockSubsidyInner @ height when V20 is active. +inline int64_t compute_dash_block_reward_post_v20(uint32_t height) +{ + // Mirror dashcore's prev_height-based loop. + int prev_height = static_cast(height) - 1; + int64_t nSubsidy = 5 * COIN_SAT; + for (int i = DASH_SUBSIDY_HALVING_INTERVAL; + i <= prev_height; + i += DASH_SUBSIDY_HALVING_INTERVAL) { + nSubsidy -= nSubsidy / 14; + } + int64_t nSuperblockPart = nSubsidy / 5; + return nSubsidy - nSuperblockPart; +} + +/// Returns the MN payment amount given the block reward (excluding +/// fees). Equivalent to dashcore's GetMasternodePayment @ post-realloc +/// + V20-active path: blockValue * 3 / 4. +/// +/// Note: dashcore passes blockValue = block_reward + tx_fees (i.e., +/// MN gets 75% of subsidy + 75% of fees). For shadow validation +/// against observed coinbases we must include fees the same way. +inline int64_t compute_dash_mn_payment_post_v20(int64_t block_value) +{ + return block_value * 3 / 4; +} + +/// Platform Credit Pool burn (DIP-0027 / Asset Lock) per-block share. +/// Activates when both V20 AND MN_RR are deployed (mainnet h>=MN_RR=2,128,896, +/// our checkpoint h=2.4M is always past). Mirror of dashcore PlatformShare() +/// at masternode/payments.cpp: PlatformShare(GetMasternodePayment(h, subsidy, true)) +/// = (subsidy * 3/4) * 375/1000. Integer-arithmetic order matters: dashcore +/// truncates between the two divisions, so we replicate that order exactly. +/// The result is added as an OP_RETURN coinbase output and DEDUCTED from the +/// MN's portion (miner share is unaffected). +inline int64_t compute_dash_platform_reward_post_v20_mn_rr(uint32_t height) +{ + if (static_cast(height) < DASH_MN_RR_HEIGHT_MAINNET) return 0; + int64_t mn_subsidy_share = compute_dash_block_reward_post_v20(height) * 3 / 4; + return mn_subsidy_share * 375 / 1000; +} + +/// True if `height` is a superblock height (treasury budget payout). +inline bool is_superblock_height(uint32_t height, + int cycle = DASH_SUPERBLOCK_CYCLE_MAINNET) +{ + return cycle > 0 && (height % static_cast(cycle)) == 0; +} + +} // namespace coin +} // namespace dash + +#include "block.hpp" +#include "transaction.hpp" +#include +#include +#include + +namespace dash { +namespace coin { + +/// Sum of all coinbase output values. = block_reward + total_block_fees +/// for any accepted block. Used as one half of the cross-check in +/// Phase C-TEMPLATE step 2 shadow validation. +inline int64_t observed_coinbase_value(const dash::coin::BlockType& block) +{ + if (block.m_txs.empty()) return 0; + int64_t sum = 0; + for (const auto& vo : block.m_txs[0].vout) sum += vo.value; + return sum; +} + +/// Compute total block fees from UTXO + same-block parent outputs. +/// Walks non-coinbase txs; for each, sums input values via UTXO +/// lookups (with same-block parent-tx output fallback for chained +/// txs inside the block) and subtracts output values. +/// +/// Returns nullopt when ANY input is missing from UTXO + same-block +/// parent. That happens during cold start (rolling-288 UTXO window +/// doesn't cover deeper inputs) or when the block contains txs +/// spending outputs from blocks we've never observed. Caller treats +/// nullopt as "skip the cross-check this block, retry next block". +inline std::optional computed_block_fees( + const dash::coin::BlockType& block, + ::core::coin::UTXOViewCache* utxo) +{ + if (!utxo || block.m_txs.size() <= 1) return int64_t{0}; + + // Pre-index this block's outputs so chained txs (parent + child + // in same block) can fall back to the parent's vout values when + // the parent isn't yet in UTXO. + std::map in_block_txs; + for (size_t i = 1; i < block.m_txs.size(); ++i) { + in_block_txs[dash::coin::dash_txid(block.m_txs[i])] = &block.m_txs[i]; + } + + int64_t total_fees = 0; + for (size_t i = 1; i < block.m_txs.size(); ++i) { + const auto& tx = block.m_txs[i]; + int64_t in_sum = 0, out_sum = 0; + for (const auto& vin : tx.vin) { + ::core::coin::Outpoint op(vin.prevout.hash, vin.prevout.index); + ::core::coin::Coin coin; + if (utxo->get_coin(op, coin)) { + in_sum += coin.value; + continue; + } + // Same-block parent fallback. + auto pit = in_block_txs.find(vin.prevout.hash); + if (pit != in_block_txs.end() + && vin.prevout.index < pit->second->vout.size()) { + in_sum += pit->second->vout[vin.prevout.index].value; + continue; + } + // Input missing → can't compute fees this block. + return std::nullopt; + } + for (const auto& vo : tx.vout) out_sum += vo.value; + if (in_sum < out_sum) return std::nullopt; + total_fees += (in_sum - out_sum); + } + return total_fees; +} + +} // namespace coin +} // namespace dash diff --git a/src/impl/dash/coin/utxo_adapter.hpp b/src/impl/dash/coin/utxo_adapter.hpp new file mode 100644 index 000000000..23eb08203 --- /dev/null +++ b/src/impl/dash/coin/utxo_adapter.hpp @@ -0,0 +1,105 @@ +#pragma once + +/// Dash UTXO adapter layer — Phase U step 1. +/// +/// Plumbs `dash::coin::BlockType` and `dash::coin::MutableTransaction` into +/// the chain-agnostic `core::coin::UTXOViewCache` / `UTXOViewDB` machinery +/// (already used by LTC and DOGE). This file is the **type-compatibility +/// shim only** — no live UTXOViewCache instance is constructed yet; that +/// wiring lands in later Phase U steps (connect_block on new-tip arrival, +/// LevelDB persistence, block-bootstrapper cold-start). +/// +/// Why adapter-only first: lets us ship the Dash-side types + constants + +/// txid helper in a standalone commit so the larger Phase U integration +/// stages (which touch main_dash.cpp + broadcaster event plumbing) land +/// against a stable base. +/// +/// References: +/// - `dashcore/src/consensus/consensus.h` (COINBASE_MATURITY = 100) +/// - `dashcore/src/consensus/amount.h` (MAX_MONEY = 21M * COIN) +/// - `dashcore/src/validation.h` (MIN_BLOCKS_TO_KEEP = 288) + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include + +namespace dash { +namespace coin { + +// ── ChainLimits ──────────────────────────────────────────────────────────── +// +// Dash consensus constants, mirroring dashcore/src/consensus. Values +// chosen to match the generic UTXO machinery's ChainLimits struct +// (max_money / coinbase_maturity / pegout_maturity). +// +// - max_money : 21_000_000 * COIN (dashcore's validation bound; +// Dash's circulating supply is ~21 M DASH but +// dashcore inherits Bitcoin's 21 M MoneyRange check). +// - coinbase_maturity : 100 blocks (standard Bitcoin-family value; +// dashcore/src/consensus/consensus.h COINBASE_MATURITY). +// - pegout_maturity : 0 (Dash has no MWEB, so no pegout concept). +inline constexpr ::core::coin::ChainLimits DASH_LIMITS = { + /* max_money */ 2'100'000'000'000'000LL, + /* coinbase_maturity */ 100, + /* pegout_maturity */ 0, +}; + +// Undo-data retention: at least 288 blocks, same as dashcore's +// MIN_BLOCKS_TO_KEEP (validation.h). Keeps reorg-repair data around +// long enough to handle deep reorganisations. +inline constexpr uint32_t DASH_MIN_BLOCKS_TO_KEEP = 288; + +// Minimum chain depth before embedded mining can fully trust the UTXO +// set: coinbase_maturity + a small reorg buffer. 100 + 6 = 106, in +// line with LTC's approach (LTC uses coinbase_maturity + pegout_maturity +// = 106; Dash has no pegout so we add an equivalent 6-block reorg +// buffer explicitly). +inline constexpr uint32_t DASH_MINING_GATE_DEPTH = + DASH_LIMITS.coinbase_maturity + 6; + +// ── Dash txid helper ─────────────────────────────────────────────────────── +// +// Double-SHA256 of the transaction's canonical serialization. DIP3 / +// DIP4 special transactions (type != 0) carry an `extra_payload` field +// that `MutableTransaction::Serialize` already appends; the resulting +// byte stream is what dashcore hashes into the txid, so we mirror +// exactly. Plain (type=0) transactions serialize identically to +// pre-DIP3 Bitcoin-family txs. +// +// UTXOViewCache::connect_block takes a TxidFn parameter — +// this function matches that signature. +inline ::uint256 dash_txid(const MutableTransaction& tx) +{ + auto packed = ::pack(tx); + return ::Hash(packed.get_span()); +} + +// ── Alias so call sites read naturally ───────────────────────────────────── +using UtxoViewCache = ::core::coin::UTXOViewCache; +using UtxoViewDB = ::core::coin::UTXOViewDB; +using Outpoint = ::core::coin::Outpoint; +using Coin = ::core::coin::Coin; +using BlockUndo = ::core::coin::BlockUndo; + +// ── Compile-time shape check ────────────────────────────────────────────── +// +// Confirms BlockType + MutableTransaction + the adapter constants are +// mutually consistent with the generic UTXOViewCache template interface. +// No runtime cost; just a type-lookup assertion that fails-to-compile if +// any of the expected fields are missing or renamed. +static_assert(sizeof(DASH_LIMITS) > 0, + "DASH_LIMITS must be a valid ChainLimits instance"); +static_assert(MutableTransaction::m_hogEx == false, + "Dash transactions never mark as MWEB HogEx (m_hogEx=false is " + "required by the shared UTXOViewCache::connect_block template)"); + +} // namespace coin +} // namespace dash diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 2137eb704..0c2dd218f 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -239,6 +239,21 @@ if (BUILD_TESTING AND GTest_FOUND) target_link_libraries(test_dash_conformance PRIVATE c2pool_payout c2pool_hashrate c2pool_merged_mining) # OBJECT-lib SCC direct-naming (#22/#39): core stratum/web_server TUs gtest_add_tests(test_dash_conformance "" AUTO) + # DASH block-subsidy + UTXO adapter (S7 foundation slice): GetBlockSubsidy / + # masternode + platform reward split (subsidy.hpp) consumed through the + # header-only utxo_adapter view shim. Both are header-only over + # block/transaction + core/coin (utxo_view_cache/db); no ltc/pool SCC + # dependency. Mirrors the test_dash_conformance link set. + add_executable(test_dash_subsidy test_dash_subsidy.cpp) + target_link_libraries(test_dash_subsidy PRIVATE + GTest::gtest_main GTest::gtest + dash_x11 core + nlohmann_json::nlohmann_json + ${Boost_LIBRARIES} + ) + target_link_libraries(test_dash_subsidy PRIVATE c2pool_payout c2pool_hashrate c2pool_merged_mining) # OBJECT-lib SCC direct-naming (#22/#39): core stratum/web_server TUs + gtest_add_tests(test_dash_subsidy "" AUTO) + add_executable(test_compact_blocks test_compact_blocks.cpp) target_link_libraries(test_compact_blocks PRIVATE GTest::gtest_main GTest::gtest diff --git a/test/test_dash_subsidy.cpp b/test/test_dash_subsidy.cpp new file mode 100644 index 000000000..2d6623d34 --- /dev/null +++ b/test/test_dash_subsidy.cpp @@ -0,0 +1,135 @@ +/// Phase C-TEMPLATE step 1 — Dash subsidy formula unit tests +/// +/// Validates compute_dash_block_reward_post_v20 + compute_dash_mn_payment_post_v20 +/// against known mainnet values. Live-validated against: +/// h=2459985 → reward=177,022,505 sat (1.77022505 DASH) +/// h=2459992 → reward=177,022,505 sat +/// h=2460022 → reward=177,022,505 sat +/// +/// Formula (from dashcore consensus/v20): +/// subsidy = 5 DASH × (13/14)^iterations × 4/5 +/// iterations = (height - DEPLOYMENT_V20_HEIGHT) / 210240 +/// where 210240 = blocks per ~year at 2.5 min spacing +/// MN payment = block_value × 3/4 (post-MN_RR / DIP-0028) +/// +/// Tests: +/// 1. Genesis-area pre-V20 height returns the legacy schedule (we +/// skip — pre-V20 is below our checkpoint and out of scope). +/// 2. V20 activation height (h=1,987,776) — sanity bound check. +/// 3. Live-mainnet reproducible value at h=2459985. +/// 4. Halving / decline progression (sanity: h+210240 is smaller). +/// 5. MN payment is exactly 3/4 of block_value. + +#include + +// utxo_adapter.hpp must come before subsidy.hpp so dash_txid is +// in scope for subsidy.hpp's computed_block_fees() helper template. +// This test only exercises the pure-arithmetic functions but the +// header still needs to compile cleanly. +#include +#include + +#include + +using dash::coin::compute_dash_block_reward_post_v20; +using dash::coin::compute_dash_mn_payment_post_v20; +using dash::coin::compute_dash_platform_reward_post_v20_mn_rr; + +// ─── Tests ────────────────────────────────────────────────────────────────── + +TEST(DashSubsidy, MatchesMainnetH2459985) +{ + // Live-validated: dashd-cli getblocktemplate at h=2459985 reported + // coinbasevalue=177022505 sat (block reward only, no fees). + int64_t reward = compute_dash_block_reward_post_v20(2459985); + EXPECT_EQ(reward, 177'022'505LL) + << "Phase C-TEMPLATE step 1 was live-validated at this height"; +} + +TEST(DashSubsidy, MNPaymentIs3QuartersOfBlockValue) +{ + int64_t block_value = 200'000'000LL; + int64_t mn = compute_dash_mn_payment_post_v20(block_value); + EXPECT_EQ(mn, 150'000'000LL); + EXPECT_EQ(mn, (block_value * 3) / 4); +} + +TEST(DashSubsidy, RewardDeclinesEachYearWindow) +{ + // V20 yearly decline: 13/14 × prev. So h+210240 must be smaller + // than h. Use heights well past V20 activation (1,987,776 mainnet). + int64_t r0 = compute_dash_block_reward_post_v20(2'500'000); + int64_t r1 = compute_dash_block_reward_post_v20(2'500'000 + 210'240); + EXPECT_LT(r1, r0) + << "yearly window crossing must reduce subsidy"; + // The reduction factor should be approximately 13/14 (≈ 92.86%) + double ratio = static_cast(r1) / r0; + EXPECT_NEAR(ratio, 13.0 / 14.0, 0.005) + << "yearly subsidy ratio should be ~13/14"; +} + +TEST(DashSubsidy, RewardPositiveForLongFuture) +{ + // Even far in the future the reward should be positive (not zero). + int64_t reward = compute_dash_block_reward_post_v20(10'000'000); + EXPECT_GT(reward, 0) + << "subsidy should asymptote toward 0 but not actually reach it " + "in any realistic horizon"; +} + +TEST(DashSubsidy, SuperblockHeightDetectionMainnet) +{ + // Mainnet superblock cycle = 16616 blocks per dashcore consensus. + EXPECT_TRUE(dash::coin::is_superblock_height(16616, 16616)); + EXPECT_TRUE(dash::coin::is_superblock_height(16616 * 2, 16616)); + EXPECT_FALSE(dash::coin::is_superblock_height(16615, 16616)); + EXPECT_FALSE(dash::coin::is_superblock_height(16617, 16616)); +} + +TEST(DashSubsidy, PlatformRewardZeroBeforeMN_RR) +{ + // MN_RR mainnet activation = h=2,128,896. Before that, platform_reward + // must be zero (pre-DIP-0027 split with no asset-lock burn output). + EXPECT_EQ(compute_dash_platform_reward_post_v20_mn_rr(2'128'895), 0); + EXPECT_EQ(compute_dash_platform_reward_post_v20_mn_rr(1'987'776), 0); + EXPECT_EQ(compute_dash_platform_reward_post_v20_mn_rr(0), 0); +} + +TEST(DashSubsidy, PlatformRewardMatchesMainnetH2470904) +{ + // Live-validated against Dash mainnet block 2470904 (2026-05-13): + // coinbase vout[0] value=0.49787579 DASH = 49,787,579 sat (OP_RETURN burn) + // coinbase vout[1] value=0.83056309 DASH = 83,056,309 sat (MN payee) + // coinbase vout[2] value=0.44281297 DASH = 44,281,297 sat (miner) + // block reward = 177,022,505 sat (this height has 11 halvings applied) + // fees = 102,680 sat (computed from in-block tx fees) + // + // Formula (dashcore masternode/payments.cpp:28-58): + // mn_subsidy_share = block_reward × 3/4 = 132,766,878 (floored) + // platform_reward = mn_subsidy_share × 375/1000 = 49,787,579 (floored) + constexpr uint32_t H = 2'470'904; + EXPECT_EQ(compute_dash_block_reward_post_v20(H), 177'022'505LL); + EXPECT_EQ(compute_dash_platform_reward_post_v20_mn_rr(H), 49'787'579LL); + + // expected_mn (for [MN-PAY] shadow check) = (subsidy+fees) × 3/4 - platform + int64_t reward = compute_dash_block_reward_post_v20(H); + int64_t fees = 102'680LL; + int64_t platform = compute_dash_platform_reward_post_v20_mn_rr(H); + int64_t expected_mn = compute_dash_mn_payment_post_v20(reward + fees) - platform; + EXPECT_EQ(expected_mn, 83'056'309LL) + << "must match observed MN coinbase output at h=2,470,904"; +} + +TEST(DashSubsidy, PlatformReward28125PercentOfBlockReward) +{ + // Per dashcore: platform = (block_reward × 3/4) × 375/1000. + // For any block_reward where the math is exact (no truncation), + // platform = block_reward × 9/32 = 28.125%. At h=2,400,000 (post-MN_RR) + // the live values produce small truncation noise but the proportion + // should hold within 1 sat. + int64_t r = compute_dash_block_reward_post_v20(2'400'000); + int64_t p = compute_dash_platform_reward_post_v20_mn_rr(2'400'000); + int64_t expected_exact = (r * 9) / 32; + EXPECT_LE(std::abs(p - expected_exact), 1LL) + << "platform should match 9/32 of block_reward within int-truncation"; +}