diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 01c412ddc..236a65370 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -67,6 +67,7 @@ jobs: test_mweb_builder \ test_address_resolution test_compute_share_target \ test_utxo \ + v37_test \ -j$(nproc) - name: Run tests @@ -192,6 +193,7 @@ jobs: test_address_resolution test_compute_share_target \ test_utxo \ test_coin_broadcaster test_multiaddress_pplns test_pplns_stress \ + v37_test \ -j$(nproc) - name: Run tests under sanitizers diff --git a/src/sharechain/CMakeLists.txt b/src/sharechain/CMakeLists.txt index 6ff4561dd..875ba0a0c 100644 --- a/src/sharechain/CMakeLists.txt +++ b/src/sharechain/CMakeLists.txt @@ -11,4 +11,5 @@ set(SHARECHAIN_SOURCE add_library(sharechain OBJECT ${SHARECHAIN_SOURCE}) target_link_libraries(sharechain core) -add_subdirectory(test) \ No newline at end of file +add_subdirectory(test) +add_subdirectory(v37/test) diff --git a/src/sharechain/v37/IMPLEMENTATION-NOTES.md b/src/sharechain/v37/IMPLEMENTATION-NOTES.md new file mode 100644 index 000000000..efb419fce --- /dev/null +++ b/src/sharechain/v37/IMPLEMENTATION-NOTES.md @@ -0,0 +1,99 @@ +# V37 MRR Roundabout Round-Buffer — implementation notes (WIP for review) + +Branch: `v37/mrr-roundabout-buffer`. Spec: `c2pool-v37-mrr-roundabout-buffer.md` v1.0 +(all OQ/S decisions resolved). Module: `src/sharechain/v37/`, header-only, +`namespace v37`, stdlib-only — compiles and tests standalone with +`g++ -std=c++20`, no conan/boost/btclibs dependency. + +## Done (implemented + tested on this VM) + +| Component (brief) | Where | Status | +|---|---|---| +| 1. Lane storage, bucket leaves (F-1 fields) | `v37_lane.hpp` | done | +| 2. MRR roll-up pyramid (OQ-5 geometry) | `v37_lane.hpp` (`fold_l0`, `cascade_folds`) | done; L=2 default fully tested, L≥3 see notes | +| 3. Epoch-scaled incremental decay + OQ-2 exact rebuild | `v37_lane.hpp` (`push`, `epoch_rebuild`) | done | +| 4. PayoutDescriptor v1 (OQ-3, S-1/S-2/S-3) | `v37_descriptor.hpp` | done | +| 5. Quantized window (OQ-1) + reorg journal D=64 (OQ-7) | `v37_lane.hpp` (`evict_oldest_bucket`, `rewind`) | done | +| 6. Lane digest (§8.5, OQ-4) | `v37_lane.hpp` (`digest`) | done | +| 7. Fixed-point model + slow reference + bit-exact gate | `v37_fixed.hpp` + `test/v37_test.cpp` (`ReferenceLane`) | done | +| Multichain container + miner intern | `v37_roundabout.hpp` | done | + +Test run (g++ 13.3, -O2 and again under `-fsanitize=address,undefined`): +**100,338 checks, 0 failures.** The consensus gate is `ReferenceLane` — an +independent implementation whose per-miner weights are recomputed by full +scan of the durable records after EVERY push — compared bit-exact against the +incremental accumulators, across two geometries (small stress geometry, 5+ +epochs; ratified OQ-5 default geometry across two epoch rebuilds, ~9.5k +pushes, full-u64-range weights). Also covered: digest determinism + +sensitivity, rewind bit-exact restoration, window quantization, raw-work +conservation (F-1), descriptor canon vectors for all five template kinds + +kind-255 fallback, S-1 identity distinctness, aux/attribution validity rules, +runtime lane add/remove, cross-lane identity intern. + +## Range pinning + spec errata (flag for the spec's next revision) + +1. **Q62, not Q64.** §8.2 says "widens to 64 fractional bits"; §8.1 delegates + exact range pinning here. Pinned at FRAC_BITS = 62 so that: every table + entry (decay ≤ 1.0, inverse ≤ 2^1.9) fits u64; w_raw keeps the FULL u64 + range; every w_raw × table product fits native unsigned __int128; + accumulators fit 256 bits. Still 2^22 finer than V36's Q40. Suggest spec + erratum: "62 fractional bits" with this rationale. +2. **Wider storage than the §3 struct sketch.** Spec sketches comp w_scaled + as q64 and bucket scaled_sum as q128; with full-range u64 raw work those + overflow. Implementation uses u128 (L0 scaled) and U256 (sums, comp + scaled). Spec §5 footprint numbers shift accordingly; tightening back + down requires capping w_raw (a consensus parameter decision — operator). +3. **Journal does not cross epoch rebuilds.** `rewind()` refuses if the span + crosses a rebuild (journal is cleared there); caller takes the full lane + rebuild path — the same escape hatch as the >D case (§6.2). Affects ~D/E + ≈ 1.6% of max-depth reorgs at default geometry. Suggest folding this rule + into §6.2 explicitly. +4. **Level sums for levels ≥ 1.** Buckets in one level can carry different + epoch tags (immutability rule), so a single stored per-level scaled sum is + frame-mixed; the per-band view weight is assembled per-bucket with the + epoch shift (O(buckets/level) ≤ 568) instead of a maintained O(1) field. + L0 sums are maintained O(1) as specced. +5. **No residual dust at L = 2 (stronger than spec).** §4.2 tolerates + deterministic truncation residuals between rebuilds; with the + rebuild-from-raw design and default L = 2 the incremental state equals the + full-scan reference EXACTLY at every operation (proven by the gate). The + only residual source is the L≥3 cascade fold (children re-framed at fold); + at L≥3 the gate holds at rebuild points, per spec. L≥3 needs its own + rebuild-point-gated test before any chain uses it. + +## Stubbed / deferred (not blocking review) + +- **L1 view projection** (`RingFrame`/`Level` serialization for + `/pplns/rings`): not implemented — view-layer concern; all backing queries + exist (`payout_map`, `raw_work_in_span`, `levels()` accessor, digest). +- **SoA / arena / power-of-two mask storage**: test-grade impl uses + std::deque/std::vector with identical semantics; the §5 memory-layout + optimizations (contiguous SoA rings, bump-allocated comps, SIMD renorm + pass) are an integration-phase change that cannot alter results (same op + sequence, same arithmetic). +- **THE state-root hookup**: `Lane::digest()` produces the leaf; committing + it into the coinbase OP_RETURN root is wiring in the existing THE + commitment path, out of scope for the standalone module. +- **Adaptive W (OQ-6)**: headroom only, per the resolution — W is a + LaneParams constant; no formula. + +## Needs CI / integrator attention + +1. **Weight adapter**: V36 `att` is `uint288` (`target_to_average_attempts`). + The lane takes `w_raw : u64`. For sharechain share targets this fits + easily (att ≈ share_difficulty × 2^32), but the adapter MUST assert/clamp + att ≤ u64::max — define the rule (reject share vs clamp) as a consensus + parameter before wiring. Flagged rather than decided here. +2. **Hashers**: `v37_hash.hpp` is a self-contained FIPS-conformant SHA-256 + (known-vector tested, bit-equal to core's CSHA256). Integration may swap + to `core/hash.hpp` for one less implementation; output identical. +3. **Full-tree build**: `src/sharechain/CMakeLists.txt` gained + `add_subdirectory(v37/test)`; the test target is dependency-free and + gated on BUILD_TESTING. Not exercised against the full conan build on + this VM (by design) — ci-steward/btc-heap-opt to verify. +4. **gtest port**: the suite uses a standalone CHECK harness so it runs + without GTest; trivially portable to the repo's gtest idiom if preferred. +5. **Caller migration**: `HeadPPLNS`/`think()` integration (replacing + `DensePPLNSRing` per the spec's "subsumes" map) is intentionally not + started — the module is per-coin-agnostic and caller-shaped for it + (push/rewind/payout_map mirror slide/rebuild/compute_v36_weights). diff --git a/src/sharechain/v37/test/CMakeLists.txt b/src/sharechain/v37/test/CMakeLists.txt new file mode 100644 index 000000000..af2ac6602 --- /dev/null +++ b/src/sharechain/v37/test/CMakeLists.txt @@ -0,0 +1,9 @@ +# V37 MRR roundabout — standalone unit tests. +# Header-only module, stdlib-only: no gtest, no core link needed. The suite +# is its own harness (returns nonzero on failure) so it runs anywhere the +# toolchain exists, including outside the conan/boost dependency tree. +if (BUILD_TESTING) + add_executable(v37_test v37_test.cpp) + target_compile_features(v37_test PRIVATE cxx_std_20) + add_test(NAME v37_test COMMAND v37_test) +endif() diff --git a/src/sharechain/v37/test/v37_test.cpp b/src/sharechain/v37/test/v37_test.cpp new file mode 100644 index 000000000..983ae22dc --- /dev/null +++ b/src/sharechain/v37/test/v37_test.cpp @@ -0,0 +1,448 @@ +// V37 MRR roundabout — standalone unit tests (no gtest dependency; tiny +// CHECK harness so the suite compiles with nothing but g++ -std=c++20). +// +// The consensus gate (spec §8 / brief component 7): ReferenceLane below is +// an INDEPENDENT implementation of the lane definition whose per-miner +// weights are recomputed by full scan over the durable records after every +// operation; the fast incremental Lane must match it bit-exact, every push, +// including across epoch rebuilds, folds, and evictions. + +#include +#include +#include +#include +#include + +#include "../v37_descriptor.hpp" +#include "../v37_lane.hpp" +#include "../v37_roundabout.hpp" + +static int g_failures = 0; +static int g_checks = 0; + +#define CHECK(cond) \ + do { \ + ++g_checks; \ + if (!(cond)) { \ + ++g_failures; \ + std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + } \ + } while (0) + +#define CHECK_MSG(cond, fmt, ...) \ + do { \ + ++g_checks; \ + if (!(cond)) { \ + ++g_failures; \ + std::printf("FAIL %s:%d: " fmt "\n", __FILE__, __LINE__, \ + __VA_ARGS__); \ + } \ + } while (0) + +using namespace v37; + +// Deterministic PRNG (consensus tests must not depend on platform RNG). +struct XorShift64 { + u64 s; + explicit XorShift64(u64 seed) : s(seed ? seed : 0x9e3779b97f4a7c15ull) {} + u64 next() { + s ^= s << 13; s ^= s >> 7; s ^= s << 17; + return s; + } + u64 range(u64 lo, u64 hi) { return lo + next() % (hi - lo + 1); } +}; + +// ── ReferenceLane: independent naive implementation of the definition ───── +// Same fixed op order as Lane (rebuild / fold / cascade / insert / evict at +// the same positionally defined points), but no incremental accumulators: +// per-miner weights are derived by FULL SCAN of the durable records. +struct ReferenceLane { + LaneParams p; + DecayTables tab; + u64 next_pos = 0, B = 0, cover = 0; + std::deque l0; + std::vector> levels; + + explicit ReferenceLane(const LaneParams& pp) : p(pp) { + tab.init(p.half_life, p.epoch_len(), p.epoch_len() + p.c0, + p.window / p.epoch_len() + 4); + levels.resize(p.level_caps.size()); + } + + void push(MinerId m, u64 w_raw, std::uint32_t flags) { + if (next_pos - B == p.epoch_len()) rebuild(); + if (l0.size() == p.c0) fold_l0(); + cascade(); + u64 pos = next_pos++; + u128 ws = u128(w_raw) * tab.inv_decay[pos - B]; + l0.push_back({pos, w_raw, ws, m, flags}); + cover += 1; + while (cover > p.window) evict(); + } + + void rebuild() { + u64 Bn = B + p.epoch_len(); + for (auto& s : l0) + s.w_scaled = u128(s.w_raw) * tab.decay[Bn - s.pos]; + B = Bn; + } + + void fold_l0() { + Bucket b; b.epoch_tag = B; + std::map agg; + for (u64 i = 0; i < p.rollup; ++i) { + const L0Slot& s = l0.front(); + if (i == 0) b.pos_lo = s.pos; + b.pos_hi = s.pos; + b.scaled_sum += U256::from_u128(s.w_scaled); + b.raw_work += s.w_raw; + auto& e = agg[s.miner]; + e.miner = s.miner; + e.scaled += U256::from_u128(s.w_scaled); + e.raw += s.w_raw; + l0.pop_front(); + } + for (auto& [m, e] : agg) b.comp.push_back(e); + levels[0].push_back(std::move(b)); + } + + void cascade() { + for (std::size_t k = 0; k + 1 < levels.size(); ++k) { + if (levels[k].size() < p.level_caps[k]) continue; + Bucket b; b.epoch_tag = B; + std::map agg; + for (u64 i = 0; i < p.rollup; ++i) { + Bucket c = levels[k].front(); + levels[k].pop_front(); + u64 f = tab.epoch_shift[(B - c.epoch_tag) / p.epoch_len()]; + if (i == 0) b.pos_lo = c.pos_lo; + b.pos_hi = c.pos_hi; + b.scaled_sum += c.scaled_sum.mul_q(f); + b.raw_work += c.raw_work; + for (const auto& e : c.comp) { + auto& a = agg[e.miner]; + a.miner = e.miner; + a.scaled += e.scaled.mul_q(f); + a.raw += e.raw; + } + } + for (auto& [m, e] : agg) b.comp.push_back(e); + levels[k + 1].push_back(std::move(b)); + } + } + + void evict() { + std::size_t best = SIZE_MAX; + for (std::size_t k = 0; k < levels.size(); ++k) { + if (levels[k].empty()) continue; + if (best == SIZE_MAX || + levels[k].front().pos_lo < levels[best].front().pos_lo) + best = k; + } + Bucket b = levels[best].front(); + levels[best].pop_front(); + cover -= (b.pos_hi - b.pos_lo + 1); + } + + // The reference computation: scan everything, no incremental state. + std::map scan_acc() const { + std::map out; + for (const auto& s : l0) + out[s.miner] += U256::from_u128(s.w_scaled); + for (const auto& lvl : levels) + for (const auto& b : lvl) { + u64 f = tab.epoch_shift[(B - b.epoch_tag) / p.epoch_len()]; + for (const auto& e : b.comp) out[e.miner] += e.scaled.mul_q(f); + } + return out; + } + u128 scan_raw() const { + u128 r = 0; + for (const auto& s : l0) r += s.w_raw; + for (const auto& lvl : levels) + for (const auto& b : lvl) r += b.raw_work; + return r; + } +}; + +// ── tests ────────────────────────────────────────────────────────────────── + +static void test_sha256() { + auto h = sha256(reinterpret_cast("abc"), 3); + static const std::uint8_t want[32] = { + 0xba, 0x78, 0x16, 0xbf, 0x8f, 0x01, 0xcf, 0xea, 0x41, 0x41, 0x40, + 0xde, 0x5d, 0xae, 0x22, 0x23, 0xb0, 0x03, 0x61, 0xa3, 0x96, 0x17, + 0x7a, 0x9c, 0xb4, 0x10, 0xff, 0x61, 0xf2, 0x00, 0x15, 0xad}; + CHECK(std::memcmp(h.data(), want, 32) == 0); + // sha256d("hello") — standard known double-hash vector + auto d = sha256d(reinterpret_cast("hello"), 5); + static const std::uint8_t want2[32] = { + 0x95, 0x95, 0xc9, 0xdf, 0x90, 0x07, 0x51, 0x48, 0xeb, 0x06, 0x86, + 0x03, 0x65, 0xdf, 0x33, 0x58, 0x4b, 0x75, 0xbf, 0xf7, 0x82, 0xa5, + 0x10, 0xc6, 0xcd, 0x48, 0x83, 0xa4, 0x19, 0x83, 0x3d, 0x50}; + CHECK(std::memcmp(d.data(), want2, 32) == 0); +} + +static void test_fixed_point() { + // U256 basics + U256 a(5), b(7); + CHECK((a + b) == U256(12)); + CHECK((b - a) == U256(2)); + CHECK(a < b); + // mul_q identity: x * 1.0 == x, including via the 5-limb path + U256 big = U256::from_u128((u128(0x0123456789abcdefULL) << 64) | 0xfedcba9876543210ULL); + big += big; // ensure multi-limb + CHECK(big.mul_q(Q_ONE) == big); + u128 x = (u128(7) << 100) + 12345; + CHECK(mul_q(x, Q_ONE) == x); + CHECK(mul_q64(Q_ONE, Q_ONE) == Q_ONE); + + // Table generation: deterministic, monotonic, V36-lineage formula + DecayTables t1, t2; + t1.init(2160, 4096, 8192, 8); + t2.init(2160, 4096, 8192, 8); + CHECK(t1.decay == t2.decay); + CHECK(t1.inv_decay == t2.inv_decay); + CHECK(t1.epoch_shift == t2.epoch_shift); + u64 expect_per = Q_ONE - static_cast( + (u128(Q_ONE) * LN2_MICRO) / (u128(1000000) * 2160)); + CHECK(t1.decay_per == expect_per); + for (std::size_t d = 1; d < t1.decay.size(); ++d) + CHECK(t1.decay[d] < t1.decay[d - 1]); + for (std::size_t j = 1; j < t1.inv_decay.size(); ++j) + CHECK(t1.inv_decay[j] > t1.inv_decay[j - 1]); + // Half-life sanity: decay[half_life] ~ 0.5. The V36-lineage per-step + // factor is first-order (1 - ln2/HL), so (1-x)^HL deviates from exactly + // 0.5 by ~ln2^2/(2*HL) ~ 5.5e-5 relative — allow 2^-12 slack. + u64 half = t1.decay[2160]; + CHECK(half > (Q_ONE / 2) - (Q_ONE >> 12) && half < (Q_ONE / 2) + (Q_ONE >> 12)); + // Inverse headroom: inv_decay max < 4.0 (E/HL = 4096/2160 -> 2^1.9). + // Compare in u128: 4.0 in Q62 == 2^64, which overflows u64. + CHECK(u128(t1.inv_decay.back()) < (u128(4) << FRAC_BITS)); + // Roundtrip: decay[j] * inv_decay[j] ~ 1.0 from below + for (std::size_t j : {std::size_t(1), std::size_t(100), std::size_t(4095)}) { + u64 rt = mul_q64(t1.decay[j], t1.inv_decay[j]); + CHECK(rt <= Q_ONE && rt > Q_ONE - (Q_ONE >> 40)); + } +} + +// Small geometry exercising every mechanism quickly: +// W=216 = C0(128) + 11 buckets x 8; E=128; HL=54; D=16. +static LaneParams small_params() { + LaneParams p; + p.window = 216; + p.c0 = 128; + p.rollup = 8; + p.level_caps = {11}; + p.half_life = 54; + p.journal_depth = 16; + return p; +} + +static void test_lane_vs_reference(const LaneParams& p, u64 pushes, u64 seed, + const char* tag) { + Lane lane(p); + ReferenceLane ref(p); + XorShift64 rng(seed); + u128 pushed_raw = 0, evicted_raw = 0; + for (u64 i = 0; i < pushes; ++i) { + MinerId m = static_cast(rng.range(0, 12)); + u64 w = rng.range(1, u64(1) << 62); // full-range raw work + u128 before = lane.raw_total(); + lane.push(m, w, 0); + ref.push(m, w, 0); + pushed_raw += w; + u128 after = lane.raw_total(); + if (after < before + w) evicted_raw += (before + w - after); + + CHECK_MSG(lane.next_pos() == ref.next_pos, "%s pos @%llu", tag, + (unsigned long long)i); + CHECK_MSG(lane.epoch_base() == ref.B, "%s B @%llu", tag, + (unsigned long long)i); + CHECK_MSG(lane.cover() == ref.cover, "%s cover @%llu", tag, + (unsigned long long)i); + // THE GATE: incremental accumulators == full-scan reference, + // bit-exact, after every operation. + if (lane.acc() != ref.scan_acc()) { + ++g_failures; + std::printf("FAIL %s: acc != reference scan at push %llu\n", tag, + (unsigned long long)i); + return; + } + ++g_checks; + CHECK_MSG(lane.raw_total() == ref.scan_raw(), "%s raw @%llu", tag, + (unsigned long long)i); + // Conservation: raw work is exact bookkeeping (F-1) + CHECK_MSG(lane.raw_total() == pushed_raw - evicted_raw, + "%s conservation @%llu", tag, (unsigned long long)i); + // Window: quantized — never exceeds W (OQ-1) + CHECK_MSG(lane.cover() <= p.window, "%s window @%llu", tag, + (unsigned long long)i); + // acc_total invariant: equals sum of acc entries exactly + U256 sum; + for (const auto& [mm, a] : lane.acc()) sum += a; + CHECK_MSG(sum == lane.acc_total(), "%s acc_total @%llu", tag, + (unsigned long long)i); + } + // Payout map truncation bound: per-miner mul_q truncates < 1 ulp each + auto pm = lane.payout_map(); + U256 pm_sum; + for (const auto& [m, w] : pm) pm_sum += w; + U256 tot = lane.decayed_total(); + CHECK(!(tot < pm_sum)); // pm_sum <= tot + U256 diff = tot - pm_sum; + CHECK(diff < U256(pm.size() + 1)); +} + +static void test_digest_and_rewind() { + LaneParams p = small_params(); + // Determinism: identical sequences -> identical digests + Lane a(p), b(p); + XorShift64 r1(42), r2(42); + for (int i = 0; i < 500; ++i) { + a.push(static_cast(r1.range(0, 5)), r1.range(1, 1000000), 0); + b.push(static_cast(r2.range(0, 5)), r2.range(1, 1000000), 0); + } + CHECK(a.digest() == b.digest()); + // Sensitivity: different order -> different digest + Lane c(p), d(p); + c.push(1, 100, 0); c.push(2, 200, 0); + d.push(2, 200, 0); d.push(1, 100, 0); + CHECK(!(c.digest() == d.digest())); + + // Rewind: bit-exact state restoration (OQ-7), within one epoch + Lane e(p); + XorShift64 r3(7); + // run to mid-epoch so the rewind span cannot cross a rebuild + for (int i = 0; i < 300; ++i) + e.push(static_cast(r3.range(0, 5)), r3.range(1, 1000000), 0); + while ((e.next_pos() - e.epoch_base()) > p.epoch_len() - 20 || + (e.next_pos() - e.epoch_base()) < 16) { + e.push(0, 1, 0); + } + auto snap = e.digest(); + XorShift64 r4(11); + for (int i = 0; i < 10; ++i) + e.push(static_cast(r4.range(0, 5)), r4.range(1, 1000000), 0); + CHECK(e.rewind(10)); + CHECK(e.digest() == snap); + + // Rewind across an epoch rebuild must refuse (journal cleared) + Lane f(p); + for (u64 i = 0; i < p.epoch_len() + 4; ++i) f.push(0, 10, 0); + CHECK(!f.rewind(8)); // boundary at E crossed 4 pushes ago, only 4 journaled + CHECK(f.rewind(2)); // shallow rewind still fine +} + +static void test_descriptor() { + auto mk = [](std::initializer_list v) { + std::vector s; + for (int x : v) s.push_back(static_cast(x)); + return s; + }; + // P2PKH + std::vector p2pkh = {0x76, 0xa9, 0x14}; + for (int i = 0; i < 20; ++i) p2pkh.push_back(static_cast(i)); + p2pkh.push_back(0x88); p2pkh.push_back(0xac); + auto r1 = canonicalize_script(p2pkh); + CHECK(r1.kind == ScriptKind::P2PKH && r1.payload.size() == 20); + // P2SH + std::vector p2sh = {0xa9, 0x14}; + for (int i = 0; i < 20; ++i) p2sh.push_back(static_cast(i)); + p2sh.push_back(0x87); + CHECK(canonicalize_script(p2sh).kind == ScriptKind::P2SH); + // P2WPKH — same h160 as the P2PKH above + std::vector p2wpkh = {0x00, 0x14}; + for (int i = 0; i < 20; ++i) p2wpkh.push_back(static_cast(i)); + auto r2 = canonicalize_script(p2wpkh); + CHECK(r2.kind == ScriptKind::P2WPKH); + // S-1: same payload, different kind -> DIFFERENT identity + CHECK(!(r1 == r2)); + MinerIntern intern; + PayoutDescriptor d1; d1.pay = r1; + PayoutDescriptor d2; d2.pay = r2; + CHECK(intern.intern(d1) != intern.intern(d2)); + CHECK(intern.intern(d1) == intern.intern(d1)); // stable + // P2WSH / P2TR + std::vector p2wsh = {0x00, 0x20}; + for (int i = 0; i < 32; ++i) p2wsh.push_back(1); + CHECK(canonicalize_script(p2wsh).kind == ScriptKind::P2WSH); + std::vector p2tr = {0x51, 0x20}; + for (int i = 0; i < 32; ++i) p2tr.push_back(2); + CHECK(canonicalize_script(p2tr).kind == ScriptKind::P2TR); + // Exotic -> kind 255, total function, deterministic + auto exotic = mk({0x6a, 0x04, 0xde, 0xad, 0xbe, 0xef}); // OP_RETURN... + auto r3 = canonicalize_script(exotic); + CHECK(r3.kind == ScriptKind::RAW && r3.payload.size() == 32); + CHECK(canonicalize_script(exotic) == r3); + // kind-255 descriptor must carry the raw script + PayoutDescriptor d3; d3.pay = r3; + CHECK(!d3.valid()); + d3.raw_script = exotic; + CHECK(d3.valid()); + // Attribution MUST be absent under V37.0 rules; OK when rule flipped + PayoutDescriptor d4; d4.pay = r1; d4.attribution = r2; + CHECK(!d4.valid(false)); + CHECK(d4.valid(true)); + // Aux: sorted unique ok; unsorted / duplicate rejected + PayoutDescriptor d5; d5.pay = r1; + d5.aux = {{98, r2}, {99, r2}}; + CHECK(d5.valid()); + d5.aux = {{99, r2}, {98, r2}}; + CHECK(!d5.valid()); + d5.aux = {{98, r2}, {98, r2}}; + CHECK(!d5.valid()); + // Canonical bytes: deterministic; aux changes bytes but NOT identity + PayoutDescriptor d6; d6.pay = r1; + PayoutDescriptor d7; d7.pay = r1; d7.aux = {{98, r2}}; + CHECK(d6.canonical_bytes() != d7.canonical_bytes()); + CHECK(d6.identity_key() == d7.identity_key()); +} + +static void test_roundabout() { + Roundabout rb; + rb.add_lane(1, small_params()); + LaneParams p2 = small_params(); + p2.window = 108; p2.c0 = 64; p2.level_caps = {6}; p2.half_life = 27; + rb.add_lane(2, p2); // runtime add, different geometry (per-lane params) + CHECK(rb.lane_count() == 2); + + std::vector p2pkh = {0x76, 0xa9, 0x14}; + for (int i = 0; i < 20; ++i) p2pkh.push_back(static_cast(i)); + p2pkh.push_back(0x88); p2pkh.push_back(0xac); + PayoutDescriptor d; d.pay = canonicalize_script(p2pkh); + + MinerId m1 = rb.push(1, d, 1000, 0); + MinerId m2 = rb.push(2, d, 2000, 0); + CHECK(m1 == m2); // one identity across lanes (global intern) + auto agg = rb.aggregate_decayed(); + CHECK(agg.size() == 1 && !agg.begin()->second.is_zero()); + + // Invalid descriptor (attribution set) rejected at the boundary + PayoutDescriptor bad = d; + bad.attribution = d.pay; + bool threw = false; + try { rb.push(1, bad, 1, 0); } catch (const std::invalid_argument&) { threw = true; } + CHECK(threw); + + rb.remove_lane(2); + CHECK(rb.lane_count() == 1 && rb.lane(2) == nullptr); +} + +int main() { + test_sha256(); + test_fixed_point(); + // small geometry: 5+ epochs, constant folding + eviction churn + test_lane_vs_reference(small_params(), 1500, 1234, "small"); + // default OQ-5 geometry: cross two epoch rebuilds (>8192 pushes) + { + LaneParams def; // defaults = ratified OQ-5 values + test_lane_vs_reference(def, 9500, 99, "default"); + } + test_digest_and_rewind(); + test_descriptor(); + test_roundabout(); + + std::printf("%d checks, %d failures\n", g_checks, g_failures); + return g_failures == 0 ? 0 : 1; +} diff --git a/src/sharechain/v37/v37_descriptor.hpp b/src/sharechain/v37/v37_descriptor.hpp new file mode 100644 index 000000000..36ed40936 --- /dev/null +++ b/src/sharechain/v37/v37_descriptor.hpp @@ -0,0 +1,159 @@ +#pragma once +// V37 PayoutDescriptor v1 — the ratified identity canon. +// Spec: docs/c2pool-v37-mrr-roundabout-buffer.md §6.3 (OQ-3 + S-1/S-2/S-3). +// +// * scripts, never address strings — the canon operates on script bytes +// * total function: known templates -> kinds 0..4, anything else -> +// kind 255 = SHA256d(raw script); every script has exactly one canon +// * identity = exact (kind, payload) of `pay` — no cross-kind collapsing +// (S-1); aux entries are attributes, not identity +// * attribution slot present in the serialization, MUST be absent under +// V37.0 validity rules (F-2; enabled later by validity-rule change) +// * serialization: fixed field order, fixed-width little-endian integers, +// no varints; identity key = SHA256d of canonical bytes (S-3) + +#include +#include +#include +#include + +#include "v37_hash.hpp" + +namespace v37 { + +enum class ScriptKind : std::uint8_t { + P2PKH = 0, // payload: hash160 (20) + P2SH = 1, // payload: hash160 (20) + P2WPKH = 2, // payload: hash160 (20) + P2WSH = 3, // payload: sha256 (32) + P2TR = 4, // payload: x-only key (32) + RAW = 255, // payload: sha256d(raw script) (32) +}; + +struct ScriptRef { + ScriptKind kind = ScriptKind::RAW; + std::vector payload; // 20 or 32 bytes per kind + + friend bool operator==(const ScriptRef& a, const ScriptRef& b) { + return a.kind == b.kind && a.payload == b.payload; // S-1: exact + } + friend bool operator<(const ScriptRef& a, const ScriptRef& b) { + if (a.kind != b.kind) return a.kind < b.kind; + return a.payload < b.payload; + } +}; + +// Canonicalize raw output-script bytes into a ScriptRef. Total: every input +// maps to exactly one ScriptRef (rule 2 of the canon). +inline ScriptRef canonicalize_script(const std::vector& s) { + ScriptRef r; + // P2PKH: OP_DUP OP_HASH160 <20> OP_EQUALVERIFY OP_CHECKSIG + if (s.size() == 25 && s[0] == 0x76 && s[1] == 0xa9 && s[2] == 0x14 && + s[23] == 0x88 && s[24] == 0xac) { + r.kind = ScriptKind::P2PKH; + r.payload.assign(s.begin() + 3, s.begin() + 23); + return r; + } + // P2SH: OP_HASH160 <20> OP_EQUAL + if (s.size() == 23 && s[0] == 0xa9 && s[1] == 0x14 && s[22] == 0x87) { + r.kind = ScriptKind::P2SH; + r.payload.assign(s.begin() + 2, s.begin() + 22); + return r; + } + // P2WPKH: OP_0 <20> + if (s.size() == 22 && s[0] == 0x00 && s[1] == 0x14) { + r.kind = ScriptKind::P2WPKH; + r.payload.assign(s.begin() + 2, s.end()); + return r; + } + // P2WSH: OP_0 <32> + if (s.size() == 34 && s[0] == 0x00 && s[1] == 0x20) { + r.kind = ScriptKind::P2WSH; + r.payload.assign(s.begin() + 2, s.end()); + return r; + } + // P2TR: OP_1 <32> + if (s.size() == 34 && s[0] == 0x51 && s[1] == 0x20) { + r.kind = ScriptKind::P2TR; + r.payload.assign(s.begin() + 2, s.end()); + return r; + } + // Fallback: contain the exotic script in kind 255. + r.kind = ScriptKind::RAW; + auto h = sha256d(s); + r.payload.assign(h.begin(), h.end()); + return r; +} + +struct AuxEntry { + std::uint32_t chain_id = 0; + ScriptRef ref; +}; + +struct PayoutDescriptor { + static constexpr std::uint8_t VERSION = 1; + + ScriptRef pay; // THE payout identity + std::optional attribution; // F-2 slot; MUST be absent V37.0 + std::vector aux; // sorted ascending, unique chain_id + // Carried alongside (NOT part of canonical identity bytes) so kind-255 + // payouts remain constructible: + std::vector raw_script; + + // Validity per the ratified canon. allow_attribution stays false for the + // whole of V37.0; flipping it is the V37.x validity-rule change. + bool valid(bool allow_attribution = false) const { + if (attribution.has_value() && !allow_attribution) return false; + for (std::size_t i = 0; i < aux.size(); ++i) { + if (i > 0 && !(aux[i - 1].chain_id < aux[i].chain_id)) + return false; // unsorted or duplicate chain_id: malformed + } + if (pay.kind == ScriptKind::RAW && raw_script.empty()) + return false; // kind 255 must carry the script for payment + return true; + } + + // Canonical serialization (§6.3 rule 6): the identity preimage. + std::vector canonical_bytes() const { + std::vector out; + out.push_back(VERSION); + append_ref(out, pay); + out.push_back(attribution.has_value() ? 1 : 0); + if (attribution.has_value()) append_ref(out, *attribution); + std::uint16_t n = static_cast(aux.size()); + out.push_back(static_cast(n)); + out.push_back(static_cast(n >> 8)); + for (const auto& e : aux) { + for (int i = 0; i < 4; ++i) + out.push_back(static_cast(e.chain_id >> (8 * i))); + append_ref(out, e.ref); + } + return out; + } + + // Identity key (S-3): SHA256d of canonical bytes. Note: identity ignores + // aux *values* only in the sense that aux are attributes of the same + // miner; the intern layer keys on `pay` alone (see MinerIntern). + bytes32 identity_key() const { + auto pay_only = identity_preimage(); + return sha256d(pay_only); + } + + // Identity = exact (kind, payload) of `pay` (canon rule 3) — attribution + // and aux do not change who the miner is. + std::vector identity_preimage() const { + std::vector out; + out.push_back(VERSION); + append_ref(out, pay); + return out; + } + +private: + static void append_ref(std::vector& out, const ScriptRef& r) { + out.push_back(static_cast(r.kind)); + out.push_back(static_cast(r.payload.size())); + out.insert(out.end(), r.payload.begin(), r.payload.end()); + } +}; + +} // namespace v37 diff --git a/src/sharechain/v37/v37_fixed.hpp b/src/sharechain/v37/v37_fixed.hpp new file mode 100644 index 000000000..f5ffa11fd --- /dev/null +++ b/src/sharechain/v37/v37_fixed.hpp @@ -0,0 +1,176 @@ +#pragma once +// V37 MRR roundabout — fixed-point arithmetic + deterministic decay tables. +// Spec: docs/c2pool-v37-mrr-roundabout-buffer.md §8.1–8.3. +// +// Consensus rules enforced here: +// * no floating point anywhere +// * every multiply truncates toward zero +// * tables generated by iterated truncating integer multiplication only +// +// Range pinning (the "exact ranges to be pinned in the implementation spec" +// of §8.1): FRAC_BITS = 62, not 64. Rationale: with Q62 every table entry +// (decay <= 1.0, inverse-decay <= 2^{E/half_life} < 4.0) fits a u64, every +// w_raw (full u64 range) x table product fits native unsigned __int128, and +// accumulator sums fit U256. Q62 is 2^22 finer than V36's Q40. Flagged in +// IMPLEMENTATION-NOTES.md as an erratum against the spec's "64 fractional +// bits" wording; the spec delegates final range pinning here. + +#include +#include +#include +#include +#include +#include + +namespace v37 { + +using u64 = std::uint64_t; +using u128 = unsigned __int128; + +static constexpr unsigned FRAC_BITS = 62; +static constexpr u64 Q_ONE = u64(1) << FRAC_BITS; // 1.0 in Q62 +static constexpr u64 LN2_MICRO = 693147; // V36 lineage + +// ── U256: minimal unsigned 256-bit integer (4 x u64 limbs, little-endian) ── +// Only the operations the consensus path needs; all wrap-free uses are +// guarded by the Q62 range pinning above. +struct U256 { + std::array v{0, 0, 0, 0}; + + constexpr U256() = default; + constexpr U256(u64 x) : v{x, 0, 0, 0} {} + static U256 from_u128(u128 x) { + U256 r; + r.v[0] = static_cast(x); + r.v[1] = static_cast(x >> 64); + return r; + } + u128 lo128() const { return (u128(v[1]) << 64) | v[0]; } + + bool is_zero() const { return !(v[0] | v[1] | v[2] | v[3]); } + + U256& operator+=(const U256& o) { + u128 c = 0; + for (int i = 0; i < 4; ++i) { + u128 s = u128(v[i]) + o.v[i] + c; + v[i] = static_cast(s); + c = s >> 64; + } + return *this; // overflow impossible under range pinning + } + U256& operator-=(const U256& o) { + u128 borrow = 0; + for (int i = 0; i < 4; ++i) { + u128 d = u128(v[i]) - o.v[i] - borrow; + v[i] = static_cast(d); + borrow = (d >> 64) ? 1 : 0; + } + return *this; // callers only subtract amounts previously added + } + friend U256 operator+(U256 a, const U256& b) { a += b; return a; } + friend U256 operator-(U256 a, const U256& b) { a -= b; return a; } + friend bool operator==(const U256& a, const U256& b) { return a.v == b.v; } + friend bool operator!=(const U256& a, const U256& b) { return !(a == b); } + friend bool operator<(const U256& a, const U256& b) { + for (int i = 3; i >= 0; --i) + if (a.v[i] != b.v[i]) return a.v[i] < b.v[i]; + return false; + } + + // (this * m) >> FRAC_BITS, truncating — the Q62 scale-application step. + // Used for: acc x decay (query), bucket scaled x epoch shift. + U256 mul_q(u64 m) const { + // 256 x 64 -> 320-bit intermediate held as 5 limbs, then >> 62. + u64 r[5] = {0, 0, 0, 0, 0}; + u128 carry = 0; + for (int i = 0; i < 4; ++i) { + u128 p = u128(v[i]) * m + carry; + r[i] = static_cast(p); + carry = p >> 64; + } + r[4] = static_cast(carry); + U256 out; + constexpr unsigned s = FRAC_BITS; // 62: cross-limb shift + for (int i = 0; i < 4; ++i) + out.v[i] = (r[i] >> s) | (u64(r[i + 1]) << (64 - s)); + // r[4] >> s contributes to out.v[3]'s high bits: + // already folded via r[4] << (64-s) above when i==3. + return out; + } + + std::string hex() const { + static const char* d = "0123456789abcdef"; + std::string s; + for (int i = 3; i >= 0; --i) + for (int j = 15; j >= 0; --j) + s.push_back(d[(v[i] >> (j * 4)) & 0xf]); + return s; + } +}; + +// (a * b) >> FRAC_BITS for u128 a (a Q62 scaled value) and u64 b (a Q62 +// factor), truncating. 128 x 64 -> 192-bit intermediate via limbs. +inline u128 mul_q(u128 a, u64 b) { + u64 a0 = static_cast(a); + u64 a1 = static_cast(a >> 64); + u128 lo = u128(a0) * b; // bits 0..127 + u128 hi = u128(a1) * b; // bits 64..191 + constexpr unsigned s = FRAC_BITS; + u128 r = (lo >> s) + (hi << (64 - s)); + return r; // < 2^128 under range pinning +} + +// (a * b) >> FRAC_BITS for two u64 Q62 factors (both values < 4.0). +inline u64 mul_q64(u64 a, u64 b) { + return static_cast((u128(a) * b) >> FRAC_BITS); +} + +// ── Deterministic decay tables (§8.2) ───────────────────────────────────── +// All entries are generated by integer-only, iterated, truncating procedures +// anchored on LN2_MICRO — identical on every conforming platform. +struct DecayTables { + u64 half_life = 0; + u64 epoch_len = 0; // E + u64 decay_per = 0; // lambda = 2^(-1/half_life), Q62 + u64 inv_per = 0; // lambda^(-1), Q62 (value slightly > 1.0) + + std::vector decay; // decay[d] = lambda^d, Q62, d in [0, span] + std::vector inv_decay; // inv_decay[j] = lambda^-j, Q62, j in [0, E) + std::vector epoch_shift; // epoch_shift[i] = lambda^(E*i), Q62 + + void init(u64 half_life_, u64 epoch_len_, u64 max_depth, u64 max_epochs) { + if (half_life_ == 0 || epoch_len_ == 0) + throw std::invalid_argument("v37: zero decay parameter"); + half_life = half_life_; + epoch_len = epoch_len_; + + // V36-lineage per-step factor at Q62: + // decay_per = 1.0 - ln2/half_life (first-order, exactly as V36's + // Q40 formula, widened): Q_ONE - (Q_ONE * LN2_MICRO) / (1e6 * HL) + decay_per = Q_ONE - static_cast( + (u128(Q_ONE) * LN2_MICRO) / (u128(1000000) * half_life)); + + // inv_per = floor(2^124 / decay_per): the Q62 reciprocal of a Q62 + // value (2^124 = Q_ONE << FRAC_BITS fits u128 exactly). + inv_per = static_cast((u128(Q_ONE) << FRAC_BITS) / decay_per); + + decay.assign(max_depth + 1, 0); + decay[0] = Q_ONE; + for (u64 d = 1; d <= max_depth; ++d) + decay[d] = mul_q64(decay[d - 1], decay_per); + + inv_decay.assign(epoch_len, 0); + inv_decay[0] = Q_ONE; + for (u64 j = 1; j < epoch_len; ++j) + inv_decay[j] = mul_q64(inv_decay[j - 1], inv_per); + + if (max_depth < epoch_len) + throw std::invalid_argument("v37: decay table shorter than epoch"); + epoch_shift.assign(max_epochs + 1, 0); + epoch_shift[0] = Q_ONE; + for (u64 i = 1; i <= max_epochs; ++i) + epoch_shift[i] = mul_q64(epoch_shift[i - 1], decay[epoch_len]); + } +}; + +} // namespace v37 diff --git a/src/sharechain/v37/v37_hash.hpp b/src/sharechain/v37/v37_hash.hpp new file mode 100644 index 000000000..5e7182f21 --- /dev/null +++ b/src/sharechain/v37/v37_hash.hpp @@ -0,0 +1,132 @@ +#pragma once +// V37 — self-contained SHA-256 / SHA-256d (spec §6.3 S-3, §8.5). +// +// Standalone implementation so the v37 module compiles without the btclibs +// dependency tree; produces standard FIPS 180-4 SHA-256, so it is bit-equal +// to core's CSHA256/CHash256. Integration MAY swap these calls for the repo +// hashers; output is identical either way (covered by known-vector tests). + +#include +#include +#include +#include + +namespace v37 { + +using bytes32 = std::array; + +namespace detail { + +struct Sha256Ctx { + std::uint32_t h[8]; + std::uint64_t len = 0; + std::uint8_t buf[64]; + std::size_t buf_used = 0; + + Sha256Ctx() { reset(); } + + void reset() { + static constexpr std::uint32_t init[8] = { + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19}; + std::memcpy(h, init, sizeof(h)); + len = 0; + buf_used = 0; + } + + static std::uint32_t rotr(std::uint32_t x, unsigned n) { + return (x >> n) | (x << (32 - n)); + } + + void compress(const std::uint8_t* p) { + static constexpr std::uint32_t K[64] = { + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, + 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, + 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, + 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, + 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, + 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, + 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, + 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, + 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, + 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, + 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2}; + std::uint32_t w[64]; + for (int i = 0; i < 16; ++i) + w[i] = (std::uint32_t(p[i * 4]) << 24) | (std::uint32_t(p[i * 4 + 1]) << 16) | + (std::uint32_t(p[i * 4 + 2]) << 8) | p[i * 4 + 3]; + for (int i = 16; i < 64; ++i) { + std::uint32_t s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >> 3); + std::uint32_t s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16] + s0 + w[i - 7] + s1; + } + std::uint32_t a = h[0], b = h[1], c = h[2], d = h[3]; + std::uint32_t e = h[4], f = h[5], g = h[6], hh = h[7]; + for (int i = 0; i < 64; ++i) { + std::uint32_t S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); + std::uint32_t ch = (e & f) ^ (~e & g); + std::uint32_t t1 = hh + S1 + ch + K[i] + w[i]; + std::uint32_t S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); + std::uint32_t mj = (a & b) ^ (a & c) ^ (b & c); + std::uint32_t t2 = S0 + mj; + hh = g; g = f; f = e; e = d + t1; + d = c; c = b; b = a; a = t1 + t2; + } + h[0] += a; h[1] += b; h[2] += c; h[3] += d; + h[4] += e; h[5] += f; h[6] += g; h[7] += hh; + } + + void write(const std::uint8_t* p, std::size_t n) { + len += n; + while (n) { + std::size_t take = 64 - buf_used; + if (take > n) take = n; + std::memcpy(buf + buf_used, p, take); + buf_used += take; + p += take; + n -= take; + if (buf_used == 64) { + compress(buf); + buf_used = 0; + } + } + } + + bytes32 finalize() { + std::uint64_t bits = len * 8; + std::uint8_t pad = 0x80; + write(&pad, 1); + std::uint8_t zero = 0; + while (buf_used != 56) write(&zero, 1); + std::uint8_t lenb[8]; + for (int i = 0; i < 8; ++i) + lenb[i] = static_cast(bits >> (56 - i * 8)); + write(lenb, 8); + bytes32 out; + for (int i = 0; i < 8; ++i) + for (int j = 0; j < 4; ++j) + out[i * 4 + j] = static_cast(h[i] >> (24 - j * 8)); + return out; + } +}; + +} // namespace detail + +inline bytes32 sha256(const std::uint8_t* p, std::size_t n) { + detail::Sha256Ctx c; + c.write(p, n); + return c.finalize(); +} + +inline bytes32 sha256d(const std::uint8_t* p, std::size_t n) { + bytes32 first = sha256(p, n); + return sha256(first.data(), first.size()); +} + +inline bytes32 sha256d(const std::vector& v) { + return sha256d(v.data(), v.size()); +} + +} // namespace v37 diff --git a/src/sharechain/v37/v37_lane.hpp b/src/sharechain/v37/v37_lane.hpp new file mode 100644 index 000000000..c47eb9cd2 --- /dev/null +++ b/src/sharechain/v37/v37_lane.hpp @@ -0,0 +1,446 @@ +#pragma once +// V37 MRR roundabout — one sharechain lane. +// Spec: docs/c2pool-v37-mrr-roundabout-buffer.md §3 (data model), §4.1 +// (roll-up pyramid), §4.2 (epoch-scaled incremental decay + OQ-2 exact +// rebuild), §6.1 (quantized window, OQ-1), §6.2 (reorg journal, OQ-7). +// +// CONSENSUS-DETERMINISM (§8.3): the operation order inside push() is fixed — +// (1) epoch rebuild when next_pos - B == E, +// (2) L0 fold when L0 is full, +// (3) level-k fold cascade when a level ring is full, +// (4) insert, +// (5) whole-bucket eviction while cover > W. +// All folds/evicts/rebuilds happen at positionally defined points; no input +// to the sequence is node-local. + +#include +#include +#include +#include +#include + +#include "v37_fixed.hpp" +#include "v37_hash.hpp" + +namespace v37 { + +using MinerId = std::uint32_t; + +struct LaneParams { + u64 window = 8640; // W (OQ-5 default) + u64 c0 = 4096; // C0, power of two; also E (epoch length) + u64 rollup = 8; // R + std::vector level_caps = {568}; // slot counts for levels >= 1 + u64 half_life = 2160; // W/4 (OQ-5) + u64 journal_depth = 64; // D (OQ-7) + + u64 epoch_len() const { return c0; } + std::size_t levels() const { return 1 + level_caps.size(); } +}; + +// Level-0 slot (SoA in the production layout; AoS here for clarity — the +// arrays are contiguous std::vector rings either way). +struct L0Slot { + u64 pos = 0; + u64 w_raw = 0; // work(target), verbatim (F-1; feeds epoch rebuild) + u128 w_scaled = 0; // w_raw x InvD[pos - B] at insert, Q62 + MinerId miner = 0; + std::uint32_t flags = 0; +}; + +struct CompEntry { // (miner, w_scaled, w_raw) triple, F-1 + MinerId miner = 0; + U256 scaled; // stored in the bucket's epoch frame + u128 raw = 0; +}; + +struct Bucket { // immutable after close (§4.1) + u64 pos_lo = 0, pos_hi = 0; + u64 epoch_tag = 0; // B at close; shift applied at read/evict + U256 scaled_sum; + u128 raw_work = 0; // F-1: per-band settlement leaf + std::vector comp; // sorted by miner id (deterministic) +}; + +class Lane { +public: + explicit Lane(const LaneParams& p) : m_p(p) { + if (p.c0 == 0 || (p.c0 & (p.c0 - 1)) != 0) + throw std::invalid_argument("v37: C0 must be a power of two"); + if (p.rollup == 0 || p.c0 % p.rollup != 0) + throw std::invalid_argument("v37: R must divide C0"); + // decay[] must cover query depths [0,E) and rebuild depths [1,E]; + // epoch_shift[] must cover the max bucket age in epochs. + u64 max_epochs = (p.window / p.epoch_len()) + 4; + m_tab.init(p.half_life, p.epoch_len(), p.epoch_len() + p.c0, max_epochs); + m_levels.resize(p.level_caps.size()); + } + + const LaneParams& params() const { return m_p; } + const DecayTables& tables() const { return m_tab; } + u64 next_pos() const { return m_next_pos; } + u64 epoch_base() const { return m_B; } + u64 cover() const { return m_cover; } + const U256& acc_total() const { return m_acc_total; } + u128 raw_total() const { return m_raw_total; } + const std::map& acc() const { return m_acc; } + const std::deque& l0() const { return m_l0; } + const std::vector>& levels() const { return m_levels; } + const U256& l0_scaled_sum() const { return m_l0_scaled_sum; } + u128 l0_raw_sum() const { return m_l0_raw_sum; } + + // ── push: O(1) amortized (§7) ───────────────────────────────────────── + void push(MinerId miner, u64 w_raw, std::uint32_t flags) { + if (m_next_pos - m_B == m_p.epoch_len()) + epoch_rebuild(); // (1) — clears journal + + if (m_l0.size() == m_p.c0) + fold_l0(); // (2) + cascade_folds(); // (3) + + u64 p = m_next_pos++; + u64 j = p - m_B; // in [0, E) + u128 w_scaled = u128(w_raw) * m_tab.inv_decay[j]; // Q62, fits u128 + L0Slot s{p, w_raw, w_scaled, miner, flags}; + m_l0.push_back(s); // (4) + m_acc[miner] += U256::from_u128(w_scaled); + m_acc_total += U256::from_u128(w_scaled); + m_raw_total += w_raw; + m_l0_scaled_sum += U256::from_u128(w_scaled); + m_l0_raw_sum += w_raw; + m_cover += 1; + journal_push(Op::push(s)); + + while (m_cover > m_p.window) // (5) OQ-1 + evict_oldest_bucket(); + } + + // ── queries ─────────────────────────────────────────────────────────── + // Decayed weight of one miner at the current head (O(1) + map lookup). + U256 decayed_weight(MinerId m) const { + auto it = m_acc.find(m); + if (it == m_acc.end()) return U256(); + return it->second.mul_q(head_decay()); + } + U256 decayed_total() const { return m_acc_total.mul_q(head_decay()); } + + // Full payout map: O(active miners) (§7). + std::map payout_map() const { + std::map out; + u64 f = head_decay(); + for (const auto& [m, a] : m_acc) out[m] = a.mul_q(f); + return out; + } + + // Per-band raw delivered work over [lo, hi] positions, bucket-granular + // at coarse levels (settlement-grade reading, F-1 / market §4.1). + u128 raw_work_in_span(u64 lo, u64 hi) const { + u128 sum = 0; + for (const auto& s : m_l0) + if (s.pos >= lo && s.pos <= hi) sum += s.w_raw; + for (const auto& lvl : m_levels) + for (const auto& b : lvl) + if (b.pos_lo >= lo && b.pos_hi <= hi) sum += b.raw_work; + return sum; + } + + // ── reorg rewind (§6.2, OQ-7) ───────────────────────────────────────── + // Rewind the last d pushes. Returns false when the journal cannot serve + // the request (d > recorded pushes, or the span crosses an epoch-rebuild + // boundary — the journal is cleared at rebuilds); the caller then does + // the full lane rebuild from the tracker (the >D path). + bool rewind(u64 d) { + u64 pushes = 0; + for (const auto& op : m_journal) + if (op.type == Op::Type::Push) ++pushes; + if (d > pushes) return false; + u64 undone = 0; + while (undone < d) { + Op op = m_journal.back(); + m_journal.pop_back(); + switch (op.type) { + case Op::Type::Push: undo_push(op); ++undone; break; + case Op::Type::FoldL0: undo_fold_l0(op); break; + case Op::Type::FoldLevel: undo_fold_level(op); break; + case Op::Type::Evict: undo_evict(op); break; + } + } + return true; + } + + // ── digest (§8.5, OQ-4) — canonical, consensus-committed ────────────── + bytes32 digest() const { + std::vector buf; + append_bytes(buf, "V37L", 4); + append_u64(buf, m_B); + append_u64(buf, m_next_pos); + append_u64(buf, static_cast(m_acc.size())); + for (const auto& [m, a] : m_acc) { // std::map: miner-id order + append_u32(buf, m); + append_u256(buf, a); + } + // level-ring headers incl. per-ring raw_sum (F-1 leaves) + append_u64(buf, static_cast(m_l0.size())); + append_u256(buf, m_l0_scaled_sum); + append_u128(buf, m_l0_raw_sum); + for (const auto& lvl : m_levels) { + append_u64(buf, static_cast(lvl.size())); + for (const auto& b : lvl) { // per-bucket leaves + append_u64(buf, b.pos_lo); + append_u64(buf, b.pos_hi); + append_u128(buf, b.raw_work); + append_u64(buf, b.epoch_tag); + auto ch = comp_hash(b); + buf.insert(buf.end(), ch.begin(), ch.end()); + } + } + return sha256d(buf); + } + + // ── OQ-2 exact epoch rebuild, exposed for tests/reference checks ────── + // Re-derives every scaled quantity from durable records (w_raw + pos for + // L0; immutable bucket records with the epoch-shift rule). This IS the + // reference function the fast path re-converges to (§8.4). + void epoch_rebuild() { + u64 B_new = m_B + m_p.epoch_len(); + m_acc.clear(); + m_acc_total = U256(); + m_l0_scaled_sum = U256(); + // L0: recompute from primary inputs, oldest -> newest. + for (auto& s : m_l0) { + u64 depth = B_new - s.pos; // in [1, E] (E == C0) + s.w_scaled = u128(s.w_raw) * m_tab.decay[depth]; + m_acc[s.miner] += U256::from_u128(s.w_scaled); + m_acc_total += U256::from_u128(s.w_scaled); + m_l0_scaled_sum += U256::from_u128(s.w_scaled); + } + // Buckets: immutable; shifted by lambda^(E*age) at read. + for (const auto& lvl : m_levels) { + for (const auto& b : lvl) { + u64 age = (B_new - b.epoch_tag) / m_p.epoch_len(); + u64 f = m_tab.epoch_shift[age]; + for (const auto& e : b.comp) { + U256 c = e.scaled.mul_q(f); + m_acc[e.miner] += c; + m_acc_total += c; + } + } + } + m_B = B_new; + m_journal.clear(); // rewind cannot cross a rebuild (see notes) + } + +private: + // ── journal ─────────────────────────────────────────────────────────── + struct Op { + enum class Type { Push, FoldL0, FoldLevel, Evict }; + Type type; + // Push + L0Slot slot; + // FoldL0 / FoldLevel / Evict + Bucket bucket; // created (folds) or removed (evict) + std::vector folded_slots; // FoldL0: removed L0 slots + std::vector folded_children; // FoldLevel: removed buckets + std::size_t level_index = 0; // FoldLevel/Evict: which level + std::vector> subtracted; // Evict: exact subs + + static Op push(const L0Slot& s) { Op o; o.type = Type::Push; o.slot = s; return o; } + }; + + u64 head_decay() const { + // factor lambda^(H - B) with H = next_pos - 1; H - B in [0, E). + if (m_next_pos == m_B) return Q_ONE; + return m_tab.decay[(m_next_pos - 1) - m_B]; + } + + void journal_push(Op op) { + m_journal.push_back(std::move(op)); + // Trim: keep ops back to (and including) the D-th most recent push. + u64 pushes = 0; + std::size_t keep_from = 0; + for (std::size_t i = m_journal.size(); i-- > 0;) { + if (m_journal[i].type == Op::Type::Push) { + if (++pushes == m_p.journal_depth) { keep_from = i; break; } + } + } + if (pushes >= m_p.journal_depth && keep_from > 0) + m_journal.erase(m_journal.begin(), + m_journal.begin() + static_cast(keep_from)); + } + + // ── fold: L0 -> level 1 (§4.1) ──────────────────────────────────────── + void fold_l0() { + Bucket b; + b.epoch_tag = m_B; + std::map agg; + Op op; op.type = Op::Type::FoldL0; op.level_index = 0; + for (u64 i = 0; i < m_p.rollup; ++i) { + const L0Slot& s = m_l0.front(); + if (i == 0) b.pos_lo = s.pos; + b.pos_hi = s.pos; + b.scaled_sum += U256::from_u128(s.w_scaled); + b.raw_work += s.w_raw; + auto& e = agg[s.miner]; + e.miner = s.miner; + e.scaled += U256::from_u128(s.w_scaled); + e.raw += s.w_raw; + m_l0_scaled_sum -= U256::from_u128(s.w_scaled); + m_l0_raw_sum -= s.w_raw; + op.folded_slots.push_back(s); + m_l0.pop_front(); + } + for (auto& [m, e] : agg) b.comp.push_back(e); // miner-id order + op.bucket = b; + m_levels[0].push_back(std::move(b)); + m_journal.push_back(std::move(op)); + // acc is untouched: fold relocates scaled value, it does not change it. + } + + // ── fold cascade: level k -> k+1 when ring k is full ────────────────── + void cascade_folds() { + for (std::size_t k = 0; k + 1 < m_levels.size(); ++k) { + if (m_levels[k].size() < m_p.level_caps[k]) continue; + Bucket b; + b.epoch_tag = m_B; + std::map agg; + Op op; op.type = Op::Type::FoldLevel; op.level_index = k; + for (u64 i = 0; i < m_p.rollup; ++i) { + Bucket child = m_levels[k].front(); + m_levels[k].pop_front(); + u64 age = (m_B - child.epoch_tag) / m_p.epoch_len(); + u64 f = m_tab.epoch_shift[age]; + if (i == 0) b.pos_lo = child.pos_lo; + b.pos_hi = child.pos_hi; + b.scaled_sum += child.scaled_sum.mul_q(f); + b.raw_work += child.raw_work; + for (const auto& e : child.comp) { + auto& a = agg[e.miner]; + a.miner = e.miner; + a.scaled += e.scaled.mul_q(f); + a.raw += e.raw; + } + op.folded_children.push_back(std::move(child)); + } + for (auto& [m, e] : agg) b.comp.push_back(e); + op.bucket = b; + m_levels[k + 1].push_back(std::move(b)); + m_journal.push_back(std::move(op)); + // NOTE: shifting children to the current frame at fold changes + // their stored frame; acc tracked them in their OLD frame, so a + // truncation residual is created here. It is deterministic and + // flushed at the next epoch rebuild (§4.2 determinism note) — + // and with default L = 2 this path never runs. + } + } + + // ── eviction: whole outermost buckets (OQ-1) ────────────────────────── + void evict_oldest_bucket() { + // Globally oldest bucket = the one with the smallest pos_lo across + // all bucket levels (data is strictly ordered through the pyramid). + std::size_t best = SIZE_MAX; + for (std::size_t k = 0; k < m_levels.size(); ++k) { + if (m_levels[k].empty()) continue; + if (best == SIZE_MAX || + m_levels[k].front().pos_lo < m_levels[best].front().pos_lo) + best = k; + } + if (best == SIZE_MAX) + throw std::logic_error("v37: cover > W with no buckets"); + Bucket b = m_levels[best].front(); + m_levels[best].pop_front(); + u64 age = (m_B - b.epoch_tag) / m_p.epoch_len(); + u64 f = m_tab.epoch_shift[age]; + Op op; op.type = Op::Type::Evict; op.level_index = best; + for (const auto& e : b.comp) { + U256 sub = e.scaled.mul_q(f); + m_acc[e.miner] -= sub; + m_acc_total -= sub; + if (m_acc[e.miner].is_zero()) m_acc.erase(e.miner); + op.subtracted.emplace_back(e.miner, sub); + } + m_raw_total -= b.raw_work; + m_cover -= (b.pos_hi - b.pos_lo + 1); + op.bucket = std::move(b); + m_journal.push_back(std::move(op)); + } + + // ── undo ops (exact bit-restoration; no rebuild in between by rule) ─── + void undo_push(const Op& op) { + const L0Slot& s = op.slot; + m_l0.pop_back(); + m_acc[s.miner] -= U256::from_u128(s.w_scaled); + if (m_acc[s.miner].is_zero()) m_acc.erase(s.miner); + m_acc_total -= U256::from_u128(s.w_scaled); + m_raw_total -= s.w_raw; + m_l0_scaled_sum -= U256::from_u128(s.w_scaled); + m_l0_raw_sum -= s.w_raw; + m_cover -= 1; + m_next_pos -= 1; + } + void undo_fold_l0(const Op& op) { + m_levels[0].pop_back(); + for (auto it = op.folded_slots.rbegin(); it != op.folded_slots.rend(); ++it) { + m_l0.push_front(*it); + m_l0_scaled_sum += U256::from_u128(it->w_scaled); + m_l0_raw_sum += it->w_raw; + } + } + void undo_fold_level(const Op& op) { + m_levels[op.level_index + 1].pop_back(); + for (auto it = op.folded_children.rbegin(); + it != op.folded_children.rend(); ++it) + m_levels[op.level_index].push_front(*it); + } + void undo_evict(const Op& op) { + m_levels[op.level_index].push_front(op.bucket); + for (const auto& [m, sub] : op.subtracted) { + m_acc[m] += sub; + m_acc_total += sub; + } + m_raw_total += op.bucket.raw_work; + m_cover += (op.bucket.pos_hi - op.bucket.pos_lo + 1); + } + + // ── digest serialization helpers (fixed-width LE, §8.3) ─────────────── + static void append_bytes(std::vector& b, const char* p, std::size_t n) { + b.insert(b.end(), p, p + n); + } + static void append_u32(std::vector& b, std::uint32_t x) { + for (int i = 0; i < 4; ++i) b.push_back(static_cast(x >> (8 * i))); + } + static void append_u64(std::vector& b, u64 x) { + for (int i = 0; i < 8; ++i) b.push_back(static_cast(x >> (8 * i))); + } + static void append_u128(std::vector& b, u128 x) { + append_u64(b, static_cast(x)); + append_u64(b, static_cast(x >> 64)); + } + static void append_u256(std::vector& b, const U256& x) { + for (int i = 0; i < 4; ++i) append_u64(b, x.v[i]); + } + static bytes32 comp_hash(const Bucket& b) { + std::vector buf; + for (const auto& e : b.comp) { + append_u32(buf, e.miner); + append_u256(buf, e.scaled); + append_u128(buf, e.raw); + } + return sha256d(buf); + } + + LaneParams m_p; + DecayTables m_tab; + u64 m_next_pos = 0; + u64 m_B = 0; + u64 m_cover = 0; + std::deque m_l0; + std::vector> m_levels; + std::map m_acc; // ordered: digest determinism + U256 m_acc_total; + u128 m_raw_total = 0; + U256 m_l0_scaled_sum; + u128 m_l0_raw_sum = 0; + std::deque m_journal; +}; + +} // namespace v37 diff --git a/src/sharechain/v37/v37_roundabout.hpp b/src/sharechain/v37/v37_roundabout.hpp new file mode 100644 index 000000000..36e25886b --- /dev/null +++ b/src/sharechain/v37/v37_roundabout.hpp @@ -0,0 +1,90 @@ +#pragma once +// V37 MRR roundabout — the multichain container (§3, §6.3, §7). +// Lanes are independent; the only cross-lane state is the global miner +// intern table (normalized PayoutDescriptor identity -> dense u32 id) and +// the directory. Adding/removing a lane never restructures another lane. + +#include +#include +#include +#include +#include + +#include "v37_descriptor.hpp" +#include "v37_lane.hpp" + +namespace v37 { + +using ChainId = std::uint32_t; + +class MinerIntern { +public: + // Identity = SHA256d of the identity preimage (exact (kind,payload) of + // `pay`, §6.3 rule 3 / S-1). Returns a dense id, stable for the session. + MinerId intern(const PayoutDescriptor& d) { + auto key = d.identity_preimage(); + auto it = m_ids.find(key); + if (it != m_ids.end()) return it->second; + MinerId id = static_cast(m_refs.size()); + m_ids.emplace(std::move(key), id); + m_refs.push_back(d.pay); + return id; + } + const ScriptRef& pay_ref(MinerId id) const { return m_refs.at(id); } + std::size_t size() const { return m_refs.size(); } + +private: + std::map, MinerId> m_ids; + std::vector m_refs; +}; + +class Roundabout { +public: + // O(1) directory append; no other lane is touched (§7). + Lane& add_lane(ChainId chain, const LaneParams& p) { + auto [it, fresh] = m_lanes.emplace(chain, nullptr); + if (!fresh) throw std::invalid_argument("v37: lane already exists"); + it->second = std::make_unique(p); + return *it->second; + } + void remove_lane(ChainId chain) { m_lanes.erase(chain); } + Lane* lane(ChainId chain) { + auto it = m_lanes.find(chain); + return it == m_lanes.end() ? nullptr : it->second.get(); + } + const Lane* lane(ChainId chain) const { + auto it = m_lanes.find(chain); + return it == m_lanes.end() ? nullptr : it->second.get(); + } + std::size_t lane_count() const { return m_lanes.size(); } + MinerIntern& miners() { return m_miners; } + + // Push a share: validates the descriptor under V37.0 rules (attribution + // MUST be absent), interns identity, forwards to the lane. + MinerId push(ChainId chain, const PayoutDescriptor& d, u64 w_raw, + std::uint32_t flags) { + if (!d.valid(false)) + throw std::invalid_argument("v37: invalid descriptor (V37.0 rules)"); + Lane* l = lane(chain); + if (!l) throw std::invalid_argument("v37: unknown chain"); + MinerId id = m_miners.intern(d); + l->push(id, w_raw, flags); + return id; + } + + // Cross-lane per-miner aggregate of decayed weights: integer-keyed merge + // (§7). Display/aggregation only — never consensus (OQ-8: normalization + // happens at serialization, not here; values stay chain-local units). + std::map aggregate_decayed() const { + std::map out; + for (const auto& [c, l] : m_lanes) + for (const auto& [m, w] : l->payout_map()) out[m] += w; + return out; + } + +private: + std::map> m_lanes; + MinerIntern m_miners; +}; + +} // namespace v37 diff --git a/web-static/sharechain-explorer/src/pplns/parse.ts b/web-static/sharechain-explorer/src/pplns/parse.ts index b31e96711..715950118 100644 --- a/web-static/sharechain-explorer/src/pplns/parse.ts +++ b/web-static/sharechain-explorer/src/pplns/parse.ts @@ -119,7 +119,10 @@ function parseNewShape(obj: Record): PplnsSnapshot { const snap: PplnsSnapshot = { totalPrimary: totalPrimary > 0 ? totalPrimary - : miners.reduce((s, m) => s + m.amount, 0), + // Clamp the fallback sum: amounts are individually finite, but a + // sum of values near Number.MAX_VALUE overflows to Infinity, which + // breaks the "totalPrimary is finite" invariant (parse-properties #204). + : num(miners.reduce((s, m) => s + m.amount, 0)), mergedChains, mergedTotals, schemaVersion: str(obj.schema_version) ?? '1.0', @@ -258,7 +261,9 @@ function parseLegacyShape(obj: Record): PplnsSnapshot { entries.push({ address: addr, amount, merged }); } - const totalPrimary = entries.reduce((s, e) => s + e.amount, 0); + // num() clamps an overflowed sum (values near Number.MAX_VALUE) back to a + // finite 0 rather than Infinity; see parse-properties #204. + const totalPrimary = num(entries.reduce((s, e) => s + e.amount, 0)); entries.sort((a, b) => b.amount - a.amount); const miners: PplnsMiner[] = entries.map((e) => ({