@umbra-privacy/ceremony 0.2.6 → 0.2.9

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.
Files changed (2) hide show
  1. package/dist/index.js +1477 -322
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -4,23 +4,38 @@
4
4
  import { render } from "ink";
5
5
 
6
6
  // src/components/App.tsx
7
- import { useEffect as useEffect5, useState as useState5 } from "react";
8
- import { Box as Box7, Text as Text7, useApp, useInput as useInput2 } from "ink";
7
+ import { useEffect as useEffect6, useState as useState6 } from "react";
8
+ import { Box as Box8, Text as Text8, useApp, useInput as useInput3 } from "ink";
9
9
 
10
10
  // src/cleanup.ts
11
11
  var pendingLeaveQueue = null;
12
+ var pendingClearSession = null;
12
13
  function setQueueCleanup(fn) {
13
14
  pendingLeaveQueue = fn;
14
15
  }
15
16
  function clearQueueCleanup() {
16
17
  pendingLeaveQueue = null;
17
18
  }
19
+ function setSessionCleanup(fn) {
20
+ pendingClearSession = fn;
21
+ }
22
+ function clearSessionCleanup() {
23
+ pendingClearSession = null;
24
+ }
18
25
  async function runQueueCleanup() {
19
26
  if (!pendingLeaveQueue) return;
20
27
  pendingLeaveQueue();
21
28
  pendingLeaveQueue = null;
22
29
  await new Promise((resolve) => setTimeout(resolve, 800));
23
30
  }
31
+ async function runSessionCleanup() {
32
+ if (!pendingClearSession) return;
33
+ try {
34
+ await pendingClearSession();
35
+ } catch {
36
+ }
37
+ pendingClearSession = null;
38
+ }
24
39
 
25
40
  // src/session.ts
26
41
  import { readFile as readFile2, unlink as unlink2, writeFile } from "fs/promises";
@@ -227,7 +242,16 @@ var STORE_FILE = process.env["CEREMONY_CONTRIBUTIONS_FILE"] ?? join2(homedir2(),
227
242
  async function load() {
228
243
  try {
229
244
  const raw = await readFile3(STORE_FILE, "utf8");
230
- return JSON.parse(raw);
245
+ const parsed = JSON.parse(raw);
246
+ const out = {};
247
+ for (const [trackId, value] of Object.entries(parsed)) {
248
+ if (Array.isArray(value)) {
249
+ out[trackId] = value;
250
+ } else if (value && typeof value === "object") {
251
+ out[trackId] = [value];
252
+ }
253
+ }
254
+ return out;
231
255
  } catch {
232
256
  return {};
233
257
  }
@@ -240,9 +264,21 @@ async function getContributions() {
240
264
  }
241
265
  async function recordContribution(trackId, contribution) {
242
266
  const store = await load();
243
- store[trackId] = contribution;
267
+ const existing = store[trackId] ?? [];
268
+ if (existing.some((c) => c.contributionId === contribution.contributionId)) {
269
+ return;
270
+ }
271
+ store[trackId] = [...existing, contribution];
244
272
  await save(store);
245
273
  }
274
+ function latestContribution(store, trackId) {
275
+ const list = store[trackId];
276
+ if (!list || list.length === 0) return null;
277
+ return list.reduce((a, b) => b.sequenceNumber > a.sequenceNumber ? b : a);
278
+ }
279
+ function roundCount(store, trackId) {
280
+ return store[trackId]?.length ?? 0;
281
+ }
246
282
 
247
283
  // src/components/Header.tsx
248
284
  import { Box, Text } from "ink";
@@ -337,7 +373,32 @@ var COMPUTING_MESSAGES = [
337
373
  "applying tau locally",
338
374
  "folding contribution proof",
339
375
  "blinding the trapdoor",
340
- "sealing the bellman response"
376
+ "sealing the bellman response",
377
+ "asking the curve where it lives",
378
+ "weaving G1 points into the new delta",
379
+ "negotiating with the pairing engine",
380
+ "rotating the keypair commitment",
381
+ "checking that tau is non-zero (just in case)",
382
+ "evaluating polynomials over your secret",
383
+ "scrubbing the entropy buffer mid-flight",
384
+ "ratifying the contribution sequence",
385
+ "computing s \xB7 \u03C4 in G1",
386
+ "computing t \xB7 \u03C4 in G2",
387
+ "running the signature-of-knowledge",
388
+ "binding the transcript to your contribution",
389
+ "double-checking nothing is at infinity",
390
+ "compressing the response footprint",
391
+ "verifying your math one more time",
392
+ "stamping the public key bundle",
393
+ "writing the response file to disk",
394
+ "preparing to hand the response back to the worker",
395
+ "almost done \u2014 finalising your tau",
396
+ "this is the cryptography working hard for you",
397
+ "your secret never leaves this machine",
398
+ "if your power-21 circuit feels slow, blame BN254",
399
+ "snarkjs is single-threaded, give it a moment",
400
+ "trusted setup ceremonies are a team sport",
401
+ "the worker will verify everything you did"
341
402
  ];
342
403
  var VERIFYING_MESSAGES = [
343
404
  "worker pulling your response from S3",
@@ -346,20 +407,75 @@ var VERIFYING_MESSAGES = [
346
407
  "writing the next zkey",
347
408
  "uploading the new zkey",
348
409
  "anchoring contribution hash in the audit chain",
349
- "almost there \u2014 finalising your receipt"
410
+ "almost there \u2014 finalising your receipt",
411
+ "parsing your response's wire format",
412
+ "validating every G1 point is on the curve",
413
+ "validating every G2 point is in the right subgroup",
414
+ "rebuilding the transcript hash chain",
415
+ "running the knowledge proof pairing",
416
+ "verifying the new delta_g1 matches its evidence",
417
+ "verifying the new delta_g2 matches its evidence",
418
+ "checking the L array against your contribution",
419
+ "checking the H array against your contribution",
420
+ "deriving Fiat-Shamir scalars for the batched check",
421
+ "running the random-linear-combination same-ratio",
422
+ "confirming the vKey fields were not altered",
423
+ "sequencing the new zkey for the next contributor",
424
+ "this is the part where every byte gets re-checked",
425
+ "soundness of the whole ceremony rides on this step",
426
+ "exporting the new bellman params for the next round",
427
+ "writing your contribution into the ceremony transcript",
428
+ "publishing the new audit chain entry",
429
+ "any tampering would have failed by now",
430
+ "if you see this, your contribution is provably honest",
431
+ "computing your contribution receipt",
432
+ "the math agrees \u2014 wrapping things up",
433
+ "finalising the verified state in Postgres",
434
+ "you did the cryptographic work \u2014 we just had to check"
350
435
  ];
351
436
  var EXPORTING_MESSAGES = [
352
437
  "preparing your challenge file",
353
438
  "extracting bellman params from the current zkey",
354
439
  "wrapping the zkey for handoff",
355
440
  "stamping a sha256 over the challenge bundle",
356
- "presigning your download URL"
441
+ "presigning your download URL",
442
+ "fetching the latest verified state for this circuit",
443
+ "downloading the previous contribution from S3",
444
+ "checking the previous contribution's hash",
445
+ "carving out a personal challenge for your tau",
446
+ "the worker is queuing your slot",
447
+ "uploading the challenge so you can download it",
448
+ "rotating the worker's S3 connection",
449
+ "binding your challenge to the audit chain",
450
+ "stamping the challenge metadata in Postgres",
451
+ "AWS SDK is recycling connection pools",
452
+ "your slot timer starts the moment this finishes",
453
+ "writing the challenge to a fresh S3 key",
454
+ "double-checking the integrity hash",
455
+ "if this takes >1 min, S3 is having a bad time",
456
+ "Fargate is parsing the current bellman params",
457
+ "verifying the previous contribution's chain link",
458
+ "this step has no contributor-side analogue",
459
+ "the worker does the boring work so you don't have to",
460
+ "any second now \u2014 challenge is almost ready",
461
+ "encoding the challenge in Bellman wire format",
462
+ "making sure the file size matches the spec",
463
+ "presign URL is signed for 1 hour",
464
+ "your download will be tamper-evident",
465
+ "the challenge SHA-256 will be on the public transcript",
466
+ "patience \u2014 this is the longest server-side step",
467
+ "almost there \u2014 your turn is moments away"
357
468
  ];
358
469
 
359
470
  // src/components/QueueView.tsx
360
471
  import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
361
472
  var POLL_FAST_MS = 5e3;
362
473
  var POLL_SLOW_MS = 15e3;
474
+ var TERMINAL_STATUSES = /* @__PURE__ */ new Set([
475
+ "timed_out",
476
+ "failed",
477
+ "verified"
478
+ ]);
363
479
  function QueueView({ ceremonyId: ceremonyId2, trackId, token, onReady, onError }) {
364
480
  const [status, setStatus] = useState2(null);
365
481
  const [pollErr, setPollErr] = useState2(null);
@@ -381,6 +497,9 @@ function QueueView({ ceremonyId: ceremonyId2, trackId, token, onReady, onError }
381
497
  onReady(s);
382
498
  return;
383
499
  }
500
+ if (TERMINAL_STATUSES.has(s.status)) {
501
+ return;
502
+ }
384
503
  const interval = s.queue_position <= 2 ? POLL_FAST_MS : POLL_SLOW_MS;
385
504
  timeoutRef.current = setTimeout(poll, interval);
386
505
  } catch (err) {
@@ -406,43 +525,55 @@ function QueueView({ ceremonyId: ceremonyId2, trackId, token, onReady, onError }
406
525
  ] })
407
526
  ] });
408
527
  }
528
+ if (TERMINAL_STATUSES.has(status.status)) {
529
+ return /* @__PURE__ */ jsx2(TerminalSlotMessage, { status: status.status });
530
+ }
409
531
  const waitMins = Math.ceil((status.estimated_wait_secs ?? 0) / 60);
410
532
  const expiresAt = status.slot_expires_at ? new Date(status.slot_expires_at).toLocaleTimeString([], {
411
533
  hour: "2-digit",
412
534
  minute: "2-digit"
413
535
  }) : null;
414
536
  const fastPoll = status.queue_position <= 2;
537
+ const aheadCount = Math.max(0, status.queue_position - 1);
538
+ const peopleWord = aheadCount === 1 ? "person" : "people";
415
539
  return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", gap: 1, children: [
416
540
  /* @__PURE__ */ jsxs2(Box2, { gap: 2, children: [
417
- /* @__PURE__ */ jsxs2(Text2, { children: [
418
- "Position",
541
+ aheadCount === 0 ? /* @__PURE__ */ jsxs2(Text2, { children: [
542
+ /* @__PURE__ */ jsx2(Text2, { bold: true, color: "green", children: "You're next" }),
543
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: " in line" })
544
+ ] }) : /* @__PURE__ */ jsxs2(Text2, { children: [
545
+ /* @__PURE__ */ jsx2(Text2, { bold: true, color: "yellow", children: aheadCount }),
419
546
  " ",
420
- /* @__PURE__ */ jsx2(Text2, { bold: true, color: "yellow", children: status.queue_position }),
421
- status.queue_depth > 0 && /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
422
- " of ",
423
- status.queue_depth
547
+ peopleWord,
548
+ " ahead of you"
549
+ ] }),
550
+ status.queue_depth > 1 && /* @__PURE__ */ jsxs2(Fragment2, { children: [
551
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "\xB7" }),
552
+ /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
553
+ status.queue_depth,
554
+ " in queue"
424
555
  ] })
425
556
  ] }),
426
557
  /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "\xB7" }),
427
558
  /* @__PURE__ */ jsxs2(Text2, { children: [
428
559
  "Estimated wait:",
429
560
  " ",
430
- /* @__PURE__ */ jsxs2(Text2, { bold: true, color: "cyan", children: [
431
- "~",
432
- waitMins,
433
- " min"
434
- ] })
561
+ /* @__PURE__ */ jsx2(Text2, { bold: true, color: "cyan", children: aheadCount === 0 ? "any moment now" : `~${waitMins} min` })
435
562
  ] })
436
563
  ] }),
437
- status.status === "exporting" || status.status === "your_turn" || status.status === "ready_to_download" ? /* @__PURE__ */ jsx2(ExportingMessage, { status: status.status }) : status.active_since ? /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", children: /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
564
+ status.status === "exporting" || status.status === "your_turn" || status.status === "ready_to_download" ? /* @__PURE__ */ jsx2(ExportingMessage, { status: status.status, slotExpiresAt: expiresAt }) : status.active_since ? /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", children: /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
438
565
  "Another contributor is active",
439
566
  expiresAt ? ` \xB7 slot expires at ${expiresAt}` : ""
440
- ] }) }) : status.queue_position > 1 ? (
567
+ ] }) }) : aheadCount > 0 ? (
441
568
  // Slot is idle but people are ahead — they joined and left without releasing.
442
569
  // timeout_watchdog will clear each stale slot after contribution_timeout_secs.
443
- /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "Slot is idle \u2014 waiting for positions ahead to respond or time out (up to ~5 min each)" })
570
+ /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
571
+ "Slot is idle \u2014 waiting for ",
572
+ peopleWord,
573
+ " ahead to respond or time out (up to ~5 min each)"
574
+ ] })
444
575
  ) : (
445
- // Position 1, slot idle — advance_queue should fire shortly.
576
+ // At the front of the line, slot idle — advance_queue should fire shortly.
446
577
  /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "Slot is idle \u2014 your turn is being prepared..." })
447
578
  ),
448
579
  pollErr ? /* @__PURE__ */ jsxs2(Text2, { color: "red", children: [
@@ -458,25 +589,63 @@ function QueueView({ ceremonyId: ceremonyId2, trackId, token, onReady, onError }
458
589
  ] })
459
590
  ] });
460
591
  }
