@mikeargento/bitgraph-audit 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +8 -2
  2. package/dist/audit.d.ts +9 -6
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js +34 -8
  5. package/dist/audit.js.map +1 -1
  6. package/dist/ceilings.d.ts.map +1 -1
  7. package/dist/ceilings.js +1 -0
  8. package/dist/ceilings.js.map +1 -1
  9. package/dist/cli.js +109 -21
  10. package/dist/cli.js.map +1 -1
  11. package/dist/exports.d.ts +39 -0
  12. package/dist/exports.d.ts.map +1 -0
  13. package/dist/exports.js +374 -0
  14. package/dist/exports.js.map +1 -0
  15. package/dist/floors.d.ts +23 -0
  16. package/dist/floors.d.ts.map +1 -0
  17. package/dist/floors.js +203 -0
  18. package/dist/floors.js.map +1 -0
  19. package/dist/index.d.ts +6 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +5 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/ingest.d.ts +2 -0
  24. package/dist/ingest.d.ts.map +1 -1
  25. package/dist/ingest.js +193 -19
  26. package/dist/ingest.js.map +1 -1
  27. package/dist/report-json.d.ts.map +1 -1
  28. package/dist/report-json.js +30 -0
  29. package/dist/report-json.js.map +1 -1
  30. package/dist/report-md.d.ts.map +1 -1
  31. package/dist/report-md.js +315 -25
  32. package/dist/report-md.js.map +1 -1
  33. package/dist/temporal.d.ts +12 -0
  34. package/dist/temporal.d.ts.map +1 -1
  35. package/dist/temporal.js +245 -41
  36. package/dist/temporal.js.map +1 -1
  37. package/dist/types.d.ts +348 -7
  38. package/dist/types.d.ts.map +1 -1
  39. package/package.json +3 -2
  40. package/src/__tests__/base-floor.test.ts +405 -0
  41. package/src/audit.ts +36 -8
  42. package/src/ceilings.ts +1 -0
  43. package/src/cli.ts +117 -22
  44. package/src/exports.ts +482 -0
  45. package/src/floors.ts +242 -0
  46. package/src/index.ts +20 -0
  47. package/src/ingest.ts +207 -18
  48. package/src/report-json.ts +30 -0
  49. package/src/report-md.ts +353 -29
  50. package/src/temporal.ts +279 -48
  51. package/src/types.ts +341 -6
package/src/temporal.ts CHANGED
@@ -47,6 +47,18 @@
47
47
  * are evidence, never divergence; overlapping or absent bounds mean
48
48
  * concurrent-or-unordered.
49
49
  *
50
+ * Base floors (enclave v10). A proof that signs a Base block as its floor
51
+ * (commit.slotFloor, read through floors.ts and signedFloorOf) is bounded
52
+ * NOT-BEFORE by that block, with evidence "signed-floor": the proof's own
53
+ * signed field. Proofs after it in the chain inherit the bound like an
54
+ * anchor's ("chain-link", or "counter-order", weaker). A floor never gives a
55
+ * not-after, to any proof: a later proof's floor block can predate this
56
+ * proof. Floors count toward an epoch's lower-bound coverage; cross-epoch
57
+ * ordering pairs stay anchor-only (a pair needs a not-after, which only an
58
+ * anchor supplies, and the after side is read from anchor bounds alone, so
59
+ * no ordering is ever claimed from floors). Proofs without slotFloor are not
60
+ * touched: a bundle from before the cutover yields exactly the bounds it did.
61
+ *
50
62
  * Run after verifyObservedProofs, reconstructChains, identifyAnchors, and
51
63
  * verifyAnchorWitnesses. Populates EpochRecord.anchorBounds on the given
52
64
  * reconstruction (the typed Phase 4c extension point) and returns the
@@ -68,6 +80,7 @@ import type {
68
80
  TemporalSegment,
69
81
  } from "./types.js";
70
82
  import { byCounterThenHash, parseCounter, pushMap } from "./validity.js";
