diff --git a/src/impl/btc/test/CMakeLists.txt b/src/impl/btc/test/CMakeLists.txt index 4029b6e28..fafd20db6 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) + 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) target_link_libraries(btc_share_test PRIVATE GTest::gtest_main GTest::gtest core btc diff --git a/src/impl/btc/test/auto_ratchet_sim_test.cpp b/src/impl/btc/test/auto_ratchet_sim_test.cpp new file mode 100644 index 000000000..1d5530206 --- /dev/null +++ b/src/impl/btc/test/auto_ratchet_sim_test.cpp @@ -0,0 +1,195 @@ +// btc_auto_ratchet_sim_test: FENCED, additive rig-free SIM KAT that arms the +// BTC G2 staged-migration harness (scripts/btc_g2_ratchet_staged_migration_harness.sh) +// C2/C3/C4 rows WITHOUT a SHA256d rig — the mirror of DGB #427's --sim-votes. +// It exercises the AutoRatchet staged-gate LOGIC over hand-derived oracle votes: +// +// C2 VOTING mints the V35 baseline while voting V36 (bootstrap, empty chain) +// C3 #288 accept gate: 60%-by-WORK, and a 95%-by-COUNT activation can NOT +// outrun it (mint-cannot-outrun-accept) — the property that keeps a +// minted V36 boundary share from being rejected by every peer +// C4 ratchet state persists across restart (CONFIRMED survives reconstruct) +// +// Consensus surface is NOT mutated. C3 pins the live 60%-by-WORK gate via a +// VERBATIM replica of the inline tail-guard expression in +// AutoRatchet::get_share_version (auto_ratchet.hpp), localising the gate the +// same non-circular way DGB's auto_ratchet_tail_guard_test does (BTC keeps the +// guard inline, no lifted SSOT). Test-only / btc-tree-local. + +#include + +#include // also pulls share_tracker.hpp + nlohmann/json +#include +#include + +#include +#include +#include +#include +#include +#include // getpid (restart-persistence temp file) + +using btc::AutoRatchet; +using btc::RatchetState; + +namespace { + +// Verbatim replica of the LIVE inline tail-guard in +// AutoRatchet::get_share_version: tail_ok = !(target*100 < total*SWITCH). +// Localises the 60%-by-WORK accept gate so C3 pins it WITHOUT driving the +// consensus path or importing a lifted SSOT. +bool inline_tail_ok(const uint288& target, const uint288& total, + int thr = AutoRatchet::SWITCH_THRESHOLD) +{ + return !((target * static_cast(100)) < + (total * static_cast(thr))); +} + +// 95%-by-COUNT activation predicate (flat head-count), mirroring the VOTING -> +// ACTIVATED branch's `vote_pct >= ACTIVATION_THRESHOLD` test. +bool count_activates(int target_votes, int total) +{ + if (total == 0) return false; + return (target_votes * 100) / total >= AutoRatchet::ACTIVATION_THRESHOLD; +} + +// EFFECTIVE activation = mint-side count gate AND work-weighted accept gate. +// This is the AND the live state machine enforces: VOTING only advances when +// BOTH the 95%-by-count window AND the 60%-by-work tail guard hold. +bool effective_activation(int votes, int total, + const uint288& w_target, const uint288& w_total) +{ + return count_activates(votes, total) && inline_tail_ok(w_target, w_total); +} + +// One staged-migration sample: after stage k, `votes` of `total` miners signal +// V36, carrying `w_target` of `w_total` work. +struct Stage { int votes; int total; uint288 w_target; uint288 w_total; }; + +} // namespace + +// --------------------------------------------------------------------------- +// Anchor: AutoRatchet thresholds match the canonical p2pool ratchet constants. +// --------------------------------------------------------------------------- +TEST(BTC_AutoRatchetSim, ThresholdsMatchCanonical) +{ + EXPECT_EQ(AutoRatchet::ACTIVATION_THRESHOLD, 95); + EXPECT_EQ(AutoRatchet::DEACTIVATION_THRESHOLD, 50); + EXPECT_EQ(AutoRatchet::CONFIRMATION_MULTIPLIER, 2); + EXPECT_EQ(AutoRatchet::SWITCH_THRESHOLD, 60); +} + +// --------------------------------------------------------------------------- +// C2 — VOTING node votes V36 but MINTS the V35 baseline on an empty tracker. +// A freshly-started node must not skip ahead of the network. +// --------------------------------------------------------------------------- +TEST(BTC_AutoRatchetSim, C2_VotingMintsBaselineWhileVoting) +{ + AutoRatchet ar("", /*target_version=*/36); + btc::ShareTracker tracker; + auto [mint, vote] = ar.get_share_version(tracker, uint256{}); // null best hash + EXPECT_EQ(mint, 35); // baseline = target-1, NOT 36 + EXPECT_EQ(vote, 36); // always vote for the target + EXPECT_EQ(ar.state(), RatchetState::VOTING); +} + +// --------------------------------------------------------------------------- +// C3 — #288 accept gate is 60%-by-WORK at the boundary. +// --------------------------------------------------------------------------- +TEST(BTC_AutoRatchetSim, C3_AcceptGateIsSixtyPercentByWork) +{ + const uint288 total(100); // floor(100*60/100)=60 + EXPECT_FALSE(inline_tail_ok(uint288(59), total)); // just under -> hold + EXPECT_TRUE (inline_tail_ok(uint288(60), total)); // at the gate -> pass + EXPECT_TRUE (inline_tail_ok(uint288(61), total)); + EXPECT_TRUE (inline_tail_ok(uint288(100), total)); + // All-old window never passes (target work = 0). + EXPECT_FALSE(inline_tail_ok(uint288(0), uint288(1000))); +} + +// --------------------------------------------------------------------------- +// C3 — the load-bearing #288 property: a 95%-by-COUNT activation can NOT +// outrun the 60%-by-WORK accept gate under heterogeneous hashrate. 19 tiny V36 +// voters (work 1 each) + 1 huge V35 miner (work 1000): 95% by count, but only +// ~1.9% by work. If mint activated on count alone it would emit a V36 boundary +// share that every peer (running the work-weighted accept gate) rejects, and +// the crossing wedges. effective_activation must therefore be FALSE. +// --------------------------------------------------------------------------- +TEST(BTC_AutoRatchetSim, C3_MintCannotOutrunAccept) +{ + const int votes = 19, total = 20; // 95% by count + const uint288 w_target(19); // 19 * work-1 + const uint288 w_total(19 + 1000); // + one huge V35 miner + + EXPECT_TRUE (count_activates(votes, total)); // count gate WOULD fire + EXPECT_FALSE(inline_tail_ok(w_target, w_total)); // but work gate holds + EXPECT_FALSE(effective_activation(votes, total, w_target, w_total)); + + // Homogeneous hashrate: when the 95%-by-count window is ALSO >=60%-by-work, + // the two gates agree and activation is permitted. + EXPECT_TRUE(effective_activation(95, 100, uint288(95), uint288(100))); +} + +// --------------------------------------------------------------------------- +// C3/monotonic — staged 1-by-1 miner migration: the work-weighted V36 tally +// advances MONOTONICALLY per stage, and effective activation flips on exactly +// once BOTH the 95%-by-count AND the 60%-by-work gates hold. Mirrors the +// harness's per-stage RESULT-line assertion, rig-free. 5 equal-work miners; +// stage k migrates miner k from V35 to V36. +// --------------------------------------------------------------------------- +TEST(BTC_AutoRatchetSim, C3_StagedTallyAdvancesMonotonically) +{ + const uint32_t W = 7; // per-miner work unit + std::vector stages; + for (int k = 0; k <= 5; ++k) + stages.push_back(Stage{ k, 5, + uint288(uint32_t(k) * W), + uint288(uint32_t(5) * W) }); + + uint288 prev_target(0); + bool seen_activation = false; + int first_active_stage = -1; + for (int k = 0; k <= 5; ++k) { + const Stage& s = stages[k]; + // Monotonic non-decreasing work tally. + EXPECT_FALSE(s.w_target < prev_target) << "stage=" << k; + prev_target = s.w_target; + + bool active = effective_activation(s.votes, s.total, s.w_target, s.w_total); + if (active && !seen_activation) { seen_activation = true; first_active_stage = k; } + // Once active on this monotone ramp, it must stay active. + if (seen_activation) EXPECT_TRUE(active) << "regressed at stage=" << k; + } + // 95%-by-count is only reached at full migration (5/5=100%); 3/5=60% and + // 4/5=80% are below 95. So activation fires at stage 5, not before. + EXPECT_EQ(first_active_stage, 5); +} + +// --------------------------------------------------------------------------- +// C4 — ratchet state persists across restart. A CONFIRMED state file +// reconstructs as CONFIRMED, and a CONFIRMED bootstrap mints the target. +// --------------------------------------------------------------------------- +TEST(BTC_AutoRatchetSim, C4_StatePersistsAcrossRestart) +{ + const std::string path = std::string("/tmp/btc_autoratchet_sim_kat_") + + std::to_string(::getpid()) + ".json"; + std::remove(path.c_str()); + { + nlohmann::json j; + j["state"] = "confirmed"; + j["activated_at"] = 1; + j["activated_height"] = 2; + j["confirmed_at"] = 3; + j["confirm_count"] = 4; + j["target_version"] = 36; + std::ofstream f(path); + f << j.dump(2); + } + AutoRatchet ar(path, /*target_version=*/36); + EXPECT_EQ(ar.state(), RatchetState::CONFIRMED); + + btc::ShareTracker tracker; + auto [mint, vote] = ar.get_share_version(tracker, uint256{}); + EXPECT_EQ(mint, 36); // CONFIRMED bootstrap mints the target + EXPECT_EQ(vote, 36); + std::remove(path.c_str()); +}