@umbra-privacy/ceremony 0.2.8 → 0.2.10

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 +1371 -321
  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";
@@ -277,18 +313,27 @@ function trackStatusLabel(status) {
277
313
  }
278
314
  }
279
315
 
316
+ // src/theme.ts
317
+ var COLORS = {
318
+ /** Primary Umbra brand colour — #00B3FF. */
319
+ BRAND: "#00B3FF",
320
+ SUCCESS: "greenBright",
321
+ EMPHASIS: "cyanBright",
322
+ MUTED: "cyan"
323
+ };
324
+
280
325
  // src/components/Header.tsx
281
326
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
282
327
  var STATUS_COLOR = {
283
328
  open: "green",
284
329
  initialized: "yellow",
285
330
  finalizing: "yellow",
286
- completed: "cyan"
331
+ completed: COLORS.BRAND
287
332
  };
288
333
  function Header({ ceremony, subtitle }) {
289
334
  return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [
290
335
  /* @__PURE__ */ jsxs(Box, { children: [
291
- /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: "\u25C6 Umbra Ceremony TUI" }),
336
+ /* @__PURE__ */ jsx(Text, { bold: true, color: COLORS.BRAND, children: "\u25C6 Umbra Ceremony" }),
292
337
  ceremony && /* @__PURE__ */ jsxs(Fragment, { children: [
293
338
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: " \xB7 " }),
294
339
  /* @__PURE__ */ jsx(Text, { bold: true, children: ceremony.name }),
@@ -297,7 +342,7 @@ function Header({ ceremony, subtitle }) {
297
342
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: "]" })
298
343
  ] })
299
344
  ] }),
300
- /* @__PURE__ */ jsx(Text, { color: "cyan", children: "Phase 2 trusted-setup contribution \xB7 Groth16 / BN254" }),
345
+ /* @__PURE__ */ jsx(Text, { color: COLORS.BRAND, children: "Phase 2 trusted-setup contribution \xB7 Groth16 / BN254" }),
301
346
  /* @__PURE__ */ jsx(Text, { color: "gray", children: "You are adding fresh entropy to Umbra's circuit proving keys. The on-chain privacy of every deposit, claim and transfer holds as long as ONE contributor in this ceremony destroys their secret." }),
302
347
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Press ? at any time for a full explanation of what is happening." }),
303
348
  ceremony && /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
@@ -498,37 +543,46 @@ function QueueView({ ceremonyId: ceremonyId2, trackId, token, onReady, onError }
498
543
  minute: "2-digit"
499
544
  }) : null;
500
545
  const fastPoll = status.queue_position <= 2;
546
+ const aheadCount = Math.max(0, status.queue_position - 1);
547
+ const peopleWord = aheadCount === 1 ? "person" : "people";
501
548
  return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", gap: 1, children: [
502
549
  /* @__PURE__ */ jsxs2(Box2, { gap: 2, children: [
503
- /* @__PURE__ */ jsxs2(Text2, { children: [
504
- "Position",
550
+ aheadCount === 0 ? /* @__PURE__ */ jsxs2(Text2, { children: [
551
+ /* @__PURE__ */ jsx2(Text2, { bold: true, color: "green", children: "You're next" }),
552
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: " in line" })
553
+ ] }) : /* @__PURE__ */ jsxs2(Text2, { children: [
554
+ /* @__PURE__ */ jsx2(Text2, { bold: true, color: "yellow", children: aheadCount }),
505
555
  " ",
506
- /* @__PURE__ */ jsx2(Text2, { bold: true, color: "yellow", children: status.queue_position }),
507
- status.queue_depth > 0 && /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
508
- " of ",
509
- status.queue_depth
556
+ peopleWord,
557
+ " ahead of you"
558
+ ] }),
559
+ status.queue_depth > 1 && /* @__PURE__ */ jsxs2(Fragment2, { children: [
560
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "\xB7" }),
561
+ /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
562
+ status.queue_depth,
563
+ " in queue"
510
564
  ] })
511
565
  ] }),
512
566
  /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "\xB7" }),
513
567
  /* @__PURE__ */ jsxs2(Text2, { children: [
514
568
  "Estimated wait:",
515
569
  " ",
516
- /* @__PURE__ */ jsxs2(Text2, { bold: true, color: "cyan", children: [
517
- "~",
518
- waitMins,
519
- " min"
520
- ] })
570
+ /* @__PURE__ */ jsx2(Text2, { bold: true, color: "cyan", children: aheadCount === 0 ? "any moment now" : `~${waitMins} min` })
521
571
  ] })
522
572
  ] }),
523
- 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: [
573
+ 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: [
524
574
  "Another contributor is active",
525
575
  expiresAt ? ` \xB7 slot expires at ${expiresAt}` : ""
526
- ] }) }) : status.queue_position > 1 ? (
576
+ ] }) }) : aheadCount > 0 ? (
527
577
  // Slot is idle but people are ahead — they joined and left without releasing.
528
578
  // timeout_watchdog will clear each stale slot after contribution_timeout_secs.
529
- /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "Slot is idle \u2014 waiting for positions ahead to respond or time out (up to ~5 min each)" })
579
+ /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
580
+ "Slot is idle \u2014 waiting for ",
581
+ peopleWord,
582
+ " ahead to respond or time out (up to ~5 min each)"
583
+ ] })
530
584
  ) : (
531
- // Position 1, slot idle — advance_queue should fire shortly.
585
+ // At the front of the line, slot idle — advance_queue should fire shortly.
532
586
  /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "Slot is idle \u2014 your turn is being prepared..." })
533
587
  ),
534
588
  pollErr ? /* @__PURE__ */ jsxs2(Text2, { color: "red", children: [
@@ -569,25 +623,38 @@ function TerminalSlotMessage({ status }) {
569
623
  /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: msg.detail })
570
624
  ] });
571
625
  }
572
- function ExportingMessage({ status }) {
626
+ function ExportingMessage({
627
+ status,
628
+ slotExpiresAt
629
+ }) {
573
630
  const exportingMsg = useCyclingMessage(EXPORTING_MESSAGES, 2500, status === "exporting");
574
- return /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", children: status === "exporting" ? /* @__PURE__ */ jsxs2(Fragment2, { children: [
575
- /* @__PURE__ */ jsxs2(Text2, { color: "green", children: [
576
- /* @__PURE__ */ jsx2(Spinner, { type: "dots" }),
577
- " ",
578
- exportingMsg,
579
- "\u2026"
580
- ] }),
581
- /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "Your slot is active. The worker is preparing the challenge file just for you." })
582
- ] }) : /* @__PURE__ */ jsx2(Text2, { color: "green", children: "Challenge ready \u2014 loading contribution flow\u2026" }) });
631
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
632
+ status === "exporting" ? /* @__PURE__ */ jsxs2(Fragment2, { children: [
633
+ /* @__PURE__ */ jsxs2(Text2, { color: "green", children: [
634
+ /* @__PURE__ */ jsx2(Spinner, { type: "dots" }),
635
+ " ",
636
+ exportingMsg,
637
+ "\u2026"
638
+ ] }),
639
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "Your slot is active. The worker is preparing the challenge file just for you." })
640
+ ] }) : /* @__PURE__ */ jsx2(Text2, { color: "green", children: "Challenge ready \u2014 loading contribution flow\u2026" }),
641
+ slotExpiresAt && // Visible countdown is critical on power-18 circuits where the
642
+ // contributor compute window can run several minutes — without
643
+ // this they have no way to tell if they're about to be reaped.
644
+ /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
645
+ "Your slot expires at ",
646
+ slotExpiresAt,
647
+ " (server time)."
648
+ ] })
649
+ ] });
583
650
  }
584
651
 
585
- // src/components/EntropyCollector.tsx
586
- import { useRef as useRef2, useState as useState3 } from "react";
652
+ // src/components/MouseEntropyCollector.tsx
653
+ import { useEffect as useEffect3, useRef as useRef2, useState as useState3 } from "react";
587
654
  import { Box as Box3, Text as Text3, useInput } from "ink";
588
655
 
589
656
  // src/entropy.ts
590
- import { createHash as createHash2, randomBytes } from "crypto";
657
+ import { createHash as createHash2, hkdfSync, randomBytes } from "crypto";
591
658
  function sha512(buf) {
592
659
  return createHash2("sha512").update(buf).digest();
593
660
  }
@@ -599,18 +666,405 @@ function buildEntropyFromKeystrokes(chars, timingsNs) {
599
666
  const osHash = sha512(randomBytes(64));
600
667
  return createHash2("sha512").update(keystrokeHash).update(osHash).digest("hex");
601
668
  }
669
+ var TRAIL_MIN_BYTES = 64;
670
+ function buildEntropyFromMouseTrail(strokes) {
671
+ const strokeBuf = Buffer.alloc(strokes.length * 12);
672
+ for (let i = 0; i < strokes.length; i++) {
673
+ const s = strokes[i];
674
+ strokeBuf.writeUInt16BE(s.x & 65535, i * 12);
675
+ strokeBuf.writeUInt16BE(s.y & 65535, i * 12 + 2);
676
+ strokeBuf.writeBigUInt64BE(s.dtNs, i * 12 + 4);
677
+ }
678
+ const padBytes = Math.max(0, TRAIL_MIN_BYTES - strokeBuf.length);
679
+ const trailInput = padBytes > 0 ? Buffer.concat([strokeBuf, randomBytes(padBytes)]) : strokeBuf;
680
+ const trailHash = sha512(trailInput);
681
+ const osHash = sha512(randomBytes(64));
682
+ return createHash2("sha512").update(trailHash).update(osHash).digest("hex");
683
+ }
684
+ function deriveCircuitEntropy(masterSeed, ceremonyId2, circuitName) {
685
+ const ikm = Buffer.from(masterSeed, "hex");
686
+ const salt = createHash2("sha256").update(ceremonyId2).digest();
687
+ const info = Buffer.from(`umbra-ceremony-${ceremonyId2}-circuit-${circuitName}`, "utf8");
688
+ const derived = Buffer.from(hkdfSync("sha512", ikm, salt, info, 64));
689
+ return derived.toString("hex");
690
+ }
602
691
 
603
- // src/components/EntropyCollector.tsx
692
+ // src/mouse.ts
693
+ var SGR_RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
694
+ function classifyButton(buttonCode) {
695
+ if (buttonCode & 32) return "motion";
696
+ const base = buttonCode & 3;
697
+ if (base === 0) return "left";
698
+ if (base === 1) return "middle";
699
+ if (base === 2) return "right";
700
+ return "other";
701
+ }
702
+ function parseMouseChunk(chunk) {
703
+ const s = typeof chunk === "string" ? chunk : chunk.toString("binary");
704
+ const events = [];
705
+ SGR_RE.lastIndex = 0;
706
+ let m;
707
+ while ((m = SGR_RE.exec(s)) !== null) {
708
+ const buttonCode = parseInt(m[1], 10);
709
+ const col = parseInt(m[2], 10) - 1;
710
+ const row = parseInt(m[3], 10) - 1;
711
+ events.push({
712
+ button: classifyButton(buttonCode),
713
+ col,
714
+ row,
715
+ isRelease: m[4] === "m"
716
+ });
717
+ }
718
+ return events;
719
+ }
720
+ function enableMouseReporting() {
721
+ process.stdout.write("\x1B[?1003h\x1B[?1006h");
722
+ return () => {
723
+ process.stdout.write("\x1B[?1003l\x1B[?1006l");
724
+ };
725
+ }
726
+
727
+ // src/components/MouseEntropyCollector.tsx
604
728
  import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