461
- function ExportingMessage({ status }) {
592
+ function TerminalSlotMessage({ status }) {
593
+ const lines = {
594
+ timed_out: {
595
+ headline: "Your slot expired before the challenge file was ready.",
596
+ detail: "This is usually a transient S3 hiccup on the worker side, not a problem with your machine. Press B or Backspace to return to the track list and rejoin the queue \u2014 it almost always succeeds the second time."
597
+ },
598
+ failed: {
599
+ headline: "The worker reported a failure on this contribution.",
600
+ detail: "Press B or Backspace to return to the track list. If this happens repeatedly on the same track, the admin needs to investigate."
601
+ },
602
+ verified: {
603
+ headline: "This contribution is already recorded as verified.",
604
+ detail: "Press B or Backspace to return to the track list and pick another track."
605
+ }
606
+ };
607
+ const key = status;
608
+ const msg = lines[key] ?? {
609
+ headline: `Contribution ended with status: ${status}.`,
610
+ detail: "Press B or Backspace to return to the track list."
611
+ };
612
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", gap: 1, children: [
613
+ /* @__PURE__ */ jsx2(Text2, { color: "yellow", bold: true, children: msg.headline }),
614
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: msg.detail })
615
+ ] });
616
+ }
617
+ function ExportingMessage({
618
+ status,
619
+ slotExpiresAt
620
+ }) {
462
621
  const exportingMsg = useCyclingMessage(EXPORTING_MESSAGES, 2500, status === "exporting");
463
- return /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", children: status === "exporting" ? /* @__PURE__ */ jsxs2(Fragment2, { children: [
464
- /* @__PURE__ */ jsxs2(Text2, { color: "green", children: [
465
- /* @__PURE__ */ jsx2(Spinner, { type: "dots" }),
466
- " ",
467
- exportingMsg,
468
- "\u2026"
469
- ] }),
470
- /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "Your slot is active. The worker is preparing the challenge file just for you." })
471
- ] }) : /* @__PURE__ */ jsx2(Text2, { color: "green", children: "Challenge ready \u2014 loading contribution flow\u2026" }) });
622
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
623
+ status === "exporting" ? /* @__PURE__ */ jsxs2(Fragment2, { children: [
624
+ /* @__PURE__ */ jsxs2(Text2, { color: "green", children: [
625
+ /* @__PURE__ */ jsx2(Spinner, { type: "dots" }),
626
+ " ",
627
+ exportingMsg,
628
+ "\u2026"
629
+ ] }),
630
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "Your slot is active. The worker is preparing the challenge file just for you." })
631
+ ] }) : /* @__PURE__ */ jsx2(Text2, { color: "green", children: "Challenge ready \u2014 loading contribution flow\u2026" }),
632
+ slotExpiresAt && // Visible countdown is critical on power-18 circuits where the
633
+ // contributor compute window can run several minutes — without
634
+ // this they have no way to tell if they're about to be reaped.
635
+ /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
636
+ "Your slot expires at ",
637
+ slotExpiresAt,
638
+ " (server time)."
639
+ ] })
640
+ ] });
472
641
  }
473
642
 
474
- // src/components/EntropyCollector.tsx
475
- import { useRef as useRef2, useState as useState3 } from "react";
643
+ // src/components/MouseEntropyCollector.tsx
644
+ import { useEffect as useEffect3, useRef as useRef2, useState as useState3 } from "react";
476
645
  import { Box as Box3, Text as Text3, useInput } from "ink";
477
646
 
478
647
  // src/entropy.ts
479
- import { createHash as createHash2, randomBytes } from "crypto";
648
+ import { createHash as createHash2, hkdfSync, randomBytes } from "crypto";
480
649
  function sha512(buf) {
481
650
  return createHash2("sha512").update(buf).digest();
482
651
  }
@@ -488,18 +657,405 @@ function buildEntropyFromKeystrokes(chars, timingsNs) {
488
657
  const osHash = sha512(randomBytes(64));
489
658
  return createHash2("sha512").update(keystrokeHash).update(osHash).digest("hex");
490
659
  }
660
+ var TRAIL_MIN_BYTES = 64;
661
+ function buildEntropyFromMouseTrail(strokes) {
662
+ const strokeBuf = Buffer.alloc(strokes.length * 12);
663
+ for (let i = 0; i < strokes.length; i++) {
664
+ const s = strokes[i];
665
+ strokeBuf.writeUInt16BE(s.x & 65535, i * 12);
666
+ strokeBuf.writeUInt16BE(s.y & 65535, i * 12 + 2);
667
+ strokeBuf.writeBigUInt64BE(s.dtNs, i * 12 + 4);
668
+ }
669
+ const padBytes = Math.max(0, TRAIL_MIN_BYTES - strokeBuf.length);
670
+ const trailInput = padBytes > 0 ? Buffer.concat([strokeBuf, randomBytes(padBytes)]) : strokeBuf;
671
+ const trailHash = sha512(trailInput);
672
+ const osHash = sha512(randomBytes(64));
673
+ return createHash2("sha512").update(trailHash).update(osHash).digest("hex");
674
+ }
675
+ function deriveCircuitEntropy(masterSeed, ceremonyId2, circuitName) {
676
+ const ikm = Buffer.from(masterSeed, "hex");
677
+ const salt = createHash2("sha256").update(ceremonyId2).digest();
678
+ const info = Buffer.from(`umbra-ceremony-${ceremonyId2}-circuit-${circuitName}`, "utf8");
679
+ const derived = Buffer.from(hkdfSync("sha512", ikm, salt, info, 64));
680
+ return derived.toString("hex");
681
+ }
491
682
 
492
- // src/components/EntropyCollector.tsx
683
+ // src/mouse.ts
684
+ var SGR_RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
685
+ function classifyButton(buttonCode) {
686
+ if (buttonCode & 32) return "motion";
687
+ const base = buttonCode & 3;
688
+ if (base === 0) return "left";
689
+ if (base === 1) return "middle";
690
+ if (base === 2) return "right";
691
+ return "other";
692
+ }
693
+ function parseMouseChunk(chunk) {
694
+ const s = typeof chunk === "string" ? chunk : chunk.toString("binary");
695
+ const events = [];
696
+ SGR_RE.lastIndex = 0;
697
+ let m;
698
+ while ((m = SGR_RE.exec(s)) !== null) {
699
+ const buttonCode = parseInt(m[1], 10);
700
+ const col = parseInt(m[2], 10) - 1;
701
+ const row = parseInt(m[3], 10) - 1;
702
+ events.push({
703
+ button: classifyButton(buttonCode),
704
+ col,
705
+ row,
706
+ isRelease: m[4] === "m"
707
+ });
708
+ }
709
+ return events;
710
+ }
711
+ function enableMouseReporting() {
712
+ process.stdout.write("\x1B[?1003h\x1B[?1006h");
713
+ return () => {
714
+ process.stdout.write("\x1B[?1003l\x1B[?1006l");
715
+ };
716
+ }
717
+
718
+ // src/components/MouseEntropyCollector.tsx
493
719
  import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
720
+ var LEFT_OFFSET = 1;
721
+ var TOP_OFFSET = 2;
722
+ var MIN_W = 18;
723
+ var MIN_H = 12;
724
+ var MAX_W = 80;
725
+ var MAX_H = 32;
726
+ var DEFAULT_ASPECT = 2.2;
727
+ var ASPECT = (() => {
728
+ const raw = process.env["CEREMONY_CANVAS_ASPECT"];
729
+ if (!raw) return DEFAULT_ASPECT;
730
+ const parsed = Number.parseFloat(raw);
731
+ if (!Number.isFinite(parsed) || parsed < 0.5 || parsed > 6) return DEFAULT_ASPECT;
732
+ return parsed;
733
+ })();
734
+ var CHROME_ROWS = 6;
735
+ var CHROME_COLS = 2;
736
+ var MIN_ELAPSED_MS = 3e3;
737
+ var RENDER_INTERVAL_MS = 33;
738
+ function deriveCanvasSize(cols, rows) {
739
+ const availH = rows - CHROME_ROWS;
740
+ const availW = cols - CHROME_COLS;
741
+ let h = Math.min(MAX_H, availH);
742
+ let w = Math.round(h * ASPECT);
743
+ const wCap = Math.min(MAX_W, availW);
744
+ if (w > wCap) {
745
+ w = wCap;
746
+ h = Math.round(w / ASPECT);
747
+ }
748
+ if (w < MIN_W || h < MIN_H) return null;
749
+ return { w, h };
750
+ }
751
+ function readTerminalDims() {
752
+ return {
753
+ cols: process.stdout.columns ?? 80,
754
+ rows: process.stdout.rows ?? 24
755
+ };
756
+ }
757
+ function MouseEntropyCollector({ title, onComplete, onError }) {
758
+ const [dims, setDims] = useState3(() => {
759
+ const { cols, rows } = readTerminalDims();
760
+ return deriveCanvasSize(cols, rows);
761
+ });
762
+ const dimsRef = useRef2(dims);
763
+ useEffect3(() => {
764
+ dimsRef.current = dims;
765
+ }, [dims]);
766
+ const pixelsRef = useRef2(/* @__PURE__ */ new Set());
767
+ const strokesRef = useRef2([]);
768
+ const mouseDownRef = useRef2(false);
769
+ const lastPaintRef = useRef2(null);
770
+ const cursorRef = useRef2(null);
771
+ const lastStrokeTsRef = useRef2(process.hrtime.bigint());
772
+ const startTsRef = useRef2(process.hrtime.bigint());
773
+ const completedRef = useRef2(false);
774
+ const [pixelCount, setPixelCount] = useState3(0);
775
+ const [elapsedMs, setElapsedMs] = useState3(0);
776
+ const [done, setDone] = useState3(false);
777
+ const [nudge, setNudge] = useState3(null);
778
+ const [grid, setGrid] = useState3(
779
+ () => dims ? Array.from({ length: dims.h }, () => " ".repeat(dims.w)) : []
780
+ );
781
+ const [cursor, setCursor] = useState3(null);
782
+ const canvasW = dims?.w ?? 0;
783
+ const canvasH = dims?.h ?? 0;
784
+ const subH = canvasH * 2;
785
+ function idx(x, y) {
786
+ return y * canvasW + x;
787
+ }
788
+ function paintLine(pixels, x0, y0, x1, y1, onNew) {
789
+ let dx = Math.abs(x1 - x0);
790
+ let dy = -Math.abs(y1 - y0);
791
+ const sx = x0 < x1 ? 1 : -1;
792
+ const sy = y0 < y1 ? 1 : -1;
793
+ let err = dx + dy;
794
+ let x = x0;
795
+ let y = y0;
796
+ while (true) {
797
+ if (x >= 0 && x < canvasW && y >= 0 && y < subH) {
798
+ const id = idx(x, y);
799
+ if (!pixels.has(id)) {
800
+ pixels.add(id);
801
+ onNew(x, y);
802
+ }
803
+ }
804
+ if (x === x1 && y === y1) break;
805
+ const e2 = 2 * err;
806
+ if (e2 >= dy) {
807
+ if (x === x1) break;
808
+ err += dy;
809
+ x += sx;
810
+ }
811
+ if (e2 <= dx) {
812
+ if (y === y1) break;
813
+ err += dx;
814
+ y += sy;
815
+ }
816
+ }
817
+ }
818
+ useEffect3(() => {
819
+ const onResize = () => {
820
+ const { cols, rows } = readTerminalDims();
821
+ const next = deriveCanvasSize(cols, rows);
822
+ const cur = dimsRef.current;
823
+ if (next === null && cur === null) return;
824
+ if (next !== null && cur !== null && next.w === cur.w && next.h === cur.h) return;
825
+ pixelsRef.current.clear();
826
+ strokesRef.current = [];
827
+ lastPaintRef.current = null;
828
+ cursorRef.current = null;
829
+ startTsRef.current = process.hrtime.bigint();
830
+ lastStrokeTsRef.current = startTsRef.current;
831
+ setNudge(null);
832
+ setPixelCount(0);
833
+ setElapsedMs(0);
834
+ setCursor(null);
835
+ setGrid(next ? Array.from({ length: next.h }, () => " ".repeat(next.w)) : []);
836
+ setDims(next);
837
+ };
838
+ process.stdout.on("resize", onResize);
839
+ return () => {
840
+ process.stdout.off("resize", onResize);
841
+ };
842
+ }, []);
843
+ useEffect3(() => {
844
+ if (!dims) return;
845
+ const disableMouse = enableMouseReporting();
846
+ const onData = (chunk) => {
847
+ if (completedRef.current) return;
848
+ const events = parseMouseChunk(chunk);
849
+ for (const ev of events) handleMouseEvent(ev);
850
+ };
851
+ process.stdin.on("data", onData);
852
+ const renderTick = setInterval(() => {
853
+ if (completedRef.current) return;
854
+ const size = pixelsRef.current.size;
855
+ const elapsed = Number((process.hrtime.bigint() - startTsRef.current) / 1000000n);
856
+ if (size !== pixelCount) setPixelCount(size);
857
+ if (elapsed !== elapsedMs) setElapsedMs(elapsed);
858
+ const next = [];
859
+ for (let r = 0; r < canvasH; r++) {
860
+ let line = "";
861
+ for (let c = 0; c < canvasW; c++) {
862
+ const top = pixelsRef.current.has(idx(c, r * 2));
863
+ const bot = pixelsRef.current.has(idx(c, r * 2 + 1));
864
+ line += top && bot ? "\u2588" : top ? "\u2580" : bot ? "\u2584" : " ";
865
+ }
866
+ next.push(line);
867
+ }
868
+ setGrid(next);
869
+ setCursor(cursorRef.current);
870
+ }, RENDER_INTERVAL_MS);
871
+ return () => {
872
+ clearInterval(renderTick);
873
+ process.stdin.off("data", onData);
874
+ disableMouse();
875
+ };
876
+ }, [canvasW, canvasH]);
877
+ function handleMouseEvent(ev) {
878
+ if (completedRef.current) return;
879
+ const canvasCol = ev.col - LEFT_OFFSET;
880
+ const canvasRow = ev.row - TOP_OFFSET;
881
+ const inBounds = canvasCol >= 0 && canvasCol < canvasW && canvasRow >= 0 && canvasRow < canvasH;
882
+ if (inBounds) cursorRef.current = { row: canvasRow, col: canvasCol };
883
+ else cursorRef.current = null;
884
+ if (ev.button === "left" && !ev.isRelease) {
885
+ mouseDownRef.current = true;
886
+ lastPaintRef.current = null;
887
+ if (inBounds) paintAt(canvasCol, canvasRow);
888
+ return;
889
+ }
890
+ if (ev.isRelease) {
891
+ mouseDownRef.current = false;
892
+ lastPaintRef.current = null;
893
+ return;
894
+ }
895
+ if (ev.button === "motion" && mouseDownRef.current && inBounds) {
896
+ paintAt(canvasCol, canvasRow);
897
+ }
898
+ }
899
+ function paintAt(canvasCol, canvasRow) {
900
+ const x = canvasCol;
901
+ const yTop = canvasRow * 2;
902
+ const yBot = canvasRow * 2 + 1;
903
+ const now = process.hrtime.bigint();
904
+ const dt = now - lastStrokeTsRef.current;
905
+ lastStrokeTsRef.current = now;
906
+ if (lastPaintRef.current) {
907
+ const lp = lastPaintRef.current;
908
+ paintLine(pixelsRef.current, lp.x, lp.y, x, yTop, (px, py) => {
909
+ strokesRef.current.push({ x: px, y: py, dtNs: dt });
910
+ });
911
+ paintLine(pixelsRef.current, lp.x, lp.y + 1, x, yBot, () => {
912
+ });
913
+ } else {
914
+ if (!pixelsRef.current.has(idx(x, yTop))) {
915
+ pixelsRef.current.add(idx(x, yTop));
916
+ strokesRef.current.push({ x, y: yTop, dtNs: dt });
917
+ }
918
+ if (!pixelsRef.current.has(idx(x, yBot))) {
919
+ pixelsRef.current.add(idx(x, yBot));
920
+ }
921
+ }
922
+ lastPaintRef.current = { x, y: yTop };
923
+ }
924
+ useInput((input, key) => {
925
+ if (completedRef.current) return;
926
+ if (key.backspace || key.delete || input === "c" || input === "C") {
927
+ pixelsRef.current.clear();
928
+ strokesRef.current = [];
929
+ lastPaintRef.current = null;
930
+ cursorRef.current = null;
931
+ startTsRef.current = process.hrtime.bigint();
932
+ lastStrokeTsRef.current = startTsRef.current;
933
+ setNudge(null);
934
+ return;
935
+ }
936
+ if (key.return) {
937
+ const elapsed = Number((process.hrtime.bigint() - startTsRef.current) / 1000000n);
938
+ if (elapsed < MIN_ELAPSED_MS) {
939
+ const secLeft = Math.ceil((MIN_ELAPSED_MS - elapsed) / 1e3);
940
+ setNudge(`Wait ${secLeft}s before committing (anti-fat-finger).`);
941
+ return;
942
+ }
943
+ completedRef.current = true;
944
+ setDone(true);
945
+ try {
946
+ const entropy = buildEntropyFromMouseTrail(strokesRef.current);
947
+ setTimeout(() => onComplete(entropy), 250);
948
+ } catch (e) {
949
+ onError(e instanceof Error ? e : new Error(String(e)));
950
+ }
951
+ }
952
+ });
953
+ if (!dims) {
954
+ const { cols, rows } = readTerminalDims();
955
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", gap: 1, children: [
956
+ /* @__PURE__ */ jsx3(Text3, { bold: true, color: "yellow", children: "\u26A0 Terminal too small for the mouse canvas" }),
957
+ /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
958
+ "Need at least ",
959
+ MIN_W + CHROME_COLS,
960
+ " cols \xD7 ",
961
+ MIN_H + CHROME_ROWS,
962
+ " rows. Got ",
963
+ cols,
964
+ " \xD7 ",
965
+ rows,
966
+ "."
967
+ ] }),
968
+ /* @__PURE__ */ jsxs3(Text3, { children: [
969
+ "Resize this window larger, or press ",
970
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "\u232B Backspace" }),
971
+ " to go back and then ",
972
+ /* @__PURE__ */ jsx3(Text3, { bold: true, color: "magenta", children: "K" }),
973
+ " on the mode-select modal to use keyboard entropy instead."
974
+ ] })
975
+ ] });
976
+ }
977
+ const secsElapsed = Math.floor(elapsedMs / 1e3);
978
+ const minSecs = Math.ceil(MIN_ELAPSED_MS / 1e3);
979
+ const timePct = Math.min(100, Math.round(elapsedMs / MIN_ELAPSED_MS * 100));
980
+ const filled = Math.round(timePct / 100 * 20);
981
+ const bar = "\u2588".repeat(filled) + "\u2591".repeat(20 - filled);
982
+ const ready = elapsedMs >= MIN_ELAPSED_MS;
983
+ if (done) {
984
+ return /* @__PURE__ */ jsxs3(Box3, { gap: 2, children: [
985
+ /* @__PURE__ */ jsx3(Text3, { color: "green", bold: true, children: "\u2713" }),
986
+ /* @__PURE__ */ jsx3(Text3, { children: "Entropy committed." })
987
+ ] });
988
+ }
989
+ const renderRow = (rowStr, rowIdx) => {
990
+ if (!cursor || cursor.row !== rowIdx) {
991
+ return /* @__PURE__ */ jsx3(Text3, { color: "white", children: rowStr });
992
+ }
993
+ const cIdx = cursor.col;
994
+ const before = rowStr.slice(0, cIdx);
995
+ const at = rowStr[cIdx] ?? " ";
996
+ const after = rowStr.slice(cIdx + 1);
997
+ return /* @__PURE__ */ jsxs3(Text3, { children: [
998
+ /* @__PURE__ */ jsx3(Text3, { color: "white", children: before }),
999
+ /* @__PURE__ */ jsx3(Text3, { color: "yellow", inverse: true, children: at === " " ? "\xB7" : at }),
1000
+ /* @__PURE__ */ jsx3(Text3, { color: "white", children: after })
1001
+ ] });
1002
+ };
1003
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
1004
+ /* @__PURE__ */ jsx3(Text3, { children: title ? /* @__PURE__ */ jsx3(Text3, { bold: true, color: "cyan", children: title.length > 70 ? title.slice(0, 67) + "\u2026" : title }) : /* @__PURE__ */ jsx3(Text3, { bold: true, color: "cyan", children: "Draw to generate entropy \u2014 hold the LEFT mouse button and drag" }) }),
1005
+ /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
1006
+ "\u250C",
1007
+ "\u2500".repeat(canvasW),
1008
+ "\u2510"
1009
+ ] }),
1010
+ grid.map((row, i) => /* @__PURE__ */ jsxs3(Text3, { children: [
1011
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "\u2502" }),
1012
+ renderRow(row, i),
1013
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "\u2502" })
1014
+ ] }, i)),
1015
+ /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
1016
+ "\u2514",
1017
+ "\u2500".repeat(canvasW),
1018
+ "\u2518"
1019
+ ] }),
1020
+ /* @__PURE__ */ jsxs3(Box3, { gap: 2, children: [
1021
+ /* @__PURE__ */ jsxs3(Text3, { color: ready ? "green" : "cyan", children: [
1022
+ "[",
1023
+ bar,
1024
+ "]"
1025
+ ] }),
1026
+ /* @__PURE__ */ jsxs3(Text3, { children: [
1027
+ /* @__PURE__ */ jsxs3(Text3, { bold: true, color: ready ? "green" : "yellow", children: [
1028
+ secsElapsed,
1029
+ "s"
1030
+ ] }),
1031
+ /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
1032
+ " ",
1033
+ "/ ",
1034
+ minSecs,
1035
+ "s \xB7 ",
1036
+ pixelCount,
1037
+ " pixels drawn"
1038
+ ] })
1039
+ ] }),
1040
+ nudge && /* @__PURE__ */ jsx3(Text3, { color: "yellow", children: nudge })
1041
+ ] }),
1042
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "Enter = commit \xB7 c / \u232B = clear \xB7 Ctrl+C / Ctrl+Z = abort" })
1043
+ ] });
1044
+ }
1045
+
1046
+ // src/components/EntropyCollector.tsx
1047
+ import { useRef as useRef3, useState as useState4 } from "react";
1048
+ import { Box as Box4, Text as Text4, useInput as useInput2 } from "ink";
1049
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
494
1050
  var TARGET = 20;
