@mikeargento/bitgraph-audit 0.7.0 → 0.9.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 +4 -0
  2. package/dist/audit.d.ts +8 -6
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js +28 -8
  5. package/dist/audit.js.map +1 -1
  6. package/dist/ceilings.d.ts +25 -0
  7. package/dist/ceilings.d.ts.map +1 -1
  8. package/dist/ceilings.js +159 -31
  9. package/dist/ceilings.js.map +1 -1
  10. package/dist/cli.js +40 -3
  11. package/dist/cli.js.map +1 -1
  12. package/dist/exports.d.ts +39 -0
  13. package/dist/exports.d.ts.map +1 -0
  14. package/dist/exports.js +371 -0
  15. package/dist/exports.js.map +1 -0
  16. package/dist/index.d.ts +5 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +4 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/ingest.d.ts.map +1 -1
  21. package/dist/ingest.js +179 -19
  22. package/dist/ingest.js.map +1 -1
  23. package/dist/report-json.d.ts.map +1 -1
  24. package/dist/report-json.js +15 -0
  25. package/dist/report-json.js.map +1 -1
  26. package/dist/report-md.d.ts.map +1 -1
  27. package/dist/report-md.js +225 -0
  28. package/dist/report-md.js.map +1 -1
  29. package/dist/settlement-blobs.d.ts +131 -0
  30. package/dist/settlement-blobs.d.ts.map +1 -0
  31. package/dist/settlement-blobs.js +682 -0
  32. package/dist/settlement-blobs.js.map +1 -0
  33. package/dist/types.d.ts +254 -3
  34. package/dist/types.d.ts.map +1 -1
  35. package/package.json +5 -3
  36. package/src/__tests__/fixtures/settlement/0x018efc046e94610e2530344caf1574e507a3182e1a975fb25b032ec1434a3f5c.bin +0 -0
  37. package/src/__tests__/fixtures/settlement/ceiling-51979918.json +101 -0
  38. package/src/__tests__/fixtures/settlement/pointer-51979918.json +69 -0
  39. package/src/__tests__/settlement.test.ts +254 -0
  40. package/src/audit.ts +30 -8
  41. package/src/ceilings.ts +167 -33
  42. package/src/cli.ts +39 -3
  43. package/src/exports.ts +478 -0
  44. package/src/index.ts +16 -0
  45. package/src/ingest.ts +192 -18
  46. package/src/report-json.ts +15 -0
  47. package/src/report-md.ts +248 -0
  48. package/src/settlement-blobs.ts +689 -0
  49. package/src/types.ts +234 -2
package/src/ceilings.ts CHANGED
@@ -9,10 +9,27 @@
9
9
  *
10
10
  * The writer address is a trust input, like a measurement allowlist: the
11
11
  * default is the address BitGraph publishes, and a reader can pass another.
12
+ *
13
+ * A ceiling file may carry its SETTLEMENT on Ethereum (bitgraph-settlement/1,
14
+ * the `settlement` field): the batcher transaction whose blobs hold the Base
15
+ * block. Two layers, both offline, reported as separate lines: the POINTER
16
+ * (verifySettlementPointer, against the Base mainnet pins or ones the reader
17
+ * passes) and the BLOBS (blob bytes kept in the bundle next to the ceiling
18
+ * file or under a `blobs/` folder, verified against the listed KZG
19
+ * commitments and decoded down to the ceiling transaction). Blob bytes
20
+ * absent from the bundle are reported, never a failure; a pointer or blob
21
+ * that is present and wrong fails the ceiling like any other bad evidence.
22
+ * The settlement is checked whether or not the ceiling's proof is in the
23
+ * bundle: it is evidence about the ceiling file itself.
12
24
  */
13
25
 
