// Copyright (c) 2025 The Limenka developers // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. #include #include #include #include #include #include BOOST_FIXTURE_TEST_SUITE(fork_pid_tests, ForkTestingSetup) // Seed target for a fresh block index: round-trip the fork powLimit through // compact encoding (24-bit mantissa is lossy), so that nBits round-trips exactly. static arith_uint256 SeedTarget(const Consensus::Params& p) { arith_uint256 seed; seed.SetCompact(UintToArith256(p.powLimit).GetCompact()); return seed; } // Advance one fork block: compute the target (pure) and commit the returned // DAA state onto the block index, mirroring ConnectBlock. static uint32_t Step(CBlockIndex& idx, CBlockHeader& hdr, const Consensus::Params& p) { ForkDAAState daa; uint32_t nbits = CalculateForkTarget(&idx, &hdr, p, &daa); idx.nForkTarget = daa.nForkTarget; idx.nForkAvgError = daa.nForkAvgError; idx.nForkLastBlockTime = daa.nForkLastBlockTime; return nbits; } BOOST_AUTO_TEST_CASE(pid_state_zero_on_fresh_index) { CBlockHeader dummy; dummy.nVersion = 0; dummy.nTime = 0; CBlockIndex fresh(dummy); BOOST_CHECK_EQUAL(fresh.nForkTarget, arith_uint256(0)); BOOST_CHECK_EQUAL(fresh.nForkAvgError, 0); BOOST_CHECK_EQUAL(fresh.nForkLastBlockTime, 0); } BOOST_AUTO_TEST_CASE(pid_init_seeds_from_block_difficulty) { const auto& p = ForkConsensus(); CBlockHeader dummy; dummy.nTime = 100; const arith_uint256 seed = SeedTarget(p); dummy.nBits = seed.GetCompact(); CBlockIndex idx(dummy); InitForkDAAState(&idx); BOOST_CHECK_EQUAL(idx.nForkTarget, seed); BOOST_CHECK_EQUAL(idx.nForkAvgError, 0); BOOST_CHECK_EQUAL(idx.nForkLastBlockTime, 0); } BOOST_AUTO_TEST_CASE(pid_interval_target) { const auto& p = ForkConsensus(); BOOST_CHECK_EQUAL(p.nForkIntervalTarget, 617); } BOOST_AUTO_TEST_CASE(pid_constants_populated) { const auto& p = ForkConsensus(); BOOST_CHECK_GT(p.nForkKp, 0); BOOST_CHECK_GT(p.nForkKiUp, 0); BOOST_CHECK_GT(p.nForkKiDown, 0); } BOOST_AUTO_TEST_CASE(pid_calculate_first_block_no_error) { const auto& p = ForkConsensus(); const arith_uint256 seed = SeedTarget(p); CBlockHeader hdr; hdr.nVersion = 2; hdr.nBits = seed.GetCompact(); hdr.nTime = 1000; CBlockIndex idx(hdr); InitForkDAAState(&idx); // First block: last_time == 0, so actual = interval. error = 0. uint32_t nbits = Step(idx, hdr, p); BOOST_CHECK_GT(nbits, 0u); BOOST_CHECK_EQUAL(idx.nForkLastBlockTime, 1000); // No error on the first block: target unchanged. BOOST_CHECK_EQUAL(idx.nForkTarget, seed); } BOOST_AUTO_TEST_CASE(pid_calculate_updates_state) { const auto& p = ForkConsensus(); const arith_uint256 seed = SeedTarget(p); CBlockHeader hdr; hdr.nVersion = 2; hdr.nBits = seed.GetCompact(); hdr.nTime = 1050; // 50s after first (fast: target 600s) CBlockIndex idx(hdr); InitForkDAAState(&idx); idx.nForkLastBlockTime = 1000; uint32_t nbits = Step(idx, hdr, p); BOOST_CHECK_GT(nbits, 0u); // State updated: error = 600-50 = 550 (blocks fast, harden). BOOST_CHECK_GT(idx.nForkAvgError, 0); BOOST_CHECK_EQUAL(idx.nForkLastBlockTime, 1050); // Target hardened (fast blocks -> lower target). BOOST_CHECK_LT(idx.nForkTarget, seed); } BOOST_AUTO_TEST_CASE(pid_converges_under_massive_hashpower) { // A massive hashpower increase: blocks arrive every 32s // instead of the 600s target. The PID must push the target DOWN (harder). const auto& p = ForkConsensus(); const arith_uint256 seed = SeedTarget(p); CBlockHeader hdr; hdr.nVersion = 2; hdr.nBits = seed.GetCompact(); CBlockIndex idx(hdr); InitForkDAAState(&idx); idx.nForkLastBlockTime = 1000; arith_uint256 prev_target = idx.nForkTarget; int64_t sim_time = 1000; // Mine 20 blocks at 32s intervals. for (int i = 0; i < 20; i++) { sim_time += 32; hdr.nTime = sim_time; Step(idx, hdr, p); } // Target must have decreased (harder) because blocks are too fast. BOOST_CHECK_LT(idx.nForkTarget, prev_target); // Target should still be above a reasonable floor (not collapsed). BOOST_CHECK_GT(idx.nForkTarget, seed / 1000); // avg_error should be positive (blocks consistently fast). BOOST_CHECK_GT(idx.nForkAvgError, 0); } BOOST_AUTO_TEST_CASE(pid_direction_reverses_on_slow_blocks) { // After fast blocks, switch to slow blocks (hashpower leaves). // The PID should switch direction and ease the target. const auto& p = ForkConsensus(); const arith_uint256 seed = SeedTarget(p); CBlockHeader hdr; hdr.nVersion = 2; hdr.nBits = seed.GetCompact(); CBlockIndex idx(hdr); InitForkDAAState(&idx); idx.nForkLastBlockTime = 1000; int64_t sim_time = 1000; // Phase 1: fast blocks (32s) for (int i = 0; i < 10; i++) { sim_time += 32; hdr.nTime = sim_time; Step(idx, hdr, p); } arith_uint256 after_fast = idx.nForkTarget; BOOST_CHECK_LT(after_fast, seed); // harder than the seed // Phase 2: slow blocks (2000s — hashpower dropped far below target) for (int i = 0; i < 10; i++) { sim_time += 2000; hdr.nTime = sim_time; Step(idx, hdr, p); } arith_uint256 after_slow = idx.nForkTarget; // Target should be EASIER than after the fast phase. BOOST_CHECK_GT(after_slow, after_fast); } BOOST_AUTO_TEST_CASE(pid_recompute_matches_stateful) { // A2 regression: the DAA state is a deterministic pure function of the // header chain. Recomputing from zeroed state (restart with an old // index format, or headers-first sync) must produce the identical // target as the stateful computation - the zero-state must not be // mistaken for "first fork block". const auto& p = ForkConsensus(); std::vector> chain; chain.reserve(8); { auto idx = std::make_unique(); idx->nTime = 1700000000; idx->nBits = SeedTarget(p).GetCompact(); idx->nHeight = 0; InitForkDAAState(idx.get()); chain.push_back(std::move(idx)); } // Build a chain of 6 fork blocks with varying intervals, storing each // block's DAA state exactly as ConnectBlock does (state of block N is // computed from block N-1's state and N's stamp). int64_t t = 1700000000; for (int i = 1; i < 7; ++i) { t += (i % 2 == 0) ? 620 : 605; auto idx = std::make_unique(); idx->nTime = t; idx->nHeight = i; idx->pprev = chain.back().get(); CBlockHeader hdr; hdr.nTime = t; ForkDAAState daa; idx->nBits = CalculateForkTarget(idx->pprev, &hdr, p, &daa); idx->nForkTarget = daa.nForkTarget; idx->nForkAvgError = daa.nForkAvgError; idx->nForkLastBlockTime = daa.nForkLastBlockTime; chain.push_back(std::move(idx)); } // Stateful target for the next block. CBlockHeader next; next.nTime = t + 600; ForkDAAState daa_stateful; const uint32_t nbits_stateful = CalculateForkTarget(chain.back().get(), &next, p, &daa_stateful); const arith_uint256 stateful_tip_state = chain.back()->nForkTarget; // Simulate a restart with an old-format index: zero every entry. for (auto& idx : chain) { idx->nForkTarget = arith_uint256(0); idx->nForkAvgError = 0; idx->nForkLastBlockTime = 0; idx->nForkAggregateSeconds = 0; } // The recompute path must reproduce the identical target and restore // the state on the entries. ForkDAAState daa; const uint32_t nbits_recomputed = CalculateForkTarget(chain.back().get(), &next, p, &daa); BOOST_CHECK_EQUAL(nbits_recomputed, nbits_stateful); // The walk restored the tip's state to its pre-restart value. BOOST_CHECK_EQUAL(chain.back()->nForkTarget, stateful_tip_state); } BOOST_AUTO_TEST_SUITE_END()