@tokamak-private-dapps/private-state-cli 1.2.1 → 2.1.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.
@@ -0,0 +1,674 @@
1
+ const textDecoder = new TextDecoder();
2
+ const textEncoder = new TextEncoder();
3
+
4
+ const state = {
5
+ files: new Map(),
6
+ manifest: null,
7
+ notes: [],
8
+ filteredNotes: [],
9
+ selectedNotePaths: new Set(),
10
+ };
11
+
12
+ const els = {
13
+ bundleFile: document.getElementById("bundleFile"),
14
+ filters: document.getElementById("filters"),
15
+ packageForm: document.getElementById("packageForm"),
16
+ applyFilters: document.getElementById("applyFilters"),
17
+ buildPackage: document.getElementById("buildPackage"),
18
+ selectAll: document.getElementById("selectAll"),
19
+ selectNone: document.getElementById("selectNone"),
20
+ status: document.getElementById("status"),
21
+ noteRows: document.getElementById("noteRows"),
22
+ };
23
+
24
+ els.bundleFile.addEventListener("change", async (event) => {
25
+ const file = event.target.files?.[0];
26
+ if (!file) {
27
+ return;
28
+ }
29
+ try {
30
+ setStatus(`Loading ${file.name}...`);
31
+ const bytes = new Uint8Array(await file.arrayBuffer());
32
+ await loadEvidenceBundle(bytes);
33
+ applyFilters();
34
+ } catch (error) {
35
+ resetBundle();
36
+ setStatus(`Failed to load bundle: ${error.message}`);
37
+ }
38
+ });
39
+
40
+ els.applyFilters.addEventListener("click", applyFilters);
41
+ els.selectAll.addEventListener("click", () => {
42
+ state.selectedNotePaths = new Set(state.filteredNotes.map((entry) => entry.path));
43
+ renderNotes();
44
+ });
45
+ els.selectNone.addEventListener("click", () => {
46
+ state.selectedNotePaths.clear();
47
+ renderNotes();
48
+ });
49
+ els.buildPackage.addEventListener("click", async () => {
50
+ try {
51
+ await buildDisclosurePackage();
52
+ } catch (error) {
53
+ setStatus(`Failed to build disclosure package: ${error.message}`);
54
+ }
55
+ });
56
+
57
+ async function loadEvidenceBundle(bytes) {
58
+ const files = await readZip(bytes);
59
+ const manifest = readJsonFile(files, "manifest.json");
60
+ if (manifest.format !== "tokamak-private-state-raw-evidence-bundle") {
61
+ throw new Error(`Unsupported evidence format: ${manifest.format ?? "missing"}.`);
62
+ }
63
+ if (Number(manifest.formatVersion) !== 2) {
64
+ throw new Error("Current evidence bundle formatVersion 2 is required. Run wallet recover-workspace, then run wallet get-notes --export-evidence again.");
65
+ }
66
+ const notes = [...files.entries()]
67
+ .filter(([path]) => isEvidenceNotePath(path))
68
+ .map(([path, content]) => ({
69
+ path,
70
+ record: JSON.parse(content),
71
+ }))
72
+ .sort((left, right) =>
73
+ String(left.record?.derived?.commitment ?? "").localeCompare(String(right.record?.derived?.commitment ?? "")));
74
+ if (notes.length === 0) {
75
+ throw new Error("The evidence bundle does not contain current epoch-aware note records.");
76
+ }
77
+ state.files = files;
78
+ state.manifest = manifest;
79
+ state.notes = notes;
80
+ state.filteredNotes = notes;
81
+ state.selectedNotePaths = new Set(notes.map((entry) => entry.path));
82
+ els.buildPackage.disabled = false;
83
+ els.selectAll.disabled = false;
84
+ els.selectNone.disabled = false;
85
+ setStatus(
86
+ `Loaded ${notes.length} note records from ${evidenceWalletLabel(manifest)} on ${manifest.network ?? "network"}.`,
87
+ );
88
+ }
89
+
90
+ function isEvidenceNotePath(entryPath) {
91
+ return /^wallets\/[^/]+\/epochs\/[^/]+\/notes\/[^/]+\.json$/u.test(entryPath);
92
+ }
93
+
94
+ function evidenceWalletLabel(manifest) {
95
+ if (manifest.wallets?.length) {
96
+ return `${manifest.wallets[0].wallet ?? manifest.wallet ?? "wallet"} (${manifest.wallets.length} epochs)`;
97
+ }
98
+ return manifest.wallet ?? "wallet";
99
+ }
100
+
101
+ function resetBundle() {
102
+ state.files = new Map();
103
+ state.manifest = null;
104
+ state.notes = [];
105
+ state.filteredNotes = [];
106
+ state.selectedNotePaths.clear();
107
+ els.buildPackage.disabled = true;
108
+ els.selectAll.disabled = true;
109
+ els.selectNone.disabled = true;
110
+ els.noteRows.textContent = "";
111
+ }
112
+
113
+ function applyFilters() {
114
+ if (!state.manifest) {
115
+ setStatus("Load a raw evidence ZIP to begin.");
116
+ return;
117
+ }
118
+ const form = new FormData(els.filters);
119
+ const criteria = getFilterCriteria(form);
120
+ state.filteredNotes = state.notes.filter(({ record }) => matchesCriteria(record, criteria));
121
+ state.selectedNotePaths = new Set(state.filteredNotes.map((entry) => entry.path));
122
+ renderNotes();
123
+ setStatus(`${state.filteredNotes.length} of ${state.notes.length} notes match the current filter.`);
124
+ }
125
+
126
+ function getFilterCriteria(form) {
127
+ return {
128
+ commitment: normalizeSearch(form.get("commitment")),
129
+ nullifier: normalizeSearch(form.get("nullifier")),
130
+ creationTx: normalizeSearch(form.get("creationTx")),
131
+ 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")),
136
+ status: String(form.get("status") ?? ""),
137
+ direction: String(form.get("direction") ?? ""),
138
+ counterparty: normalizeSearch(form.get("counterparty")),
139
+ };
140
+ }
141
+
142
+ function matchesCriteria(record, criteria) {
143
+ if (criteria.commitment && !contains(record.derived?.commitment, criteria.commitment)) return false;
144
+ if (criteria.nullifier && !contains(record.derived?.nullifier, criteria.nullifier)) return false;
145
+ if (criteria.creationTx && !contains(record.creation?.txHash, criteria.creationTx)) return false;
146
+ if (criteria.spendTx && !contains(record.spend?.txHash, criteria.spendTx)) return false;
147
+ if (criteria.status && record.spend?.status !== criteria.status) return false;
148
+ if (criteria.direction && record.relationshipHints?.direction !== criteria.direction) return false;
149
+ if (criteria.counterparty && !contains(record.relationshipHints?.counterpartyL2Address, criteria.counterparty)) return false;
150
+ if (!inRange(record.creation?.blockNumber, criteria.createdFrom, criteria.createdTo)) return false;
151
+ if (!inRange(record.spend?.blockNumber, criteria.spentFrom, criteria.spentTo)) return false;
152
+ return true;
153
+ }
154
+
155
+ function renderNotes() {
156
+ els.noteRows.textContent = "";
157
+ const fragment = document.createDocumentFragment();
158
+ for (const { path, record } of state.filteredNotes) {
159
+ const row = document.createElement("tr");
160
+ row.append(
161
+ cellWithCheckbox(path),
162
+ monoCell(shortHex(record.derived?.commitment)),
163
+ textCell(record.plaintext?.value ?? ""),
164
+ textCell(record.spend?.status ?? ""),
165
+ monoCell(formatEventRef(record.creation)),
166
+ monoCell(formatEventRef(record.spend)),
167
+ textCell(record.relationshipHints?.direction ?? "unknown"),
168
+ monoCell(shortHex(record.relationshipHints?.counterpartyL2Address)),
169
+ );
170
+ fragment.append(row);
171
+ }
172
+ els.noteRows.append(fragment);
173
+ }
174
+
175
+ function cellWithCheckbox(path) {
176
+ const cell = document.createElement("td");
177
+ const checkbox = document.createElement("input");
178
+ checkbox.type = "checkbox";
179
+ checkbox.checked = state.selectedNotePaths.has(path);
180
+ checkbox.addEventListener("change", () => {
181
+ if (checkbox.checked) {
182
+ state.selectedNotePaths.add(path);
183
+ } else {
184
+ state.selectedNotePaths.delete(path);
185
+ }
186
+ });
187
+ cell.append(checkbox);
188
+ return cell;
189
+ }
190
+
191
+ function textCell(value) {
192
+ const cell = document.createElement("td");
193
+ cell.textContent = value === null || value === undefined || value === "" ? "-" : String(value);
194
+ return cell;
195
+ }
196
+
197
+ function monoCell(value) {
198
+ const cell = textCell(value);
199
+ cell.className = "mono";
200
+ return cell;
201
+ }
202
+
203
+ async function buildDisclosurePackage() {
204
+ if (!state.manifest) {
205
+ throw new Error("Load an evidence bundle first.");
206
+ }
207
+ const selectedNotes = state.notes.filter((entry) => state.selectedNotePaths.has(entry.path));
208
+ if (selectedNotes.length === 0) {
209
+ throw new Error("Select at least one note.");
210
+ }
211
+ const packageMetadata = readPackageMetadata();
212
+ const criteria = getFilterCriteria(new FormData(els.filters));
213
+ const selectedPaths = collectSelectedPaths(selectedNotes);
214
+ const files = new Map();
215
+ const manifest = buildDisclosureManifest({
216
+ selectedNotes,
217
+ selectedPaths,
218
+ packageMetadata,
219
+ criteria,
220
+ });
221
+
222
+ files.set("manifest.json", jsonString(manifest));
223
+ files.set("indexes/by-commitment.json", jsonString(buildDisclosureIndex(selectedNotes, "commitment")));
224
+ files.set("indexes/by-nullifier.json", jsonString(buildDisclosureIndex(selectedNotes, "nullifier")));
225
+ files.set("indexes/by-creation-tx.json", jsonString(buildDisclosureTxIndex(selectedNotes, "creation")));
226
+ files.set("indexes/by-spend-tx.json", jsonString(buildDisclosureTxIndex(selectedNotes, "spend")));
227
+ files.set("indexes/by-block-range.json", jsonString(buildDisclosureBlockIndex(selectedNotes)));
228
+ files.set("indexes/by-counterparty.json", jsonString(buildDisclosureCounterpartyIndex(selectedNotes)));
229
+ files.set("verification-guide.md", buildVerificationGuide());
230
+ if (packageMetadata.statement) {
231
+ files.set("user-statement.txt", `${packageMetadata.statement}\n`);
232
+ }
233
+ for (const path of selectedPaths) {
234
+ const content = state.files.get(path);
235
+ if (content !== undefined) {
236
+ files.set(path, content.endsWith("\n") ? content : `${content}\n`);
237
+ }
238
+ }
239
+
240
+ const zipBytes = createZip(files);
241
+ const blob = new Blob([zipBytes], { type: "application/zip" });
242
+ const fileName = disclosureFileName(packageMetadata, state.manifest);
243
+ downloadBlob(blob, fileName);
244
+ setStatus(`Built ${fileName} with ${selectedNotes.length} selected notes and ${files.size} files.`);
245
+ }
246
+
247
+ function readPackageMetadata() {
248
+ const form = new FormData(els.packageForm);
249
+ return {
250
+ caseId: String(form.get("caseId") ?? "").trim(),
251
+ requestingParty: String(form.get("requestingParty") ?? "").trim(),
252
+ bridgeDepositTx: normalizeSearch(form.get("bridgeDepositTx")),
253
+ withdrawTx: normalizeSearch(form.get("withdrawTx")),
254
+ statement: String(form.get("statement") ?? "").trim(),
255
+ intents: form.getAll("intent").map((value) => String(value)),
256
+ };
257
+ }
258
+
259
+ function collectSelectedPaths(selectedNotes) {
260
+ const paths = new Set();
261
+ for (const { path, record } of selectedNotes) {
262
+ paths.add(path);
263
+ addTransitionPaths(paths, record.creation?.acceptedTransition);
264
+ addTransitionPaths(paths, record.spend?.acceptedTransition);
265
+ }
266
+ return [...paths].sort();
267
+ }
268
+
269
+ function buildDisclosureIndex(selectedNotes, key) {
270
+ const result = {};
271
+ for (const { path, record } of selectedNotes) {
272
+ const value = key === "commitment" ? record.derived?.commitment : record.derived?.nullifier;
273
+ if (value) {
274
+ result[value] = path;
275
+ }
276
+ }
277
+ return result;
278
+ }
279
+
280
+ function buildDisclosureTxIndex(selectedNotes, section) {
281
+ const result = {};
282
+ for (const { path, record } of selectedNotes) {
283
+ const txHash = record[section]?.txHash;
284
+ if (!txHash) {
285
+ continue;
286
+ }
287
+ if (!result[txHash]) {
288
+ result[txHash] = [];
289
+ }
290
+ result[txHash].push(path);
291
+ }
292
+ return result;
293
+ }
294
+
295
+ function buildDisclosureBlockIndex(selectedNotes) {
296
+ return selectedNotes.map(({ path, record }) => ({
297
+ commitment: record.derived?.commitment ?? null,
298
+ createdAtBlockNumber: record.creation?.blockNumber ?? null,
299
+ spentAtBlockNumber: record.spend?.blockNumber ?? null,
300
+ path,
301
+ }));
302
+ }
303
+
304
+ function buildDisclosureCounterpartyIndex(selectedNotes) {
305
+ const result = { unavailable: [] };
306
+ for (const { path, record } of selectedNotes) {
307
+ const counterparty = record.relationshipHints?.counterpartyL2Address;
308
+ if (!counterparty) {
309
+ result.unavailable.push(path);
310
+ continue;
311
+ }
312
+ if (!result[counterparty]) {
313
+ result[counterparty] = { sent: [], received: [], both: [] };
314
+ }
315
+ const direction = record.relationshipHints?.direction === "received" ? "received" : "sent";
316
+ result[counterparty][direction].push(path);
317
+ result[counterparty].both.push(path);
318
+ }
319
+ return result;
320
+ }
321
+
322
+ function addTransitionPaths(paths, transition) {
323
+ for (const key of ["transactionPath", "receiptPath", "eventsPath"]) {
324
+ if (transition?.[key]) {
325
+ paths.add(transition[key]);
326
+ }
327
+ }
328
+ }
329
+
330
+ function buildDisclosureManifest({ selectedNotes, selectedPaths, packageMetadata, criteria }) {
331
+ return {
332
+ format: "tokamak-private-state-consent-disclosure-package",
333
+ formatVersion: 1,
334
+ generatedAt: new Date().toISOString(),
335
+ sourceBundle: {
336
+ format: state.manifest.format,
337
+ formatVersion: state.manifest.formatVersion,
338
+ network: state.manifest.network,
339
+ chainId: state.manifest.chainId,
340
+ channelName: state.manifest.channelName,
341
+ channelId: state.manifest.channelId,
342
+ wallet: state.manifest.wallet,
343
+ wallets: state.manifest.wallets ?? null,
344
+ walletL1Address: state.manifest.walletL1Address,
345
+ walletL2Address: state.manifest.walletL2Address,
346
+ },
347
+ case: {
348
+ caseId: packageMetadata.caseId || null,
349
+ requestingParty: packageMetadata.requestingParty || null,
350
+ bridgeDepositTx: packageMetadata.bridgeDepositTx || null,
351
+ withdrawTx: packageMetadata.withdrawTx || null,
352
+ disclosureIntents: packageMetadata.intents,
353
+ },
354
+ filterCriteria: criteria,
355
+ disclosureScope: {
356
+ selectedNoteCount: selectedNotes.length,
357
+ selectedCommitments: selectedNotes.map(({ record }) => record.derived?.commitment).filter(Boolean),
358
+ selectedNullifiers: selectedNotes.map(({ record }) => record.derived?.nullifier).filter(Boolean),
359
+ includedPaths: selectedPaths,
360
+ includesFullRawBundle: false,
361
+ includesOnlySelectedNotes: true,
362
+ },
363
+ includedSecrets: {
364
+ spendingKey: false,
365
+ viewingKey: false,
366
+ walletSecret: false,
367
+ accountPrivateKey: false,
368
+ keyFiles: false,
369
+ },
370
+ warnings: [
371
+ "Selected note plaintext is included for the disclosed notes.",
372
+ "This package is not a keyless cryptographic decryption proof.",
373
+ "Counterparty filtering is only as complete as the relationship hints present in the raw evidence bundle.",
374
+ ],
375
+ };
376
+ }
377
+
378
+ function buildVerificationGuide() {
379
+ return [
380
+ "# Verification Guide",
381
+ "",
382
+ "This ZIP is a user-consent disclosure package derived from a local raw evidence bundle.",
383
+ "",
384
+ "A reviewer can:",
385
+ "",
386
+ "1. Recompute each note commitment from `plaintext.owner`, `plaintext.value`, and `plaintext.salt`.",
387
+ "2. Recompute each note nullifier from the same plaintext fields.",
388
+ "3. Compare creation and spend transaction references against included transaction, receipt, and event files.",
389
+ "4. Confirm that the package manifest excludes viewing keys, spending keys, wallet secrets, account private keys, and `.key` files.",
390
+ "5. Treat bridge deposit, withdraw, and counterparty fields as user-scoped disclosure context, not as an operator-reconstructed private note graph.",
391
+ "",
392
+ ].join("\n");
393
+ }
394
+
395
+ async function readZip(bytes) {
396
+ const entries = parseZipEntries(bytes);
397
+ const files = new Map();
398
+ for (const entry of entries) {
399
+ if (entry.isDirectory) {
400
+ continue;
401
+ }
402
+ const data = await inflateZipEntry(bytes, entry);
403
+ files.set(entry.name, textDecoder.decode(data));
404
+ }
405
+ return files;
406
+ }
407
+
408
+ function parseZipEntries(bytes) {
409
+ const eocdOffset = findEndOfCentralDirectory(bytes);
410
+ const entryCount = readUint16(bytes, eocdOffset + 10);
411
+ const centralDirectoryOffset = readUint32(bytes, eocdOffset + 16);
412
+ const entries = [];
413
+ let offset = centralDirectoryOffset;
414
+ for (let index = 0; index < entryCount; index += 1) {
415
+ if (readUint32(bytes, offset) !== 0x02014b50) {
416
+ throw new Error("Invalid ZIP central directory.");
417
+ }
418
+ const method = readUint16(bytes, offset + 10);
419
+ const compressedSize = readUint32(bytes, offset + 20);
420
+ const uncompressedSize = readUint32(bytes, offset + 24);
421
+ const nameLength = readUint16(bytes, offset + 28);
422
+ const extraLength = readUint16(bytes, offset + 30);
423
+ const commentLength = readUint16(bytes, offset + 32);
424
+ const localHeaderOffset = readUint32(bytes, offset + 42);
425
+ const name = textDecoder.decode(bytes.slice(offset + 46, offset + 46 + nameLength));
426
+ entries.push({
427
+ name,
428
+ method,
429
+ compressedSize,
430
+ uncompressedSize,
431
+ localHeaderOffset,
432
+ isDirectory: name.endsWith("/"),
433
+ });
434
+ offset += 46 + nameLength + extraLength + commentLength;
435
+ }
436
+ return entries;
437
+ }
438
+
439
+ function findEndOfCentralDirectory(bytes) {
440
+ const minimum = Math.max(0, bytes.length - 65557);
441
+ for (let offset = bytes.length - 22; offset >= minimum; offset -= 1) {
442
+ if (readUint32(bytes, offset) === 0x06054b50) {
443
+ return offset;
444
+ }
445
+ }
446
+ throw new Error("ZIP end-of-central-directory record not found.");
447
+ }
448
+
449
+ async function inflateZipEntry(bytes, entry) {
450
+ const offset = entry.localHeaderOffset;
451
+ if (readUint32(bytes, offset) !== 0x04034b50) {
452
+ throw new Error(`Invalid local ZIP header for ${entry.name}.`);
453
+ }
454
+ const nameLength = readUint16(bytes, offset + 26);
455
+ const extraLength = readUint16(bytes, offset + 28);
456
+ const dataStart = offset + 30 + nameLength + extraLength;
457
+ const compressed = bytes.slice(dataStart, dataStart + entry.compressedSize);
458
+ if (entry.method === 0) {
459
+ return compressed;
460
+ }
461
+ if (entry.method !== 8) {
462
+ throw new Error(`Unsupported ZIP compression method ${entry.method} for ${entry.name}.`);
463
+ }
464
+ if (!("DecompressionStream" in window)) {
465
+ throw new Error("This browser cannot decompress ZIP deflate entries.");
466
+ }
467
+ const stream = new Blob([compressed]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
468
+ return new Uint8Array(await new Response(stream).arrayBuffer());
469
+ }
470
+
471
+ function createZip(files) {
472
+ const localParts = [];
473
+ const centralParts = [];
474
+ let offset = 0;
475
+ for (const [name, content] of files.entries()) {
476
+ const nameBytes = textEncoder.encode(name);
477
+ const data = typeof content === "string" ? textEncoder.encode(content) : content;
478
+ const crc = crc32(data);
479
+ const { time, date } = dosTimeDate(new Date());
480
+ const localHeader = new Uint8Array(30 + nameBytes.length);
481
+ writeUint32(localHeader, 0, 0x04034b50);
482
+ writeUint16(localHeader, 4, 20);
483
+ writeUint16(localHeader, 8, 0);
484
+ writeUint16(localHeader, 10, time);
485
+ writeUint16(localHeader, 12, date);
486
+ writeUint32(localHeader, 14, crc);
487
+ writeUint32(localHeader, 18, data.length);
488
+ writeUint32(localHeader, 22, data.length);
489
+ writeUint16(localHeader, 26, nameBytes.length);
490
+ localHeader.set(nameBytes, 30);
491
+ localParts.push(localHeader, data);
492
+
493
+ const centralHeader = new Uint8Array(46 + nameBytes.length);
494
+ writeUint32(centralHeader, 0, 0x02014b50);
495
+ writeUint16(centralHeader, 4, 20);
496
+ writeUint16(centralHeader, 6, 20);
497
+ writeUint16(centralHeader, 10, 0);
498
+ writeUint16(centralHeader, 12, time);
499
+ writeUint16(centralHeader, 14, date);
500
+ writeUint32(centralHeader, 16, crc);
501
+ writeUint32(centralHeader, 20, data.length);
502
+ writeUint32(centralHeader, 24, data.length);
503
+ writeUint16(centralHeader, 28, nameBytes.length);
504
+ writeUint32(centralHeader, 42, offset);
505
+ centralHeader.set(nameBytes, 46);
506
+ centralParts.push(centralHeader);
507
+ offset += localHeader.length + data.length;
508
+ }
509
+
510
+ const centralOffset = offset;
511
+ const centralSize = centralParts.reduce((sum, part) => sum + part.length, 0);
512
+ const end = new Uint8Array(22);
513
+ writeUint32(end, 0, 0x06054b50);
514
+ writeUint16(end, 8, files.size);
515
+ writeUint16(end, 10, files.size);
516
+ writeUint32(end, 12, centralSize);
517
+ writeUint32(end, 16, centralOffset);
518
+ return concatBytes([...localParts, ...centralParts, end]);
519
+ }
520
+
521
+ function readJsonFile(files, path) {
522
+ const content = files.get(path);
523
+ if (!content) {
524
+ throw new Error(`Missing ${path}.`);
525
+ }
526
+ return JSON.parse(content);
527
+ }
528
+
529
+ function readUint16(bytes, offset) {
530
+ return bytes[offset] | (bytes[offset + 1] << 8);
531
+ }
532
+
533
+ function readUint32(bytes, offset) {
534
+ return (
535
+ bytes[offset]
536
+ | (bytes[offset + 1] << 8)
537
+ | (bytes[offset + 2] << 16)
538
+ | (bytes[offset + 3] << 24)
539
+ ) >>> 0;
540
+ }
541
+
542
+ function writeUint16(bytes, offset, value) {
543
+ bytes[offset] = value & 0xff;
544
+ bytes[offset + 1] = (value >>> 8) & 0xff;
545
+ }
546
+
547
+ function writeUint32(bytes, offset, value) {
548
+ bytes[offset] = value & 0xff;
549
+ bytes[offset + 1] = (value >>> 8) & 0xff;
550
+ bytes[offset + 2] = (value >>> 16) & 0xff;
551
+ bytes[offset + 3] = (value >>> 24) & 0xff;
552
+ }
553
+
554
+ function concatBytes(parts) {
555
+ const total = parts.reduce((sum, part) => sum + part.length, 0);
556
+ const result = new Uint8Array(total);
557
+ let offset = 0;
558
+ for (const part of parts) {
559
+ result.set(part, offset);
560
+ offset += part.length;
561
+ }
562
+ return result;
563
+ }
564
+
565
+ function crc32(bytes) {
566
+ let value = 0xffffffff;
567
+ for (const byte of bytes) {
568
+ value = crcTable[(value ^ byte) & 0xff] ^ (value >>> 8);
569
+ }
570
+ return (value ^ 0xffffffff) >>> 0;
571
+ }
572
+
573
+ const crcTable = (() => {
574
+ const table = [];
575
+ for (let index = 0; index < 256; index += 1) {
576
+ let value = index;
577
+ for (let bit = 0; bit < 8; bit += 1) {
578
+ value = (value & 1) ? (0xedb88320 ^ (value >>> 1)) : (value >>> 1);
579
+ }
580
+ table[index] = value >>> 0;
581
+ }
582
+ return table;
583
+ })();
584
+
585
+ function dosTimeDate(date) {
586
+ const time =
587
+ (date.getHours() << 11)
588
+ | (date.getMinutes() << 5)
589
+ | Math.floor(date.getSeconds() / 2);
590
+ const year = Math.max(1980, date.getFullYear());
591
+ const dosDate =
592
+ ((year - 1980) << 9)
593
+ | ((date.getMonth() + 1) << 5)
594
+ | date.getDate();
595
+ return { time, date: dosDate };
596
+ }
597
+
598
+ function parseOptionalNumber(value) {
599
+ const text = String(value ?? "").trim();
600
+ if (!text) {
601
+ return null;
602
+ }
603
+ const number = Number(text);
604
+ if (!Number.isSafeInteger(number) || number < 0) {
605
+ return null;
606
+ }
607
+ return number;
608
+ }
609
+
610
+ function inRange(value, from, to) {
611
+ if (from === null && to === null) {
612
+ return true;
613
+ }
614
+ if (value === null || value === undefined) {
615
+ return false;
616
+ }
617
+ const number = Number(value);
618
+ if (from !== null && number < from) return false;
619
+ if (to !== null && number > to) return false;
620
+ return true;
621
+ }
622
+
623
+ function normalizeSearch(value) {
624
+ return String(value ?? "").trim().toLowerCase();
625
+ }
626
+
627
+ function contains(value, needle) {
628
+ return String(value ?? "").toLowerCase().includes(needle);
629
+ }
630
+
631
+ function shortHex(value) {
632
+ const text = String(value ?? "");
633
+ if (!text) {
634
+ return "-";
635
+ }
636
+ return text.length > 18 ? `${text.slice(0, 10)}...${text.slice(-8)}` : text;
637
+ }
638
+
639
+ function formatEventRef(value) {
640
+ if (!value?.txHash) {
641
+ return "-";
642
+ }
643
+ const block = value.blockNumber === null || value.blockNumber === undefined ? "?" : value.blockNumber;
644
+ return `${shortHex(value.txHash)} @ ${block}`;
645
+ }
646
+
647
+ function jsonString(value) {
648
+ return `${JSON.stringify(value, null, 2)}\n`;
649
+ }
650
+
651
+ function disclosureFileName(metadata, manifest) {
652
+ const rawName = [
653
+ metadata.caseId || "consent-disclosure",
654
+ manifest.network,
655
+ manifest.channelName,
656
+ new Date().toISOString().replace(/[:.]/g, "-"),
657
+ ].filter(Boolean).join("-");
658
+ return `${rawName.replace(/[^A-Za-z0-9_.-]+/g, "-")}.zip`;
659
+ }
660
+
661
+ function downloadBlob(blob, fileName) {
662
+ const url = URL.createObjectURL(blob);
663
+ const link = document.createElement("a");
664
+ link.href = url;
665
+ link.download = fileName;
666
+ document.body.append(link);
667
+ link.click();
668
+ link.remove();
669
+ URL.revokeObjectURL(url);
670
+ }
671
+
672
+ function setStatus(message) {
673
+ els.status.textContent = message;
674
+ }