495
1051
  function EntropyCollector({ onComplete, onError }) {
496
- const [count, setCount] = useState3(0);
497
- const [done, setDone] = useState3(false);
498
- const charsRef = useRef2([]);
499
- const timingsRef = useRef2([]);
500
- const lastRef = useRef2(process.hrtime.bigint());
501
- const completedRef = useRef2(false);
502
- useInput((input) => {
1052
+ const [count, setCount] = useState4(0);
1053
+ const [done, setDone] = useState4(false);
1054
+ const charsRef = useRef3([]);
1055
+ const timingsRef = useRef3([]);
1056
+ const lastRef = useRef3(process.hrtime.bigint());
1057
+ const completedRef = useRef3(false);
1058
+ useInput2((input) => {
503
1059
  if (completedRef.current) return;
504
1060
  const now = process.hrtime.bigint();
505
1061
  timingsRef.current.push(now - lastRef.current);
@@ -523,32 +1079,32 @@ function EntropyCollector({ onComplete, onError }) {
523
1079
  const pct = Math.round(count / TARGET * 100);
524
1080
  const stars = "*".repeat(Math.min(count, 32));
525
1081
  if (done) {
526
- return /* @__PURE__ */ jsxs3(Box3, { gap: 2, children: [
527
- /* @__PURE__ */ jsx3(Text3, { color: "green", bold: true, children: "\u2713" }),
528
- /* @__PURE__ */ jsxs3(Text3, { children: [
1082
+ return /* @__PURE__ */ jsxs4(Box4, { gap: 2, children: [
1083
+ /* @__PURE__ */ jsx4(Text4, { color: "green", bold: true, children: "\u2713" }),
1084
+ /* @__PURE__ */ jsxs4(Text4, { children: [
529
1085
  "Entropy collected \u2014 [",
530
1086
  bar,
531
1087
  "] 100%"
532
1088
  ] })
533
1089
  ] });
534
1090
  }
535
- return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", gap: 1, children: [
536
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "Type anything to generate entropy:" }),
537
- /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "Keystroke timing (nanosecond intervals) is the randomness source." }),
538
- /* @__PURE__ */ jsxs3(Box3, { marginTop: 1, children: [
539
- /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: " > " }),
540
- /* @__PURE__ */ jsx3(Text3, { color: "green", children: stars }),
541
- /* @__PURE__ */ jsx3(Text3, { color: "yellow", children: "\u2588" })
1091
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", gap: 1, children: [
1092
+ /* @__PURE__ */ jsx4(Text4, { bold: true, children: "Type anything to generate entropy:" }),
1093
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "Keystroke timing (nanosecond intervals) is the randomness source." }),
1094
+ /* @__PURE__ */ jsxs4(Box4, { marginTop: 1, children: [
1095
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: " > " }),
1096
+ /* @__PURE__ */ jsx4(Text4, { color: "green", children: stars }),
1097
+ /* @__PURE__ */ jsx4(Text4, { color: "yellow", children: "\u2588" })
542
1098
  ] }),
543
- /* @__PURE__ */ jsxs3(Box3, { gap: 2, marginTop: 1, children: [
544
- /* @__PURE__ */ jsxs3(Text3, { color: "cyan", children: [
1099
+ /* @__PURE__ */ jsxs4(Box4, { gap: 2, marginTop: 1, children: [
1100
+ /* @__PURE__ */ jsxs4(Text4, { color: "cyan", children: [
545
1101
  "[",
546
1102
  bar,
547
1103
  "]"
548
1104
  ] }),
549
- /* @__PURE__ */ jsxs3(Text3, { children: [
550
- /* @__PURE__ */ jsx3(Text3, { bold: true, color: count > 0 ? "green" : "yellow", children: count }),
551
- /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
1105
+ /* @__PURE__ */ jsxs4(Text4, { children: [
1106
+ /* @__PURE__ */ jsx4(Text4, { bold: true, color: count > 0 ? "green" : "yellow", children: count }),
1107
+ /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
552
1108
  "/",
553
1109
  TARGET,
554
1110
  " keystrokes (",
@@ -557,13 +1113,14 @@ function EntropyCollector({ onComplete, onError }) {
557
1113
  ] })
558
1114
  ] })
559
1115
  ] }),
560
- /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "Shown as * \u2014 your actual input is never revealed." })
1116
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "Shown as * \u2014 your actual input is never revealed." }),
1117
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "To quit without contributing: Ctrl+C or Ctrl+Z (Q is part of the entropy alphabet here)." })
561
1118
  ] });
562
1119
  }
563
1120
 
564
1121
  // src/components/ContributeFlow.tsx
565
- import { useEffect as useEffect4, useState as useState4 } from "react";
566
- import { Box as Box4, Text as Text4 } from "ink";
1122
+ import { useEffect as useEffect5, useState as useState5 } from "react";
1123
+ import { Box as Box5, Text as Text5 } from "ink";
567
1124
  import Spinner2 from "ink-spinner";
568
1125
  import { tmpdir as tmpdir2 } from "os";
569
1126
  import { join as join4 } from "path";
@@ -590,7 +1147,7 @@ async function cleanupTemp(path) {
590
1147
  }
591
1148
 
592
1149
  // src/components/ContributeFlow.tsx
593
- import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
1150
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
594
1151
  var STEP_LABELS = ["downloading", "computing", "uploading", "signalling", "verifying"];
595
1152
  var STEP_INDEX = {
596
1153
  downloading: 0,
@@ -601,8 +1158,8 @@ var STEP_INDEX = {
601
1158
  };
602
1159
  function ContributeFlow(props) {
603
1160
  const { ceremonyId: ceremonyId2, trackId, token, slotStatus, entropy, displayName: displayName2 } = props;
604
- const [step, setStep] = useState4({ name: "downloading", bytesReceived: 0, total: null });
605
- useEffect4(() => {
1161
+ const [step, setStep] = useState5({ name: "downloading", bytesReceived: 0, total: null });
1162
+ useEffect5(() => {
606
1163
  let cancelled = false;
607
1164
  const challengePath = join4(tmpdir2(), `ceremony-challenge-${Date.now()}.mpcparams`);
608
1165
  let responsePath = null;
@@ -685,112 +1242,138 @@ function ContributeFlow(props) {
685
1242
  const currentIdx = STEP_INDEX[step.name] ?? 0;
686
1243
  const computingMsg = useCyclingMessage(COMPUTING_MESSAGES, 2500, step.name === "computing");
687
1244
  const verifyingMsg = useCyclingMessage(VERIFYING_MESSAGES, 3e3, step.name === "verifying");
688
- return /* @__PURE__ */ jsx4(Box4, { flexDirection: "column", gap: 1, children: STEP_LABELS.map((label, i) => {
689
- const isDone = i < currentIdx;
690
- const isActive = i === currentIdx;
691
- const isPending = i > currentIdx;
692
- let indicator;
693
- if (isDone) indicator = /* @__PURE__ */ jsx4(Text4, { color: "green", children: "\u2713" });
694
- else if (isActive) indicator = /* @__PURE__ */ jsx4(Spinner2, { type: "dots" });
695
- else indicator = /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "\u25CB" });
696
- let detail = null;
697
- if (isActive && step.name === "downloading" && step.total) {
698
- const pct = Math.round(step.bytesReceived / step.total * 100);
699
- const filled = Math.round(pct / 5);
700
- const bar = "\u2588".repeat(filled) + "\u2591".repeat(20 - filled);
701
- detail = /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
702
- " ",
703
- "[",
704
- bar,
705
- "] ",
706
- pct,
707
- "%"
708
- ] });
709
- }
710
- if (isActive && step.name === "verifying") {
711
- const elapsed = step.attempt <= 10 ? step.attempt * 3 : 10 * 3 + (step.attempt - 10) * 10;
712
- const mins = Math.floor(elapsed / 60);
713
- const secs = elapsed % 60;
714
- const time = mins > 0 ? `${mins}m ${secs.toString().padStart(2, "0")}s` : `${secs}s`;
715
- detail = /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
716
- " ",
717
- verifyingMsg,
718
- "\u2026 (",
719
- time,
720
- " elapsed)"
721
- ] });
722
- }
723
- if (isActive && step.name === "computing") {
724
- detail = /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
1245
+ return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", gap: 1, children: [
1246
+ STEP_LABELS.map((label, i) => {
1247
+ const isDone = i < currentIdx;
1248
+ const isActive = i === currentIdx;
1249
+ const isPending = i > currentIdx;
1250
+ let indicator;
1251
+ if (isDone) indicator = /* @__PURE__ */ jsx5(Text5, { color: "green", children: "\u2713" });
1252
+ else if (isActive) indicator = /* @__PURE__ */ jsx5(Spinner2, { type: "dots" });
1253
+ else indicator = /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "\u25CB" });
1254
+ let detail = null;
1255
+ if (isActive && step.name === "downloading" && step.total) {
1256
+ const pct = Math.round(step.bytesReceived / step.total * 100);
1257
+ const filled = Math.round(pct / 5);
1258
+ const bar = "\u2588".repeat(filled) + "\u2591".repeat(20 - filled);
1259
+ detail = /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
1260
+ " ",
1261
+ "[",
1262
+ bar,
1263
+ "] ",
1264
+ pct,
1265
+ "%"
1266
+ ] });
1267
+ }
1268
+ if (isActive && step.name === "verifying") {
1269
+ const elapsed = step.attempt <= 10 ? step.attempt * 3 : 10 * 3 + (step.attempt - 10) * 10;
1270
+ const mins = Math.floor(elapsed / 60);
1271
+ const secs = elapsed % 60;
1272
+ const time = mins > 0 ? `${mins}m ${secs.toString().padStart(2, "0")}s` : `${secs}s`;
1273
+ detail = /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
1274
+ " ",
1275
+ verifyingMsg,
1276
+ "\u2026 (",
1277
+ time,
1278
+ " elapsed)"
1279
+ ] });
1280
+ }
1281
+ if (isActive && step.name === "computing") {
1282
+ detail = /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
1283
+ " ",
1284
+ computingMsg,
1285
+ "\u2026"
1286
+ ] });
1287
+ }
1288
+ return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", children: [
1289
+ /* @__PURE__ */ jsxs5(Box5, { gap: 2, children: [
1290
+ indicator,
1291
+ /* @__PURE__ */ jsxs5(
1292
+ Text5,
1293
+ {
1294
+ color: isDone ? "green" : isActive ? "white" : void 0,
1295
+ bold: isActive,
1296
+ dimColor: isPending,
1297
+ children: [
1298
+ label.charAt(0).toUpperCase() + label.slice(1),
1299
+ " ",
1300
+ isActive && label === "computing" && /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "(entropy stays local)" })
1301
+ ]
1302
+ }
1303
+ )
1304
+ ] }),
1305
+ detail
1306
+ ] }, label);
1307
+ }),
1308
+ slotStatus.slot_expires_at && step.name !== "verifying" && /* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
1309
+ "Your slot expires at",
1310
+ " ",
1311
+ new Date(slotStatus.slot_expires_at).toLocaleTimeString([], {
1312
+ hour: "2-digit",
1313
+ minute: "2-digit"
1314
+ }),
1315
+ " ",
1316
+ "(server time)."
1317
+ ] }) }),
1318
+ /* @__PURE__ */ jsxs5(Box5, { marginTop: 1, flexDirection: "column", children: [
1319
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }),
1320
+ /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
1321
+ "Need to bail? Press",
725
1322
  " ",
726
- computingMsg,
727
- "\u2026"
728
- ] });
729
- }
730
- return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
731
- /* @__PURE__ */ jsxs4(Box4, { gap: 2, children: [
732
- indicator,
733
- /* @__PURE__ */ jsxs4(
734
- Text4,
735
- {
736
- color: isDone ? "green" : isActive ? "white" : void 0,
737
- bold: isActive,
738
- dimColor: isPending,
739
- children: [
740
- label.charAt(0).toUpperCase() + label.slice(1),
741
- " ",
742
- isActive && label === "computing" && /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "(entropy stays local)" })
743
- ]
744
- }
745
- )
1323
+ /* @__PURE__ */ jsx5(Text5, { bold: true, children: "Q" }),
1324
+ ", ",
1325
+ /* @__PURE__ */ jsx5(Text5, { bold: true, children: "Ctrl+C" }),
1326
+ ", or ",
1327
+ /* @__PURE__ */ jsx5(Text5, { bold: true, children: "Ctrl+Z" }),
1328
+ " \u2014 your slot is released immediately so the next contributor can take over."
746
1329
  ] }),
