@tokamak-private-dapps/private-state-cli 0.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,1869 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta
7
+ name="description"
8
+ content="Local assistant for constructing Tokamak private-state bridge CLI commands."
9
+ />
10
+ <meta property="og:title" content="Private-State CLI Assistant" />
11
+ <meta
12
+ property="og:description"
13
+ content="Local assistant for constructing Tokamak private-state bridge CLI commands."
14
+ />
15
+ <meta property="og:type" content="website" />
16
+ <meta name="twitter:card" content="summary" />
17
+ <title>Private-State CLI Assistant</title>
18
+ <script type="application/ld+json">
19
+ {
20
+ "@context": "https://schema.org",
21
+ "@type": "SoftwareApplication",
22
+ "name": "Private-State CLI Assistant",
23
+ "applicationCategory": "DeveloperApplication",
24
+ "operatingSystem": "Any",
25
+ "description": "Local assistant for constructing Tokamak private-state bridge CLI commands."
26
+ }
27
+ </script>
28
+ <style>
29
+ body {
30
+ margin: 0;
31
+ padding: 12px;
32
+ font-family: sans-serif;
33
+ font-size: 14px;
34
+ line-height: 1.3;
35
+ background: #f5f5f2;
36
+ color: #111;
37
+ }
38
+
39
+ main {
40
+ max-width: 1480px;
41
+ margin: 0 auto;
42
+ }
43
+
44
+ section {
45
+ margin-bottom: 0;
46
+ }
47
+
48
+ form,
49
+ .box {
50
+ border: 1px solid #d7d7d0;
51
+ background: #fff;
52
+ border-radius: 8px;
53
+ padding: 10px;
54
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
55
+ }
56
+
57
+ .fields {
58
+ display: grid;
59
+ gap: 10px;
60
+ grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
61
+ align-items: start;
62
+ }
63
+
64
+ #memory-fields {
65
+ grid-template-columns: repeat(3, minmax(0, 1fr));
66
+ align-items: start;
67
+ }
68
+
69
+ .fields > *,
70
+ .field-stack {
71
+ min-width: 0;
72
+ align-self: start;
73
+ }
74
+
75
+ label {
76
+ display: grid;
77
+ gap: 6px;
78
+ font-size: 13px;
79
+ min-width: 0;
80
+ font-weight: 600;
81
+ }
82
+
83
+ input,
84
+ select,
85
+ textarea,
86
+ button {
87
+ font: inherit;
88
+ }
89
+
90
+ input,
91
+ select,
92
+ textarea {
93
+ box-sizing: border-box;
94
+ min-width: 0;
95
+ width: 100%;
96
+ padding: 8px 10px;
97
+ border: 1px solid #cfd1c7;
98
+ border-radius: 8px;
99
+ background: #fff;
100
+ font-size: 13px;
101
+ }
102
+
103
+ .field-stack button {
104
+ box-sizing: border-box;
105
+ min-width: 0;
106
+ width: 100%;
107
+ }
108
+
109
+ button {
110
+ padding: 7px 11px;
111
+ border: 1px solid #bfc2b6;
112
+ border-radius: 8px;
113
+ background: #fafaf7;
114
+ cursor: pointer;
115
+ font-size: 13px;
116
+ }
117
+
118
+ button:hover {
119
+ background: #f1f2ec;
120
+ }
121
+
122
+ textarea {
123
+ min-height: 96px;
124
+ resize: vertical;
125
+ }
126
+
127
+ .actions {
128
+ display: flex;
129
+ flex-wrap: wrap;
130
+ gap: 8px;
131
+ margin-top: 6px;
132
+ }
133
+
134
+ .inline-actions {
135
+ display: flex;
136
+ flex-wrap: wrap;
137
+ gap: 6px;
138
+ }
139
+
140
+ .inline-actions button {
141
+ width: auto;
142
+ }
143
+
144
+ .recipient-input-row {
145
+ display: grid;
146
+ grid-template-columns: minmax(0, 1fr) auto;
147
+ gap: 6px;
148
+ align-items: center;
149
+ }
150
+
151
+ .recipient-input-row button {
152
+ width: auto;
153
+ white-space: nowrap;
154
+ }
155
+
156
+ .transfer-pair-grid {
157
+ display: grid;
158
+ gap: 12px;
159
+ grid-template-columns: 1fr;
160
+ align-items: start;
161
+ }
162
+
163
+ .transfer-pair {
164
+ display: grid;
165
+ gap: 8px;
166
+ align-content: start;
167
+ }
168
+
169
+ .field-stack {
170
+ display: grid;
171
+ gap: 6px;
172
+ align-content: start;
173
+ }
174
+
175
+ .field-span-full {
176
+ grid-column: 1 / -1;
177
+ }
178
+
179
+ .note-option-list {
180
+ display: grid;
181
+ gap: 6px;
182
+ padding: 8px;
183
+ border: 1px solid #cfd1c7;
184
+ border-radius: 8px;
185
+ background: #fff;
186
+ }
187
+
188
+ .note-option-item {
189
+ display: grid;
190
+ grid-template-columns: auto minmax(0, 1fr);
191
+ gap: 10px;
192
+ align-items: start;
193
+ }
194
+
195
+ .note-option-item input[type="checkbox"] {
196
+ margin-top: 3px;
197
+ }
198
+
199
+ .note-option-copy {
200
+ min-width: 0;
201
+ overflow-wrap: anywhere;
202
+ line-height: 1.35;
203
+ }
204
+
205
+ .note-option-list.is-disabled {
206
+ color: #666;
207
+ background: #f6f6f3;
208
+ }
209
+
210
+ pre {
211
+ margin: 0;
212
+ white-space: pre-wrap;
213
+ word-break: break-word;
214
+ max-height: 240px;
215
+ overflow: auto;
216
+ font-size: 13px;
217
+ line-height: 1.35;
218
+ }
219
+
220
+ .hint,
221
+ .status {
222
+ color: #444;
223
+ font-size: 12px;
224
+ overflow-wrap: anywhere;
225
+ line-height: 1.3;
226
+ }
227
+
228
+ h1,
229
+ h2 {
230
+ margin: 0 0 8px;
231
+ line-height: 1.1;
232
+ }
233
+
234
+ h1 {
235
+ font-size: 26px;
236
+ }
237
+
238
+ h2 {
239
+ font-size: 20px;
240
+ }
241
+
242
+ p {
243
+ margin: 0 0 6px;
244
+ }
245
+
246
+ .page-grid {
247
+ display: grid;
248
+ grid-template-columns: minmax(0, 1.7fr) minmax(360px, 1fr);
249
+ gap: 12px;
250
+ align-items: start;
251
+ }
252
+
253
+ .page-column {
254
+ display: grid;
255
+ gap: 12px;
256
+ align-content: start;
257
+ }
258
+
259
+ .compact-select {
260
+ max-width: none;
261
+ }
262
+
263
+ #workspace-picker {
264
+ margin-bottom: 10px;
265
+ }
266
+
267
+ #memory-status {
268
+ margin-top: 6px;
269
+ min-height: 1.3em;
270
+ }
271
+
272
+ #command-preview {
273
+ min-height: 150px;
274
+ padding: 8px 10px;
275
+ border: 1px solid #e0e1d9;
276
+ border-radius: 8px;
277
+ background: #fbfbf8;
278
+ }
279
+
280
+ .intro {
281
+ margin-bottom: 10px;
282
+ }
283
+
284
+ .footer-stack {
285
+ display: grid;
286
+ gap: 12px;
287
+ margin-top: 12px;
288
+ }
289
+
290
+ .guide-list {
291
+ margin: 0;
292
+ padding-left: 20px;
293
+ display: grid;
294
+ gap: 8px;
295
+ }
296
+
297
+ .guide-list li {
298
+ line-height: 1.4;
299
+ }
300
+
301
+ .guide-label {
302
+ font-weight: 600;
303
+ }
304
+
305
+ .guide-example {
306
+ margin-top: 6px;
307
+ padding: 8px 10px;
308
+ border: 1px solid #e0e1d9;
309
+ border-radius: 8px;
310
+ background: #fbfbf8;
311
+ font-size: 13px;
312
+ line-height: 1.35;
313
+ overflow-wrap: anywhere;
314
+ }
315
+
316
+ @media (max-width: 900px) {
317
+ .page-grid {
318
+ grid-template-columns: 1fr;
319
+ }
320
+
321
+ #memory-fields {
322
+ grid-template-columns: 1fr;
323
+ }
324
+ }
325
+ </style>
326
+ </head>
327
+ <body>
328
+ <main>
329
+ <h1>Private-State CLI Assistant</h1>
330
+ <p class="hint intro">
331
+ This page stores your common inputs locally and assembles the current
332
+ <code>private-state-bridge-cli.mjs</code> command. A plain browser page cannot execute your terminal directly.
333
+ </p>
334
+
335
+ <div class="page-grid">
336
+ <div class="page-column">
337
+ <section>
338
+ <h2>Shared Inputs</h2>
339
+ <form class="box">
340
+ <div id="workspace-picker" class="field-stack"></div>
341
+ <div id="memory-fields" class="fields"></div>
342
+ <p id="memory-status" class="hint"></p>
343
+ </form>
344
+ </section>
345
+
346
+ <section>
347
+ <h2>Command Inputs</h2>
348
+ <form class="box">
349
+ <div id="command-fields" class="fields"></div>
350
+ </form>
351
+ </section>
352
+ </div>
353
+
354
+ <div class="page-column">
355
+ <section>
356
+ <h2>Command</h2>
357
+ <div class="box">
358
+ <div class="fields">
359
+ <label class="compact-select">
360
+ Command
361
+ <select id="command-select"></select>
362
+ </label>
363
+ </div>
364
+ <p id="command-description" class="hint"></p>
365
+ </div>
366
+ </section>
367
+
368
+ <section>
369
+ <h2>Command Output</h2>
370
+ <div class="box">
371
+ <p id="warning" class="hint"></p>
372
+ <pre id="command-preview"></pre>
373
+ <div class="actions">
374
+ <button id="copy-command" type="button">Copy command</button>
375
+ </div>
376
+ <p id="status" class="status" aria-live="polite"></p>
377
+ </div>
378
+ </section>
379
+ </div>
380
+ </div>
381
+
382
+ <div class="footer-stack">
383
+ <section>
384
+ <h2>Security Notes</h2>
385
+ <div class="box">
386
+ <ul class="guide-list">
387
+ <li>
388
+ Your channel wallet is stored in your local workspace at
389
+ <code id="workspace-path-label"></code>.
390
+ </li>
391
+ <li>
392
+ Even if you lose the channel wallet file, only your Ethereum private key, together with the correct
393
+ channel context, can recover your channel wallet as long as you still know the wallet password.
394
+ </li>
395
+ <li>
396
+ If your channel wallet file and wallet password are both stolen, your channel funds can be stolen.
397
+ </li>
398
+ <li>
399
+ If you lose your wallet password, you lose the ability to derive your channel L2 private key. In that
400
+ case, you lose ownership of all notes because you can no longer use them, and that ownership cannot be
401
+ recovered.
402
+ </li>
403
+ </ul>
404
+ </div>
405
+ </section>
406
+
407
+ <section>
408
+ <h2>Command Guide</h2>
409
+ <div class="box">
410
+ <ol class="guide-list">
411
+ <li>
412
+ <span class="guide-label">Join a channel:</span> <code>join-channel</code>
413
+ <div class="guide-example">
414
+ <code>join-channel --channel-name 'my-private-channel' --password '__WALLET_PASSWORD__' --network 'sepolia' --private-key '__L1_PRIVATE_KEY__' --alchemy-api-key '__ALCHEMY_API_KEY__'</code>
415
+ </div>
416
+ </li>
417
+ <li>
418
+ <span class="guide-label">Create notes:</span> <code>deposit-bridge</code> -&gt; <code>deposit-channel</code> -&gt; <code>mint-notes</code>
419
+ <div class="guide-example">
420
+ <code>deposit-bridge --amount '100' --network 'sepolia' --private-key '__L1_PRIVATE_KEY__' --alchemy-api-key '__ALCHEMY_API_KEY__'</code><br />
421
+ <code>deposit-channel --wallet 'my-private-channel-0xYourL1Address' --password '__WALLET_PASSWORD__' --network 'sepolia' --amount '100'</code><br />
422
+ <code>mint-notes --wallet 'my-private-channel-0xYourL1Address' --password '__WALLET_PASSWORD__' --network 'sepolia' --amounts '[&quot;50&quot;,&quot;50&quot;,&quot;0&quot;]'</code>
423
+ </div>
424
+ </li>
425
+ <li>
426
+ <span class="guide-label">Split, merge, or transfer note ownership:</span> <code>transfer-notes</code>
427
+ <div class="guide-example">
428
+ <code>transfer-notes --wallet 'my-private-channel-0xYourL1Address' --password '__WALLET_PASSWORD__' --network 'sepolia' --note-ids '[&quot;0xNoteIdA&quot;]' --recipients '[&quot;0xRecipientL2AddressA&quot;,&quot;0xRecipientL2AddressB&quot;]' --amounts '[&quot;25&quot;,&quot;25&quot;]'</code>
429
+ </div>
430
+ </li>
431
+ <li>
432
+ <span class="guide-label">Redeem notes back to L1:</span> <code>redeem-notes</code> -&gt; <code>withdraw-channel</code> -&gt; <code>withdraw-bridge</code>
433
+ <div class="guide-example">
434
+ <code>redeem-notes --wallet 'my-private-channel-0xYourL1Address' --password '__WALLET_PASSWORD__' --network 'sepolia' --note-ids '[&quot;0xNoteIdA&quot;]'</code><br />
435
+ <code>withdraw-channel --wallet 'my-private-channel-0xYourL1Address' --password '__WALLET_PASSWORD__' --network 'sepolia' --amount '25'</code><br />
436
+ <code>withdraw-bridge --amount '25' --network 'sepolia' --private-key '__L1_PRIVATE_KEY__' --alchemy-api-key '__ALCHEMY_API_KEY__'</code>
437
+ </div>
438
+ </li>
439
+ </ol>
440
+ </div>
441
+ </section>
442
+ </div>
443
+ </main>
444
+
445
+ <script src="../../../node_modules/ethers/dist/ethers.umd.js"></script>
446
+ <script>
447
+ const storageKey = "private-state-cli-assistant:v2";
448
+ const windowNameKey = "private-state-cli-assistant-state:";
449
+ const cliEntry = "node packages/apps/private-state/cli/private-state-bridge-cli.mjs";
450
+ const defaultWorkspaceRootLabel = navigator.userAgent.includes("Windows")
451
+ ? "%USERPROFILE%\\\\tokamak-private-channels\\\\workspace"
452
+ : "~/tokamak-private-channels/workspace";
453
+ const walletEncryptionVersion = 1;
454
+ const walletEncryptionAlgorithm = "aes-256-gcm";
455
+ const textEncoder = new TextEncoder();
456
+ const textDecoder = new TextDecoder();
457
+
458
+ const fieldCatalog = {
459
+ channelName: {
460
+ label: "Channel Name",
461
+ type: "text",
462
+ placeholder: "demo-channel",
463
+ },
464
+ network: {
465
+ label: "Network",
466
+ type: "select",
467
+ options: ["sepolia", "mainnet", "anvil"],
468
+ },
469
+ alchemyApiKey: {
470
+ label: "Alchemy API Key",
471
+ type: "password",
472
+ placeholder: "alchemy-key",
473
+ },
474
+ privateKey: {
475
+ label: "L1 Private Key",
476
+ type: "password",
477
+ placeholder: "0x...",
478
+ },
479
+ password: {
480
+ label: "Wallet Password",
481
+ type: "password",
482
+ placeholder: "channel-password",
483
+ },
484
+ wallet: {
485
+ label: "Wallet Name",
486
+ type: "text",
487
+ placeholder: "channel-0xYourL1Address",
488
+ },
489
+ amount: {
490
+ label: "Amount",
491
+ type: "text",
492
+ placeholder: "3",
493
+ },
494
+ amounts: {
495
+ label: "Amounts",
496
+ type: "textarea",
497
+ placeholder: "[1,2,3]",
498
+ },
499
+ noteIds: {
500
+ label: "Note IDs",
501
+ type: "textarea",
502
+ placeholder: "[\"0x...\"]",
503
+ },
504
+ recipients: {
505
+ label: "Recipients JSON",
506
+ type: "textarea",
507
+ placeholder: "[\"0xRecipientL2Address\"]",
508
+ },
509
+ force: {
510
+ label: "Force Exit",
511
+ type: "checkbox",
512
+ hint: "Bypass the CLI zero-balance guard for exit-channel.",
513
+ },
514
+ docker: {
515
+ label: "Docker Install Mode",
516
+ type: "checkbox",
517
+ hint: "Forward --docker to install-zk-evm. This mode is supported only on Linux hosts by the Tokamak CLI.",
518
+ },
519
+ };
520
+
521
+ const commands = [
522
+ { id: "install-zk-evm", description: "Install the local Tokamak zk-EVM toolchain. Optionally forward --docker for Linux-host Docker installs.", fields: ["docker"] },
523
+ { id: "uninstall-zk-evm", description: "Remove the Tokamak zk-EVM CLI runtime workspace.", fields: [] },
524
+ { id: "create-channel", description: "Create the bridge channel and initialize its workspace.", fields: ["channelName", "network", "privateKey", "alchemyApiKey"] },
525
+ { id: "recover-workspace", description: "Rebuild the saved channel workspace from bridge state.", fields: ["channelName", "network", "alchemyApiKey"] },
526
+ { id: "deposit-bridge", description: "Deposit canonical tokens into the shared bridge vault.", fields: ["amount", "network", "privateKey", "alchemyApiKey"] },
527
+ { id: "withdraw-bridge", description: "Withdraw shared bridge-vault funds back to the L1 wallet.", fields: ["amount", "network", "privateKey", "alchemyApiKey"] },
528
+ { id: "get-my-bridge-fund", description: "Read the current bridge-vault balance.", fields: ["network", "privateKey", "alchemyApiKey"] },
529
+ { id: "join-channel", description: "Bind the caller to a channel-specific L2 identity.", fields: ["channelName", "password", "network", "privateKey", "alchemyApiKey"] },
530
+ { id: "recover-wallet", description: "Rebuild the recoverable portion of a wallet.", fields: ["channelName", "password", "network", "privateKey", "alchemyApiKey"] },
531
+ { id: "get-my-address", description: "Check whether a saved wallet matches on-chain registration.", fields: ["wallet", "password", "network"] },
532
+ { id: "deposit-channel", description: "Move bridged funds into the channel L2 accounting balance.", fields: ["wallet", "password", "network", "amount"] },
533
+ { id: "withdraw-channel", description: "Move channel L2 balance back into the shared bridge vault.", fields: ["wallet", "password", "network", "amount"] },
534
+ { id: "get-my-channel-fund", description: "Read the current channel L2 accounting balance.", fields: ["wallet", "password", "network"] },
535
+ { id: "exit-channel", description: "Exit a channel. The assistant keeps the CLI zero-balance guard unless Force Exit is enabled.", fields: ["wallet", "password", "network", "force"] },
536
+ { id: "mint-notes", description: "Mint one or two private-state notes from the wallet channel balance.", fields: ["wallet", "password", "network", "amounts"] },
537
+ { id: "transfer-notes", description: "Spend tracked notes into the registered 1->1, 1->2, or 2->1 encrypted transfer shapes.", fields: ["wallet", "password", "network", "noteIds", "recipients", "amounts"] },
538
+ { id: "redeem-notes", description: "Redeem exactly one tracked note back into liquid accounting balance.", fields: ["wallet", "password", "network", "noteIds"] },
539
+ { id: "get-my-notes", description: "Refresh encrypted note-log recovery and show current wallet notes.", fields: ["wallet", "password", "network"] },
540
+ ];
541
+
542
+ const defaultState = {
543
+ commandId: "join-channel",
544
+ channelName: "",
545
+ network: "sepolia",
546
+ alchemyApiKey: "",
547
+ privateKey: "",
548
+ password: "",
549
+ wallet: "",
550
+ amount: "",
551
+ amounts: "",
552
+ mintAmount1: "3",
553
+ mintAmount2: "",
554
+ noteIds: "[\"0x...\"]",
555
+ recipients: "",
556
+ transferRecipient1: "",
557
+ transferAmount1: "",
558
+ transferRecipient2: "",
559
+ transferAmount2: "",
560
+ force: false,
561
+ docker: false,
562
+ };
563
+
564
+ const sharedFieldKeys = [
565
+ "network",
566
+ "alchemyApiKey",
567
+ "privateKey",
568
+ ];
569
+
570
+ const state = loadState();
571
+ const workspaceDirectoryState = {
572
+ handle: null,
573
+ byNetwork: {},
574
+ loaded: false,
575
+ statusMessage: "",
576
+ };
577
+ const walletNoteState = {
578
+ cacheKey: "",
579
+ options: [],
580
+ canonicalAssetDecimals: 18,
581
+ walletL2Address: "",
582
+ statusMessage: "",
583
+ hasLoaded: false,
584
+ };
585
+ const privateKeyAddressState = {
586
+ inputValue: "",
587
+ derivedAddress: "",
588
+ statusMessage: "",
589
+ };
590
+ let scryptModulePromise = null;
591
+ let ethersModulePromise = null;
592
+ let privateKeyLookupGeneration = 0;
593
+ let walletNoteRefreshTimer = null;
594
+
595
+ const workspacePickerEl = document.getElementById("workspace-picker");
596
+ const memoryFieldsEl = document.getElementById("memory-fields");
597
+ const memoryStatusEl = document.getElementById("memory-status");
598
+ const commandSelectEl = document.getElementById("command-select");
599
+ const commandDescriptionEl = document.getElementById("command-description");
600
+ const commandFieldsEl = document.getElementById("command-fields");
601
+ const warningEl = document.getElementById("warning");
602
+ const previewEl = document.getElementById("command-preview");
603
+ const statusEl = document.getElementById("status");
604
+ const workspacePathLabelEl = document.getElementById("workspace-path-label");
605
+ let workspaceStatusEl = null;
606
+ const maskedSecretValues = {
607
+ alchemyApiKey: "__ALCHEMY_API_KEY__",
608
+ privateKey: "__L1_PRIVATE_KEY__",
609
+ password: "__WALLET_PASSWORD__",
610
+ };
611
+
612
+ document.getElementById("copy-command").addEventListener("click", async () => {
613
+ await copyText(buildCommand({ maskSecrets: false }));
614
+ setStatus("Command copied.");
615
+ });
616
+
617
+ function loadState() {
618
+ const parseSerializedState = (raw) => {
619
+ if (!raw) {
620
+ return null;
621
+ }
622
+ return { ...structuredClone(defaultState), ...JSON.parse(raw) };
623
+ };
624
+
625
+ try {
626
+ const raw = window.localStorage.getItem(storageKey);
627
+ const parsed = parseSerializedState(raw);
628
+ if (parsed) {
629
+ return parsed;
630
+ }
631
+ } catch {
632
+ // Fallback to window.name below.
633
+ }
634
+
635
+ try {
636
+ if (typeof window.name === "string" && window.name.startsWith(windowNameKey)) {
637
+ const parsed = parseSerializedState(window.name.slice(windowNameKey.length));
638
+ if (parsed) {
639
+ return parsed;
640
+ }
641
+ }
642
+ } catch {
643
+ // Fall through to defaults.
644
+ }
645
+
646
+ return structuredClone(defaultState);
647
+ }
648
+
649
+ function persistState() {
650
+ const serialized = JSON.stringify(state);
651
+ try {
652
+ window.localStorage.setItem(storageKey, serialized);
653
+ } catch {
654
+ // window.name fallback below still preserves state across refreshes in the current tab.
655
+ }
656
+ try {
657
+ window.name = `${windowNameKey}${serialized}`;
658
+ } catch {
659
+ // Ignore window.name failures.
660
+ }
661
+ }
662
+
663
+ function setStatus(message) {
664
+ statusEl.textContent = message;
665
+ }
666
+
667
+ function setWorkspaceStatus(message) {
668
+ workspaceDirectoryState.statusMessage = message;
669
+ if (workspaceStatusEl) {
670
+ workspaceStatusEl.textContent = message;
671
+ }
672
+ renderWorkspacePathLabel();
673
+ }
674
+
675
+ function showWorkspaceSelectionError(message) {
676
+ workspaceDirectoryState.handle = null;
677
+ workspaceDirectoryState.byNetwork = {};
678
+ workspaceDirectoryState.loaded = false;
679
+ setWorkspaceStatus(message);
680
+ warningEl.textContent = message;
681
+ }
682
+
683
+ function shellEscape(value) {
684
+ return `'${String(value).replace(/'/g, `'\"'\"'`)}'`;
685
+ }
686
+
687
+ function currentWorkspacePathLabel() {
688
+ if (workspaceDirectoryState.loaded && workspaceDirectoryState.handle) {
689
+ return `${defaultWorkspaceRootLabel} (selected directory: ${workspaceDirectoryState.handle.name})`;
690
+ }
691
+ return defaultWorkspaceRootLabel;
692
+ }
693
+
694
+ function renderWorkspacePathLabel() {
695
+ workspacePathLabelEl.textContent = currentWorkspacePathLabel();
696
+ }
697
+
698
+ function appendOption(parts, optionName, value) {
699
+ parts.push(optionName, shellEscape(value));
700
+ }
701
+
702
+ function commandNeedsWallet(command = currentCommand()) {
703
+ return command.fields.includes("wallet");
704
+ }
705
+
706
+ function commandNeedsWalletNotes(command = currentCommand()) {
707
+ return command.id === "transfer-notes" || command.id === "redeem-notes";
708
+ }
709
+
710
+ function commandNeedsExistingChannel(command = currentCommand()) {
711
+ return command.fields.includes("channelName")
712
+ && command.id !== "create-channel"
713
+ && command.id !== "recover-workspace";
714
+ }
715
+
716
+ function currentWorkspaceOptions() {
717
+ return workspaceDirectoryState.byNetwork[state.network] ?? {
718
+ channelOptions: [],
719
+ walletOptions: [],
720
+ walletEntriesByName: {},
721
+ };
722
+ }
723
+
724
+ function currentWalletEntry() {
725
+ return currentWorkspaceOptions().walletEntriesByName?.[state.wallet] ?? null;
726
+ }
727
+
728
+ function walletOptionsForCommand(command = currentCommand()) {
729
+ const options = currentWorkspaceOptions();
730
+ if (!commandNeedsWallet(command)) {
731
+ return options.walletOptions;
732
+ }
733
+ const derivedAddress = privateKeyAddressState.derivedAddress.trim().toLowerCase();
734
+ if (!derivedAddress) {
735
+ return [];
736
+ }
737
+ return options.walletOptions.filter((walletName) => {
738
+ const walletEntry = options.walletEntriesByName?.[walletName];
739
+ return walletEntry?.l1Address
740
+ && derivedAddress
741
+ && ethers.toBigInt(walletEntry.l1Address) === ethers.toBigInt(derivedAddress);
742
+ });
743
+ }
744
+
745
+ function buildWorkspaceStatusMessage() {
746
+ if (!workspaceDirectoryState.loaded) {
747
+ return `Default CLI workspace root: ${defaultWorkspaceRootLabel}`;
748
+ }
749
+
750
+ const loadedNetworkCount = Object.keys(workspaceDirectoryState.byNetwork).length;
751
+ const currentOptions = currentWorkspaceOptions();
752
+ if (currentOptions.channelOptions.length === 0) {
753
+ return `Loaded ${loadedNetworkCount} network folder${loadedNetworkCount === 1 ? "" : "s"}, but none contains channel folders for ${state.network}.`;
754
+ }
755
+ return `Loaded ${loadedNetworkCount} network folder${loadedNetworkCount === 1 ? "" : "s"}, ${currentOptions.channelOptions.length} channel folder${currentOptions.channelOptions.length === 1 ? "" : "s"}, and ${currentOptions.walletOptions.length} wallet folder${currentOptions.walletOptions.length === 1 ? "" : "s"} for ${state.network}.`;
756
+ }
757
+
758
+ function syncWorkspaceSelectionsForCurrentNetwork() {
759
+ const options = currentWorkspaceOptions();
760
+ if (commandNeedsExistingChannel() && !options.channelOptions.includes(state.channelName)) {
761
+ state.channelName = options.channelOptions[0] ?? "";
762
+ }
763
+ const walletOptions = walletOptionsForCommand();
764
+ if (commandNeedsWallet() && !walletOptions.includes(state.wallet)) {
765
+ state.wallet = walletOptions[0] ?? "";
766
+ }
767
+ }
768
+
769
+ function clearWalletNoteState(message = "") {
770
+ walletNoteState.cacheKey = "";
771
+ walletNoteState.options = [];
772
+ walletNoteState.canonicalAssetDecimals = 18;
773
+ walletNoteState.walletL2Address = "";
774
+ walletNoteState.statusMessage = message;
775
+ walletNoteState.hasLoaded = false;
776
+ if (commandNeedsWalletNotes()) {
777
+ state.noteIds = "";
778
+ }
779
+ }
780
+
781
+ function parseSelectedNoteIds() {
782
+ if (!state.noteIds) {
783
+ return [];
784
+ }
785
+ try {
786
+ const parsed = JSON.parse(state.noteIds);
787
+ return Array.isArray(parsed)
788
+ ? parsed.filter((entry) => typeof entry === "string" && entry.length > 0)
789
+ : [];
790
+ } catch {
791
+ return [];
792
+ }
793
+ }
794
+
795
+ function currentMintAmounts() {
796
+ return [state.mintAmount1, state.mintAmount2]
797
+ .map((value) => String(value ?? "").trim())
798
+ .filter((value) => value.length > 0);
799
+ }
800
+
801
+ function noteSelectionLimit() {
802
+ return currentCommand().id === "redeem-notes" ? 1 : 2;
803
+ }
804
+
805
+ function mintAmountsTotalLabel() {
806
+ const amounts = currentMintAmounts();
807
+ if (amounts.length === 0) {
808
+ return "Total amount: 0";
809
+ }
810
+ if (!amounts.every((value) => /^\d+$/.test(value))) {
811
+ return "Total amount unavailable. Enter whole-number amounts only.";
812
+ }
813
+ const ethersLib = (window.ethers?.ethers ?? window.ethers);
814
+ const total = amounts.reduce((sum, value) => sum + ethersLib.toBigInt(value), 0n);
815
+ return `Total amount: ${total.toString()}`;
816
+ }
817
+
818
+ function syncSelectedNoteIdsToAvailableOptions() {
819
+ const available = new Set(walletNoteState.options.map((option) => option.value));
820
+ const selected = parseSelectedNoteIds().filter((noteId) => available.has(noteId));
821
+ const normalized = selected.slice(0, noteSelectionLimit());
822
+ state.noteIds = normalized.length === 0 ? "" : JSON.stringify(normalized);
823
+ }
824
+
825
+ function selectedNoteTotalLabel() {
826
+ const selectedIds = parseSelectedNoteIds();
827
+ if (selectedIds.length === 0) {
828
+ return "Selected total: 0";
829
+ }
830
+ const optionMap = new Map(walletNoteState.options.map((option) => [option.value, option]));
831
+ const ethersLib = (window.ethers?.ethers ?? window.ethers);
832
+ const totalBaseUnits = selectedIds.reduce((sum, noteId) => {
833
+ const option = optionMap.get(noteId);
834
+ return sum + ethersLib.toBigInt(option?.amountBaseUnits ?? 0n);
835
+ }, 0n);
836
+ const totalTokens = ethersLib.formatUnits(
837
+ totalBaseUnits,
838
+ walletNoteState.canonicalAssetDecimals,
839
+ );
840
+ return `Selected total: ${totalTokens} (${totalBaseUnits.toString()} base units)`;
841
+ }
842
+
843
+ function selectedTransferInputNoteCount() {
844
+ return parseSelectedNoteIds().length;
845
+ }
846
+
847
+ function transferPairLimit() {
848
+ return selectedTransferInputNoteCount() === 1 ? 2 : 1;
849
+ }
850
+
851
+ function transferPairRows() {
852
+ return [
853
+ {
854
+ recipient: String(state.transferRecipient1 ?? "").trim(),
855
+ amount: String(state.transferAmount1 ?? "").trim(),
856
+ },
857
+ {
858
+ recipient: String(state.transferRecipient2 ?? "").trim(),
859
+ amount: String(state.transferAmount2 ?? "").trim(),
860
+ },
861
+ ];
862
+ }
863
+
864
+ function currentTransferOutputs() {
865
+ const limit = transferPairLimit();
866
+ const rows = transferPairRows().slice(0, limit);
867
+ const recipients = [];
868
+ const amounts = [];
869
+ let hasPartialPair = false;
870
+
871
+ for (const row of rows) {
872
+ if (!row.recipient && !row.amount) {
873
+ continue;
874
+ }
875
+ if (!row.recipient || !row.amount) {
876
+ hasPartialPair = true;
877
+ continue;
878
+ }
879
+ recipients.push(row.recipient);
880
+ amounts.push(row.amount);
881
+ }
882
+
883
+ return {
884
+ limit,
885
+ recipients,
886
+ amounts,
887
+ hasPartialPair,
888
+ };
889
+ }
890
+
891
+ function transferPairHintLabel() {
892
+ const inputNoteCount = selectedTransferInputNoteCount();
893
+ if (inputNoteCount === 0) {
894
+ return "Select 1 input note to unlock up to 2 recipient/amount pairs. Select 2 input notes to use 1 pair.";
895
+ }
896
+ if (inputNoteCount === 1) {
897
+ return "With 1 selected input note, you may enter up to 2 recipient/amount pairs.";
898
+ }
899
+ return "With 2 selected input notes, exactly 1 recipient/amount pair can be used.";
900
+ }
901
+
902
+ function rerenderCommandField(fieldKey) {
903
+ const existing = commandFieldsEl.querySelector(`[data-field-key="${fieldKey}"]`);
904
+ if (!existing) {
905
+ return;
906
+ }
907
+ const replacement = createField(fieldKey);
908
+ replacement.dataset.fieldKey = fieldKey;
909
+ existing.replaceWith(replacement);
910
+ }
911
+
912
+ function scheduleWalletNoteRefresh() {
913
+ if (walletNoteRefreshTimer) {
914
+ window.clearTimeout(walletNoteRefreshTimer);
915
+ }
916
+ walletNoteRefreshTimer = window.setTimeout(async () => {
917
+ walletNoteRefreshTimer = null;
918
+ await refreshWalletNoteOptions();
919
+ if (commandNeedsWalletNotes()) {
920
+ rerenderCommandField("noteIds");
921
+ if (currentCommand().id === "transfer-notes") {
922
+ rerenderCommandField("recipients");
923
+ }
924
+ }
925
+ renderPreview();
926
+ }, 250);
927
+ }
928
+
929
+ function addHexPrefix(value) {
930
+ const normalized = String(value ?? "");
931
+ return normalized.startsWith("0x") || normalized.startsWith("0X") ? normalized : `0x${normalized}`;
932
+ }
933
+
934
+ function hexToBytes(value) {
935
+ const normalizedWithPrefix = addHexPrefix(value);
936
+ const normalized = normalizedWithPrefix.slice(2);
937
+ if (normalized.length === 0) {
938
+ return new Uint8Array();
939
+ }
940
+ return Uint8Array.from(
941
+ normalized.match(/.{1,2}/g).map((pair) => Number.parseInt(pair, 16)),
942
+ );
943
+ }
944
+
945
+ async function deriveScryptKeyBytes(password, saltHex) {
946
+ const browserEthers = window.ethers?.ethers ?? window.ethers;
947
+ if (browserEthers?.scryptSync) {
948
+ return browserEthers.getBytes(
949
+ browserEthers.scryptSync(textEncoder.encode(String(password)), saltHex, 16384, 8, 1, 32),
950
+ );
951
+ }
952
+ if (!scryptModulePromise) {
953
+ scryptModulePromise = import("../../../node_modules/@noble/hashes/esm/scrypt.js");
954
+ }
955
+ const { scrypt } = await scryptModulePromise;
956
+ return scrypt(textEncoder.encode(String(password)), hexToBytes(saltHex), {
957
+ N: 16384,
958
+ r: 8,
959
+ p: 1,
960
+ dkLen: 32,
961
+ });
962
+ }
963
+
964
+ async function deriveAddressFromPrivateKey(privateKey) {
965
+ const browserWalletClass = window.ethers?.Wallet ?? window.ethers?.ethers?.Wallet;
966
+ if (browserWalletClass) {
967
+ return new browserWalletClass(String(privateKey)).address;
968
+ }
969
+ if (!ethersModulePromise) {
970
+ ethersModulePromise = import("../../../node_modules/ethers/lib.esm/index.js");
971
+ }
972
+ const { Wallet } = await ethersModulePromise;
973
+ return new Wallet(String(privateKey)).address;
974
+ }
975
+
976
+ async function refreshPrivateKeyAddressState(privateKeyValue) {
977
+ const normalizedValue = String(privateKeyValue ?? "").trim();
978
+ privateKeyAddressState.inputValue = normalizedValue;
979
+
980
+ if (normalizedValue.length === 0) {
981
+ privateKeyAddressState.derivedAddress = "";
982
+ privateKeyAddressState.statusMessage = "";
983
+ return;
984
+ }
985
+
986
+ const currentGeneration = ++privateKeyLookupGeneration;
987
+ try {
988
+ const derivedAddress = await deriveAddressFromPrivateKey(normalizedValue);
989
+ if (currentGeneration !== privateKeyLookupGeneration) {
990
+ return;
991
+ }
992
+ privateKeyAddressState.derivedAddress = derivedAddress;
993
+ privateKeyAddressState.statusMessage = `L1 address: ${derivedAddress}`;
994
+ } catch {
995
+ if (currentGeneration !== privateKeyLookupGeneration) {
996
+ return;
997
+ }
998
+ privateKeyAddressState.derivedAddress = "";
999
+ privateKeyAddressState.statusMessage = "Invalid private key.";
1000
+ }
1001
+ }
1002
+
1003
+ async function decryptWalletJson(envelope, walletPassword) {
1004
+ if (
1005
+ envelope?.version !== walletEncryptionVersion
1006
+ || envelope?.algorithm !== walletEncryptionAlgorithm
1007
+ || envelope?.kdf !== "scrypt"
1008
+ ) {
1009
+ throw new Error("Unsupported wallet encryption envelope.");
1010
+ }
1011
+
1012
+ const encryptionKeyBytes = await deriveScryptKeyBytes(walletPassword, envelope.salt);
1013
+ const cryptoKey = await crypto.subtle.importKey("raw", encryptionKeyBytes, "AES-GCM", false, ["decrypt"]);
1014
+ const ciphertext = hexToBytes(envelope.ciphertext);
1015
+ const tag = hexToBytes(envelope.tag);
1016
+ const combinedCiphertext = new Uint8Array(ciphertext.length + tag.length);
1017
+ combinedCiphertext.set(ciphertext, 0);
1018
+ combinedCiphertext.set(tag, ciphertext.length);
1019
+ const plaintextBuffer = await crypto.subtle.decrypt(
1020
+ {
1021
+ name: "AES-GCM",
1022
+ iv: hexToBytes(envelope.iv),
1023
+ tagLength: 128,
1024
+ },
1025
+ cryptoKey,
1026
+ combinedCiphertext,
1027
+ );
1028
+ return JSON.parse(textDecoder.decode(new Uint8Array(plaintextBuffer)));
1029
+ }
1030
+
1031
+ function extractUnusedNoteOptions(walletJson) {
1032
+ const notes = walletJson?.notes ?? {};
1033
+ const unusedNotes = notes.unused ?? {};
1034
+ const unusedOrder = Array.isArray(notes.unusedOrder) ? notes.unusedOrder : Object.keys(unusedNotes);
1035
+ return unusedOrder
1036
+ .map((commitment) => unusedNotes[commitment])
1037
+ .filter((note) => note && typeof note.commitment === "string")
1038
+ .map((note) => ({
1039
+ value: note.commitment,
1040
+ amountBaseUnits: String(note.value ?? "0"),
1041
+ label: typeof note.value === "string"
1042
+ ? `${note.commitment} (${note.value})`
1043
+ : note.commitment,
1044
+ }));
1045
+ }
1046
+
1047
+ async function refreshWalletNoteOptions() {
1048
+ if (!commandNeedsWalletNotes()) {
1049
+ return;
1050
+ }
1051
+ if (!workspaceDirectoryState.loaded) {
1052
+ clearWalletNoteState("Select a workspace directory to load wallet note IDs.");
1053
+ return;
1054
+ }
1055
+ if (!state.wallet) {
1056
+ clearWalletNoteState("Choose a wallet to load note IDs.");
1057
+ return;
1058
+ }
1059
+ if (!state.password) {
1060
+ clearWalletNoteState("Enter the wallet password to load note IDs.");
1061
+ return;
1062
+ }
1063
+
1064
+ const walletEntry = currentWalletEntry();
1065
+ if (!walletEntry) {
1066
+ clearWalletNoteState("Selected wallet is unavailable for the current network.");
1067
+ return;
1068
+ }
1069
+
1070
+ const cacheKey = `${state.network}::${state.wallet}::${state.password}`;
1071
+ if (walletNoteState.cacheKey === cacheKey && walletNoteState.hasLoaded) {
1072
+ return;
1073
+ }
1074
+
1075
+ walletNoteState.cacheKey = cacheKey;
1076
+ walletNoteState.statusMessage = "Loading wallet note IDs...";
1077
+ walletNoteState.hasLoaded = false;
1078
+
1079
+ try {
1080
+ const walletFileHandle = await walletEntry.handle.getFileHandle("wallet.json");
1081
+ const walletEnvelope = JSON.parse(await (await walletFileHandle.getFile()).text());
1082
+ const walletJson = await decryptWalletJson(walletEnvelope, state.password);
1083
+ walletNoteState.options = extractUnusedNoteOptions(walletJson);
1084
+ walletNoteState.canonicalAssetDecimals = Number(walletJson?.canonicalAssetDecimals ?? 18);
1085
+ walletNoteState.walletL2Address = typeof walletJson?.l2Address === "string" ? walletJson.l2Address : "";
1086
+ walletNoteState.statusMessage = walletNoteState.options.length === 0
1087
+ ? "This wallet has no unused note IDs."
1088
+ : `Loaded ${walletNoteState.options.length} unused note ID${walletNoteState.options.length === 1 ? "" : "s"}.`;
1089
+ walletNoteState.hasLoaded = true;
1090
+ syncSelectedNoteIdsToAvailableOptions();
1091
+ } catch {
1092
+ clearWalletNoteState("Unable to decrypt the selected wallet. Check the wallet and password.");
1093
+ }
1094
+ }
1095
+
1096
+ function needsAlchemy(command) {
1097
+ return command.fields.includes("alchemyApiKey") && state.network !== "anvil";
1098
+ }
1099
+
1100
+ function currentCommand() {
1101
+ return commands.find((command) => command.id === state.commandId) ?? commands[0];
1102
+ }
1103
+
1104
+ function missingFields(command) {
1105
+ const transferOutputs = command.id === "transfer-notes"
1106
+ ? currentTransferOutputs()
1107
+ : null;
1108
+ return command.fields.filter((fieldKey) => {
1109
+ if (fieldKey === "alchemyApiKey" && !needsAlchemy(command)) {
1110
+ return false;
1111
+ }
1112
+ if (fieldKey === "amounts" && command.id === "mint-notes") {
1113
+ return currentMintAmounts().length === 0;
1114
+ }
1115
+ if ((fieldKey === "recipients" || fieldKey === "amounts") && command.id === "transfer-notes") {
1116
+ if (fieldKey === "amounts") {
1117
+ return false;
1118
+ }
1119
+ return transferOutputs.recipients.length === 0 || transferOutputs.hasPartialPair;
1120
+ }
1121
+ return !String(state[fieldKey] ?? "").trim();
1122
+ });
1123
+ }
1124
+
1125
+ function buildCommand({ maskSecrets }) {
1126
+ const command = currentCommand();
1127
+ const parts = [cliEntry, command.id];
1128
+ const transferOutputs = command.id === "transfer-notes"
1129
+ ? currentTransferOutputs()
1130
+ : null;
1131
+
1132
+ for (const fieldKey of command.fields) {
1133
+ if (fieldKey === "alchemyApiKey" && !needsAlchemy(command)) {
1134
+ continue;
1135
+ }
1136
+
1137
+ const value = fieldKey === "amounts" && command.id === "mint-notes"
1138
+ ? JSON.stringify(currentMintAmounts())
1139
+ : fieldKey === "amounts" && command.id === "transfer-notes"
1140
+ ? (!transferOutputs.hasPartialPair && transferOutputs.amounts.length > 0
1141
+ ? JSON.stringify(transferOutputs.amounts)
1142
+ : "")
1143
+ : fieldKey === "recipients" && command.id === "transfer-notes"
1144
+ ? (!transferOutputs.hasPartialPair && transferOutputs.recipients.length > 0
1145
+ ? JSON.stringify(transferOutputs.recipients)
1146
+ : "")
1147
+ : state[fieldKey];
1148
+ if (!value) {
1149
+ continue;
1150
+ }
1151
+ const renderedValue = maskSecrets && Object.hasOwn(maskedSecretValues, fieldKey)
1152
+ ? maskedSecretValues[fieldKey]
1153
+ : value;
1154
+
1155
+ switch (fieldKey) {
1156
+ case "channelName":
1157
+ appendOption(parts, "--channel-name", renderedValue);
1158
+ break;
1159
+ case "network":
1160
+ appendOption(parts, "--network", renderedValue);
1161
+ break;
1162
+ case "alchemyApiKey":
1163
+ appendOption(parts, "--alchemy-api-key", renderedValue);
1164
+ break;
1165
+ case "privateKey":
1166
+ appendOption(parts, "--private-key", renderedValue);
1167
+ break;
1168
+ case "password":
1169
+ appendOption(parts, "--password", renderedValue);
1170
+ break;
1171
+ case "wallet":
1172
+ appendOption(parts, "--wallet", renderedValue);
1173
+ break;
1174
+ case "amount":
1175
+ appendOption(parts, "--amount", renderedValue);
1176
+ break;
1177
+ case "amounts":
1178
+ appendOption(parts, "--amounts", renderedValue);
1179
+ break;
1180
+ case "noteIds":
1181
+ appendOption(parts, "--note-ids", renderedValue);
1182
+ break;
1183
+ case "recipients":
1184
+ appendOption(parts, "--recipients", renderedValue);
1185
+ break;
1186
+ case "force":
1187
+ parts.push("--force");
1188
+ break;
1189
+ case "docker":
1190
+ parts.push("--docker");
1191
+ break;
1192
+ default:
1193
+ break;
1194
+ }
1195
+ }
1196
+
1197
+ return parts.join(" ");
1198
+ }
1199
+
1200
+ async function loadWorkspaceDirectoryOptions(handle) {
1201
+ const byNetwork = {};
1202
+
1203
+ for await (const [networkFolderName, networkHandle] of handle.entries()) {
1204
+ if (networkHandle.kind !== "directory") {
1205
+ continue;
1206
+ }
1207
+
1208
+ const channelEntries = [];
1209
+ for await (const [channelFolderName, channelHandle] of networkHandle.entries()) {
1210
+ if (channelHandle.kind !== "directory") {
1211
+ continue;
1212
+ }
1213
+
1214
+ const channelDirHandle = await channelHandle.getDirectoryHandle("channel").catch(() => null);
1215
+ if (!channelDirHandle) {
1216
+ continue;
1217
+ }
1218
+
1219
+ const workspaceFileHandle = await channelDirHandle.getFileHandle("workspace.json").catch(() => null);
1220
+ if (!workspaceFileHandle) {
1221
+ continue;
1222
+ }
1223
+
1224
+ let workspaceJson;
1225
+ try {
1226
+ workspaceJson = JSON.parse(await (await workspaceFileHandle.getFile()).text());
1227
+ } catch {
1228
+ continue;
1229
+ }
1230
+
1231
+ const resolvedNetworkName = typeof workspaceJson.network === "string" && workspaceJson.network.length > 0
1232
+ ? workspaceJson.network
1233
+ : networkFolderName;
1234
+ const channelName = typeof workspaceJson.channelName === "string" && workspaceJson.channelName.length > 0
1235
+ ? workspaceJson.channelName
1236
+ : channelFolderName;
1237
+
1238
+ const walletsHandle = await channelHandle.getDirectoryHandle("wallets").catch(() => null);
1239
+ const walletEntries = [];
1240
+ if (walletsHandle) {
1241
+ for await (const [walletFolderName, walletHandle] of walletsHandle.entries()) {
1242
+ if (walletHandle.kind !== "directory") {
1243
+ continue;
1244
+ }
1245
+ const addressMatch = /-(0x[a-fA-F0-9]{40})$/.exec(walletFolderName);
1246
+ walletEntries.push({
1247
+ name: addressMatch ? `${channelName}-${addressMatch[1]}` : walletFolderName,
1248
+ handle: walletHandle,
1249
+ l1Address: addressMatch ? addressMatch[1] : "",
1250
+ });
1251
+ }
1252
+ }
1253
+
1254
+ if (!byNetwork[resolvedNetworkName]) {
1255
+ byNetwork[resolvedNetworkName] = [];
1256
+ }
1257
+ byNetwork[resolvedNetworkName].push({
1258
+ channelName,
1259
+ walletEntries,
1260
+ });
1261
+ }
1262
+ }
1263
+
1264
+ return Object.fromEntries(
1265
+ Object.entries(byNetwork).map(([networkName, channelEntries]) => {
1266
+ channelEntries.sort((left, right) => left.channelName.localeCompare(right.channelName));
1267
+ const walletEntriesByName = {};
1268
+ for (const entry of channelEntries) {
1269
+ for (const walletEntry of entry.walletEntries) {
1270
+ walletEntriesByName[walletEntry.name] = walletEntry;
1271
+ }
1272
+ }
1273
+ return [networkName, {
1274
+ channelOptions: channelEntries.map((entry) => entry.channelName),
1275
+ walletOptions: [...new Set(channelEntries.flatMap((entry) => entry.walletEntries.map((walletEntry) => walletEntry.name)))]
1276
+ .sort((left, right) => left.localeCompare(right)),
1277
+ walletEntriesByName,
1278
+ }];
1279
+ }),
1280
+ );
1281
+ }
1282
+
1283
+ function applyWorkspaceDirectoryOptions(handle, byNetwork) {
1284
+ workspaceDirectoryState.handle = handle;
1285
+ workspaceDirectoryState.byNetwork = byNetwork;
1286
+ workspaceDirectoryState.loaded = true;
1287
+ const currentOptions = currentWorkspaceOptions();
1288
+ if (
1289
+ !currentOptions.channelOptions.includes(state.channelName)
1290
+ && currentCommand().id !== "create-channel"
1291
+ && currentCommand().id !== "recover-workspace"
1292
+ ) {
1293
+ state.channelName = currentOptions.channelOptions[0] ?? "";
1294
+ }
1295
+ const filteredWalletOptions = walletOptionsForCommand();
1296
+ if (!filteredWalletOptions.includes(state.wallet)) {
1297
+ state.wallet = filteredWalletOptions[0] ?? "";
1298
+ }
1299
+ clearWalletNoteState();
1300
+ renderWorkspacePathLabel();
1301
+ }
1302
+
1303
+ async function rescanSelectedWorkspaceDirectory() {
1304
+ if (!workspaceDirectoryState.handle) {
1305
+ return false;
1306
+ }
1307
+
1308
+ try {
1309
+ setWorkspaceStatus("Rescanning workspace directory...");
1310
+ const byNetwork = await loadWorkspaceDirectoryOptions(workspaceDirectoryState.handle);
1311
+ applyWorkspaceDirectoryOptions(workspaceDirectoryState.handle, byNetwork);
1312
+ setWorkspaceStatus(buildWorkspaceStatusMessage());
1313
+ return true;
1314
+ } catch {
1315
+ showWorkspaceSelectionError("Failed to read the selected workspace directory.");
1316
+ return false;
1317
+ }
1318
+ }
1319
+
1320
+ async function chooseWorkspaceDirectory() {
1321
+ if (typeof window.showDirectoryPicker !== "function") {
1322
+ showWorkspaceSelectionError("This browser does not support directory selection.");
1323
+ return;
1324
+ }
1325
+
1326
+ try {
1327
+ const handle = await window.showDirectoryPicker();
1328
+ if (handle.name !== "workspace") {
1329
+ showWorkspaceSelectionError('Selected directory must be named "workspace".');
1330
+ return;
1331
+ }
1332
+ const byNetwork = await loadWorkspaceDirectoryOptions(handle);
1333
+ applyWorkspaceDirectoryOptions(handle, byNetwork);
1334
+ persistState();
1335
+ renderWorkspacePicker();
1336
+ await refreshWalletNoteOptions();
1337
+ renderCommandFields();
1338
+ renderPreview();
1339
+ setWorkspaceStatus(buildWorkspaceStatusMessage());
1340
+ } catch (error) {
1341
+ if (error?.name === "AbortError") {
1342
+ return;
1343
+ }
1344
+ showWorkspaceSelectionError("Failed to read the selected workspace directory.");
1345
+ }
1346
+ }
1347
+
1348
+ function renderWorkspacePicker() {
1349
+ workspacePickerEl.innerHTML = "";
1350
+
1351
+ const button = document.createElement("button");
1352
+ button.type = "button";
1353
+ button.textContent = "Select workspace directory";
1354
+ button.addEventListener("click", async () => {
1355
+ await chooseWorkspaceDirectory();
1356
+ });
1357
+ workspacePickerEl.appendChild(button);
1358
+
1359
+ workspaceStatusEl = document.createElement("p");
1360
+ workspaceStatusEl.className = "hint";
1361
+ workspaceStatusEl.textContent = workspaceDirectoryState.statusMessage || buildWorkspaceStatusMessage();
1362
+ workspacePickerEl.appendChild(workspaceStatusEl);
1363
+ }
1364
+
1365
+ function createWalletField() {
1366
+ const wrapper = document.createElement("div");
1367
+ wrapper.className = "field-stack";
1368
+ const label = document.createElement("label");
1369
+ label.textContent = fieldCatalog.wallet.label;
1370
+ const select = document.createElement("select");
1371
+ const walletOptions = walletOptionsForCommand();
1372
+ const placeholder = document.createElement("option");
1373
+ placeholder.value = "";
1374
+ placeholder.textContent = !workspaceDirectoryState.loaded
1375
+ ? "Select a workspace directory for this network first"
1376
+ : commandNeedsWallet(currentCommand()) && !privateKeyAddressState.derivedAddress
1377
+ ? "Enter a valid L1 private key first"
1378
+ : walletOptions.length === 0
1379
+ ? "No wallet matches the current L1 private key"
1380
+ : "Choose wallet";
1381
+ select.appendChild(placeholder);
1382
+ for (const option of walletOptions) {
1383
+ const optionEl = document.createElement("option");
1384
+ optionEl.value = option;
1385
+ optionEl.textContent = option;
1386
+ select.appendChild(optionEl);
1387
+ }
1388
+ select.value = walletOptions.includes(state.wallet) ? state.wallet : "";
1389
+ select.disabled = walletOptions.length === 0;
1390
+ select.addEventListener("change", async (event) => {
1391
+ state.wallet = event.target.value;
1392
+ clearWalletNoteState();
1393
+ persistState();
1394
+ await refreshWalletNoteOptions();
1395
+ renderCommandFields();
1396
+ renderPreview();
1397
+ });
1398
+ label.appendChild(select);
1399
+ wrapper.appendChild(label);
1400
+
1401
+ return wrapper;
1402
+ }
1403
+
1404
+ function createExistingChannelField() {
1405
+ const label = document.createElement("label");
1406
+ label.textContent = fieldCatalog.channelName.label;
1407
+ const select = document.createElement("select");
1408
+ const options = currentWorkspaceOptions();
1409
+ const placeholder = document.createElement("option");
1410
+ placeholder.value = "";
1411
+ placeholder.textContent = options.channelOptions.length === 0
1412
+ ? "Select a workspace directory for this network first"
1413
+ : "Choose channel";
1414
+ select.appendChild(placeholder);
1415
+ for (const option of options.channelOptions) {
1416
+ const optionEl = document.createElement("option");
1417
+ optionEl.value = option;
1418
+ optionEl.textContent = option;
1419
+ select.appendChild(optionEl);
1420
+ }
1421
+ select.value = options.channelOptions.includes(state.channelName) ? state.channelName : "";
1422
+ select.disabled = options.channelOptions.length === 0;
1423
+ select.addEventListener("change", (event) => {
1424
+ state.channelName = event.target.value;
1425
+ persistState();
1426
+ renderPreview();
1427
+ });
1428
+ label.appendChild(select);
1429
+ return label;
1430
+ }
1431
+
1432
+ function createNoteIdsField() {
1433
+ const wrapper = document.createElement("div");
1434
+ wrapper.className = "field-stack field-span-full";
1435
+ const label = document.createElement("label");
1436
+ label.textContent = fieldCatalog.noteIds.label;
1437
+ wrapper.appendChild(label);
1438
+
1439
+ const selectedIds = new Set(parseSelectedNoteIds());
1440
+ const optionList = document.createElement("div");
1441
+ optionList.className = "note-option-list";
1442
+
1443
+ if (walletNoteState.options.length === 0) {
1444
+ optionList.classList.add("is-disabled");
1445
+ optionList.textContent = "No selectable note IDs are currently available.";
1446
+ } else {
1447
+ for (const option of walletNoteState.options) {
1448
+ const optionLabel = document.createElement("label");
1449
+ optionLabel.className = "note-option-item";
1450
+
1451
+ const checkbox = document.createElement("input");
1452
+ checkbox.type = "checkbox";
1453
+ checkbox.value = option.value;
1454
+ checkbox.checked = selectedIds.has(option.value);
1455
+
1456
+ const copy = document.createElement("span");
1457
+ copy.className = "note-option-copy";
1458
+ copy.textContent = option.label;
1459
+
1460
+ checkbox.addEventListener("change", () => {
1461
+ const currentSelection = parseSelectedNoteIds();
1462
+ let nextSelection = currentSelection.filter((noteId) => noteId !== option.value);
1463
+ if (checkbox.checked) {
1464
+ nextSelection = [...nextSelection, option.value].slice(0, noteSelectionLimit());
1465
+ }
1466
+ state.noteIds = nextSelection.length === 0 ? "" : JSON.stringify(nextSelection);
1467
+ persistState();
1468
+ rerenderCommandField("noteIds");
1469
+ if (currentCommand().id === "transfer-notes") {
1470
+ rerenderCommandField("recipients");
1471
+ }
1472
+ renderPreview();
1473
+ });
1474
+
1475
+ optionLabel.appendChild(checkbox);
1476
+ optionLabel.appendChild(copy);
1477
+ optionList.appendChild(optionLabel);
1478
+ }
1479
+ }
1480
+
1481
+ wrapper.appendChild(optionList);
1482
+
1483
+ const hint = document.createElement("p");
1484
+ hint.className = "hint";
1485
+ hint.textContent = walletNoteState.statusMessage || `Choose a wallet and enter its password to load up to ${noteSelectionLimit()} note IDs.`;
1486
+ wrapper.appendChild(hint);
1487
+
1488
+ const totalHint = document.createElement("p");
1489
+ totalHint.className = "hint";
1490
+ totalHint.textContent = selectedNoteTotalLabel();
1491
+ wrapper.appendChild(totalHint);
1492
+
1493
+ return wrapper;
1494
+ }
1495
+
1496
+ function createTransferPairsField() {
1497
+ const wrapper = document.createElement("div");
1498
+ wrapper.className = "field-stack field-span-full";
1499
+
1500
+ const title = document.createElement("label");
1501
+ title.textContent = "Transfer Output Pairs";
1502
+ wrapper.appendChild(title);
1503
+
1504
+ const grid = document.createElement("div");
1505
+ grid.className = "transfer-pair-grid";
1506
+
1507
+ const pairLimit = transferPairLimit();
1508
+ [
1509
+ {
1510
+ recipientKey: "transferRecipient1",
1511
+ amountKey: "transferAmount1",
1512
+ recipientLabel: "Recipient 1",
1513
+ amountLabel: "Amount 1",
1514
+ },
1515
+ {
1516
+ recipientKey: "transferRecipient2",
1517
+ amountKey: "transferAmount2",
1518
+ recipientLabel: "Recipient 2",
1519
+ amountLabel: "Amount 2",
1520
+ },
1521
+ ].slice(0, pairLimit).forEach(({ recipientKey, amountKey, recipientLabel, amountLabel }) => {
1522
+ const pairField = document.createElement("div");
1523
+ pairField.className = "transfer-pair";
1524
+
1525
+ const recipientField = document.createElement("div");
1526
+ recipientField.className = "field-stack";
1527
+
1528
+ const recipientFieldLabel = document.createElement("label");
1529
+ recipientFieldLabel.textContent = recipientLabel;
1530
+ const recipientRow = document.createElement("div");
1531
+ recipientRow.className = "recipient-input-row";
1532
+ const recipientInput = document.createElement("input");
1533
+ recipientInput.type = "text";
1534
+ recipientInput.placeholder = "0xRecipientL2Address";
1535
+ recipientInput.value = state[recipientKey] ?? "";
1536
+ recipientInput.addEventListener("input", (event) => {
1537
+ state[recipientKey] = event.target.value;
1538
+ persistState();
1539
+ renderPreview();
1540
+ });
1541
+ recipientRow.appendChild(recipientInput);
1542
+ const useSelfButton = document.createElement("button");
1543
+ useSelfButton.type = "button";
1544
+ useSelfButton.textContent = "Use my L2 address";
1545
+ useSelfButton.disabled = !walletNoteState.walletL2Address;
1546
+ useSelfButton.addEventListener("click", () => {
1547
+ if (!walletNoteState.walletL2Address) {
1548
+ return;
1549
+ }
1550
+ state[recipientKey] = walletNoteState.walletL2Address;
1551
+ persistState();
1552
+ rerenderCommandField("recipients");
1553
+ renderPreview();
1554
+ });
1555
+ recipientRow.appendChild(useSelfButton);
1556
+ recipientFieldLabel.appendChild(recipientRow);
1557
+ recipientField.appendChild(recipientFieldLabel);
1558
+ pairField.appendChild(recipientField);
1559
+
1560
+ const amountField = document.createElement("div");
1561
+ amountField.className = "field-stack";
1562
+ const amountFieldLabel = document.createElement("label");
1563
+ const amountInput = document.createElement("input");
1564
+ amountInput.type = "text";
1565
+ amountInput.placeholder = "3";
1566
+ amountInput.value = state[amountKey] ?? "";
1567
+ amountInput.addEventListener("input", (event) => {
1568
+ state[amountKey] = event.target.value;
1569
+ persistState();
1570
+ renderPreview();
1571
+ });
1572
+ amountFieldLabel.textContent = amountLabel;
1573
+ amountFieldLabel.appendChild(amountInput);
1574
+ amountField.appendChild(amountFieldLabel);
1575
+ pairField.appendChild(amountField);
1576
+
1577
+ grid.appendChild(pairField);
1578
+ });
1579
+
1580
+ wrapper.appendChild(grid);
1581
+
1582
+ const hint = document.createElement("p");
1583
+ hint.className = "hint";
1584
+ hint.textContent = transferPairHintLabel();
1585
+ wrapper.appendChild(hint);
1586
+
1587
+ return wrapper;
1588
+ }
1589
+
1590
+ function createMintAmountsField() {
1591
+ const wrapper = document.createElement("div");
1592
+ wrapper.className = "field-stack";
1593
+
1594
+ const title = document.createElement("label");
1595
+ title.textContent = "Mint Amount Entries";
1596
+ wrapper.appendChild(title);
1597
+
1598
+ const grid = document.createElement("div");
1599
+ grid.className = "fields";
1600
+
1601
+ [
1602
+ { key: "mintAmount1", label: "Amount 1" },
1603
+ { key: "mintAmount2", label: "Amount 2" },
1604
+ ].forEach(({ key, label }) => {
1605
+ const amountLabel = document.createElement("label");
1606
+ amountLabel.textContent = label;
1607
+ const input = document.createElement("input");
1608
+ input.type = "text";
1609
+ input.placeholder = "3";
1610
+ input.value = state[key] ?? "";
1611
+ input.addEventListener("input", (event) => {
1612
+ state[key] = event.target.value;
1613
+ persistState();
1614
+ totalHint.textContent = mintAmountsTotalLabel();
1615
+ renderPreview();
1616
+ });
1617
+ amountLabel.appendChild(input);
1618
+ grid.appendChild(amountLabel);
1619
+ });
1620
+
1621
+ wrapper.appendChild(grid);
1622
+
1623
+ const totalHint = document.createElement("p");
1624
+ totalHint.className = "hint";
1625
+ totalHint.textContent = mintAmountsTotalLabel();
1626
+ wrapper.appendChild(totalHint);
1627
+
1628
+ return wrapper;
1629
+ }
1630
+
1631
+ function createField(fieldKey) {
1632
+ const config = fieldCatalog[fieldKey];
1633
+ if (fieldKey === "wallet") {
1634
+ return createWalletField();
1635
+ }
1636
+ if (fieldKey === "amounts" && currentCommand().id === "mint-notes") {
1637
+ return createMintAmountsField();
1638
+ }
1639
+ if (fieldKey === "recipients" && currentCommand().id === "transfer-notes") {
1640
+ return createTransferPairsField();
1641
+ }
1642
+ if (fieldKey === "noteIds" && commandNeedsWalletNotes()) {
1643
+ return createNoteIdsField();
1644
+ }
1645
+ if (
1646
+ fieldKey === "channelName"
1647
+ && currentCommand().id !== "create-channel"
1648
+ && currentCommand().id !== "recover-workspace"
1649
+ ) {
1650
+ return createExistingChannelField();
1651
+ }
1652
+ const label = document.createElement("label");
1653
+ label.textContent = config.label;
1654
+
1655
+ let input;
1656
+ if (config.type === "select") {
1657
+ input = document.createElement("select");
1658
+ for (const option of config.options) {
1659
+ const optionEl = document.createElement("option");
1660
+ optionEl.value = option;
1661
+ optionEl.textContent = option;
1662
+ input.appendChild(optionEl);
1663
+ }
1664
+ } else if (config.type === "checkbox") {
1665
+ input = document.createElement("input");
1666
+ input.type = "checkbox";
1667
+ } else if (config.type === "textarea") {
1668
+ input = document.createElement("textarea");
1669
+ } else {
1670
+ input = document.createElement("input");
1671
+ input.type = config.type;
1672
+ }
1673
+
1674
+ if (config.type === "checkbox") {
1675
+ input.checked = state[fieldKey] === true;
1676
+ } else {
1677
+ input.value = state[fieldKey] ?? "";
1678
+ input.placeholder = config.placeholder;
1679
+ }
1680
+ input.addEventListener("input", async (event) => {
1681
+ state[fieldKey] = config.type === "checkbox" ? event.target.checked : event.target.value;
1682
+ if (fieldKey === "network") {
1683
+ syncWorkspaceSelectionsForCurrentNetwork();
1684
+ workspaceDirectoryState.statusMessage = buildWorkspaceStatusMessage();
1685
+ clearWalletNoteState();
1686
+ }
1687
+ if (fieldKey === "privateKey") {
1688
+ await refreshPrivateKeyAddressState(event.target.value);
1689
+ if (memoryStatusEl) {
1690
+ memoryStatusEl.textContent = privateKeyAddressState.statusMessage;
1691
+ }
1692
+ syncWorkspaceSelectionsForCurrentNetwork();
1693
+ }
1694
+ if (fieldKey === "password") {
1695
+ clearWalletNoteState();
1696
+ if (commandNeedsWalletNotes()) {
1697
+ walletNoteState.statusMessage = "Loading wallet note IDs...";
1698
+ }
1699
+ }
1700
+ persistState();
1701
+ if (fieldKey === "network") {
1702
+ await refreshWalletNoteOptions();
1703
+ }
1704
+ renderPreview();
1705
+ if (fieldKey === "network") {
1706
+ renderWorkspacePicker();
1707
+ renderCommandFields();
1708
+ } else if (fieldKey === "privateKey" && commandNeedsWallet()) {
1709
+ renderCommandFields();
1710
+ } else if (fieldKey === "password" && commandNeedsWalletNotes()) {
1711
+ rerenderCommandField("noteIds");
1712
+ if (currentCommand().id === "transfer-notes") {
1713
+ rerenderCommandField("recipients");
1714
+ }
1715
+ scheduleWalletNoteRefresh();
1716
+ }
1717
+ });
1718
+
1719
+ if (fieldKey === "password") {
1720
+ input.addEventListener("blur", async () => {
1721
+ if (walletNoteRefreshTimer) {
1722
+ window.clearTimeout(walletNoteRefreshTimer);
1723
+ walletNoteRefreshTimer = null;
1724
+ }
1725
+ await refreshWalletNoteOptions();
1726
+ if (commandNeedsWalletNotes()) {
1727
+ rerenderCommandField("noteIds");
1728
+ if (currentCommand().id === "transfer-notes") {
1729
+ rerenderCommandField("recipients");
1730
+ }
1731
+ }
1732
+ renderPreview();
1733
+ });
1734
+ }
1735
+
1736
+ label.appendChild(input);
1737
+ if (config.type === "checkbox" && config.hint) {
1738
+ const hint = document.createElement("p");
1739
+ hint.className = "hint";
1740
+ hint.textContent = config.hint;
1741
+ label.appendChild(hint);
1742
+ }
1743
+ return label;
1744
+ }
1745
+
1746
+ function renderMemoryFields() {
1747
+ memoryFieldsEl.innerHTML = "";
1748
+ [
1749
+ "network",
1750
+ "alchemyApiKey",
1751
+ "privateKey",
1752
+ ].forEach((fieldKey) => {
1753
+ memoryFieldsEl.appendChild(createField(fieldKey));
1754
+ });
1755
+ memoryStatusEl.textContent = privateKeyAddressState.statusMessage;
1756
+ void refreshPrivateKeyAddressState(state.privateKey).then(() => {
1757
+ memoryStatusEl.textContent = privateKeyAddressState.statusMessage;
1758
+ syncWorkspaceSelectionsForCurrentNetwork();
1759
+ if (currentCommand().fields.includes("wallet")) {
1760
+ renderCommandFields();
1761
+ renderPreview();
1762
+ }
1763
+ });
1764
+ }
1765
+
1766
+ function renderCommandSelect() {
1767
+ commandSelectEl.innerHTML = "";
1768
+ for (const command of commands) {
1769
+ const option = document.createElement("option");
1770
+ option.value = command.id;
1771
+ option.textContent = command.id;
1772
+ commandSelectEl.appendChild(option);
1773
+ }
1774
+ commandSelectEl.value = state.commandId;
1775
+ commandSelectEl.addEventListener("change", async (event) => {
1776
+ state.commandId = event.target.value;
1777
+ await rescanSelectedWorkspaceDirectory();
1778
+ persistState();
1779
+ await render();
1780
+ });
1781
+ }
1782
+
1783
+ function renderCommandFields() {
1784
+ const command = currentCommand();
1785
+ commandDescriptionEl.textContent = command.description;
1786
+ commandFieldsEl.innerHTML = "";
1787
+ const commandOnlyFields = command.fields.filter((fieldKey) => !sharedFieldKeys.includes(fieldKey));
1788
+
1789
+ if (commandOnlyFields.length === 0) {
1790
+ commandFieldsEl.textContent = "This command does not need extra command-specific inputs.";
1791
+ return;
1792
+ }
1793
+
1794
+ for (const fieldKey of commandOnlyFields) {
1795
+ if (fieldKey === "alchemyApiKey" && !needsAlchemy(command)) {
1796
+ continue;
1797
+ }
1798
+ if (fieldKey === "amounts" && command.id === "transfer-notes") {
1799
+ continue;
1800
+ }
1801
+ const fieldElement = createField(fieldKey);
1802
+ if (fieldKey === "noteIds") {
1803
+ fieldElement.classList.add("field-span-full");
1804
+ }
1805
+ fieldElement.dataset.fieldKey = fieldKey;
1806
+ commandFieldsEl.appendChild(fieldElement);
1807
+ }
1808
+ }
1809
+
1810
+ function renderPreview() {
1811
+ const command = currentCommand();
1812
+ const missing = missingFields(command);
1813
+ previewEl.textContent = buildCommand({ maskSecrets: true });
1814
+ const workspaceWarnings = [
1815
+ commandNeedsExistingChannel(command) && workspaceDirectoryState.handle === null
1816
+ ? " Select a workspace directory to populate the channel dropdown."
1817
+ : "",
1818
+ commandNeedsWallet(command) && workspaceDirectoryState.handle === null
1819
+ ? " Select a workspace directory to populate the wallet dropdown."
1820
+ : "",
1821
+ ].join("");
1822
+ warningEl.textContent = missing.length === 0
1823
+ ? state.network === "anvil"
1824
+ ? `Ready. --alchemy-api-key is omitted automatically on anvil.${workspaceWarnings}`
1825
+ : `Ready.${workspaceWarnings}`
1826
+ : `Missing inputs: ${missing.map((fieldKey) => fieldCatalog[fieldKey].label).join(", ")}.${workspaceWarnings}`;
1827
+ }
1828
+
1829
+ async function copyText(value) {
1830
+ if (navigator.clipboard?.writeText) {
1831
+ await navigator.clipboard.writeText(value);
1832
+ return;
1833
+ }
1834
+
1835
+ const textarea = document.createElement("textarea");
1836
+ textarea.value = value;
1837
+ textarea.setAttribute("readonly", "");
1838
+ textarea.style.position = "fixed";
1839
+ textarea.style.opacity = "0";
1840
+ document.body.appendChild(textarea);
1841
+ textarea.select();
1842
+ document.execCommand("copy");
1843
+ textarea.remove();
1844
+ }
1845
+
1846
+ async function render() {
1847
+ syncWorkspaceSelectionsForCurrentNetwork();
1848
+ workspaceDirectoryState.statusMessage = buildWorkspaceStatusMessage();
1849
+ await refreshWalletNoteOptions();
1850
+ renderWorkspacePathLabel();
1851
+ renderWorkspacePicker();
1852
+ renderMemoryFields();
1853
+ renderCommandFields();
1854
+ renderPreview();
1855
+ }
1856
+
1857
+ async function init() {
1858
+ renderCommandSelect();
1859
+ await render();
1860
+ }
1861
+
1862
+ init().catch((error) => {
1863
+ console.error(error);
1864
+ warningEl.textContent = "Assistant initialization failed. Reload the page and try again.";
1865
+ setStatus("Assistant initialization failed.");
1866
+ });
1867
+ </script>
1868
+ </body>
1869
+ </html>