@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
@@ -0,0 +1,405 @@
1
+ // Copyright (c) 2024-2026 Argento Computing Inc.
2
+
3
+ /**
4
+ * Base floors (enclave v10) in the audit.
5
+ *
6
+ * A proof made by enclave v10 signs its floor as commit.slotFloor, a Base
7
+ * block, instead of an Ethereum anchor. The audit reads it through
8
+ * signedFloorOf and gives the proof a NOT-BEFORE bound labelled Base, never a
9
+ * not-after: a later proof's floor block can predate this proof. Floors count
10
+ * toward an epoch's coverage but never order epochs. Bundles from before the
11
+ * cutover must audit exactly as before, so the mixed case checks that the old
12
+ * epoch's segments are the same with and without the new epoch beside them.
13
+ *
14
+ * The real fixtures are the v10 harness proofs over Base block #52,271,417
15
+ * (src/__tests__/fuse3-fixtures/, the block's header saved beside them). The
16
+ * synthetic proofs are signed here with throwaway keys, exactly as the
17
+ * enclave signs (the canonical signed body), so they verify.
18
+ *
19
+ * Run, after `npm run build` in packages/audit:
20
+ * node --test packages/audit/src/__tests__/base-floor.test.ts
21
+ */
22
+ import { test, describe, before, after } from "node:test";
23
+ import assert from "node:assert/strict";
24
+ import { readFileSync } from "node:fs";
25
+ import { mkdtemp, mkdir, writeFile, rm, readFile } from "node:fs/promises";
26
+ import { tmpdir } from "node:os";
27
+ import { join, dirname } from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+ import { spawnSync } from "node:child_process";
30
+ import { getPublicKeyAsync, signAsync } from "@noble/ed25519";
31
+ import { sha256 } from "@noble/hashes/sha256";
32
+ import { keccak_256 } from "@noble/hashes/sha3";
33
+ import {
34
+ BASE_MAINNET_GENESIS_TIME, assembleCarrierV3Payload, buildCarrier, buildSignedBody, canonicalize, computeChainHash,
35
+ computeProofHash, type BitGraphProof,
36
+ } from "@mikeargento/bitgraph-verify";
37
+ import { buildJsonReport, buildMarkdownReport, computeExitFlags, runAudit, FLOOR_HEADER_VERSION } from "@mikeargento/bitgraph-audit";
38
+ import type { AuditResult, TemporalSegment } from "@mikeargento/bitgraph-audit";
39
+
40
+ const here = dirname(fileURLToPath(import.meta.url));
41
+ const FIX = join(here, "../../../../src/__tests__/fuse3-fixtures");
42
+ const CLI = join(here, "../../dist/cli.js");
43
+ const HEADER = readFileSync(join(FIX, "floor-base-52271417.rlp.hex"), "utf8").trim();
44
+ const BLOCK = 52271417;
45
+ const BLOCK_HASH = "0x71d926aeae9847fcc15fad2d4de8871923f56862d42db7a7463e95a838f8f9b0";
46
+ const BLOCK_TIME = 1791332181;
47
+ const realProof = (name: string): string => readFileSync(join(FIX, name), "utf8");
48
+ const realHash = (name: string): string => computeProofHash(JSON.parse(realProof(name)));
49
+
50
+ const b64 = (u: Uint8Array): string => Buffer.from(u).toString("base64");
51
+ const hex = (u: Uint8Array): string => `0x${Buffer.from(u).toString("hex")}`;
52
+ const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s);
53
+ const scheduled = (n: number): number => BASE_MAINNET_GENESIS_TIME + 2 * n;
54
+
55
+ interface Key { priv: Uint8Array; pub: string }
56
+ async function key(seed: string): Promise<Key> {
57
+ const priv = sha256(utf8(`base-floor audit test key ${seed} (TEST ONLY)`));
58
+ return { priv, pub: b64(await getPublicKeyAsync(priv)) };
59
+ }
60
+
61
+ interface Minted { proof: BitGraphProof; proofHash: string; chainHash: string }
62
+
63
+ async function sign(k: Key, artifactBytes: Uint8Array, commit: Record<string, unknown>, attribution?: Record<string, string>): Promise<Minted> {
64
+ const proof = {
65
+ version: "bitgraph/1",
66
+ artifact: { hashAlg: "sha256", digestB64: b64(sha256(artifactBytes)) },
67
+ commit: { nonceB64: b64(sha256(utf8(`nonce ${JSON.stringify(commit)}`))), ...commit },
68
+ signer: { publicKeyB64: k.pub, signatureB64: "" },
69
+ environment: { enforcement: "stub", measurement: "test-measurement-base-floor" },
70
+ ...(attribution !== undefined ? { attribution } : {}),
71
+ } as unknown as BitGraphProof;
72
+ proof.signer.signatureB64 = b64(await signAsync(canonicalize(buildSignedBody(proof) as never), k.priv));
73
+ return { proof, proofHash: computeProofHash(proof), chainHash: computeChainHash(proof) };
74
+ }
75
+
76
+ function baseFloor(blockNumber: number, opts?: { hash?: string; time?: number }): Record<string, unknown> {
77
+ return {
78
+ chain: "base",
79
+ evmChainId: 8453,
80
+ blockNumber,
81
+ blockHash: opts?.hash ?? hex(sha256(utf8(`base block ${blockNumber}`))),
82
+ blockTimestamp: opts?.time ?? scheduled(blockNumber),
83
+ };
84
+ }
85
+
86
+ /** A user proof whose floor is a Base block (enclave v10). */
87
+ async function baseProof(k: Key, epochId: string, slot: string, counter: string, floor: Record<string, unknown>, prevB64?: string, extra?: Record<string, unknown>): Promise<Minted> {
88
+ return sign(k, utf8(`base ${epochId} ${counter}`), {
89
+ counter, slotCounter: slot, ...(prevB64 !== undefined ? { prevB64 } : {}), epochId, slotFloor: floor, chainId: "bitgraph:main", ...(extra ?? {}),
90
+ });
91
+ }
92
+
93
+ // --- the old world: an Ethereum anchor and its witness ---------------------
94
+
95
+ function rlp(item: Uint8Array | Uint8Array[]): Uint8Array {
96
+ const len = (n: number, off: number): number[] => {
97
+ if (n <= 55) return [off + n];
98
+ const b: number[] = [];
99
+ for (let v = n; v > 0; v = Math.floor(v / 256)) b.unshift(v & 0xff);
100
+ return [off + 55 + b.length, ...b];
101
+ };
102
+ if (item instanceof Uint8Array) return item.length === 1 && item[0]! < 0x80 ? item : new Uint8Array([...len(item.length, 0x80), ...item]);
103
+ const body = item.flatMap((i) => [...rlp(i)]);
104
+ return new Uint8Array([...len(body.length, 0xc0), ...body]);
105
+ }
106
+ function be(n: number): Uint8Array {
107
+ const b: number[] = [];
108
+ for (let v = n; v > 0; v = Math.floor(v / 256)) b.unshift(v & 0xff);
109
+ return new Uint8Array(b);
110
+ }
111
+ function ethHeader(blockNumber: number, timestamp: number): { rlpHex: string; hash: string } {
112
+ const items: Uint8Array[] = [];
113
+ for (let i = 0; i < 20; i++) items.push(i === 8 ? be(blockNumber) : i === 11 ? be(timestamp) : new Uint8Array(32).fill(i + 1));
114
+ const bytes = rlp(items);
115
+ return { rlpHex: hex(bytes), hash: hex(keccak_256(bytes)) };
116
+ }
117
+
118
+ async function anchorProof(k: Key, epochId: string, slot: string, counter: string, blockNumber: number, blockHash: string, prevB64?: string): Promise<Minted> {
119
+ return sign(k, utf8(blockHash), { counter, slotCounter: slot, ...(prevB64 !== undefined ? { prevB64 } : {}), epochId, chainId: "bitgraph:main" }, {
120
+ name: "Ethereum Anchor", title: `https://etherscan.io/block/${blockNumber}`, message: blockHash,
121
+ });
122
+ }
123
+ async function userProof(k: Key, epochId: string, slot: string, counter: string, prevB64?: string): Promise<Minted> {
124
+ return sign(k, utf8(`user ${epochId} ${counter}`), { counter, slotCounter: slot, ...(prevB64 !== undefined ? { prevB64 } : {}), epochId, chainId: "bitgraph:main" });
125
+ }
126
+
127
+ // --- bundles ----------------------------------------------------------------
128
+
129
+ const dirs: string[] = [];
130
+ async function bundle(files: Record<string, string>): Promise<string> {
131
+ const dir = await mkdtemp(join(tmpdir(), "audit-base-floor-"));
132
+ dirs.push(dir);
133
+ for (const [path, content] of Object.entries(files)) {
134
+ await mkdir(dirname(join(dir, path)), { recursive: true });
135
+ await writeFile(join(dir, path), content);
136
+ }
137
+ return dir;
138
+ }
139
+ after(async () => {
140
+ for (const d of dirs) await rm(d, { recursive: true, force: true });
141
+ });
142
+
143
+ const json = (m: Minted): string => JSON.stringify(m.proof, null, 2);
144
+ const floorHeaderFile = (header = HEADER, extra?: Record<string, unknown>): string =>
145
+ JSON.stringify({ version: FLOOR_HEADER_VERSION, chain: "base", evmChainId: 8453, blockNumber: BLOCK, blockHash: BLOCK_HASH, blockTimestamp: BLOCK_TIME, header, ...(extra ?? {}) });
146
+
147
+ function segmentOf(r: AuditResult, proofHash: string): TemporalSegment {
148
+ const s = r.temporal.segments.find((x) => x.memberProofHashes.includes(proofHash));
149
+ assert.ok(s, `proof ${proofHash} lands in a segment`);
150
+ return s;
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+
155
+ describe("a Base floor bounds its proof not-before, labelled Base", () => {
156
+ test("real v10 harness proofs with the floor header: a checked not-before on Base block 52,271,417, no not-after", async () => {
157
+ const dir = await bundle({
158
+ "proofs/trailer3.json": realProof("trailer3.proof.json"),
159
+ "proofs/made3.json": realProof("made3.proof.json"),
160
+ "base-floor/floor-header.json": floorHeaderFile(),
161
+ });
162
+ const r = await runAudit(dir);
163
+ assert.equal(r.temporal.floorProblems, undefined, "no floor problem");
164
+ const floors = r.temporal.signedFloors ?? [];
165
+ assert.equal(floors.length, 2);
166
+ for (const f of floors) {
167
+ assert.equal(f.chain, "base");
168
+ assert.equal(f.blockNumber, BLOCK);
169
+ assert.equal(f.blockHash, BLOCK_HASH);
170
+ assert.equal(f.header, "checked");
171
+ assert.equal(f.headerPath, "base-floor/floor-header.json");
172
+ assert.equal(f.bound, "not-before");
173
+ }
174
+ for (const name of ["trailer3.proof.json", "made3.proof.json"]) {
175
+ const seg = segmentOf(r, realHash(name));
176
+ assert.equal(seg.upperBounds.length, 0, "a floor never gives a not-after");
177
+ const b = seg.lowerBounds[0]!;
178
+ assert.equal(b.kind, "not-before");
179
+ assert.equal(b.source, "signed-floor");
180
+ assert.equal(b.chain, "base");
181
+ assert.equal(b.timeSource, "header");
182
+ assert.equal(b.blockNumber, String(BLOCK));
183
+ assert.equal(b.timestamp, BLOCK_TIME);
184
+ assert.equal(b.basis, "block-hash-unpredictability");
185
+ assert.equal(b.boundClass, "evidence");
186
+ assert.equal(b.weaker, false);
187
+ assert.match(b.claim, /Base block 52271417/);
188
+ assert.doesNotMatch(b.claim, /Ethereum/);
189
+ assert.doesNotMatch(b.claim, /[–—]/, "no dashes in report copy");
190
+ }
191
+ // made3 follows trailer3 in the chain? Either way its own floor is the bound.
192
+ assert.equal(segmentOf(r, realHash("made3.proof.json")).lowerBounds.some((b) => b.evidence === "signed-floor"), true);
193
+ // Epoch coverage counts both as floored.
194
+ const epoch = r.reconstruction.epochRelationships.epochs[0]!;
195
+ const nb = (epoch.anchorBounds ?? []).find((b) => b.kind === "not-before");
196
+ assert.ok(nb, "the epoch has a not-before");
197
+ assert.equal(nb.source, "signed-floor");
198
+ assert.equal(nb.chain, "base");
199
+ assert.equal(nb.coveredProofCount, 2);
200
+ assert.equal((epoch.anchorBounds ?? []).some((b) => b.kind === "not-after"), false);
201
+ assert.equal(computeExitFlags(r).chainAnomaliesOrDivergences && (r.temporal.floorProblems ?? []).length > 0, false);
202
+ const report = buildJsonReport(r);
203
+ assert.deepEqual(report.summary.temporal.baseFloors, { signed: 2, bounding: 2, headersChecked: 2, withheld: 0, problems: 0 });
204
+ const md = buildMarkdownReport(r);
205
+ assert.match(md, /2 proofs sign a Base block as their floor/);
206
+ assert.match(md, /Base block 52,271,417/);
207
+ assert.doesNotMatch(md, /this report makes no wall-clock claims/);
208
+ });
209
+
210
+ test("without the header: the time is the signed one, and the bound says it needs a Base lookup", async () => {
211
+ const dir = await bundle({ "proofs/made3.json": realProof("made3.proof.json") });
212
+ const r = await runAudit(dir);
213
+ const f = r.temporal.signedFloors![0]!;
214
+ assert.equal(f.header, "not-carried");
215
+ assert.equal(f.bound, "not-before");
216
+ const b = segmentOf(r, realHash("made3.proof.json")).lowerBounds[0]!;
217
+ assert.equal(b.timeSource, "signed");
218
+ assert.equal(b.timestamp, BLOCK_TIME);
219
+ assert.match(b.claim, /needs a Base lookup/);
220
+ });
221
+
222
+ test("no not-after is invented from a later proof's floor, even when that floor is older", async () => {
223
+ const k = await key("chain");
224
+ // B2 follows B1 in the chain but signs an OLDER Base block: its floor
225
+ // says nothing about B1, and B1 gets no upper bound from it.
226
+ const b1 = await baseProof(k, "E-base", "1", "2", baseFloor(52_000_100));
227
+ const b2 = await baseProof(k, "E-base", "3", "4", baseFloor(52_000_000), b1.chainHash);
228
+ const b3 = await baseProof(k, "E-base", "5", "6", baseFloor(52_000_200), b2.chainHash);
229
+ const r = await runAudit(await bundle({ "b1.json": json(b1), "b2.json": json(b2), "b3.json": json(b3) }));
230
+ for (const seg of r.temporal.segments) {
231
+ assert.equal(seg.upperBounds.length, 0, "no segment carries a not-after");
232
+ assert.equal(seg.status, "lower-bounded");
233
+ }
234
+ const s1 = segmentOf(r, b1.proofHash);
235
+ assert.equal(s1.lowerBounds[0]!.timestamp, scheduled(52_000_100));
236
+ assert.equal(s1.lowerBounds[0]!.evidence, "signed-floor");
237
+ // B2's tightest not-before is B1's floor, reached by the hash link (it is later than B2's own).
238
+ const s2 = segmentOf(r, b2.proofHash);
239
+ assert.equal(s2.lowerBounds[0]!.timestamp, scheduled(52_000_100));
240
+ assert.equal(s2.lowerBounds[0]!.evidence, "chain-link");
241
+ assert.equal(s2.lowerBounds[0]!.anchorProofHash, b1.proofHash);
242
+ // B3's own floor is the latest.
243
+ assert.equal(segmentOf(r, b3.proofHash).lowerBounds[0]!.evidence, "signed-floor");
244
+ const epoch = r.reconstruction.epochRelationships.epochs[0]!;
245
+ assert.equal((epoch.anchorBounds ?? []).some((b) => b.kind === "not-after"), false);
246
+ assert.equal(r.temporal.anchorOrderedPairs.length, 0);
247
+ });
248
+ });
249
+
250
+ describe("a bundle mixing Ethereum-anchored and Base-floored proofs", () => {
251
+ let oldOnly: AuditResult;
252
+ let mixed: AuditResult;
253
+ const h: Record<string, Minted> = {};
254
+ const T_ANCHOR = 1_700_000_000;
255
+
256
+ before(async () => {
257
+ const k1 = await key("old epoch");
258
+ const k2 = await key("new epoch");
259
+ const H = ethHeader(100, T_ANCHOR);
260
+ h.p1 = await userProof(k1, "E-old", "1", "2");
261
+ h.a1 = await anchorProof(k1, "E-old", "3", "4", 100, H.hash, h.p1.chainHash);
262
+ h.p2 = await userProof(k1, "E-old", "5", "6", h.a1.chainHash);
263
+ h.b1 = await baseProof(k2, "E-new", "1", "2", baseFloor(52_100_000));
264
+ h.b2 = await baseProof(k2, "E-new", "3", "4", baseFloor(52_100_010), h.b1.chainHash);
265
+ const old = {
266
+ "old/p1.json": json(h.p1), "old/a1.json": json(h.a1), "old/p2.json": json(h.p2),
267
+ "old/a1.witness.json": JSON.stringify({ version: "bitgraph-anchor-witness/1", headerRlpHex: H.rlpHex, blockNumber: 100, blockHash: H.hash }),
268
+ };
269
+ oldOnly = await runAudit(await bundle(old));
270
+ mixed = await runAudit(await bundle({ ...old, "new/b1.json": json(h.b1), "new/b2.json": json(h.b2) }));
271
+ });
272
+
273
+ test("the old epoch's bounds are exactly what they are without the new epoch beside it", () => {
274
+ const oldSegs = (r: AuditResult) => r.temporal.segments.filter((s) => s.partition.epochId === "E-old");
275
+ assert.deepEqual(oldSegs(mixed), oldSegs(oldOnly));
276
+ const p2 = segmentOf(mixed, h.p2!.proofHash).lowerBounds[0]!;
277
+ assert.equal(p2.source, undefined, "an anchor bound carries no source");
278
+ assert.equal(p2.chain, undefined, "an anchor bound is Ethereum");
279
+ assert.equal(p2.anchorProofHash, h.a1!.proofHash);
280
+ assert.equal(p2.timestamp, T_ANCHOR);
281
+ assert.equal(segmentOf(mixed, h.p1!.proofHash).upperBounds[0]!.anchorProofHash, h.a1!.proofHash);
282
+ const eOld = (r: AuditResult) => r.reconstruction.epochRelationships.epochs.find((e) => e.epochId === "E-old")!.anchorBounds;
283
+ assert.deepEqual(eOld(mixed), eOld(oldOnly));
284
+ });
285
+
286
+ test("the new epoch is floored on Base, and floors order no epoch", () => {
287
+ for (const m of [h.b1!, h.b2!]) {
288
+ const b = segmentOf(mixed, m.proofHash).lowerBounds[0]!;
289
+ assert.equal(b.chain, "base");
290
+ assert.equal(b.source, "signed-floor");
291
+ }
292
+ const eNew = mixed.reconstruction.epochRelationships.epochs.find((e) => e.epochId === "E-new")!;
293
+ const nb = eNew.anchorBounds!.find((b) => b.kind === "not-before")!;
294
+ assert.equal(nb.chain, "base");
295
+ assert.equal(nb.coveredProofCount, 2);
296
+ assert.match(nb.claim!, /orders no epoch/);
297
+ // The old epoch has a not-after (an anchor followed P1) earlier than every
298
+ // Base floor, yet no pair is claimed: the after side reads anchors only.
299
+ assert.equal(mixed.temporal.anchorOrderedPairs.length, 0);
300
+ const report = buildJsonReport(mixed);
301
+ assert.equal(report.summary.temporal.anchorsIdentified, 1);
302
+ assert.equal(report.summary.temporal.baseFloors?.signed, 2);
303
+ const md = buildMarkdownReport(mixed);
304
+ assert.match(md, /1 Ethereum anchor proof was/);
305
+ assert.match(md, /2 proofs sign a Base block as their floor/);
306
+ });
307
+ });
308
+
309
+ describe("floor problems are reported, never silently bounded (exit bit 2)", () => {
310
+ test("a proof that signs both an Ethereum anchor and a Base floor", async () => {
311
+ const k = await key("both");
312
+ const both = await baseProof(k, "E-both", "1", "2", baseFloor(52_200_000), undefined, {
313
+ slotAnchor: { counter: "0", blockNumber: 25_000_000, blockHash: hex(sha256(utf8("eth block"))) },
314
+ });
315
+ const r = await runAudit(await bundle({ "both.json": json(both) }));
316
+ const problems = r.temporal.floorProblems ?? [];
317
+ assert.equal(problems.length, 1);
318
+ assert.equal(problems[0]!.code, "floor-ambiguous");
319
+ assert.equal(problems[0]!.proofHash, both.proofHash);
320
+ assert.equal(r.temporal.signedFloors, undefined, "nothing is read from it as a floor");
321
+ assert.equal(segmentOf(r, both.proofHash).lowerBounds.length, 0);
322
+ assert.equal(computeExitFlags(r).code & 2, 2);
323
+ assert.match(buildMarkdownReport(r), /floor-ambiguous/);
324
+ });
325
+
326
+ test("a Base floor off Base mainnet's schedule", async () => {
327
+ const k = await key("schedule");
328
+ const off = await baseProof(k, "E-off", "1", "2", baseFloor(52_200_000, { time: scheduled(52_200_000) + 1 }));
329
+ const r = await runAudit(await bundle({ "off.json": json(off) }));
330
+ assert.equal(r.temporal.floorProblems?.[0]?.code, "floor-off-schedule");
331
+ assert.equal(r.temporal.signedFloors?.[0]?.bound, "withheld");
332
+ assert.equal(segmentOf(r, off.proofHash).lowerBounds.length, 0);
333
+ assert.equal(computeExitFlags(r).code & 2, 2);
334
+ });
335
+
336
+ test("a slotFloor that does not name Base mainnet", async () => {
337
+ const k = await key("chain id");
338
+ const wrong = await baseProof(k, "E-wrong", "1", "2", { ...baseFloor(52_200_000), evmChainId: 84532 });
339
+ const r = await runAudit(await bundle({ "wrong.json": json(wrong) }));
340
+ assert.equal(r.temporal.floorProblems?.[0]?.code, "floor-malformed");
341
+ assert.equal(segmentOf(r, wrong.proofHash).lowerBounds.length, 0);
342
+ });
343
+
344
+ test("a floor header for the signed hash that carries another number contradicts the proof", async () => {
345
+ const k = await key("header");
346
+ // Signs the real block's hash at the wrong height (on schedule for that height).
347
+ const liar = await baseProof(k, "E-liar", "1", "2", baseFloor(BLOCK + 1, { hash: BLOCK_HASH }));
348
+ const r = await runAudit(await bundle({ "liar.json": json(liar), "floor.json": floorHeaderFile() }));
349
+ const p = (r.temporal.floorProblems ?? []).find((x) => x.code === "floor-header-mismatch");
350
+ assert.ok(p, "the mismatch is a problem");
351
+ assert.equal(p.proofHash, liar.proofHash);
352
+ assert.equal(r.temporal.signedFloors?.[0]?.bound, "withheld");
353
+ assert.equal(segmentOf(r, liar.proofHash).lowerBounds.length, 0);
354
+ assert.equal(computeExitFlags(r).code & 2, 2);
355
+ });
356
+
357
+ test("a floor header that hashes to another block than it names, and one no proof signs", async () => {
358
+ const tampered = HEADER.slice(0, -2) + (HEADER.endsWith("0") ? "1" : "0");
359
+ const r1 = await runAudit(await bundle({ "proofs/made3.json": realProof("made3.proof.json"), "h.json": floorHeaderFile(tampered) }));
360
+ assert.ok((r1.temporal.floorProblems ?? []).some((p) => p.code === "floor-header-mismatch" || p.code === "floor-header-malformed"));
361
+ assert.equal(r1.temporal.signedFloors?.[0]?.header, "not-carried", "a tampered header never checks the floor");
362
+ const r2 = await runAudit(await bundle({ "h.json": floorHeaderFile() }));
363
+ assert.equal(r2.temporal.floorProblems?.[0]?.code, "floor-header-unmatched");
364
+ assert.equal(computeExitFlags(r2).code & 2, 2);
365
+ });
366
+ });
367
+
368
+ describe("bitgraph-carrier/3 through the CLI", () => {
369
+ test("the Base floor header is unpacked, checked and reported; no closing anchor is asked for", async () => {
370
+ const k = await key("carrier");
371
+ const inner = utf8("a BitGraphed file's committed bytes\n");
372
+ const floor = JSON.parse(realProof("trailer3.proof.json")).commit.slotFloor as Record<string, unknown>;
373
+ const minted = await sign(k, inner, { counter: "2", slotCounter: "1", epochId: "E-carrier", slotFloor: floor, chainId: "bitgraph:main" });
374
+ const payload = assembleCarrierV3Payload({
375
+ proof: minted.proof as never,
376
+ floor: { status: "present", basis: "base-header", header: HEADER },
377
+ ceilingInTime: { status: "unfetched" },
378
+ });
379
+ const dir = await bundle({});
380
+ const file = join(dir, "note.bitgraph.txt");
381
+ await writeFile(file, buildCarrier(inner, payload));
382
+ const out = join(dir, "out");
383
+ const run = spawnSync(process.execPath, [CLI, file, "--out", out, "--format", "json"], { encoding: "utf8" });
384
+ assert.ok(run.status === 0 || run.status === 1 || run.status === 2 || run.status === 3, `ran (${run.status}): ${run.stderr}`);
385
+ assert.match(run.stdout, /no earlier than: Base block 52271417/);
386
+ assert.match(run.stdout, /no ceiling in position/);
387
+ assert.doesNotMatch(run.stdout, /closing anchor/);
388
+ assert.doesNotMatch(run.stdout, /floor PROBLEM/);
389
+ const report = JSON.parse(await readFile(join(out, "audit-report.json"), "utf8"));
390
+ const f = report.temporal.signedFloors[0];
391
+ assert.equal(f.header, "checked");
392
+ assert.equal(f.headerPath, "base-floor/floor-header.json");
393
+ assert.equal(f.bound, "not-before");
394
+ assert.equal(report.anchors.records.length, 0, "no anchor proof stands under a /3 floor");
395
+ assert.equal(report.temporal.segments[0].lowerBounds[0].chain, "base");
396
+ });
397
+
398
+ test("--help names the /3 layout", () => {
399
+ const run = spawnSync(process.execPath, [CLI, "--help"], { encoding: "utf8" });
400
+ assert.equal(run.status, 0);
401
+ assert.match(run.stdout, /base-floor\/floor-header\.json/);
402
+ assert.match(run.stdout, /bitgraph-floor-header\/1/);
403
+ assert.doesNotMatch(run.stdout, /[–—]/);
404
+ });
405
+ });
package/src/audit.ts CHANGED
@@ -5,7 +5,8 @@
5
5
  *
6
6
  * ingest -> verify tiers -> reconstruct -> classify anomalies ->
7
7
  * analyze authorities -> identify anchors -> verify witnesses ->
8
- * derive temporal bounds -> validate attestations
8
+ * derive temporal bounds -> ceilings in time -> validate attestations ->
9
+ * exports (bitgraph-export/1)
9
10
  *
10
11
  * Deterministic given the same bundle: the only wall-clock read in the
11
12
  * entire pipeline is the runMetadata.startedAt stamp taken here. Every
@@ -18,6 +19,7 @@
18
19
  */
19
20
 
20
21
  import { verifyCeilings } from "./ceilings.js";
22
+ import { verifyExports } from "./exports.js";
21
23
  import { ingestBundle } from "./ingest.js";
22
24
  import { verifyObservedProofs } from "./verify-tiers.js";
23
25
  import { reconstructChains } from "./reconstruct.js";
@@ -38,7 +40,7 @@ import type { AuditOptions, AuditResult, ExitFlags, IngestResult } from "./types
38
40
  * this equals package.json's version, so the constant cannot drift silently
39
41
  * across releases.
40
42
  */
41
- export const AUDIT_VERSION = "0.8.0";
43
+ export const AUDIT_VERSION = "0.10.0";
42
44
 
43
45
  /** The audit package's own version. */
44
46
  export function auditToolVersion(): string {
@@ -89,6 +91,17 @@ export async function auditIngest(
89
91
  ? { trustedRootCaDer: options.trustedRootCaDer }
90
92
  : undefined
91
93
  );
94
+ // Exports: an export's Base ceiling is held to the same writer and chain as
95
+ // the ceiling files, and a trust policy's measurement allowlist is the PCR0
96
+ // list its attestation claims are judged against, unless given explicitly.
97
+ const exportOptions = options?.exports ?? {};
98
+ const allowed = options?.trustAnchors?.allowedMeasurements;
99
+ const exports = await verifyExports(ingest, {
100
+ ...exportOptions,
101
+ ...(exportOptions.ceilingWriter === undefined && options?.ceilings?.writer !== undefined ? { ceilingWriter: options.ceilings.writer } : {}),
102
+ ...(exportOptions.baseChainId === undefined && options?.ceilings?.chainId !== undefined ? { baseChainId: options.ceilings.chainId } : {}),
103
+ ...(exportOptions.pcr0 === undefined && allowed !== undefined ? { pcr0: allowed } : {}),
104
+ });
92
105
 
93
106
  return {
94
107
  runMetadata: {
@@ -107,6 +120,7 @@ export async function auditIngest(
107
120
  temporal,
108
121
  attestations,
109
122
  ceilings,
123
+ exports,
110
124
  };
111
125
  }
112
126
 
@@ -143,23 +157,37 @@ const WITNESS_VERIFICATION_FAILURE_CODES: ReadonlySet<string> = new Set([
143
157
  /**
144
158
  * Derive the CLI exit bit flags from an audit result. Semantics are
145
159
  * documented on the ExitFlags type: bit 1 is verification failures
146
- * (including unsupported-version rejections), bit 2 is chain or authority
147
- * anomalies, divergences between valid proofs, or anchor witness
148
- * verification failures. artifact-unavailable is never a failure by itself;
149
- * attestation results and informational anchor findings never set bits;
150
- * benign ingest findings never set bits.
160
+ * (including unsupported-version rejections, and any export with a FALSE
161
+ * claim or an export-shaped file that could not be checked), bit 2 is chain
162
+ * or authority anomalies, divergences between valid proofs, anchor
163
+ * witness verification failures, a failed ceiling, or a floor problem
164
+ * (TemporalAnalysis.floorProblems). artifact-unavailable is never a failure by
165
+ * itself; the attestation stage's results and informational anchor findings
166
+ * never set bits (an export's own attestation claims are part of its
167
+ * verdict); benign ingest findings never set bits.
151
168
  */
152
169
  export function computeExitFlags(result: AuditResult): ExitFlags {
170
+ // An export is checked as one self-contained object, like a carrier: a
171
+ // FALSE verdict (any FALSE claim) is a verification failure, as a bad proof
172
+ // is, and so is an export-shaped file the audit could not check.
173
+ const exportFailures = (result.exports?.checks ?? []).some(
174
+ (e) => e.status !== "checked" || e.verdict === "FALSE"
175
+ );
153
176
  const verificationFailures =
154
- result.verification.failed > 0 || result.ingest.counts.unsupportedVersion > 0;
177
+ result.verification.failed > 0 || result.ingest.counts.unsupportedVersion > 0 || exportFailures;
155
178
  const witnessVerificationFailures = result.witnesses.findings.some((f) =>
156
179
  WITNESS_VERIFICATION_FAILURE_CODES.has(f.code)
157
180
  );
158
181
  // A ceiling file that is present and wrong is failed evidence, like a bad
159
182
  // witness: bit 2. Pending, unmatched and status notes are reported, never bits.
160
183
  const ceilingFailures = (result.ceilings?.checks ?? []).some((c) => c.status === "failed");
184
+ // A floor problem (a proof signing two floors, a malformed or off-schedule
185
+ // Base floor, a floor header that contradicts or matches no signed floor)
186
+ // is bad evidence too: bit 2, never silently bounded.
187
+ const floorProblems = (result.temporal.floorProblems ?? []).length > 0;
161
188
  const chainAnomaliesOrDivergences =
162
189
  ceilingFailures ||
190
+ floorProblems ||
163
191
  result.anomalies.anomalies.length > 0 ||
164
192
  result.anomalies.divergences.length > 0 ||
165
193
  result.authorities.anomalies.length > 0 ||
package/src/ceilings.ts CHANGED
@@ -74,6 +74,7 @@ export async function verifyCeilings(ingest: IngestResult, options: CeilingAudit
74
74
  status: "verified",
75
75
  ...(r.label ? { label: r.label } : {}),
76
76
  window: {
77
+ ...(w.floor.chain === "base" ? { floorChain: "base" as const } : {}),
77
78
  floorBlock: w.floor.blockNumber || null,
78
79
  floorTime: w.floor.blockTimestamp,
79
80
  ceilingChainId: w.ceiling.chainId,