747
- detail
748
- ] }, label);
749
- }) });
1330
+ step.name === "computing" && /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "(During this step snarkjs is busy \u2014 Ctrl+C / Ctrl+Z respond faster than Q.)" })
1331
+ ] })
1332
+ ] });
750
1333
  }
751
1334
 
752
1335
  // src/components/Attestation.tsx
753
- import { Box as Box5, Text as Text5 } from "ink";
754
- import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1336
+ import { Box as Box6, Text as Text6 } from "ink";
1337
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
755
1338
  function Attestation({ contribution }) {
756
1339
  const hashShort = contribution.contributionHash ? `${contribution.contributionHash.slice(0, 16)}...${contribution.contributionHash.slice(-8)}` : "(pending verification)";
757
1340
  const verifiedAt = contribution.verifiedAt ? new Date(contribution.verifiedAt).toLocaleString() : null;
758
- return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", gap: 1, children: [
759
- /* @__PURE__ */ jsx5(Text5, { bold: true, color: "green", children: "\u2713 Contribution verified!" }),
760
- /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", gap: 0, children: [
761
- /* @__PURE__ */ jsxs5(Box5, { gap: 2, children: [
762
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "Circuit" }),
763
- /* @__PURE__ */ jsx5(Text5, { bold: true, children: contribution.circuitName })
1341
+ return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", gap: 1, children: [
1342
+ /* @__PURE__ */ jsx6(Text6, { bold: true, color: "green", children: "\u2713 Contribution verified!" }),
1343
+ /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", gap: 0, children: [
1344
+ /* @__PURE__ */ jsxs6(Box6, { gap: 2, children: [
1345
+ /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "Circuit" }),
1346
+ /* @__PURE__ */ jsx6(Text6, { bold: true, children: contribution.circuitName })
764
1347
  ] }),
765
- /* @__PURE__ */ jsxs5(Box5, { gap: 2, children: [
766
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "Round " }),
767
- /* @__PURE__ */ jsxs5(Text5, { bold: true, children: [
1348
+ /* @__PURE__ */ jsxs6(Box6, { gap: 2, children: [
1349
+ /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "Round " }),
1350
+ /* @__PURE__ */ jsxs6(Text6, { bold: true, children: [
768
1351
  "#",
769
1352
  contribution.sequenceNumber
770
1353
  ] })
771
1354
  ] }),
772
- /* @__PURE__ */ jsxs5(Box5, { gap: 2, children: [
773
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "Hash " }),
774
- /* @__PURE__ */ jsx5(Text5, { color: "cyan", children: hashShort })
1355
+ /* @__PURE__ */ jsxs6(Box6, { gap: 2, children: [
1356
+ /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "Hash " }),
1357
+ /* @__PURE__ */ jsx6(Text6, { color: "cyan", children: hashShort })
775
1358
  ] }),
776
- verifiedAt && /* @__PURE__ */ jsxs5(Box5, { gap: 2, children: [
777
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "Time " }),
778
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: verifiedAt })
1359
+ verifiedAt && /* @__PURE__ */ jsxs6(Box6, { gap: 2, children: [
1360
+ /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "Time " }),
1361
+ /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: verifiedAt })
779
1362
  ] })
780
1363
  ] }),
781
- contribution.contributionHash && /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", marginTop: 1, children: [
782
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "Full hash (share this to prove participation):" }),
783
- /* @__PURE__ */ jsx5(Text5, { color: "cyan", wrap: "wrap", children: contribution.contributionHash })
1364
+ contribution.contributionHash && /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", marginTop: 1, children: [
1365
+ /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "Full hash (share this to prove participation):" }),
1366
+ /* @__PURE__ */ jsx6(Text6, { color: "cyan", wrap: "wrap", children: contribution.contributionHash })
784
1367
  ] })
785
1368
  ] });
786
1369
  }
787
1370
 
788
1371
  // src/components/InfoModal.tsx
789
- import { Box as Box6, Text as Text6 } from "ink";
790
- import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
1372
+ import { Box as Box7, Text as Text7 } from "ink";
1373
+ import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
791
1374
  function InfoModal() {
792
- return /* @__PURE__ */ jsx6(Box6, { flexDirection: "column", paddingX: 2, paddingY: 1, children: /* @__PURE__ */ jsxs6(
793
- Box6,
1375
+ return /* @__PURE__ */ jsx7(Box7, { flexDirection: "column", paddingX: 2, paddingY: 1, children: /* @__PURE__ */ jsxs7(
1376
+ Box7,
794
1377
  {
795
1378
  flexDirection: "column",
796
1379
  borderStyle: "round",
@@ -800,34 +1383,66 @@ function InfoModal() {
800
1383
  width: 84,
801
1384
  gap: 1,
802
1385
  children: [
803
- /* @__PURE__ */ jsx6(Text6, { bold: true, color: "cyan", children: "Umbra Phase 2 Trusted-Setup Ceremony" }),
804
- /* @__PURE__ */ jsx6(Text6, { children: "Umbra uses Groth16 zero-knowledge proofs to give Solana users on-chain privacy. Every circuit needs a one-time multi-party ceremony to generate its proving key safely \u2014 that is Phase 2. Each contributor adds their own secret entropy and destroys it afterward. The setup stays secure as long as AT LEAST ONE contributor erased theirs, which is why your single contribution genuinely matters." }),
805
- /* @__PURE__ */ jsx6(Text6, { children: "What you will do: tap 20 keys to seed your entropy, download the latest challenge file, run snarkjs locally to combine your secret with the parameters, and upload the response. Your secret never leaves your machine. Erase it when you are done." }),
806
- /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "Press B, Esc or ? to close" })
1386
+ /* @__PURE__ */ jsx7(Text7, { bold: true, color: "cyan", children: "Umbra Phase 2 Trusted-Setup Ceremony" }),
1387
+ /* @__PURE__ */ jsx7(Text7, { children: "Umbra uses Groth16 zero-knowledge proofs to give Solana users on-chain privacy. Every circuit needs a one-time multi-party ceremony to generate its proving key safely \u2014 that is Phase 2. Each contributor adds their own secret entropy and destroys it afterward. The setup stays secure as long as AT LEAST ONE contributor erased theirs, which is why your single contribution genuinely matters." }),
1388
+ /* @__PURE__ */ jsx7(Text7, { children: "What you will do: tap 20 keys to seed your entropy, download the latest challenge file, run snarkjs locally to combine your secret with the parameters, and upload the response. Your secret never leaves your machine. Erase it when you are done." }),
1389
+ /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "Press B, Esc or ? to close" })
807
1390
  ]
808
1391
  }
809
1392
  ) });
810
1393
  }
811
1394
 
812
1395
  // src/components/App.tsx
813
- import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
1396
+ import { Fragment as Fragment3, jsx as jsx8, jsxs as jsxs8 } from "react/jsx-runtime";
814
1397
  var NAME_MAX_LEN = 100;
815
1398
  var NAME_VALID_RE = /^[\p{L}\p{N} _.\-]*$/u;