729
+ var LEFT_OFFSET = 1;
730
+ var TOP_OFFSET = 2;
731
+ var MIN_W = 18;
732
+ var MIN_H = 12;
733
+ var MAX_W = 80;
734
+ var MAX_H = 32;
735
+ var DEFAULT_ASPECT = 2.2;
736
+ var ASPECT = (() => {
737
+ const raw = process.env["CEREMONY_CANVAS_ASPECT"];
738
+ if (!raw) return DEFAULT_ASPECT;
739
+ const parsed = Number.parseFloat(raw);
740
+ if (!Number.isFinite(parsed) || parsed < 0.5 || parsed > 6) return DEFAULT_ASPECT;
741
+ return parsed;
742
+ })();
743
+ var CHROME_ROWS = 6;
744
+ var CHROME_COLS = 2;
745
+ var MIN_ELAPSED_MS = 3e3;
746
+ var RENDER_INTERVAL_MS = 33;
747
+ function deriveCanvasSize(cols, rows) {
748
+ const availH = rows - CHROME_ROWS;
749
+ const availW = cols - CHROME_COLS;
750
+ let h = Math.min(MAX_H, availH);
751
+ let w = Math.round(h * ASPECT);
752
+ const wCap = Math.min(MAX_W, availW);
753
+ if (w > wCap) {
754
+ w = wCap;
755
+ h = Math.round(w / ASPECT);
756
+ }
757
+ if (w < MIN_W || h < MIN_H) return null;
758
+ return { w, h };
759
+ }
760
+ function readTerminalDims() {
761
+ return {
762
+ cols: process.stdout.columns ?? 80,
763
+ rows: process.stdout.rows ?? 24
764
+ };
765
+ }
766
+ function MouseEntropyCollector({ title, onComplete, onError }) {
767
+ const [dims, setDims] = useState3(() => {
768
+ const { cols, rows } = readTerminalDims();
769
+ return deriveCanvasSize(cols, rows);
770
+ });
771
+ const dimsRef = useRef2(dims);
772
+ useEffect3(() => {
773
+ dimsRef.current = dims;
774
+ }, [dims]);
775
+ const pixelsRef = useRef2(/* @__PURE__ */ new Set());
776
+ const strokesRef = useRef2([]);
777
+ const mouseDownRef = useRef2(false);
778
+ const lastPaintRef = useRef2(null);
779
+ const cursorRef = useRef2(null);
780
+ const lastStrokeTsRef = useRef2(process.hrtime.bigint());
781
+ const startTsRef = useRef2(process.hrtime.bigint());
782
+ const completedRef = useRef2(false);
783
+ const [pixelCount, setPixelCount] = useState3(0);
784
+ const [elapsedMs, setElapsedMs] = useState3(0);
785
+ const [done, setDone] = useState3(false);
786
+ const [nudge, setNudge] = useState3(null);
787
+ const [grid, setGrid] = useState3(
788
+ () => dims ? Array.from({ length: dims.h }, () => " ".repeat(dims.w)) : []
789
+ );
790
+ const [cursor, setCursor] = useState3(null);
791
+ const canvasW = dims?.w ?? 0;
792
+ const canvasH = dims?.h ?? 0;
793
+ const subH = canvasH * 2;
794
+ function idx(x, y) {
795
+ return y * canvasW + x;
796
+ }
797
+ function paintLine(pixels, x0, y0, x1, y1, onNew) {
798
+ let dx = Math.abs(x1 - x0);
799
+ let dy = -Math.abs(y1 - y0);
800
+ const sx = x0 < x1 ? 1 : -1;
801
+ const sy = y0 < y1 ? 1 : -1;
802
+ let err = dx + dy;
803
+ let x = x0;
804
+ let y = y0;
805
+ while (true) {
806
+ if (x >= 0 && x < canvasW && y >= 0 && y < subH) {
807
+ const id = idx(x, y);
808
+ if (!pixels.has(id)) {
809
+ pixels.add(id);
810
+ onNew(x, y);
811
+ }
812
+ }
813
+ if (x === x1 && y === y1) break;
814
+ const e2 = 2 * err;
815
+ if (e2 >= dy) {
816
+ if (x === x1) break;
817
+ err += dy;
818
+ x += sx;
819
+ }
820
+ if (e2 <= dx) {
821
+ if (y === y1) break;
822
+ err += dx;
823
+ y += sy;
824
+ }
825
+ }
826
+ }
827
+ useEffect3(() => {
828
+ const onResize = () => {
829
+ const { cols, rows } = readTerminalDims();
830
+ const next = deriveCanvasSize(cols, rows);
831
+ const cur = dimsRef.current;
832
+ if (next === null && cur === null) return;
833
+ if (next !== null && cur !== null && next.w === cur.w && next.h === cur.h) return;
834
+ pixelsRef.current.clear();
835
+ strokesRef.current = [];
836
+ lastPaintRef.current = null;
837
+ cursorRef.current = null;
838
+ startTsRef.current = process.hrtime.bigint();
839
+ lastStrokeTsRef.current = startTsRef.current;
840
+ setNudge(null);
841
+ setPixelCount(0);
842
+ setElapsedMs(0);
843
+ setCursor(null);
844
+ setGrid(next ? Array.from({ length: next.h }, () => " ".repeat(next.w)) : []);
845
+ setDims(next);
846
+ };
847
+ process.stdout.on("resize", onResize);
848
+ return () => {
849
+ process.stdout.off("resize", onResize);
850
+ };
851
+ }, []);
852
+ useEffect3(() => {
853
+ if (!dims) return;
854
+ const disableMouse = enableMouseReporting();
855
+ const onData = (chunk) => {
856
+ if (completedRef.current) return;
857
+ const events = parseMouseChunk(chunk);
858
+ for (const ev of events) handleMouseEvent(ev);
859
+ };
860
+ process.stdin.on("data", onData);
861
+ const renderTick = setInterval(() => {
862
+ if (completedRef.current) return;
863
+ const size = pixelsRef.current.size;
864
+ const elapsed = Number((process.hrtime.bigint() - startTsRef.current) / 1000000n);
865
+ if (size !== pixelCount) setPixelCount(size);
866
+ if (elapsed !== elapsedMs) setElapsedMs(elapsed);
867
+ const next = [];
868
+ for (let r = 0; r < canvasH; r++) {
869
+ let line = "";
870
+ for (let c = 0; c < canvasW; c++) {
871
+ const top = pixelsRef.current.has(idx(c, r * 2));
872
+ const bot = pixelsRef.current.has(idx(c, r * 2 + 1));
873
+ line += top && bot ? "\u2588" : top ? "\u2580" : bot ? "\u2584" : " ";
874
+ }
875
+ next.push(line);
876
+ }
877
+ setGrid(next);
878
+ setCursor(cursorRef.current);
879
+ }, RENDER_INTERVAL_MS);
880
+ return () => {
881
+ clearInterval(renderTick);
882
+ process.stdin.off("data", onData);
883
+ disableMouse();
884
+ };
885
+ }, [canvasW, canvasH]);
886
+ function handleMouseEvent(ev) {
887
+ if (completedRef.current) return;
888
+ const canvasCol = ev.col - LEFT_OFFSET;
889
+ const canvasRow = ev.row - TOP_OFFSET;
890
+ const inBounds = canvasCol >= 0 && canvasCol < canvasW && canvasRow >= 0 && canvasRow < canvasH;
891
+ if (inBounds) cursorRef.current = { row: canvasRow, col: canvasCol };
892
+ else cursorRef.current = null;
893
+ if (ev.button === "left" && !ev.isRelease) {
894
+ mouseDownRef.current = true;
895
+ lastPaintRef.current = null;
896
+ if (inBounds) paintAt(canvasCol, canvasRow);
897
+ return;
898
+ }
899
+ if (ev.isRelease) {
900
+ mouseDownRef.current = false;
901
+ lastPaintRef.current = null;
902
+ return;
903
+ }
904
+ if (ev.button === "motion" && mouseDownRef.current && inBounds) {
905
+ paintAt(canvasCol, canvasRow);
906
+ }
907
+ }
908
+ function paintAt(canvasCol, canvasRow) {
909
+ const x = canvasCol;
910
+ const yTop = canvasRow * 2;
911
+ const yBot = canvasRow * 2 + 1;
912
+ const now = process.hrtime.bigint();
913
+ const dt = now - lastStrokeTsRef.current;
914
+ lastStrokeTsRef.current = now;
915
+ if (lastPaintRef.current) {
916
+ const lp = lastPaintRef.current;
917
+ paintLine(pixelsRef.current, lp.x, lp.y, x, yTop, (px, py) => {
918
+ strokesRef.current.push({ x: px, y: py, dtNs: dt });
919
+ });
920
+ paintLine(pixelsRef.current, lp.x, lp.y + 1, x, yBot, () => {
921
+ });
922
+ } else {
923
+ if (!pixelsRef.current.has(idx(x, yTop))) {
924
+ pixelsRef.current.add(idx(x, yTop));
925
+ strokesRef.current.push({ x, y: yTop, dtNs: dt });
926
+ }
927
+ if (!pixelsRef.current.has(idx(x, yBot))) {
928
+ pixelsRef.current.add(idx(x, yBot));
929
+ }
930
+ }
931
+ lastPaintRef.current = { x, y: yTop };
932
+ }
933
+ useInput((input, key) => {
934
+ if (completedRef.current) return;
935
+ if (key.backspace || key.delete || input === "c" || input === "C") {
936
+ pixelsRef.current.clear();
937
+ strokesRef.current = [];
938
+ lastPaintRef.current = null;
939
+ cursorRef.current = null;
940
+ startTsRef.current = process.hrtime.bigint();
941
+ lastStrokeTsRef.current = startTsRef.current;
942
+ setNudge(null);
943
+ return;
944
+ }
945
+ if (key.return) {
946
+ const elapsed = Number((process.hrtime.bigint() - startTsRef.current) / 1000000n);
947
+ if (elapsed < MIN_ELAPSED_MS) {
948
+ const secLeft = Math.ceil((MIN_ELAPSED_MS - elapsed) / 1e3);
949
+ setNudge(`Wait ${secLeft}s before committing (anti-fat-finger).`);
950
+ return;
951
+ }
952
+ completedRef.current = true;
953
+ setDone(true);
954
+ try {
955
+ const entropy = buildEntropyFromMouseTrail(strokesRef.current);
956
+ setTimeout(() => onComplete(entropy), 250);
957
+ } catch (e) {
958
+ onError(e instanceof Error ? e : new Error(String(e)));
959
+ }
960
+ }
961
+ });
962
+ if (!dims) {
963
+ const { cols, rows } = readTerminalDims();
964
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", gap: 1, children: [
965
+ /* @__PURE__ */ jsx3(Text3, { bold: true, color: "yellow", children: "\u26A0 Terminal too small for the mouse canvas" }),
966
+ /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
967
+ "Need at least ",
968
+ MIN_W + CHROME_COLS,
969
+ " cols \xD7 ",
970
+ MIN_H + CHROME_ROWS,
971
+ " rows. Got ",
972
+ cols,
973
+ " \xD7 ",
974
+ rows,
975
+ "."
976
+ ] }),
977
+ /* @__PURE__ */ jsxs3(Text3, { children: [
978
+ "Resize this window larger, or press ",
979
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "\u232B Backspace" }),
980
+ " to go back and then ",
981
+ /* @__PURE__ */ jsx3(Text3, { bold: true, color: "magenta", children: "K" }),
982
+ " on the mode-select modal to use keyboard entropy instead."
983
+ ] })
984
+ ] });
985
+ }
986
+ const secsElapsed = Math.floor(elapsedMs / 1e3);
987
+ const minSecs = Math.ceil(MIN_ELAPSED_MS / 1e3);
988
+ const timePct = Math.min(100, Math.round(elapsedMs / MIN_ELAPSED_MS * 100));
989
+ const filled = Math.round(timePct / 100 * 20);
990
+ const bar = "\u2588".repeat(filled) + "\u2591".repeat(20 - filled);
991
+ const ready = elapsedMs >= MIN_ELAPSED_MS;
992
+ if (done) {
993
+ return /* @__PURE__ */ jsxs3(Box3, { gap: 2, children: [
994
+ /* @__PURE__ */ jsx3(Text3, { color: "green", bold: true, children: "\u2713" }),
995
+ /* @__PURE__ */ jsx3(Text3, { children: "Entropy committed." })
996
+ ] });
997
+ }
998
+ const renderRow = (rowStr, rowIdx) => {
999
+ if (!cursor || cursor.row !== rowIdx) {
1000
+ return /* @__PURE__ */ jsx3(Text3, { color: "white", children: rowStr });
1001
+ }
1002
+ const cIdx = cursor.col;
1003
+ const before = rowStr.slice(0, cIdx);
1004
+ const at = rowStr[cIdx] ?? " ";
1005
+ const after = rowStr.slice(cIdx + 1);
1006
+ return /* @__PURE__ */ jsxs3(Text3, { children: [
1007
+ /* @__PURE__ */ jsx3(Text3, { color: "white", children: before }),
1008
+ /* @__PURE__ */ jsx3(Text3, { color: "yellow", inverse: true, children: at === " " ? "\xB7" : at }),
1009
+ /* @__PURE__ */ jsx3(Text3, { color: "white", children: after })
1010
+ ] });
1011
+ };
1012
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
1013
+ /* @__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" }) }),
1014
+ /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
1015
+ "\u250C",
1016
+ "\u2500".repeat(canvasW),
1017
+ "\u2510"
1018
+ ] }),
1019
+ grid.map((row, i) => /* @__PURE__ */ jsxs3(Text3, { children: [
1020
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "\u2502" }),
1021
+ renderRow(row, i),
1022
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "\u2502" })
1023
+ ] }, i)),
1024
+ /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
1025
+ "\u2514",
1026
+ "\u2500".repeat(canvasW),
1027
+ "\u2518"
1028
+ ] }),
1029
+ /* @__PURE__ */ jsxs3(Box3, { gap: 2, children: [
1030
+ /* @__PURE__ */ jsxs3(Text3, { color: ready ? COLORS.SUCCESS : COLORS.BRAND, children: [
1031
+ "[",
1032
+ bar,
1033
+ "]"
1034
+ ] }),
1035
+ /* @__PURE__ */ jsxs3(Text3, { children: [
1036
+ /* @__PURE__ */ jsxs3(Text3, { bold: true, color: ready ? "green" : "yellow", children: [
1037
+ secsElapsed,
1038
+ "s"
1039
+ ] }),
1040
+ /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
1041
+ " ",
1042
+ "/ ",
1043
+ minSecs,
1044
+ "s \xB7 ",
1045
+ pixelCount,
1046
+ " pixels drawn"
1047
+ ] })
1048
+ ] }),
1049
+ nudge && /* @__PURE__ */ jsx3(Text3, { color: "yellow", children: nudge })
1050
+ ] }),
1051
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "Enter = commit \xB7 c / \u232B = clear \xB7 Ctrl+C = abort" })
1052
+ ] });
1053
+ }
1054
+
1055
+ // src/components/EntropyCollector.tsx
1056
+ import { useRef as useRef3, useState as useState4 } from "react";
1057
+ import { Box as Box4, Text as Text4, useInput as useInput2 } from "ink";
1058
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
605
1059
  var TARGET = 20;
