@tokamak-private-dapps/private-state-cli 2.1.0 → 2.1.2

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.
@@ -7,18 +7,29 @@ const state = {
7
7
  notes: [],
8
8
  filteredNotes: [],
9
9
  selectedNotePaths: new Set(),
10
+ graph: { nodes: [], edges: [] },
11
+ activeTab: "graph",
10
12
  };
11
13
 
12
14
  const els = {
13
15
  bundleFile: document.getElementById("bundleFile"),
16
+ summaryCards: document.getElementById("summaryCards"),
14
17
  filters: document.getElementById("filters"),
15
18
  packageForm: document.getElementById("packageForm"),
16
19
  applyFilters: document.getElementById("applyFilters"),
17
20
  buildPackage: document.getElementById("buildPackage"),
21
+ exportAscii: document.getElementById("exportAscii"),
18
22
  selectAll: document.getElementById("selectAll"),
19
23
  selectNone: document.getElementById("selectNone"),
20
24
  status: document.getElementById("status"),
21
25
  noteRows: document.getElementById("noteRows"),
26
+ graphSvg: document.getElementById("graphSvg"),
27
+ graphViewport: document.getElementById("graphViewport"),
28
+ nodeDetail: document.getElementById("nodeDetail"),
29
+ graphPanel: document.getElementById("graphPanel"),
30
+ notesPanel: document.getElementById("notesPanel"),
31
+ graphTab: document.getElementById("graphTab"),
32
+ notesTab: document.getElementById("notesTab"),
22
33
  };
23
34
 
