@sanning/proof 0.14.0 → 0.16.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.
@@ -131,10 +131,15 @@
131
131
  // The kernel's one reader for bytes a producer signed: strict UTF-8, no leading
132
132
  // byte-order mark, no non-finite literal. `jcs_loads` in
133
133
  // `src/sanning_proof/canonicalize.py` is the twin (proof#140 gate 5).
134
- import { hexToBytes } from "./crypto.js";
135
- import { jcsParse } from "./verifier.js";
134
+ import { hexToBytes, utf8 } from "./crypto.js";
135
+ import { jcs, jcsParse } from "./verifier.js";
136
136
  import { isConformantHandoffRecord, isHandoffNamespace, type HandoffRecord } from "./handoff.js";
137
- import { hasSideContent, inBodyContentOffered, type SideContentMap } from "./side-content.js";
137
+ import {
138
+ hasSideContent,
139
+ inBodyContentOffered,
140
+ sideContentEntry,
141
+ type SideContentMap,
142
+ } from "./side-content.js";
138
143
 
139
144
  /** The first declaration schema, which every earlier reader knows. */
140
145
  export const DECLARATION_VERSION = "sanning.declaration/v1";
@@ -278,6 +283,23 @@ export const DECLARATION_NOTE_KINDS = [
278
283
  * because its payload binding is undetermined.
279
284
  */
280
285
  "role_step_withheld",
286
+ /**
287
+ * Rule 10 (v1.29, A36). A record whose signed `event_type` is `declaration`
288
+ * and whose content HANDED IN to this reader is exactly `JCS(object)` of a
289
+ * declaration object of a version this reader knows, carrying exactly that
290
+ * version's keys, and not this pack's own. The note names the content handed
291
+ * in, never the record: whether those bytes bind to the record's
292
+ * `content_hash` is the bundle verifier's check, and an accuser can hand in
293
+ * declaration-shaped bytes for an honest record. What the pack holds is what
294
+ * the declaration in its body says, so this moves no verdict.
295
+ */
296
+ "other_declaration_held",
297
+ /**
298
+ * Rule 10. A record whose signed `event_type` is `declaration` and whose
299
+ * content did not reach this reader from either channel, so whether it is a
300
+ * declaration other than this pack's own is UNKNOWN.
301
+ */
302
+ "other_declaration_withheld",
281
303
  ] as const;
282
304
  export type DeclarationNoteKind = (typeof DECLARATION_NOTE_KINDS)[number];
283
305
 
@@ -698,6 +720,149 @@ function roleReading(
698
720
  return null;
699
721
  }
700
722
 