606
1060
  function EntropyCollector({ onComplete, onError }) {
607
- const [count, setCount] = useState3(0);
608
- const [done, setDone] = useState3(false);
609
- const charsRef = useRef2([]);
610
- const timingsRef = useRef2([]);
611
- const lastRef = useRef2(process.hrtime.bigint());
612
- const completedRef = useRef2(false);
613
- useInput((input) => {
1061
+ const [count, setCount] = useState4(0);
1062
+ const [done, setDone] = useState4(false);
1063
+ const charsRef = useRef3([]);
1064
+ const timingsRef = useRef3([]);
1065
+ const lastRef = useRef3(process.hrtime.bigint());
1066
+ const completedRef = useRef3(false);
1067
+ useInput2((input) => {
614
1068
  if (completedRef.current) return;
615
1069
  const now = process.hrtime.bigint();
616
1070
  timingsRef.current.push(now - lastRef.current);
@@ -634,32 +1088,32 @@ function EntropyCollector({ onComplete, onError }) {
634
1088
  const pct = Math.round(count / TARGET * 100);
635
1089
  const stars = "*".repeat(Math.min(count, 32));
636
1090
  if (done) {
637
- return /* @__PURE__ */ jsxs3(Box3, { gap: 2, children: [
638
- /* @__PURE__ */ jsx3(Text3, { color: "green", bold: true, children: "\u2713" }),
639
- /* @__PURE__ */ jsxs3(Text3, { children: [
1091
+ return /* @__PURE__ */ jsxs4(Box4, { gap: 2, children: [
1092
+ /* @__PURE__ */ jsx4(Text4, { color: "green", bold: true, children: "\u2713" }),
1093
+ /* @__PURE__ */ jsxs4(Text4, { children: [
640
1094
  "Entropy collected \u2014 [",
641
1095
  bar,
642
1096
  "] 100%"
643
1097
  ] })
644
1098
  ] });
645
1099
  }
646
- return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", gap: 1, children: [
647
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "Type anything to generate entropy:" }),
648
- /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "Keystroke timing (nanosecond intervals) is the randomness source." }),
649
- /* @__PURE__ */ jsxs3(Box3, { marginTop: 1, children: [
650
- /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: " > " }),
651
- /* @__PURE__ */ jsx3(Text3, { color: "green", children: stars }),
652
- /* @__PURE__ */ jsx3(Text3, { color: "yellow", children: "\u2588" })
1100
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", gap: 1, children: [
1101
+ /* @__PURE__ */ jsx4(Text4, { bold: true, children: "Type anything to generate entropy:" }),
1102
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "Keystroke timing (nanosecond intervals) is the randomness source." }),
1103
+ /* @__PURE__ */ jsxs4(Box4, { marginTop: 1, children: [
1104
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: " > " }),
1105
+ /* @__PURE__ */ jsx4(Text4, { color: "green", children: stars }),
1106
+ /* @__PURE__ */ jsx4(Text4, { color: "yellow", children: "\u2588" })
653
1107
  ] }),
654
- /* @__PURE__ */ jsxs3(Box3, { gap: 2, marginTop: 1, children: [
655
- /* @__PURE__ */ jsxs3(Text3, { color: "cyan", children: [
1108
+ /* @__PURE__ */ jsxs4(Box4, { gap: 2, marginTop: 1, children: [
1109
+ /* @__PURE__ */ jsxs4(Text4, { color: COLORS.BRAND, children: [
656
1110
  "[",
657
1111
  bar,
658
1112
  "]"
659
1113
  ] }),
660
- /* @__PURE__ */ jsxs3(Text3, { children: [
661
- /* @__PURE__ */ jsx3(Text3, { bold: true, color: count > 0 ? "green" : "yellow", children: count }),
662
- /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
1114
+ /* @__PURE__ */ jsxs4(Text4, { children: [
1115
+ /* @__PURE__ */ jsx4(Text4, { bold: true, color: count > 0 ? "green" : "yellow", children: count }),
1116
+ /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
663
1117
  "/",
664
1118
  TARGET,
665
1119
  " keystrokes (",
@@ -668,13 +1122,14 @@ function EntropyCollector({ onComplete, onError }) {
668
1122
  ] })
669
1123
  ] })
670
1124
  ] }),
671
- /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "Shown as * \u2014 your actual input is never revealed." })
1125
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "Shown as * \u2014 your actual input is never revealed." }),
1126
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "To quit without contributing: Ctrl+C (Q is part of the entropy alphabet here)." })
672
1127
  ] });
673
1128
  }
674
1129
 
675
1130
  // src/components/ContributeFlow.tsx
676
- import { useEffect as useEffect4, useState as useState4 } from "react";
677
- import { Box as Box4, Text as Text4 } from "ink";
1131
+ import { useEffect as useEffect5, useState as useState5 } from "react";
1132
+ import { Box as Box5, Text as Text5 } from "ink";
678
1133
  import Spinner2 from "ink-spinner";
679
1134
  import { tmpdir as tmpdir2 } from "os";
680
1135
  import { join as join4 } from "path";
@@ -701,7 +1156,7 @@ async function cleanupTemp(path) {
701
1156
  }
702
1157
 
703
1158
  // src/components/ContributeFlow.tsx
704
- import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
1159
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
705
1160
  var STEP_LABELS = ["downloading", "computing", "uploading", "signalling", "verifying"];
706
1161
  var STEP_INDEX = {
707
1162
  downloading: 0,
@@ -712,8 +1167,8 @@ var STEP_INDEX = {
712
1167
  };
713
1168
  function ContributeFlow(props) {
714
1169
  const { ceremonyId: ceremonyId2, trackId, token, slotStatus, entropy, displayName: displayName2 } = props;
715
- const [step, setStep] = useState4({ name: "downloading", bytesReceived: 0, total: null });
716
- useEffect4(() => {
1170
+ const [step, setStep] = useState5({ name: "downloading", bytesReceived: 0, total: null });
1171
+ useEffect5(() => {
717
1172
  let cancelled = false;
718
1173
  const challengePath = join4(tmpdir2(), `ceremony-challenge-${Date.now()}.mpcparams`);
719
1174
  let responsePath = null;
@@ -796,112 +1251,144 @@ function ContributeFlow(props) {
796
1251
  const currentIdx = STEP_INDEX[step.name] ?? 0;
797
1252
  const computingMsg = useCyclingMessage(COMPUTING_MESSAGES, 2500, step.name === "computing");
798
1253
  const verifyingMsg = useCyclingMessage(VERIFYING_MESSAGES, 3e3, step.name === "verifying");
799
- return /* @__PURE__ */ jsx4(Box4, { flexDirection: "column", gap: 1, children: STEP_LABELS.map((label, i) => {
800
- const isDone = i < currentIdx;
801
- const isActive = i === currentIdx;
802
- const isPending = i > currentIdx;
803
- let indicator;
804
- if (isDone) indicator = /* @__PURE__ */ jsx4(Text4, { color: "green", children: "\u2713" });
805
- else if (isActive) indicator = /* @__PURE__ */ jsx4(Spinner2, { type: "dots" });
806
- else indicator = /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "\u25CB" });
807
- let detail = null;
808
- if (isActive && step.name === "downloading" && step.total) {
809
- const pct = Math.round(step.bytesReceived / step.total * 100);
810
- const filled = Math.round(pct / 5);
811
- const bar = "\u2588".repeat(filled) + "\u2591".repeat(20 - filled);
812
- detail = /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
813
- " ",
814
- "[",
815
- bar,
816
- "] ",
817
- pct,
818
- "%"
819
- ] });
820
- }
821
- if (isActive && step.name === "verifying") {
822
- const elapsed = step.attempt <= 10 ? step.attempt * 3 : 10 * 3 + (step.attempt - 10) * 10;
823
- const mins = Math.floor(elapsed / 60);
824
- const secs = elapsed % 60;
825
- const time = mins > 0 ? `${mins}m ${secs.toString().padStart(2, "0")}s` : `${secs}s`;
826
- detail = /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
827
- " ",
828
- verifyingMsg,
829
- "\u2026 (",
830
- time,
831
- " elapsed)"
832
- ] });
833
- }
834
- if (isActive && step.name === "computing") {
835
- detail = /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
836
- " ",
837
- computingMsg,
838
- "\u2026"
839
- ] });
840
- }
841
- return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
842
- /* @__PURE__ */ jsxs4(Box4, { gap: 2, children: [
843
- indicator,
844
- /* @__PURE__ */ jsxs4(
845
- Text4,
846
- {
847
- color: isDone ? "green" : isActive ? "white" : void 0,
848
- bold: isActive,
849
- dimColor: isPending,
850
- children: [
851
- label.charAt(0).toUpperCase() + label.slice(1),
852
- " ",
853
- isActive && label === "computing" && /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "(entropy stays local)" })
854
- ]
855
- }
856
- )
1254
+ return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", gap: 1, children: [
1255
+ STEP_LABELS.map((label, i) => {
1256
+ const isDone = i < currentIdx;
1257
+ const isActive = i === currentIdx;
1258
+ const isPending = i > currentIdx;
1259
+ let indicator;
1260
+ if (isDone) indicator = /* @__PURE__ */ jsx5(Text5, { color: "green", children: "\u2713" });
1261
+ else if (isActive) indicator = /* @__PURE__ */ jsx5(Spinner2, { type: "dots" });
1262
+ else indicator = /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "\u25CB" });
1263
+ let detail = null;
1264
+ if (isActive && step.name === "downloading" && step.total) {
1265
+ const pct = Math.round(step.bytesReceived / step.total * 100);
1266
+ const filled = Math.round(pct / 5);
1267
+ const bar = "\u2588".repeat(filled) + "\u2591".repeat(20 - filled);
1268
+ detail = /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
1269
+ " ",
1270
+ "[",
1271
+ bar,
1272
+ "] ",
1273
+ pct,
1274
+ "%"
1275
+ ] });
1276
+ }
1277
+ if (isActive && step.name === "verifying") {
1278
+ const elapsed = step.attempt <= 10 ? step.attempt * 3 : 10 * 3 + (step.attempt - 10) * 10;
1279
+ const mins = Math.floor(elapsed / 60);
1280
+ const secs = elapsed % 60;
1281
+ const time = mins > 0 ? `${mins}m ${secs.toString().padStart(2, "0")}s` : `${secs}s`;
1282
+ detail = /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
1283
+ " ",
1284
+ verifyingMsg,
1285
+ "\u2026 (",
1286
+ time,
1287
+ " elapsed)"
1288
+ ] });
1289
+ }
1290
+ if (isActive && step.name === "computing") {
1291
+ detail = /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
1292
+ " ",
1293
+ computingMsg,
1294
+ "\u2026"
1295
+ ] });
1296
+ }
1297
+ return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", children: [
1298
+ /* @__PURE__ */ jsxs5(Box5, { gap: 2, children: [
1299
+ indicator,
1300
+ /* @__PURE__ */ jsxs5(
1301
+ Text5,
1302
+ {
1303
+ color: isDone ? "green" : isActive ? "white" : void 0,
1304
+ bold: isActive,
1305
+ dimColor: isPending,
1306
+ children: [
1307
+ label.charAt(0).toUpperCase() + label.slice(1),
1308
+ " ",
1309
+ isActive && label === "computing" && /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "(entropy stays local)" })
1310
+ ]
1311
+ }
1312
+ )
1313
+ ] }),
1314
+ detail
1315
+ ] }, label);
1316
+ }),
1317
+ slotStatus.slot_expires_at && step.name !== "verifying" && /* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
1318
+ "Your slot expires at",
1319
+ " ",
1320
+ new Date(slotStatus.slot_expires_at).toLocaleTimeString([], {
1321
+ hour: "2-digit",
1322
+ minute: "2-digit"
1323
+ }),
1324
+ " ",
1325
+ "(server time)."
1326
+ ] }) }),
1327
+ /* @__PURE__ */ jsxs5(Box5, { marginTop: 1, flexDirection: "column", children: [
1328
+ /* @__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" }),
1329
+ /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
1330
+ "Need to bail? Press ",
1331
+ /* @__PURE__ */ jsx5(Text5, { bold: true, children: "Q" }),
1332
+ " or ",
1333
+ /* @__PURE__ */ jsx5(Text5, { bold: true, children: "Ctrl+C" }),
1334
+ " \u2014 your slot is released immediately so the next contributor can take over."
857
1335
  ] }),
858
- detail
859
- ] }, label);
860
- }) });
1336
+ step.name === "computing" && /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "(During this step snarkjs is busy \u2014 Ctrl+C responds faster than Q.)" })
1337
+ ] })
1338
+ ] });
861
1339
  }
862
1340
 
863
1341
  // src/components/Attestation.tsx
