@tokamak-private-dapps/private-state-cli 2.1.1 → 2.2.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.
- package/CHANGELOG.md +45 -0
- package/README.md +109 -38
- package/cli-assistant.html +11 -5
- package/commands/account.mjs +39 -0
- package/commands/channel.mjs +58 -0
- package/commands/index.mjs +62 -0
- package/commands/investigator.mjs +11 -0
- package/commands/notes.mjs +34 -0
- package/commands/system.mjs +49 -0
- package/commands/wallet.mjs +80 -0
- package/investigator/README.md +16 -6
- package/investigator/app.js +612 -17
- package/investigator/index.html +153 -90
- package/investigator/styles.css +277 -28
- package/lib/private-state-cli-command-registry.mjs +102 -30
- package/lib/private-state-runtime-management.mjs +294 -25
- package/lib/runtime.mjs +11572 -0
- package/package.json +2 -1
- package/private-state-bridge-cli.mjs +2 -11269
package/investigator/app.js
CHANGED
|
@@ -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
|
-
|
|
60
|
+
renderResults();
|
|
44
61
|
});
|
|
45
62
|
els.selectNone.addEventListener("click", () => {
|
|
46
63
|
state.selectedNotePaths.clear();
|
|
47
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|