24
35
  els.bundleFile.addEventListener("change", async (event) => {
@@ -38,13 +49,19 @@ els.bundleFile.addEventListener("change", async (event) => {
38
49
  });
39
50
 
40
51
  els.applyFilters.addEventListener("click", applyFilters);
52
+ els.filters.addEventListener("change", (event) => {
53
+ if (event.target?.name === "purpose") {
54
+ updatePurposeFields();
55
+ applyFilters();
56
+ }
57
+ });
41
58
  els.selectAll.addEventListener("click", () => {
42
59
  state.selectedNotePaths = new Set(state.filteredNotes.map((entry) => entry.path));
43
- renderNotes();
60
+ renderResults();
44
61
  });
45
62
  els.selectNone.addEventListener("click", () => {
46
63
  state.selectedNotePaths.clear();
47
- renderNotes();
64
+ renderResults();
48
65
  });
49
66
  els.buildPackage.addEventListener("click", async () => {
50
67
  try {
@@ -53,6 +70,17 @@ els.buildPackage.addEventListener("click", async () => {
53
70
  setStatus(`Failed to build disclosure package: ${error.message}`);
54
71
  }
55
72
  });
73
+ els.exportAscii.addEventListener("click", () => {
74
+ try {
75
+ exportAsciiReport();
76
+ } catch (error) {
77
+ setStatus(`Failed to export ASCII report: ${error.message}`);
78
+ }
79
+ });
80
+ els.graphTab.addEventListener("click", () => setResultTab("graph"));
81
+ els.notesTab.addEventListener("click", () => setResultTab("notes"));
82
+
83
+ updatePurposeFields();
56
84
 
57
85
  async function loadEvidenceBundle(bytes) {
58
86
  const files = await readZip(bytes);
@@ -65,12 +93,19 @@ async function loadEvidenceBundle(bytes) {
65
93
  }
66
94
  const notes = [...files.entries()]
67
95
  .filter(([path]) => isEvidenceNotePath(path))
68
- .map(([path, content]) => ({
69
- path,
70
- record: JSON.parse(content),
71
- }))
96
+ .map(([path, content], index) => {
97
+ const record = JSON.parse(content);
98
+ return {
99
+ path,
100
+ record,
101
+ note: normalizeNoteEntry(path, record, index),
102
+ };
103
+ })
72
104
  .sort((left, right) =>
73
105
  String(left.record?.derived?.commitment ?? "").localeCompare(String(right.record?.derived?.commitment ?? "")));
106
+ notes.forEach((entry, index) => {
107
+ entry.note.label = `N${String(index + 1).padStart(2, "0")}`;
108
+ });
74
109
  if (notes.length === 0) {
75
110
  throw new Error("The evidence bundle does not contain current epoch-aware note records.");
76
111
  }
@@ -80,8 +115,10 @@ async function loadEvidenceBundle(bytes) {
80
115
  state.filteredNotes = notes;
81
116
  state.selectedNotePaths = new Set(notes.map((entry) => entry.path));
82
117
  els.buildPackage.disabled = false;
118
+ els.exportAscii.disabled = false;
83
119
  els.selectAll.disabled = false;
84
120
  els.selectNone.disabled = false;
121
+ renderSummary();
85
122
  setStatus(
86
123
  `Loaded ${notes.length} note records from ${evidenceWalletLabel(manifest)} on ${manifest.network ?? "network"}.`,
87
124
  );
@@ -103,11 +140,16 @@ function resetBundle() {
103
140
  state.manifest = null;
104
141
  state.notes = [];
105
142
  state.filteredNotes = [];
143
+ state.graph = { nodes: [], edges: [] };
106
144
  state.selectedNotePaths.clear();
107
145
  els.buildPackage.disabled = true;
146
+ els.exportAscii.disabled = true;
108
147
  els.selectAll.disabled = true;
109
148
  els.selectNone.disabled = true;
110
149
  els.noteRows.textContent = "";
150
+ els.graphSvg.textContent = "";
151
+ els.summaryCards.textContent = "";
152
+ hideNodeDetail();
111
153
  }
112
154
 
113
155
  function applyFilters() {
@@ -116,27 +158,49 @@ function applyFilters() {
116
158
  return;
117
159
  }
118
160
  const form = new FormData(els.filters);
119
- const criteria = getFilterCriteria(form);
161
+ let criteria;
162
+ try {
163
+ criteria = getFilterCriteria(form);
164
+ } catch (error) {
165
+ setStatus(`Invalid filter: ${error.message}`);
166
+ return;
167
+ }
120
168
  state.filteredNotes = state.notes.filter(({ record }) => matchesCriteria(record, criteria));
121
169
  state.selectedNotePaths = new Set(state.filteredNotes.map((entry) => entry.path));
122
- renderNotes();
170
+ renderResults();
123
171
  setStatus(`${state.filteredNotes.length} of ${state.notes.length} notes match the current filter.`);
124
172
  }
125
173
 
126
174
  function getFilterCriteria(form) {
127
- return {
175
+ const purpose = String(form.get("purpose") ?? "overview");
176
+ const criteria = {
177
+ purpose,
128
178
  commitment: normalizeSearch(form.get("commitment")),
129
179
  nullifier: normalizeSearch(form.get("nullifier")),
130
180
  creationTx: normalizeSearch(form.get("creationTx")),
131
181
  spendTx: normalizeSearch(form.get("spendTx")),
132
- createdFrom: parseOptionalNumber(form.get("createdFrom")),
133
- createdTo: parseOptionalNumber(form.get("createdTo")),
134
- spentFrom: parseOptionalNumber(form.get("spentFrom")),
135
- spentTo: parseOptionalNumber(form.get("spentTo")),
182
+ createdFrom: parseOptionalNumber(form.get("createdFrom"), "Created from block"),
183
+ createdTo: parseOptionalNumber(form.get("createdTo"), "Created to block"),
184
+ spentFrom: parseOptionalNumber(form.get("spentFrom"), "Spent from block"),
185
+ spentTo: parseOptionalNumber(form.get("spentTo"), "Spent to block"),
136
186
  status: String(form.get("status") ?? ""),
137
187
  direction: String(form.get("direction") ?? ""),
138
188
  counterparty: normalizeSearch(form.get("counterparty")),
139
189
  };
190
+ if (!["receipt", "spend"].includes(purpose)) criteria.commitment = "";
191
+ if (purpose !== "spend") criteria.nullifier = "";
192
+ if (!["receipt", "transaction"].includes(purpose)) criteria.creationTx = "";
193
+ if (!["spend", "transaction"].includes(purpose)) criteria.spendTx = "";
194
+ if (purpose !== "range") {
195
+ criteria.createdFrom = null;
196
+ criteria.createdTo = null;
197
+ }
198
+ if (!["range", "spend"].includes(purpose)) {
199
+ criteria.spentFrom = null;
200
+ criteria.spentTo = null;
201
+ }
202
+ if (purpose !== "counterparty") criteria.counterparty = "";
203
+ return criteria;
140
204
  }
141
205
 
142
206
  function matchesCriteria(record, criteria) {
@@ -152,14 +216,97 @@ function matchesCriteria(record, criteria) {
152
216
  return true;
153
217
  }
154
218
 
219
+ function normalizeNoteEntry(path, record, index) {
220
+ return {
221
+ path,
222
+ label: `N${String(index + 1).padStart(2, "0")}`,
223
+ commitment: record.derived?.commitment ?? null,
224
+ nullifier: record.derived?.nullifier ?? null,
225
+ value: record.plaintext?.value ?? null,
226
+ owner: record.plaintext?.owner ?? null,
227
+ salt: record.plaintext?.salt ?? null,
228
+ status: record.spend?.status ?? "unknown",
229
+ direction: record.relationshipHints?.direction ?? "unknown",
230
+ counterparty: record.relationshipHints?.counterpartyL2Address ?? null,
231
+ creation: normalizeEventRef(record.creation),
232
+ spend: normalizeEventRef(record.spend),
233
+ walletPath: path.split("/notes/")[0] ?? "",
234
+ };
235
+ }
236
+
237
+ function normalizeEventRef(value) {
238
+ if (!value) {
239
+ return null;
240
+ }
241
+ return {
242
+ txHash: value.txHash ?? null,
243
+ blockNumber: value.blockNumber ?? null,
244
+ logIndex: value.logIndex ?? null,
245
+ functionName: value.functionName ?? value.function ?? null,
246
+ acceptedTransition: value.acceptedTransition ?? null,
247
+ };
248
+ }
249
+
250
+ function renderResults() {
251
+ renderNotes();
252
+ buildGraph();
253
+ renderGraph();
254
+ renderSummary();
255
+ hideNodeDetail();
256
+ }
257
+
258
+ function renderSummary() {
259
+ els.summaryCards.textContent = "";
260
+ if (!state.manifest) {
261
+ return;
262
+ }
263
+ const total = state.notes.length;
264
+ const spent = state.notes.filter(({ note }) => note.status === "spent").length;
265
+ const unused = state.notes.filter(({ note }) => note.status === "unused").length;
266
+ const visible = state.filteredNotes.length;
267
+ const externalOnly = countExternalOnlyNotes(state.notes);
268
+ const selected = state.selectedNotePaths.size;
269
+ const summaryItems = [
270
+ ["Wallet", evidenceWalletLabel(state.manifest)],
271
+ ["Visible notes", `${visible} / ${total}`],
272
+ ["Spent / unused", `${spent} / ${unused}`],
273
+ ["Selected for ZIP", String(selected)],
274
+ ["External-only notes", String(externalOnly)],
275
+ ];
276
+ for (const [label, value] of summaryItems) {
277
+ const item = document.createElement("div");
278
+ item.className = "summary-card";
279
+ const title = document.createElement("span");
280
+ title.textContent = label;
281
+ const body = document.createElement("strong");
282
+ body.textContent = value;
283
+ item.append(title, body);
284
+ els.summaryCards.append(item);
285
+ }
286
+ }
287
+
288
+ function countExternalOnlyNotes(notes) {
289
+ const creationTxs = new Set(notes.map(({ note }) => note.creation?.txHash).filter(Boolean));
290
+ const spendTxs = new Set(notes.map(({ note }) => note.spend?.txHash).filter(Boolean));
291
+ let count = 0;
292
+ for (const { note } of notes) {
293
+ const hasLocalPredecessor = note.creation?.txHash && spendTxs.has(note.creation.txHash);
294
+ const hasLocalSuccessor = note.spend?.txHash && creationTxs.has(note.spend.txHash);
295
+ if (!hasLocalPredecessor && !hasLocalSuccessor) {
296
+ count += 1;
297
+ }
298
+ }
299
+ return count;
300
+ }
301
+
155
302
  function renderNotes() {
156
303
  els.noteRows.textContent = "";
157
304
  const fragment = document.createDocumentFragment();
158
- for (const { path, record } of state.filteredNotes) {
305
+ for (const { path, record, note } of state.filteredNotes) {
159
306
  const row = document.createElement("tr");
160
307
  row.append(
161
308
  cellWithCheckbox(path),
162
- monoCell(shortHex(record.derived?.commitment)),
309
+ monoCell(`${note.label} ${shortHex(record.derived?.commitment)}`),
163
310
  textCell(record.plaintext?.value ?? ""),
164
311
  textCell(record.spend?.status ?? ""),
165
312
  monoCell(formatEventRef(record.creation)),
@@ -183,11 +330,437 @@ function cellWithCheckbox(path) {
183
330
  } else {
184
331
  state.selectedNotePaths.delete(path);
185
332
  }
333
+ renderSummary();
186
334
  });
187
335
  cell.append(checkbox);
188
336
  return cell;
189
337
  }
190
338
 
339
+ function buildGraph() {
340
+ const entries = state.filteredNotes;
341
+ const noteByPath = new Map(entries.map((entry) => [entry.path, entry.note]));
342
+ const notesByCreationTx = groupNotesByTx(entries, "creation");
343
+ const edges = [];
344
+ const incoming = new Map(entries.map(({ path }) => [path, []]));
345
+ const outgoing = new Map(entries.map(({ path }) => [path, []]));
346
+
347
+ for (const { path, note } of entries) {
348
+ const localSuccessors = note.spend?.txHash ? notesByCreationTx.get(note.spend.txHash) ?? [] : [];
349
+ for (const successor of localSuccessors) {
350
+ if (successor.path === path) {
351
+ continue;
352
+ }
353
+ const edge = {
354
+ id: `${path}->${successor.path}`,
355
+ type: "local",
356
+ sourcePath: path,
357
+ targetPath: successor.path,
358
+ txHash: note.spend.txHash,
359
+ };
360
+ edges.push(edge);
361
+ outgoing.get(path).push(edge);
362
+ incoming.get(successor.path).push(edge);
363
+ }
364
+ }
365
+
366
+ for (const { path, note } of entries) {
367
+ const hasLocalPredecessor = incoming.get(path).length > 0;
368
+ const hasLocalSuccessor = outgoing.get(path).length > 0;
369
+ if (!hasLocalPredecessor) {
370
+ edges.push({
371
+ id: `external-in->${path}`,
372
+ type: "external-in",
373
+ targetPath: path,
374
+ txHash: note.creation?.txHash ?? null,
375
+ });
376
+ }
377
+ if (note.status === "spent" && !hasLocalSuccessor) {
378
+ edges.push({
379
+ id: `${path}->external-out`,
380
+ type: "external-out",
381
+ sourcePath: path,
382
+ txHash: note.spend?.txHash ?? null,
383
+ });
384
+ }
385
+ }
386
+
387
+ const depthByPath = computeGraphDepth(entries, incoming, noteByPath);
388
+ const rowByPath = assignGraphRows(entries, incoming, outgoing);
389
+
390
+ const nodes = entries.map(({ path, note }) => ({
391
+ path,
392
+ note,
393
+ depth: depthByPath.get(path) ?? 0,
394
+ row: rowByPath.get(path) ?? 0,
395
+ x: 120 + (depthByPath.get(path) ?? 0) * 240,
396
+ y: 80 + (rowByPath.get(path) ?? 0) * 88,
397
+ }));
398
+ state.graph = { nodes, edges };
399
+ }
400
+
401
+ function assignGraphRows(entries, incoming, outgoing) {
402
+ const entryByPath = new Map(entries.map((entry) => [entry.path, entry]));
403
+ const rowByPath = new Map();
404
+ let row = 0;
405
+ const ordered = [...entries].sort(compareNoteEntriesForGraph);
406
+ const roots = ordered.filter((entry) => (incoming.get(entry.path) ?? []).length === 0);
407
+ const assignChain = (entry) => {
408
+ let current = entry;
409
+ let safety = entries.length + 1;
410
+ while (current && !rowByPath.has(current.path) && safety > 0) {
411
+ rowByPath.set(current.path, row);
412
+ const nextEdge = (outgoing.get(current.path) ?? [])[0];
413
+ current = nextEdge?.targetPath ? entryByPath.get(nextEdge.targetPath) : null;
414
+ safety -= 1;
415
+ }
416
+ row += 1;
417
+ };
418
+ for (const root of roots) {
419
+ assignChain(root);
420
+ }
421
+ for (const entry of ordered) {
422
+ if (!rowByPath.has(entry.path)) {
423
+ assignChain(entry);
424
+ }
425
+ }
426
+ return rowByPath;
427
+ }
428
+
429
+ function compareNoteEntriesForGraph(left, right) {
430
+ return compareNullableNumber(left.note.creation?.blockNumber, right.note.creation?.blockNumber)
431
+ || compareNullableNumber(left.note.creation?.logIndex, right.note.creation?.logIndex)
432
+ || left.note.label.localeCompare(right.note.label);
433
+ }
434
+
435
+ function groupNotesByTx(entries, section) {
436
+ const result = new Map();
437
+ for (const entry of entries) {
438
+ const txHash = entry.note[section]?.txHash;
439
+ if (!txHash) {
440
+ continue;
441
+ }
442
+ if (!result.has(txHash)) {
443
+ result.set(txHash, []);
444
+ }
445
+ result.get(txHash).push(entry);
446
+ }
447
+ return result;
448
+ }
449
+
450
+ function computeGraphDepth(entries, incoming, noteByPath) {
451
+ const depthByPath = new Map();
452
+ const visiting = new Set();
453
+ const visit = (path) => {
454
+ if (depthByPath.has(path)) {
455
+ return depthByPath.get(path);
456
+ }
457
+ if (visiting.has(path)) {
458
+ return 0;
459
+ }
460
+ visiting.add(path);
461
+ const predecessors = incoming.get(path) ?? [];
462
+ let depth = 0;
463
+ for (const edge of predecessors) {
464
+ if (!edge.sourcePath || !noteByPath.has(edge.sourcePath)) {
465
+ continue;
466
+ }
467
+ depth = Math.max(depth, visit(edge.sourcePath) + 1);
468
+ }
469
+ visiting.delete(path);
470
+ depthByPath.set(path, depth);
471
+ return depth;
472
+ };
473
+ for (const { path } of entries) {
474
+ visit(path);
475
+ }
476
+ return depthByPath;
477
+ }
478
+
479
+ function renderGraph() {
480
+ const svg = els.graphSvg;
481
+ svg.textContent = "";
482
+ const nodes = state.graph.nodes;
483
+ if (!nodes.length) {
484
+ svg.setAttribute("width", "720");
485
+ svg.setAttribute("height", "220");
486
+ const empty = svgElement("text", { x: 28, y: 48, class: "graph-empty" });
487
+ empty.textContent = "No notes match the current request.";
488
+ svg.append(empty);
489
+ return;
490
+ }
491
+
492
+ const nodeByPath = new Map(nodes.map((node) => [node.path, node]));
493
+ const width = Math.max(900, Math.max(...nodes.map((node) => node.x)) + 260);
494
+ const height = Math.max(360, Math.max(...nodes.map((node) => node.y)) + 130);
495
+ svg.setAttribute("width", String(width));
496
+ svg.setAttribute("height", String(height));
497
+ svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
498
+ svg.append(buildGraphDefs());
499
+
500
+ for (const edge of state.graph.edges) {
501
+ renderGraphEdge(svg, edge, nodeByPath, width);
502
+ }
503
+ for (const node of nodes) {
504
+ renderGraphNode(svg, node);
505
+ }
506
+ }
507
+
508
+ function buildGraphDefs() {
509
+ const defs = svgElement("defs");
510
+ const marker = svgElement("marker", {
511
+ id: "arrow",
512
+ markerWidth: "10",
513
+ markerHeight: "10",
514
+ refX: "9",
515
+ refY: "3",
516
+ orient: "auto",
517
+ markerUnits: "strokeWidth",
518
+ });
519
+ const path = svgElement("path", { d: "M0,0 L0,6 L9,3 z", class: "edge-arrow" });
520
+ marker.append(path);
521
+ defs.append(marker);
522
+ return defs;
523
+ }
524
+
525
+ function renderGraphEdge(svg, edge, nodeByPath, width) {
526
+ const source = edge.sourcePath ? nodeByPath.get(edge.sourcePath) : null;
527
+ const target = edge.targetPath ? nodeByPath.get(edge.targetPath) : null;
528
+ const sourcePoint = source
529
+ ? { x: source.x + 150, y: source.y + 28 }
530
+ : { x: 34, y: target.y + 28 };
531
+ const targetPoint = target
532
+ ? { x: target.x, y: target.y + 28 }
533
+ : { x: Math.min(width - 34, source.x + 230), y: source.y + 28 };
534
+ const path = svgElement("path", {
535
+ d: curvedPath(sourcePoint, targetPoint),
536
+ class: `graph-edge ${edge.type}`,
537
+ "marker-end": "url(#arrow)",
538
+ });
539
+ svg.append(path);
540
+
541
+ const label = svgElement("text", {
542
+ x: String((sourcePoint.x + targetPoint.x) / 2),
543
+ y: String((sourcePoint.y + targetPoint.y) / 2 - 8),
544
+ class: "edge-label",
545
+ });
546
+ label.textContent = edge.type === "local" ? shortHex(edge.txHash) : edge.type === "external-in" ? "created" : "spent";
547
+ svg.append(label);
548
+ }
549
+
550
+ function curvedPath(source, target) {
551
+ const distance = Math.max(60, Math.abs(target.x - source.x));
552
+ const control = Math.min(120, distance / 2);
553
+ return `M ${source.x} ${source.y} C ${source.x + control} ${source.y}, ${target.x - control} ${target.y}, ${target.x} ${target.y}`;
554
+ }
555
+
556
+ function renderGraphNode(svg, node) {
557
+ const group = svgElement("g", {
558
+ class: `graph-node ${state.selectedNotePaths.has(node.path) ? "is-selected" : ""}`,
559
+ tabindex: "0",
560
+ role: "button",
561
+ "aria-label": `${node.note.label} note details`,
562
+ });
563
+ group.addEventListener("click", () => showNodeDetail(node));
564
+ group.addEventListener("keydown", (event) => {
565
+ if (event.key === "Enter" || event.key === " ") {
566
+ event.preventDefault();
567
+ showNodeDetail(node);
568
+ }
569
+ });
570
+ const rect = svgElement("rect", {
571
+ x: String(node.x),
572
+ y: String(node.y),
573
+ width: "150",
574
+ height: "58",
575
+ rx: "8",
576
+ });
577
+ const label = svgElement("text", { x: String(node.x + 14), y: String(node.y + 22), class: "node-label" });
578
+ label.textContent = node.note.label;
579
+ const value = svgElement("text", { x: String(node.x + 14), y: String(node.y + 42), class: "node-value" });
580
+ value.textContent = `${shortText(node.note.value ?? "-", 15)} ${node.note.status}`;
581
+ const commitment = svgElement("title");
582
+ commitment.textContent = node.note.commitment ?? "";
583
+ group.append(rect, label, value, commitment);
584
+ svg.append(group);
585
+ }
586
+
587
+ function showNodeDetail(node) {
588
+ const detail = els.nodeDetail;
589
+ const selected = state.selectedNotePaths.has(node.path);
590
+ detail.classList.remove("is-hidden");
591
+ detail.style.left = `${node.x + 166}px`;
592
+ detail.style.top = `${Math.max(12, node.y - 8)}px`;
593
+ detail.textContent = "";
594
+ const title = document.createElement("div");
595
+ title.className = "detail-title";
596
+ title.textContent = `${node.note.label} note`;
597
+ const selectButton = document.createElement("button");
598
+ selectButton.type = "button";
599
+ selectButton.className = "secondary";
600
+ selectButton.textContent = selected ? "Remove from ZIP" : "Add to ZIP";
601
+ selectButton.addEventListener("click", () => {
602
+ if (state.selectedNotePaths.has(node.path)) {
603
+ state.selectedNotePaths.delete(node.path);
604
+ } else {
605
+ state.selectedNotePaths.add(node.path);
606
+ }
607
+ renderResults();
608
+ showNodeDetail(node);
609
+ });
610
+ detail.append(title, detailRows(node.note), selectButton);
611
+ }
612
+
613
+ function detailRows(note) {
614
+ const container = document.createElement("dl");
615
+ container.className = "detail-rows";
616
+ for (const [label, value] of [
617
+ ["Commitment", note.commitment],
618
+ ["Nullifier", note.nullifier],
619
+ ["Value", note.value],
620
+ ["Status", note.status],
621
+ ["Created", formatEventRef(note.creation)],
622
+ ["Spent", formatEventRef(note.spend)],
623
+ ["Direction", note.direction],
624
+ ["Counterparty", note.counterparty],
625
+ ]) {
626
+ const term = document.createElement("dt");
627
+ term.textContent = label;
628
+ const description = document.createElement("dd");
629
+ description.className = "mono";
630
+ description.textContent = value || "-";
631
+ container.append(term, description);
632
+ }
633
+ return container;
634
+ }
635
+
636
+ function hideNodeDetail() {
637
+ els.nodeDetail.classList.add("is-hidden");
638
+ els.nodeDetail.textContent = "";
639
+ }
640
+
641
+ function setResultTab(tab) {
642
+ state.activeTab = tab;
643
+ els.graphTab.classList.toggle("is-active", tab === "graph");
644
+ els.notesTab.classList.toggle("is-active", tab === "notes");
645
+ els.graphPanel.classList.toggle("is-active", tab === "graph");
646
+ els.notesPanel.classList.toggle("is-active", tab === "notes");
647
+ }
648
+
649
+ function updatePurposeFields() {
650
+ const purpose = String(new FormData(els.filters).get("purpose") ?? "overview");
651
+ const fields = [...els.filters.querySelectorAll("[data-purpose-field]")];
652
+ for (const element of fields) {
653
+ const values = element.dataset.purposeField.split(/\s+/u);
654
+ element.hidden = purpose === "overview" ? true : !values.includes(purpose);
655
+ }
656
+ const section = els.filters.querySelector(".form-section");
657
+ if (section) {
658
+ section.hidden = fields.every((element) => element.hidden);
659
+ }
660
+ }
661
+
662
+ function exportAsciiReport() {
663
+ if (!state.manifest) {
664
+ throw new Error("Load an evidence bundle first.");
665
+ }
666
+ if (state.filteredNotes.length === 0) {
667
+ throw new Error("No notes match the current request.");
668
+ }
669
+ const metadata = readPackageMetadata();
670
+ const report = buildAsciiReport(metadata);
671
+ const name = asciiReportFileName(metadata, state.manifest);
672
+ downloadBlob(new Blob([report], { type: "text/markdown" }), name);
673
+ setStatus(`Exported ${name} with ${state.filteredNotes.length} visible notes.`);
674
+ }
675
+
676
+ function buildAsciiReport(metadata) {
677
+ const selected = new Set(state.selectedNotePaths);
678
+ const lines = [
679
+ "# Private-State Note Linkage Report",
680
+ "",
681
+ `Generated at: ${new Date().toISOString()}`,
682
+ `Network: ${state.manifest.network ?? "-"}`,
683
+ `Channel: ${state.manifest.channelName ?? state.manifest.channelId ?? "-"}`,
684
+ `Wallet: ${evidenceWalletLabel(state.manifest)}`,
685
+ `Case ID: ${metadata.caseId || "-"}`,
686
+ `Requesting party: ${metadata.requestingParty || "-"}`,
687
+ "",
688
+ "## ASCII Linkage Graph",
689
+ "",
690
+ "```text",
691
+ ...asciiGraphLines(),
692
+ "```",
693
+ "",
694
+ "## Note Details",
695
+ "",
696
+ ];
697
+ for (const { note } of state.filteredNotes) {
698
+ lines.push(
699
+ `### ${note.label}${selected.has(note.path) ? " (selected)" : ""}`,
700
+ "",
701
+ `- Commitment: ${note.commitment ?? "-"}`,
702
+ `- Nullifier: ${note.nullifier ?? "-"}`,
703
+ `- Value: ${note.value ?? "-"}`,
704
+ `- Status: ${note.status}`,
705
+ `- Created: ${formatEventRef(note.creation)}`,
706
+ `- Spent: ${formatEventRef(note.spend)}`,
707
+ `- Direction: ${note.direction}`,
708
+ `- Counterparty: ${note.counterparty ?? "-"}`,
709
+ "",
710
+ );
711
+ }
712
+ return `${lines.join("\n")}\n`;
713
+ }
714
+
715
+ function asciiGraphLines() {
716
+ const nodeByPath = new Map(state.graph.nodes.map((node) => [node.path, node]));
717
+ const localEdges = state.graph.edges.filter((edge) => edge.type === "local");
718
+ const outgoing = new Map();
719
+ const incoming = new Map();
720
+ for (const edge of localEdges) {
721
+ if (!outgoing.has(edge.sourcePath)) outgoing.set(edge.sourcePath, []);
722
+ if (!incoming.has(edge.targetPath)) incoming.set(edge.targetPath, []);
723
+ outgoing.get(edge.sourcePath).push(edge);
724
+ incoming.get(edge.targetPath).push(edge);
725
+ }
726
+ const lines = [];
727
+ const nodes = [...state.graph.nodes].sort((left, right) =>
728
+ compareNullableNumber(left.note.creation?.blockNumber, right.note.creation?.blockNumber)
729
+ || left.note.label.localeCompare(right.note.label));
730
+ for (const node of nodes) {
731
+ if (!incoming.has(node.path)) {
732
+ lines.push(`external -> ${asciiNodeLabel(node.note)}`);
733
+ }
734
+ for (const edge of outgoing.get(node.path) ?? []) {
735
+ const target = nodeByPath.get(edge.targetPath);
736
+ if (target) {
737
+ lines.push(`${asciiNodeLabel(node.note)} -- ${shortHex(edge.txHash)} --> ${asciiNodeLabel(target.note)}`);
738
+ }
739
+ }
740
+ if (node.note.status === "spent" && !outgoing.has(node.path)) {
741
+ lines.push(`${asciiNodeLabel(node.note)} -> external`);
742
+ }
743
+ if (node.note.status !== "spent" && !outgoing.has(node.path)) {
744
+ lines.push(`${asciiNodeLabel(node.note)} remains unused`);
745
+ }
746
+ }
747
+ return lines.length ? lines : ["No linkage graph is available for the current filter."];
748
+ }
749
+
750
+ function asciiNodeLabel(note) {
751
+ return `${note.label}[${note.value ?? "-"}, ${note.status}, ${shortHex(note.commitment)}]`;
752
+ }
753
+
754
+ function asciiReportFileName(metadata, manifest) {
755
+ const rawName = [
756
+ metadata.caseId || "note-linkage-report",
757
+ manifest.network,
758
+ manifest.channelName,
759
+ new Date().toISOString().replace(/[:.]/g, "-"),
760
+ ].filter(Boolean).join("-");
761
+ return `${rawName.replace(/[^A-Za-z0-9_.-]+/g, "-")}.md`;
762
+ }
763
+
191
764
  function textCell(value) {
192
765
  const cell = document.createElement("td");
193
766
  cell.textContent = value === null || value === undefined || value === "" ? "-" : String(value);
@@ -595,14 +1168,14 @@ function dosTimeDate(date) {
595
1168
  return { time, date: dosDate };
596
1169
  }
597
1170
 
598
- function parseOptionalNumber(value) {
1171
+ function parseOptionalNumber(value, label) {
599
1172
  const text = String(value ?? "").trim();
600
1173
  if (!text) {
601
1174
  return null;
602
1175
  }
603
1176
  const number = Number(text);
604
1177
  if (!Number.isSafeInteger(number) || number < 0) {
605
- return null;
1178
+ throw new Error(`${label} must be a non-negative integer.`);
606
1179
  }
607
1180
  return number;
608
1181
  }
@@ -620,6 +1193,12 @@ function inRange(value, from, to) {
620
1193
  return true;
621
1194
  }
622
1195
 
1196
+ function compareNullableNumber(left, right) {
1197
+ const leftNumber = Number.isFinite(Number(left)) ? Number(left) : Number.MAX_SAFE_INTEGER;
1198
+ const rightNumber = Number.isFinite(Number(right)) ? Number(right) : Number.MAX_SAFE_INTEGER;
1199
+ return leftNumber - rightNumber;
1200
+ }
1201
+
623
1202
  function normalizeSearch(value) {
624
1203
  return String(value ?? "").trim().toLowerCase();
625
1204
  }
@@ -636,6 +1215,14 @@ function shortHex(value) {
636
1215
  return text.length > 18 ? `${text.slice(0, 10)}...${text.slice(-8)}` : text;
637
1216
  }
638
1217
 
1218
+ function shortText(value, maxLength) {
1219
+ const text = String(value ?? "");
1220
+ if (text.length <= maxLength) {
1221
+ return text;
1222
+ }
1223
+ return `${text.slice(0, Math.max(1, maxLength - 3))}...`;
1224
+ }
1225
+
639
1226
  function formatEventRef(value) {
640
1227
  if (!value?.txHash) {
641
1228
  return "-";
@@ -672,3 +1259,11 @@ function downloadBlob(blob, fileName) {
672
1259
  function setStatus(message) {
673
1260
  els.status.textContent = message;
674
1261
  }
1262
+
1263
+ function svgElement(tagName, attributes = {}) {
1264
+ const element = document.createElementNS("http://www.w3.org/2000/svg", tagName);
1265
+ for (const [key, value] of Object.entries(attributes)) {
1266
+ element.setAttribute(key, value);
1267
+ }
1268
+ return element;
1269
+ }