864
- import { Box as Box5, Text as Text5 } from "ink";
865
- import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1342
+ import { Box as Box6, Text as Text6 } from "ink";
1343
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
866
1344
  function Attestation({ contribution }) {
867
1345
  const hashShort = contribution.contributionHash ? `${contribution.contributionHash.slice(0, 16)}...${contribution.contributionHash.slice(-8)}` : "(pending verification)";
868
1346
  const verifiedAt = contribution.verifiedAt ? new Date(contribution.verifiedAt).toLocaleString() : null;
869
- return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", gap: 1, children: [
870
- /* @__PURE__ */ jsx5(Text5, { bold: true, color: "green", children: "\u2713 Contribution verified!" }),
871
- /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", gap: 0, children: [
872
- /* @__PURE__ */ jsxs5(Box5, { gap: 2, children: [
873
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "Circuit" }),
874
- /* @__PURE__ */ jsx5(Text5, { bold: true, children: contribution.circuitName })
1347
+ return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", gap: 1, children: [
1348
+ /* @__PURE__ */ jsx6(Text6, { bold: true, color: "greenBright", children: "\u2714 Contribution verified!" }),
1349
+ /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", gap: 0, children: [
1350
+ /* @__PURE__ */ jsxs6(Box6, { gap: 2, children: [
1351
+ /* @__PURE__ */ jsx6(Text6, { dimColor: true, color: "cyan", children: "Circuit" }),
1352
+ /* @__PURE__ */ jsx6(Text6, { bold: true, color: "blueBright", children: contribution.circuitName })
875
1353
  ] }),
876
- /* @__PURE__ */ jsxs5(Box5, { gap: 2, children: [
877
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "Round " }),
878
- /* @__PURE__ */ jsxs5(Text5, { bold: true, children: [
1354
+ /* @__PURE__ */ jsxs6(Box6, { gap: 2, children: [
1355
+ /* @__PURE__ */ jsxs6(Text6, { dimColor: true, color: "cyan", children: [
1356
+ "Round",
1357
+ " "
1358
+ ] }),
1359
+ /* @__PURE__ */ jsxs6(Text6, { bold: true, color: "greenBright", children: [
879
1360
  "#",
880
1361
  contribution.sequenceNumber
881
1362
  ] })
882
1363
  ] }),
883
- /* @__PURE__ */ jsxs5(Box5, { gap: 2, children: [
884
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "Hash " }),
885
- /* @__PURE__ */ jsx5(Text5, { color: "cyan", children: hashShort })
1364
+ /* @__PURE__ */ jsxs6(Box6, { gap: 2, children: [
1365
+ /* @__PURE__ */ jsxs6(Text6, { dimColor: true, color: "cyan", children: [
1366
+ "Hash",
1367
+ " "
1368
+ ] }),
1369
+ /* @__PURE__ */ jsx6(Text6, { color: "cyanBright", children: hashShort })
886
1370
  ] }),
887
- verifiedAt && /* @__PURE__ */ jsxs5(Box5, { gap: 2, children: [
888
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "Time " }),
889
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: verifiedAt })
1371
+ verifiedAt && /* @__PURE__ */ jsxs6(Box6, { gap: 2, children: [
1372
+ /* @__PURE__ */ jsxs6(Text6, { dimColor: true, color: "cyan", children: [
1373
+ "Time",
1374
+ " "
1375
+ ] }),
1376
+ /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: verifiedAt })
890
1377
  ] })
891
1378
  ] }),
892
- contribution.contributionHash && /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", marginTop: 1, children: [
893
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "Full hash (share this to prove participation):" }),
894
- /* @__PURE__ */ jsx5(Text5, { color: "cyan", wrap: "wrap", children: contribution.contributionHash })
1379
+ contribution.contributionHash && /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", marginTop: 1, children: [
1380
+ /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "Full hash (share this to prove participation):" }),
1381
+ /* @__PURE__ */ jsx6(Text6, { color: "cyanBright", wrap: "wrap", children: contribution.contributionHash })
895
1382
  ] })
896
1383
  ] });
897
1384
  }
898
1385
 
899
1386
  // src/components/InfoModal.tsx