1399
+ function elideMiddle(s, maxLen) {
1400
+ if (s.length <= maxLen) return s;
1401
+ const keepHead = Math.ceil((maxLen - 1) / 2);
1402
+ const keepTail = Math.floor((maxLen - 1) / 2);
1403
+ return s.slice(0, keepHead) + "\u2026" + s.slice(s.length - keepTail);
1404
+ }
1405
+ function orderTracksForAuto(tracks, contributed) {
1406
+ const openTracks = tracks.filter((t) => t.status === "open");
1407
+ const fresh = [];
1408
+ const alreadyDone = [];
1409
+ for (const t of openTracks) {
1410
+ if ((contributed[t.id]?.length ?? 0) > 0) alreadyDone.push(t);
1411
+ else fresh.push(t);
1412
+ }
1413
+ return [...fresh, ...alreadyDone];
1414
+ }
1415
+ function copyToClipboardOSC52(value) {
1416
+ const payload = Buffer.from(value, "utf8").toString("base64");
1417
+ process.stdout.write(`\x1B]52;c;${payload}\x07`);
1418
+ }
816
1419
  function App({ ceremonyId: initialCeremonyId, displayName: initialDisplayName }) {
817
1420
  const { exit } = useApp();
818
- const [activeCeremonyId, setActiveCeremonyId] = useState5(initialCeremonyId);
819
- const [displayName2, setDisplayName] = useState5(initialDisplayName ?? "anonymous");
820
- const [nameSet, setNameSet] = useState5(initialDisplayName !== void 0);
821
- const [screen, setScreen] = useState5(
1421
+ const [activeCeremonyId, setActiveCeremonyId] = useState6(initialCeremonyId);
1422
+ const [displayName2, setDisplayName] = useState6(initialDisplayName ?? "anonymous");
1423
+ const [nameSet, setNameSet] = useState6(initialDisplayName !== void 0);
1424
+ const [screen, setScreenRaw] = useState6(
822
1425
  initialDisplayName === void 0 ? { name: "name-input", value: "" } : initialCeremonyId ? { name: "loading" } : { name: "ceremony-picker", ceremonies: [], loading: true }
823
1426
  );
824
- const [ceremony, setCeremony] = useState5(null);
825
- const [session, setSession] = useState5(null);
826
- const [contributed, setContributed] = useState5({});
827
- const [selectedIdx, setSelectedIdx] = useState5(0);
828
- const [tab, setTab] = useState5(0);
829
- const [showInfo, setShowInfo] = useState5(false);
830
- useEffect5(() => {
1427
+ const setScreen = (next) => {
1428
+ setScreenRaw((prev) => {
1429
+ const resolved = typeof next === "function" ? next(prev) : next;
1430
+ if (prev.name !== resolved.name) {
1431
+ process.stdout.write("\x1B[2J\x1B[H");
1432
+ }
1433
+ return resolved;
1434
+ });
1435
+ };
1436
+ const [ceremony, setCeremony] = useState6(null);
1437
+ const [session, setSession] = useState6(null);
1438
+ const [contributed, setContributed] = useState6({});
1439
+ const [selectedIdx, setSelectedIdx] = useState6(0);
1440
+ const [tab, setTab] = useState6(0);
1441
+ const [contribCursor, setContribCursor] = useState6(0);
1442
+ const [copyToast, setCopyToast] = useState6(null);
1443
+ const [showInfo, setShowInfo] = useState6(false);
1444
+ const [entropyMode, setEntropyMode] = useState6("mouse");
1445
+ useEffect6(() => {
831
1446
  if (!nameSet) return;
832
1447
  if (!initialCeremonyId) {
833
1448
  loadCeremonies();
@@ -875,7 +1490,7 @@ function App({ ceremonyId: initialCeremonyId, displayName: initialDisplayName })
875
1490
  return;
876
1491
  }
877
1492
  setSelectedIdx(0);
878
- setScreen({ name: "tracks", tracks });
1493
+ setScreen({ name: "mode-select", tracks });
879
1494
  } catch (e) {
880
1495
  if (e.code === "INVALID_SESSION" || e.status === 401) {
881
1496
  try {
@@ -892,7 +1507,7 @@ function App({ ceremonyId: initialCeremonyId, displayName: initialDisplayName })
892
1507
  return;
893
1508
  }
894
1509
  setSelectedIdx(0);
895
- setScreen({ name: "tracks", tracks });
1510
+ setScreen({ name: "mode-select", tracks });
896
1511
  } catch (e2) {
897
1512
  setScreen({
898
1513
  name: "error",
@@ -950,9 +1565,25 @@ function App({ ceremonyId: initialCeremonyId, displayName: initialDisplayName })
950
1565
  setScreen({ name: "ceremony-picker", ceremonies: [], loading: true });
951
1566
  loadCeremonies();
952
1567
  }
953
- useEffect5(() => {
954
- if (screen.name === "queue" && session) {
955
- const { trackId } = screen;
1568
+ useEffect6(() => {
1569
+ if (session) {
1570
+ setSessionCleanup(clearSession);
1571
+ } else {
1572
+ clearSessionCleanup();
1573
+ }
1574
+ }, [session]);
1575
+ useEffect6(() => {
1576
+ let trackId = null;
1577
+ if (screen.name === "queue" || screen.name === "entropy" || screen.name === "contribute") {
1578
+ trackId = screen.trackId;
1579
+ } else if (screen.name === "auto-running") {
1580
+ if (screen.subPhase.kind === "waiting" || screen.subPhase.kind === "contributing" || screen.subPhase.kind === "releasing") {
1581
+ trackId = screen.subPhase.trackId;
1582
+ } else if (screen.current) {
1583
+ trackId = screen.current.id;
1584
+ }
1585
+ }
1586
+ if (trackId && session) {
956
1587
  setQueueCleanup(() => {
957
1588
  api.leaveQueue(activeCeremonyId, trackId, session.session_token).catch(() => {
958
1589
  });
@@ -960,8 +1591,69 @@ function App({ ceremonyId: initialCeremonyId, displayName: initialDisplayName })
960
1591
  } else {
961
1592
  clearQueueCleanup();
962
1593
  }
963
- }, [screen.name]);
964
- useInput2((input, key) => {
1594
+ }, [screen.name, screen.name === "auto-running" ? screen.subPhase.kind : null]);
1595
+ useEffect6(() => {
1596
+ if (screen.name !== "auto-running") return;
1597
+ if (screen.current !== null) return;
1598
+ if (screen.subPhase.kind !== "queueing") return;
1599
+ if (!session) return;
1600
+ const currentScreen = screen;
1601
+ (async () => {
1602
+ let next = null;
1603
+ let nextRemaining = currentScreen.remaining;
1604
+ let nextRetryQueue = currentScreen.retryQueue;
1605
+ let nextRetryingPhase = currentScreen.retryingPhase;
1606
+ if (currentScreen.remaining.length > 0) {
1607
+ next = currentScreen.remaining[0];
1608
+ nextRemaining = currentScreen.remaining.slice(1);
1609
+ } else if (currentScreen.retryQueue.length > 0 && !currentScreen.retryingPhase) {
1610
+ nextRemaining = currentScreen.retryQueue.slice(1);
1611
+ next = currentScreen.retryQueue[0];
1612
+ nextRetryQueue = [];
1613
+ nextRetryingPhase = true;
1614
+ }
1615
+ if (!next) {
1616
+ setScreen({ name: "auto-summary", outcomes: currentScreen.done });
1617
+ return;
1618
+ }
1619
+ try {
1620
+ await api.joinQueue(activeCeremonyId, next.id, session.session_token);
1621
+ } catch (e) {
1622
+ const reason = e?.message ?? String(e);
1623
+ setScreen({
1624
+ ...currentScreen,
1625
+ remaining: nextRemaining,
1626
+ retryQueue: nextRetryQueue,
1627
+ retryingPhase: nextRetryingPhase,
1628
+ current: null,
1629
+ subPhase: { kind: "queueing" },
1630
+ done: [
1631
+ ...currentScreen.done,
1632
+ {
1633
+ trackId: next.id,
1634
+ circuitName: next.circuit_name,
1635
+ result: "failed",
1636
+ reason: `joinQueue: ${reason}`
1637
+ }
1638
+ ]
1639
+ });
1640
+ return;
1641
+ }
1642
+ setScreen({
1643
+ ...currentScreen,
1644
+ remaining: nextRemaining,
1645
+ retryQueue: nextRetryQueue,
1646
+ retryingPhase: nextRetryingPhase,
1647
+ current: next,
1648
+ subPhase: { kind: "waiting", trackId: next.id, circuitName: next.circuit_name }
1649
+ });
1650
+ })();
1651
+ }, [
1652
+ screen.name,
1653
+ screen.name === "auto-running" ? screen.subPhase.kind : null,
1654
+ screen.name === "auto-running" ? screen.current?.id ?? null : null
1655
+ ]);
1656
+ useInput3((input, key) => {
965
1657
  const q = input.toLowerCase();
966
1658
  if (showInfo) {
967
1659
  if (key.escape || input === "?" || q === "b") {
@@ -974,7 +1666,7 @@ function App({ ceremonyId: initialCeremonyId, displayName: initialDisplayName })
974
1666
  }
975
1667
  return;
976
1668
  }
977
- if (input === "?" && screen.name !== "entropy") {
1669
+ if (input === "?" && screen.name !== "entropy" && screen.name !== "auto-entropy") {
978
1670
  setShowInfo(true);
979
1671
  return;
980
1672
  }
@@ -1001,23 +1693,84 @@ function App({ ceremonyId: initialCeremonyId, displayName: initialDisplayName })
1001
1693
  }
1002
1694
  return;
1003
1695
  }
1004
- if (q === "q" && screen.name !== "entropy") {
1005
- if (screen.name === "queue" && session) {
1696
+ if (q === "q" && screen.name !== "entropy" && screen.name !== "auto-entropy") {
1697
+ let trackId = null;
1698
+ if (screen.name === "queue" || screen.name === "contribute") {
1699
+ trackId = screen.trackId;
1700
+ } else if (screen.name === "auto-running") {
1701
+ if (screen.subPhase.kind === "waiting" || screen.subPhase.kind === "contributing" || screen.subPhase.kind === "releasing") {
1702
+ trackId = screen.subPhase.trackId;
1703
+ }
1704
+ }
1705
+ if (trackId && session) {
1006
1706
  clearQueueCleanup();
1007
- api.leaveQueue(activeCeremonyId, screen.trackId, session.session_token).catch(() => {
1707
+ clearSessionCleanup();
1708
+ api.leaveQueue(activeCeremonyId, trackId, session.session_token).catch(() => {
1709
+ });
1710
+ clearSession().catch(() => {
1008
1711
  });
1009
1712
  setTimeout(() => exit(), 500);
1713
+ } else if (session) {
1714
+ clearSessionCleanup();
1715
+ clearSession().catch(() => {
1716
+ });
1717
+ setTimeout(() => exit(), 100);
1010
1718
  } else {
1011
1719
  exit();
1012
1720
  }
1013
1721
  return;
1014
1722
  }
1015
1723
  if (key.backspace || key.delete) {
1016
- if (!initialCeremonyId && (screen.name === "tracks" || screen.name === "error")) {
1724
+ if (!initialCeremonyId && (screen.name === "tracks" || screen.name === "error" || screen.name === "mode-select" || screen.name === "auto-summary")) {
1017
1725
  goCeremonyPicker();
1018
1726
  return;
1019
1727
  }
1020
- if (screen.name !== "tracks" && screen.name !== "loading" && screen.name !== "joining" && screen.name !== "entropy" && screen.name !== "ceremony-picker") {
1728
+ if (screen.name === "auto-running" && (screen.subPhase.kind === "waiting" || screen.subPhase.kind === "contributing") && screen.current && session) {
1729
+ const skipped = screen.current;
1730
+ const skippedTrackId = screen.subPhase.trackId;
1731
+ const skippedCircuitName = screen.subPhase.circuitName;
1732
+ const sessionToken = session.session_token;
1733
+ setScreen({
1734
+ ...screen,
1735
+ subPhase: {
1736
+ kind: "releasing",
1737
+ trackId: skippedTrackId,
1738
+ circuitName: skippedCircuitName
1739
+ }
1740
+ });
1741
+ api.leaveQueue(activeCeremonyId, skippedTrackId, sessionToken).catch((err) => {
1742
+ const msg = err instanceof Error ? err.message : String(err);
1743
+ process.stderr.write(
1744
+ `[ceremony-tui] leaveQueue(${skippedTrackId}) failed during skip: ${msg}
1745
+ `
1746
+ );
1747
+ }).finally(() => {
1748
+ setScreen((prev) => {
1749
+ if (prev.name !== "auto-running" || prev.subPhase.kind !== "releasing" || prev.subPhase.trackId !== skippedTrackId) {
1750
+ return prev;
1751
+ }
1752
+ return {
1753
+ ...prev,
1754
+ current: null,
1755
+ subPhase: { kind: "queueing" },
1756
+ done: [
1757
+ ...prev.done,
1758
+ {
1759
+ trackId: skipped.id,
1760
+ circuitName: skipped.circuit_name,
1761
+ result: "failed",
1762
+ reason: "user skipped"
1763
+ }
1764
+ ]
1765
+ // Don't queue skipped circuits for retry — the user
1766
+ // actively chose to skip, so a silent retry would
1767
+ // defeat that intent.
1768
+ };
1769
+ });
1770
+ });
1771
+ return;
1772
+ }
1773
+ if (screen.name !== "tracks" && screen.name !== "loading" && screen.name !== "joining" && screen.name !== "entropy" && screen.name !== "auto-entropy" && screen.name !== "ceremony-picker") {
1021
1774
  goHome();
1022
1775
  }
1023
1776
  return;
@@ -1042,10 +1795,27 @@ function App({ ceremonyId: initialCeremonyId, displayName: initialDisplayName })
1042
1795
  }
1043
1796
  return;
1044
1797
  }
1798
+ if (screen.name === "mode-select") {
1799
+ if (q === "k") {
1800
+ setEntropyMode((m) => m === "mouse" ? "keyboard" : "mouse");
1801
+ return;
1802
+ }
1803
+ if (key.return) {
1804
+ setScreen({ name: "auto-entropy", tracks: screen.tracks });
1805
+ return;
1806
+ }
1807
+ if (q === "n" || q === "m") {
1808
+ setSelectedIdx(0);
1809
+ setScreen({ name: "tracks", tracks: screen.tracks });
1810
+ return;
1811
+ }
1812
+ return;
1813
+ }
1045
1814
  if (screen.name === "tracks") {
1046
1815
  const { tracks } = screen;
1047
1816
  if (key.tab) {
1048
1817
  setTab((t) => (t + 1) % 2);
1818
+ setContribCursor(0);
1049
1819
  return;
1050
1820
  }
1051
1821
  if (tab === 0) {
@@ -1059,7 +1829,33 @@ function App({ ceremonyId: initialCeremonyId, displayName: initialDisplayName })
1059
1829
  }
1060
1830
  if (key.return) {
1061
1831
  const t = tracks[selectedIdx];
1062
- if (t && t.status === "open" && !contributed[t.id]) joinTrack(t);
1832
+ if (t && t.status === "open") joinTrack(t);
1833
+ return;
1834
+ }
1835
+ } else if (tab === 1) {
1836
+ const myContribs = Object.values(contributed).flat().filter((c) => c.ceremonyId === activeCeremonyId).sort((a, b) => {
1837
+ const ta = a.verifiedAt ? Date.parse(a.verifiedAt) : 0;
1838
+ const tb = b.verifiedAt ? Date.parse(b.verifiedAt) : 0;
1839
+ return tb - ta;
1840
+ });
1841
+ if (key.upArrow) {
1842
+ setContribCursor((i) => Math.max(0, i - 1));
1843
+ return;
1844
+ }
1845
+ if (key.downArrow) {
1846
+ setContribCursor((i) => Math.min(myContribs.length - 1, i + 1));
1847
+ return;
1848
+ }
1849
+ if (q === "c") {
1850
+ const target = myContribs[contribCursor];
1851
+ if (target && target.contributionHash) {
1852
+ copyToClipboardOSC52(target.contributionHash);
1853
+ setCopyToast(`\u2713 Hash copied (round #${target.sequenceNumber})`);
1854
+ setTimeout(() => setCopyToast(null), 2e3);
1855
+ } else if (target) {
1856
+ setCopyToast("Nothing to copy \u2014 hash is still pending");
1857
+ setTimeout(() => setCopyToast(null), 2e3);
1858
+ }
1063
1859
  return;
1064
1860
  }
1065
1861
  }
@@ -1073,52 +1869,55 @@ function App({ ceremonyId: initialCeremonyId, displayName: initialDisplayName })
1073
1869
  }
1074
1870
  });
1075
1871
  if (showInfo) {
1076
- return /* @__PURE__ */ jsx7(InfoModal, {});
1872
+ return /* @__PURE__ */ jsx8(InfoModal, {});
1077
1873
  }
1078
1874
  if (screen.name === "name-input") {
1079
1875
  const { value } = screen;
1080
1876
  const canSubmit = value.trim().length > 0;
1081
- return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1082
- /* @__PURE__ */ jsx7(Header, { ceremony: null }),
1083
- /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", marginTop: 1, gap: 1, children: [
1084
- /* @__PURE__ */ jsx7(Text7, { bold: true, children: "Who are you contributing as?" }),
1085
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "Your display name appears next to your contributions in the public transcript. Pick anything \u2014 your real name, a handle, or hit Tab for a random anonymous name." }),
1086
- /* @__PURE__ */ jsxs7(Box7, { children: [
1087
- /* @__PURE__ */ jsx7(Text7, { color: "cyan", children: " > " }),
1088
- /* @__PURE__ */ jsx7(Text7, { children: value }),
1089
- /* @__PURE__ */ jsx7(Text7, { color: "cyan", inverse: true, children: " " })
1877
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
1878
+ /* @__PURE__ */ jsx8(Header, { ceremony: null }),
1879
+ /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", marginTop: 1, gap: 1, children: [
1880
+ /* @__PURE__ */ jsx8(Text8, { bold: true, children: "Who are you contributing as?" }),
1881
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "Your display name appears next to your contributions in the public transcript. Pick anything \u2014 your real name, a handle, or hit Tab for a random anonymous name." }),
1882
+ /* @__PURE__ */ jsxs8(Box8, { children: [
1883
+ /* @__PURE__ */ jsx8(Text8, { color: "cyan", children: " > " }),
1884
+ /* @__PURE__ */ jsx8(Text8, { children: value }),
1885
+ /* @__PURE__ */ jsx8(Text8, { color: "cyan", inverse: true, children: " " })
1090
1886
  ] }),
1091
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: canSubmit ? "Enter to continue \xB7 Tab rerolls random name \xB7 \u232B backspace \xB7 Esc to quit" : "Type a name, or press Tab to suggest a random anonymous one \xB7 Esc to quit" })
1887
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: canSubmit ? "Enter to continue \xB7 Tab rerolls random name \xB7 \u232B backspace \xB7 Esc to quit" : "Type a name, or press Tab to suggest a random anonymous one \xB7 Esc to quit" })
1092
1888
  ] })
1093
1889
  ] });
1094
1890
  }
1095
1891
  if (screen.name === "ceremony-picker") {
1096
1892
  const { ceremonies, loading } = screen;
1097
- return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1098
- /* @__PURE__ */ jsx7(Header, { ceremony: null }),
1099
- loading ? /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "Loading ceremonies..." }) : ceremonies.length === 0 ? /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", gap: 1, children: [
1100
- /* @__PURE__ */ jsx7(Text7, { color: "yellow", children: "No ceremonies found." }),
1101
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "The server may not have any active ceremonies yet." }),
1102
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "Q to quit" })
1103
- ] }) : /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1104
- /* @__PURE__ */ jsx7(Text7, { bold: true, children: "Select a ceremony:" }),
1105
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: " " + "\u2500".repeat(60) }),
1893
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
1894
+ /* @__PURE__ */ jsx8(Header, { ceremony: null }),
1895
+ loading ? /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
1896
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "Loading ceremonies..." }),
1897
+ /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "Q / Ctrl+C / Ctrl+Z to quit" }) })
1898
+ ] }) : ceremonies.length === 0 ? /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", gap: 1, children: [
1899
+ /* @__PURE__ */ jsx8(Text8, { color: "yellow", children: "No ceremonies found." }),
1900
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "The server may not have any active ceremonies yet." }),
1901
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "Q to quit" })
1902
+ ] }) : /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
1903
+ /* @__PURE__ */ jsx8(Text8, { bold: true, children: "Select a ceremony:" }),
1904
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: " " + "\u2500".repeat(60) }),
1106
1905
  ceremonies.map((c, i) => {
1107
1906
  const isSelected = i === selectedIdx;
1108
1907
  const isOpen = c.status === "open";
1109
1908
  const statusColor = c.status === "open" ? "green" : c.status === "completed" ? "cyan" : "yellow";
1110
1909
  const statusLabel = ceremonyStatusLabel(c.status);
1111
- return /* @__PURE__ */ jsxs7(Box7, { gap: 2, children: [
1112
- /* @__PURE__ */ jsxs7(Text7, { color: isSelected ? "cyan" : isOpen ? void 0 : "gray", children: [
1910
+ return /* @__PURE__ */ jsxs8(Box8, { gap: 2, children: [
1911
+ /* @__PURE__ */ jsxs8(Text8, { color: isSelected ? "cyan" : isOpen ? void 0 : "gray", children: [
1113
1912
  isSelected ? "\u25B6 " : " ",
1114
1913
  c.name.padEnd(30)
1115
1914
  ] }),
1116
- /* @__PURE__ */ jsxs7(Text7, { color: statusColor, children: [
1915
+ /* @__PURE__ */ jsxs8(Text8, { color: statusColor, children: [
1117
1916
  "[",
1118
1917
  statusLabel,
1119
1918
  "]"
1120
1919
  ] }),
1121
- /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
1920
+ /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
1122
1921
  c.track_count,
1123
1922
  " track",
1124
1923
  c.track_count !== 1 ? "s" : "",
@@ -1129,164 +1928,496 @@ function App({ ceremonyId: initialCeremonyId, displayName: initialDisplayName })
1129
1928
  ] })
1130
1929
  ] }, c.id);
1131
1930
  }),
1132
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: " " + "\u2500".repeat(60) }),
1931
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: " " + "\u2500".repeat(60) }),
1133
1932
  (() => {
1134
1933
  const c = ceremonies[selectedIdx];
1135
1934
  if (!c) return null;
1935
+ const navHint = /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "\u2191/\u2193 select \xB7 Enter join \xB7 Q / Ctrl+C / Ctrl+Z to quit" });
1136
1936
  if (c.status === "initialized") {
1137
- return /* @__PURE__ */ jsxs7(Text7, { color: "yellow", children: [
1138
- " ",
1139
- "This ceremony is not yet open for contributions. The Umbra team is still preparing the circuits \u2014 please check back shortly."
1937
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
1938
+ /* @__PURE__ */ jsxs8(Text8, { color: "yellow", children: [
1939
+ " ",
1940
+ "This ceremony is not yet open for contributions. The Umbra team is still preparing the circuits \u2014 please check back shortly."
1941
+ ] }),
1942
+ navHint
1140
1943
  ] });
1141
1944
  }
1142
1945
  if (c.status === "finalizing") {
1143
- return /* @__PURE__ */ jsxs7(Text7, { color: "yellow", children: [
1144
- " ",
1145
- "Contributions are closed. The ceremony is computing the final verification key \u2014 no further contributions can be added."
1946
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
1947
+ /* @__PURE__ */ jsxs8(Text8, { color: "yellow", children: [
1948
+ " ",
1949
+ "Contributions are closed. The ceremony is computing the final verification key \u2014 no further contributions can be added."
1950
+ ] }),
1951
+ navHint
1146
1952
  ] });
1147
1953
  }
1148
1954
  if (c.status === "completed") {
1149
- return /* @__PURE__ */ jsxs7(Text7, { color: "cyan", children: [
1150
- " ",
1151
- "Ceremony complete. The verification keys are finalised \u2014 thank you to everyone who contributed."
1955
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
1956
+ /* @__PURE__ */ jsxs8(Text8, { color: "cyan", children: [
1957
+ " ",
1958
+ "Ceremony complete. The verification keys are finalised \u2014 thank you to everyone who contributed."
1959
+ ] }),
1960
+ navHint
1152
1961
  ] });
1153
1962
  }
1154
- return /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "\u2191/\u2193 select \xB7 Enter join \xB7 Q quit" });
1963
+ return navHint;
1155
1964
  })()