14
- import { verifyCeiling, checkCeilingOnline, type CeilingSidecar } from "@mikeargento/bitgraph-verify";
15
- import type { CeilingAnalysis, CeilingCheck, IngestResult } from "./types.js";
26
+ import {
27
+ verifyCeiling, checkCeilingOnline, verifySettlementPointer, BASE_MAINNET_SETTLEMENT_PINS, decodeHeader, evmHexToBytes,
28
+ type CeilingSidecar, type SettlementPointer, type SettlementPins,
29
+ } from "@mikeargento/bitgraph-verify";
30
+ import { streamArtifactsByHash } from "./ingest.js";
31
+ import { verifySettlementBlobs } from "./settlement-blobs.js";
32
+ import type { CeilingAnalysis, CeilingCheck, CeilingSettlementCheck, IngestResult } from "./types.js";
16
33
 
17
34
  /** BitGraph's published ceiling writer on Base mainnet (bitgraph.ing/ceilings). */
18
35
  export const BITGRAPH_CEILING_WRITER = "0xf3972408D853c975F86351C311f4310220bbF2a3";
@@ -27,51 +44,64 @@ export interface CeilingAuditOptions {
27
44
  * network call (its zero-network guarantee), so the CLI never sets this.
28
45
  */
29
46
  getBlockHash?: (blockNumber: number) => Promise<string | null>;
47
+ /** The batch inbox and batcher a settlement pointer must name. Base mainnet's by default. */
48
+ settlementPins?: SettlementPins;
30
49
  }
31
50
 