900
- import { Box as Box6, Text as Text6 } from "ink";
901
- import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
1387
+ import { Box as Box7, Text as Text7 } from "ink";
1388
+ import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
902
1389
  function InfoModal() {
903
- return /* @__PURE__ */ jsx6(Box6, { flexDirection: "column", paddingX: 2, paddingY: 1, children: /* @__PURE__ */ jsxs6(
904
- Box6,
1390
+ return /* @__PURE__ */ jsx7(Box7, { flexDirection: "column", paddingX: 2, paddingY: 1, children: /* @__PURE__ */ jsxs7(
1391
+ Box7,
905
1392
  {
906
1393
  flexDirection: "column",
907
1394
  borderStyle: "round",
@@ -911,17 +1398,33 @@ function InfoModal() {
911
1398
  width: 84,
912
1399
  gap: 1,
913
1400
  children: [
914
- /* @__PURE__ */ jsx6(Text6, { bold: true, color: "cyan", children: "Umbra Phase 2 Trusted-Setup Ceremony" }),
915
- /* @__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." }),
916
- /* @__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." }),
917
- /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "Press B, Esc or ? to close" })
1401
+ /* @__PURE__ */ jsx7(Text7, { bold: true, color: "cyan", children: "Umbra Phase 2 Trusted-Setup Ceremony" }),
1402
+ /* @__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." }),
1403
+ /* @__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." }),
1404
+ /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "Press B, Esc or ? to close" })
918
1405
  ]
919
1406
  }
920
1407
  ) });
921
1408
  }
922
1409
 
1410
+ // src/exit-hints.ts
1411
+ var QUIT_HINTS = {
1412
+ /** Idle screens with no active slot — ceremony picker, tracks list, etc. */
1413
+ quit: "Q / Ctrl+C to quit",
1414
+ /** Auto-mode driving the loop — emphasises "abort run", not just "quit". */
1415
+ abortRun: "Q / Ctrl+C abort run",
1416
+ /** Between-circuits auto-mode "queueing" — calls out the slot-release side effect. */
1417
+ abortSlot: "Q / Ctrl+C to abort (releases any active slot)"
1418
+ };
1419
+
1420
+ // src/slot-release.ts
1421
+ function fireAndForgetLeaveQueue(ceremonyId2, trackId, sessionToken) {
1422
+ api.leaveQueue(ceremonyId2, trackId, sessionToken).catch(() => {
1423
+ });
1424
+ }
1425
+
923
1426
  // src/components/App.tsx
924
- import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
1427
+ import { Fragment as Fragment3, jsx as jsx8, jsxs as jsxs8 } from "react/jsx-runtime";
925
1428
  var NAME_MAX_LEN = 100;
926
1429
  var NAME_VALID_RE = /^[\p{L}\p{N} _.\-]*$/u;
927
1430
  function elideMiddle(s, maxLen) {
@@ -930,27 +1433,47 @@ function elideMiddle(s, maxLen) {
930
1433
  const keepTail = Math.floor((maxLen - 1) / 2);
931
1434
  return s.slice(0, keepHead) + "\u2026" + s.slice(s.length - keepTail);
932
1435
  }
1436
+ function orderTracksForAuto(tracks, contributed) {
1437
+ const openTracks = tracks.filter((t) => t.status === "open");
1438
+ const fresh = [];
1439
+ const alreadyDone = [];
1440
+ for (const t of openTracks) {
1441
+ if ((contributed[t.id]?.length ?? 0) > 0) alreadyDone.push(t);
1442
+ else fresh.push(t);
1443
+ }
1444
+ return [...fresh, ...alreadyDone];
1445
+ }
933
1446
  function copyToClipboardOSC52(value) {
934
1447
  const payload = Buffer.from(value, "utf8").toString("base64");
935
1448
  process.stdout.write(`\x1B]52;c;${payload}\x07`);
936
1449
  }
937
1450
  function App({ ceremonyId: initialCeremonyId, displayName: initialDisplayName }) {
938
1451
  const { exit } = useApp();
939
- const [activeCeremonyId, setActiveCeremonyId] = useState5(initialCeremonyId);
940
- const [displayName2, setDisplayName] = useState5(initialDisplayName ?? "anonymous");
941
- const [nameSet, setNameSet] = useState5(initialDisplayName !== void 0);
942
- const [screen, setScreen] = useState5(
1452
+ const [activeCeremonyId, setActiveCeremonyId] = useState6(initialCeremonyId);
1453
+ const [displayName2, setDisplayName] = useState6(initialDisplayName ?? "anonymous");
1454
+ const [nameSet, setNameSet] = useState6(initialDisplayName !== void 0);
1455
+ const [screen, setScreenRaw] = useState6(
943
1456
  initialDisplayName === void 0 ? { name: "name-input", value: "" } : initialCeremonyId ? { name: "loading" } : { name: "ceremony-picker", ceremonies: [], loading: true }
944
1457
  );
945
- const [ceremony, setCeremony] = useState5(null);
946
- const [session, setSession] = useState5(null);
947
- const [contributed, setContributed] = useState5({});
948
- const [selectedIdx, setSelectedIdx] = useState5(0);
949
- const [tab, setTab] = useState5(0);
950
- const [contribCursor, setContribCursor] = useState5(0);
951
- const [copyToast, setCopyToast] = useState5(null);
952
- const [showInfo, setShowInfo] = useState5(false);
953
- useEffect5(() => {
1458
+ const setScreen = (next) => {
1459
+ setScreenRaw((prev) => {
1460
+ const resolved = typeof next === "function" ? next(prev) : next;
1461
+ if (prev.name !== resolved.name) {
1462
+ process.stdout.write("\x1B[2J\x1B[H");
1463
+ }
1464
+ return resolved;
1465
+ });
1466
+ };
1467
+ const [ceremony, setCeremony] = useState6(null);
1468
+ const [session, setSession] = useState6(null);
1469
+ const [contributed, setContributed] = useState6({});
1470
+ const [selectedIdx, setSelectedIdx] = useState6(0);
1471
+ const [tab, setTab] = useState6(0);
1472
+ const [contribCursor, setContribCursor] = useState6(0);
1473
+ const [copyToast, setCopyToast] = useState6(null);
1474
+ const [showInfo, setShowInfo] = useState6(false);
1475
+ const [entropyMode, setEntropyMode] = useState6("mouse");
1476
+ useEffect6(() => {
954
1477
  if (!nameSet) return;
955
1478
  if (!initialCeremonyId) {
956
1479
  loadCeremonies();
@@ -998,7 +1521,7 @@ function App({ ceremonyId: initialCeremonyId, displayName: initialDisplayName })
998
1521
  return;
999
1522
  }
1000
1523
  setSelectedIdx(0);
1001
- setScreen({ name: "tracks", tracks });
1524
+ setScreen({ name: "mode-select", tracks });
1002
1525
  } catch (e) {
1003
1526
  if (e.code === "INVALID_SESSION" || e.status === 401) {
1004
1527
  try {
@@ -1015,7 +1538,7 @@ function App({ ceremonyId: initialCeremonyId, displayName: initialDisplayName })
1015
1538
  return;
1016
1539
  }
1017
1540
  setSelectedIdx(0);
1018
- setScreen({ name: "tracks", tracks });
1541
+ setScreen({ name: "mode-select", tracks });
1019
1542
  } catch (e2) {
1020
1543
  setScreen({
1021
1544
  name: "error",
@@ -1073,36 +1596,112 @@ function App({ ceremonyId: initialCeremonyId, displayName: initialDisplayName })
1073
1596
  setScreen({ name: "ceremony-picker", ceremonies: [], loading: true });
1074
1597
  loadCeremonies();
1075
1598
  }
1076
- useEffect5(() => {
1077
- if (screen.name === "queue" && session) {
1078
- const { trackId } = screen;
1079
- setQueueCleanup(() => {
1080
- api.leaveQueue(activeCeremonyId, trackId, session.session_token).catch(() => {
1081
- });
1082
- });
1599
+ useEffect6(() => {
1600
+ if (session) {
1601
+ setSessionCleanup(clearSession);
1602
+ } else {
1603
+ clearSessionCleanup();
1604
+ }
1605
+ }, [session]);
1606
+ useEffect6(() => {
1607
+ let trackId = null;
1608
+ if (screen.name === "queue" || screen.name === "entropy" || screen.name === "contribute") {
1609
+ trackId = screen.trackId;
1610
+ } else if (screen.name === "auto-running") {
1611
+ if (screen.subPhase.kind === "waiting" || screen.subPhase.kind === "contributing" || screen.subPhase.kind === "releasing") {
1612
+ trackId = screen.subPhase.trackId;
1613
+ } else if (screen.current) {
1614
+ trackId = screen.current.id;
1615
+ }
1616
+ }
1617
+ if (trackId && session) {
1618
+ const sessionToken = session.session_token;
1619
+ setQueueCleanup(() => fireAndForgetLeaveQueue(activeCeremonyId, trackId, sessionToken));
1083
1620
  } else {
1084
1621
  clearQueueCleanup();
1085
1622
  }
1086
- }, [screen.name]);
1087
- useInput2((input, key) => {
1623
+ }, [screen.name, screen.name === "auto-running" ? screen.subPhase.kind : null]);
1624
+ useEffect6(() => {
1625
+ if (screen.name !== "auto-running") return;
1626
+ if (screen.current !== null) return;
1627
+ if (screen.subPhase.kind !== "queueing") return;
1628
+ if (!session) return;
1629
+ const currentScreen = screen;
1630
+ (async () => {
1631
+ let next = null;
1632
+ let nextRemaining = currentScreen.remaining;
1633
+ let nextRetryQueue = currentScreen.retryQueue;
1634
+ let nextRetryingPhase = currentScreen.retryingPhase;
1635
+ if (currentScreen.remaining.length > 0) {
1636
+ next = currentScreen.remaining[0];
1637
+ nextRemaining = currentScreen.remaining.slice(1);
1638
+ } else if (currentScreen.retryQueue.length > 0 && !currentScreen.retryingPhase) {
1639
+ nextRemaining = currentScreen.retryQueue.slice(1);
1640
+ next = currentScreen.retryQueue[0];
1641
+ nextRetryQueue = [];
1642
+ nextRetryingPhase = true;
1643
+ }
1644
+ if (!next) {
1645
+ setScreen({ name: "auto-summary", outcomes: currentScreen.done });
1646
+ return;
1647
+ }
1648
+ try {
1649
+ await api.joinQueue(activeCeremonyId, next.id, session.session_token);
1650
+ } catch (e) {
1651
+ const reason = e?.message ?? String(e);
1652
+ setScreen({
1653
+ ...currentScreen,
1654
+ remaining: nextRemaining,
1655
+ retryQueue: nextRetryQueue,
1656
+ retryingPhase: nextRetryingPhase,
1657
+ current: null,
1658
+ subPhase: { kind: "queueing" },
1659
+ done: [
1660
+ ...currentScreen.done,
1661
+ {
1662
+ trackId: next.id,
1663
+ circuitName: next.circuit_name,
1664
+ result: "failed",
1665
+ reason: `joinQueue: ${reason}`
1666
+ }
1667
+ ]
1668
+ });
1669
+ return;
1670
+ }
1671
+ setScreen({
1672
+ ...currentScreen,
1673
+ remaining: nextRemaining,
1674
+ retryQueue: nextRetryQueue,
1675
+ retryingPhase: nextRetryingPhase,
1676
+ current: next,
1677
+ subPhase: { kind: "waiting", trackId: next.id, circuitName: next.circuit_name }
1678
+ });
1679
+ })();
1680
+ }, [
1681
+ screen.name,
1682
+ screen.name === "auto-running" ? screen.subPhase.kind : null,
1683
+ screen.name === "auto-running" ? screen.current?.id ?? null : null
1684
+ ]);
1685
+ useInput3((input, key) => {
1088
1686
  const q = input.toLowerCase();
1687
+ const isQuit = q === "q" || key.ctrl === true && q === "c";
1089
1688
  if (showInfo) {
1090
1689
  if (key.escape || input === "?" || q === "b") {
1091
1690
  setShowInfo(false);
1092
1691
  return;
1093
1692
  }
1094
- if (q === "q") {
1693
+ if (isQuit) {
1095
1694
  exit();
1096
1695
  return;
1097
1696
  }
1098
1697
  return;
1099
1698
  }
1100
- if (input === "?" && screen.name !== "entropy") {
1699
+ if (input === "?" && screen.name !== "entropy" && screen.name !== "auto-entropy") {
1101
1700
  setShowInfo(true);
1102
1701
  return;
1103
1702
  }
1104
1703
  if (screen.name === "name-input") {
1105
- if (key.escape) {
1704
+ if (key.ctrl === true && q === "c") {
1106
1705
  exit();
1107
1706
  return;
1108
1707
  }
@@ -1118,29 +1717,89 @@ function App({ ceremonyId: initialCeremonyId, displayName: initialDisplayName })
1118
1717
  setScreen({ name: "name-input", value: screen.value.slice(0, -1) });
1119
1718
  return;
1120
1719
  }
1121
- if (input && input.length === 1 && NAME_VALID_RE.test(input)) {
1720
+ if (input && input.length === 1 && !key.ctrl && !key.meta && NAME_VALID_RE.test(input)) {
1122
1721
  if (screen.value.length >= NAME_MAX_LEN) return;
1123
1722
  setScreen({ name: "name-input", value: screen.value + input });
1124
1723
  }
1125
1724
  return;
1126
1725
  }
1127
- if (q === "q" && screen.name !== "entropy") {
1128
- if (screen.name === "queue" && session) {
1726
+ if (isQuit && screen.name !== "entropy" && screen.name !== "auto-entropy") {
1727
+ let trackId = null;
1728
+ if (screen.name === "queue" || screen.name === "contribute") {
1729
+ trackId = screen.trackId;
1730
+ } else if (screen.name === "auto-running") {
1731
+ if (screen.subPhase.kind === "waiting" || screen.subPhase.kind === "contributing" || screen.subPhase.kind === "releasing") {
1732
+ trackId = screen.subPhase.trackId;
1733
+ }
1734
+ }
1735
+ if (trackId && session) {
1129
1736
  clearQueueCleanup();
1130
- api.leaveQueue(activeCeremonyId, screen.trackId, session.session_token).catch(() => {
1737
+ clearSessionCleanup();
1738
+ fireAndForgetLeaveQueue(activeCeremonyId, trackId, session.session_token);
1739
+ clearSession().catch(() => {
1131
1740
  });
1132
1741
  setTimeout(() => exit(), 500);
1742
+ } else if (session) {
1743
+ clearSessionCleanup();
1744
+ clearSession().catch(() => {
1745
+ });
1746
+ setTimeout(() => exit(), 100);
1133
1747
  } else {
1134
1748
  exit();
1135
1749
  }
1136
1750
  return;
1137
1751
  }
1138
1752
  if (key.backspace || key.delete) {
1139
- if (!initialCeremonyId && (screen.name === "tracks" || screen.name === "error")) {
1753
+ if (!initialCeremonyId && (screen.name === "tracks" || screen.name === "error" || screen.name === "mode-select" || screen.name === "auto-summary")) {
1140
1754
  goCeremonyPicker();
1141
1755
  return;
1142
1756
  }
1143
- if (screen.name !== "tracks" && screen.name !== "loading" && screen.name !== "joining" && screen.name !== "entropy" && screen.name !== "ceremony-picker") {
1757
+ if (screen.name === "auto-running" && (screen.subPhase.kind === "waiting" || screen.subPhase.kind === "contributing") && screen.current && session) {
1758
+ const skipped = screen.current;
1759
+ const skippedTrackId = screen.subPhase.trackId;
1760
+ const skippedCircuitName = screen.subPhase.circuitName;
1761
+ const sessionToken = session.session_token;
1762
+ setScreen({
1763
+ ...screen,
1764
+ subPhase: {
1765
+ kind: "releasing",
1766
+ trackId: skippedTrackId,
1767
+ circuitName: skippedCircuitName
1768
+ }
1769
+ });
1770
+ api.leaveQueue(activeCeremonyId, skippedTrackId, sessionToken).catch((err) => {
1771
+ const msg = err instanceof Error ? err.message : String(err);
1772
+ process.stderr.write(
1773
+ `[ceremony-tui] leaveQueue(${skippedTrackId}) failed during skip: ${msg}
1774
+ `
1775
+ );
1776
+ }).finally(() => {
1777
+ setScreen((prev) => {
1778
+ if (prev.name !== "auto-running" || prev.subPhase.kind !== "releasing" || prev.subPhase.trackId !== skippedTrackId) {
1779
+ return prev;
1780
+ }
1781
+ return {
1782
+ ...prev,
1783
+ current: null,
1784
+ subPhase: { kind: "queueing" },
1785
+ done: [
1786
+ ...prev.done,
1787
+ {
1788
+ trackId: skipped.id,
1789
+ circuitName: skipped.circuit_name,
1790
+ result: "failed",
1791
+ reason: "user skipped"
1792
+ }
1793
+ ]
1794
+ // Don't queue skipped circuits for retry — the user
1795
+ // actively chose to skip, so a silent retry would
1796
+ // defeat that intent.
1797
+ };
1798
+ });
1799
+ });
1800
+ return;
1801
+ }
1802
+ if (screen.name !== "tracks" && screen.name !== "loading" && screen.name !== "joining" && screen.name !== "entropy" && screen.name !== "auto-entropy" && screen.name !== "ceremony-picker") {
1144
1803
  goHome();
1145
1804
  }
1146
1805
  return;
@@ -1165,6 +1824,22 @@ function App({ ceremonyId: initialCeremonyId, displayName: initialDisplayName })
1165
1824
  }
1166
1825
  return;
1167
1826
  }
1827
+ if (screen.name === "mode-select") {
1828
+ if (q === "k") {
1829
+ setEntropyMode((m) => m === "mouse" ? "keyboard" : "mouse");
1830
+ return;
1831
+ }
1832
+ if (key.return) {
1833
+ setScreen({ name: "auto-entropy", tracks: screen.tracks });
1834
+ return;
1835
+ }
1836
+ if (q === "n" || q === "m") {
1837
+ setSelectedIdx(0);
1838
+ setScreen({ name: "tracks", tracks: screen.tracks });
1839
+ return;
1840
+ }
1841
+ return;
1842
+ }
1168
1843
  if (screen.name === "tracks") {
1169
1844
  const { tracks } = screen;
1170
1845
  if (key.tab) {
@@ -1183,13 +1858,15 @@ function App({ ceremonyId: initialCeremonyId, displayName: initialDisplayName })
1183
1858
  }
1184
1859
  if (key.return) {
1185
1860
  const t = tracks[selectedIdx];
1186
- if (t && t.status === "open" && !contributed[t.id]) joinTrack(t);
1861
+ if (t && t.status === "open") joinTrack(t);
1187
1862
  return;
1188
1863
  }
1189
1864
  } else if (tab === 1) {
1190
- const myContribs = Object.values(contributed).filter(
1191
- (c) => c.ceremonyId === activeCeremonyId
1192
- );
1865
+ const myContribs = Object.values(contributed).flat().filter((c) => c.ceremonyId === activeCeremonyId).sort((a, b) => {
1866
+ const ta = a.verifiedAt ? Date.parse(a.verifiedAt) : 0;
1867
+ const tb = b.verifiedAt ? Date.parse(b.verifiedAt) : 0;
1868
+ return tb - ta;
1869
+ });
1193
1870
  if (key.upArrow) {
1194
1871
  setContribCursor((i) => Math.max(0, i - 1));
1195
1872
  return;
@@ -1221,52 +1898,55 @@ function App({ ceremonyId: initialCeremonyId, displayName: initialDisplayName })
1221
1898
  }
1222
1899
  });
1223
1900
  if (showInfo) {
1224
- return /* @__PURE__ */ jsx7(InfoModal, {});
1901
+ return /* @__PURE__ */ jsx8(InfoModal, {});
1225
1902
  }
1226
1903
  if (screen.name === "name-input") {
1227
1904
  const { value } = screen;
1228
1905
  const canSubmit = value.trim().length > 0;
1229
- return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1230
- /* @__PURE__ */ jsx7(Header, { ceremony: null }),
1231
- /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", marginTop: 1, gap: 1, children: [
1232
- /* @__PURE__ */ jsx7(Text7, { bold: true, children: "Who are you contributing as?" }),
1233
- /* @__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." }),
1234
- /* @__PURE__ */ jsxs7(Box7, { children: [
1235
- /* @__PURE__ */ jsx7(Text7, { color: "cyan", children: " > " }),
1236
- /* @__PURE__ */ jsx7(Text7, { children: value }),
1237
- /* @__PURE__ */ jsx7(Text7, { color: "cyan", inverse: true, children: " " })
1906
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
1907
+ /* @__PURE__ */ jsx8(Header, { ceremony: null }),
1908
+ /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", marginTop: 1, gap: 1, children: [
1909
+ /* @__PURE__ */ jsx8(Text8, { bold: true, children: "Who are you contributing as?" }),
1910
+ /* @__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." }),
1911
+ /* @__PURE__ */ jsxs8(Box8, { children: [
1912
+ /* @__PURE__ */ jsx8(Text8, { color: "cyan", children: " > " }),
1913
+ /* @__PURE__ */ jsx8(Text8, { children: value }),
1914
+ /* @__PURE__ */ jsx8(Text8, { color: "cyan", inverse: true, children: " " })
1238
1915
  ] }),
1239
- /* @__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" })
1916
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: canSubmit ? "Enter to continue \xB7 Tab rerolls random name \xB7 \u232B backspace \xB7 Ctrl+C to quit" : "Type a name, or press Tab to suggest a random anonymous one \xB7 Ctrl+C to quit" })
1240
1917
  ] })
1241
1918
  ] });
1242
1919
  }
1243
1920
  if (screen.name === "ceremony-picker") {
1244
1921
  const { ceremonies, loading } = screen;
1245
- return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1246
- /* @__PURE__ */ jsx7(Header, { ceremony: null }),
1247
- loading ? /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "Loading ceremonies..." }) : ceremonies.length === 0 ? /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", gap: 1, children: [
1248
- /* @__PURE__ */ jsx7(Text7, { color: "yellow", children: "No ceremonies found." }),
1249
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "The server may not have any active ceremonies yet." }),
1250
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "Q to quit" })
1251
- ] }) : /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1252
- /* @__PURE__ */ jsx7(Text7, { bold: true, children: "Select a ceremony:" }),
1253
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: " " + "\u2500".repeat(60) }),
1922
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
1923
+ /* @__PURE__ */ jsx8(Header, { ceremony: null }),
1924
+ loading ? /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
1925
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "Loading ceremonies..." }),
1926
+ /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: QUIT_HINTS.quit }) })
1927
+ ] }) : ceremonies.length === 0 ? /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", gap: 1, children: [
1928
+ /* @__PURE__ */ jsx8(Text8, { color: "yellow", children: "No ceremonies found." }),
1929
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "The server may not have any active ceremonies yet." }),
1930
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: QUIT_HINTS.quit })
1931
+ ] }) : /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
1932
+ /* @__PURE__ */ jsx8(Text8, { bold: true, children: "Select a ceremony:" }),
1933
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: " " + "\u2500".repeat(60) }),
1254
1934
  ceremonies.map((c, i) => {
1255
1935
  const isSelected = i === selectedIdx;
1256
1936
  const isOpen = c.status === "open";
1257
1937
  const statusColor = c.status === "open" ? "green" : c.status === "completed" ? "cyan" : "yellow";
1258
1938
  const statusLabel = ceremonyStatusLabel(c.status);
1259
- return /* @__PURE__ */ jsxs7(Box7, { gap: 2, children: [
1260
- /* @__PURE__ */ jsxs7(Text7, { color: isSelected ? "cyan" : isOpen ? void 0 : "gray", children: [
1939
+ return /* @__PURE__ */ jsxs8(Box8, { gap: 2, children: [
1940
+ /* @__PURE__ */ jsxs8(Text8, { color: isSelected ? "cyan" : isOpen ? void 0 : "gray", children: [
1261
1941
  isSelected ? "\u25B6 " : " ",
1262
1942
  c.name.padEnd(30)
1263
1943
  ] }),
1264
- /* @__PURE__ */ jsxs7(Text7, { color: statusColor, children: [
1944
+ /* @__PURE__ */ jsxs8(Text8, { color: statusColor, children: [
1265
1945
  "[",
1266
1946
  statusLabel,
1267
1947
  "]"
1268
1948
  ] }),
1269
- /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
1949
+ /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
1270
1950
  c.track_count,
1271
1951
  " track",
1272
1952
  c.track_count !== 1 ? "s" : "",
@@ -1277,65 +1957,391 @@ function App({ ceremonyId: initialCeremonyId, displayName: initialDisplayName })
1277
1957
  ] })
1278
1958
  ] }, c.id);
1279
1959
  }),
1280
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: " " + "\u2500".repeat(60) }),
1960
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: " " + "\u2500".repeat(60) }),
1281
1961
  (() => {
1282
1962
  const c = ceremonies[selectedIdx];
1283
1963
  if (!c) return null;
1964
+ const navHint = /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
1965
+ "\u2191/\u2193 select \xB7 Enter join \xB7 ",
1966
+ QUIT_HINTS.quit
1967
+ ] }) });
1284
1968
  if (c.status === "initialized") {
1285
- return /* @__PURE__ */ jsxs7(Text7, { color: "yellow", children: [
1286
- " ",
1287
- "This ceremony is not yet open for contributions. The Umbra team is still preparing the circuits \u2014 please check back shortly."
1969
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
1970
+ /* @__PURE__ */ jsxs8(Text8, { color: "yellow", children: [
1971
+ " ",
1972
+ "This ceremony is not yet open for contributions. The Umbra team is still preparing the circuits \u2014 please check back shortly."
1973
+ ] }),
1974
+ navHint
1288
1975
  ] });
1289
1976
  }
1290
1977
  if (c.status === "finalizing") {
1291
- return /* @__PURE__ */ jsxs7(Text7, { color: "yellow", children: [
1292
- " ",
1293
- "Contributions are closed. The ceremony is computing the final verification key \u2014 no further contributions can be added."
1978
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
1979
+ /* @__PURE__ */ jsxs8(Text8, { color: "yellow", children: [
1980
+ " ",
1981
+ "Contributions are closed. The ceremony is computing the final verification key \u2014 no further contributions can be added."
1982
+ ] }),
1983
+ navHint
1294
1984
  ] });
1295
1985
  }
1296
1986
  if (c.status === "completed") {
1297
- return /* @__PURE__ */ jsxs7(Text7, { color: "cyan", children: [
1298
- " ",
1299
- "Ceremony complete. The verification keys are finalised \u2014 thank you to everyone who contributed."
1987
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
1988
+ /* @__PURE__ */ jsxs8(Text8, { children: [
1989
+ " ",
1990
+ /* @__PURE__ */ jsx8(Text8, { color: "greenBright", bold: true, children: "\u2714 Ceremony complete." }),
1991
+ " ",
1992
+ /* @__PURE__ */ jsx8(Text8, { color: "cyanBright", children: "The verification keys are finalised" }),
1993
+ /* @__PURE__ */ jsx8(Text8, { color: "blueBright", children: " \u2014 thank you to everyone who contributed." })
1994
+ ] }),
1995
+ navHint
1300
1996
  ] });
1301
1997
  }
1302
- return /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "\u2191/\u2193 select \xB7 Enter join \xB7 Q quit" });
1998
+ return navHint;
1303
1999
  })()