1156
1965
  ] })
1157
1966
  ] });
1158
1967
  }
1159
1968
  if (screen.name === "loading") {
1160
- return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1161
- /* @__PURE__ */ jsx7(Header, { ceremony }),
1162
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "Loading tracks..." })
1969
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
1970
+ /* @__PURE__ */ jsx8(Header, { ceremony }),
1971
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "Loading tracks..." }),
1972
+ /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "Q / Ctrl+C / Ctrl+Z to quit" }) })
1973
+ ] });
1974
+ }
1975
+ if (screen.name === "mode-select") {
1976
+ const { tracks } = screen;
1977
+ const openCount = tracks.filter((t) => t.status === "open").length;
1978
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
1979
+ /* @__PURE__ */ jsx8(Header, { ceremony }),
1980
+ /* @__PURE__ */ jsxs8(
1981
+ Box8,
1982
+ {
1983
+ flexDirection: "column",
1984
+ borderStyle: "round",
1985
+ borderColor: "cyan",
1986
+ paddingX: 2,
1987
+ paddingY: 0,
1988
+ children: [
1989
+ /* @__PURE__ */ jsx8(Text8, { bold: true, color: "cyan", children: "Automate?" }),
1990
+ /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
1991
+ openCount,
1992
+ " circuit",
1993
+ openCount === 1 ? "" : "s",
1994
+ " open \xB7 auto runs the whole loop"
1995
+ ] }),
1996
+ /* @__PURE__ */ jsxs8(Box8, { marginTop: 1, flexDirection: "column", children: [
1997
+ /* @__PURE__ */ jsxs8(Text8, { children: [
1998
+ /* @__PURE__ */ jsx8(Text8, { bold: true, color: "green", children: "Enter" }),
1999
+ " ",
2000
+ "auto \xB7 entropy once, every circuit in order"
2001
+ ] }),
2002
+ /* @__PURE__ */ jsxs8(Text8, { children: [
2003
+ /* @__PURE__ */ jsx8(Text8, { bold: true, color: "yellow", children: "N" }),
2004
+ " ",
2005
+ "manual \xB7 pick circuits one at a time"
2006
+ ] }),
2007
+ /* @__PURE__ */ jsxs8(Text8, { children: [
2008
+ /* @__PURE__ */ jsx8(Text8, { bold: true, color: "magenta", children: "K" }),
2009
+ " ",
2010
+ "input:",
2011
+ " ",
2012
+ /* @__PURE__ */ jsx8(Text8, { bold: true, color: entropyMode === "mouse" ? "cyan" : "yellow", children: entropyMode }),
2013
+ " ",
2014
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "(press K to toggle)" })
2015
+ ] })
2016
+ ] })
2017
+ ]
2018
+ }
2019
+ ),
2020
+ /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "\u232B back \xB7 Q quit" }) })
2021
+ ] });
2022
+ }
2023
+ if (screen.name === "auto-entropy") {
2024
+ const { tracks } = screen;
2025
+ const onComplete = (masterSeed) => {
2026
+ const ordered = orderTracksForAuto(tracks, contributed);
2027
+ setScreen({
2028
+ name: "auto-running",
2029
+ tracks,
2030
+ masterSeed,
2031
+ remaining: ordered,
2032
+ retryQueue: [],
2033
+ retryingPhase: false,
2034
+ done: [],
2035
+ current: null,
2036
+ subPhase: { kind: "queueing" }
2037
+ });
2038
+ };
2039
+ const onError = (e) => setScreen({ name: "error", message: e.message, recoverable: false });
2040
+ if (entropyMode === "mouse") {
2041
+ return /* @__PURE__ */ jsx8(
2042
+ MouseEntropyCollector,
2043
+ {
2044
+ title: `Auto-mode entropy \xB7 one drawing seeds every circuit (HKDF per circuit)`,
2045
+ onComplete,
2046
+ onError
2047
+ }
2048
+ );
2049
+ }
2050
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2051
+ /* @__PURE__ */ jsx8(Header, { ceremony, subtitle: "Auto-mode entropy \xB7 seeds every circuit (HKDF)" }),
2052
+ /* @__PURE__ */ jsx8(EntropyCollector, { onComplete, onError })
2053
+ ] });
2054
+ }
2055
+ if (screen.name === "auto-running") {
2056
+ const totalCount = screen.done.length + screen.remaining.length + screen.retryQueue.length + (screen.current ? 1 : 0);
2057
+ const verifiedCount = screen.done.filter((d) => d.result === "verified").length;
2058
+ const failedCount = screen.done.filter((d) => d.result === "failed").length;
2059
+ const currentLabel = screen.current ? `current: ${screen.current.circuit_name}` : "advancing to next circuit\u2026";
2060
+ const progressStrip = /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", marginBottom: 1, children: [
2061
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "\u2500".repeat(60) }),
2062
+ /* @__PURE__ */ jsxs8(Text8, { children: [
2063
+ /* @__PURE__ */ jsx8(Text8, { bold: true, color: "cyan", children: "Auto-mode" }),
2064
+ " \xB7 ",
2065
+ /* @__PURE__ */ jsx8(Text8, { children: verifiedCount }),
2066
+ "/",
2067
+ /* @__PURE__ */ jsx8(Text8, { children: totalCount }),
2068
+ " verified",
2069
+ failedCount > 0 && /* @__PURE__ */ jsxs8(Fragment3, { children: [
2070
+ " \xB7 ",
2071
+ /* @__PURE__ */ jsxs8(Text8, { color: "yellow", children: [
2072
+ failedCount,
2073
+ " failed"
2074
+ ] }),
2075
+ screen.retryingPhase ? " (retrying)" : ""
2076
+ ] }),
2077
+ screen.retryQueue.length > 0 && !screen.retryingPhase && /* @__PURE__ */ jsxs8(Fragment3, { children: [
2078
+ " \xB7 ",
2079
+ /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
2080
+ screen.retryQueue.length,
2081
+ " queued for retry"
2082
+ ] })
2083
+ ] })
2084
+ ] }),
2085
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: currentLabel }),
2086
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "\u2500".repeat(60) })
2087
+ ] });
2088
+ if (screen.subPhase.kind === "releasing") {
2089
+ const sub = screen.subPhase;
2090
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2091
+ /* @__PURE__ */ jsx8(
2092
+ Header,
2093
+ {
2094
+ ceremony,
2095
+ subtitle: `Auto-mode \xB7 releasing slot for ${sub.circuitName}`
2096
+ }
2097
+ ),
2098
+ progressStrip,
2099
+ /* @__PURE__ */ jsxs8(Text8, { color: "yellow", children: [
2100
+ "\u23F3 Releasing slot for ",
2101
+ sub.circuitName,
2102
+ "\u2026"
2103
+ ] }),
2104
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "Waiting for the server to confirm the slot is free before joining the next circuit's queue." }),
2105
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "Q / Ctrl+C / Ctrl+Z abort run" })
2106
+ ] });
2107
+ }
2108
+ if (screen.subPhase.kind === "queueing") {
2109
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2110
+ /* @__PURE__ */ jsx8(Header, { ceremony, subtitle: "Auto-mode \xB7 joining next queue" }),
2111
+ progressStrip,
2112
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "Picking the next circuit\u2026" }),
2113
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "Q / Ctrl+C / Ctrl+Z to abort (releases any active slot)" })
2114
+ ] });
2115
+ }
2116
+ if (screen.subPhase.kind === "waiting") {
2117
+ const sub = screen.subPhase;
2118
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2119
+ /* @__PURE__ */ jsx8(
2120
+ Header,
2121
+ {
2122
+ ceremony,
2123
+ subtitle: `Auto-mode \xB7 waiting for slot \xB7 ${sub.circuitName}`
2124
+ }
2125
+ ),
2126
+ progressStrip,
2127
+ /* @__PURE__ */ jsx8(
2128
+ QueueView,
2129
+ {
2130
+ ceremonyId: activeCeremonyId,
2131
+ trackId: sub.trackId,
2132
+ token: session.session_token,
2133
+ onReady: (status) => {
2134
+ const entropy = deriveCircuitEntropy(
2135
+ screen.masterSeed,
2136
+ activeCeremonyId,
2137
+ sub.circuitName
2138
+ );
2139
+ setScreen({
2140
+ ...screen,
2141
+ subPhase: {
2142
+ kind: "contributing",
2143
+ trackId: sub.trackId,
2144
+ circuitName: sub.circuitName,
2145
+ slotStatus: status,
2146
+ entropy
2147
+ }
2148
+ });
2149
+ },
2150
+ onError: (e) => {
2151
+ const failedTrack = screen.current;
2152
+ const newDone = {
2153
+ trackId: failedTrack.id,
2154
+ circuitName: failedTrack.circuit_name,
2155
+ result: "failed",
2156
+ reason: e.message
2157
+ };
2158
+ setScreen({
2159
+ ...screen,
2160
+ current: null,
2161
+ subPhase: { kind: "queueing" },
2162
+ done: [...screen.done, newDone],
2163
+ retryQueue: screen.retryingPhase ? screen.retryQueue : [...screen.retryQueue, failedTrack]
2164
+ });
2165
+ }
2166
+ }
2167
+ ),
2168
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "\u232B Backspace skip this circuit \xB7 Q / Ctrl+C / Ctrl+Z abort run" })
2169
+ ] });
2170
+ }
2171
+ if (screen.subPhase.kind === "contributing") {
2172
+ const sub = screen.subPhase;
2173
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2174
+ /* @__PURE__ */ jsx8(
2175
+ Header,
2176
+ {
2177
+ ceremony,
2178
+ subtitle: `Auto-mode \xB7 contributing \xB7 ${sub.circuitName}`
2179
+ }
2180
+ ),
2181
+ progressStrip,
2182
+ /* @__PURE__ */ jsx8(
2183
+ ContributeFlow,
2184
+ {
2185
+ ceremonyId: activeCeremonyId,
2186
+ trackId: sub.trackId,
2187
+ token: session.session_token,
2188
+ slotStatus: sub.slotStatus,
2189
+ entropy: sub.entropy,
2190
+ displayName: displayName2,
2191
+ onComplete: async (contributionId, receipt) => {
2192
+ const local = {
2193
+ contributionId,
2194
+ sequenceNumber: receipt?.sequence_number ?? 0,
2195
+ contributionHash: receipt?.contribution_hash ?? "",
2196
+ circuitName: sub.circuitName,
2197
+ ceremonyId: activeCeremonyId,
2198
+ verifiedAt: receipt?.verified_at ?? (/* @__PURE__ */ new Date()).toISOString()
2199
+ };
2200
+ await recordContribution(sub.trackId, local);
2201
+ setContributed((prev) => {
2202
+ const existing = prev[sub.trackId] ?? [];
2203
+ if (existing.some((c) => c.contributionId === local.contributionId)) return prev;
2204
+ return { ...prev, [sub.trackId]: [...existing, local] };
2205
+ });
2206
+ const successTrack = screen.current;
2207
+ setScreen({
2208
+ ...screen,
2209
+ current: null,
2210
+ subPhase: { kind: "queueing" },
2211
+ done: [
2212
+ ...screen.done,
2213
+ {
2214
+ trackId: successTrack.id,
2215
+ circuitName: successTrack.circuit_name,
2216
+ result: "verified",
2217
+ hash: local.contributionHash
2218
+ }
2219
+ ]
2220
+ });
2221
+ },
2222
+ onError: (e) => {
2223
+ const failedTrack = screen.current;
2224
+ setScreen({
2225
+ ...screen,
2226
+ current: null,
2227
+ subPhase: { kind: "queueing" },
2228
+ done: [
2229
+ ...screen.done,
2230
+ {
2231
+ trackId: failedTrack.id,
2232
+ circuitName: failedTrack.circuit_name,
2233
+ result: "failed",
2234
+ reason: e.message
2235
+ }
2236
+ ],
2237
+ retryQueue: screen.retryingPhase ? screen.retryQueue : [...screen.retryQueue, failedTrack]
2238
+ });
2239
+ }
2240
+ }
2241
+ )
2242
+ ] });
2243
+ }
2244
+ return null;
2245
+ }
2246
+ if (screen.name === "auto-summary") {
2247
+ const verified = screen.outcomes.filter((o) => o.result === "verified");
2248
+ const failed = screen.outcomes.filter((o) => o.result === "failed");
2249
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2250
+ /* @__PURE__ */ jsx8(Header, { ceremony, subtitle: "Auto-mode \xB7 complete" }),
2251
+ /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", gap: 1, children: [
2252
+ /* @__PURE__ */ jsx8(Text8, { bold: true, color: failed.length === 0 ? "green" : "yellow", children: failed.length === 0 ? `\u2713 Auto-mode complete \u2014 ${verified.length} circuits verified` : `\u26A0 Auto-mode complete with ${failed.length} failure${failed.length === 1 ? "" : "s"}` }),
2253
+ verified.length > 0 && /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2254
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "Verified contributions:" }),
2255
+ verified.map((o, i) => /* @__PURE__ */ jsxs8(Box8, { paddingLeft: 2, children: [
2256
+ /* @__PURE__ */ jsx8(Text8, { color: "green", children: "\u2713 " }),
2257
+ /* @__PURE__ */ jsx8(Text8, { children: elideMiddle(o.circuitName, 42).padEnd(44) }),
2258
+ /* @__PURE__ */ jsx8(Text8, { color: "cyan", children: o.hash ? o.hash.slice(0, 16) + "\u2026" : "" })
2259
+ ] }, i))
2260
+ ] }),
2261
+ failed.length > 0 && /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2262
+ /* @__PURE__ */ jsx8(Text8, { color: "yellow", children: "Failed (retry already attempted):" }),
2263
+ failed.map((o, i) => /* @__PURE__ */ jsxs8(Box8, { paddingLeft: 2, flexDirection: "column", children: [
2264
+ /* @__PURE__ */ jsxs8(Box8, { children: [
2265
+ /* @__PURE__ */ jsx8(Text8, { color: "red", children: "\u2717 " }),
2266
+ /* @__PURE__ */ jsx8(Text8, { children: elideMiddle(o.circuitName, 42) })
2267
+ ] }),
2268
+ /* @__PURE__ */ jsx8(Box8, { paddingLeft: 4, children: /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: o.reason ?? "(no reason recorded)" }) })
2269
+ ] }, i)),
2270
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "\u232B Backspace / B \u2014 back to track list (you can retry failed circuits manually)" })
2271
+ ] }),
2272
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "Q / Ctrl+C / Ctrl+Z \u2014 quit" })
2273
+ ] })
1163
2274
  ] });
