Skip to content

Commit 8249dc2

Browse files
committed
Dash Phase C-PAY: block-replay test harness Day 2 — runner + 5 green tests
Phase 2 of the block-replay test harness. Day 1 (cedc265) shipped the fixture dumper + 66 fixtures; this commit adds the test runner. 5 tests, 178 ms for 60 fixtures checked (6 superblocks skipped — same skip applied by main_dash.cpp's [SUBSIDY-XCHECK]): 1. BlockReplay.FixtureSetLoadable — guard against empty fixture set 2. BlockReplay.CbTxHeightSelfConsistent — block.cbTx.height == fixture.height (catches fixture corruption) 3. BlockReplay.PlatformRewardMatchesOPRETURNBurn — pre-MN_RR no nulldata vout; post-MN_RR vout[0] value matches compute_dash_platform_reward_post_v20_mn_rr(height) bit-exact 4. BlockReplay.SubsidyFormulaMatchesCoinbaseMinusFees — compute_dash_block_reward_post_v20(h) == (coinbase_total - sum(non-coinbase fees)), skips pre-V20 and superblock heights (both have separate code paths) 5. BlockReplay.KnownBug15AnchorHas3VoutsSplit — pins block 2,470,904 to its known (49,787,579 platform | 83,056,309 MN | 44,281,297 miner) shape so silent fixture tampering is caught Verification: ran the test against the e3bf011 (Bug 15 fix) binary — all 5 pass. Earlier failure shapes encountered during bring-up correctly identified superblock-treasury inflation (h % 16616 == 0 adds ~7,900 DASH) and pre-V20 schedule divergence (h < 1,987,776 used a different formula); both are correctly modeled as skips matching the production [SUBSIDY-XCHECK] gate. Day 2 milestone: the harness can now catch Bug 15-class consensus formula regressions in <200 ms of CI instead of days of soak. Future expansion per design doc: - Day 3: parse block_hex via BlockType, run mn_state_machine.apply_block to validate find_expected_payee against observed coinbase - Day 3: SML root + quorums root assertions (need vendored CSimplifiedMNList construction from fixtures) - Day 3: full-replay mode (10k blocks) as nightly CI gate Test target: cmake --build build --target test_dash_block_replay Run: ./build/test/test_dash_block_replay
1 parent cedc265 commit 8249dc2

2 files changed

Lines changed: 271 additions & 0 deletions

File tree

test/CMakeLists.txt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,25 @@ if (BUILD_TESTING AND GTest_FOUND)
344344
)
345345
gtest_add_tests(test_dash_subsidy "" AUTO)
346346

347+
# Block-replay harness — loads test/fixtures/dash_blocks/*.json and
348+
# asserts c2pool's Dash consensus formulas bit-exact against on-chain
349+
# ground truth from getblock verbosity=2. Day 2 of the harness;
350+
# design doc: frstrtr/the/docs/c2pool-dash-block-replay-test-harness.md
351+
add_executable(test_dash_block_replay test_dash_block_replay.cpp)
352+
target_link_libraries(test_dash_block_replay PRIVATE
353+
GTest::gtest_main GTest::gtest
354+
ltc core dash_x11
355+
nlohmann_json::nlohmann_json
356+
${Boost_LIBRARIES}
357+
)
358+
target_include_directories(test_dash_block_replay PRIVATE
359+
${CMAKE_SOURCE_DIR}/src
360+
)
361+
target_compile_definitions(test_dash_block_replay PRIVATE
362+
TEST_FIXTURE_DIR="${CMAKE_SOURCE_DIR}/test/fixtures/dash_blocks"
363+
)
364+
gtest_add_tests(test_dash_block_replay "" AUTO)
365+
347366
add_executable(test_dash_battletest_regressions test_dash_battletest_regressions.cpp)
348367
target_link_libraries(test_dash_battletest_regressions PRIVATE
349368
GTest::gtest_main GTest::gtest

test/test_dash_block_replay.cpp

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
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

Comments
 (0)