83
+ import { baseFloorTimeSentence, readSignedFloors, type FloorEvidence } from "./floors.js";
71
84
 
72
85
  // ---------------------------------------------------------------------------
73
86
  // Public API
@@ -102,20 +115,28 @@ export function deriveTemporalBounds(
102
115
  .filter((h) => !evidence.has(h))
103
116
  .sort();
104
117
 
118
+ // Base floors the proofs sign (enclave v10). Empty for older bundles.
119
+ const floors = readSignedFloors(ingest);
120
+
105
121
  // Per-partition segments.
106
122
  const segments: TemporalSegment[] = [];
123
+ const anchorLower = new Map<string, SegmentBound[]>();
107
124
  for (const partition of reconstruction.partitions) {
108
- segments.push(...buildPartitionSegments(partition, byHash, evidence));
125
+ const built = buildPartitionSegments(partition, byHash, evidence, floors.bounds);
126
+ segments.push(...built.segments);
127
+ for (const [h, b] of built.anchorLower) anchorLower.set(h, b);
109
128
  }
110
129
 
111
130
  // Epoch-level aggregation and cross-epoch ordering.
112
- const anchorOrderedPairs = aggregateEpochs(reconstruction, segments);
131
+ const anchorOrderedPairs = aggregateEpochs(reconstruction, segments, anchorLower);
113
132
 
114
133
  return {
115
134
  segments,
116
135
  anchorOrderedPairs,
117
136
  verifiedAnchorProofHashes,
118
137
  unverifiedAnchorProofHashes,
138
+ ...(floors.records.length > 0 ? { signedFloors: floors.records } : {}),
139
+ ...(floors.problems.length > 0 ? { floorProblems: floors.problems } : {}),
119
140
  };
120
141
  }
121
142
 
@@ -124,6 +145,32 @@ interface VerifiedAnchorEvidence {
124
145
  timestamp: number;
125
146
  blockHash: string;
126
147
  blockNumber?: string;
148
+ /** Present for a Base floor: how its time was read. Absent for an anchor. */
149
+ floor?: { timeSource: "header" | "signed" };
150
+ }
151
+
152
+ /** Tie-break strength of bound evidence at an equal block time. */
153
+ const EVIDENCE_RANK: Record<BoundEvidence, number> = { "signed-floor": 2, "chain-link": 1, "counter-order": 0 };
154
+
155
+ /** Candidate key of a Base floor: kept apart from anchor keys (a proof hash). */
156
+ const FLOOR_KEY = "floor:";
157
+
158
+ function floorSource(f: FloorEvidence): VerifiedAnchorEvidence {
159
+ return {
160
+ anchorProofHash: f.proofHash,
161
+ timestamp: f.timestamp,
162
+ blockHash: f.blockHash,
163
+ blockNumber: String(f.blockNumber),
164
+ floor: { timeSource: f.timeSource },
165
+ };
166
+ }
167
+
168
+ /** The tighter of two floors (later block time; at a tie, the lower proof hash, for determinism). */
169
+ function laterFloor(a: FloorEvidence | undefined, b: FloorEvidence | undefined): FloorEvidence | undefined {
170
+ if (a === undefined) return b;
171
+ if (b === undefined) return a;
172
+ if (a.timestamp !== b.timestamp) return a.timestamp > b.timestamp ? a : b;
173
+ return a.proofHash <= b.proofHash ? a : b;
127
174
  }
128
175
 
129
176
  // ---------------------------------------------------------------------------