723
+ /**
724
+ * The `event_type` a declaration record is signed under: a reserved
725
+ * single-segment name, and the one `@sanning/anchor`'s `bundle` anchors a
726
+ * declaration as.
727
+ */
728
+ const DECLARATION_EVENT_TYPE = "declaration";
729
+
730
+ /**
731
+ * The keys a declaration object carries, under every version this reader
732
+ * knows: exactly these, no more and no fewer (5.4.1).
733
+ */
734
+ const DECLARATION_KEYS = ["declaration_version", "case_id", "agents", "period", "records", "handoffs"];
735
+
736
+ function hasExactKeys(value: unknown, keys: string[]): value is Record<string, unknown> {
737
+ if (!isObject(value)) return false;
738
+ const have = Object.keys(value);
739
+ return have.length === keys.length && keys.every((k) => Object.prototype.hasOwnProperty.call(value, k));
740
+ }
741
+
742
+ /**
743
+ * Whether `obj` is shaped like a declaration this reader knows: one of
744
+ * `DECLARATION_VERSIONS` compared exactly, exactly the keys that version
745
+ * defines, and the value types this module reads.
746
+ *
747
+ * It is the shape test `@sanning/anchor`'s `isDeclarationShape` makes, and it
748
+ * checks shape only, not the 5.4.1 rules `readDeclaration` grades: a stale
749
+ * declaration is somebody else's claim, and this reader does not grade it, it
750
+ * names it.
751
+ */
752
+ function isDeclarationShape(obj: unknown): boolean {
753
+ if (!isObject(obj)) return false;
754
+ const version = obj["declaration_version"];
755
+ if (version !== DECLARATION_VERSION && version !== DECLARATION_VERSION_V2) return false;
756
+ if (!hasExactKeys(obj, DECLARATION_KEYS)) return false;
757
+ if (!isId(obj["case_id"])) return false;
758
+ const agents = obj["agents"];
759
+ if (!Array.isArray(agents) || !agents.every((a) => typeof a === "string")) return false;
760
+ const period = obj["period"];
761
+ if (!hasExactKeys(period, ["start", "end"])) return false;
762
+ if (typeof period["start"] !== "string" || typeof period["end"] !== "string") return false;
763
+ const recordKeys =
764
+ version === DECLARATION_VERSION_V2 ? ["event_id", "disclosure", "role"] : ["event_id", "disclosure"];
765
+ const records = obj["records"];
766
+ if (!Array.isArray(records)) return false;
767
+ for (const r of records) {
768
+ if (!hasExactKeys(r, recordKeys) || typeof r["event_id"] !== "string") return false;
769
+ }
770
+ const handoffs = obj["handoffs"];
771
+ if (!Array.isArray(handoffs)) return false;
772
+ return handoffs.every((h) => hasExactKeys(h, ["sent_event_id", "received_event_id"]));
773
+ }
774
+
775
+ /**
776
+ * The content bytes disclosed for one record, or `null` where none are usable:
777
+ * `events[].content` first, then the side-content map, chosen the way the
778
+ * bundle verifier chooses the bytes it binds.
779
+ */
780
+ function disclosedBytes(
781
+ ev: unknown,
782
+ p: PackRecord,
783
+ content: SideContentMap | null | undefined,
784
+ ): Uint8Array | null {
785
+ const inBody = isObject(ev) ? ev["content"] : undefined;
786
+ if (typeof inBody === "string") {
787
+ try {
788
+ return hexToBytes(inBody);
789
+ } catch {
790
+ // falls through to the side channel, as the bundle verifier does
791
+ }
792
+ }
793
+ if (!p.nameable) return null;
794
+ const side = sideContentEntry(content, p.eventId).value;
795
+ if (side instanceof Uint8Array) return side;
796
+ if (typeof side === "string") {
797
+ try {
798
+ return hexToBytes(side);
799
+ } catch {
800
+ return null;
801
+ }
802
+ }
803
+ return null;
804
+ }
805
+
806
+ function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
807
+ if (a.length !== b.length) return false;
808
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
809
+ return true;
810
+ }
811
+
812
+ /**
813
+ * Whether `raw` is exactly `JCS(object)` of a declaration-shaped object.
814
+ * Canonical bytes, not merely parseable ones: a declaration is anchored as
815
+ * `JCS(declaration)`, and nothing an agent writes by accident.
816
+ */
817
+ function isDeclarationBytes(raw: Uint8Array): boolean {
818
+ try {
819
+ const obj = jcsParse(raw);
820
+ return isDeclarationShape(obj) && sameBytes(utf8(jcs(obj)), raw);
821
+ } catch {
822
+ return false;
823
+ }
824
+ }
825
+
826
+ /**
827
+ * Rule 10: every record that is a declaration other than the pack's own, in
828
+ * bundle order.
829
+ *
830
+ * The record's `event_type` is read from its signed `event_bytes`, so a record
831
+ * whose bytes the pack withholds is not named: the reader cannot see what it
832
+ * was signed as. Its content is read from either channel; a record whose
833
+ * content is byte for byte `JCS` of the pack's own declaration is the pack's
834
+ * own and is never named.
835
+ */
836
+ function otherDeclarationNotes(
837
+ bundle: unknown,
838
+ pack: PackRecord[],
839
+ declaration: unknown,
840
+ content: SideContentMap | null | undefined,
841
+ ): DeclarationNote[] {
842
+ const events = pack.length > 0 ? (bodyOf(bundle)?.events ?? []) : [];
843
+ let own: Uint8Array | null = null;
844
+ try {
845
+ own = utf8(jcs(declaration));
846
+ } catch {
847
+ own = null;
848
+ }
849
+ const notes: DeclarationNote[] = [];
850
+ pack.forEach((p, i) => {
851
+ if (p.record === null || p.record.event_type !== DECLARATION_EVENT_TYPE) return;
852
+ const raw = disclosedBytes(events[i], p, content);
853
+ if (raw === null) {
854
+ // Offered and unusable is not withheld: something arrived, it is not a
855
+ // declaration, and the bundle verifier names what is wrong with it.
856
+ // Nothing arrived is the reading the reader cannot settle.
857
+ if (!p.disclosed) notes.push({ kind: "other_declaration_withheld", eventId: p.eventId });
858
+ return;
859
+ }
860
+ if (own !== null && sameBytes(raw, own)) return;
861
+ if (isDeclarationBytes(raw)) notes.push({ kind: "other_declaration_held", eventId: p.eventId });
862
+ });
863
+ return notes;
864
+ }
865
+
701
866
  /**
702
867
  * Check a pack against the declaration inside its body.
703
868
  *
@@ -915,6 +1080,12 @@ export function checkDeclaration(
915
1080
  }
916
1081
  }
917
1082
 
1083
+ // Rule 10 (5.4.2 v1.29, A36): content handed in for a record signed
1084
+ // `declaration` that is a declaration other than this pack's own. Notes only, in bundle order, after every other note, and
1085
+ // never a deviation: the pack holds what its own declaration says whatever
1086
+ // one of its records says.
1087
+ notes.push(...otherDeclarationNotes(bundle, pack, declaration, content));
1088
+
918
1089
  // A proven deviation wins: it is a mismatch between what the pack claimed
919
1090
  // and what its own signed body carries, and a failing check always wins.
920
1091
  // With no deviation and a content-dependent finding, the pack lines up with
@@ -1120,6 +1291,18 @@ export function renderNote(note: DeclarationNote): string {
1120
1291
  `${note.eventId} is signed as a step and this pack withholds its ` +
1121
1292
  "record bytes, so whether it is one is UNKNOWN"
1122
1293
  );
1294
+ case "other_declaration_held":
1295
+ return (
1296
+ `the content handed in for ${note.eventId} is a declaration that is ` +
1297
+ "not this pack's own: it says what some pack holds, and what this " +
1298
+ "pack holds is what the declaration in its body says"
1299
+ );
1300
+ case "other_declaration_withheld":
1301
+ return (
1302
+ `${note.eventId} is signed as a declaration and no content for it ` +
1303
+ "reached this verifier, so whether it is a declaration other than " +
1304
+ "this pack's own is UNKNOWN"
1305
+ );
1123
1306
  }
1124
1307
  }
1125
1308
 
package/src/index.ts CHANGED
@@ -251,6 +251,31 @@ export {
251
251
  witnessTimeQuery,
252
252
  } from "./witness-cache.js";
253
253
  export type { CachedWitnessTime, WitnessTimeAnswer, WitnessTimeCheck } from "./witness-cache.js";
254
+
255
+ // The public-record (witness) lookup the CLI runs with `--gateways`
256
+ // (src/witness-lookup.ts, proof#200): moved out of the CLI so a consumer
257
+ // that is not the CLI, the verify page, calls the SAME implementation
258
+ // rather than keeping its own copy (verify-page#70). Pure kernel scope: the
259
+ // one network call lives in `lookupPublicRecord` and `onChainHalf`, runs
260
+ // only when a caller hands in a gateway, and goes through `options.fetchImpl`
261
+ // where a caller supplies one (a browser page's own `fetch`) or the ambient
262
+ // global `fetch` otherwise, so this export runs in a browser exactly as it
263
+ // runs in Node. `onChainHalf` and `witnessLine` are the CLI's own unchanged
264
+ // building blocks; `lookupPublicRecord` is the new one-call, four-state entry
265
+ // point a page wants, additive over both. `confirmed` from
266
+ // `lookupPublicRecord` is a claim about the BYTES only, never about whether
267
+ // the pack itself verifies, and a caller must never show it apart from the
268
+ // pack's own verdict.
269
+ export { lookupPublicRecord, onChainHalf, witnessLine } from "./witness-lookup.js";
270
+ export type {
271
+ OnChainHalf,
272
+ OnChainRow,
273
+ PublicRecordPack,
274
+ PublicRecordResult,
275
+ PublicRecordState,
276
+ WitnessLookupOptions,
277
+ WitnessTimeRow,
278
+ } from "./witness-lookup.js";
254
279
  export type {
255
280
  CaseException,
256
281
  CaseExceptionKind,
@@ -0,0 +1,428 @@
1
+ // The public-record (witness) lookup the CLI runs with `--gateways`
2
+ // (evidence-bundle.md §5.5.6, v1.20, A24, D7): re-fetch each pack's witness on
3
+ // the public record and report what it said. Moved here from `cli.ts`
4
+ // (proof#200) so the CLI and a consumer that is not the CLI, the verify page,
5
+ // a browser app, call the SAME implementation rather than each keeping its
6
+ // own copy that can drift.
7
+ //
8
+ // Pure by the kernel's own rule (specs/architecture.md §3): the network call
9
+ // is the one exception this module exists to hold, and it never runs unless a
10
+ // caller hands in a gateway to ask. `options.fetchImpl` is the seam: where a
11
+ // caller supplies one (a browser page's own `fetch`, or a test's fake), that
12
+ // is the ONE this module calls; where none is given, it falls back to the
13
+ // ambient global `fetch`, the same fallback `cli.ts` already applied at its
14
+ // own call site before this module existed.
15
+ //
16
+ // `onChainHalf` and `witnessLine` are the CLI's own building blocks,
17
+ // UNCHANGED (proof#200 is additive: the CLI's `case` output and exit codes are
18
+ // byte-for-byte what they were). `lookupPublicRecord` is the new export:
19
+ // one pack (or several) in, one four-state result out, with a pack's
20
+ // `witness-cache.json` read as part of the pack rather than as a side file a
21
+ // caller must remember to also pass in.
22
+
23
+ import { verifyEvidenceBundle } from "./evidence.js";
24
+ import type { WitnessLine, WitnessState } from "./case.js";
25
+ import { WITNESS_NOT_LOOKED_UP, renderWitnessLine } from "./case.js";
26
+ import {
27
+ type CachedWitnessTime,
28
+ type WitnessTimeAnswer,
29
+ type WitnessTimeCheck,
30
+ checkWitnessCache,
31
+ readWitnessCache,
32
+ renderWitnessTime,
33
+ } from "./witness-cache.js";
34
+
35
+ /** The seam every network call in this module goes through: used where given, else the ambient global `fetch`. */
36
+ export interface WitnessLookupOptions {
37
+ fetchImpl?: typeof fetch;
38
+ }
39
+
40
+ export interface OnChainRow {
41
+ ref: string;
42
+ witnesses: number;
43
+ confirmed: number;
44
+ mismatched: number;
45
+ unreachable: number;
46
+ not_checked: number;
47
+ /** Present only where the pack caches a witness time (5.6.6, v1.25, A30). */
48
+ witness_times?: WitnessTimeRow[];
49
+ }
50
+
51
+ export interface WitnessTimeRow {
52
+ stamp_ref: string;
53
+ cached: { block_height: number; block_time: number };
54
+ outcome: WitnessTimeCheck["outcome"];
55
+ answers: {
56
+ gateway: string;
57
+ outcome: WitnessTimeAnswer["outcome"];
58
+ block_height: number | null;
59
+ block_time: number | null;
60
+ }[];
61
+ }
62
+
63
+ function witnessTimeRow(t: WitnessTimeCheck): WitnessTimeRow {
64
+ return {
65
+ stamp_ref: t.cached.stampRef,
66
+ cached: { block_height: t.cached.blockHeight, block_time: t.cached.blockTime },
67
+ outcome: t.outcome,
68
+ answers: t.answers.map((a) => ({
69
+ gateway: a.gateway,
70
+ outcome: a.outcome,
71
+ block_height: a.blockHeight,
72
+ block_time: a.blockTime,
73
+ })),
74
+ };
75
+ }
76
+
77
+ export interface OnChainHalf {
78
+ /** Present only where a lookup ran: each gateway that answered, in the order given. */
79
+ answered?: string[];
80
+ checked: boolean;
81
+ gateways: string[];
82
+ packs: OnChainRow[];
83
+ verdict: "not_checked" | "confirmed" | "failed" | "undetermined";
84
+ }
85
+
86
+ /**
87
+ * Each pack's witnesses, re-fetched through `verifyEvidenceBundle`. Outside
88
+ * the case reading on purpose: `readCase` makes no network call, and neither
89
+ * does the verify page's case reading, so the case verdict stays the one both
90
+ * surfaces compute. This adds the half the reading names as not checked.
91
+ *
92
+ * Moved from `cli.ts` unchanged, apart from taking `fetchImpl` directly
93
+ * instead of a `Cli` stream (`io.fetchImpl ?? fetch`, the same fallback the
94
+ * CLI already applied at its own call site).
95
+ */
96
+ export async function onChainHalf(
97
+ packs: readonly (readonly [string, unknown, readonly CachedWitnessTime[]])[],
98
+ gateways: readonly string[],
99
+ options: WitnessLookupOptions = {},
100
+ ): Promise<OnChainHalf> {
101
+ if (gateways.length === 0) return { checked: false, gateways: [], packs: [], verdict: "not_checked" };
102
+ const fetchImpl = options.fetchImpl ?? fetch;
103
+ const rows: OnChainRow[] = [];
104
+ const answered = new Set<string>();
105
+ for (const [ref, bundle, cached] of packs) {
106
+ const result = await verifyEvidenceBundle(bundle, { gateways: [...gateways], fetchImpl });
107
+ const witnesses = [...result.checkpoints, ...(result.stamps ?? [])];
108
+ const row: OnChainRow = {
109
+ ref,
110
+ witnesses: witnesses.length,
111
+ confirmed: 0,
112
+ mismatched: 0,
113
+ unreachable: 0,
114
+ not_checked: 0,
115
+ };
116
+ for (const w of witnesses) {
117
+ const rollup = w.onChain?.rollup ?? null;
118
+ if (rollup === "confirm") row.confirmed++;
119
+ else if (rollup === "mismatch") row.mismatched++;
120
+ else if (rollup === "unreachable") row.unreachable++;
121
+ else row.not_checked++;
122
+ }
123
+ // The witness times the pack's witness-cache.json gives, looked up where
124
+ // it gives any (5.6.6, v1.25, A30). A pack with no cache gets no key and
125
+ // no request.
126
+ const times = await checkWitnessCache(cached, gateways, fetchImpl);
127
+ if (times.length > 0) row.witness_times = times.map(witnessTimeRow);
128
+ rows.push(row);
129
+ for (const w of witnesses) {
130
+ for (const outcome of w.onChain?.perGateway ?? []) {
131
+ if (outcome.outcome === "confirm" || outcome.outcome === "mismatch") {
132
+ answered.add(outcome.gateway);
133
+ }
134
+ }
135
+ }
136
+ for (const t of times) {
137
+ for (const a of t.answers) {
138
+ if (a.outcome === "matches" || a.outcome === "differs") answered.add(a.gateway);
139
+ }
140
+ }
141
+ }
142
+ const verdict = rows.some(
143
+ (r) => r.mismatched > 0 || (r.witness_times ?? []).some((t) => t.outcome === "differs"),
144
+ )
145
+ ? "failed"
146
+ : rows.some((r) => r.witnesses === 0 || r.unreachable > 0 || r.not_checked > 0)
147
+ ? "undetermined"
148
+ : "confirmed";
149
+ return {
150
+ answered: gateways.filter((g) => answered.has(g)),
151
+ checked: true,
152
+ gateways: [...gateways],
153
+ packs: rows,
154
+ verdict,
155
+ };
156
+ }
157
+
158
+ // Distinct `[blockHeight, blockTime]` pairs, in time order.
159
+ function distinctTimes(times: Iterable<[number, number]>): [number, number][] {
160
+ const seen = new Map<string, [number, number]>();
161
+ for (const [h, t] of times) seen.set(`${h}:${t}`, [h, t]);
162
+ return [...seen.values()].sort((a, b) => a[1] - b[1] || a[0] - b[0]);
163
+ }
164
+
165
+ // Only the keys that carry something: a line with no cached or recorded time
166
+ // is the line 0.13.0 printed, field for field.
167
+ function withTimes(
168
+ line: WitnessLine,
169
+ cached: [number, number][],
170
+ recorded: [number, number][],
171
+ ): WitnessLine {
172
+ return {
173
+ ...line,
174
+ ...(cached.length > 0 ? { cached } : {}),
175
+ ...(recorded.length > 0 ? { recorded } : {}),
176
+ };
177
+ }
178
+
179
+ /**
180
+ * `cached` is every witness time the packs cache (5.6.6, v1.25, A30). Where no
181
+ * lookup ran, each is printed labelled not checked. Where one ran, each time
182
+ * the public record matched is printed as the record's, each time no lookup
183
+ * settled stays labelled not checked, and a time the record contradicts is the
184
+ * reason the line reads `differs`.
185
+ *
186
+ * Moved from `cli.ts` unchanged: same inputs, same three states
187
+ * (`WitnessState`), same rendering. The CLI's `case` command still calls this
188
+ * exact function, so its witness line is unchanged.
189
+ */
190
+ export function witnessLine(onChain: OnChainHalf, cached: readonly CachedWitnessTime[] = []): WitnessLine {
191
+ if (!onChain.checked) {
192
+ return withTimes(
193
+ {
194
+ state: WITNESS_NOT_LOOKED_UP.state,
195
+ reasons: [...WITNESS_NOT_LOOKED_UP.reasons],
196
+ services: [...WITNESS_NOT_LOOKED_UP.services],
197
+ },
198
+ distinctTimes(cached.map((c): [number, number] => [c.blockHeight, c.blockTime])),
199
+ [],
200
+ );
201
+ }
202
+ const services = [...(onChain.answered ?? [])];
203
+ const rows = onChain.packs;
204
+ const times = rows.flatMap((r) => r.witness_times ?? []);
205
+ const unsettled = distinctTimes(
206
+ times
207
+ .filter((t) => t.outcome === "not_checked")
208
+ .map((t): [number, number] => [t.cached.block_height, t.cached.block_time]),
209
+ );
210
+ const recorded = distinctTimes(
211
+ times
212
+ .filter((t) => t.outcome === "matches")
213
+ .flatMap((t) => t.answers.filter((a) => a.outcome === "matches"))
214
+ .map((a): [number, number] => [a.block_height as number, a.block_time as number]),
215
+ );
216
+ const contradicted: string[] = [];
217
+ for (const t of times) {
218
+ if (t.outcome !== "differs") continue;
219
+ const a = t.answers.find((x) => x.outcome === "differs")!;
220
+ const reason =
221
+ `a pack dates the witness ${renderWitnessTime(t.cached.block_height, t.cached.block_time)}` +
222
+ `, and the public record dates it ${renderWitnessTime(a.block_height as number, a.block_time as number)}`;
223
+ if (!contradicted.includes(reason)) contradicted.push(reason);
224
+ }
225
+ if (onChain.verdict === "failed") {
226
+ return withTimes({ state: "differs", reasons: contradicted, services }, unsettled, recorded);
227
+ }
228
+ if (onChain.verdict === "confirmed") {
229
+ return withTimes({ state: "confirmed", reasons: [], services }, unsettled, recorded);
230
+ }
231
+ const reasons: string[] = [];
232
+ if (rows.some((r) => r.witnesses === 0)) reasons.push("a pack names no witness");
233
+ if (rows.some((r) => r.unreachable > 0)) {
234
+ reasons.push(
235
+ "a lookup did not settle: the service could not be reached, or it " + "holds no such witness yet",
236
+ );
237
+ }
238
+ if (rows.some((r) => r.not_checked > 0)) {
239
+ reasons.push("no lookup is made for a witness kind a pack names");
240
+ }
241
+ return withTimes({ state: "not_checked", reasons, services }, unsettled, recorded);
242
+ }
243
+
244
+ // ─── The public-entry-point export (proof#200) ─────────────────────────────
245
+ //
246
+ // `onChainHalf` and `witnessLine` above are the CLI's own two-step shape:
247
+ // gateways in, an `OnChainHalf` (every row, every gateway) out, then a
248
+ // `WitnessLine` (three states: `confirmed`, `not_checked`, `differs`) rendered
249
+ // from it. A second caller, the verify page, does not want the CLI's
250
+ // internal row shape and does want a fourth state the CLI's three-state
251
+ // `WitnessLine` cannot name: a pack that carries NO witness at all yet is not
252
+ // "not checked" (a lookup that could have found something and didn't), it is
253
+ // "there is nothing on the public record to check". Folding that pack into
254
+ // `not_checked` is not wrong for the CLI (`case` already says so in the
255
+ // reason text: "a pack names no witness"), but a tag that only ever reads
256
+ // "Not checked" hides the one distinction an auditor most wants: did we look
257
+ // and find nothing, or is there nothing to look for yet.
258
+ //
259
+ // `lookupPublicRecord` is additive over the two functions above: it calls
260
+ // them, never reimplements them, so there is exactly one place the network
261
+ // call and the verdict arithmetic live. It never throws: the underlying
262
+ // primitives (`verifyEvidenceBundle`, `checkWitnessCache`) already treat a
263
+ // gateway's answer as untrusted input and report `unreachable`/`differs`
264
+ // rather than raise, and this function adds no new path to the network or the
265
+ // parser.
266
+
267
+ /** The four states this export's result can carry. The CLI's own `WitnessState` plus `no_public_record`. */
268
+ export type PublicRecordState = WitnessState | "no_public_record";
269
+
270
+ /**
271
+ * One pack, as this lookup needs it: the parsed `bundle.json` and, where the
272
+ * pack carries one, the RAW BYTES of its `witness-cache.json`, read as part
273
+ * of the pack, per proof#200's acceptance criteria, rather than as a
274
+ * separately-loaded side file. `null` and `undefined` both mean "this pack has
275
+ * no witness-cache.json", the same reading `readWitnessCache` gives a missing
276
+ * file.
277
+ */
278
+ export interface PublicRecordPack {
279
+ /** How this pack is named in the result; carries no meaning beyond that. */
280
+ ref: string;
281
+ bundle: unknown;
282
+ witnessCache?: Uint8Array | null;
283
+ }
284
+
285
+ export interface PublicRecordResult {
286
+ state: PublicRecordState;
287
+ reasons: string[];
288
+ services: string[];
289
+ /** Witness times a pack caches that no lookup settled (5.6.6, v1.25, A30). */
290
+ cached?: [number, number][];
291
+ /** Witness times the public record returned that matched a pack's cache. */
292
+ recorded?: [number, number][];
293
+ /** The line as both the CLI and a page print it, one string, kernel words. */
294
+ rendered: string;
295
+ }
296
+
297
+ /**
298
+ * The public-record lookup the CLI runs with `--gateways`, exported so a
299
+ * consumer that is not the CLI can run the SAME lookup rather than copy it
300
+ * (proof#200; the copy this closes is verify-page#70's `src/lib/verify.ts`).
301
+ *
302
+ * `packs` is every pack in the drop being read; `gateways` is empty for an
303
+ * offline reading, which never makes a network call and always answers
304
+ * `not_checked` or `no_public_record`. `options.fetchImpl` is the one seam a
305
+ * network call goes through, a browser page supplies its own `fetchImpl`;
306
+ * with none given this falls back to the ambient global `fetch`, so a
307
+ * browser page and a test can each supply their own and Node's `fetch` is
308
+ * never assumed where a caller passed something else.
309
+ *
310
+ * `state` is `confirmed` only about the BYTES: the public record holds what a
311
+ * pack names. It says nothing about whether the pack itself verifies (its
312
+ * wrapper signature, its `body_hash`), and a caller must never show
313
+ * `confirmed` apart from the pack's own verdict (proof#201).
314
+ *
315
+ * Never throws. Every input, including a malformed pack, a non-string
316
+ * gateway, or a `fetchImpl` that itself misbehaves, is read as untrusted, and
317
+ * anything this function or what it calls cannot make sense of settles as
318
+ * `not_checked` rather than escape as an exception.
319
+ */
320
+ export async function lookupPublicRecord(
321
+ packs: readonly PublicRecordPack[],
322
+ gateways: readonly string[],
323
+ options: WitnessLookupOptions = {},
324
+ ): Promise<PublicRecordResult> {
325
+ try {
326
+ // Nothing to look up: zero packs means the CALLER handed in nothing to
327
+ // check, which is not the same claim as "the packs it handed in name no
328
+ // witness" (`no_public_record`, below). Checked first, and unconditionally,
329
+ // so a caller than mishandles an empty drop never reads it as confirmed.
330
+ if (packs.length === 0) {
331
+ const reasons = [...WITNESS_NOT_LOOKED_UP.reasons];
332
+ return {
333
+ state: "not_checked",
334
+ reasons,
335
+ services: [],
336
+ rendered: renderWitnessLine({ state: "not_checked", reasons, services: [] }),
337
+ };
338
+ }
339
+
340
+ const caches = packs.map((p) => readWitnessCache(p.bundle, p.witnessCache ?? null));
341
+ const rows: [string, unknown, readonly CachedWitnessTime[]][] = packs.map((p, i) => [
342
+ p.ref,
343
+ p.bundle,
344
+ caches[i]!,
345
+ ]);
346
+ const onChain = await onChainHalfForWitnessCount(rows, gateways, options);
347
+ const line = witnessLine(onChain.half, caches.flat());
348
+
349
+ // "No public record yet": every pack this lookup was handed names ZERO
350
+ // witnesses, so there is nothing the public record could confirm or
351
+ // contradict. This holds independently of whether a lookup ran, an
352
+ // offline reading of a fresh, unstamped pack is exactly as much "nothing to
353
+ // check yet" as an online one, and it is computed from the SAME witness
354
+ // count `onChainHalf` derives from `verifyEvidenceBundle`'s own parse, never
355
+ // a second reading of the bundle.
356
+ //
357
+ // A pack that does NOT verify (a broken wrapper signature, `bundle: null`,
358
+ // garbage, an unrecognised body type) also names zero witnesses, by
359
+ // construction: `verifyEvidenceBundle` never reports a checkpoint or a
360
+ // stamp for a bundle it could not authenticate. That is not "nothing to
361
+ // check yet", it is a pack this lookup could not read, so `no_public_record`
362
+ // is reserved for a pack whose wrapper genuinely verifies
363
+ // (`signatureOk && bodyHashOk && status !== "failed"`) and that carries no
364
+ // cached witness time either (a cache entry names something to check).
365
+ // Anything else that would otherwise qualify reads `not_checked` instead.
366
+ const noWitnesses = onChain.witnessCounts.every((n) => n === 0);
367
+ const everyPackVerifies = onChain.verifies.every((v) => v);
368
+ const noCacheRead = caches.every((c) => c.length === 0);
369
+ const canBeNoPublicRecord = noWitnesses && everyPackVerifies && noCacheRead;
370
+ const state: PublicRecordState =
371
+ line.state === "not_checked" && canBeNoPublicRecord ? "no_public_record" : line.state;
372
+
373
+ return {
374
+ state,
375
+ reasons: line.reasons,
376
+ services: line.services,
377
+ ...(line.cached ? { cached: line.cached } : {}),
378
+ // `recorded` is a claim about the public record confirming a cached
379
+ // time; it never rides alongside anything but a `confirmed` verdict, so
380
+ // a pack whose wrapper does not verify can never surface a cached time
381
+ // as though the record had confirmed it.
382
+ ...(state === "confirmed" && line.recorded ? { recorded: line.recorded } : {}),
383
+ rendered: renderWitnessLine({ ...line, state: state === "no_public_record" ? "not_checked" : state }),
384
+ };
385
+ } catch (e) {
386
+ // Untrusted input all the way down: a gateway's answer, a pack's shape, a
387
+ // caller's `fetchImpl`. Anything this function or what it calls cannot
388
+ // make sense of settles `not_checked` rather than throw out of an export a
389
+ // browser page calls directly.
390
+ const reason =
391
+ e instanceof Error ? `the lookup did not settle: ${e.message}` : "the lookup did not settle";
392
+ const reasons = [reason];
393
+ return {
394
+ state: "not_checked",
395
+ reasons,
396
+ services: [],
397
+ rendered: renderWitnessLine({ state: "not_checked", reasons, services: [] }),
398
+ };
399
+ }
400
+ }
401
+
402
+ // `onChainHalf` short-circuits to `{ checked: false, packs: [] }` with no
403
+ // gateways, which is right for the CLI (nothing to print per pack) but leaves
404
+ // `lookupPublicRecord` unable to tell "no gateways" from "no witnesses" for an
405
+ // OFFLINE reading. This wrapper reads each pack's witness count, and whether
406
+ // its wrapper verifies, the same way `onChainHalf` does, via
407
+ // `verifyEvidenceBundle`: an OFFLINE parse (`gateways: []`), so it costs no
408
+ // extra request whether or not a lookup runs, and `verifies` is never itself
409
+ // a statement about the public record, only about the pack's own signature
410
+ // and `body_hash`.
411
+ async function onChainHalfForWitnessCount(
412
+ packs: readonly (readonly [string, unknown, readonly CachedWitnessTime[]])[],
413
+ gateways: readonly string[],
414
+ options: WitnessLookupOptions,
415
+ ): Promise<{ half: OnChainHalf; witnessCounts: number[]; verifies: boolean[] }> {
416
+ const witnessCounts: number[] = [];
417
+ const verifies: boolean[] = [];
418
+ for (const [, bundle] of packs) {
419
+ const result = await verifyEvidenceBundle(bundle, { gateways: [] });
420
+ witnessCounts.push(result.checkpoints.length + (result.stamps?.length ?? 0));
421
+ verifies.push(result.signatureOk && result.bodyHashOk && result.status !== "failed");
422
+ }
423
+ if (gateways.length > 0) {
424
+ const half = await onChainHalf(packs, gateways, options);
425
+ return { half, witnessCounts, verifies };
426
+ }
427
+ return { half: { checked: false, gateways: [], packs: [], verdict: "not_checked" }, witnessCounts, verifies };
428
+ }