diff --git a/src/sharechain/v37/IMPLEMENTATION-NOTES.md b/src/sharechain/v37/IMPLEMENTATION-NOTES.md index efb419fce..aea402791 100644 --- a/src/sharechain/v37/IMPLEMENTATION-NOTES.md +++ b/src/sharechain/v37/IMPLEMENTATION-NOTES.md @@ -1,6 +1,164 @@ # V37 MRR Roundabout Round-Buffer — implementation notes (WIP for review) -Branch: `v37/mrr-roundabout-buffer`. Spec: `c2pool-v37-mrr-roundabout-buffer.md` v1.0 +## Merkle digest (2026-06-12, OQ-M5 resolved) — lite-client proofs + +The lane digest is now the root of a domain-separated Merkle tree over the +same canonical leaves (leaf = sha256d(0x00||payload), interior = +sha256d(0x01||L||R), odd promoted; fixed order: header leaf with geometry/ +position/counts/L0 sums, acc leaves in canonical-identity order, bucket +leaves level-by-level oldest-first). `Lane::acc_proof()` produces a log-size +inclusion proof for one miner's accumulator; static `Lane::verify_proof()` +is the stateless lite-client verifier (kilobytes end-to-end via parent-chain +SPV -> OP_RETURN -> THE root -> lane root -> leaf). Same mechanism makes +per-band bucket raw_work individually provable for market settlement. +Tested: proof round-trip for every miner, tamper/index/root-freshness +rejection. Suite: 100,509 checks, 0 failures (-O2 and ASan/UBSan). + +## Formal review pass (2026-06-12, fourth commit) — 7-angle review, 8 fixes + +A structured multi-angle review (line-scan, replaced-behavior audit vs V36, +contract tracing, reuse/simplification/efficiency, altitude) confirmed the +three earlier fixes and surfaced new defects. Fixed in this commit: + +1. **Rewind over a rebuild boundary** (consensus split, confirmed by + experiment): rewinding onto the rebuild-triggering push succeeded without + undoing the rebuild — restored state was in the new frame while honest + peers that never saw the orphan stayed in the old frame. Fix: a + RebuildBoundary sentinel journaled at every rebuild; rewind refuses to + land on the rebuild-triggering push (caller takes the full-rebuild path); + journal trim preserves an adjacent sentinel. +2. **kind-255 raw_script unbound to identity**: `valid()` now requires + `canonicalize_script(raw_script) == pay` for RAW (enforces the hash + binding AND the one-canon rule — template scripts smuggled under kind 255 + fail), rejects raw_script on template kinds, and validates payload widths + (20/32) for every ScriptRef incl. attribution and aux. +3. **Geometry validation gaps**: constructor now refuses window < C0 + (previously: deterministic mid-push crash), empty level_caps, and inner + level caps < R (previously: empty-deque UB on cascade). +4. **add_lane exception safety**: Lane constructed before directory insert — + a geometry throw no longer bricks the chain id with a null entry. +5. **Public epoch_rebuild() misuse**: throws off the positional boundary + (previously: B > next_pos, u64 underflow, OOB table reads). +6. **Zero-work shares rejected**: push(w_raw=0) created a zero acc entry the + digest committed but undo_push erased — rewind was not bit-exact. +7. **aux count bound**: valid() caps aux at 0xffff so the canonical u16 + count field can never contradict the serialized entries. +8. **Lane geometry digest-committed**: W/C0/R/half_life/level_caps are now + part of the digest preimage — parameter mismatch between nodes surfaces + as an immediate attributable digest difference. Dead consensus surface + removed (unused append_u32, free mul_q, U256::lo128). + +Post-fix: **100,464 checks, 0 failures** (-O2 and ASan/UBSan), including +regressions for every fix above. + +### Review findings deferred to integration (not fixed here) + +- **Donation split (IMPORTANT)**: V36 splits each share's weight via the + 65535-donation scheme; v37 has no equivalent. The integration adapter MUST + define donation handling — e.g. split w_raw at the adapter into a miner + push and a donation-descriptor push (integer rule to be specified as + consensus), or carry donation in the descriptor. Unresolved = donation + outputs silently unpaid after migration. +- **Backward slide / multi-head**: V36 `slide_backward`/per-head `HeadPPLNS` + have no direct v37 API. Multi-head = one Lane instance per competing head + (~130 KiB each at default geometry); deep verification needs a + rebuild-from-tracker constructor (O(W) push replay — same cost class as + V36 `rebuild()`). Needs caller-shaped API at integration. +- **att (uint288) -> w_raw (u64) adapter** must assert/clamp (already + flagged; review re-confirmed nothing in-module guards it). +- **Authority share messaging (V36 carry-forward, IMPORTANT)**: V36 embeds + authority messages in shares' message_data — consensus-carried + (ref_hash/PoW-protected), validated in share_check (bad envelope = share + REJECTED), typed (incl. EMERGENCY and TRANSITION_SIGNAL — the channel a + V37 activation itself would ride). See src/impl//share_messages.hpp. + This module reflects only PRESENCE at the lowest temporal level (L0 flag + bit L0F_AUTHORITY_MSG, annotation-only, set by the adapter after + share_check validation); payloads stay in the share store and join by + pos. OQ-10 resolved (2026-06-12): the V36 authority key set + (DONATION_AUTHORITY_PUBKEYS) carries forward UNCHANGED for V37.0 — + share_check reuses the existing envelope validation verbatim; rotation is + a V37.x validity-rule change. OQ-11 resolved (2026-06-12): fold-bounded + visibility accepted — FLAG_PERSISTENT imposes no buffer/consensus + obligation; pinning is an optional view-layer feature. No open design + questions remain anywhere in the V37 corpus. +- **Efficiency backlog** (semantics-neutral): journal push-count counter + instead of per-push O(|journal|) scans; drop dead Bucket copies in fold + journal ops (only Evict undo reads op.bucket); payout_map emplace_hint or + vector return (documented O(n) is currently O(n log n)); digest streaming + into the incremental SHA ctx + per-call id_key memoization; + raw_work_in_span lower_bound on ordered deques. Also consider deriving + m_cover / m_acc_total / m_l0 sums instead of hand-maintaining (drift-proof + by construction; the L0 sums are digest leaves, so derivation in digest() + is the safer shape). +- **raw_work_in_span semantics**: buckets straddling the span edge are + excluded (bucket-granular by design) and L0-vs-folded timing changes + answers for unaligned bands — settlement bands MUST align to bucket/epoch + boundaries (the market doc already assumes epoch-aligned bands); API doc + updated, consider an aligned-band-only API at integration. +- **Reuse divergence risks for CI**: this module is the 3rd script-template + classifier (vs core/address_utils.cpp x2), the 7th copy of the LN2_MICRO + decay derivation, and a 2nd big-int (U256 vs base_uint). Integration + should add cross-implementation agreement tests (same inputs -> same + outputs) and a digest byte-layout golden test pinning the serialization + independently of these helpers before any swap to core/pack.hpp idioms. + +## Full reassessment pass 2 (2026-06-12, third commit) — C-1, consensus-critical + +**C-1: lane digest keyed by node-local intern ids.** `MinerIntern` assigns +dense u32 ids at first *global* sighting; with multiple lanes the cross-lane +interleaving of first sightings is node-local (wall-clock dependent), so two +honest nodes assign different ids to the same miners. `digest()` serialized +acc in id order with raw id values (and `comp_hash` embedded ids), so two +honest nodes produced different digests for identical lane state — a chain +split under the consensus-committed digest (OQ-4). Payouts were never +affected (ids resolve to descriptors); only digest bytes. + +Root cause is the SPEC: §8.5 says "acc in miner-id order". Erratum filed in +the design doc (v1.1): consensus serialization must be in **canonical +payout-descriptor identity order** with the 32-byte identity key (S-3) as +the serialized name — intern ids must never appear in consensus bytes. + +Fix: `Lane::digest(resolver)` + `comp_hash(bucket, resolver)` sort and +serialize by `MinerIntern::key(id)` (identity_key, SHA256d of the identity +preimage); `Roundabout::lane_digest(chain)` is the production entry point. +Regression: two Roundabouts fed identical per-lane sequences under different +cross-lane interleavings — intern ids diverge, lane digests must not. +Post-fix: **100,448 checks, 0 failures** (-O2 and ASan/UBSan). + +Docs re-verified in the same pass (geometry sums, half-life coverage, epoch +growth, tail-density claims all check out); doc errata E-1..E-5 listed in +the design doc v1.1 revision note. + +## Reassessment pass (2026-06-12, second commit) + +A full fresh-eyes re-audit of the consensus paths (re-deriving every range +bound rather than trusting comments) found and fixed two real defects that +the original 100k-check run could not see because both were self-consistent +between the fast path and the reference: + +1. **Unguarded u64 wrap in `inv_decay` generation.** The inverse table + requires `lambda^-(E-1) < 4.0` (roughly `E <= 2*half_life`); geometries + violating it silently wrapped — lane and reference shared the wrapped + table, so the bit-exact gate passed on a mathematically wrong curve. The + generator now throws on any non-increase (wrap detection); two test + geometries violated the ratio and were corrected; a regression test pins + the throw. The ratified default geometry (E/HL = 1.896) was never + affected. +2. **`rewind()` left the landing push's folds applied.** One push() call + journals `[Folds][Push][Evicts]`; the undo loop stopped at the d-th Push + without popping its own preceding folds, so a rewind landing exactly on a + fold-triggering share restored "after the fold" instead of "before the + share". The original digest test passed only by alignment luck. Fixed in + `rewind()` and in the journal trim (which now keeps the kept-oldest + push's folds); regression-tested by forcing a fold at the rewind boundary + plus a 50-round randomized snapshot/push-k/rewind-k digest sweep. + +Post-fix: **100,444 checks, 0 failures** (-O2 and ASan/UBSan). Lesson +folded into the suite: the reference-equality gate proves fast==reference, +not fast==intended-math — table generation and rewind now have their own +direct oracles (monotonicity/throw, digest-restore sweeps). + +Branch: `fable/v37-mrr-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. diff --git a/src/sharechain/v37/test/v37_test.cpp b/src/sharechain/v37/test/v37_test.cpp index 983ae22dc..7206fcc91 100644 --- a/src/sharechain/v37/test/v37_test.cpp +++ b/src/sharechain/v37/test/v37_test.cpp @@ -165,6 +165,16 @@ struct ReferenceLane { } }; +// Injective deterministic id -> key map for single-node lane tests (real +// deployments resolve through MinerIntern::key — see the canonical-identity +// regression test below). +static bytes32 test_key(MinerId m) { + std::uint8_t b[4] = { + static_cast(m), static_cast(m >> 8), + static_cast(m >> 16), static_cast(m >> 24)}; + return sha256d(b, 4); +} + // ── tests ────────────────────────────────────────────────────────────────── static void test_sha256() { @@ -193,8 +203,6 @@ static void test_fixed_point() { 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 @@ -227,18 +235,34 @@ static void test_fixed_point() { } // Small geometry exercising every mechanism quickly: -// W=216 = C0(128) + 11 buckets x 8; E=128; HL=54; D=16. +// W=256 = C0(128) + 16 buckets x 8; E=128; HL=64 (=W/4); D=16. +// NOTE: E/HL must keep lambda^-(E-1) < 4.0 (inverse-decay u64 headroom) — +// the table generator now enforces this (see test_headroom_guard). static LaneParams small_params() { LaneParams p; - p.window = 216; + p.window = 256; p.c0 = 128; p.rollup = 8; - p.level_caps = {11}; - p.half_life = 54; + p.level_caps = {16}; + p.half_life = 64; p.journal_depth = 16; return p; } +static void test_headroom_guard() { + // E/HL = 128/54 = 2.37: lambda^-(127) ~ 2^2.35 > 4.0 -> must throw, + // never silently wrap the inverse table. + LaneParams bad = small_params(); + bad.half_life = 54; + bool threw = false; + try { Lane l(bad); } catch (const std::invalid_argument&) { threw = true; } + CHECK(threw); + // Ratified default geometry (4096/2160 = 1.896) constructs fine. + LaneParams def; + Lane ok(def); + CHECK(ok.tables().inv_decay.size() == def.epoch_len()); +} + static void test_lane_vs_reference(const LaneParams& p, u64 pushes, u64 seed, const char* tag) { Lane lane(p); @@ -303,12 +327,12 @@ static void test_digest_and_rewind() { 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()); + CHECK(a.digest(test_key) == b.digest(test_key)); // 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())); + CHECK(!(c.digest(test_key) == d.digest(test_key))); // Rewind: bit-exact state restoration (OQ-7), within one epoch Lane e(p); @@ -320,18 +344,67 @@ static void test_digest_and_rewind() { (e.next_pos() - e.epoch_base()) < 16) { e.push(0, 1, 0); } - auto snap = e.digest(); + auto snap = e.digest(test_key); 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); + CHECK(e.digest(test_key) == 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 + // REVIEW REGRESSION: rewind landing exactly on the rebuild-triggering + // push must refuse — the journal cannot restore the pre-rebuild frame. + CHECK(!f.rewind(4)); // d == pushes-since-rebuild, sentinel present CHECK(f.rewind(2)); // shallow rewind still fine + + // Regression: rewind landing exactly on a push that triggered a fold + // must undo that fold too (state = before the share arrived). + Lane g(p); + XorShift64 r5(21); + for (int i = 0; i < 200; ++i) + g.push(static_cast(r5.range(0, 5)), r5.range(1, 1000000), 0); + // Drive L0 to exactly full so the NEXT push folds, staying mid-epoch. + while (g.l0().size() != p.c0 || + (g.next_pos() - g.epoch_base()) >= p.epoch_len() - 4) + g.push(0, 7, 0); + auto snap_fold = g.digest(test_key); + std::size_t buckets_before = g.levels()[0].size(); + g.push(1, 99, 0); // folds 8 slots, then inserts + CHECK(g.levels()[0].size() == buckets_before + 1); + CHECK(g.rewind(1)); + CHECK(g.digest(test_key) == snap_fold); // fold undone with its push + CHECK(g.levels()[0].size() == buckets_before); + + // Randomized rewind sweep: snapshot / push k <= D / rewind k must be a + // bit-exact no-op wherever it lands in the fold/evict cycle (mid-epoch). + Lane h(p); + XorShift64 r6(31); + for (int i = 0; i < 150; ++i) + h.push(static_cast(r6.range(0, 5)), r6.range(1, 1000000), 0); + for (int round = 0; round < 50; ++round) { + // keep the span clear of the epoch boundary (journal is cleared there) + while ((h.next_pos() - h.epoch_base()) >= p.epoch_len() - (p.journal_depth + 2) || + (h.next_pos() - h.epoch_base()) < p.journal_depth + 2) + h.push(0, 3, 0); + u64 k = r6.range(1, p.journal_depth); + auto snap = h.digest(test_key); + for (u64 i = 0; i < k; ++i) + h.push(static_cast(r6.range(0, 5)), r6.range(1, 1000000), 0); + CHECK(h.rewind(k)); + if (!(h.digest(test_key) == snap)) { + ++g_failures; + std::printf("FAIL rewind sweep round %d (k=%llu)\n", round, + (unsigned long long)k); + return; + } + ++g_checks; + // replay so rounds keep advancing through fold/evict alignments + for (u64 i = 0; i < k; ++i) + h.push(static_cast(r6.range(0, 7)), r6.range(1, 1000000), 0); + } } static void test_descriptor() { @@ -380,6 +453,23 @@ static void test_descriptor() { CHECK(!d3.valid()); d3.raw_script = exotic; CHECK(d3.valid()); + // REVIEW REGRESSIONS: raw_script must BIND to the identity payload + d3.raw_script.push_back(0x00); // different script, same payload + CHECK(!d3.valid()); + d3.raw_script = exotic; + PayoutDescriptor d3b; d3b.pay = r3; d3b.raw_script = p2pkh; + CHECK(!d3b.valid()); // wrong script entirely + PayoutDescriptor d3c; // template smuggled under kind 255 + d3c.pay.kind = ScriptKind::RAW; + auto th = sha256d(p2pkh); + d3c.pay.payload.assign(th.begin(), th.end()); + d3c.raw_script = p2pkh; // canonicalizes to P2PKH, not RAW + CHECK(!d3c.valid()); + PayoutDescriptor d3d; d3d.pay = r1; d3d.raw_script = p2pkh; + CHECK(!d3d.valid()); // template kinds carry no script + PayoutDescriptor d3e; d3e.pay = r1; + d3e.pay.payload.resize(5); // bad payload width + CHECK(!d3e.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)); @@ -403,7 +493,7 @@ 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; + p2.window = 112; p2.c0 = 64; p2.level_caps = {6}; p2.half_life = 32; rb.add_lane(2, p2); // runtime add, different geometry (per-lane params) CHECK(rb.lane_count() == 2); @@ -429,9 +519,155 @@ static void test_roundabout() { CHECK(rb.lane_count() == 1 && rb.lane(2) == nullptr); } +// CONSENSUS REGRESSION (C-1): intern ids are node-local — two nodes seeing +// the same per-lane share sequences but a different cross-lane interleaving +// assign different ids to the same miners. The lane digest must be identical +// anyway, because it serializes canonical identity keys, never intern ids. +static void test_digest_canonical_identity() { + auto mk_desc = [](std::uint8_t fill) { + std::vector s = {0x76, 0xa9, 0x14}; + for (int i = 0; i < 20; ++i) s.push_back(fill); + s.push_back(0x88); s.push_back(0xac); + PayoutDescriptor d; d.pay = canonicalize_script(s); + return d; + }; + PayoutDescriptor dx = mk_desc(0xaa), dy = mk_desc(0xbb); + + LaneParams p = small_params(); + Roundabout rb1, rb2; + rb1.add_lane(1, p); rb1.add_lane(2, p); + rb2.add_lane(1, p); rb2.add_lane(2, p); + + // Node 1 sees lane 1 first; node 2 sees lane 2 first. Per-lane order is + // identical (consensus order): lane1 = [dx, dy], lane2 = [dy, dx]. + rb1.push(1, dx, 100, 0); rb1.push(2, dy, 300, 0); + rb1.push(1, dy, 200, 0); rb1.push(2, dx, 400, 0); + + rb2.push(2, dy, 300, 0); rb2.push(2, dx, 400, 0); + rb2.push(1, dx, 100, 0); rb2.push(1, dy, 200, 0); + + // Intern ids genuinely diverge between the nodes... + CHECK(rb1.miners().intern(dx) != rb2.miners().intern(dx)); + // ...but the consensus digests must not. + CHECK(rb1.lane_digest(1) == rb2.lane_digest(1)); + CHECK(rb1.lane_digest(2) == rb2.lane_digest(2)); + // Sanity: digests still distinguish different lane contents. + CHECK(!(rb1.lane_digest(1) == rb1.lane_digest(2))); +} + +// Guards added by the formal review pass: geometry validation, zero-work +// rejection, public epoch_rebuild misuse, add_lane exception safety, aux +// count bound, geometry committed in the digest. +static void test_review_guards() { + LaneParams p = small_params(); + { // window < C0: refused at construction, not a mid-push logic_error + LaneParams bad = p; bad.window = 64; + bool threw = false; + try { Lane l(bad); } catch (const std::invalid_argument&) { threw = true; } + CHECK(threw); + } + { // inner level cap below the roll-up factor: refused + LaneParams bad = p; bad.level_caps = {4, 568}; + bool threw = false; + try { Lane l(bad); } catch (const std::invalid_argument&) { threw = true; } + CHECK(threw); + } + { // no bucket levels at all: refused (eviction needs buckets) + LaneParams bad = p; bad.level_caps = {}; + bool threw = false; + try { Lane l(bad); } catch (const std::invalid_argument&) { threw = true; } + CHECK(threw); + } + { // zero-work share: refused (digest/rewind exactness) + Lane l(p); + bool threw = false; + try { l.push(0, 0, 0); } catch (const std::invalid_argument&) { threw = true; } + CHECK(threw); + } + { // public epoch_rebuild() off the positional boundary: refused + Lane l(p); + l.push(0, 5, 0); + bool threw = false; + try { l.epoch_rebuild(); } catch (const std::logic_error&) { threw = true; } + CHECK(threw); + } + { // add_lane exception safety: failed construction leaves no entry + Roundabout rb; + LaneParams bad = p; bad.window = 1; + bool threw = false; + try { rb.add_lane(5, bad); } catch (const std::invalid_argument&) { threw = true; } + CHECK(threw); + CHECK(rb.lane_count() == 0 && rb.lane(5) == nullptr); + rb.add_lane(5, p); // chain id is NOT bricked + CHECK(rb.lane(5) != nullptr); + } + { // aux count beyond the canonical u16 field: invalid + std::vector s = {0x76, 0xa9, 0x14}; + for (int i = 0; i < 20; ++i) s.push_back(1); + s.push_back(0x88); s.push_back(0xac); + PayoutDescriptor d; d.pay = canonicalize_script(s); + d.aux.resize(0x10000); + for (std::size_t i = 0; i < d.aux.size(); ++i) { + d.aux[i].chain_id = static_cast(i); + d.aux[i].ref = d.pay; + } + CHECK(!d.valid()); + d.aux.resize(0xffff); + CHECK(d.valid()); + } + { // lane geometry is digest-committed: same pushes, different + // half_life -> different digests (attributable parameter mismatch) + LaneParams p2 = p; p2.half_life = 96; + Lane a(p), b(p2); + for (int i = 0; i < 50; ++i) { a.push(1, 100, 0); b.push(1, 100, 0); } + CHECK(!(a.digest(test_key) == b.digest(test_key))); + } +} + +// OQ-M5: Merkle digest — inclusion proofs verify against the root; any +// tamper (leaf, index, root) fails verification. +static void test_merkle_proofs() { + LaneParams p = small_params(); + Lane l(p); + XorShift64 r(77); + for (int i = 0; i < 400; ++i) + l.push(static_cast(r.range(0, 9)), r.range(1, 1000000), 0); + bytes32 root = l.digest(test_key); + int proved = 0; + for (const auto& [m, a] : l.acc()) { + bytes32 leaf; + Lane::MerkleProof proof; + CHECK(l.acc_proof(m, test_key, leaf, proof)); + CHECK(Lane::verify_proof(root, leaf, proof)); + // tampered leaf fails + bytes32 bad = leaf; bad[0] ^= 1; + CHECK(!Lane::verify_proof(root, bad, proof)); + // wrong index fails + Lane::MerkleProof p2 = proof; + p2.index = (p2.index + 1) % p2.leaf_count; + CHECK(!Lane::verify_proof(root, leaf, p2)); + ++proved; + } + CHECK(proved >= 5); + // proofs remain valid only against the matching root: push one share, + // old proofs must fail against the new root + bytes32 leaf; + Lane::MerkleProof proof; + MinerId m0 = l.acc().begin()->first; + CHECK(l.acc_proof(m0, test_key, leaf, proof)); + l.push(m0, 12345, 0); + bytes32 root2 = l.digest(test_key); + CHECK(!(root2 == root)); + CHECK(!Lane::verify_proof(root2, leaf, proof)); + // missing miner: no proof + Lane::MerkleProof p3; bytes32 lf3; + CHECK(!l.acc_proof(4040404u, test_key, lf3, p3)); +} + int main() { test_sha256(); test_fixed_point(); + test_headroom_guard(); // 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) @@ -442,6 +678,9 @@ int main() { test_digest_and_rewind(); test_descriptor(); test_roundabout(); + test_digest_canonical_identity(); + test_review_guards(); + test_merkle_proofs(); 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 index 36ed40936..6a5345109 100644 --- a/src/sharechain/v37/v37_descriptor.hpp +++ b/src/sharechain/v37/v37_descriptor.hpp @@ -104,15 +104,46 @@ struct PayoutDescriptor { // 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; + if (attribution.has_value() && !ref_well_formed(*attribution)) + return false; + if (aux.size() > 0xffff) + return false; // canonical u16 count field must not truncate 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 (!ref_well_formed(aux[i].ref)) return false; + } + if (!ref_well_formed(pay)) return false; + if (pay.kind == ScriptKind::RAW) { + // The carried script must BIND to the identity: re-canonicalize + // and require exact equality. This enforces both the hash + // binding (payload == sha256d(raw_script)) and the one-canon + // rule (a template script smuggled under kind 255 canonicalizes + // to its template kind and fails the comparison). + if (raw_script.empty()) return false; + if (!(canonicalize_script(raw_script) == pay)) return false; + } else { + // Template kinds derive their script from (kind, payload); a + // carried raw_script has no meaning and is rejected as malformed. + if (!raw_script.empty()) return false; } - if (pay.kind == ScriptKind::RAW && raw_script.empty()) - return false; // kind 255 must carry the script for payment return true; } + // Payload widths are part of the canon: 20 bytes for hash160 kinds, + // 32 for sha256/x-only/raw-hash kinds. + static bool ref_well_formed(const ScriptRef& r) { + switch (r.kind) { + case ScriptKind::P2PKH: + case ScriptKind::P2SH: + case ScriptKind::P2WPKH: return r.payload.size() == 20; + case ScriptKind::P2WSH: + case ScriptKind::P2TR: + case ScriptKind::RAW: return r.payload.size() == 32; + } + return false; // unknown kind byte + } + // Canonical serialization (§6.3 rule 6): the identity preimage. std::vector canonical_bytes() const { std::vector out; diff --git a/src/sharechain/v37/v37_fixed.hpp b/src/sharechain/v37/v37_fixed.hpp index f5ffa11fd..65ca0153c 100644 --- a/src/sharechain/v37/v37_fixed.hpp +++ b/src/sharechain/v37/v37_fixed.hpp @@ -45,8 +45,6 @@ struct U256 { 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) { @@ -108,18 +106,6 @@ struct U256 { } }; -// (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); @@ -161,8 +147,18 @@ struct DecayTables { inv_decay.assign(epoch_len, 0); inv_decay[0] = Q_ONE; - for (u64 j = 1; j < epoch_len; ++j) + for (u64 j = 1; j < epoch_len; ++j) { inv_decay[j] = mul_q64(inv_decay[j - 1], inv_per); + // Headroom guard: the true sequence is strictly increasing + // (every entry >= 1.0 and inv_per > 1.0), so any non-increase + // is a u64 wrap — the lane geometry violates the inverse-decay + // headroom (requires lambda^-(E-1) < 4.0, i.e. roughly + // epoch_len <= 2 * half_life). Refuse rather than corrupt. + if (inv_decay[j] <= inv_decay[j - 1]) + throw std::invalid_argument( + "v37: epoch_len/half_life ratio overflows inverse-decay " + "headroom (need lambda^-(E-1) < 4.0)"); + } if (max_depth < epoch_len) throw std::invalid_argument("v37: decay table shorter than epoch"); diff --git a/src/sharechain/v37/v37_lane.hpp b/src/sharechain/v37/v37_lane.hpp index c47eb9cd2..93409e25f 100644 --- a/src/sharechain/v37/v37_lane.hpp +++ b/src/sharechain/v37/v37_lane.hpp @@ -13,6 +13,7 @@ // All folds/evicts/rebuilds happen at positionally defined points; no input // to the sequence is node-local. +#include #include #include #include @@ -38,6 +39,22 @@ struct LaneParams { std::size_t levels() const { return 1 + level_caps.size(); } }; +// L0 slot flag bits. Annotation-only: flags are NOT digest leaves and never +// affect weights. Bit 0x08 mirrors V36 share_messages FLAG_PROTOCOL_AUTHORITY +// for operator familiarity: it marks shares whose message_data carried +// validated authority messages (V36 share_messages.hpp — envelope under the +// authority pubkeys, ECDSA-signed, ref_hash/PoW-protected; validation happens +// in share_check BEFORE push, payloads stay in the share store). Views join +// message payloads by `pos`; messages age out of visibility at fold, which +// matches their per-share nature (FLAG_PERSISTENT semantics: operator OQ). +constexpr std::uint32_t L0F_FEE_SHARE = 0x01; +constexpr std::uint32_t L0F_STALE_DOA = 0x02; +constexpr std::uint32_t L0F_AUTHORITY_MSG = 0x08; +constexpr std::uint32_t L0F_MINER_MSG = 0x10; // permissionless miner +// message present (c2pool-v37-miner-messages.md: plaintext envelope, sig +// bound to the payout identity, TTL funded by decayed_weight() — the lane +// is the budget ledger; the live-message index is view-layer, not here) + // Level-0 slot (SoA in the production layout; AoS here for clarity — the // arrays are contiguous std::vector rings either way). struct L0Slot { @@ -45,7 +62,7 @@ struct L0Slot { 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; + std::uint32_t flags = 0; // L0F_* bits above }; struct CompEntry { // (miner, w_scaled, w_raw) triple, F-1 @@ -69,6 +86,17 @@ class Lane { 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"); + if (p.window < p.c0) + throw std::invalid_argument("v37: window must be >= C0 " + "(eviction needs whole buckets)"); + if (p.level_caps.empty()) + throw std::invalid_argument("v37: at least one bucket level"); + // Inner bucket levels must hold at least one roll-up group; the + // outermost level never folds, so its cap is not load-bearing. + for (std::size_t k = 0; k + 1 < p.level_caps.size(); ++k) + if (p.level_caps[k] < p.rollup) + throw std::invalid_argument( + "v37: inner level cap smaller than roll-up factor"); // 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; @@ -91,6 +119,10 @@ class Lane { // ── push: O(1) amortized (§7) ───────────────────────────────────────── void push(MinerId miner, u64 w_raw, std::uint32_t flags) { + if (w_raw == 0) + throw std::invalid_argument("v37: zero-work share"); + // (a zero-weight acc entry would be committed by the digest but + // erased by undo_push — rewind would not be bit-exact) if (m_next_pos - m_B == m_p.epoch_len()) epoch_rebuild(); // (1) — clears journal @@ -151,9 +183,18 @@ class Lane { // the full lane rebuild from the tracker (the >D path). bool rewind(u64 d) { u64 pushes = 0; - for (const auto& op : m_journal) + bool boundary = false; + for (const auto& op : m_journal) { if (op.type == Op::Type::Push) ++pushes; + else if (op.type == Op::Type::RebuildBoundary) boundary = true; + } if (d > pushes) return false; + // The push that TRIGGERED an epoch rebuild cannot be undone without + // undoing the rebuild itself (the canonical pre-share state is the + // pre-rebuild frame, which the journal cannot restore). When the + // boundary sentinel is in the journal, the rebuild-triggering push + // is the oldest journaled push — refuse to land on it. + if (boundary && d == pushes) return false; u64 undone = 0; while (undone < d) { Op op = m_journal.back(); @@ -163,38 +204,100 @@ class Lane { 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; + case Op::Type::RebuildBoundary: + throw std::logic_error("v37: rewind crossed rebuild sentinel" + " (guarded above — unreachable)"); } } + // One push() call journals [Folds][Push][Evicts]. The loop above + // stops at the d-th Push; any folds IMMEDIATELY preceding it were + // triggered by that same share's arrival and must be undone too — + // the restored state is "before the share arrived", not "after its + // folds". Trailing folds are unambiguous: nothing else sits between + // a push's folds and its Push record. + while (!m_journal.empty() && + (m_journal.back().type == Op::Type::FoldL0 || + m_journal.back().type == Op::Type::FoldLevel)) { + Op op = m_journal.back(); + m_journal.pop_back(); + if (op.type == Op::Type::FoldL0) undo_fold_l0(op); + else undo_fold_level(op); + } 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()); + // ── digest (§8.5, OQ-4 + OQ-M5) — Merkle root, consensus-committed ──── + // CONSENSUS: intern ids are NODE-LOCAL (assigned at first global + // sighting, which interleaves differently across lanes on different + // nodes). All consensus bytes use the CANONICAL identity key + // (PayoutDescriptor identity, 32 bytes) supplied by the resolver, sorted + // by those bytes — never raw intern ids. + // + // OQ-M5 (resolved): the digest is the root of a Merkle tree over the + // canonical leaves, so lite clients can verify one accumulator entry + // (message-TTL budget) or one bucket's raw work (market settlement band) + // with a log-size proof against the on-chain commitment. Tree rule: + // leaf node = sha256d(0x00 || leaf payload) + // interior node = sha256d(0x01 || left || right) (domain-separated) + // odd node = promoted unchanged (no duplication ambiguity) + // Leaf order (fixed): [0] header leaf (geometry, B, next_pos, counts, + // L0 sums); then acc leaves in canonical-identity order; then bucket + // leaves level-by-level, oldest -> newest. + template // bytes32 resolver(MinerId) + bytes32 digest(Resolver&& id_key) const { + return merkle_root(build_leaves(id_key)); + } + + // Inclusion proof for one miner's accumulator leaf (lite-client TTL + // verification). Returns false if the miner has no acc entry. + struct MerkleProof { + u64 leaf_count = 0; + u64 index = 0; + std::vector path; // siblings bottom-up; promote-odd levels + // contribute no entry (structure is + // derivable from leaf_count + index) + }; + template + bool acc_proof(MinerId m, Resolver&& id_key, bytes32& leaf_out, + MerkleProof& proof_out) const { + if (!m_acc.count(m)) return false; + auto leaves = build_leaves(id_key); + // locate the miner's acc leaf: header is leaf 0; acc leaves follow + // in canonical order — recompute its leaf hash and find it. + bytes32 key = id_key(m); + std::vector payload; + append_bytes(payload, "V37A", 4); + payload.insert(payload.end(), key.begin(), key.end()); + append_u256(payload, m_acc.at(m)); + bytes32 target = leaf_hash(payload); + u64 idx = 0; + for (; idx < leaves.size(); ++idx) + if (leaves[idx] == target) break; + if (idx == leaves.size()) return false; + leaf_out = target; + proof_out = make_proof(leaves, idx); + return true; + } + + // Stateless verifier (what a lite client runs against the committed + // root): recomputes the path using only leaf_count/index for structure. + static bool verify_proof(const bytes32& root, const bytes32& leaf, + const MerkleProof& p) { + bytes32 h = leaf; + u64 idx = p.index, size = p.leaf_count; + std::size_t used = 0; + if (idx >= size || size == 0) return false; + while (size > 1) { + bool odd_last = (size & 1) && idx == size - 1; + if (!odd_last) { + if (used >= p.path.size()) return false; + const bytes32& sib = p.path[used++]; + h = (idx & 1) ? interior_hash(sib, h) : interior_hash(h, sib); } + idx /= 2; + size = (size + 1) / 2; } - return sha256d(buf); + return used == p.path.size() && h == root; } // ── OQ-2 exact epoch rebuild, exposed for tests/reference checks ────── @@ -202,6 +305,9 @@ class Lane { // 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() { + if (m_next_pos - m_B != m_p.epoch_len()) + throw std::logic_error("v37: epoch_rebuild outside the epoch " + "boundary (positionally defined, §8.3)"); u64 B_new = m_B + m_p.epoch_len(); m_acc.clear(); m_acc_total = U256(); @@ -228,12 +334,14 @@ class Lane { } m_B = B_new; m_journal.clear(); // rewind cannot cross a rebuild (see notes) + Op b2; b2.type = Op::Type::RebuildBoundary; + m_journal.push_back(std::move(b2)); // sentinel: see rewind() } private: // ── journal ─────────────────────────────────────────────────────────── struct Op { - enum class Type { Push, FoldL0, FoldLevel, Evict }; + enum class Type { Push, FoldL0, FoldLevel, Evict, RebuildBoundary }; Type type; // Push L0Slot slot; @@ -255,7 +363,9 @@ class Lane { 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. + // Trim: keep ops back to (and including) the D-th most recent push, + // PLUS that push's immediately preceding folds — rewind(D) must be + // able to undo the full push() call it lands on. u64 pushes = 0; std::size_t keep_from = 0; for (std::size_t i = m_journal.size(); i-- > 0;) { @@ -263,7 +373,15 @@ class Lane { if (++pushes == m_p.journal_depth) { keep_from = i; break; } } } - if (pushes >= m_p.journal_depth && keep_from > 0) + if (pushes < m_p.journal_depth || keep_from == 0) return; + while (keep_from > 0 && + (m_journal[keep_from - 1].type == Op::Type::FoldL0 || + m_journal[keep_from - 1].type == Op::Type::FoldLevel || + m_journal[keep_from - 1].type == Op::Type::RebuildBoundary)) + --keep_from; + // the boundary sentinel is kept when adjacent: rewind() must + // still see it to refuse landing on the rebuild-triggering push + if (keep_from > 0) m_journal.erase(m_journal.begin(), m_journal.begin() + static_cast(keep_from)); } @@ -405,9 +523,6 @@ class Lane { 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))); } @@ -418,12 +533,128 @@ class Lane { 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) { + // ── Merkle digest machinery (OQ-M5) ─────────────────────────────────── + static bytes32 leaf_hash(const std::vector& payload) { + std::vector b; + b.reserve(payload.size() + 1); + b.push_back(0x00); + b.insert(b.end(), payload.begin(), payload.end()); + return sha256d(b); + } + static bytes32 interior_hash(const bytes32& l, const bytes32& r) { + std::uint8_t b[65]; + b[0] = 0x01; + std::copy(l.begin(), l.end(), b + 1); + std::copy(r.begin(), r.end(), b + 33); + return sha256d(b, 65); + } + static bytes32 merkle_root(std::vector level) { + // never empty: build_leaves always emits the header leaf + while (level.size() > 1) { + std::vector next; + next.reserve((level.size() + 1) / 2); + std::size_t i = 0; + for (; i + 1 < level.size(); i += 2) + next.push_back(interior_hash(level[i], level[i + 1])); + if (i < level.size()) next.push_back(level[i]); // promote odd + level = std::move(next); + } + return level[0]; + } + static MerkleProof make_proof(const std::vector& leaves, u64 idx) { + MerkleProof p; + p.leaf_count = static_cast(leaves.size()); + p.index = idx; + std::vector level = leaves; + while (level.size() > 1) { + bool odd_last = (level.size() & 1) && idx == level.size() - 1; + if (!odd_last) p.path.push_back(level[idx ^ 1]); + std::vector next; + next.reserve((level.size() + 1) / 2); + std::size_t i = 0; + for (; i + 1 < level.size(); i += 2) + next.push_back(interior_hash(level[i], level[i + 1])); + if (i < level.size()) next.push_back(level[i]); + level = std::move(next); + idx /= 2; + } + return p; + } + + // Canonical leaf list — the fixed order every conforming node produces. + template + std::vector build_leaves(Resolver&& id_key) const { + std::vector leaves; + u64 bucket_total = 0; + for (const auto& lvl : m_levels) bucket_total += lvl.size(); + leaves.reserve(1 + m_acc.size() + bucket_total); + { // [0] header leaf: geometry (consensus parameters — a mismatch + // becomes an attributable digest difference), position state, + // counts, and the L0 ring sums (F-1 leaves). + std::vector h; + append_bytes(h, "V37H", 4); + append_u64(h, m_p.window); + append_u64(h, m_p.c0); + append_u64(h, m_p.rollup); + append_u64(h, m_p.half_life); + append_u64(h, static_cast(m_p.level_caps.size())); + for (u64 c : m_p.level_caps) append_u64(h, c); + append_u64(h, m_B); + append_u64(h, m_next_pos); + append_u64(h, static_cast(m_acc.size())); + append_u64(h, static_cast(m_l0.size())); + append_u256(h, m_l0_scaled_sum); + append_u128(h, m_l0_raw_sum); + for (const auto& lvl : m_levels) + append_u64(h, static_cast(lvl.size())); + leaves.push_back(leaf_hash(h)); + } + { // acc leaves, canonical-identity order + std::vector> rows; + rows.reserve(m_acc.size()); + for (const auto& [m, a] : m_acc) rows.emplace_back(id_key(m), &a); + std::sort(rows.begin(), rows.end(), + [](const auto& a, const auto& b) { return a.first < b.first; }); + for (const auto& [k, a] : rows) { + std::vector b; + append_bytes(b, "V37A", 4); + b.insert(b.end(), k.begin(), k.end()); + append_u256(b, *a); + leaves.push_back(leaf_hash(b)); + } + } + // bucket leaves, level-by-level, oldest -> newest (F-1 settlement + // bands provable individually) + for (std::size_t k = 0; k < m_levels.size(); ++k) { + for (const auto& bkt : m_levels[k]) { + std::vector b; + append_bytes(b, "V37B", 4); + append_u64(b, static_cast(k)); + append_u64(b, bkt.pos_lo); + append_u64(b, bkt.pos_hi); + append_u128(b, bkt.raw_work); + append_u64(b, bkt.epoch_tag); + auto ch = comp_hash(bkt, id_key); + b.insert(b.end(), ch.begin(), ch.end()); + leaves.push_back(leaf_hash(b)); + } + } + return leaves; + } + + // Composition hash, also in canonical-identity order (see digest()). + template + static bytes32 comp_hash(const Bucket& b, Resolver&& id_key) { + std::vector> rows; + rows.reserve(b.comp.size()); + for (const auto& e : b.comp) rows.emplace_back(id_key(e.miner), &e); + std::sort(rows.begin(), rows.end(), + [](const auto& a, const auto& c) { return a.first < c.first; }); std::vector buf; - for (const auto& e : b.comp) { - append_u32(buf, e.miner); - append_u256(buf, e.scaled); - append_u128(buf, e.raw); + for (const auto& [k, e] : rows) { + buf.insert(buf.end(), k.begin(), k.end()); + append_u256(buf, e->scaled); + append_u128(buf, e->raw); } return sha256d(buf); } diff --git a/src/sharechain/v37/v37_roundabout.hpp b/src/sharechain/v37/v37_roundabout.hpp index 36e25886b..3b6b7b7d1 100644 --- a/src/sharechain/v37/v37_roundabout.hpp +++ b/src/sharechain/v37/v37_roundabout.hpp @@ -28,23 +28,33 @@ class MinerIntern { MinerId id = static_cast(m_refs.size()); m_ids.emplace(std::move(key), id); m_refs.push_back(d.pay); + m_keys.push_back(d.identity_key()); return id; } const ScriptRef& pay_ref(MinerId id) const { return m_refs.at(id); } + // Canonical identity key (S-3): the consensus-stable name of a miner. + // Intern ids are node-local and MUST NOT appear in any consensus bytes. + const bytes32& key(MinerId id) const { return m_keys.at(id); } std::size_t size() const { return m_refs.size(); } private: std::map, MinerId> m_ids; std::vector m_refs; + std::vector m_keys; }; class Roundabout { public: // O(1) directory append; no other lane is touched (§7). + // Exception-safe: the Lane is constructed BEFORE the directory entry is + // created, so a geometry-validation throw leaves the directory unchanged + // (no null entry, the chain id stays usable). 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); + if (m_lanes.count(chain)) + throw std::invalid_argument("v37: lane already exists"); + auto lane = std::make_unique(p); + auto [it, fresh] = m_lanes.emplace(chain, std::move(lane)); + (void)fresh; return *it->second; } void remove_lane(ChainId chain) { m_lanes.erase(chain); } @@ -72,6 +82,15 @@ class Roundabout { return id; } + // Lane digest with the canonical identity resolver (the only correct way + // to produce the consensus-committed digest; Lane::digest is generic so + // tests can inject resolvers, but production goes through here). + bytes32 lane_digest(ChainId chain) const { + const Lane* l = lane(chain); + if (!l) throw std::invalid_argument("v37: unknown chain"); + return l->digest([this](MinerId m) { return m_miners.key(m); }); + } + // 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).