@sanning/proof 0.7.0 → 0.8.1

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.
package/dist/chain.js CHANGED
@@ -143,7 +143,15 @@ function adapterLinkage(record) {
143
143
  // here: nothing to resolve is a beginning.
144
144
  const prev = str(fields.prev_event_id);
145
145
  const seqRaw = fields.seq;
146
- const seq = Number.isInteger(seqRaw) ? seqRaw : null;
146
+ // SAFE integer, not merely integral. Beyond 2^53 a JSON number does not
147
+ // survive `JSON.parse` intact — `9007199254740993` arrives as
148
+ // `...992` — while Python's parser keeps it exactly, so the two kernels
149
+ // read DIFFERENT sequence numbers off identical bytes and report gaps of
150
+ // different widths. Measured at 2^53+1: a `chain-gap` count of
151
+ // 9007199254740990 here against 9007199254740992 there (proof#49). A value
152
+ // this kernel cannot represent is one it cannot read, which is the same
153
+ // answer it gives every other unreadable field: no sequence, take no part.
154
+ const seq = Number.isSafeInteger(seqRaw) ? seqRaw : null;
147
155
  return { namespace, chainKey, prev, seq };
148
156
  }
149
157
  return null;
@@ -226,6 +234,20 @@ function byChainOrder(a, b) {
226
234
  return a.seq - b.seq;
227
235
  return a.order - b.order;
228
236
  }