32
51
  export async function verifyCeilings(ingest: IngestResult, options: CeilingAuditOptions = {}): Promise<CeilingAnalysis> {
33
52
  const writer = options.writer ?? BITGRAPH_CEILING_WRITER;
34
53
  const chainId = options.chainId ?? BASE_MAINNET_CHAIN_ID;
54
+ const pins = options.settlementPins ?? BASE_MAINNET_SETTLEMENT_PINS;
35
55
  const byHash = new Map(ingest.proofs.map((p) => [p.proofHash, p]));
36
56
  const checks: CeilingCheck[] = [];
37
57
  for (const f of ingest.ceilings ?? []) {
38
58
  const side = f.json as unknown as CeilingSidecar;
39
59
  const proofHash = typeof side.proofHash === "string" ? side.proofHash : null;
40
60
  const observed = proofHash ? byHash.get(proofHash) : undefined;
61
+ let check: CeilingCheck;
41
62
  if (!observed) {
42
- checks.push({ path: f.path, proofHash, status: "unmatched", reason: "no proof in this bundle has this ceiling's proofHash", onChain: null });
43
- continue;
44
- }
45
- const r = await verifyCeiling(observed.proof, side, { writerAddress: writer, chainId });
46
- if (!r.ok) {
47
- const pending = r.status === "pending";
48
- checks.push({ path: f.path, proofHash, status: pending ? "pending" : "failed", ...(r.reason ? { reason: r.reason } : {}), onChain: null });
49
- continue;
63
+ check = { path: f.path, proofHash, status: "unmatched", reason: "no proof in this bundle has this ceiling's proofHash", onChain: null };
64
+ } else {
65
+ const r = await verifyCeiling(observed.proof, side, { writerAddress: writer, chainId });
66
+ if (!r.ok) {
67
+ const pending = r.status === "pending";
68
+ check = { path: f.path, proofHash, status: pending ? "pending" : "failed", ...(r.reason ? { reason: r.reason } : {}), onChain: null };
69
+ } else {
70
+ const w = r.window!;
71
+ check = {
72
+ path: f.path,
73
+ proofHash,
74
+ status: "verified",
75
+ ...(r.label ? { label: r.label } : {}),
76
+ window: {
77
+ floorBlock: w.floor.blockNumber || null,
78
+ floorTime: w.floor.blockTimestamp,
79
+ ceilingChainId: w.ceiling.chainId,
80
+ ceilingBlock: w.ceiling.blockNumber,
81
+ ceilingTime: w.ceiling.blockTimestamp,
82
+ widthSeconds: w.widthSeconds,
83
+ },
84
+ onChain: null,
85
+ onChainDetail: "header not checked against chain",
86
+ };
87
+ if (options.getBlockHash) {
88
+ const online = await checkCeilingOnline(side, options.getBlockHash);
89
+ check.onChain = online.onChain;
90
+ check.onChainDetail = online.detail;
91
+ if (online.onChain === false) {
92
+ check.status = "failed";
93
+ check.reason = `chain: ${online.detail}`;
94
+ }
95
+ }
96
+ }
50
97
  }
51
- const w = r.window!;
52
- const check: CeilingCheck = {
53
- path: f.path,
54
- proofHash,
55
- status: "verified",
56
- ...(r.label ? { label: r.label } : {}),
57
- window: {
58
- floorBlock: w.floor.blockNumber || null,
59
- floorTime: w.floor.blockTimestamp,
60
- ceilingChainId: w.ceiling.chainId,
61
- ceilingBlock: w.ceiling.blockNumber,
62
- ceilingTime: w.ceiling.blockTimestamp,
63
- widthSeconds: w.widthSeconds,
64
- },
65
- onChain: null,
66
- onChainDetail: "header not checked against chain",
67
- };
68
- if (options.getBlockHash) {
69
- const online = await checkCeilingOnline(side, options.getBlockHash);
70
- check.onChain = online.onChain;
71
- check.onChainDetail = online.detail;
72
- if (online.onChain === false) {
98
+ const carried = f.json["settlement"];
99
+ if (carried !== null && carried !== undefined) {
100
+ const settlement = await auditSettlement(f.path, side, carried, ingest, pins);
101
+ check.settlement = settlement;
102
+ if (settlement.status === "failed" && check.status !== "failed") {
73
103
  check.status = "failed";
74
- check.reason = `chain: ${online.detail}`;
104
+ check.reason = settlement.lines.find((l) => l.includes("FAILED")) ?? "settlement: failed";
75
105
  }
76
106
  }
77
107
  checks.push(check);
@@ -83,3 +113,107 @@ export async function verifyCeilings(ingest: IngestResult, options: CeilingAudit
83
113
  }));
84
114
  return { writer, chainId, checks, statuses };
85
115
  }
116
+
117
+ // ── settlement ─────────────────────────────────────────────────────────────
118
+
119
+ function whenUtc(unix: number): string {
120
+ const iso = new Date(unix * 1000).toISOString();
121
+ return `${iso.slice(0, 10)} ${iso.slice(11, 19)} UTC`;
122
+ }
123
+
124
+ /** The bundle paths a blob file named `file` may sit at, next to the ceiling file first. */
125
+ function blobCandidatePaths(ceilingPath: string, file: string): string[] {
126
+ const slash = ceilingPath.lastIndexOf("/");
127
+ const dir = slash < 0 ? "" : ceilingPath.slice(0, slash + 1);
128
+ return [`${dir}${file}`, `${dir}blobs/${file}`, `blobs/${file}`, file];
129
+ }
130
+
131
+ async function auditSettlement(
132
+ path: string,
133
+ side: CeilingSidecar,
134
+ carried: unknown,
135
+ ingest: IngestResult,
136
+ pins: SettlementPins,
137
+ ): Promise<CeilingSettlementCheck> {
138
+ const lines: string[] = [];
139
+ const failed = (pointer: CeilingSettlementCheck["pointer"], blobs: CeilingSettlementCheck["blobs"]): CeilingSettlementCheck =>
140
+ ({ status: "failed", pointer, blobs, lines });
141
+ const noBlobs = (detail: string, listed: number): CeilingSettlementCheck["blobs"] => ({ status: "absent", detail, listed, supplied: 0 });
142
+
143
+ // 1. The pointer: header, inclusion, batcher, blob commitments.
144
+ const pointer = carried as SettlementPointer;
145
+ const p = verifySettlementPointer(pointer, pins);
146
+ const listed = Array.isArray(pointer?.blobs) ? pointer.blobs.length : 0;
147
+ if (!p.ok) {
148
+ lines.push(`settlement: pointer FAILED (${p.reason ?? "invalid"})`);
149
+ return failed({ ok: false, reason: p.reason ?? "invalid" }, noBlobs("not checked: the pointer failed", listed));
150
+ }
151
+ const existedBy = p.existedBy!;
152
+ // The pointer must be about THIS ceiling's Base block and transaction.
153
+ const anchor = side.anchor;
154
+ let mismatch: string | null = null;
155
+ if (!anchor) mismatch = "the ceiling has no Base transaction yet";
156
+ else if (pointer.baseBlockNumber !== anchor.blockNumber || pointer.baseBlockHash.toLowerCase() !== anchor.blockHash.toLowerCase()) {
157
+ mismatch = `the pointer names Base block ${pointer.baseBlockNumber} (${pointer.baseBlockHash.slice(0, 10)}…), this ceiling is in block ${anchor.blockNumber} (${anchor.blockHash.slice(0, 10)}…)`;
158
+ } else if (pointer.located && pointer.located.txHash.toLowerCase() !== anchor.txHash.toLowerCase()) {
159
+ mismatch = `the pointer locates transaction ${pointer.located.txHash.slice(0, 10)}…, this ceiling's is ${anchor.txHash.slice(0, 10)}…`;
160
+ }
161
+ if (mismatch) {
162
+ lines.push(`settlement: pointer FAILED (${mismatch})`);
163
+ return failed({ ok: false, reason: mismatch, existedBy }, noBlobs("not checked: the pointer is for another block", listed));
164
+ }
165
+ lines.push(`settlement: pointer ok, Ethereum block ${existedBy.blockNumber} at ${whenUtc(existedBy.blockTimestamp)}`);
166
+ const pointerOk: CeilingSettlementCheck["pointer"] = { ok: true, existedBy };
167
+
168
+ // 2. The blobs, when the bundle holds their bytes.
169
+ const wanted = new Map<string, string>(); // sha256 hex -> versioned hash
170
+ const byPath = new Map<string, string>(); // bundle path -> sha256 hex
171
+ for (const a of ingest.artifacts) for (const ap of a.paths) byPath.set(ap, a.sha256Hex);
172
+ const byBasename = new Map<string, string>();
173
+ for (const a of ingest.artifacts) for (const ap of a.paths) byBasename.set(ap.slice(ap.lastIndexOf("/") + 1), a.sha256Hex);
174
+ for (const b of pointer.blobs) {
175
+ const file = typeof b.file === "string" ? b.file : `${b.versionedHash.toLowerCase()}.bin`;
176
+ if (file.includes("/") || file.includes("\\") || file.includes("..")) continue;
177
+ const hex = blobCandidatePaths(path, file).map((c) => byPath.get(c)).find((h) => h !== undefined) ?? byBasename.get(file);
178
+ if (hex) wanted.set(hex, b.versionedHash.toLowerCase());
179
+ }
180
+ if (wanted.size === 0) {
181
+ lines.push("settlement: blobs not in bundle");
182
+ return { status: "pointer-only", pointer: pointerOk, blobs: noBlobs("blobs not in bundle", listed), lines };
183
+ }
184
+ const bytesByHash = new Map<string, Uint8Array>();
185
+ for await (const m of streamArtifactsByHash(ingest, wanted.keys())) bytesByHash.set(wanted.get(m.sha256Hex)!, m.bytes);
186
+ let expectParent: string | undefined;
187
+ try {
188
+ expectParent = anchor ? decodeHeader(evmHexToBytes(anchor.blockHeader)).parentHash : undefined;
189
+ } catch {
190
+ expectParent = undefined;
191
+ }
192
+ const r = await verifySettlementBlobs(pointer, bytesByHash, {
193
+ expect: {
194
+ txHash: anchor!.txHash,
195
+ rawTx: anchor!.rawTx,
196
+ ...(expectParent ? { baseParentHash: expectParent } : {}),
197
+ },
198
+ });
199
+ if (!r.ok) {
200
+ lines.push(`settlement: blobs FAILED (${r.reason ?? "invalid"})`);
201
+ return failed(pointerOk, { status: "failed", detail: r.reason ?? "invalid", listed, supplied: bytesByHash.size });
202
+ }
203
+ const c = r.channel!;
204
+ const frames = c.complete ? `all ${c.framesDecoded} frames` : `frames 0..${c.framesDecoded - 1}${c.framesTotal ? ` of ${c.framesTotal}` : ""}`;
205
+ const loc = r.located!;
206
+ lines.push(`settlement: blobs decoded (${frames}), ceiling tx located in Base block ${loc.baseBlockNumber}${loc.batchComplete ? "" : " (batch cut at the last frame supplied)"}`);
207
+ return {
208
+ status: "verified",
209
+ pointer: pointerOk,
210
+ blobs: {
211
+ status: "verified",
212
+ detail: `${bytesByHash.size} of ${listed} blobs supplied; ${frames} decoded; ${r.checks.map((k) => k.detail ?? k.name).join("; ")}`,
213
+ listed,
214
+ supplied: bytesByHash.size,
215
+ located: { baseBlockNumber: loc.baseBlockNumber, baseTxIndex: loc.baseTxIndex, txHash: loc.txHash, batchComplete: loc.batchComplete, framesDecoded: c.framesDecoded, framesTotal: c.framesTotal },
216
+ },
217
+ lines,
218
+ };
219
+ }
package/src/cli.ts CHANGED
@@ -25,6 +25,7 @@ import { parseCarrier, verifyCarrier } from "@mikeargento/bitgraph-verify";
25
25
  import type { VerificationPolicy } from "@mikeargento/bitgraph-verify";
26
26
  import { auditToolVersion, computeExitFlags, runAudit } from "./audit.js";
27
27
  import { attestationTimestampMs } from "./attestation.js";
28
+ import { exportRunClaims } from "./exports.js";
28
29
  import { buildJsonReport } from "./report-json.js";
29
30
  import { buildMarkdownReport } from "./report-md.js";
30
31
  import type { ExitFlags } from "./types.js";
@@ -67,6 +68,14 @@ function helpText(): string {
67
68
  "printed first.",
68
69
  "archive. The audit runs entirely offline: no RPC, no HTTP, no DNS.",
69
70
  "",
71
+ "Exports (bitgraph-export/1) anywhere in the bundle are found by their",
72
+ "format field. Each is checked with verifyExport once per file in the",
73
+ "bundle it covers (by SHA-256: a member's committed bytes or original;",
74
+ "for the owner's export, any leaf's), or once without a file when none",
75
+ "is there, and its proof joins the proof analysis. Every claim, the",
76
+ "covered files and the three time claims (floor; ceiling on Base,",
77
+ "provisional; ceiling on Ethereum) are reported, never merged.",
78
+ "",
70
79
  "Options:",
71
80
  " --out <dir> Directory to write the report files into",
72
81
  " (default: current directory; created if missing).",
@@ -98,7 +107,12 @@ function helpText(): string {
98
107
  " are absent from the bundle is NOT a failure by itself: its",
99
108
  " bytes-free checks decide, unless a supplied trust policy makes",
100
109
  " them fail (for example requireSlot), in which case it counts",
101
- " here.",
110
+ " here. Also: an export (bitgraph-export/1) with any FALSE claim,",
111
+ " its attestation claims included (an export is checked as one",
112
+ " self-contained object, as a carrier is), or an export-shaped",
113
+ " file that is malformed, of an unsupported format, or too large",
114
+ " to read. A claim the export does not carry (NOT_CARRIED: a",
115
+ " pending ceiling, the covered file absent) is never a failure.",
102
116
  " 2 Chain or authority anomalies, divergences between valid proofs,",
103
117
  " or anchor witness verification failures: unexplained counter",
104
118
  " positions, chain breaks, collisions, cross-kind position reuse,",
@@ -122,7 +136,8 @@ function helpText(): string {
122
136
  "change the exit code on their own: an invalid attestation document on",
123
137
  "an otherwise verified proof is reported without affecting the exit",
124
138
  "code, and counts under exit bit 1 only when a supplied trust policy",
125
- "made verification itself fail.",
139
+ "made verification itself fail. The exception is an export's own",
140
+ "attestation claims, which are part of the export's verdict (exit 1).",
126
141
  ].join("\n");
127
142
  }
128
143
 
@@ -234,7 +249,7 @@ function loadTrustPolicy(path: string): VerificationPolicy {
234
249
  function exitMeaning(flags: ExitFlags): string {
235
250
  if (flags.code === 0) return "clean: no verification failures, no chain anomalies, no divergences";
236
251
  const parts: string[] = [];
237
- if (flags.verificationFailures) parts.push("verification failures");
252
+ if (flags.verificationFailures) parts.push("verification failures (a proof, or an export with a FALSE claim)");
238
253
  if (flags.chainAnomaliesOrDivergences) {
239
254
  parts.push("chain anomalies, divergences, or anchor witness verification failures (or a failed ceiling in time)");
240
255
  }
@@ -375,8 +390,29 @@ async function main(): Promise<number> {
375
390
  process.stdout.write(c.status === "verified"
376
391
  ? `ceiling VERIFIED ${c.path}: ${c.label ?? ""}${width}; ${c.onChainDetail ?? ""}\n`
377
392
  : `ceiling ${c.status.toUpperCase()} ${c.path}: ${c.reason ?? ""}\n`);
393
+ for (const line of c.settlement?.lines ?? []) process.stdout.write(` ${line}\n`);
378
394
  }
379
395
  for (const s of result.ceilings?.statuses ?? []) process.stdout.write(`ceiling ${s.status} ${s.path}: ${s.note}\n`);
396
+ for (const e of result.exports?.checks ?? []) {
397
+ if (e.status !== "checked") {
398
+ process.stdout.write(`export NOT CHECKED ${e.path}: ${e.reason ?? e.status}\n`);
399
+ continue;
400
+ }
401
+ const withFile = e.runs.filter((r) => r.file !== null).length;
402
+ const files = e.files.length === 0 ? "no covered file in the bundle" : `${withFile} of ${e.files.length} covered file${e.files.length === 1 ? "" : "s"} checked`;
403
+ process.stdout.write(`export ${e.verdict} ${e.path}: ${e.kind ?? "export"}, ${files}${e.failedClaims.length > 0 ? `; FALSE: ${e.failedClaims.join(", ")}` : ""}\n`);
404
+ // The three time claims, one line each, never merged.
405
+ const t = e.times;
406
+ const when = (unix: number) => new Date(unix * 1000).toISOString();
407
+ const firstClaims = e.runs[0] !== undefined ? exportRunClaims(e, e.runs[0]) : e.claims;
408
+ const why = (id: string) => {
409
+ const c = firstClaims.find((x) => x.id === id);
410
+ return c ? `not established (${c.result}: ${c.detail})` : "not established";
411
+ };
412
+ process.stdout.write(` floor: ${t.floor ? `committed bytes finished after Ethereum block ${t.floor.blockNumber} (mined ${when(t.floor.blockTimestamp)})` : why("floor.header")}\n`);
413
+ process.stdout.write(` ceiling on Base: ${t.ceilingBase ? `existed by Base block ${t.ceilingBase.blockNumber} (${when(t.ceilingBase.blockTimestamp)})${t.ceilingBase.provisional ? ", provisional until checked against Base" : ""}` : why("ceiling.base")}\n`);
414
+ process.stdout.write(` ceiling on Ethereum: ${t.ceilingEthereum ? `existed by Ethereum block ${t.ceilingEthereum.blockNumber} (${when(t.ceilingEthereum.blockTimestamp)})` : why("ceiling.ethereum")}\n`);
415
+ }
380
416
  process.stdout.write(
381
417
  `bitgraph-audit ${auditToolVersion()}: wrote ${written.join(", ")}\n` +
382
418
  `exit ${flags.code}: ${exitMeaning(flags)}\n`