1164
2275
  }
1165
2276
  if (screen.name === "error") {
1166
2277
  const backHint = !initialCeremonyId ? "\u232B Backspace \u2014 back to ceremony list \xB7 Q to quit" : screen.recoverable ? "\u232B Backspace / B to go back \xB7 Q to quit" : "Q to quit";
1167
- return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1168
- /* @__PURE__ */ jsx7(Header, { ceremony }),
1169
- /* @__PURE__ */ jsxs7(Text7, { color: "red", bold: true, children: [
2278
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2279
+ /* @__PURE__ */ jsx8(Header, { ceremony }),
2280
+ /* @__PURE__ */ jsxs8(Text8, { color: "red", bold: true, children: [
1170
2281
  "\u2717 ",
1171
2282
  screen.message
1172
2283
  ] }),
1173
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: backHint })
2284
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: backHint })
1174
2285
  ] });
1175
2286
  }
1176
2287
  if (screen.name === "tracks") {
1177
2288
  const { tracks } = screen;
1178
2289
  const openTracks = tracks.filter((t) => t.status === "open");
1179
- const myContributions = Object.values(contributed).filter(
1180
- (c) => c.ceremonyId === activeCeremonyId
1181
- );
1182
- const TabBar = () => /* @__PURE__ */ jsxs7(Box7, { gap: 1, marginBottom: 1, children: [
1183
- /* @__PURE__ */ jsx7(Text7, { bold: tab === 0, color: tab === 0 ? "cyan" : void 0, dimColor: tab !== 0, children: tab === 0 ? "[ Dashboard ]" : " Dashboard " }),
1184
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "|" }),
1185
- /* @__PURE__ */ jsx7(Text7, { bold: tab === 1, color: tab === 1 ? "cyan" : void 0, dimColor: tab !== 1, children: tab === 1 ? `[ My Contributions (${myContributions.length}) ]` : ` My Contributions (${myContributions.length}) ` }),
1186
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: " Tab to switch" })
2290
+ const myContributions = Object.values(contributed).flat().filter((c) => c.ceremonyId === activeCeremonyId).sort((a, b) => {
2291
+ const ta = a.verifiedAt ? Date.parse(a.verifiedAt) : 0;
2292
+ const tb = b.verifiedAt ? Date.parse(b.verifiedAt) : 0;
2293
+ return tb - ta;
2294
+ });
2295
+ const TabBar = () => /* @__PURE__ */ jsxs8(Box8, { gap: 1, marginBottom: 1, children: [
2296
+ /* @__PURE__ */ jsx8(Text8, { bold: tab === 0, color: tab === 0 ? "cyan" : void 0, dimColor: tab !== 0, children: tab === 0 ? "[ Dashboard ]" : " Dashboard " }),
2297
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "|" }),
2298
+ /* @__PURE__ */ jsx8(Text8, { bold: tab === 1, color: tab === 1 ? "cyan" : void 0, dimColor: tab !== 1, children: tab === 1 ? `[ My Contributions (${myContributions.length}) ]` : ` My Contributions (${myContributions.length}) ` }),
2299
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: " Tab to switch" })
1187
2300
  ] });
1188
- return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1189
- /* @__PURE__ */ jsx7(Header, { ceremony }),
1190
- /* @__PURE__ */ jsx7(TabBar, {}),
2301
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2302
+ /* @__PURE__ */ jsx8(Header, { ceremony }),
2303
+ /* @__PURE__ */ jsx8(TabBar, {}),
1191
2304
  tab === 0 ? (
1192
2305
  // ── Dashboard tab ────────────────────────────────────────────────
1193
- /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1194
- /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
1195
- " CIRCUIT".padEnd(30),
1196
- "TOTAL".padEnd(10),
2306
+ // CIRCUIT column is sized to fit the longest mainnet name
2307
+ // (`claim-deposit-into-confidential-amount-n4` = 41 chars). Names
2308
+ // longer than that are middle-elided so the disambiguating suffix
2309
+ // (-n1/-n2/-n4) stays visible — previously a hard slice(0,24)
2310
+ // showed every claim variant as `claim-deposit-into-confi..`.
2311
+ /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2312
+ /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
2313
+ " CIRCUIT".padEnd(46),
2314
+ "TOTAL".padEnd(8),
1197
2315
  "QUEUE".padEnd(8),
1198
- "STATUS".padEnd(16),
2316
+ "STATUS".padEnd(14),
1199
2317
  "MY CONTRIBUTIONS"
1200
2318
  ] }),
1201
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: " " + "\u2500".repeat(70) }),
2319
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: " " + "\u2500".repeat(90) }),
1202
2320
  tracks.map((t, i) => {
1203
2321
  const isSelected = i === selectedIdx;
1204
2322
  const canContribute = t.status === "open";
1205
- const nameClipped = t.circuit_name.length > 26 ? t.circuit_name.slice(0, 24) + ".." : t.circuit_name;
2323
+ const nameDisplay = elideMiddle(t.circuit_name, 42);
1206
2324
  const statusColor = t.status === "open" ? "green" : t.status === "finalized" ? "cyan" : "yellow";
1207
- const myContrib = contributed[t.id];
1208
- return /* @__PURE__ */ jsxs7(Box7, { children: [
1209
- /* @__PURE__ */ jsxs7(Text7, { color: isSelected ? "cyan" : canContribute ? void 0 : "gray", children: [
2325
+ const myRounds = roundCount(contributed, t.id);
2326
+ return /* @__PURE__ */ jsxs8(Box8, { children: [
2327
+ /* @__PURE__ */ jsxs8(Text8, { color: isSelected ? "cyan" : canContribute ? void 0 : "gray", children: [
1210
2328
  isSelected ? "\u25B6 " : " ",
1211
- nameClipped.padEnd(28),
1212
- String(t.contribution_count).padEnd(10),
2329
+ nameDisplay.padEnd(44),
2330
+ String(t.contribution_count).padEnd(8),
1213
2331
  String(t.queue_depth).padEnd(8)
1214
2332
  ] }),
1215
- /* @__PURE__ */ jsx7(Text7, { color: statusColor, children: trackStatusLabel(t.status).padEnd(16) }),
1216
- myContrib ? isSelected ? /* @__PURE__ */ jsxs7(Text7, { color: "green", children: [
1217
- "\u2713 contributed (round #",
1218
- myContrib.sequenceNumber,
1219
- ") \u2014 already done"
1220
- ] }) : /* @__PURE__ */ jsxs7(Text7, { color: "green", children: [
1221
- "\u2713 contributed (round #",
1222
- myContrib.sequenceNumber,
2333
+ /* @__PURE__ */ jsx8(Text8, { color: statusColor, children: trackStatusLabel(t.status).padEnd(14) }),
2334
+ myRounds > 0 ? canContribute && isSelected ? /* @__PURE__ */ jsxs8(Text8, { color: "green", children: [
2335
+ "\u2713 contributed (",
2336
+ myRounds,
2337
+ " round",
2338
+ myRounds === 1 ? "" : "s",
2339
+ ") \u2014",
2340
+ " ",
2341
+ /* @__PURE__ */ jsx8(Text8, { color: "yellow", children: "Enter to add more" })
2342
+ ] }) : /* @__PURE__ */ jsxs8(Text8, { color: "green", children: [
2343
+ "\u2713 contributed (",
2344
+ myRounds,
2345
+ " round",
2346
+ myRounds === 1 ? "" : "s",
1223
2347
  ")"
1224
- ] }) : canContribute ? isSelected ? /* @__PURE__ */ jsx7(Text7, { color: "yellow", children: "\u2190 Enter to contribute" }) : /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "not contributed" }) : /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "\u2014" })
2348
+ ] }) : canContribute ? isSelected ? /* @__PURE__ */ jsx8(Text8, { color: "yellow", children: "\u2190 Enter to contribute" }) : /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "not contributed" }) : /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "\u2014" })
1225
2349
  ] }, t.id);
