@mikeargento/bitgraph-audit 0.9.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 (49) hide show
  1. package/README.md +5 -3
  2. package/dist/audit.d.ts +4 -3
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js +9 -3
  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 +72 -19
  10. package/dist/cli.js.map +1 -1
  11. package/dist/exports.d.ts.map +1 -1
  12. package/dist/exports.js +6 -3
  13. package/dist/exports.js.map +1 -1
  14. package/dist/floors.d.ts +23 -0
  15. package/dist/floors.d.ts.map +1 -0
  16. package/dist/floors.js +203 -0
  17. package/dist/floors.js.map +1 -0
  18. package/dist/index.d.ts +4 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +3 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/ingest.d.ts +2 -0
  23. package/dist/ingest.d.ts.map +1 -1
  24. package/dist/ingest.js +14 -0
  25. package/dist/ingest.js.map +1 -1
  26. package/dist/report-json.d.ts.map +1 -1
  27. package/dist/report-json.js +15 -0
  28. package/dist/report-json.js.map +1 -1
  29. package/dist/report-md.js +95 -27
  30. package/dist/report-md.js.map +1 -1
  31. package/dist/temporal.d.ts +12 -0
  32. package/dist/temporal.d.ts.map +1 -1
  33. package/dist/temporal.js +245 -41
  34. package/dist/temporal.js.map +1 -1
  35. package/dist/types.d.ts +136 -5
  36. package/dist/types.d.ts.map +1 -1
  37. package/package.json +3 -2
  38. package/src/__tests__/base-floor.test.ts +405 -0
  39. package/src/audit.ts +9 -3
  40. package/src/ceilings.ts +1 -0
  41. package/src/cli.ts +80 -20
  42. package/src/exports.ts +7 -3
  43. package/src/floors.ts +242 -0
  44. package/src/index.ts +8 -0
  45. package/src/ingest.ts +15 -0
  46. package/src/report-json.ts +15 -0
  47. package/src/report-md.ts +109 -31
  48. package/src/temporal.ts +279 -48
  49. package/src/types.ts +140 -6
