diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3debd89f7..21641b3a0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -68,7 +68,7 @@ jobs: test_mweb_builder \ test_address_resolution test_compute_share_target \ test_utxo test_dgb_subsidy dgb_share_test \ - rpc_request_test softfork_check_test genesis_check_test algo_select_test \ + rpc_request_test softfork_check_test genesis_check_test algo_select_test digishield_walk_test header_chain_test \ v37_test \ -j$(nproc) @@ -196,7 +196,7 @@ jobs: test_mweb_builder \ test_address_resolution test_compute_share_target \ test_utxo test_dgb_subsidy dgb_share_test \ - rpc_request_test softfork_check_test genesis_check_test algo_select_test \ + rpc_request_test softfork_check_test genesis_check_test algo_select_test digishield_walk_test header_chain_test \ test_coin_broadcaster test_multiaddress_pplns test_pplns_stress \ v37_test \ -j$(nproc) diff --git a/src/impl/dgb/coin/dgb_arith256.hpp b/src/impl/dgb/coin/dgb_arith256.hpp new file mode 100644 index 000000000..eff88541b --- /dev/null +++ b/src/impl/dgb/coin/dgb_arith256.hpp @@ -0,0 +1,112 @@ +#pragma once +// --------------------------------------------------------------------------- +// DGB DigiShield retarget arithmetic at TRUE 256-bit width (M3 §7b). +// +// The DigiShield/MultiShield damped multiply in coin/header_chain.hpp pins +// DigiByte Core CalculateNextWorkRequired: +// +// bnNew = avg_target (arith_uint256) +// bnNew *= damped_timespan <-- 256-bit multiply, OVERFLOW TRUNCATES +// bnNew /= nominal_timespan +// +// Earlier §7b slices used an unsigned __int128 proxy: correct ONLY while the +// expanded target fits 64 bits. A real DigiByte Scrypt target is a full +// arith_uint256 (pow_limit ~2^224); near it, avg_target * damped exceeds +// 2^256 and arith_uint256 DROPS the overflow limb. That truncation is +// CONSENSUS behaviour — a wider (overflow-safe) intermediate would compute a +// DIFFERENT next target and diverge from DigiByte Core / p2pool-merged-v36. +// +// This header is the minimal 256-bit unsigned that reproduces that exact +// truncation, isolated + separately guarded like dgb_digishield.hpp. It is +// header-only and depends ONLY on , so it links into the standalone +// GTest guard (no dgb OBJECT lib, no bitcoin arith_uint256 link) — the same +// constraint algo_select / header_chain tests run under. When the embedded +// DigiByte Core port lands, real arith_uint256 drops in with this same +// multiply-then-divide ordering and the boundary vectors carry over. +// --------------------------------------------------------------------------- + +#include + +namespace dgb::coin { + +// 256-bit unsigned, little-endian limbs (limb[0] least significant). Only the +// operations the DigiShield damped multiply needs: scale-by-u64 (truncating at +// 256 bits exactly as arith_uint256::operator*=), divide-by-u64, and compare. +struct u256 { + uint64_t limb[4] = {0, 0, 0, 0}; + + // Default-zero, plus an IMPLICIT widening from uint64_t so the field-shape + // swap (M3 7b) leaves every existing uint64-range brace-init / literal + // comparison byte-identical: `target = 4096`, `h.target == expected`, + // `pow_limit != 0` all keep compiling and computing the same value, while + // the embedded-daemon port drops full-width digests through this SAME field. + u256() = default; + u256(uint64_t v) { limb[0] = v; } + + static u256 from_u64(uint64_t v) { u256 r; r.limb[0] = v; return r; } + + bool fits_u64() const { return limb[1] == 0 && limb[2] == 0 && limb[3] == 0; } + bool is_zero() const { return limb[0] == 0 && limb[1] == 0 && limb[2] == 0 && limb[3] == 0; } + uint64_t low64() const { return limb[0]; } + + // Multiply by a 64-bit scalar. Carry past the top limb is DISCARDED, i.e. + // the result is truncated to 256 bits — byte-for-byte what arith_uint256 + // does. This is the divergence point vs a 128/wider proxy. + u256 mul_u64(uint64_t m) const { + u256 r; + unsigned __int128 carry = 0; + for (int i = 0; i < 4; ++i) { + unsigned __int128 prod = (unsigned __int128)limb[i] * m + carry; + r.limb[i] = (uint64_t)prod; + carry = prod >> 64; + } + // carry beyond limb[3] dropped -> 256-bit truncation (consensus). + return r; + } + + // Divide by a 64-bit scalar (d must be non-zero), truncating toward zero. + // Schoolbook long division from the most-significant limb down. + u256 div_u64(uint64_t d) const { + u256 r; + unsigned __int128 rem = 0; + for (int i = 3; i >= 0; --i) { + unsigned __int128 cur = (rem << 64) | limb[i]; + r.limb[i] = (uint64_t)(cur / d); + rem = cur % d; + } + return r; + } + + // Add another 256-bit value, truncating carry past limb[3] (256-bit wrap, + // same width discipline as mul_u64). Accumulates the retarget window's + // target sum before the divide-by-count average. + u256& operator+=(const u256& o) { + unsigned __int128 carry = 0; + for (int i = 0; i < 4; ++i) { + unsigned __int128 s = (unsigned __int128)limb[i] + o.limb[i] + carry; + limb[i] = (uint64_t)s; + carry = s >> 64; + } + return *this; + } + + friend bool operator<(const u256& a, const u256& b) { + for (int i = 3; i >= 0; --i) + if (a.limb[i] != b.limb[i]) return a.limb[i] < b.limb[i]; + return false; + } + friend bool operator>(const u256& a, const u256& b) { return b < a; } + friend bool operator==(const u256& a, const u256& b) { + return a.limb[0] == b.limb[0] && a.limb[1] == b.limb[1] + && a.limb[2] == b.limb[2] && a.limb[3] == b.limb[3]; + } +}; + +// avg * mul / div at full 256-bit width, in DigiByte Core ordering: MULTIPLY +// FIRST (overflow truncates at 256 bits), THEN divide. Reordering to divide +// first would lose low-order precision and is NOT what consensus computes. +inline u256 mul_div_u256(const u256& avg, uint64_t mul, uint64_t div) { + return avg.mul_u64(mul).div_u64(div); +} + +} // namespace dgb::coin diff --git a/src/impl/dgb/coin/dgb_digishield.hpp b/src/impl/dgb/coin/dgb_digishield.hpp new file mode 100644 index 000000000..1c5a8e9dd --- /dev/null +++ b/src/impl/dgb/coin/dgb_digishield.hpp @@ -0,0 +1,73 @@ +#pragma once +// --------------------------------------------------------------------------- +// DGB DigiShield Scrypt-only ancestor walk (M3 §7b — SCRYPT-ONLY VALIDATION). +// +// DigiByte retargets per-block per-algo with DigiShield/MultiShield, NOT +// Bitcoin's 2016-block window. For the V36 Scrypt-only lane the difficulty +// window must walk SCRYPT-ALGO ancestors ONLY. On a mixed-algo chain the +// header sequence interleaves Scrypt headers (full PoW validate) with +// non-Scrypt headers accepted by continuity (work-neutral). Folding a +// continuity header into the retarget window corrupts the Scrypt target AND +// re-introduces the work-neutrality break documented as the THIRD INVARIANT +// in coin/header_chain.hpp. This header isolates that walk so the trap lives +// in one consensus-pinned, separately-guarded place — exactly the failure the +// header_chain.hpp DIGISHIELD INSERTION POINT note warns is "easy to get wrong +// on a mixed-algo chain". +// +// This is the ancestor-selection step only; the per-algo DigiShield target +// math (LWMA/MultiShield) layers on top in the following slice and consumes +// the indices this returns. +// +// Header-only: depends ONLY on coin/dgb_block_algo.hpp + std, so it links into +// the standalone CI guard (GTest-only, no dgb OBJECT lib) like algo_select. +// --------------------------------------------------------------------------- + +#include +#include +#include +#include + +#include + +namespace dgb::coin { + +// THIRD-INVARIANT work-accounting predicate. Only a Scrypt header credits +// cumulative work / best-chain weight; a continuity (known non-Scrypt) header +// extends the header chain but is work-neutral. This is the single predicate +// HeaderChain::validate() consults before adding a header's work, so the +// invariant cannot drift between the validate() path and the retarget walk. +inline bool header_credits_work(int32_t n_version) noexcept +{ + return is_scrypt_header(n_version); +} + +// Collect the Scrypt-algo ancestors that form a DigiShield retarget window. +// +// version_at(k) -> nVersion of the header k positions back from the search +// start (k = 0 is the nearest candidate ancestor). +// depth -> how many positions are walkable (chain length behind). +// window -> number of Scrypt ancestors the retarget needs. +// +// Returns the k-indices of the first `window` SCRYPT headers, nearest-first, +// skipping every non-Scrypt (continuity) header. Returns fewer than `window` +// only when the available chain is exhausted (early-chain / genesis region). +// Unknown-algo headers never enter a validated chain (REJECT at ingest), so +// the walk treats anything not Scrypt as a skip. +inline std::vector scrypt_window_ancestors( + const std::function& version_at, + std::size_t depth, + std::size_t window) +{ + std::vector out; + if (window == 0) return out; + out.reserve(window); + for (std::size_t k = 0; k < depth && out.size() < window; ++k) + { + if (is_scrypt_header(version_at(k))) + out.push_back(k); + // else: continuity header -> skipped, contributes no window sample + } + return out; +} + +} // namespace dgb::coin diff --git a/src/impl/dgb/coin/header_chain.hpp b/src/impl/dgb/coin/header_chain.hpp index 1694d8fb6..983e4f8b0 100644 --- a/src/impl/dgb/coin/header_chain.hpp +++ b/src/impl/dgb/coin/header_chain.hpp @@ -28,11 +28,290 @@ // // >>> DIGISHIELD INSERTION POINT (M1 §2) <<< // DGB uses DigiShield/MultiShield per-algo difficulty retarget, NOT BTC's -// 2016-block retarget. TODO(M3): port per-algo DigiShield target calc for the -// Scrypt algo lane only. -// TODO(M3): the DigiShield difficulty window MUST walk Scrypt-algo ancestors -// ONLY — never the interleaved multi-algo header chain. On a mixed-algo chain -// the previous-block / window walk must skip non-Scrypt (continuity) headers; -// folding them into the window corrupts the Scrypt retarget and re-introduces -// the work-neutrality break above. Easy to get wrong on a mixed-algo chain. -namespace c2pool::dgb { /* TODO(M3): HeaderChain w/ Scrypt-only validate() + accept-by-continuity */ } +// 2016-block retarget. The Scrypt-only ancestor selection for that window is +// implemented + guarded in coin/dgb_digishield.hpp (scrypt_window_ancestors + +// header_credits_work). THIS header lands the validate() body that drives the +// chain: per-header disposition (Scrypt validate / continuity / reject), +// work-neutral accounting, and the Scrypt-only retarget-window ASSEMBLY +// (averaged target + actual timespan over the Scrypt samples). The damped +// DigiShield/MultiShield multiply (bnNew = avg * f(timespan)/target_timespan, +// arith_uint256, clamp + adjustment) layers on top of RetargetWindow in the +// following slice — its inputs are pinned here so the multiply cannot reach a +// continuity header. + +#include +#include +#include +#include + +#include +#include +#include + +namespace c2pool::dgb { + +using ::dgb::coin::DgbAlgo; +using ::dgb::coin::HeaderDisposition; +using ::dgb::coin::dgb_header_disposition; +using ::dgb::coin::header_credits_work; +using ::dgb::coin::is_scrypt_header; +using ::dgb::coin::mul_div_u256; +using ::dgb::coin::scrypt_window_ancestors; +using ::dgb::coin::u256; + +// Minimal header sample the Scrypt-only retarget body consumes. The embedded +// DigiByte Core port (M3+) carries the full CBlockHeader; the validate() + +// retarget path needs only the algo bits (disposition + work-credit), the +// timestamp (timespan), the expanded difficulty target (averaging), and the +// scrypt(header) PoW digest (`pow_hash`, the CheckProofOfWork hash <= target +// check). `target` and `pow_hash` are full 256-bit (coin/dgb_arith256.hpp u256) +// as of the field-shape swap: the implicit uint64 widening keeps every +// uint64-range vector byte-identical while the ingest PoW/ceiling checks and the +// DigiShield averaging now run at true arith_uint256 width, so the embedded +// daemon port drops real digests into this SAME field with no reshape. pow_hash +// == 0 is the default for "scrypt(header) not evaluated here" (trivially +// satisfies any target); the daemon port fills it. +struct HeaderSample { + int32_t n_version = 0; + int64_t n_time = 0; + u256 target = 0; // expanded PoW target (smaller == more work) + u256 pow_hash = 0; // scrypt(header) digest; hash <= target == valid PoW +}; + +// Outcome of validating + ingesting one header. +enum class IngestResult { + VALIDATED_SCRYPT, // Scrypt header, PoW-validated, work credited + ACCEPTED_CONTINUITY, // known non-Scrypt, appended, ZERO work credited + REJECTED, // unknown algo bits, or malformed Scrypt header +}; + +// Inputs the DigiShield/MultiShield damped multiply consumes. Assembled over +// SCRYPT ancestors ONLY — a continuity header can never reach `avg_target` or +// `actual_timespan`, which is precisely the corruption the THIRD INVARIANT and +// the DIGISHIELD INSERTION POINT warn against. +struct RetargetWindow { + std::size_t scrypt_samples = 0; // Scrypt headers folded in (<= window) + u256 avg_target = 0; // mean expanded target over samples + int64_t actual_timespan = 0; // newest - oldest Scrypt sample time + bool sufficient = false; // true iff the full window was found +}; + +// DigiShield/MultiShield retarget parameters (per-algo). `target_timespan` is +// the nominal per-block Scrypt solve time the damped filter pulls toward; +// `pow_limit` is the easiest (largest) admissible target -- bnNew is capped to +// it so a long stall can never relax difficulty past the network minimum. +struct DigiShieldParams { + int64_t target_timespan = 0; // nominal solve time the filter centers on + u256 pow_limit = 0; // max (easiest) target; result never exceeds +}; + +// Per-algo DigiShield/MultiShield damped retarget multiply (M3 7b). +// +// Consumes the Scrypt-only RetargetWindow (averaged target + actual timespan, +// both assembled over SCRYPT ancestors EXCLUSIVELY in next_retarget_window) and +// produces the next block's expanded target. Pins DigiByte Core's DigiShield v3 +// amplitude filter: +// +// damped = target_timespan + (actual_timespan - target_timespan) / 8 +// clamp damped into [target_timespan*3/4, target_timespan*3/2] +// bnNew = avg_target * damped / target_timespan +// bnNew = min(bnNew, pow_limit) +// +// The /8 filter damps a single block's deviation; the clamp rails bound the +// per-step move so a manipulated (or non-monotonic) timestamp run cannot swing +// difficulty arbitrarily in one retarget. The lower rail is reachable only when +// actual_timespan goes sharply negative (out-of-order block times); the upper +// rail trips once actual_timespan exceeds ~5x nominal. +// +// The damped-multiply intermediate runs at TRUE 256-bit width via mul_div_u256 +// (coin/dgb_arith256.hpp): arith_uint256 multiply-then-divide with 256-bit +// overflow truncation -- the consensus behaviour DigiByte Core exhibits near +// pow_limit, which an __int128 proxy could NOT reproduce (it would compute a +// wider, divergent next target). With the field-shape swap the target/avg_target +// fields ARE arith_uint256 (u256); for any uint64-range avg_target the result is +// bit-identical to the prior __int128 proxy, so existing vectors are unchanged, +// while a >2^64 avg now retargets at full width. The embedded-daemon port reuses +// this exact multiply/clamp ordering with real arith_uint256. +// +// Returns 0 when the window carries no Scrypt samples -- the caller MUST keep +// the prior target rather than retarget off an empty window (early/genesis). +inline u256 digishield_next_target(const RetargetWindow& rw, + const DigiShieldParams& params) +{ + if (rw.scrypt_samples == 0 || params.target_timespan <= 0) + return u256{}; + + const int64_t nominal = params.target_timespan; + + // Amplitude filter: pull the observed timespan 1/8 of the way off nominal. + int64_t damped = nominal + (rw.actual_timespan - nominal) / 8; + + // Per-step clamp rails: [3/4 nominal, 3/2 nominal]. + const int64_t floor_ts = nominal - nominal / 4; + const int64_t ceil_ts = nominal + nominal / 2; + if (damped < floor_ts) damped = floor_ts; + if (damped > ceil_ts) damped = ceil_ts; + + // bnNew = avg_target * damped / nominal at full 256-bit width. damped is + // strictly positive after the clamp (floor_ts > 0 for nominal > 0). The + // multiply truncates at 256 bits exactly as arith_uint256 does, so a target + // near pow_limit retargets to the SAME value DigiByte Core computes. + const u256 bn_new = mul_div_u256(rw.avg_target, + static_cast(damped), + static_cast(nominal)); + + // Difficulty floor: never relax past the network's easiest target. The cap + // compare runs at full 256-bit width, so a pow_limit in the high limbs is + // honoured exactly (a uint64 proxy would mis-decide near 2^64). + if (!params.pow_limit.is_zero() && bn_new > params.pow_limit) + return params.pow_limit; + + // Full-width next target (uint64-range inputs leave the high limbs zero, so + // this is bit-identical to the prior low64() return for existing vectors). + return bn_new; +} + +// Work-neutral header chain with a Scrypt-only DigiShield retarget body. +// Append order is oldest..newest; the retarget walk reads nearest-first. +class HeaderChain { +public: + HeaderChain() = default; + + // Configure the consensus retarget gate: the DigiShield params + the + // Scrypt-only window depth validate_and_append enforces declared targets + // against. Default-constructed chains leave target_timespan == 0, which + // makes digishield_next_target() return 0 and the gate a no-op -- the + // retarget-window/work-accounting tests run unconstrained, exactly as + // before this slice. + HeaderChain(DigiShieldParams ds, std::size_t retarget_window) + : m_ds_params(ds), m_retarget_window(retarget_window) {} + + // Validate one header and, unless rejected, append it to the chain. + // Work-neutrality SSOT: cumulative work advances iff header_credits_work() + // — the SAME predicate scrypt_window_ancestors() consults for the retarget + // window (header_credits_work forwards to is_scrypt_header). The two paths + // cannot drift: a header either credits work AND enters the window, or + // does neither. + IngestResult validate_and_append(const HeaderSample& h) + { + switch (dgb_header_disposition(h.n_version)) + { + case HeaderDisposition::REJECT: + return IngestResult::REJECTED; + + case HeaderDisposition::VALIDATE_SCRYPT: + { + // PoW validate path. A zero target is not a validatable Scrypt + // header (the structural guard here is that a malformed target + // never credits work). + if (h.target.is_zero()) + return IngestResult::REJECTED; + + // Minimum-difficulty ceiling (DigiByte Core CheckProofOfWork + // rejects when bnTarget > bnPowLimit). A declared Scrypt target + // EASIER (numerically larger) than the network minimum is + // consensus-invalid REGARDLESS of the retarget window -- this + // guards the bootstrap/empty-window path (expected == 0) where + // the nBits-style equality below cannot fire yet, so a stall + // could not otherwise reject a sub-minimum-difficulty header. + // pow_limit == 0 leaves the gate unconfigured (legacy + // default-ctor chains stay unconstrained, exactly as before). + if (!m_ds_params.pow_limit.is_zero() && h.target > m_ds_params.pow_limit) + return IngestResult::REJECTED; + + // PoW satisfaction (DigiByte Core CheckProofOfWork second half): + // the header's scrypt(header) digest must SATISFY its declared + // target -- hash <= target. A header claiming work it does not meet + // is consensus-invalid. This is the CONTEXT-FREE PoW check (mirrors + // `if (UintToArith256(hash) > bnTarget) return false`), run before + // the contextual nBits-equality gate below. pow_hash == 0 is the + // standalone proxy for "scrypt(header) not evaluated here" and + // trivially satisfies any target, so the chain-helper tests stay + // unconstrained; the embedded-daemon port fills pow_hash with the + // real Scrypt digest and this gate goes live with the SAME shape. + if (h.pow_hash > h.target) + return IngestResult::REJECTED; + + // Consensus retarget gate: the declared target must equal the + // DigiShield next-target computed over the Scrypt-only window + // ending at the CURRENT tip (assembled BEFORE h is appended). + // expected == 0 means no Scrypt ancestor is in range yet + // (genesis/bootstrap) or the gate is unconfigured -- no retarget + // constraint is enforceable, so the header passes on its + // structural (non-zero) target alone. nBits-style exact match + // mirrors Bitcoin/DigiByte consensus: the header carries the + // required next-work value, not merely a target it satisfies. + const RetargetWindow rw = next_retarget_window(m_retarget_window); + const u256 expected = digishield_next_target(rw, m_ds_params); + if (!expected.is_zero() && !(h.target == expected)) + return IngestResult::REJECTED; + + m_chain.push_back(h); + m_cumulative_work += work_from_target(h.target); // credited + return IngestResult::VALIDATED_SCRYPT; + } + + case HeaderDisposition::ACCEPT_BY_CONTINUITY: + default: + // Extends the header chain; work-neutral (NO m_cumulative_work + // change) and excluded from the retarget window by construction. + m_chain.push_back(h); + return IngestResult::ACCEPTED_CONTINUITY; + } + } + + // Assemble the Scrypt-only retarget window for the next block. Walks the + // SCRYPT ancestors of the tip (skipping continuity headers via the shared + // helper) and returns the averaged target + actual timespan the DigiShield + // multiply needs. `actual_timespan` spans the newest and oldest Scrypt + // samples only — continuity headers between them never widen it. + RetargetWindow next_retarget_window(std::size_t window) const + { + RetargetWindow rw; + if (window == 0 || m_chain.empty()) + return rw; + + const std::size_t depth = m_chain.size(); + // nearest-first view: k == 0 is the tip. + const auto version_at = [&](std::size_t k) { + return m_chain[depth - 1 - k].n_version; + }; + const std::vector idx = + scrypt_window_ancestors(version_at, depth, window); + if (idx.empty()) + return rw; + + u256 target_sum; + for (std::size_t k : idx) + target_sum += m_chain[depth - 1 - k].target; + + rw.scrypt_samples = idx.size(); + rw.avg_target = target_sum.div_u64(static_cast(idx.size())); + // idx is nearest-first: front() == newest Scrypt sample (smallest k), + // back() == oldest. Timespan is over Scrypt samples exclusively. + rw.actual_timespan = m_chain[depth - 1 - idx.front()].n_time + - m_chain[depth - 1 - idx.back()].n_time; + rw.sufficient = (idx.size() == window); + return rw; + } + + uint64_t cumulative_work() const { return m_cumulative_work; } + std::size_t size() const { return m_chain.size(); } + +private: + // Work proxy: inversely proportional to the target. The full-width field + // narrows to low64() for the proxy, keeping cumulative_work byte-identical + // for every uint64-range vector; the embedded port swaps in arith_uint256 + // work() (2^256 / (target+1)) over the same Scrypt-only credit path. + static uint64_t work_from_target(const u256& target) + { + return target.is_zero() ? 0 : (UINT64_MAX / target.low64()); + } + + DigiShieldParams m_ds_params{}; // retarget gate params + std::size_t m_retarget_window = 0; // Scrypt window depth + std::vector m_chain; // oldest .. newest + uint64_t m_cumulative_work = 0; +}; + +} // namespace c2pool::dgb diff --git a/src/impl/dgb/test/CMakeLists.txt b/src/impl/dgb/test/CMakeLists.txt index 0a79ed8b9..4446c52b3 100644 --- a/src/impl/dgb/test/CMakeLists.txt +++ b/src/impl/dgb/test/CMakeLists.txt @@ -25,7 +25,7 @@ if (BUILD_TESTING AND GTest_FOUND) # transport rpc.cpp wiring is deferred (Option-B scope). Each MUST appear # in BOTH this ctest registration AND the build.yml --target allowlist, # or it becomes a #143-style NOT_BUILT sentinel that reds master. - foreach(dgb_guard rpc_request_test softfork_check_test genesis_check_test algo_select_test) + foreach(dgb_guard rpc_request_test softfork_check_test genesis_check_test algo_select_test digishield_walk_test header_chain_test) add_executable(${dgb_guard} ${dgb_guard}.cpp) target_link_libraries(${dgb_guard} PRIVATE GTest::gtest_main GTest::gtest nlohmann_json::nlohmann_json) diff --git a/src/impl/dgb/test/digishield_walk_test.cpp b/src/impl/dgb/test/digishield_walk_test.cpp new file mode 100644 index 000000000..1b060e176 --- /dev/null +++ b/src/impl/dgb/test/digishield_walk_test.cpp @@ -0,0 +1,84 @@ +// --------------------------------------------------------------------------- +// dgb M3 §7b DigiShield Scrypt-only ancestor-walk regression guard. +// +// Pins the consensus quirk flagged at coin/header_chain.hpp's DIGISHIELD +// INSERTION POINT: on a mixed-algo DGB chain the Scrypt difficulty window must +// walk SCRYPT ancestors ONLY and SKIP continuity (non-Scrypt) headers. Folding +// a continuity header into the window corrupts the Scrypt retarget and breaks +// work-neutrality (THIRD INVARIANT). Also pins header_credits_work() == the +// Scrypt predicate, so the validate() work-accounting and the retarget walk +// share one SSOT and cannot drift apart. +// +// Links ONLY the header-only helpers + gtest -- no dgb OBJECT lib / transport. +// --------------------------------------------------------------------------- + +#include +#include + +#include + +#include + +using namespace dgb::coin; + +static constexpr int32_t PRIMARY = 2; // BLOCK_VERSION_DEFAULT +static constexpr int32_t SCRYPT = PRIMARY | DGB_BLOCK_VERSION_SCRYPT; +static constexpr int32_t SHA256D = PRIMARY | DGB_BLOCK_VERSION_SHA256D; +static constexpr int32_t SKEIN = PRIMARY | DGB_BLOCK_VERSION_SKEIN; +static constexpr int32_t ODO = PRIMARY | DGB_BLOCK_VERSION_ODO; + +// Build a version_at() over a fixed nearest-first chain. +static std::function chain(const std::vector& c) +{ + return [c](std::size_t k) { return c.at(k); }; +} + +TEST(DigishieldWalk, WorkCreditPredicateIsScryptOnly) +{ + EXPECT_TRUE (header_credits_work(SCRYPT)); + EXPECT_TRUE (header_credits_work(PRIMARY)); // bare primary == Scrypt + EXPECT_FALSE(header_credits_work(SHA256D)); + EXPECT_FALSE(header_credits_work(SKEIN)); + EXPECT_FALSE(header_credits_work(ODO)); +} + +TEST(DigishieldWalk, AllScryptChainTakesContiguousWindow) +{ + std::vector c(10, SCRYPT); + auto w = scrypt_window_ancestors(chain(c), c.size(), 4); + ASSERT_EQ(w.size(), 4u); + EXPECT_EQ(w, (std::vector{0, 1, 2, 3})); +} + +TEST(DigishieldWalk, SkipsInterleavedContinuityHeaders) +{ + // Mixed: S, sha, S, skein, S, odo, S -> Scrypt at k = 0,2,4,6. + std::vector c{SCRYPT, SHA256D, SCRYPT, SKEIN, SCRYPT, ODO, SCRYPT}; + auto w = scrypt_window_ancestors(chain(c), c.size(), 3); + ASSERT_EQ(w.size(), 3u); + EXPECT_EQ(w, (std::vector{0, 2, 4})); // continuity skipped +} + +TEST(DigishieldWalk, LeadingContinuityRunIsSkipped) +{ + // Nearest headers are all non-Scrypt; first Scrypt sample is deep. + std::vector c{SHA256D, SKEIN, ODO, SCRYPT, SCRYPT}; + auto w = scrypt_window_ancestors(chain(c), c.size(), 2); + ASSERT_EQ(w.size(), 2u); + EXPECT_EQ(w, (std::vector{3, 4})); +} + +TEST(DigishieldWalk, ExhaustedChainReturnsFewerThanWindow) +{ + // Only 2 Scrypt headers exist but the window wants 4 (early-chain region). + std::vector c{SCRYPT, SHA256D, SCRYPT, SHA256D}; + auto w = scrypt_window_ancestors(chain(c), c.size(), 4); + ASSERT_EQ(w.size(), 2u); + EXPECT_EQ(w, (std::vector{0, 2})); +} + +TEST(DigishieldWalk, ZeroWindowIsEmpty) +{ + std::vector c(4, SCRYPT); + EXPECT_TRUE(scrypt_window_ancestors(chain(c), c.size(), 0).empty()); +} diff --git a/src/impl/dgb/test/header_chain_test.cpp b/src/impl/dgb/test/header_chain_test.cpp new file mode 100644 index 000000000..0a25085af --- /dev/null +++ b/src/impl/dgb/test/header_chain_test.cpp @@ -0,0 +1,442 @@ +// --------------------------------------------------------------------------- +// dgb M3 §7b HeaderChain Scrypt-only validate() + retarget-body guard. +// +// Pins the THIRD INVARIANT (coin/header_chain.hpp) at the validate() level: +// 1. Work-neutrality SSOT — only a Scrypt header credits cumulative work; a +// continuity (known non-Scrypt) header appends but is work-neutral; an +// unknown-algo or malformed header is rejected. The work-credit decision +// and the retarget-window inclusion go through ONE predicate +// (header_credits_work == is_scrypt_header) so they cannot drift. +// 2. Retarget continuity-skip — a continuity header sitting INSIDE the +// nominal window is excluded from the Scrypt target computation: it never +// reaches avg_target nor widens actual_timespan. A naive all-headers +// window would corrupt both; this proves it can't. +// +// Header-only guard: links the header-only chain helpers + gtest, no dgb +// OBJECT lib / transport. MUST appear in BOTH the test/CMakeLists foreach AND +// both build.yml --target allowlists, or it becomes a #143 NOT_BUILT sentinel. +// --------------------------------------------------------------------------- + +#include + +#include + +#include + +using namespace c2pool::dgb; +using dgb::coin::DGB_BLOCK_VERSION_SCRYPT; +using dgb::coin::DGB_BLOCK_VERSION_SHA256D; +using dgb::coin::DGB_BLOCK_VERSION_SKEIN; + +static constexpr int32_t PRIMARY = 2; // BLOCK_VERSION_DEFAULT +static constexpr int32_t SCRYPT = PRIMARY | DGB_BLOCK_VERSION_SCRYPT; +static constexpr int32_t SHA256D = PRIMARY | DGB_BLOCK_VERSION_SHA256D; +static constexpr int32_t SKEIN = PRIMARY | DGB_BLOCK_VERSION_SKEIN; +static constexpr int32_t UNKNOWN_ALGO = PRIMARY | (10 << 8); // not a known codepoint + +TEST(HeaderChainValidate, ScryptCreditsWorkContinuityIsNeutral) +{ + HeaderChain hc; + EXPECT_EQ(hc.validate_and_append({SCRYPT, 1000, 100}), + IngestResult::VALIDATED_SCRYPT); + const uint64_t after_one = hc.cumulative_work(); + EXPECT_GT(after_one, 0u); + + // Continuity header: appended, but ZERO work credited. + EXPECT_EQ(hc.validate_and_append({SHA256D, 1075, 999999}), + IngestResult::ACCEPTED_CONTINUITY); + EXPECT_EQ(hc.cumulative_work(), after_one); // work-neutral + EXPECT_EQ(hc.size(), 2u); // but chain extended +} + +TEST(HeaderChainValidate, RejectsUnknownAlgoAndMalformedScrypt) +{ + HeaderChain hc; + EXPECT_EQ(hc.validate_and_append({UNKNOWN_ALGO, 1000, 100}), + IngestResult::REJECTED); + EXPECT_EQ(hc.validate_and_append({SCRYPT, 1000, 0}), // zero target + IngestResult::REJECTED); + EXPECT_EQ(hc.size(), 0u); // neither appended + EXPECT_EQ(hc.cumulative_work(), 0u); // no work credited +} + +TEST(HeaderChainValidate, ContinuityHeaderInsideWindowSkippedFromTarget) +{ + // oldest..newest: S(100) , sha(cheap) , S(100) , S(100) + // The continuity header sits between two Scrypt samples, INSIDE a window=3. + HeaderChain hc; + ASSERT_EQ(hc.validate_and_append({SCRYPT, 1000, 100}), IngestResult::VALIDATED_SCRYPT); + ASSERT_EQ(hc.validate_and_append({SHA256D, 1075, 999999}), IngestResult::ACCEPTED_CONTINUITY); + ASSERT_EQ(hc.validate_and_append({SCRYPT, 1150, 100}), IngestResult::VALIDATED_SCRYPT); + ASSERT_EQ(hc.validate_and_append({SCRYPT, 1225, 100}), IngestResult::VALIDATED_SCRYPT); + + const RetargetWindow rw = hc.next_retarget_window(3); + + EXPECT_TRUE(rw.sufficient); + EXPECT_EQ(rw.scrypt_samples, 3u); + // Scrypt-only average: (100+100+100)/3 == 100. A naive all-headers window + // would fold the cheap 999999 target in and blow this up. + EXPECT_EQ(rw.avg_target, 100u); + // Timespan spans the newest..oldest SCRYPT samples (1225 - 1000 == 225), + // NOT the naive newest..3rd-back-header span (1225 - 1075 == 150). + EXPECT_EQ(rw.actual_timespan, 225); + EXPECT_NE(rw.actual_timespan, 150); + + // The continuity header was work-neutral: only the 3 Scrypt headers count. + EXPECT_EQ(hc.size(), 4u); + EXPECT_EQ(hc.cumulative_work(), 3u * (UINT64_MAX / 100)); +} + +TEST(HeaderChainValidate, AllScryptWindowIsContiguous) +{ + HeaderChain hc; + hc.validate_and_append({SCRYPT, 1000, 200}); + hc.validate_and_append({SCRYPT, 1100, 100}); + hc.validate_and_append({SCRYPT, 1200, 300}); + const RetargetWindow rw = hc.next_retarget_window(3); + EXPECT_TRUE(rw.sufficient); + EXPECT_EQ(rw.scrypt_samples, 3u); + EXPECT_EQ(rw.avg_target, (200u + 100u + 300u) / 3u); + EXPECT_EQ(rw.actual_timespan, 200); // 1200 - 1000 +} + +TEST(HeaderChainValidate, InsufficientWindowFlaggedNotSufficient) +{ + // Only 2 Scrypt samples exist; a window of 4 is under-filled (early chain). + HeaderChain hc; + hc.validate_and_append({SCRYPT, 1000, 100}); + hc.validate_and_append({SKEIN, 1050, 555}); // continuity + hc.validate_and_append({SCRYPT, 1100, 100}); + const RetargetWindow rw = hc.next_retarget_window(4); + EXPECT_FALSE(rw.sufficient); + EXPECT_EQ(rw.scrypt_samples, 2u); + EXPECT_EQ(rw.avg_target, 100u); + EXPECT_EQ(rw.actual_timespan, 100); // 1100 - 1000, Scrypt-only +} + +TEST(HeaderChainValidate, EmptyChainAndZeroWindowAreSafe) +{ + HeaderChain hc; + EXPECT_FALSE(hc.next_retarget_window(3).sufficient); // empty chain + hc.validate_and_append({SCRYPT, 1000, 100}); + const RetargetWindow rw = hc.next_retarget_window(0); // zero window + EXPECT_FALSE(rw.sufficient); + EXPECT_EQ(rw.scrypt_samples, 0u); +} + + +// --------------------------------------------------------------------------- +// DigiShield/MultiShield damped retarget multiply (digishield_next_target). +// Exercises the amplitude filter + clamp at BOTH rails plus the nominal case, +// and the pow_limit difficulty floor. Inputs are hand-built RetargetWindows so +// the multiply is pinned independently of the (already-tested) Scrypt-only +// window assembly above. +// +// nominal target_timespan = 60 -> floor = 60 - 60/4 = 45, +// ceil = 60 + 60/2 = 90. +// --------------------------------------------------------------------------- +static RetargetWindow make_window(uint64_t avg_target, int64_t actual_timespan) +{ + RetargetWindow rw; + rw.scrypt_samples = 3; + rw.avg_target = avg_target; + rw.actual_timespan = actual_timespan; + rw.sufficient = true; + return rw; +} + +TEST(DigiShieldRetarget, NominalTimespanLeavesTargetUnchanged) +{ + // actual == nominal -> damped == nominal -> bnNew == avg_target. + const DigiShieldParams p{60, 0}; + EXPECT_EQ(digishield_next_target(make_window(1000, 60), p), 1000u); +} + +TEST(DigiShieldRetarget, CeilingRailCapsTheEasing) +{ + // actual far above nominal: damped = 60 + (1000-60)/8 = 177 -> clamps to 90. + // bnNew = 1000 * 90 / 60 = 1500 (target relaxes by exactly the 3/2 rail). + const DigiShieldParams p{60, 0}; + EXPECT_EQ(digishield_next_target(make_window(1000, 1000), p), 1500u); +} + +TEST(DigiShieldRetarget, FloorRailCapsTheTightening) +{ + // Sharply negative timespan (out-of-order block times): damped goes well + // below floor -> clamps to 45. bnNew = 1000 * 45 / 60 = 750 (3/4 rail). + const DigiShieldParams p{60, 0}; + EXPECT_EQ(digishield_next_target(make_window(1000, -1000), p), 750u); +} + +TEST(DigiShieldRetarget, PowLimitFloorsTheTarget) +{ + // Ceiling rail would yield 1500, but pow_limit (easiest target) caps it. + const DigiShieldParams p{60, 1200}; + EXPECT_EQ(digishield_next_target(make_window(1000, 1000), p), 1200u); +} + +TEST(DigiShieldRetarget, EmptyWindowKeepsPriorTarget) +{ + // No Scrypt samples -> 0 sentinel: caller keeps the prior target. + RetargetWindow rw; // scrypt_samples == 0 + const DigiShieldParams p{60, 0}; + EXPECT_EQ(digishield_next_target(rw, p), 0u); + // Degenerate nominal is also rejected (no divide-by-zero). + EXPECT_EQ(digishield_next_target(make_window(1000, 60), DigiShieldParams{0, 0}), 0u); +} + +TEST(DigiShieldRetarget, ConsumesLiveScryptOnlyWindow) +{ + // End-to-end: the same continuity-skipped window the validate() path builds + // (avg 100, timespan 225 over 3 Scrypt samples) feeds the multiply. With + // nominal 225 the damped value is exactly nominal -> target unchanged at 100. + HeaderChain hc; + hc.validate_and_append({SCRYPT, 1000, 100}); + hc.validate_and_append({SHA256D, 1075, 999999}); // continuity, skipped + hc.validate_and_append({SCRYPT, 1150, 100}); + hc.validate_and_append({SCRYPT, 1225, 100}); + const RetargetWindow rw = hc.next_retarget_window(3); + ASSERT_TRUE(rw.sufficient); + EXPECT_EQ(digishield_next_target(rw, DigiShieldParams{225, 0}), 100u); +} + +// --------------------------------------------------------------------------- +// Ingest-path retarget gate: validate_and_append must demand the declared +// Scrypt target EQUAL the DigiShield next-target computed over the Scrypt-only +// window ending at the current tip (nBits-style exact consensus match). A +// default-constructed chain leaves the gate unconfigured (target_timespan 0 -> +// expected 0), so every test above runs unconstrained; this one configures it. +// +// window depth 1, nominal 80. Window(1) over a lone Scrypt tip has +// actual_timespan 0 (front == back) -> damped = 80 + (0-80)/8 = 70, above the +// floor rail 60 -> next target = avg * 70/80 = avg * 7/8, deterministically. +// --------------------------------------------------------------------------- +TEST(HeaderChainValidate, IngestGateEnforcesDigiShieldNextTarget) +{ + HeaderChain hc(DigiShieldParams{80, 0}, /*retarget_window=*/1); + + // Seed: empty window -> gate no-op, any non-zero target seeds the chain. + ASSERT_EQ(hc.validate_and_append({SCRYPT, 1000, 4096}), + IngestResult::VALIDATED_SCRYPT); + + // Required next target = 4096 * 7/8 = 3584. A mismatch is consensus-invalid + // and must REJECT without mutating the chain (size + work unchanged). + const std::size_t size_before = hc.size(); + const uint64_t work_before = hc.cumulative_work(); + EXPECT_EQ(hc.validate_and_append({SCRYPT, 1080, 4096}), + IngestResult::REJECTED); + EXPECT_EQ(hc.size(), size_before); + EXPECT_EQ(hc.cumulative_work(), work_before); + + // The exact DigiShield target is accepted and credits work. + EXPECT_EQ(hc.validate_and_append({SCRYPT, 1080, 3584}), + IngestResult::VALIDATED_SCRYPT); + EXPECT_GT(hc.cumulative_work(), work_before); + + // Continuity headers bypass the retarget gate (work-neutral, no nBits). + EXPECT_EQ(hc.validate_and_append({SHA256D, 1090, 1}), + IngestResult::ACCEPTED_CONTINUITY); +} + + +// Ingest-path minimum-difficulty ceiling: a Scrypt header declaring a target +// EASIER than pow_limit (numerically larger) is consensus-invalid REGARDLESS of +// the retarget window -- it must be rejected even on the bootstrap/empty-window +// path where the nBits-style equality gate is a no-op (expected == 0). Mirrors +// DigiByte Core CheckProofOfWork rejecting when bnTarget > bnPowLimit. +TEST(HeaderChainValidate, IngestGateRejectsTargetAbovePowLimit) +{ + // pow_limit configured, retarget_window 1. Seed empty -> equality gate is a + // no-op, so only the ceiling can reject this first header. + HeaderChain hc(DigiShieldParams{80, /*pow_limit=*/4096}, /*window=*/1); + + // target == pow_limit is the easiest ADMISSIBLE target (strict > reject): + // accepted and credits work, seeding the chain. + ASSERT_EQ(hc.validate_and_append({SCRYPT, 1000, 4096}), + IngestResult::VALIDATED_SCRYPT); + + // A target ABOVE pow_limit (easier than the network minimum) must REJECT + // without mutating the chain -- and it must do so on the ceiling alone, + // before the retarget-equality gate (which here would demand 4096*7/8=3584). + const std::size_t size_before = hc.size(); + const uint64_t work_before = hc.cumulative_work(); + EXPECT_EQ(hc.validate_and_append({SCRYPT, 1080, 4097}), + IngestResult::REJECTED); + EXPECT_EQ(hc.size(), size_before); + EXPECT_EQ(hc.cumulative_work(), work_before); + + // pow_limit == 0 leaves the ceiling unconfigured: an enormous target is not + // rejected by THIS gate (legacy default-ctor behaviour preserved). + HeaderChain unconfigured; // target_timespan 0, pow_limit 0 + EXPECT_EQ(unconfigured.validate_and_append({SCRYPT, 1000, UINT64_MAX}), + IngestResult::VALIDATED_SCRYPT); +} + +// Ingest-path PoW satisfaction (DigiByte Core CheckProofOfWork second half): +// a Scrypt header whose scrypt(header) digest is NUMERICALLY GREATER than its +// declared target does not meet the work it claims and is consensus-invalid -- +// rejected without mutating the chain, independent of the retarget/ceiling +// gates. Mirrors `if (UintToArith256(hash) > bnTarget) return false`. The +// pow_hash field defaults to 0 (every brace-init vector above), which trivially +// satisfies any target, so all chain-helper tests run unchanged; here we set it +// explicitly to drive the gate. A default-ctor chain leaves the retarget gate +// and pow_limit ceiling unconfigured, so ONLY this PoW check can fire. +TEST(HeaderChainValidate, IngestGateRejectsInsufficientScryptPoW) +{ + HeaderChain hc; // gate + ceiling unconfigured: only the PoW check can fire + + // pow_hash strictly ABOVE the declared target: the header does not satisfy + // its own difficulty. REJECT, no work credited, chain not extended. + HeaderSample weak{SCRYPT, 1000, 100}; + weak.pow_hash = 101; + EXPECT_EQ(hc.validate_and_append(weak), IngestResult::REJECTED); + EXPECT_EQ(hc.size(), 0u); + EXPECT_EQ(hc.cumulative_work(), 0u); + + // pow_hash == target is the boundary: hash <= target satisfies the work. + HeaderSample exact{SCRYPT, 1000, 100}; + exact.pow_hash = 100; + EXPECT_EQ(hc.validate_and_append(exact), IngestResult::VALIDATED_SCRYPT); + + // pow_hash strictly below target also satisfies (more work than required). + HeaderSample strong{SCRYPT, 1075, 100}; + strong.pow_hash = 1; + EXPECT_EQ(hc.validate_and_append(strong), IngestResult::VALIDATED_SCRYPT); + EXPECT_EQ(hc.size(), 2u); + + // A continuity (non-Scrypt) header never reaches the PoW check: its + // disposition short-circuits to ACCEPT_BY_CONTINUITY even with a huge + // pow_hash, confirming the gate is Scrypt-path only. + HeaderSample cont{SHA256D, 1090, 100}; + cont.pow_hash = UINT64_MAX; + EXPECT_EQ(hc.validate_and_append(cont), IngestResult::ACCEPTED_CONTINUITY); +} + +// --------------------------------------------------------------------------- +// 256-BIT BOUNDARY (M3 §7b — arith_uint256 swap). Proves, not assumes, that +// the DigiShield damped multiply runs at TRUE 256-bit width: a uint64/__int128 +// proxy and the full-width path DIVERGE once avg_target leaves the 64-bit range +// or the product overflows 256 bits near pow_limit. mul_div_u256 reproduces +// arith_uint256 multiply-then-divide with 256-bit overflow truncation, which is +// CONSENSUS behaviour in DigiByte Core CalculateNextWorkRequired. +// --------------------------------------------------------------------------- +#include +using dgb::coin::u256; +using dgb::coin::mul_div_u256; + +static constexpr uint64_t BIT63 = 0x8000000000000000ull; // 2^63 + +TEST(DigiShield256, Uint64RangeIsBitIdenticalToProxy) +{ + // For every avg in 64-bit range the 256-bit path equals the old __int128 + // proxy (a*m)/d exactly — this is the no-regression guard for the swap. + const uint64_t cases[][3] = { + {1000, 60, 60}, // nominal -> 1000 + {1000, 90, 60}, // ceiling rail-> 1500 + {1000, 45, 60}, // floor rail -> 750 + {4096, 70, 80}, // window-1 7/8-> 3584 + {999999999ull, 90, 60}, + }; + for (auto& c : cases) { + const u256 r = mul_div_u256(u256::from_u64(c[0]), c[1], c[2]); + ASSERT_TRUE(r.fits_u64()); + const unsigned __int128 proxy = + ((unsigned __int128)c[0] * c[1]) / c[2]; + EXPECT_EQ(r.low64(), (uint64_t)proxy); + } +} + +TEST(DigiShield256, FullWidthAvgDivergesFromUint64Proxy) +{ + // avg_target = 2^64 (limb[1]=1, limb[0]=0): a value the uint64 proxy CANNOT + // hold — it sees only low64()==0. Full width: 2^64 * 3/2 = 3*2^63. + u256 avg; avg.limb[1] = 1; avg.limb[0] = 0; // == 2^64 + const u256 full = mul_div_u256(avg, 3, 2); + + // Full-width result carries the high limb: 3*2^63 = 2^64 + 2^63. + EXPECT_EQ(full.limb[1], 1u); + EXPECT_EQ(full.limb[0], BIT63); + EXPECT_FALSE(full.fits_u64()); + + // The proxy, fed the truncated low64()==0, would compute 0 -> divergence. + const u256 proxy = mul_div_u256(u256::from_u64(avg.low64()), 3, 2); + EXPECT_TRUE(proxy.is_zero()); + EXPECT_FALSE(full == proxy); +} + +TEST(DigiShield256, MultiplyTruncatesAt256BitsLikeArithUint256) +{ + // avg = 2^255 (top bit of the top limb). *4 = 2^257 -> wraps to 0 mod 2^256. + // A wider/overflow-safe intermediate would yield 2^257 (non-zero); the + // 256-bit consensus path MUST truncate to 0. + u256 avg; avg.limb[3] = BIT63; // == 2^255 + EXPECT_TRUE(mul_div_u256(avg, 4, 1).is_zero()); + + // *3 = 2^256 + 2^255 -> truncates back to 2^255. + const u256 wrap = mul_div_u256(avg, 3, 1); + EXPECT_EQ(wrap.limb[3], BIT63); + EXPECT_EQ(wrap.limb[0], 0u); + EXPECT_EQ(wrap.limb[1], 0u); + EXPECT_EQ(wrap.limb[2], 0u); +} + +TEST(DigiShield256, NearPowLimitCeilingRailRetargetsAtFullWidth) +{ + // A near-pow_limit avg at the ceiling rail (damped 90, nominal 60 -> *3/2). + // avg = 2^224 (representative DGB Scrypt pow_limit magnitude); *3/2 stays in + // range here (no wrap) but lives ENTIRELY in the high limbs the proxy drops. + u256 avg; avg.limb[3] = 0x0000000100000000ull; // 2^224 + const u256 full = mul_div_u256(avg, 90, 60); // *3/2 + // 2^224 * 3/2 = 3 * 2^223 = 2^224 + 2^223; high limb 0x0000000180000000. + EXPECT_EQ(full.limb[3], 0x0000000180000000ull); + EXPECT_FALSE(full.fits_u64()); + // Proxy on low64()==0 collapses to 0 — the rail would be computed wrong. + EXPECT_TRUE(mul_div_u256(u256::from_u64(avg.low64()), 90, 60).is_zero()); +} + +TEST(DigiShield256, ComparePicksTheLargerFullWidthValue) +{ + // pow_limit cap uses u256 compare; verify ordering across the limb boundary. + u256 big; big.limb[3] = 1; // 2^192 + u256 small = u256::from_u64(UINT64_MAX); // 2^64 - 1 + EXPECT_TRUE(small < big); + EXPECT_TRUE(big > small); + EXPECT_FALSE(big < small); +} + +// --------------------------------------------------------------------------- +// 256-BIT INGEST PATH (M3 7b -- field-shape swap). The swap widened +// HeaderSample::target/pow_hash (and the retarget fields) to u256, so the +// ingest PoW-satisfaction check (h.pow_hash <= h.target) now runs at TRUE +// 256-bit width through validate_and_append -- not only inside +// digishield_next_target. This PROVES it: targets/digests living in the HIGH +// limbs decide acceptance the SAME way arith_uint256 would and DIVERGE from a +// uint64 (low64-only) proxy in BOTH directions. Gate + ceiling stay +// unconfigured (default ctor) so only the PoW satisfaction check can fire. +// --------------------------------------------------------------------------- +TEST(HeaderChainValidate, IngestPoWSatisfactionRunsAtFullWidth) +{ + HeaderChain hc; // unconfigured: only the PoW satisfaction gate can fire + + // target = 2^192 + 5 (lives in the top limb; low64 == 5). + u256 target; target.limb[3] = 1; target.limb[0] = 5; + + // pow_hash = 10 (fits u64). Full width: 10 <= 2^192+5 -> PoW SATISFIED, the + // header is valid. A low64-only proxy would compare 10 > 5 and WRONGLY + // reject -- so acceptance here proves the check is full-width. + HeaderSample ok{SCRYPT, 1000}; + ok.target = target; + ok.pow_hash = u256::from_u64(10); + EXPECT_EQ(hc.validate_and_append(ok), IngestResult::VALIDATED_SCRYPT); + EXPECT_EQ(hc.size(), 1u); + + // pow_hash = 2^193 (top limb 2, low64 == 0) vs the same 2^192+5 target. + // Full width: 2^193 > 2^192+5 -> does NOT satisfy -> REJECT. A low64-only + // proxy would compare 0 > 5 (false) and WRONGLY accept -- divergence the + // other direction. No mutation on reject. + HeaderSample weak{SCRYPT, 1075}; + weak.target = target; + weak.pow_hash.limb[3] = 2; // 2^193, low64 == 0 + EXPECT_EQ(hc.validate_and_append(weak), IngestResult::REJECTED); + EXPECT_EQ(hc.size(), 1u); // unchanged +}