|
| 1 | +/// Block-replay regression test harness — Phase 2 (test runner). |
| 2 | +/// |
| 3 | +/// Loads all *.json fixtures from test/fixtures/dash_blocks/ and asserts |
| 4 | +/// c2pool's pure-arithmetic Dash consensus formulas bit-exact against |
| 5 | +/// the on-chain ground truth captured by dashd's getblock verbosity=2. |
| 6 | +/// |
| 7 | +/// Design doc: frstrtr/the/docs/c2pool-dash-block-replay-test-harness.md |
| 8 | +/// Day 1: block_dumper.hpp + 66 committed fixtures (commit cedc2655). |
| 9 | +/// Day 2 (this file): per-block assertions on the formulas we can |
| 10 | +/// validate without reconstructing in-memory state. |
| 11 | +/// |
| 12 | +/// MVP coverage (Day 2): |
| 13 | +/// 1. Platform reward (Bug 15) — vout[0] OP_RETURN value matches |
| 14 | +/// compute_dash_platform_reward_post_v20_mn_rr(height) |
| 15 | +/// 2. Subsidy formula — coinbase_total − sum(non-coinbase fees) |
| 16 | +/// == compute_dash_block_reward_post_v20(height) |
| 17 | +/// 3. cbTx height self-consistency — block.cbTx.height == fixture height |
| 18 | +/// 4. Pre-MN_RR activation: no nulldata vout present; platform_reward=0 |
| 19 | +/// |
| 20 | +/// Future expansion (Day 3+ in design doc): |
| 21 | +/// - Parse block_hex via c2pool's BlockType → run apply_block |
| 22 | +/// - Validate find_expected_payee against the observed MN payee |
| 23 | +/// - Validate SML root / quorums root |
| 24 | +/// |
| 25 | +/// Runs in ~30s for 66 blocks. Fails fast: first MISMATCH gets a clear |
| 26 | +/// per-block report; the rest still run so we see the full failure shape. |
| 27 | + |
| 28 | +#include <gtest/gtest.h> |
| 29 | + |
| 30 | +// utxo_adapter.hpp must come before subsidy.hpp so dash_txid is in scope |
| 31 | +// for subsidy.hpp's computed_block_fees() helper. We don't use that helper |
| 32 | +// directly but the header must compile cleanly. |
| 33 | +#include <impl/dash/coin/utxo_adapter.hpp> |
| 34 | +#include <impl/dash/coin/subsidy.hpp> |
| 35 | + |
| 36 | +#include <nlohmann/json.hpp> |
| 37 | + |
| 38 | +#include <cstdint> |
| 39 | +#include <filesystem> |
| 40 | +#include <fstream> |
| 41 | +#include <string> |
| 42 | +#include <vector> |
| 43 | + |
| 44 | +namespace { |
| 45 | + |
| 46 | +using dash::coin::compute_dash_block_reward_post_v20; |
| 47 | +using dash::coin::compute_dash_platform_reward_post_v20_mn_rr; |
| 48 | +using dash::coin::DASH_MN_RR_HEIGHT_MAINNET; |
| 49 | +using dash::coin::DASH_V20_HEIGHT_MAINNET; |
| 50 | + |
| 51 | +// One fixture file → in-memory representation. Doesn't reconstruct the |
| 52 | +// c2pool BlockType — uses only the JSON ground truth. |
| 53 | +struct Fixture { |
| 54 | + uint32_t height = 0; |
| 55 | + std::string block_hash; |
| 56 | + int64_t coinbase_total_sat = 0; |
| 57 | + int64_t sum_non_coinbase_fees_sat = 0; |
| 58 | + // Platform burn — present (and positive) post-MN_RR; absent or zero pre-MN_RR. |
| 59 | + int64_t platform_burn_sat = 0; |
| 60 | + bool has_platform_vout = false; |
| 61 | + // cbTx fields (decoded extraPayload — what dashd returned). |
| 62 | + uint32_t cbtx_height = 0; |
| 63 | + int32_t cbtx_version = 0; |
| 64 | +}; |
| 65 | + |
| 66 | +// DASH amounts in getblock verbosity=2 are floats (DASH not sat). Convert |
| 67 | +// using lround to avoid double-to-int truncation surprises. |
| 68 | +inline int64_t dash_to_sat(double dash) |
| 69 | +{ |
| 70 | + // 1 DASH = 100_000_000 sat. Round to nearest to handle floating noise. |
| 71 | + return static_cast<int64_t>(std::llround(dash * 1e8)); |
| 72 | +} |
| 73 | + |
| 74 | +Fixture load_fixture(const std::filesystem::path& p) |
| 75 | +{ |
| 76 | + std::ifstream in(p); |
| 77 | + nlohmann::json j; |
| 78 | + in >> j; |
| 79 | + |
| 80 | + Fixture f; |
| 81 | + f.height = j.at("height").get<uint32_t>(); |
| 82 | + f.block_hash = j.at("block_hash").get<std::string>(); |
| 83 | + |
| 84 | + const auto& bv = j.at("block_verbose"); |
| 85 | + const auto& coinbase = bv.at("tx").at(0); |
| 86 | + |
| 87 | + for (const auto& vout : coinbase.at("vout")) { |
| 88 | + int64_t sat = dash_to_sat(vout.at("value").get<double>()); |
| 89 | + f.coinbase_total_sat += sat; |
| 90 | + |
| 91 | + std::string type = vout.at("scriptPubKey").value("type", ""); |
| 92 | + if (type == "nulldata") { |
| 93 | + // Platform burn output (DIP-0027). There should be at most one. |
| 94 | + f.platform_burn_sat += sat; |
| 95 | + f.has_platform_vout = true; |
| 96 | + } |
| 97 | + } |
| 98 | + |
| 99 | + // Non-coinbase fees. getblock verbosity=2 includes per-tx "fee" for |
| 100 | + // non-coinbase txs. Sum them. |
| 101 | + for (size_t i = 1; i < bv.at("tx").size(); ++i) { |
| 102 | + const auto& tx = bv.at("tx").at(i); |
| 103 | + if (tx.contains("fee")) { |
| 104 | + f.sum_non_coinbase_fees_sat += dash_to_sat(tx.at("fee").get<double>()); |
| 105 | + } |
| 106 | + } |
| 107 | + |
| 108 | + if (coinbase.contains("cbTx")) { |
| 109 | + const auto& cb = coinbase.at("cbTx"); |
| 110 | + f.cbtx_height = cb.value("height", 0u); |
| 111 | + f.cbtx_version = cb.value("version", 0); |
| 112 | + } |
| 113 | + |
| 114 | + return f; |
| 115 | +} |
| 116 | + |
| 117 | +std::vector<std::filesystem::path> all_fixture_paths() |
| 118 | +{ |
| 119 | + // Resolve relative to the test source directory (passed by CMake at |
| 120 | + // configure time via -DTEST_FIXTURE_DIR=...). Fallback: try the |
| 121 | + // canonical path relative to the source tree. |
| 122 | +#ifdef TEST_FIXTURE_DIR |
| 123 | + std::filesystem::path dir(TEST_FIXTURE_DIR); |
| 124 | +#else |
| 125 | + std::filesystem::path dir("test/fixtures/dash_blocks"); |
| 126 | +#endif |
| 127 | + std::vector<std::filesystem::path> ps; |
| 128 | + if (!std::filesystem::exists(dir)) return ps; |
| 129 | + for (auto& entry : std::filesystem::directory_iterator(dir)) { |
| 130 | + if (entry.path().extension() == ".json") { |
| 131 | + ps.push_back(entry.path()); |
| 132 | + } |
| 133 | + } |
| 134 | + std::sort(ps.begin(), ps.end()); |
| 135 | + return ps; |
| 136 | +} |
| 137 | + |
| 138 | +} // anon namespace |
| 139 | + |
| 140 | +// ─── Tests ────────────────────────────────────────────────────────────────── |
| 141 | + |
| 142 | +TEST(BlockReplay, FixtureSetLoadable) |
| 143 | +{ |
| 144 | + auto paths = all_fixture_paths(); |
| 145 | + ASSERT_GT(paths.size(), 50u) |
| 146 | + << "expected ≥50 fixtures; found " << paths.size() |
| 147 | + << ". Re-run `c2pool-dash --dump-blocks test/fixtures/dash_blocks " |
| 148 | + "--dashd-rpc HOST:PORT:U:P` to refresh."; |
| 149 | +} |
| 150 | + |
| 151 | +TEST(BlockReplay, CbTxHeightSelfConsistent) |
| 152 | +{ |
| 153 | + for (auto& path : all_fixture_paths()) { |
| 154 | + Fixture f = load_fixture(path); |
| 155 | + if (f.cbtx_version == 0) continue; // pre-DIP-0008, no cbTx |
| 156 | + EXPECT_EQ(f.cbtx_height, f.height) |
| 157 | + << "fixture " << path.filename().string() |
| 158 | + << ": cbTx.height=" << f.cbtx_height |
| 159 | + << " but fixture.height=" << f.height; |
| 160 | + } |
| 161 | +} |
| 162 | + |
| 163 | +TEST(BlockReplay, PlatformRewardMatchesOPRETURNBurn) |
| 164 | +{ |
| 165 | + // Post-MN_RR every block has a nulldata vout for the platform burn. |
| 166 | + // Pre-MN_RR no such vout exists. |
| 167 | + for (auto& path : all_fixture_paths()) { |
| 168 | + Fixture f = load_fixture(path); |
| 169 | + int64_t computed = compute_dash_platform_reward_post_v20_mn_rr(f.height); |
| 170 | + |
| 171 | + if (f.height < static_cast<uint32_t>(DASH_MN_RR_HEIGHT_MAINNET)) { |
| 172 | + EXPECT_EQ(computed, 0) |
| 173 | + << "pre-MN_RR (h=" << f.height << ") must yield zero"; |
| 174 | + EXPECT_FALSE(f.has_platform_vout) |
| 175 | + << "pre-MN_RR (h=" << f.height |
| 176 | + << ") must NOT have a nulldata vout"; |
| 177 | + } else { |
| 178 | + EXPECT_TRUE(f.has_platform_vout) |
| 179 | + << "post-MN_RR (h=" << f.height |
| 180 | + << ") must have a nulldata vout (DIP-0027)"; |
| 181 | + EXPECT_EQ(computed, f.platform_burn_sat) |
| 182 | + << "platform reward mismatch at h=" << f.height |
| 183 | + << " computed=" << computed |
| 184 | + << " on-chain=" << f.platform_burn_sat |
| 185 | + << " diff=" << (computed - f.platform_burn_sat); |
| 186 | + } |
| 187 | + } |
| 188 | +} |
| 189 | + |
| 190 | +TEST(BlockReplay, SubsidyFormulaMatchesCoinbaseMinusFees) |
| 191 | +{ |
| 192 | + // For every block: coinbase_total = subsidy + sum_fees. Therefore |
| 193 | + // computed_subsidy must == coinbase_total - sum_fees. (No platform |
| 194 | + // accounting needed here because platform comes OUT OF subsidy, so |
| 195 | + // coinbase_total still equals subsidy + fees.) |
| 196 | + // |
| 197 | + // Superblock heights are skipped: they carry additional treasury |
| 198 | + // outputs that inflate coinbase_total by ~7900 DASH. Mirrors the |
| 199 | + // existing [SUBSIDY-XCHECK] skip in main_dash.cpp. |
| 200 | + int checked = 0, skipped = 0; |
| 201 | + for (auto& path : all_fixture_paths()) { |
| 202 | + Fixture f = load_fixture(path); |
| 203 | + // Skip pre-V20: compute_dash_block_reward_post_v20 is documented |
| 204 | + // as post-V20 only; pre-V20 used a different schedule. |
| 205 | + if (f.height < static_cast<uint32_t>(DASH_V20_HEIGHT_MAINNET)) { |
| 206 | + ++skipped; |
| 207 | + continue; |
| 208 | + } |
| 209 | + if (dash::coin::is_superblock_height(f.height)) { |
| 210 | + ++skipped; |
| 211 | + continue; |
| 212 | + } |
| 213 | + int64_t computed_reward = compute_dash_block_reward_post_v20(f.height); |
| 214 | + int64_t observed_reward = f.coinbase_total_sat - f.sum_non_coinbase_fees_sat; |
| 215 | + EXPECT_EQ(computed_reward, observed_reward) |
| 216 | + << "subsidy formula mismatch at h=" << f.height |
| 217 | + << " computed=" << computed_reward |
| 218 | + << " observed=" << observed_reward |
| 219 | + << " (coinbase_total=" << f.coinbase_total_sat |
| 220 | + << " - fees=" << f.sum_non_coinbase_fees_sat << ")"; |
| 221 | + ++checked; |
| 222 | + } |
| 223 | + EXPECT_GT(checked, 50) << "expected to check >50 non-superblock fixtures"; |
| 224 | + // Make the skip count visible in successful runs (no log if all pass). |
| 225 | + if (skipped > 0) { |
| 226 | + std::cerr << "[BlockReplay] SubsidyFormulaMatchesCoinbaseMinusFees: " |
| 227 | + << "checked=" << checked << " skipped(superblocks)=" << skipped |
| 228 | + << std::endl; |
| 229 | + } |
| 230 | +} |
| 231 | + |
| 232 | +TEST(BlockReplay, KnownBug15AnchorHas3VoutsSplit) |
| 233 | +{ |
| 234 | + // Pin the original Bug 15 discovery shape: block 2,470,904 must have |
| 235 | + // 3 coinbase vouts with the specific values that surfaced the bug. |
| 236 | + // If anyone ever silently changes the fixture, this catches it. |
| 237 | + auto paths = all_fixture_paths(); |
| 238 | + auto it = std::find_if(paths.begin(), paths.end(), |
| 239 | + [](const std::filesystem::path& p) { |
| 240 | + return p.filename().string() == "h2470904.json"; |
| 241 | + }); |
| 242 | + ASSERT_NE(it, paths.end()) << "Bug 15 anchor fixture missing"; |
| 243 | + |
| 244 | + Fixture f = load_fixture(*it); |
| 245 | + EXPECT_EQ(f.platform_burn_sat, 49'787'579LL) |
| 246 | + << "Bug 15 anchor: expected platform=49,787,579 sat"; |
| 247 | + EXPECT_EQ(f.coinbase_total_sat, 177'125'185LL) |
| 248 | + << "Bug 15 anchor: expected coinbase_total=177,125,185 sat"; |
| 249 | + EXPECT_EQ(f.sum_non_coinbase_fees_sat, 102'680LL) |
| 250 | + << "Bug 15 anchor: expected fees=102,680 sat"; |
| 251 | + EXPECT_TRUE(f.has_platform_vout); |
| 252 | +} |
0 commit comments