237
+ /**
238
+ * The smallest slot between the lowest and highest occupied ones that no record
239
+ * occupies, found by walking the SORTED occupants rather than the range they
240
+ * span. See the call site: the range's width is chosen by whoever wrote the
241
+ * bundle.
242
+ */
243
+ function firstMissingSlot(present) {
244
+ const ordered = [...present].sort((a, b) => a - b);
245
+ for (let i = 0; i < ordered.length - 1; i++) {
246
+ if (ordered[i + 1] > ordered[i] + 1)
247
+ return ordered[i] + 1;
248
+ }
249
+ return null;
250
+ }
229
251
  function findingsFor(group) {
230
252
  const { members, chainKey, source, publicKey } = group;
231
253
  const of = members.length;
@@ -283,18 +305,23 @@ function findingsFor(group) {
283
305
  const present = new Set(sequenced.map((m) => m.seq));
284
306
  const low = Math.min(...present);
285
307
  const high = Math.max(...present);
286
- const missing = [];
287
- for (let s = low; s <= high; s++)
288
- if (!present.has(s))
289
- missing.push(s);
290
- if (missing.length > 0) {
291
- const after = sequenced.filter((m) => m.seq > missing[0]).sort(byChainOrder)[0];
308
+ // Counted, never walked. `seq` is attacker-chosen, so a bundle presenting
309
+ // two records at seq 0 and seq 1e15 made the old `for (let s = low; s <=
310
+ // high; s++)` spin for days — a verifier that HANGS on adversarial input is
311
+ // worse than one that raises, and this reader promises neither (proof#49).
312
+ // The present set is a subset of [low, high], so the absent slots are the
313
+ // width minus the occupants, and the first of them is found by walking the
314
+ // occupants in order.
315
+ const missingCount = high - low + 1 - present.size;
316
+ const firstMissing = missingCount > 0 ? firstMissingSlot(present) : null;
317
+ if (firstMissing !== null) {
318
+ const after = sequenced.filter((m) => m.seq > firstMissing).sort(byChainOrder)[0];
292
319
  findings.push({
293
320
  kind: "chain-gap",
294
321
  ...base,
295
- count: missing.length,
322
+ count: missingCount,
296
323
  eventIds: after === undefined ? [] : [after.eventId],
297
- firstMissingSeq: missing[0],
324
+ firstMissingSeq: firstMissing,
298
325
  });
299
326
  }
300
327
  }
@@ -311,7 +338,18 @@ function findingsFor(group) {
311
338
  export async function readChains(bundle) {
312
339
  const groups = await groupChains(bundle);
313
340
  const order = (g) => `${g.source} ${g.namespace ?? ""} ${g.chainKey}`;
314
- groups.sort((a, b) => order(a).localeCompare(order(b)));
341
+ // Ordered by UTF-16 code unit, NOT `localeCompare`. A chain key is caller
342
+ // text, and `localeCompare` with no locale argument answers from the host's
343
+ // ICU collation: measured here, it puts "chain-2" AFTER "chain_1" where every
344
+ // code-wise comparison puts it before, and it can differ between two
345
+ // TypeScript installs, let alone between kernels. The Python reader sorts the
346
+ // same key encoded as `utf-16-be`, which is exactly this comparison — so the
347
+ // two kernels emit findings in one order (proof#49).
348
+ groups.sort((a, b) => {
349
+ const x = order(a);
350
+ const y = order(b);
351
+ return x < y ? -1 : x > y ? 1 : 0;
352
+ });
315
353
  return {
316
354
  chains: groups.map((g) => ({
317
355
  chainKey: g.chainKey,
package/dist/cli.js CHANGED
@@ -610,6 +610,31 @@ function reportEvidence(result, path, gateways, io, chains) {
610
610
  io.out(paint(` - ${e}`, R));
611
611
  }
612
612
  }
613
+ // Stamps (evidence-bundle.md §5.2 / §5.3). Printed rather than summarised,
614
+ // because for a stamp body the WITNESS is the entire independent part
615
+ // (§5.2.4) — and until now this reporter showed nothing about it at all, so a
616
+ // §5.3 pack printed a rollup line claiming an on-chain scope with no visible
617
+ // on-chain result anywhere above it.
618
+ //
619
+ // The on-chain column distinguishes all four states in the same three
620
+ // characters the checkpoint block uses, plus its absence: `✓` confirmed,
621
+ // `✗` mismatched, `~` asked and unreachable, and NO COLUMN AT ALL when
622
+ // nothing was checked. A reader must never have to infer "not checked" from
623
+ // a symbol that also means "could not be reached".
624
+ if (result.stamps && result.stamps.length > 0) {
625
+ io.out("");
626
+ io.out(paint(" Stamps", DIM));
627
+ for (const st of result.stamps) {
628
+ const witness = st.anchorRef === null
629
+ ? paint("~ no witness", Y)
630
+ : `witness ${st.anchorRef.kind}:${shortTx(st.anchorRef.id)}`;
631
+ const onChain = st.onChain === null ? "" : ` on-chain ${mark(st.onChainOk)} (${st.onChain.rollup})`;
632
+ io.out(` ${mark(st.ok)} ${shortTx(st.stampRef)} bytes ${mark(st.stampRefOk && st.bodyOk)}` +
633
+ ` ranges ${mark(st.subtreesOk)} ${witness}${onChain}`);
634
+ for (const e of st.errors)
635
+ io.out(paint(` - ${e}`, R));
636
+ }
637
+ }
613
638
  if (result.events.length > 0) {
614
639
  io.out("");
615
640
  io.out(paint(" Events", DIM));
@@ -25,6 +25,7 @@ export declare const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
25
25
  * unrecoverable once witnessed.
26
26
  */
27
27
  export declare const STAMP_TRACE_BODY_TYPE = "sanning.stamp.trace/v1";
28
+ export declare const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/v1";
28
29
  /**
29
30
  * The version string INSIDE a witnessed stamp body — a different string from
30
31
  * the `body_type` above, and the two are easy to confuse.
@@ -162,6 +163,26 @@ export interface StampEntry {
162
163
  export interface StampTraceBody {
163
164
  stamps: StampEntry[];
164
165
  }
166
+ /** §5.3's inclusion: §5.1's shape with `stamp_ref` as the join key. */
167
+ export interface StampEvidenceInclusion {
168
+ leaf_hash: string;
169
+ leaf_index: number;
170
+ leaf_count: number;
171
+ audit_path: string[];
172
+ /** Resolves against `stamps[].stamp_ref` in the SAME body. */
173
+ stamp_ref: string;
174
+ }
175
+ export interface StampEvidenceEvent {
176
+ envelope: Envelope;
177
+ record_bytes?: string;
178
+ content?: string;
179
+ inclusion: StampEvidenceInclusion;
180
+ }
181
+ export interface StampEvidenceBody {
182
+ /** Byte-identical to §5.2.2 — one definition, one place to change. */
183
+ stamps: StampEntry[];
184
+ events: StampEvidenceEvent[];
185
+ }
165
186
  export interface EvidenceBundle {
166
187
  spec_version: string;
167
188
  body_type: string;
@@ -271,6 +292,35 @@ export interface StampResult {
271
292
  * carries it.
272
293
  */
273
294
  anchorRef: AnchorRef | null;
295
+ /**
296
+ * Per-gateway on-chain outcomes for this stamp's `anchor_ref` (§4.2's shape,
297
+ * `checkpoints[]`'s vocabulary), or `null` when NOTHING WAS CHECKED.
298
+ *
299
+ * ── The four states, and why none of them collapses ──
300
+ *
301
+ * `null` means not checked — no gateways were supplied, or this stamp names
302
+ * no witness to check. It is a statement about the RUN, never about the
303
+ * stamp. The other three are statements about the stamp and live in
304
+ * `onChain.rollup`: `confirm` (a gateway returned the witnessed bytes),
305
+ * `mismatch` (a gateway returned DIFFERENT bytes — a finding, and the only
306
+ * one of the four that fails anything), `unreachable` (we asked and nobody
307
+ * answered — an availability gap, never an accusation).
308
+ *
309
+ * Keeping "not checked" distinct from "unreachable" is the whole point. A
310
+ * surface handed a collapsed boolean cannot tell "no gateway could be
311
+ * reached" from "no gateway was tried", and reported the first when the
312
+ * second was true — a false accusation manufactured by a missing state.
313
+ */
314
+ onChain: OnChainResult | null;
315
+ /**
316
+ * Derived collapse of `onChain`, mirroring `CheckpointResult.onChainOk` so a
317
+ * consumer reads one field the same way on both: `false` on mismatch, `true`
318
+ * on confirm, `null` on unreachable AND on not-checked.
319
+ *
320
+ * It is deliberately the LOSSY view — `null` is three-quarters of the state
321
+ * space here. Anything that needs to tell those apart reads `onChain`.
322
+ */
323
+ onChainOk: boolean | null;
274
324
  ok: boolean;
275
325
  errors: string[];
276
326
  }