1304
2000
  ] })
1305
2001
  ] });
1306
2002
  }
1307
2003
  if (screen.name === "loading") {
1308
- return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1309
- /* @__PURE__ */ jsx7(Header, { ceremony }),
1310
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "Loading tracks..." })
2004
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2005
+ /* @__PURE__ */ jsx8(Header, { ceremony }),
2006
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "Loading tracks..." }),
2007
+ /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: QUIT_HINTS.quit }) })
2008
+ ] });
2009
+ }
2010
+ if (screen.name === "mode-select") {
2011
+ const { tracks } = screen;
2012
+ const openCount = tracks.filter((t) => t.status === "open").length;
2013
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2014
+ /* @__PURE__ */ jsx8(Header, { ceremony }),
2015
+ /* @__PURE__ */ jsxs8(
2016
+ Box8,
2017
+ {
2018
+ flexDirection: "column",
2019
+ borderStyle: "round",
2020
+ borderColor: "cyan",
2021
+ paddingX: 2,
2022
+ paddingY: 0,
2023
+ children: [
2024
+ /* @__PURE__ */ jsx8(Text8, { bold: true, color: "cyan", children: "Automate?" }),
2025
+ /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
2026
+ openCount,
2027
+ " circuit",
2028
+ openCount === 1 ? "" : "s",
2029
+ " open \xB7 auto runs the whole loop"
2030
+ ] }),
2031
+ /* @__PURE__ */ jsxs8(Box8, { marginTop: 1, flexDirection: "column", children: [
2032
+ /* @__PURE__ */ jsxs8(Text8, { children: [
2033
+ /* @__PURE__ */ jsx8(Text8, { bold: true, color: "green", children: "Enter" }),
2034
+ " ",
2035
+ "auto \xB7 entropy once, every circuit in order"
2036
+ ] }),
2037
+ /* @__PURE__ */ jsxs8(Text8, { children: [
2038
+ /* @__PURE__ */ jsx8(Text8, { bold: true, color: "yellow", children: "N" }),
2039
+ " ",
2040
+ "manual \xB7 pick circuits one at a time"
2041
+ ] }),
2042
+ /* @__PURE__ */ jsxs8(Text8, { children: [
2043
+ /* @__PURE__ */ jsx8(Text8, { bold: true, color: "magenta", children: "K" }),
2044
+ " ",
2045
+ "input:",
2046
+ " ",
2047
+ /* @__PURE__ */ jsx8(Text8, { bold: true, color: entropyMode === "mouse" ? "cyan" : "yellow", children: entropyMode }),
2048
+ " ",
2049
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "(press K to toggle)" })
2050
+ ] })
2051
+ ] })
2052
+ ]
2053
+ }
2054
+ ),
2055
+ /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
2056
+ "\u232B back \xB7 ",
2057
+ QUIT_HINTS.quit
2058
+ ] }) })
2059
+ ] });
2060
+ }
2061
+ if (screen.name === "auto-entropy") {
2062
+ const { tracks } = screen;
2063
+ const onComplete = (masterSeed) => {
2064
+ const ordered = orderTracksForAuto(tracks, contributed);
2065
+ setScreen({
2066
+ name: "auto-running",
2067
+ tracks,
2068
+ masterSeed,
2069
+ remaining: ordered,
2070
+ retryQueue: [],
2071
+ retryingPhase: false,
2072
+ done: [],
2073
+ current: null,
2074
+ subPhase: { kind: "queueing" }
2075
+ });
2076
+ };
2077
+ const onError = (e) => setScreen({ name: "error", message: e.message, recoverable: false });
2078
+ if (entropyMode === "mouse") {
2079
+ return /* @__PURE__ */ jsx8(
2080
+ MouseEntropyCollector,
2081
+ {
2082
+ title: `Auto-mode entropy \xB7 one drawing seeds every circuit (HKDF per circuit)`,
2083
+ onComplete,
2084
+ onError
2085
+ }
2086
+ );
2087
+ }
2088
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2089
+ /* @__PURE__ */ jsx8(Header, { ceremony, subtitle: "Auto-mode entropy \xB7 seeds every circuit (HKDF)" }),
2090
+ /* @__PURE__ */ jsx8(EntropyCollector, { onComplete, onError })
2091
+ ] });
2092
+ }
2093
+ if (screen.name === "auto-running") {
2094
+ const totalCount = screen.done.length + screen.remaining.length + screen.retryQueue.length + (screen.current ? 1 : 0);
2095
+ const verifiedCount = screen.done.filter((d) => d.result === "verified").length;
2096
+ const failedCount = screen.done.filter((d) => d.result === "failed").length;
2097
+ const currentLabel = screen.current ? `current: ${screen.current.circuit_name}` : "advancing to next circuit\u2026";
2098
+ const progressStrip = /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", marginBottom: 1, children: [
2099
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "\u2500".repeat(60) }),
2100
+ /* @__PURE__ */ jsxs8(Text8, { children: [
2101
+ /* @__PURE__ */ jsx8(Text8, { bold: true, color: "cyan", children: "Auto-mode" }),
2102
+ " \xB7 ",
2103
+ /* @__PURE__ */ jsx8(Text8, { children: verifiedCount }),
2104
+ "/",
2105
+ /* @__PURE__ */ jsx8(Text8, { children: totalCount }),
2106
+ " verified",
2107
+ failedCount > 0 && /* @__PURE__ */ jsxs8(Fragment3, { children: [
2108
+ " \xB7 ",
2109
+ /* @__PURE__ */ jsxs8(Text8, { color: "yellow", children: [
2110
+ failedCount,
2111
+ " failed"
2112
+ ] }),
2113
+ screen.retryingPhase ? " (retrying)" : ""
2114
+ ] }),
2115
+ screen.retryQueue.length > 0 && !screen.retryingPhase && /* @__PURE__ */ jsxs8(Fragment3, { children: [
2116
+ " \xB7 ",
2117
+ /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
2118
+ screen.retryQueue.length,
2119
+ " queued for retry"
2120
+ ] })
2121
+ ] })
2122
+ ] }),
2123
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: currentLabel }),
2124
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "\u2500".repeat(60) })
2125
+ ] });
2126
+ if (screen.subPhase.kind === "releasing") {
2127
+ const sub = screen.subPhase;
2128
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2129
+ /* @__PURE__ */ jsx8(
2130
+ Header,
2131
+ {
2132
+ ceremony,
2133
+ subtitle: `Auto-mode \xB7 releasing slot for ${sub.circuitName}`
2134
+ }
2135
+ ),
2136
+ progressStrip,
2137
+ /* @__PURE__ */ jsxs8(Text8, { color: "yellow", children: [
2138
+ "\u23F3 Releasing slot for ",
2139
+ sub.circuitName,
2140
+ "\u2026"
2141
+ ] }),
2142
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "Waiting for the server to confirm the slot is free before joining the next circuit's queue." }),
2143
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: QUIT_HINTS.abortRun })
2144
+ ] });
2145
+ }
2146
+ if (screen.subPhase.kind === "queueing") {
2147
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2148
+ /* @__PURE__ */ jsx8(Header, { ceremony, subtitle: "Auto-mode \xB7 joining next queue" }),
2149
+ progressStrip,
2150
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "Picking the next circuit\u2026" }),
2151
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: QUIT_HINTS.abortSlot })
2152
+ ] });
2153
+ }
2154
+ if (screen.subPhase.kind === "waiting") {
2155
+ const sub = screen.subPhase;
2156
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2157
+ /* @__PURE__ */ jsx8(
2158
+ Header,
2159
+ {
2160
+ ceremony,
2161
+ subtitle: `Auto-mode \xB7 waiting for slot \xB7 ${sub.circuitName}`
2162
+ }
2163
+ ),
2164
+ progressStrip,
2165
+ /* @__PURE__ */ jsx8(
2166
+ QueueView,
2167
+ {
2168
+ ceremonyId: activeCeremonyId,
2169
+ trackId: sub.trackId,
2170
+ token: session.session_token,
2171
+ onReady: (status) => {
2172
+ const entropy = deriveCircuitEntropy(
2173
+ screen.masterSeed,
2174
+ activeCeremonyId,
2175
+ sub.circuitName
2176
+ );
2177
+ setScreen({
2178
+ ...screen,
2179
+ subPhase: {
2180
+ kind: "contributing",
2181
+ trackId: sub.trackId,
2182
+ circuitName: sub.circuitName,
2183
+ slotStatus: status,
2184
+ entropy
2185
+ }
2186
+ });
2187
+ },
2188
+ onError: (e) => {
2189
+ const failedTrack = screen.current;
2190
+ const newDone = {
2191
+ trackId: failedTrack.id,
2192
+ circuitName: failedTrack.circuit_name,
2193
+ result: "failed",
2194
+ reason: e.message
2195
+ };
2196
+ setScreen({
2197
+ ...screen,
2198
+ current: null,
2199
+ subPhase: { kind: "queueing" },
2200
+ done: [...screen.done, newDone],
2201
+ retryQueue: screen.retryingPhase ? screen.retryQueue : [...screen.retryQueue, failedTrack]
2202
+ });
2203
+ }
2204
+ }
2205
+ ),
2206
+ /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
2207
+ "\u232B Backspace skip this circuit \xB7 ",
2208
+ QUIT_HINTS.abortRun
2209
+ ] })
2210
+ ] });
2211
+ }
2212
+ if (screen.subPhase.kind === "contributing") {
2213
+ const sub = screen.subPhase;
2214
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2215
+ /* @__PURE__ */ jsx8(
2216
+ Header,
2217
+ {
2218
+ ceremony,
2219
+ subtitle: `Auto-mode \xB7 contributing \xB7 ${sub.circuitName}`
2220
+ }
2221
+ ),
2222
+ progressStrip,
2223
+ /* @__PURE__ */ jsx8(
2224
+ ContributeFlow,
2225
+ {
2226
+ ceremonyId: activeCeremonyId,
2227
+ trackId: sub.trackId,
2228
+ token: session.session_token,
2229
+ slotStatus: sub.slotStatus,
2230
+ entropy: sub.entropy,
2231
+ displayName: displayName2,
2232
+ onComplete: async (contributionId, receipt) => {
2233
+ const local = {
2234
+ contributionId,
2235
+ sequenceNumber: receipt?.sequence_number ?? 0,
2236
+ contributionHash: receipt?.contribution_hash ?? "",
2237
+ circuitName: sub.circuitName,
2238
+ ceremonyId: activeCeremonyId,
2239
+ verifiedAt: receipt?.verified_at ?? (/* @__PURE__ */ new Date()).toISOString()
2240
+ };
2241
+ await recordContribution(sub.trackId, local);
2242
+ setContributed((prev) => {
2243
+ const existing = prev[sub.trackId] ?? [];
2244
+ if (existing.some((c) => c.contributionId === local.contributionId)) return prev;
2245
+ return { ...prev, [sub.trackId]: [...existing, local] };
2246
+ });
2247
+ const successTrack = screen.current;
2248
+ setScreen({
2249
+ ...screen,
2250
+ current: null,
2251
+ subPhase: { kind: "queueing" },
2252
+ done: [
2253
+ ...screen.done,
2254
+ {
2255
+ trackId: successTrack.id,
2256
+ circuitName: successTrack.circuit_name,
2257
+ result: "verified",
2258
+ hash: local.contributionHash
2259
+ }
2260
+ ]
2261
+ });
2262
+ },
2263
+ onError: (e) => {
2264
+ const failedTrack = screen.current;
2265
+ setScreen({
2266
+ ...screen,
2267
+ current: null,
2268
+ subPhase: { kind: "queueing" },
2269
+ done: [
2270
+ ...screen.done,
2271
+ {
2272
+ trackId: failedTrack.id,
2273
+ circuitName: failedTrack.circuit_name,
2274
+ result: "failed",
2275
+ reason: e.message
2276
+ }
2277
+ ],
2278
+ retryQueue: screen.retryingPhase ? screen.retryQueue : [...screen.retryQueue, failedTrack]
2279
+ });
2280
+ }
2281
+ }
2282
+ )
2283
+ ] });
2284
+ }
2285
+ return null;
2286
+ }
2287
+ if (screen.name === "auto-summary") {
2288
+ const verified = screen.outcomes.filter((o) => o.result === "verified");
2289
+ const failed = screen.outcomes.filter((o) => o.result === "failed");
2290
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2291
+ /* @__PURE__ */ jsx8(Header, { ceremony, subtitle: "Auto-mode \xB7 complete" }),
2292
+ /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", gap: 1, children: [
2293
+ /* @__PURE__ */ jsx8(Text8, { bold: true, color: failed.length === 0 ? "greenBright" : "yellow", children: failed.length === 0 ? `\u2714 Auto-mode complete \u2014 ${verified.length} circuits verified` : `\u26A0 Auto-mode complete with ${failed.length} failure${failed.length === 1 ? "" : "s"}` }),
2294
+ verified.length > 0 && /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2295
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, color: "cyan", children: "Verified contributions:" }),
2296
+ verified.map((o, i) => /* @__PURE__ */ jsxs8(Box8, { paddingLeft: 2, children: [
2297
+ /* @__PURE__ */ jsx8(Text8, { color: "greenBright", children: "\u2714 " }),
2298
+ /* @__PURE__ */ jsx8(Text8, { color: "blueBright", children: elideMiddle(o.circuitName, 42).padEnd(44) }),
2299
+ /* @__PURE__ */ jsx8(Text8, { color: "cyanBright", children: o.hash ? o.hash.slice(0, 16) + "\u2026" : "" })
2300
+ ] }, i))
2301
+ ] }),
2302
+ failed.length > 0 && /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2303
+ /* @__PURE__ */ jsx8(Text8, { color: "yellow", children: "Failed (retry already attempted):" }),
2304
+ failed.map((o, i) => /* @__PURE__ */ jsxs8(Box8, { paddingLeft: 2, flexDirection: "column", children: [
2305
+ /* @__PURE__ */ jsxs8(Box8, { children: [
2306
+ /* @__PURE__ */ jsx8(Text8, { color: "red", children: "\u2717 " }),
2307
+ /* @__PURE__ */ jsx8(Text8, { children: elideMiddle(o.circuitName, 42) })
2308
+ ] }),
2309
+ /* @__PURE__ */ jsx8(Box8, { paddingLeft: 4, children: /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: o.reason ?? "(no reason recorded)" }) })
2310
+ ] }, i)),
2311
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "\u232B Backspace / B \u2014 back to track list (you can retry failed circuits manually)" })
2312
+ ] }),
2313
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: QUIT_HINTS.quit })
2314
+ ] })
1311
2315
  ] });