1226
2350
  }),
1227
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: " " + "\u2500".repeat(70) }),
1228
- openTracks.length === 0 ? /* @__PURE__ */ jsx7(Text7, { color: "yellow", children: "No circuits are open for contributions right now \u2014 the ceremony may be closing or already complete." }) : /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2351
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: " " + "\u2500".repeat(70) }),
2352
+ openTracks.length === 0 ? /* @__PURE__ */ jsx8(Text8, { color: "yellow", children: "No circuits are open for contributions right now \u2014 the ceremony may be closing or already complete." }) : /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
1229
2353
  "\u2191/\u2193 select \xB7 Enter contribute \xB7 R refresh \xB7 Q quit",
1230
2354
  !initialCeremonyId ? " \xB7 \u232B back to ceremony list" : ""
1231
2355
  ] })
1232
2356
  ] })
1233
2357
  ) : (
1234
2358
  // ── My Contributions tab ─────────────────────────────────────────
1235
- /* @__PURE__ */ jsx7(Box7, { flexDirection: "column", children: myContributions.length === 0 ? /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", gap: 1, children: [
1236
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "No contributions yet." }),
1237
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "Switch to Dashboard tab and press Enter on a circuit to contribute." })
1238
- ] }) : /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1239
- /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
1240
- " CIRCUIT".padEnd(28),
1241
- "ROUND".padEnd(8),
1242
- "HASH".padEnd(20),
1243
- "TIME"
2359
+ /* @__PURE__ */ jsx8(Box8, { flexDirection: "column", children: myContributions.length === 0 ? /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", gap: 1, children: [
2360
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "No contributions yet." }),
2361
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "Switch to Dashboard tab and press Enter on a circuit to contribute." })
2362
+ ] }) : /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2363
+ /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
2364
+ " CIRCUIT".padEnd(44),
2365
+ "ROUND".padEnd(7),
2366
+ "VERIFIED AT".padEnd(22)
1244
2367
  ] }),
1245
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: " " + "\u2500".repeat(68) }),
1246
- myContributions.map((c, i) => /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1247
- /* @__PURE__ */ jsxs7(Box7, { children: [
1248
- /* @__PURE__ */ jsx7(Text7, { color: "green", children: " \u2713 " }),
1249
- /* @__PURE__ */ jsx7(Text7, { bold: true, children: c.circuitName.padEnd(24) }),
1250
- /* @__PURE__ */ jsx7(Text7, { color: "yellow", children: "#" + c.sequenceNumber + " " }),
1251
- /* @__PURE__ */ jsx7(Text7, { color: "cyan", children: c.contributionHash ? c.contributionHash.slice(0, 16) + "..." : "(pending)" })
1252
- ] }),
1253
- /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
1254
- " ",
1255
- c.verifiedAt ? new Date(c.verifiedAt).toLocaleString() : ""
1256
- ] })
1257
- ] }, i)),
1258
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: " " + "\u2500".repeat(68) }),
1259
- /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2368
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: " " + "\u2500".repeat(90) }),
2369
+ myContributions.map((c, i) => {
2370
+ const isSel = i === contribCursor;
2371
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2372
+ /* @__PURE__ */ jsxs8(Box8, { children: [
2373
+ /* @__PURE__ */ jsx8(Text8, { color: isSel ? "cyan" : "green", children: isSel ? "\u25B6 " : " " }),
2374
+ /* @__PURE__ */ jsx8(Text8, { bold: isSel, children: elideMiddle(c.circuitName, 40).padEnd(42) }),
2375
+ /* @__PURE__ */ jsx8(Text8, { color: "yellow", children: ("#" + c.sequenceNumber).padEnd(7) }),
2376
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: c.verifiedAt ? new Date(c.verifiedAt).toLocaleString() : "\u2014" })
2377
+ ] }),
2378
+ /* @__PURE__ */ jsx8(Box8, { paddingLeft: 4, children: /* @__PURE__ */ jsxs8(Text8, { color: isSel ? "cyan" : "gray", dimColor: !isSel, children: [
2379
+ "hash:",
2380
+ " ",
2381
+ c.contributionHash ? c.contributionHash : "(pending \u2014 verify still in flight)"
2382
+ ] }) })
2383
+ ] }, i);
2384
+ }),
2385
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: " " + "\u2500".repeat(90) }),
2386
+ copyToast ? /* @__PURE__ */ jsx8(Text8, { color: "green", children: copyToast }) : /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
1260
2387
  "Total: ",
1261
2388
  myContributions.length,
1262
2389
  " contribution",
1263
2390
  myContributions.length !== 1 ? "s" : "",
1264
2391
  " \xB7 ",
1265
- "Tab to switch \xB7 Q to quit"
2392
+ "\u2191/\u2193 select \xB7 C copy hash \xB7 Tab switch \xB7 Q quit"
1266
2393
  ] })
1267
2394
  ] }) })
1268
2395
  )
1269
2396
  ] });
1270
2397
  }
1271
2398
  if (screen.name === "joining") {
1272
- return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1273
- /* @__PURE__ */ jsx7(Header, { ceremony }),
1274
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "Joining queue..." })
2399
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2400
+ /* @__PURE__ */ jsx8(Header, { ceremony }),
2401
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "Joining queue..." }),
2402
+ /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "Q / Ctrl+C / Ctrl+Z to quit" }) })
1275
2403
  ] });
1276
2404
  }
1277
2405
  if (screen.name === "queue") {
1278
2406
  const { trackId, circuitName } = screen;
1279
- const priorContrib = contributed[trackId];
1280
- return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1281
- /* @__PURE__ */ jsx7(Header, { ceremony, subtitle: `Circuit: ${circuitName}` }),
1282
- priorContrib && /* @__PURE__ */ jsx7(Box7, { marginBottom: 1, paddingX: 1, children: /* @__PURE__ */ jsxs7(Text7, { color: "yellow", children: [
1283
- "\u26A0 You already contributed to this circuit (round #",
1284
- priorContrib.sequenceNumber,
1285
- ").",
1286
- " ",
1287
- "Contributing again is allowed and adds more entropy."
2407
+ const priorRounds = roundCount(contributed, trackId);
2408
+ const lastContrib = latestContribution(contributed, trackId);
2409
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2410
+ /* @__PURE__ */ jsx8(Header, { ceremony, subtitle: `Circuit: ${circuitName}` }),
2411
+ priorRounds > 0 && lastContrib && /* @__PURE__ */ jsx8(Box8, { marginBottom: 1, paddingX: 1, children: /* @__PURE__ */ jsxs8(Text8, { color: "yellow", children: [
2412
+ "\u26A0 You've contributed to this circuit ",
2413
+ priorRounds,
2414
+ " time",
2415
+ priorRounds === 1 ? "" : "s",
2416
+ " (latest: round #",
2417
+ lastContrib.sequenceNumber,
2418
+ "). Each additional round adds more entropy and strengthens the ceremony \u2014 go ahead."
1288
2419
  ] }) }),
1289
- /* @__PURE__ */ jsx7(
2420
+ /* @__PURE__ */ jsx8(
1290
2421
  QueueView,
1291
2422
  {
1292
2423
  ceremonyId: activeCeremonyId,
@@ -1296,27 +2427,39 @@ function App({ ceremonyId: initialCeremonyId, displayName: initialDisplayName })
1296
2427
  onError: (e) => setScreen({ name: "error", message: e.message, recoverable: true })
1297
2428
  }
1298
2429
  ),
1299
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "\u232B Backspace to go back \xB7 Q to quit" })
2430
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "\u232B Backspace to go back \xB7 Q to quit" })
1300
2431
  ] });
1301
2432
  }
1302
2433
  if (screen.name === "entropy") {
1303
2434
  const { trackId, circuitName, slotStatus } = screen;
1304
- return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1305
- /* @__PURE__ */ jsx7(Header, { ceremony, subtitle: `Circuit: ${circuitName} \xB7 Your turn!` }),
1306
- /* @__PURE__ */ jsx7(
1307
- EntropyCollector,
2435
+ const onComplete = (entropy) => setScreen({ name: "contribute", trackId, circuitName, slotStatus, entropy });
2436
+ const onError = (e) => setScreen({ name: "error", message: e.message, recoverable: false });
2437
+ if (entropyMode === "mouse") {
2438
+ return /* @__PURE__ */ jsx8(
2439
+ MouseEntropyCollector,
1308
2440
  {
1309
- onComplete: (entropy) => setScreen({ name: "contribute", trackId, circuitName, slotStatus, entropy }),
1310
- onError: (e) => setScreen({ name: "error", message: e.message, recoverable: false })
2441
+ title: `Your turn \xB7 ${circuitName} \u2014 draw entropy for this contribution`,
2442
+ onComplete,
2443
+ onError
1311
2444
  }
1312
- )
2445
+ );
2446
+ }
2447
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2448
+ /* @__PURE__ */ jsx8(
2449
+ Header,
2450
+ {
2451
+ ceremony,
2452
+ subtitle: `Circuit: ${circuitName} \xB7 Type to collect entropy`
2453
+ }
2454
+ ),
2455
+ /* @__PURE__ */ jsx8(EntropyCollector, { onComplete, onError })
1313
2456
  ] });
1314
2457
  }
1315
2458
  if (screen.name === "contribute") {
1316
2459
  const { trackId, circuitName, slotStatus, entropy } = screen;
1317
- return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1318
- /* @__PURE__ */ jsx7(Header, { ceremony, subtitle: `Circuit: ${circuitName} \xB7 Contributing` }),
1319
- /* @__PURE__ */ jsx7(
2460
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2461
+ /* @__PURE__ */ jsx8(Header, { ceremony, subtitle: `Circuit: ${circuitName} \xB7 Contributing` }),
2462
+ /* @__PURE__ */ jsx8(
1320
2463
  ContributeFlow,
1321
2464
  {
1322
2465
  ceremonyId: activeCeremonyId,
@@ -1335,7 +2478,13 @@ function App({ ceremonyId: initialCeremonyId, displayName: initialDisplayName })
1335
2478
  verifiedAt: receipt?.verified_at ?? (/* @__PURE__ */ new Date()).toISOString()
1336
2479
  };
1337
2480
  await recordContribution(trackId, local);
1338
- setContributed((prev) => ({ ...prev, [trackId]: local }));
2481
+ setContributed((prev) => {
2482
+ const existing = prev[trackId] ?? [];
2483
+ if (existing.some((c) => c.contributionId === local.contributionId)) {
2484
+ return prev;
2485
+ }
2486
+ return { ...prev, [trackId]: [...existing, local] };
2487
+ });
1339
2488
  setScreen({ name: "done", contribution: local });
1340
2489
  },
1341
2490
  onError: (e) => setScreen({ name: "error", message: e.message, recoverable: false })
@@ -1345,41 +2494,47 @@ function App({ ceremonyId: initialCeremonyId, displayName: initialDisplayName })
1345
2494
  }
1346
2495
  if (screen.name === "done") {
1347
2496
  const { contribution } = screen;
1348
- return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1349
- /* @__PURE__ */ jsx7(Header, { ceremony }),
1350
- /* @__PURE__ */ jsx7(Attestation, { contribution }),
1351
- /* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "\u232B Backspace / B = contribute to another circuit \xB7 Q to quit" }) })
2497
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2498
+ /* @__PURE__ */ jsx8(Header, { ceremony }),
2499
+ /* @__PURE__ */ jsx8(Attestation, { contribution }),
2500
+ /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "\u232B Backspace / B = contribute to another circuit \xB7 Q to quit" }) })
1352
2501
  ] });
1353
2502
  }
1354
2503
  return null;
1355
2504
  }
1356
2505
 
1357
2506
  // src/index.tsx
1358
- import { jsx as jsx8 } from "react/jsx-runtime";
2507
+ import { jsx as jsx9 } from "react/jsx-runtime";
1359
2508
  var ceremonyId = process.env["CEREMONY_ID"] ?? "";
1360
2509
  var displayName = process.env["CONTRIBUTOR_NAME"];
1361
2510
  process.stdout.write("\x1B[?1049h\x1B[H");
1362
2511
  function restoreScreen() {
1363
2512
  process.stdout.write("\x1B[?1049l");
1364
2513
  }
1365
- async function gracefulExit(code = 0) {
2514
+ var exiting = false;
2515
+ async function gracefulExit(code = 0, deliberate = false) {
2516
+ if (exiting) {
2517
+ process.exit(code);
2518
+ }
2519
+ exiting = true;
1366
2520
  restoreScreen();
1367
2521
  await runQueueCleanup();
2522
+ if (deliberate) {
2523
+ await runSessionCleanup();
2524
+ }
1368
2525
  process.exit(code);
1369
2526
  }
1370
- process.on("SIGINT", () => {
1371
- gracefulExit(0).catch(() => {
1372
- restoreScreen();
1373
- process.exit(0);
1374
- });
1375
- });
1376
- process.on("SIGTERM", () => {
1377
- gracefulExit(0).catch(() => {
1378
- restoreScreen();
1379
- process.exit(0);
2527
+ globalThis.__ceremonyGracefulExit = gracefulExit;
2528
+ var DELIBERATE_SIGNALS = /* @__PURE__ */ new Set(["SIGINT", "SIGTSTP"]);
2529
+ for (const signal of ["SIGINT", "SIGTERM", "SIGTSTP", "SIGHUP"]) {
2530
+ process.on(signal, () => {
2531
+ gracefulExit(0, DELIBERATE_SIGNALS.has(signal)).catch(() => {
2532
+ restoreScreen();
2533
+ process.exit(0);
2534
+ });
1380
2535
  });
1381
- });
1382
- var { waitUntilExit } = render(/* @__PURE__ */ jsx8(App, { ceremonyId, displayName }), {
2536
+ }
2537
+ var { waitUntilExit } = render(/* @__PURE__ */ jsx9(App, { ceremonyId, displayName }), {
1383
2538
  exitOnCtrlC: false
1384
2539
  });
1385
2540
  await waitUntilExit();