@@ -139,15 +186,26 @@ interface MemberBounds {
139
186
  function buildPartitionSegments(
140
187
  partition: ChainPartition,
141
188
  byHash: Map<string, ObservedProof>,
142
- evidence: Map<string, VerifiedAnchorEvidence>
143
- ): TemporalSegment[] {
189
+ anchorEvidence: Map<string, VerifiedAnchorEvidence>,
190
+ floorBounds: Map<string, FloorEvidence>
191
+ ): { segments: TemporalSegment[]; anchorLower: Map<string, SegmentBound[]> } {
144
192
  const members = partition.memberProofHashes.map((h) => byHash.get(h) as ObservedProof);
145
193
  // Keyed by CHAIN hash: this map is used only to resolve prevB64 pointers
146
194
  // (here and in collectAncestors), and prevB64 references the predecessor's
147
195
  // chain hash, not its identity hash.
148
196
  const memberSet = new Map<string, ObservedProof>(members.map((m) => [m.chainHash, m]));
149
197
 
150
- const anchors = members.filter((m) => evidence.has(m.proofHash));
198
+ const anchors = members.filter((m) => anchorEvidence.has(m.proofHash));
199
+
200
+ // Base floors signed in this partition. Floor sources join the evidence
201
+ // map under their own keys, so selection treats a floor exactly as it
202
+ // treats an anchor's not-before.
203
+ const floored = members.filter((m) => floorBounds.has(m.proofHash));
204
+ let evidence = anchorEvidence;
205
+ if (floored.length > 0) {
206
+ evidence = new Map(anchorEvidence);
207
+ for (const m of floored) evidence.set(FLOOR_KEY + m.proofHash, floorSource(floorBounds.get(m.proofHash) as FloorEvidence));
208
+ }
151
209
 
152
210
  // Hash-link structure within the partition.
153
211
  const successors = new Map<string, ObservedProof[]>();
@@ -167,6 +225,58 @@ function buildPartitionSegments(
167
225
  chainBefore.set(anchor.proofHash, collectAncestors(anchor, memberSet));
168
226
  }
169
227
 
228
+ // Base floors reach a member three ways: its own signed floor; the
229
+ // tightest floor among its hash-link ancestors (chain-link); the tightest
230
+ // floor signed at a lower commit counter (counter-order). Both inherited
231
+ // forms are computed in one pass each, never per pair.
232
+ const chainFloor = new Map<string, FloorEvidence | undefined>();
233
+ const counterFloor = new Map<string, FloorEvidence | undefined>();
234
+ if (floored.length > 0) {
235
+ const pred = (m: ObservedProof): ObservedProof | undefined => {
236
+ if (m.prevB64 === undefined) return undefined;
237
+ const p = memberSet.get(m.prevB64);
238
+ return p === undefined || p.proofHash === m.proofHash ? undefined : p;
239
+ };
240
+ for (const m of members) {
241
+ const path: ObservedProof[] = [];
242
+ const seen = new Set<string>();
243
+ let cur: ObservedProof | undefined = m;
244
+ while (cur !== undefined && !chainFloor.has(cur.proofHash) && !seen.has(cur.proofHash)) {
245
+ seen.add(cur.proofHash);
246
+ path.push(cur);
247
+ cur = pred(cur);
248
+ }
249
+ let carry =
250
+ cur !== undefined && chainFloor.has(cur.proofHash)
251
+ ? laterFloor(chainFloor.get(cur.proofHash), floorBounds.get(cur.proofHash))
252
+ : undefined;
253
+ for (let i = path.length - 1; i >= 0; i--) {
254
+ const node = path[i] as ObservedProof;
255
+ chainFloor.set(node.proofHash, carry);
256
+ carry = laterFloor(carry, floorBounds.get(node.proofHash));
257
+ }
258
+ }
259
+ const counted = members
260
+ .map((m) => ({ m, c: parseCounter(m.counter) }))
261
+ .filter((x): x is { m: ObservedProof; c: bigint } => x.c !== undefined)
262
+ .sort((a, b) => (a.c < b.c ? -1 : a.c > b.c ? 1 : 0));
263
+ let running: FloorEvidence | undefined;
264
+ for (let i = 0; i < counted.length; ) {
265
+ let j = i;
266
+ while (j < counted.length && (counted[j] as { c: bigint }).c === (counted[i] as { c: bigint }).c) j++;
267
+ let group: FloorEvidence | undefined;
268
+ for (let k = i; k < j; k++) {
269
+ const m = (counted[k] as { m: ObservedProof }).m;
270
+ counterFloor.set(m.proofHash, running);
271
+ group = laterFloor(group, floorBounds.get(m.proofHash));
272
+ }
273
+ running = laterFloor(running, group);
274
+ i = j;
275
+ }
276
+ }
277
+ const mixed = floored.length > 0 && anchors.length > 0;
278
+ const anchorLower = new Map<string, SegmentBound[]>();
279
+
170
280
  // Candidate bounds per member.
171
281
  const memberBounds: MemberBounds[] = members.map((member) => {
172
282
  const lowerCandidates = new Map<string, BoundEvidence>();
@@ -205,6 +315,26 @@ function buildPartitionSegments(
205
315
  }
206
316
  }
207
317
 
318
+ // A partition holding both anchors and floors keeps each member's
319
+ // anchor-only not-before apart, for the cross-epoch pairs.
320
+ if (mixed && lowerCandidates.size > 0) {
321
+ anchorLower.set(member.proofHash, selectBounds("not-before", lowerCandidates, evidence));
322
+ }
323
+
324
+ // Base floors: not-before only. Never an upper candidate.
325
+ if (floored.length > 0) {
326
+ const own = floorBounds.get(member.proofHash);
327
+ if (own !== undefined) lowerCandidates.set(FLOOR_KEY + own.proofHash, "signed-floor");
328
+ const viaChain = chainFloor.get(member.proofHash);
329
+ if (viaChain !== undefined && !lowerCandidates.has(FLOOR_KEY + viaChain.proofHash)) {
330
+ lowerCandidates.set(FLOOR_KEY + viaChain.proofHash, "chain-link");
331
+ }
332
+ const viaCounter = counterFloor.get(member.proofHash);
333
+ if (viaCounter !== undefined && !lowerCandidates.has(FLOOR_KEY + viaCounter.proofHash)) {
334
+ lowerCandidates.set(FLOOR_KEY + viaCounter.proofHash, "counter-order");
335
+ }
336
+ }
337
+
208
338
  return {
209
339
  member,
210
340
  lower: selectBounds("not-before", lowerCandidates, evidence),
@@ -245,7 +375,7 @@ function buildPartitionSegments(
245
375
  }
246
376
 
247
377
  segments.sort(compareSegments);
248
- return segments;
378
+ return { segments, anchorLower };
249
379
  }
250
380
 
251
381
  /**
@@ -277,8 +407,9 @@ function selectBounds(
277
407
  ? a.info.timestamp > b.info.timestamp
278
408
  : a.info.timestamp < b.info.timestamp;
279
409
  }
280
- // Tie: prefer chain-link evidence, then the lower anchor hash.
281
- if (a.evidenceKind !== b.evidenceKind) return a.evidenceKind === "chain-link";
410
+ // Tie: prefer the proof's own signed floor, then chain-link evidence over
411
+ // counter order, then the lower key.
412
+ if (a.evidenceKind !== b.evidenceKind) return EVIDENCE_RANK[a.evidenceKind] > EVIDENCE_RANK[b.evidenceKind];
282
413
  return a.anchorHash < b.anchorHash;
283
414
  };
284
415
 
@@ -286,15 +417,11 @@ function selectBounds(
286
417
  let bestChain: (typeof entries)[number] | undefined;
287
418
  for (const entry of entries) {
288
419
  if (better(entry, best)) best = entry;
289
- if (entry.evidenceKind === "chain-link" && better(entry, bestChain)) bestChain = entry;
420
+ if (entry.evidenceKind !== "counter-order" && better(entry, bestChain)) bestChain = entry;
290
421
  }
291
422
 
292
423
  const bounds: SegmentBound[] = [makeBound(kind, best as (typeof entries)[number])];
293
- if (
294
- bestChain !== undefined &&
295
- ((best as (typeof entries)[number]).anchorHash !== bestChain.anchorHash ||
296
- (best as (typeof entries)[number]).evidenceKind !== "chain-link")
297
- ) {
424
+ if (bestChain !== undefined && bestChain !== best) {
298
425
  bounds.push(makeBound(kind, bestChain));
299
426
  }
300
427
  return bounds;
@@ -305,6 +432,7 @@ function makeBound(
305
432
  entry: { anchorHash: string; evidenceKind: BoundEvidence; info: VerifiedAnchorEvidence }
306
433
  ): SegmentBound {
307
434
  const { info, evidenceKind } = entry;
435
+ if (info.floor !== undefined) return makeFloorBound(entry);
308
436
  const iso = new Date(info.timestamp * 1000).toISOString();
309
437
  const blockName =
310
438
  info.blockNumber !== undefined ? `Ethereum block ${info.blockNumber}` : "an Ethereum block";
@@ -342,9 +470,48 @@ function makeBound(
342
470
  };
343
471
  }
344
472
 
473
+ /**
474
+ * A not-before from a Base floor (enclave v10). The proof that signs the
475
+ * floor block is named in anchorProofHash; source, chain and timeSource say
476
+ * what it is. Always evidence, never an assumption, and never a not-after.
477
+ */
478
+ function makeFloorBound(entry: { anchorHash: string; evidenceKind: BoundEvidence; info: VerifiedAnchorEvidence }): SegmentBound {
479
+ const { info, evidenceKind } = entry;
480
+ const timeSource = (info.floor as { timeSource: "header" | "signed" }).timeSource;
481
+ const iso = new Date(info.timestamp * 1000).toISOString();
482
+ const evidenceSentence =
483
+ evidenceKind === "signed-floor"
484
+ ? "Evidence: the proof signs this block as its floor (commit.slotFloor)."
485
+ : evidenceKind === "chain-link"
486
+ ? "Evidence: a verified hash-link path connects these proofs to a proof that signs this block as its floor."
487
+ : "Evidence: commit-counter ordering within the partition only, after a proof that signs this block as its " +
488
+ "floor; this relies on the authority's counter discipline rather than verifiable hash links (weaker).";
489
+ const claim =
490
+ `These proofs were committed no earlier than ${iso} (unix ${info.timestamp}), the time of Base block ` +
491
+ `${info.blockNumber}: the block hash was unpredictable before that time and the floor proof signs it. ` +
492
+ `${baseFloorTimeSentence(info.blockNumber as string, info.timestamp, timeSource)} Reading this as a wall-clock ` +
493
+ `floor additionally assumes the block is a genuine, publicly published Base block, which this offline audit ` +
494
+ `cannot confirm. A floor is never an upper bound on anything. ${evidenceSentence}`;
495
+ return {
496
+ kind: "not-before",
497
+ anchorProofHash: info.anchorProofHash,
498
+ source: "signed-floor",
499
+ chain: "base",
500
+ timeSource,
501
+ ...(info.blockNumber !== undefined ? { blockNumber: info.blockNumber } : {}),
502
+ blockHash: info.blockHash,
503
+ timestamp: info.timestamp,
504
+ evidence: evidenceKind,
505
+ weaker: evidenceKind === "counter-order",
506
+ basis: "block-hash-unpredictability",
507
+ boundClass: "evidence",
508
+ claim,
509
+ };
510
+ }
511
+
345
512
  function boundSetKey(mb: MemberBounds): string {
346
513
  const part = (bounds: SegmentBound[]): string =>
347
- bounds.map((b) => `${b.anchorProofHash}:${b.evidence}`).join(",");
514
+ bounds.map((b) => `${b.source === "signed-floor" ? FLOOR_KEY : ""}${b.anchorProofHash}:${b.evidence}`).join(",");
348
515
  return `${part(mb.lower)}|${part(mb.upper)}`;
349
516
  }
350
517
 
@@ -420,6 +587,8 @@ interface CoverageRepresentative {
420
587
  anchorProofHash: string;
421
588
  blockHash: string;
422
589
  blockNumber?: string;
590
+ /** Present when the representative is a Base floor. */
591
+ floor?: true;
423
592
  /** Evidence class of the bound chosen as the conservative representative. */
424
593
  evidence: BoundEvidence;
425
594
  /** True when that representative rests on counter-order evidence. */
@@ -428,9 +597,16 @@ interface CoverageRepresentative {
428
597
 
429
598
  interface EpochCoverage {
430
599
  totalProofCount: number;
431
- /** Distinct members covered by any not-before bound, and the most conservative (minimum) covering timestamp. */
600
+ /** Distinct members covered by any not-before bound (anchor or Base floor), and the most conservative (minimum) covering timestamp. */
432
601
  lowerCovered: Set<string>;
433
602
  lowerMin?: CoverageRepresentative;
603
+ /** Whether any not-before bound counted here is a Base floor (changes the claim's wording only). */
604
+ lowerFromFloors: boolean;
605
+ /** Whether any not-before bound counted here is an anchor. */
606
+ lowerFromAnchors: boolean;
607
+ /** The same, from anchor bounds alone: the after side of a cross-epoch pair. Floors never order epochs. */
608
+ anchorLowerCovered: Set<string>;
609
+ anchorLowerMin?: CoverageRepresentative;
434
610
  /** Distinct members covered by any not-after bound, and the most conservative (maximum) covering timestamp. */
435
611
  upperCovered: Set<string>;
436
612
  upperMax?: CoverageRepresentative;
@@ -457,15 +633,69 @@ function weakerCaveat(weaker: boolean): string {
457
633
  : "";
458
634
  }
459
635
 
636
+ /** Fold one not-before bound into a coverage minimum, preferring stronger evidence at an equal time. */
637
+ function foldLower(current: CoverageRepresentative | undefined, bound: SegmentBound): CoverageRepresentative {
638
+ if (
639
+ current === undefined ||
640
+ bound.timestamp < current.timestamp ||
641
+ (bound.timestamp === current.timestamp && current.weaker && !bound.weaker)
642
+ ) {
643
+ return {
644
+ timestamp: bound.timestamp,
645
+ anchorProofHash: bound.anchorProofHash,
646
+ blockHash: bound.blockHash,
647
+ ...(bound.blockNumber !== undefined ? { blockNumber: bound.blockNumber } : {}),
648
+ ...(bound.source === "signed-floor" ? { floor: true as const } : {}),
649
+ evidence: bound.evidence,
650
+ weaker: bound.weaker,
651
+ };
652
+ }
653
+ return current;
654
+ }
655
+
656
+ /** The epoch-level not-before claim, worded for what grounds it. Pure-anchor wording is unchanged. */
657
+ function lowerClaim(cov: EpochCoverage): string {
658
+ const min = cov.lowerMin as CoverageRepresentative;
659
+ if (!cov.lowerFromFloors) {
660
+ return (
661
+ `${cov.lowerCovered.size} of ${cov.totalProofCount} observed proofs of this epoch were ` +
662
+ `committed no earlier than unix ${min.timestamp}, grounded in block-hash ` +
663
+ `unpredictability through verified anchor witnesses. This additionally assumes the anchored ` +
664
+ `header is a genuine, publicly published Ethereum block, which this offline audit cannot ` +
665
+ `confirm.` +
666
+ weakerCaveat(min.weaker) +
667
+ ` The remaining proofs sit causally before the covering anchors and carry no lower bound ` +
668
+ `from this evidence.`
669
+ );
670
+ }
671
+ const grounds = cov.lowerFromAnchors
672
+ ? "the Base blocks the proofs sign as their floors and verified Ethereum anchor witnesses"
673
+ : "the Base blocks the proofs sign as their floors";
674
+ const blocks = cov.lowerFromAnchors ? "Base or Ethereum block" : "Base block";
675
+ return (
676
+ `${cov.lowerCovered.size} of ${cov.totalProofCount} observed proofs of this epoch were ` +
677
+ `committed no earlier than unix ${min.timestamp} (${min.floor ? `Base block ${min.blockNumber}` : `Ethereum block ${min.blockNumber ?? "(unnumbered)"}`}), ` +
678
+ `grounded in block-hash unpredictability through ${grounds}. This additionally assumes each ` +
679
+ `${blocks} is a genuine, publicly published one, which this offline audit cannot confirm.` +
680
+ weakerCaveat(min.weaker) +
681
+ ` The remaining proofs sign no Base floor and follow no covering floor or anchor; they carry no ` +
682
+ `lower bound from this evidence. A floor is a lower bound only: it orders no epoch.`
683
+ );
684
+ }
685
+
460
686
  function aggregateEpochs(
461
687
  reconstruction: ReconstructionResult,
462
- segments: TemporalSegment[]
688
+ segments: TemporalSegment[],
689
+ anchorLower: Map<string, SegmentBound[]>
463
690
  ): AnchorOrderedPair[] {
464
691
  const coverage = new Map<string, EpochCoverage>();
465
692
  for (const epoch of reconstruction.epochRelationships.epochs) {
466
693
  coverage.set(epoch.epochId, {
467
694
  totalProofCount: epoch.proofCount,
468
695
  lowerCovered: new Set(),
696
+ lowerFromFloors: false,
697
+ lowerFromAnchors: false,
698
+ anchorLowerCovered: new Set(),
469
699
  upperCovered: new Set(),
470
700
  });
471
701
  }
@@ -483,19 +713,25 @@ function aggregateEpochs(
483
713
  // never marked weaker when a hash-link bound justifies the same time.
484
714
  for (const bound of segment.lowerBounds) {
485
715
  for (const h of segment.memberProofHashes) cov.lowerCovered.add(h);
486
- if (
487
- cov.lowerMin === undefined ||
488
- bound.timestamp < cov.lowerMin.timestamp ||
489
- (bound.timestamp === cov.lowerMin.timestamp && cov.lowerMin.weaker && !bound.weaker)
490
- ) {
491
- cov.lowerMin = {
492
- timestamp: bound.timestamp,
493
- anchorProofHash: bound.anchorProofHash,
494
- blockHash: bound.blockHash,
495
- ...(bound.blockNumber !== undefined ? { blockNumber: bound.blockNumber } : {}),
496
- evidence: bound.evidence,
497
- weaker: bound.weaker,
498
- };
716
+ if (bound.source === "signed-floor") cov.lowerFromFloors = true;
717
+ else cov.lowerFromAnchors = true;
718
+ cov.lowerMin = foldLower(cov.lowerMin, bound);
719
+ }
720
+ // Anchor-only coverage, for the pairs. In a partition holding both
721
+ // anchors and floors a member's anchor bound may have been displaced
722
+ // from its segment by a tighter floor, so it is read per member there.
723
+ if (segment.memberProofHashes.some((h) => anchorLower.has(h))) {
724
+ for (const h of segment.memberProofHashes) {
725
+ for (const bound of anchorLower.get(h) ?? []) {
726
+ cov.anchorLowerCovered.add(h);
727
+ cov.anchorLowerMin = foldLower(cov.anchorLowerMin, bound);
728
+ }
729
+ }
730
+ } else {
731
+ for (const bound of segment.lowerBounds) {
732
+ if (bound.source === "signed-floor") continue;
733
+ for (const h of segment.memberProofHashes) cov.anchorLowerCovered.add(h);
734
+ cov.anchorLowerMin = foldLower(cov.anchorLowerMin, bound);
499
735
  }
500
736
  }
501
737
  // Maximum timestamp for not-after, same chain-link tie preference.
@@ -526,6 +762,7 @@ function aggregateEpochs(
526
762
  bounds.push({
527
763
  kind: "not-before",
528
764
  anchorProofHash: cov.lowerMin.anchorProofHash,
765
+ ...(cov.lowerMin.floor ? { source: "signed-floor" as const, chain: "base" as const } : {}),
529
766
  ...(cov.lowerMin.blockNumber !== undefined ? { blockNumber: cov.lowerMin.blockNumber } : {}),
530
767
  blockHash: cov.lowerMin.blockHash,
531
768
  witnessTimestamp: cov.lowerMin.timestamp,
@@ -535,15 +772,7 @@ function aggregateEpochs(
535
772
  basis: "block-hash-unpredictability",
536
773
  evidence: cov.lowerMin.evidence,
537
774
  weaker: cov.lowerMin.weaker,
538
- claim:
539
- `${cov.lowerCovered.size} of ${cov.totalProofCount} observed proofs of this epoch were ` +
540
- `committed no earlier than unix ${cov.lowerMin.timestamp}, grounded in block-hash ` +
541
- `unpredictability through verified anchor witnesses. This additionally assumes the anchored ` +
542
- `header is a genuine, publicly published Ethereum block, which this offline audit cannot ` +
543
- `confirm.` +
544
- weakerCaveat(cov.lowerMin.weaker) +
545
- ` The remaining proofs sit causally before the covering anchors and carry no lower bound ` +
546
- `from this evidence.`,
775
+ claim: lowerClaim(cov),
547
776
  });
548
777
  }
549
778
  if (cov.upperMax !== undefined) {
@@ -583,18 +812,20 @@ function aggregateEpochs(
583
812
  for (const after of epochs) {
584
813
  if (after.epochId === before.epochId) continue;
585
814
  const covB = coverage.get(after.epochId) as EpochCoverage;
586
- if (covB.lowerMin === undefined) continue;
587
- if (covA.upperMax.timestamp >= covB.lowerMin.timestamp) continue;
815
+ // The after side is read from anchor bounds alone: floors order no epoch.
816
+ const lowerB = covB.anchorLowerMin;
817
+ if (lowerB === undefined) continue;
818
+ if (covA.upperMax.timestamp >= lowerB.timestamp) continue;
588
819
  const upperEvidence = covA.upperMax.evidence;
589
- const lowerEvidence = covB.lowerMin.evidence;
590
- const weaker = covA.upperMax.weaker || covB.lowerMin.weaker;
820
+ const lowerEvidence = lowerB.evidence;
821
+ const weaker = covA.upperMax.weaker || lowerB.weaker;
591
822
  pairs.push({
592
823
  beforeEpochId: before.epochId,
593
824
  afterEpochId: after.epochId,
594
825
  upperAnchorProofHash: covA.upperMax.anchorProofHash,
595
826
  upperBoundTimestamp: covA.upperMax.timestamp,
596
- lowerAnchorProofHash: covB.lowerMin.anchorProofHash,
597
- lowerBoundTimestamp: covB.lowerMin.timestamp,
827
+ lowerAnchorProofHash: lowerB.anchorProofHash,
828
+ lowerBoundTimestamp: lowerB.timestamp,
598
829
  basis: "anchor-bounds",
599
830
  assumptionDependent: true,
600
831
  upperEvidence,
@@ -602,15 +833,15 @@ function aggregateEpochs(
602
833
  weaker,
603
834
  beforeCoveredProofCount: covA.upperCovered.size,
604
835
  beforeTotalProofCount: covA.totalProofCount,
605
- afterCoveredProofCount: covB.lowerCovered.size,
836
+ afterCoveredProofCount: covB.anchorLowerCovered.size,
606
837
  afterTotalProofCount: covB.totalProofCount,
607
838
  note:
608
839
  `The anchor-covered portion of epoch ${before.epochId} ` +
609
840
  `(${covA.upperCovered.size} of ${covA.totalProofCount} proofs) precedes the ` +
610
841
  `anchor-covered portion of epoch ${after.epochId} ` +
611
- `(${covB.lowerCovered.size} of ${covB.totalProofCount} proofs): the first is bounded ` +
842
+ `(${covB.anchorLowerCovered.size} of ${covB.totalProofCount} proofs): the first is bounded ` +
612
843
  `not-after unix ${covA.upperMax.timestamp} and the second not-before unix ` +
613
- `${covB.lowerMin.timestamp}. One-sided evidence about the covered portions only; the ` +
844
+ `${lowerB.timestamp}. One-sided evidence about the covered portions only; the ` +
614
845
  `not-after side rests on the anchor-freshness assumption documented on its bound.` +
615
846
  (weaker
616
847
  ? ` At least one side rests on commit-counter ordering rather than a verified hash-link ` +