1312
2316
  }
1313
2317
  if (screen.name === "error") {
1314
- 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";
1315
- return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1316
- /* @__PURE__ */ jsx7(Header, { ceremony }),
1317
- /* @__PURE__ */ jsxs7(Text7, { color: "red", bold: true, children: [
2318
+ const backHint = !initialCeremonyId ? `\u232B Backspace \u2014 back to ceremony list \xB7 ${QUIT_HINTS.quit}` : screen.recoverable ? `\u232B Backspace / B to go back \xB7 ${QUIT_HINTS.quit}` : QUIT_HINTS.quit;
2319
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2320
+ /* @__PURE__ */ jsx8(Header, { ceremony }),
2321
+ /* @__PURE__ */ jsxs8(Text8, { color: "red", bold: true, children: [
1318
2322
  "\u2717 ",
1319
2323
  screen.message
1320
2324
  ] }),
1321
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: backHint })
2325
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: backHint })
1322
2326
  ] });
1323
2327
  }
1324
2328
  if (screen.name === "tracks") {
1325
2329
  const { tracks } = screen;
1326
2330
  const openTracks = tracks.filter((t) => t.status === "open");
1327
- const myContributions = Object.values(contributed).filter(
1328
- (c) => c.ceremonyId === activeCeremonyId
1329
- );
1330
- const TabBar = () => /* @__PURE__ */ jsxs7(Box7, { gap: 1, marginBottom: 1, children: [
1331
- /* @__PURE__ */ jsx7(Text7, { bold: tab === 0, color: tab === 0 ? "cyan" : void 0, dimColor: tab !== 0, children: tab === 0 ? "[ Dashboard ]" : " Dashboard " }),
1332
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "|" }),
1333
- /* @__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}) ` }),
1334
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: " Tab to switch" })
2331
+ const myContributions = Object.values(contributed).flat().filter((c) => c.ceremonyId === activeCeremonyId).sort((a, b) => {
2332
+ const ta = a.verifiedAt ? Date.parse(a.verifiedAt) : 0;
2333
+ const tb = b.verifiedAt ? Date.parse(b.verifiedAt) : 0;
2334
+ return tb - ta;
2335
+ });
2336
+ const TabBar = () => /* @__PURE__ */ jsxs8(Box8, { gap: 1, marginBottom: 1, children: [
2337
+ /* @__PURE__ */ jsx8(Text8, { bold: tab === 0, color: tab === 0 ? "cyan" : void 0, dimColor: tab !== 0, children: tab === 0 ? "[ Dashboard ]" : " Dashboard " }),
2338
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "|" }),
2339
+ /* @__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}) ` }),
2340
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: " Tab to switch" })
1335
2341
  ] });
1336
- return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1337
- /* @__PURE__ */ jsx7(Header, { ceremony }),
1338
- /* @__PURE__ */ jsx7(TabBar, {}),
2342
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2343
+ /* @__PURE__ */ jsx8(Header, { ceremony }),
2344
+ /* @__PURE__ */ jsx8(TabBar, {}),
1339
2345
  tab === 0 ? (
1340
2346
  // ── Dashboard tab ────────────────────────────────────────────────
1341
2347
  // CIRCUIT column is sized to fit the longest mainnet name
@@ -1343,106 +2349,118 @@ function App({ ceremonyId: initialCeremonyId, displayName: initialDisplayName })
1343
2349
  // longer than that are middle-elided so the disambiguating suffix
1344
2350
  // (-n1/-n2/-n4) stays visible — previously a hard slice(0,24)
1345
2351
  // showed every claim variant as `claim-deposit-into-confi..`.
1346
- /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1347
- /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2352
+ /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2353
+ /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
1348
2354
  " CIRCUIT".padEnd(46),
1349
2355
  "TOTAL".padEnd(8),
1350
2356
  "QUEUE".padEnd(8),
1351
2357
  "STATUS".padEnd(14),
1352
2358
  "MY CONTRIBUTIONS"
1353
2359
  ] }),
1354
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: " " + "\u2500".repeat(90) }),
2360
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: " " + "\u2500".repeat(90) }),
1355
2361
  tracks.map((t, i) => {
1356
2362
  const isSelected = i === selectedIdx;
1357
2363
  const canContribute = t.status === "open";
1358
2364
  const nameDisplay = elideMiddle(t.circuit_name, 42);
1359
2365
  const statusColor = t.status === "open" ? "green" : t.status === "finalized" ? "cyan" : "yellow";
1360
- const myContrib = contributed[t.id];
1361
- return /* @__PURE__ */ jsxs7(Box7, { children: [
1362
- /* @__PURE__ */ jsxs7(Text7, { color: isSelected ? "cyan" : canContribute ? void 0 : "gray", children: [
2366
+ const myRounds = roundCount(contributed, t.id);
2367
+ return /* @__PURE__ */ jsxs8(Box8, { children: [
2368
+ /* @__PURE__ */ jsxs8(Text8, { color: isSelected ? "cyan" : canContribute ? void 0 : "gray", children: [
1363
2369
  isSelected ? "\u25B6 " : " ",
1364
2370
  nameDisplay.padEnd(44),
1365
2371
  String(t.contribution_count).padEnd(8),
1366
2372
  String(t.queue_depth).padEnd(8)
1367
2373
  ] }),
1368
- /* @__PURE__ */ jsx7(Text7, { color: statusColor, children: trackStatusLabel(t.status).padEnd(14) }),
1369
- myContrib ? isSelected ? /* @__PURE__ */ jsxs7(Text7, { color: "green", children: [
1370
- "\u2713 contributed (round #",
1371
- myContrib.sequenceNumber,
1372
- ") \u2014 already done"
1373
- ] }) : /* @__PURE__ */ jsxs7(Text7, { color: "green", children: [
1374
- "\u2713 contributed (round #",
1375
- myContrib.sequenceNumber,
2374
+ /* @__PURE__ */ jsx8(Text8, { color: statusColor, children: trackStatusLabel(t.status).padEnd(14) }),
2375
+ myRounds > 0 ? canContribute && isSelected ? /* @__PURE__ */ jsxs8(Text8, { color: "green", children: [
2376
+ "\u2713 contributed (",
2377
+ myRounds,
2378
+ " round",
2379
+ myRounds === 1 ? "" : "s",
2380
+ ") \u2014",
2381
+ " ",
2382
+ /* @__PURE__ */ jsx8(Text8, { color: "yellow", children: "Enter to add more" })
2383
+ ] }) : /* @__PURE__ */ jsxs8(Text8, { color: "green", children: [
2384
+ "\u2713 contributed (",
2385
+ myRounds,
2386
+ " round",
2387
+ myRounds === 1 ? "" : "s",
1376
2388
  ")"
1377
- ] }) : 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" })
2389
+ ] }) : 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" })
1378
2390
  ] }, t.id);
1379
2391
  }),
1380
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: " " + "\u2500".repeat(70) }),
1381
- 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: [
1382
- "\u2191/\u2193 select \xB7 Enter contribute \xB7 R refresh \xB7 Q quit",
2392
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: " " + "\u2500".repeat(70) }),
2393
+ 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: [
2394
+ "\u2191/\u2193 select \xB7 Enter contribute \xB7 R refresh \xB7 ",
2395
+ QUIT_HINTS.quit,
1383
2396
  !initialCeremonyId ? " \xB7 \u232B back to ceremony list" : ""
1384
2397
  ] })
1385
2398
  ] })