package/src/floors.ts ADDED
@@ -0,0 +1,242 @@
1
+ // Copyright (c) 2024-2026 Argento Computing Inc. Licensed under the MIT License. See LICENSE.
2
+
3
+ /**
4
+ * bitgraph-audit signed floors (enclave v10)
5
+ *
6
+ * From enclave v10 a proof's floor is a Base block the enclave signs into the
7
+ * proof as commit.slotFloor { chain: "base", evmChainId: 8453, blockNumber,
8
+ * blockHash, blockTimestamp }, one per proof, instead of an Ethereum anchor
9
+ * (commit.slotAnchor, read through the anchor proofs of the chain). Every
10
+ * floor is read through signedFloorOf from bitgraph-verify, which refuses a
11
+ * proof that signs both kinds: such a proof is ambiguous and nothing is read
12
+ * from it as a floor.
13
+ *
14
+ * What a Base floor gives the temporal stage: a NOT-BEFORE for the proof that
15
+ * signs it, grounded in block-hash unpredictability (the block hash did not
16
+ * exist before the block, and the proof signs it). Never a not-after: a later
17
+ * proof's floor block can predate this proof, so a floor bounds nothing
18
+ * before it.
19
+ *
20
+ * The block time. Base mainnet stamps every block by its height, and the
21
+ * enclave signs the time it read from a header it hashed itself. When the
22
+ * bundle carries that header (a floor header file, bitgraph-floor-header/1,
23
+ * as the carrier/3 unpacker writes it; or the floor inside a ceiling file or
24
+ * an export) it is checked with checkFloorHeader: keccak-256 to the signed
25
+ * hash, the signed number and time, Base mainnet's schedule. Without one the
26
+ * time is the signed one, which must still be on Base mainnet's schedule for
27
+ * the signed number, and confirming the block itself needs a Base lookup,
28
+ * which this offline audit never makes.
29
+ *
30
+ * A floor stamped after the proof's own attestation document is withheld as a
31
+ * bound (floorTimeIsBound): the record did not exist before that block, but
32
+ * the block's time cannot be a "not before" for a record already attested.
33
+ *
34
+ * Proofs with no slotFloor are not read here at all: bundles from before the
35
+ * cutover audit exactly as before.
36
+ */
37
+
38
+ import {
39
+ baseHeaderFields,
40
+ checkFloorHeader,
41
+ evmHexToBytes,
42
+ floorTimeIsBound,
43
+ onBaseSchedule,
44
+ signedFloorOf,
45
+ } from "@mikeargento/bitgraph-verify";
46
+ import type { SignedFloor } from "@mikeargento/bitgraph-verify";
47
+ import { attestationTimestampMs } from "./attestation.js";
48
+ import type { FloorProblem, IngestResult, ObservedProof, SignedFloorRecord } from "./types.js";
49
+
50
+ /** A Base floor that bounds the proof signing it. */
51
+ export interface FloorEvidence {
52
+ /** The proof that signs the floor. */
53
+ proofHash: string;
54
+ blockNumber: number;
55
+ blockHash: string;
56
+ /** Unix seconds: the signed time, equal to the checked header's when one was carried. */
57
+ timestamp: number;
58
+ timeSource: "header" | "signed";
59
+ }
60
+
61
+ export interface FloorReading {
62
+ /** Base floors usable as not-before bounds, keyed by the proof that signs them. */
63
+ bounds: Map<string, FloorEvidence>;
64
+ /** Every Base floor a proof signs, bounding or withheld, in observation order. */
65
+ records: SignedFloorRecord[];
66
+ problems: FloorProblem[];
67
+ }
68
+
69
+ interface HeaderCandidate {
70
+ path: string;
71
+ raw: Uint8Array;
72
+ /** Only floor header files are judged here; ceilings and exports are judged by their own stages. */
73
+ fromFloorFile: boolean;
74
+ }
75
+
76
+ function record(value: unknown): Record<string, unknown> | null {
77
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
78
+ }
79
+
80
+ function iso(unix: number): string {
81
+ return new Date(unix * 1000).toISOString();
82
+ }
83
+
84
+ function firstPath(p: ObservedProof): string | undefined {
85
+ return p.sources[0]?.path;
86
+ }
87
+
88
+ /** Read every Base floor the bundle's proofs sign, and the headers that check them. */
89
+ export function readSignedFloors(ingest: IngestResult): FloorReading {
90
+ const problems: FloorProblem[] = [];
91
+ const records: SignedFloorRecord[] = [];
92
+ const bounds = new Map<string, FloorEvidence>();
93
+
94
+ // The Base floors the proofs sign, by block hash (to match header files).
95
+ const signedHashes = new Set<string>();
96
+ for (const p of ingest.proofs) {
97
+ const f = p.proof.commit?.slotFloor as { blockHash?: unknown } | undefined;
98
+ if (f !== undefined && typeof f.blockHash === "string") signedHashes.add(f.blockHash.toLowerCase());
99
+ }
100
+
101
+ // Headers in the bundle, by the hash their bytes compute to.
102
+ const headers = new Map<string, HeaderCandidate[]>();
103
+ const add = (hash: string, c: HeaderCandidate): void => {
104
+ const list = headers.get(hash) ?? [];
105
+ list.push(c);
106
+ headers.set(hash, list);
107
+ };
108
+ for (const file of ingest.floorHeaders ?? []) {
109
+ const json = file.json;
110
+ const header = json["header"];
111
+ const fields = typeof header === "string" ? baseHeaderFields(header) : null;
112
+ if (json["chain"] !== "base" || fields === null) {
113
+ problems.push({
114
+ code: "floor-header-malformed",
115
+ path: file.path,
116
+ message:
117
+ json["chain"] !== "base"
118
+ ? `the floor header file names chain ${JSON.stringify(json["chain"])}; only a Base header (chain "base") is a floor header`
119
+ : "the floor header file carries no readable Base block header",
120
+ });
121
+ continue;
122
+ }
123
+ const declared = typeof json["blockHash"] === "string" ? (json["blockHash"] as string).toLowerCase() : undefined;
124
+ if (declared !== undefined && declared !== fields.blockHash) {
125
+ problems.push({
126
+ code: "floor-header-mismatch",
127
+ path: file.path,
128
+ message: `the header in this file hashes to ${fields.blockHash}, not to the block it names (${declared})`,
129
+ });
130
+ continue;
131
+ }
132
+ if (!signedHashes.has(fields.blockHash)) {
133
+ problems.push({
134
+ code: "floor-header-unmatched",
135
+ path: file.path,
136
+ message: `the header is Base block ${fields.blockNumber} (${fields.blockHash}), which no proof in this bundle signs as its floor`,
137
+ });
138
+ continue;
139
+ }
140
+ add(fields.blockHash, { path: file.path, raw: evmHexToBytes(header as string), fromFloorFile: true });
141
+ }
142
+ // The floor a ceiling file or an export carries, when it is a Base header.
143
+ const carried = (path: string, floor: Record<string, unknown> | null, field: string): void => {
144
+ if (floor === null || floor["chain"] !== "base" || typeof floor[field] !== "string") return;
145
+ const fields = baseHeaderFields(floor[field] as string);
146
+ if (fields === null || !signedHashes.has(fields.blockHash)) return;
147
+ add(fields.blockHash, { path, raw: evmHexToBytes(floor[field] as string), fromFloorFile: false });
148
+ };
149
+ for (const c of ingest.ceilings ?? []) carried(c.path, record(c.json["floor"]), "blockHeader");
150
+ for (const e of ingest.exports ?? []) if (e.status === "ok") carried(e.path, record(e.json["floor"]), "header");
151
+
152
+ for (const p of ingest.proofs) {
153
+ const commit = p.proof.commit as unknown as Record<string, unknown> | undefined;
154
+ if (commit === undefined || commit["slotFloor"] === undefined) continue;
155
+ const path = firstPath(p);
156
+ let floor: SignedFloor | null;
157
+ try {
158
+ floor = signedFloorOf(p.proof);
159
+ } catch (e) {
160
+ const both = commit["slotAnchor"] !== undefined;
161
+ problems.push({
162
+ code: both ? "floor-ambiguous" : "floor-malformed",
163
+ proofHash: p.proofHash,
164
+ ...(path !== undefined ? { path } : {}),
165
+ message: both
166
+ ? "the proof signs two floors (commit.slotAnchor, an Ethereum anchor, and commit.slotFloor, a Base block); it is ambiguous and neither is read as its floor"
167
+ : `the proof's commit.slotFloor is not a Base floor: ${(e as Error).message}`,
168
+ });
169
+ continue;
170
+ }
171
+ if (floor === null || floor.chain !== "base" || floor.blockTimestamp === undefined) continue;
172
+ const base = {
173
+ proofHash: p.proofHash,
174
+ chain: "base" as const,
175
+ blockNumber: floor.blockNumber,
176
+ blockHash: floor.blockHash,
177
+ blockTimestamp: floor.blockTimestamp,
178
+ };
179
+ const withheld = (reason: string, header: SignedFloorRecord["header"], headerPath?: string): void => {
180
+ records.push({ ...base, header, ...(headerPath !== undefined ? { headerPath } : {}), bound: "withheld", withheldReason: reason });
181
+ };
182
+
183
+ if (!onBaseSchedule(floor.blockNumber, floor.blockTimestamp)) {
184
+ const reason = `the signed time of Base block ${floor.blockNumber} (${floor.blockTimestamp}) is not Base mainnet's schedule for that block`;
185
+ problems.push({ code: "floor-off-schedule", proofHash: p.proofHash, ...(path !== undefined ? { path } : {}), message: reason });
186
+ withheld(reason, "not-carried");
187
+ continue;
188
+ }
189
+
190
+ // A header in the bundle for the signed block: every floor header file
191
+ // naming it must hold; the first that holds dates the floor.
192
+ let checkedPath: string | undefined;
193
+ let mismatch: string | undefined;
194
+ for (const c of headers.get(floor.blockHash) ?? []) {
195
+ const r = checkFloorHeader(floor, c.raw, "base");
196
+ if (r.ok) {
197
+ checkedPath ??= c.path;
198
+ } else if (c.fromFloorFile) {
199
+ mismatch = `${c.path}: ${r.reason}`;
200
+ problems.push({
201
+ code: "floor-header-mismatch",
202
+ proofHash: p.proofHash,
203
+ path: c.path,
204
+ message: `the floor header does not match the Base floor the proof signs: ${r.reason}`,
205
+ });
206
+ }
207
+ }
208
+ const header: SignedFloorRecord["header"] = checkedPath !== undefined ? "checked" : "not-carried";
209
+ if (mismatch !== undefined) {
210
+ withheld(`a floor header in the bundle contradicts the signed floor (${mismatch})`, header, checkedPath);
211
+ continue;
212
+ }
213
+ if (p.verification?.status === "failed") {
214
+ withheld("the proof does not verify, so the floor in it is not a signed one", header, checkedPath);
215
+ continue;
216
+ }
217
+ const attestedMs = attestationTimestampMs(
218
+ ((p.proof as { environment?: { attestation?: { reportB64?: string } } }).environment?.attestation?.reportB64) ?? ""
219
+ );
220
+ const timeOk = floorTimeIsBound(floor.blockTimestamp, attestedMs);
221
+ if (!timeOk.ok) {
222
+ withheld(timeOk.reason, header, checkedPath);
223
+ continue;
224
+ }
225
+ records.push({ ...base, header, ...(checkedPath !== undefined ? { headerPath: checkedPath } : {}), bound: "not-before" });
226
+ bounds.set(p.proofHash, {
227
+ proofHash: p.proofHash,
228
+ blockNumber: floor.blockNumber,
229
+ blockHash: floor.blockHash,
230
+ timestamp: floor.blockTimestamp,
231
+ timeSource: checkedPath !== undefined ? "header" : "signed",
232
+ });
233
+ }
234
+ return { bounds, records, problems };
235
+ }
236
+
237
+ /** The sentence a Base floor bound states about its block time, shared by the reports. */
238
+ export function baseFloorTimeSentence(blockNumber: number | string, timestamp: number, timeSource: "header" | "signed"): string {
239
+ return timeSource === "header"
240
+ ? `Base block ${blockNumber} is stamped ${iso(timestamp)}; its header is in the bundle and was checked against the signed block hash, number and time.`
241
+ : `Base block ${blockNumber} is stamped ${iso(timestamp)} as the proof signs it, on Base mainnet's schedule for that block; no header for it is in the bundle, so confirming the block needs a Base lookup.`;
242
+ }
package/src/index.ts CHANGED
@@ -65,6 +65,10 @@ export type {
65
65
  TemporalSegment,
66
66
  AnchorOrderedPair,
67
67
  TemporalAnalysis,
68
+ SignedFloorRecord,
69
+ FloorProblem,
70
+ FloorProblemCode,
71
+ FloorHeaderFile,
68
72
  AttestationCheck,
69
73
  NitroValidationOptions,
70
74
  NitroValidationResult,
@@ -109,6 +113,10 @@ export { identifyAnchors } from "./anchors.js";
109
113
  export { verifyAnchorWitnesses, verifyAnchorWitness } from "./witness.js";
110
114
 
111
115
  export { deriveTemporalBounds } from "./temporal.js";
116
+ // Base floors (enclave v10): the floor each proof signs, read through signedFloorOf.
117
+ export { readSignedFloors } from "./floors.js";
118
+ export type { FloorEvidence, FloorReading } from "./floors.js";
119
+ export { FLOOR_HEADER_VERSION } from "./ingest.js";
112
120
  export { verifyCeilings, BITGRAPH_CEILING_WRITER, BASE_MAINNET_CHAIN_ID } from "./ceilings.js";
113
121
  export type { CeilingAuditOptions } from "./ceilings.js";
114
122
 
package/src/ingest.ts CHANGED
@@ -20,6 +20,10 @@
20
20
  * exactly like a proof file. A file that declares another bitgraph-export
21
21
  * format, or lacks an export's structure, is listed as rejected.
22
22
  *
23
+ * A floor header file (bitgraph-floor-header/1, found by its version field)
24
+ * is evidence too: the header of the Base block a proof signs as its floor
25
+ * (commit.slotFloor, enclave v10), listed for the temporal stage.
26
+ *
23
27
  * Memory: archives are never unpacked to disk and never loaded whole.
24
28
  * Every entry is hashed incrementally as it streams. Only small JSON
25
29
  * candidates (at most MAX_CANDIDATE_JSON_BYTES) are buffered for parsing,
@@ -51,6 +55,7 @@ import { combineEntryDigests } from "./contents-hash.js";
51
55
  import type {
52
56
  AnchorWitnessFile,
53
57
  CeilingFile,
58
+ FloorHeaderFile,
54
59
  ArtifactRecord,
55
60
  AuditFinding,
56
61
  BundleManifest,
@@ -108,6 +113,8 @@ const BUNDLE_VERSION = "bitgraph-bundle/1";
108
113
  const WITNESS_VERSION = "bitgraph-anchor-witness/1";
109
114
  const CEILING_VERSION_TAG = "bitgraph-ceiling/1";
110
115
  const CEILING_STATUS_VERSION_TAG = "bitgraph-ceiling-status/1";
116
+ /** A Base floor block's header, beside the proof that signs it (the carrier/3 unpacker writes these). */
117
+ export const FLOOR_HEADER_VERSION = "bitgraph-floor-header/1";
111
118
  /** Every export format starts with this; only EXPORT_FORMAT ("bitgraph-export/1") is checked. */
112
119
  const EXPORT_FORMAT_PREFIX = "bitgraph-export/";
113
120
 
@@ -369,6 +376,7 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
369
376
  const witnesses: AnchorWitnessFile[] = [];
370
377
  const ceilings: CeilingFile[] = [];
371
378
  const ceilingStatuses: CeilingFile[] = [];
379
+ const floorHeaders: FloorHeaderFile[] = [];
372
380
  const exportFiles: ExportFile[] = [];
373
381
  const artifactsByHex = new Map<string, ArtifactRecord>();
374
382
  let manifest: ManifestReport | undefined;
@@ -481,6 +489,11 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
481
489
 
482
490
  // A ceiling in time travels beside its proof: evidence, never an artifact.
483
491
  const pv = parsed !== undefined ? (parsed as Record<string, unknown>)["version"] : undefined;
492
+ // So does a Base floor block's header: evidence for the temporal stage.
493
+ if (pv === FLOOR_HEADER_VERSION) {
494
+ floorHeaders.push({ path: entry.path, fileSha256Hex: entry.sha256Hex, json: parsed as Record<string, unknown> });
495
+ continue;
496
+ }
484
497
  if (pv === CEILING_VERSION_TAG || pv === CEILING_STATUS_VERSION_TAG) {
485
498
  (pv === CEILING_VERSION_TAG ? ceilings : ceilingStatuses).push({
486
499
  path: entry.path,
@@ -517,6 +530,7 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
517
530
  unsupportedVersion: unsupportedVersions.length,
518
531
  artifacts: artifacts.length,
519
532
  witnesses: witnesses.length,
533
+ ...(floorHeaders.length > 0 ? { floorHeaders: floorHeaders.length } : {}),
520
534
  ceilings: ceilings.length + ceilingStatuses.length,
521
535
  exports: exportFiles.length,
522
536
  skippedUnsafePaths,
@@ -531,6 +545,7 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
531
545
  unsupportedVersions,
532
546
  artifacts,
533
547
  witnesses,
548
+ ...(floorHeaders.length > 0 ? { floorHeaders } : {}),
534
549
  ceilings,
535
550
  ceilingStatuses,
536
551
  exports: exportFiles,
@@ -128,6 +128,8 @@ export function buildJsonReport(result: AuditResult): AuditJsonReport {
128
128
  segments: result.temporal.segments,
129
129
  verifiedAnchorProofHashes: result.temporal.verifiedAnchorProofHashes,
130
130
  unverifiedAnchorProofHashes: result.temporal.unverifiedAnchorProofHashes,
131
+ ...(result.temporal.signedFloors !== undefined ? { signedFloors: result.temporal.signedFloors } : {}),
132
+ ...(result.temporal.floorProblems !== undefined ? { floorProblems: result.temporal.floorProblems } : {}),
131
133
  },
132
134
  summary,
133
135
  };
@@ -287,6 +289,8 @@ function buildSummary(
287
289
  if (proof.measurement !== undefined) measurements.add(proof.measurement);
288
290
  }
289
291
 
292
+ const floors = result.temporal.signedFloors ?? [];
293
+ const floorProblems = result.temporal.floorProblems ?? [];
290
294
  const segmentCount = (status: TemporalSegmentStatus): number =>
291
295
  result.temporal.segments.filter((s) => s.status === status).length;
292
296
 
@@ -333,6 +337,17 @@ function buildSummary(
333
337
  segmentsLowerBounded: segmentCount("lower-bounded"),
334
338
  segmentsUpperBounded: segmentCount("upper-bounded"),
335
339
  segmentsUnanchored: segmentCount("ordered-but-unanchored"),
340
+ ...(floors.length > 0 || floorProblems.length > 0
341
+ ? {
342
+ baseFloors: {
343
+ signed: floors.length,
344
+ bounding: floors.filter((f) => f.bound === "not-before").length,
345
+ headersChecked: floors.filter((f) => f.header === "checked").length,
346
+ withheld: floors.filter((f) => f.bound === "withheld").length,
347
+ problems: floorProblems.length,
348
+ },
349
+ }
350
+ : {}),
336
351
  },
337
352
  ...(exportChecks.length > 0
338
353
  ? {
package/src/report-md.ts CHANGED
@@ -390,41 +390,51 @@ function executiveSummary(
390
390
  lines.push("### External time evidence");
391
391
  lines.push("");
392
392
  const t = s.temporal;
393
- if (t.anchorsIdentified === 0) {
393
+ const bf = t.baseFloors;
394
+ if (t.anchorsIdentified === 0 && bf === undefined) {
394
395
  lines.push(
395
396
  "No Ethereum anchor proofs were observed. The causal order of the " +
396
397
  "proofs stands on its own; this report makes no wall-clock claims."
397
398
  );
398
399
  lines.push("");
399
400
  } else {
400
- lines.push(
401
- `${withCommas(t.anchorsIdentified)} Ethereum anchor ${plural(t.anchorsIdentified, "proof was", "proofs were")} ` +
402
- "identified. An anchor is an ordinary chain member that committed " +
403
- "the fingerprint of a public Ethereum block, giving the chain a " +
404
- "contact point with a public timeline. " +
405
- `${withCommas(t.anchorsWithVerifiedWitness)} ${plural(t.anchorsWithVerifiedWitness, "anchor", "anchors")} ` +
406
- "came with witness material (the block header) that let this tool " +
407
- "reconstruct the block fingerprint locally and confirm it against " +
408
- "the signed anchor, entirely offline."
409
- );
410
- lines.push("");
411
- lines.push(
412
- "A verified anchor bounds time in one direction at a time. Proofs " +
413
- "that come after an anchor in the chain were committed no earlier " +
414
- "than that block's timestamp, because the block's fingerprint " +
415
- "could not have been known before the block existed. That lower " +
416
- "bound additionally assumes the anchored header is a genuine, " +
417
- "publicly published Ethereum block: this offline audit checks the " +
418
- "header's structure and hash binding, not proof-of-work, " +
419
- "consensus, or chain membership, so it cannot confirm the block " +
420
- "is real. Proofs that come before an anchor existed before the " +
421
- "commit that consumed the block; reading that as a wall-clock " +
422
- "ceiling additionally assumes the anchor consumed a recently " +
423
- "published block. Every such bound in this report states its " +
424
- "assumption. No individual proof's exact creation time is ever " +
425
- "stated."
426
- );
427
- lines.push("");
401
+ if (t.anchorsIdentified > 0) {
402
+ lines.push(
403
+ `${withCommas(t.anchorsIdentified)} Ethereum anchor ${plural(t.anchorsIdentified, "proof was", "proofs were")} ` +
404
+ "identified. An anchor is an ordinary chain member that committed " +
405
+ "the fingerprint of a public Ethereum block, giving the chain a " +
406
+ "contact point with a public timeline. " +
407
+ `${withCommas(t.anchorsWithVerifiedWitness)} ${plural(t.anchorsWithVerifiedWitness, "anchor", "anchors")} ` +
408
+ "came with witness material (the block header) that let this tool " +
409
+ "reconstruct the block fingerprint locally and confirm it against " +
410
+ "the signed anchor, entirely offline."
411
+ );
412
+ lines.push("");
413
+ lines.push(
414
+ "A verified anchor bounds time in one direction at a time. Proofs " +
415
+ "that come after an anchor in the chain were committed no earlier " +
416
+ "than that block's timestamp, because the block's fingerprint " +
417
+ "could not have been known before the block existed. That lower " +
418
+ "bound additionally assumes the anchored header is a genuine, " +
419
+ "publicly published Ethereum block: this offline audit checks the " +
420
+ "header's structure and hash binding, not proof-of-work, " +
421
+ "consensus, or chain membership, so it cannot confirm the block " +
422
+ "is real. Proofs that come before an anchor existed before the " +
423
+ "commit that consumed the block; reading that as a wall-clock " +
424
+ "ceiling additionally assumes the anchor consumed a recently " +
425
+ "published block. Every such bound in this report states its " +
426
+ "assumption. No individual proof's exact creation time is ever " +
427
+ "stated."
428
+ );
429
+ lines.push("");
430
+ } else {
431
+ lines.push(
432
+ "No Ethereum anchor proofs were observed. After the cutover to Base floors (enclave v10) there " +
433
+ "are none: order after a record is the chain of proof hashes."
434
+ );
435
+ lines.push("");
436
+ }
437
+ if (bf !== undefined) baseFloorSummary(lines, report);
428
438
  lines.push(
429
439
  ...table(
430
440
  ["Temporal coverage", "Segments"],
@@ -440,6 +450,50 @@ function executiveSummary(
440
450
  }
441
451
  }
442
452
 
453
+ /** The executive summary's paragraph on Base floors (enclave v10). Only written when a proof signs one or a floor problem was found. */
454
+ function baseFloorSummary(lines: string[], report: AuditJsonReport): void {
455
+ const bf = report.summary.temporal.baseFloors;
456
+ if (bf === undefined) return;
457
+ if (bf.signed > 0) {
458
+ const rest = bf.signed - bf.headersChecked;
459
+ lines.push(
460
+ `${withCommas(bf.signed)} ${plural(bf.signed, "proof signs", "proofs sign")} a Base block as ` +
461
+ `${plural(bf.signed, "its", "their")} floor (commit.slotFloor, enclave v10). A proof that signs a floor was ` +
462
+ "committed no earlier than that block's time: the block's hash could not exist before the block, and the " +
463
+ "proof signs it. Proofs after it in the chain carry the same bound. A floor is a lower bound only: it " +
464
+ "bounds nothing from above, and it orders no epoch. " +
465
+ `${withCommas(bf.headersChecked)} of ${withCommas(bf.signed)} came with the block's header, checked against ` +
466
+ "the signed hash, number and time" +
467
+ (rest > 0
468
+ ? `; for the other ${withCommas(rest)} the time is the one the proof signs, on Base mainnet's schedule for ` +
469
+ "that block, and confirming the block needs a Base lookup. "
470
+ : ". ") +
471
+ "Like an anchored block, a floor additionally assumes the block is a genuine, publicly published Base " +
472
+ "block, which this offline audit cannot confirm." +
473
+ (bf.withheld > 0
474
+ ? ` ${withCommas(bf.withheld)} ${plural(bf.withheld, "floor is", "floors are")} not used as a bound; ` +
475
+ "the reasons are in the temporal bounds section."
476
+ : "")
477
+ );
478
+ lines.push("");
479
+ }
480
+ const problems = report.temporal.floorProblems ?? [];
481
+ if (problems.length > 0) {
482
+ lines.push(
483
+ `${withCommas(problems.length)} floor ${plural(problems.length, "problem was", "problems were")} found ` +
484
+ "(exit bit 2). A floor with a problem bounds nothing:"
485
+ );
486
+ lines.push("");
487
+ for (const p of problems.slice(0, MAX_TABLE_ROWS)) {
488
+ lines.push(
489
+ `- ${inlineCode(p.code)}${p.path !== undefined ? ` ${inlineCode(p.path)}` : ""}` +
490
+ `${p.proofHash !== undefined ? `, proof ${inlineCode(p.proofHash)}` : ""}: ${p.message}`
491
+ );
492
+ }
493
+ lines.push("");
494
+ }
495
+ }
496
+
443
497
  /** Plain sentences describing one partition's chain state, in the brief's style. */
444
498
  function partitionSummarySentences(report: AuditJsonReport, partition: ReportPartition): string[] {
445
499
  const out: string[] = [];
@@ -841,6 +895,22 @@ function temporalDetails(lines: string[], report: AuditJsonReport): void {
841
895
  }
842
896
  lines.push("");
843
897
  }
898
+ const floors = report.temporal.signedFloors ?? [];
899
+ if (floors.length > 0) {
900
+ lines.push("Base floors the proofs sign (enclave v10), each a not-before for its proof and never an upper bound:");
901
+ lines.push("");
902
+ for (const f of floors.slice(0, MAX_TABLE_ROWS)) {
903
+ lines.push(
904
+ `- Base block ${withCommas(f.blockNumber)} (${formatTimestamp(f.blockTimestamp)}, hash ${inlineCode(f.blockHash)}), ` +
905
+ `signed by ${inlineCode(f.proofHash)}: ` +
906
+ (f.header === "checked"
907
+ ? `header checked (${inlineCode(f.headerPath ?? "")})`
908
+ : "no header in the bundle; confirming the block needs a Base lookup") +
909
+ (f.bound === "not-before" ? "; bounds the proof not before that time." : `; not used as a bound: ${f.withheldReason ?? "withheld"}.`)
910
+ );
911
+ }
912
+ lines.push("");
913
+ }
844
914
  if (report.epochRelationships.anchorOrderedPairs.length > 0) {
845
915
  lines.push("Anchor-derived epoch ordering (assumption-dependent, covered portions only):");
846
916
  lines.push("");
@@ -859,6 +929,13 @@ function temporalDetails(lines: string[], report: AuditJsonReport): void {
859
929
  }
860
930
 
861
931
  function boundLine(bound: SegmentBound): string {
932
+ if (bound.source === "signed-floor") {
933
+ return (
934
+ `Committed no earlier than ${formatTimestamp(bound.timestamp)} ` +
935
+ `(Base block ${bound.blockNumber ?? "?"}, the floor signed by ${inlineCode(bound.anchorProofHash)}, ` +
936
+ `evidence: ${bound.evidence}${bound.weaker ? ", weaker" : ""}). ${bound.claim}`
937
+ );
938
+ }
862
939
  const direction =
863
940
  bound.kind === "not-before"
864
941
  ? "Committed no earlier than"
@@ -1005,12 +1082,13 @@ function exportDetail(lines: string[], e: ExportCheck): void {
1005
1082
  const t = e.times;
1006
1083
  lines.push(
1007
1084
  t.floor !== null
1008
- ? `- Floor: Ethereum block ${withCommas(t.floor.blockNumber)}, mined at ${formatTimestamp(t.floor.blockTimestamp)}, ` +
1085
+ ? `- Floor: ${t.floor.chain === "base" ? "Base" : "Ethereum"} block ${withCommas(t.floor.blockNumber)}, ` +
1086
+ `${t.floor.chain === "base" ? "stamped" : "mined"} at ${formatTimestamp(t.floor.blockTimestamp)}, ` +
1009
1087
  `hash ${inlineCode(t.floor.blockHash)}: the proof's signed floor block, its header checked by hash. ` +
1010
1088
  "Every file in the tree was recorded after it (the record floor). Committed bytes that carry this position's " +
1011
1089
  "commitment were also finished after it (the content floor); an original inside them, and a file recorded as is, " +
1012
1090
  "are not dated by it. What the floor covers for each file is stated per run below." +
1013
- confirmedNote("confirmed.floor", "Ethereum")
1091
+ confirmedNote("confirmed.floor", t.floor.chain === "base" ? "Base" : "Ethereum")
1014
1092
  : `- Floor: not established (${why("floor.header")}).`
1015
1093
  );
1016
1094
  lines.push(