1386
2399
  ) : (
1387
2400
  // ── My Contributions tab ─────────────────────────────────────────
1388
- /* @__PURE__ */ jsx7(Box7, { flexDirection: "column", children: myContributions.length === 0 ? /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", gap: 1, children: [
1389
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "No contributions yet." }),
1390
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "Switch to Dashboard tab and press Enter on a circuit to contribute." })
1391
- ] }) : /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1392
- /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2401
+ /* @__PURE__ */ jsx8(Box8, { flexDirection: "column", children: myContributions.length === 0 ? /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", gap: 1, children: [
2402
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "No contributions yet." }),
2403
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "Switch to Dashboard tab and press Enter on a circuit to contribute." })
2404
+ ] }) : /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2405
+ /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
1393
2406
  " CIRCUIT".padEnd(44),
1394
2407
  "ROUND".padEnd(7),
1395
2408
  "VERIFIED AT".padEnd(22)
1396
2409
  ] }),
1397
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: " " + "\u2500".repeat(90) }),
2410
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: " " + "\u2500".repeat(90) }),
1398
2411
  myContributions.map((c, i) => {
1399
2412
  const isSel = i === contribCursor;
1400
- return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1401
- /* @__PURE__ */ jsxs7(Box7, { children: [
1402
- /* @__PURE__ */ jsx7(Text7, { color: isSel ? "cyan" : "green", children: isSel ? "\u25B6 " : " " }),
1403
- /* @__PURE__ */ jsx7(Text7, { bold: isSel, children: elideMiddle(c.circuitName, 40).padEnd(42) }),
1404
- /* @__PURE__ */ jsx7(Text7, { color: "yellow", children: ("#" + c.sequenceNumber).padEnd(7) }),
1405
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: c.verifiedAt ? new Date(c.verifiedAt).toLocaleString() : "\u2014" })
2413
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2414
+ /* @__PURE__ */ jsxs8(Box8, { children: [
2415
+ /* @__PURE__ */ jsx8(Text8, { color: isSel ? "cyan" : "green", children: isSel ? "\u25B6 " : " " }),
2416
+ /* @__PURE__ */ jsx8(Text8, { bold: isSel, children: elideMiddle(c.circuitName, 40).padEnd(42) }),
2417
+ /* @__PURE__ */ jsx8(Text8, { color: "yellow", children: ("#" + c.sequenceNumber).padEnd(7) }),
2418
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: c.verifiedAt ? new Date(c.verifiedAt).toLocaleString() : "\u2014" })
1406
2419
  ] }),
1407
- /* @__PURE__ */ jsx7(Box7, { paddingLeft: 4, children: /* @__PURE__ */ jsxs7(Text7, { color: isSel ? "cyan" : "gray", dimColor: !isSel, children: [
2420
+ /* @__PURE__ */ jsx8(Box8, { paddingLeft: 4, children: /* @__PURE__ */ jsxs8(Text8, { color: isSel ? "cyan" : "gray", dimColor: !isSel, children: [
1408
2421
  "hash:",
1409
2422
  " ",
1410
2423
  c.contributionHash ? c.contributionHash : "(pending \u2014 verify still in flight)"
1411
2424
  ] }) })
1412
2425
  ] }, i);
1413
2426
  }),
1414
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: " " + "\u2500".repeat(90) }),
1415
- copyToast ? /* @__PURE__ */ jsx7(Text7, { color: "green", children: copyToast }) : /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2427
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: " " + "\u2500".repeat(90) }),
2428
+ copyToast ? /* @__PURE__ */ jsx8(Text8, { color: "green", children: copyToast }) : /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
1416
2429
  "Total: ",
1417
2430
  myContributions.length,
1418
2431
  " contribution",
1419
2432
  myContributions.length !== 1 ? "s" : "",
1420
2433
  " \xB7 ",
1421
- "\u2191/\u2193 select \xB7 C copy hash \xB7 Tab switch \xB7 Q quit"
2434
+ "\u2191/\u2193 select \xB7 C copy hash \xB7 Tab switch \xB7 ",
2435
+ QUIT_HINTS.quit
1422
2436
  ] })
1423
2437
  ] }) })
1424
2438
  )
1425
2439
  ] });
1426
2440
  }
1427
2441
  if (screen.name === "joining") {
1428
- return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1429
- /* @__PURE__ */ jsx7(Header, { ceremony }),
1430
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "Joining queue..." })
2442
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2443
+ /* @__PURE__ */ jsx8(Header, { ceremony }),
2444
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "Joining queue..." }),
2445
+ /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: QUIT_HINTS.quit }) })
1431
2446
  ] });
1432
2447
  }
1433
2448
  if (screen.name === "queue") {
1434
2449
  const { trackId, circuitName } = screen;
1435
- const priorContrib = contributed[trackId];
1436
- return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1437
- /* @__PURE__ */ jsx7(Header, { ceremony, subtitle: `Circuit: ${circuitName}` }),
1438
- priorContrib && /* @__PURE__ */ jsx7(Box7, { marginBottom: 1, paddingX: 1, children: /* @__PURE__ */ jsxs7(Text7, { color: "yellow", children: [
1439
- "\u26A0 You already contributed to this circuit (round #",
1440
- priorContrib.sequenceNumber,
1441
- ").",
1442
- " ",
1443
- "Contributing again is allowed and adds more entropy."
2450
+ const priorRounds = roundCount(contributed, trackId);
2451
+ const lastContrib = latestContribution(contributed, trackId);
2452
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2453
+ /* @__PURE__ */ jsx8(Header, { ceremony, subtitle: `Circuit: ${circuitName}` }),
2454
+ priorRounds > 0 && lastContrib && /* @__PURE__ */ jsx8(Box8, { marginBottom: 1, paddingX: 1, children: /* @__PURE__ */ jsxs8(Text8, { color: "yellow", children: [
2455
+ "\u26A0 You've contributed to this circuit ",
2456
+ priorRounds,
2457
+ " time",
2458
+ priorRounds === 1 ? "" : "s",
2459
+ " (latest: round #",
2460
+ lastContrib.sequenceNumber,
2461
+ "). Each additional round adds more entropy and strengthens the ceremony \u2014 go ahead."
1444
2462
  ] }) }),
1445
- /* @__PURE__ */ jsx7(
2463
+ /* @__PURE__ */ jsx8(
1446
2464
  QueueView,
1447
2465
  {
1448
2466
  ceremonyId: activeCeremonyId,
@@ -1452,27 +2470,42 @@ function App({ ceremonyId: initialCeremonyId, displayName: initialDisplayName })
1452
2470
  onError: (e) => setScreen({ name: "error", message: e.message, recoverable: true })
1453
2471
  }
1454
2472
  ),
1455
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "\u232B Backspace to go back \xB7 Q to quit" })
2473
+ /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
2474
+ "\u232B Backspace to go back \xB7 ",
2475
+ QUIT_HINTS.quit
2476
+ ] })
1456
2477
  ] });
1457
2478
  }
1458
2479
  if (screen.name === "entropy") {
1459
2480
  const { trackId, circuitName, slotStatus } = screen;
1460
- return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1461
- /* @__PURE__ */ jsx7(Header, { ceremony, subtitle: `Circuit: ${circuitName} \xB7 Your turn!` }),
1462
- /* @__PURE__ */ jsx7(
1463
- EntropyCollector,
2481
+ const onComplete = (entropy) => setScreen({ name: "contribute", trackId, circuitName, slotStatus, entropy });
2482
+ const onError = (e) => setScreen({ name: "error", message: e.message, recoverable: false });
2483
+ if (entropyMode === "mouse") {
2484
+ return /* @__PURE__ */ jsx8(
2485
+ MouseEntropyCollector,
1464
2486
  {
1465
- onComplete: (entropy) => setScreen({ name: "contribute", trackId, circuitName, slotStatus, entropy }),
1466
- onError: (e) => setScreen({ name: "error", message: e.message, recoverable: false })
2487
+ title: `Your turn \xB7 ${circuitName} \u2014 draw entropy for this contribution`,
2488
+ onComplete,
2489
+ onError
1467
2490
  }
1468
- )
2491
+ );
2492
+ }
2493
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2494
+ /* @__PURE__ */ jsx8(
2495
+ Header,
2496
+ {
2497
+ ceremony,
2498
+ subtitle: `Circuit: ${circuitName} \xB7 Type to collect entropy`
2499
+ }
2500
+ ),
2501
+ /* @__PURE__ */ jsx8(EntropyCollector, { onComplete, onError })
1469
2502
  ] });
1470
2503
  }
1471
2504
  if (screen.name === "contribute") {
1472
2505
  const { trackId, circuitName, slotStatus, entropy } = screen;
1473
- return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1474
- /* @__PURE__ */ jsx7(Header, { ceremony, subtitle: `Circuit: ${circuitName} \xB7 Contributing` }),
1475
- /* @__PURE__ */ jsx7(
2506
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2507
+ /* @__PURE__ */ jsx8(Header, { ceremony, subtitle: `Circuit: ${circuitName} \xB7 Contributing` }),
2508
+ /* @__PURE__ */ jsx8(
1476
2509
  ContributeFlow,
1477
2510
  {
1478
2511
  ceremonyId: activeCeremonyId,
@@ -1491,7 +2524,13 @@ function App({ ceremonyId: initialCeremonyId, displayName: initialDisplayName })
1491
2524
  verifiedAt: receipt?.verified_at ?? (/* @__PURE__ */ new Date()).toISOString()
1492
2525
  };
1493
2526
  await recordContribution(trackId, local);
1494
- setContributed((prev) => ({ ...prev, [trackId]: local }));
2527
+ setContributed((prev) => {
2528
+ const existing = prev[trackId] ?? [];
2529
+ if (existing.some((c) => c.contributionId === local.contributionId)) {
2530
+ return prev;
2531
+ }
2532
+ return { ...prev, [trackId]: [...existing, local] };
2533
+ });
1495
2534
  setScreen({ name: "done", contribution: local });
1496
2535
  },
1497
2536
  onError: (e) => setScreen({ name: "error", message: e.message, recoverable: false })
@@ -1501,41 +2540,52 @@ function App({ ceremonyId: initialCeremonyId, displayName: initialDisplayName })
1501
2540
  }
1502
2541
  if (screen.name === "done") {
1503
2542
  const { contribution } = screen;
1504
- return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1505
- /* @__PURE__ */ jsx7(Header, { ceremony }),
1506
- /* @__PURE__ */ jsx7(Attestation, { contribution }),
1507
- /* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "\u232B Backspace / B = contribute to another circuit \xB7 Q to quit" }) })
2543
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
2544
+ /* @__PURE__ */ jsx8(Header, { ceremony }),
2545
+ /* @__PURE__ */ jsx8(Attestation, { contribution }),
2546
+ /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
2547
+ "\u232B Backspace / B = contribute to another circuit \xB7 ",
2548
+ QUIT_HINTS.quit
2549
+ ] }) })
1508
2550
  ] });
1509
2551
  }
1510
2552
  return null;
1511
2553
  }
1512
2554
 
1513
2555
  // src/index.tsx
1514
- import { jsx as jsx8 } from "react/jsx-runtime";
2556
+ import { jsx as jsx9 } from "react/jsx-runtime";
1515
2557
  var ceremonyId = process.env["CEREMONY_ID"] ?? "";
1516
2558
  var displayName = process.env["CONTRIBUTOR_NAME"];
1517
2559
  process.stdout.write("\x1B[?1049h\x1B[H");
1518
2560
  function restoreScreen() {
1519
2561
  process.stdout.write("\x1B[?1049l");
1520
2562
  }
1521
- async function gracefulExit(code = 0) {
2563
+ var exiting = false;
2564
+ async function gracefulExit(code = 0, deliberate = false) {
2565
+ if (exiting) {
2566
+ process.exit(code);
2567
+ }
2568
+ exiting = true;
1522
2569
  restoreScreen();
1523
2570
  await runQueueCleanup();
2571
+ if (deliberate) {
2572
+ await runSessionCleanup();
2573
+ }
1524
2574
  process.exit(code);
1525
2575
  }
1526
- process.on("SIGINT", () => {
1527
- gracefulExit(0).catch(() => {
1528
- restoreScreen();
1529
- process.exit(0);
1530
- });
2576
+ globalThis.__ceremonyGracefulExit = gracefulExit;
2577
+ process.on("SIGTSTP", () => {
1531
2578
  });
1532
- process.on("SIGTERM", () => {
1533
- gracefulExit(0).catch(() => {
1534
- restoreScreen();
1535
- process.exit(0);
2579
+ var DELIBERATE_SIGNALS = /* @__PURE__ */ new Set(["SIGINT"]);
2580
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
2581
+ process.on(signal, () => {
2582
+ gracefulExit(0, DELIBERATE_SIGNALS.has(signal)).catch(() => {
2583
+ restoreScreen();
2584
+ process.exit(0);
2585
+ });
1536
2586
  });
1537
- });
1538
- var { waitUntilExit } = render(/* @__PURE__ */ jsx8(App, { ceremonyId, displayName }), {
2587
+ }
2588
+ var { waitUntilExit } = render(/* @__PURE__ */ jsx9(App, { ceremonyId, displayName }), {
1539
2589
  exitOnCtrlC: false
1540
2590
  });
1541
2591
  await waitUntilExit();