@umbra-privacy/ceremony 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1125 -0
- package/package.json +45 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.tsx
|
|
4
|
+
import { render } from "ink";
|
|
5
|
+
|
|
6
|
+
// src/components/App.tsx
|
|
7
|
+
import { useEffect as useEffect4, useState as useState4 } from "react";
|
|
8
|
+
import { Box as Box6, Text as Text6, useApp, useInput as useInput2 } from "ink";
|
|
9
|
+
|
|
10
|
+
// src/cleanup.ts
|
|
11
|
+
var pendingLeaveQueue = null;
|
|
12
|
+
function setQueueCleanup(fn) {
|
|
13
|
+
pendingLeaveQueue = fn;
|
|
14
|
+
}
|
|
15
|
+
function clearQueueCleanup() {
|
|
16
|
+
pendingLeaveQueue = null;
|
|
17
|
+
}
|
|
18
|
+
async function runQueueCleanup() {
|
|
19
|
+
if (!pendingLeaveQueue) return;
|
|
20
|
+
pendingLeaveQueue();
|
|
21
|
+
pendingLeaveQueue = null;
|
|
22
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// src/session.ts
|
|
26
|
+
import { readFile as readFile2, unlink as unlink2, writeFile } from "fs/promises";
|
|
27
|
+
import { homedir } from "os";
|
|
28
|
+
import { join } from "path";
|
|
29
|
+
|
|
30
|
+
// src/api.ts
|
|
31
|
+
import { createHash } from "crypto";
|
|
32
|
+
import { createWriteStream } from "fs";
|
|
33
|
+
import { readFile, unlink } from "fs/promises";
|
|
34
|
+
import { pipeline } from "stream/promises";
|
|
35
|
+
import { Readable } from "stream";
|
|
36
|
+
var DEFAULT_API_URL = "http://ceremony.api.umbraprivacy.com";
|
|
37
|
+
var BASE = (process.env["CEREMONY_API_URL"] ?? DEFAULT_API_URL).replace(/\/$/, "");
|
|
38
|
+
async function request(path, options = {}) {
|
|
39
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
40
|
+
...options,
|
|
41
|
+
headers: {
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
...options.headers
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
const body = await res.json().catch(() => null);
|
|
47
|
+
if (!res.ok) {
|
|
48
|
+
const code = body?.error?.code ?? "UNKNOWN";
|
|
49
|
+
const message = body?.error?.message ?? `HTTP ${res.status}`;
|
|
50
|
+
throw Object.assign(new Error(message), { code, status: res.status });
|
|
51
|
+
}
|
|
52
|
+
return body;
|
|
53
|
+
}
|
|
54
|
+
function bearer(token) {
|
|
55
|
+
return { Authorization: `Bearer ${token}` };
|
|
56
|
+
}
|
|
57
|
+
var api = {
|
|
58
|
+
// Sessions
|
|
59
|
+
createSession(displayName2) {
|
|
60
|
+
return request("/api/sessions", {
|
|
61
|
+
method: "POST",
|
|
62
|
+
body: JSON.stringify({ display_name: displayName2 })
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
// Ceremonies
|
|
66
|
+
listCeremonies() {
|
|
67
|
+
return request("/api/ceremonies");
|
|
68
|
+
},
|
|
69
|
+
getCeremonyStatus(ceremonyId2) {
|
|
70
|
+
return request(`/api/ceremonies/${ceremonyId2}/status`);
|
|
71
|
+
},
|
|
72
|
+
// Tracks
|
|
73
|
+
listTracks(ceremonyId2, token) {
|
|
74
|
+
return request(`/api/ceremonies/${ceremonyId2}/tracks`, {
|
|
75
|
+
headers: bearer(token)
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
// Queue
|
|
79
|
+
joinQueue(ceremonyId2, trackId, token) {
|
|
80
|
+
return request(`/api/ceremonies/${ceremonyId2}/tracks/${trackId}/queue`, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: bearer(token)
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
leaveQueue(ceremonyId2, trackId, token) {
|
|
86
|
+
return request(`/api/ceremonies/${ceremonyId2}/tracks/${trackId}/queue`, {
|
|
87
|
+
method: "DELETE",
|
|
88
|
+
headers: bearer(token)
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
// Slot polling
|
|
92
|
+
myTurn(ceremonyId2, trackId, token) {
|
|
93
|
+
return request(
|
|
94
|
+
`/api/ceremonies/${ceremonyId2}/tracks/${trackId}/my-turn`,
|
|
95
|
+
{ headers: bearer(token) }
|
|
96
|
+
);
|
|
97
|
+
},
|
|
98
|
+
// Contribution
|
|
99
|
+
signalUploaded(ceremonyId2, trackId, contributionId, token) {
|
|
100
|
+
return request(
|
|
101
|
+
`/api/ceremonies/${ceremonyId2}/tracks/${trackId}/signal-uploaded`,
|
|
102
|
+
{
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: bearer(token),
|
|
105
|
+
body: JSON.stringify({ contribution_id: contributionId })
|
|
106
|
+
}
|
|
107
|
+
);
|
|
108
|
+
},
|
|
109
|
+
getReceipt(ceremonyId2, contributionId) {
|
|
110
|
+
return request(
|
|
111
|
+
`/api/ceremonies/${ceremonyId2}/contributions/${contributionId}/receipt`
|
|
112
|
+
);
|
|
113
|
+
},
|
|
114
|
+
// Admin
|
|
115
|
+
adminDashboard(ceremonyId2, adminKey) {
|
|
116
|
+
return request(`/api/admin/ceremonies/${ceremonyId2}/dashboard`, {
|
|
117
|
+
headers: { "X-Admin-Key": adminKey }
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
var ChallengeIntegrityError = class extends Error {
|
|
122
|
+
name = "ChallengeIntegrityError";
|
|
123
|
+
};
|
|
124
|
+
var SHA256_HEX_RE = /^[0-9a-f]{64}$/;
|
|
125
|
+
async function downloadFile(presignedUrl, destPath, onProgress, expectedSha256) {
|
|
126
|
+
let expected = null;
|
|
127
|
+
if (expectedSha256 !== void 0 && expectedSha256 !== null) {
|
|
128
|
+
const candidate = expectedSha256.toLowerCase();
|
|
129
|
+
if (!SHA256_HEX_RE.test(candidate)) {
|
|
130
|
+
throw new ChallengeIntegrityError(
|
|
131
|
+
`server sent malformed challenge_sha256 (${JSON.stringify(expectedSha256)})`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
expected = candidate;
|
|
135
|
+
}
|
|
136
|
+
const res = await fetch(presignedUrl);
|
|
137
|
+
if (!res.ok) throw new Error(`S3 download failed: HTTP ${res.status}`);
|
|
138
|
+
const contentLength = res.headers.get("content-length");
|
|
139
|
+
const total = contentLength ? parseInt(contentLength, 10) : null;
|
|
140
|
+
const hasher = expected ? createHash("sha256") : null;
|
|
141
|
+
let received = 0;
|
|
142
|
+
const readable = Readable.fromWeb(res.body);
|
|
143
|
+
readable.on("data", (chunk) => {
|
|
144
|
+
received += chunk.length;
|
|
145
|
+
hasher?.update(chunk);
|
|
146
|
+
onProgress?.(received, total);
|
|
147
|
+
});
|
|
148
|
+
await pipeline(readable, createWriteStream(destPath));
|
|
149
|
+
if (hasher && expected) {
|
|
150
|
+
const actual = hasher.digest("hex");
|
|
151
|
+
if (actual !== expected) {
|
|
152
|
+
await unlink(destPath).catch(() => void 0);
|
|
153
|
+
throw new ChallengeIntegrityError(
|
|
154
|
+
`challenge sha256 mismatch (expected ${expected}, got ${actual})`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async function uploadFile(presignedUrl, filePath) {
|
|
160
|
+
const data = await readFile(filePath);
|
|
161
|
+
const res = await fetch(presignedUrl, {
|
|
162
|
+
method: "PUT",
|
|
163
|
+
body: data,
|
|
164
|
+
headers: { "Content-Type": "application/octet-stream" }
|
|
165
|
+
});
|
|
166
|
+
if (!res.ok) {
|
|
167
|
+
throw new Error(`S3 upload failed: HTTP ${res.status}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/session.ts
|
|
172
|
+
var SESSION_FILE = process.env["CEREMONY_SESSION_FILE"] ?? join(homedir(), ".ceremony-session");
|
|
173
|
+
async function loadOrCreateSession(displayName2) {
|
|
174
|
+
try {
|
|
175
|
+
const raw = await readFile2(SESSION_FILE, "utf8");
|
|
176
|
+
const session2 = JSON.parse(raw);
|
|
177
|
+
if (session2.session_token && session2.contributor_id) {
|
|
178
|
+
return session2;
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
}
|
|
182
|
+
const response = await api.createSession(displayName2);
|
|
183
|
+
const session = {
|
|
184
|
+
session_token: response.session_token,
|
|
185
|
+
contributor_id: response.contributor_id
|
|
186
|
+
};
|
|
187
|
+
await writeFile(SESSION_FILE, JSON.stringify(session, null, 2), {
|
|
188
|
+
encoding: "utf8",
|
|
189
|
+
mode: 384
|
|
190
|
+
});
|
|
191
|
+
return session;
|
|
192
|
+
}
|
|
193
|
+
async function clearSession() {
|
|
194
|
+
await unlink2(SESSION_FILE).catch(() => void 0);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// src/store.ts
|
|
198
|
+
import { readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
|
|
199
|
+
import { homedir as homedir2 } from "os";
|
|
200
|
+
import { join as join2 } from "path";
|
|
201
|
+
var STORE_FILE = process.env["CEREMONY_CONTRIBUTIONS_FILE"] ?? join2(homedir2(), ".ceremony-contributions.json");
|
|
202
|
+
async function load() {
|
|
203
|
+
try {
|
|
204
|
+
const raw = await readFile3(STORE_FILE, "utf8");
|
|
205
|
+
return JSON.parse(raw);
|
|
206
|
+
} catch {
|
|
207
|
+
return {};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
async function save(store) {
|
|
211
|
+
await writeFile2(STORE_FILE, JSON.stringify(store, null, 2), "utf8");
|
|
212
|
+
}
|
|
213
|
+
async function getContributions() {
|
|
214
|
+
return load();
|
|
215
|
+
}
|
|
216
|
+
async function recordContribution(trackId, contribution) {
|
|
217
|
+
const store = await load();
|
|
218
|
+
store[trackId] = contribution;
|
|
219
|
+
await save(store);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// src/components/Header.tsx
|
|
223
|
+
import { Box, Text } from "ink";
|
|
224
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
225
|
+
var STATUS_COLOR = {
|
|
226
|
+
open: "green",
|
|
227
|
+
initialized: "yellow",
|
|
228
|
+
finalizing: "yellow",
|
|
229
|
+
completed: "cyan"
|
|
230
|
+
};
|
|
231
|
+
function Header({ ceremony, subtitle }) {
|
|
232
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [
|
|
233
|
+
/* @__PURE__ */ jsxs(Box, { children: [
|
|
234
|
+
/* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: "\u25C6 CEREMONY TUI" }),
|
|
235
|
+
ceremony && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
236
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: " \xB7 " }),
|
|
237
|
+
/* @__PURE__ */ jsx(Text, { bold: true, children: ceremony.name }),
|
|
238
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: " [" }),
|
|
239
|
+
/* @__PURE__ */ jsx(Text, { color: STATUS_COLOR[ceremony.status] ?? "white", children: ceremony.status }),
|
|
240
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: "]" })
|
|
241
|
+
] })
|
|
242
|
+
] }),
|
|
243
|
+
ceremony && /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
|
|
244
|
+
ceremony.total_tracks,
|
|
245
|
+
" circuit",
|
|
246
|
+
ceremony.total_tracks !== 1 ? "s" : "",
|
|
247
|
+
" \xB7 ",
|
|
248
|
+
ceremony.total_contributions,
|
|
249
|
+
" total contribution",
|
|
250
|
+
ceremony.total_contributions !== 1 ? "s" : ""
|
|
251
|
+
] }),
|
|
252
|
+
subtitle && /* @__PURE__ */ jsx(Text, { color: "cyan", dimColor: true, children: subtitle }),
|
|
253
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2500".repeat(56) })
|
|
254
|
+
] });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// src/components/QueueView.tsx
|
|
258
|
+
import { useEffect, useRef, useState } from "react";
|
|
259
|
+
import { Box as Box2, Text as Text2 } from "ink";
|
|
260
|
+
import Spinner from "ink-spinner";
|
|
261
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
262
|
+
var POLL_FAST_MS = 5e3;
|
|
263
|
+
var POLL_SLOW_MS = 15e3;
|
|
264
|
+
function QueueView({ ceremonyId: ceremonyId2, trackId, token, onReady, onError }) {
|
|
265
|
+
const [status, setStatus] = useState(null);
|
|
266
|
+
const [pollErr, setPollErr] = useState(null);
|
|
267
|
+
const [tick, setTick] = useState(0);
|
|
268
|
+
const timeoutRef = useRef(null);
|
|
269
|
+
useEffect(() => {
|
|
270
|
+
const id = setInterval(() => setTick((t) => (t + 1) % 4), 500);
|
|
271
|
+
return () => clearInterval(id);
|
|
272
|
+
}, []);
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
let cancelled = false;
|
|
275
|
+
async function poll() {
|
|
276
|
+
try {
|
|
277
|
+
const s = await api.myTurn(ceremonyId2, trackId, token);
|
|
278
|
+
if (cancelled) return;
|
|
279
|
+
setStatus(s);
|
|
280
|
+
setPollErr(null);
|
|
281
|
+
if (s.ready) {
|
|
282
|
+
onReady(s);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const interval = s.queue_position <= 2 ? POLL_FAST_MS : POLL_SLOW_MS;
|
|
286
|
+
timeoutRef.current = setTimeout(poll, interval);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
if (cancelled) return;
|
|
289
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
290
|
+
setPollErr(msg);
|
|
291
|
+
timeoutRef.current = setTimeout(poll, POLL_SLOW_MS);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
poll();
|
|
295
|
+
return () => {
|
|
296
|
+
cancelled = true;
|
|
297
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
298
|
+
};
|
|
299
|
+
}, [ceremonyId2, trackId, token]);
|
|
300
|
+
const dots = ".".repeat(tick + 1).padEnd(4, " ");
|
|
301
|
+
if (!status) {
|
|
302
|
+
return /* @__PURE__ */ jsxs2(Box2, { gap: 1, children: [
|
|
303
|
+
/* @__PURE__ */ jsx2(Spinner, { type: "dots" }),
|
|
304
|
+
/* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
|
|
305
|
+
"Connecting",
|
|
306
|
+
dots
|
|
307
|
+
] })
|
|
308
|
+
] });
|
|
309
|
+
}
|
|
310
|
+
const waitMins = Math.ceil((status.estimated_wait_secs ?? 0) / 60);
|
|
311
|
+
const expiresAt = status.slot_expires_at ? new Date(status.slot_expires_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : null;
|
|
312
|
+
const fastPoll = status.queue_position <= 2;
|
|
313
|
+
return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", gap: 1, children: [
|
|
314
|
+
/* @__PURE__ */ jsxs2(Box2, { gap: 2, children: [
|
|
315
|
+
/* @__PURE__ */ jsxs2(Text2, { children: [
|
|
316
|
+
"Position",
|
|
317
|
+
" ",
|
|
318
|
+
/* @__PURE__ */ jsx2(Text2, { bold: true, color: "yellow", children: status.queue_position }),
|
|
319
|
+
status.queue_depth > 0 && /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
|
|
320
|
+
" of ",
|
|
321
|
+
status.queue_depth
|
|
322
|
+
] })
|
|
323
|
+
] }),
|
|
324
|
+
/* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "\xB7" }),
|
|
325
|
+
/* @__PURE__ */ jsxs2(Text2, { children: [
|
|
326
|
+
"Estimated wait:",
|
|
327
|
+
" ",
|
|
328
|
+
/* @__PURE__ */ jsxs2(Text2, { bold: true, color: "cyan", children: [
|
|
329
|
+
"~",
|
|
330
|
+
waitMins,
|
|
331
|
+
" min"
|
|
332
|
+
] })
|
|
333
|
+
] })
|
|
334
|
+
] }),
|
|
335
|
+
status.status === "exporting" || status.status === "your_turn" || status.status === "ready_to_download" ? /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", children: /* @__PURE__ */ jsx2(Text2, { color: "green", children: status.status === "exporting" ? "Preparing your challenge file \u2014 hang tight..." : "Challenge ready \u2014 loading contribution flow..." }) }) : status.active_since ? /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", children: /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
|
|
336
|
+
"Another contributor is active",
|
|
337
|
+
expiresAt ? ` \xB7 slot expires at ${expiresAt}` : ""
|
|
338
|
+
] }) }) : status.queue_position > 1 ? (
|
|
339
|
+
// Slot is idle but people are ahead — they joined and left without releasing.
|
|
340
|
+
// timeout_watchdog will clear each stale slot after contribution_timeout_secs.
|
|
341
|
+
/* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
|
|
342
|
+
"Slot is idle \u2014 waiting for positions ahead to respond or time out",
|
|
343
|
+
" ",
|
|
344
|
+
"(up to ~5 min each)"
|
|
345
|
+
] })
|
|
346
|
+
) : (
|
|
347
|
+
// Position 1, slot idle — advance_queue should fire shortly.
|
|
348
|
+
/* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "Slot is idle \u2014 your turn is being prepared..." })
|
|
349
|
+
),
|
|
350
|
+
pollErr ? /* @__PURE__ */ jsxs2(Text2, { color: "red", children: [
|
|
351
|
+
"Poll error: ",
|
|
352
|
+
pollErr,
|
|
353
|
+
" \u2014 retrying",
|
|
354
|
+
dots
|
|
355
|
+
] }) : /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
|
|
356
|
+
"Checking every ",
|
|
357
|
+
fastPoll ? 5 : 15,
|
|
358
|
+
"s",
|
|
359
|
+
dots
|
|
360
|
+
] })
|
|
361
|
+
] });
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// src/components/EntropyCollector.tsx
|
|
365
|
+
import { useRef as useRef2, useState as useState2 } from "react";
|
|
366
|
+
import { Box as Box3, Text as Text3, useInput } from "ink";
|
|
367
|
+
|
|
368
|
+
// src/entropy.ts
|
|
369
|
+
import { createHash as createHash2, randomBytes } from "crypto";
|
|
370
|
+
function sha512(buf) {
|
|
371
|
+
return createHash2("sha512").update(buf).digest();
|
|
372
|
+
}
|
|
373
|
+
function buildEntropyFromKeystrokes(chars, timingsNs) {
|
|
374
|
+
const charBuf = Buffer.from(chars);
|
|
375
|
+
const timingBuf = Buffer.alloc(timingsNs.length * 8);
|
|
376
|
+
timingsNs.forEach((t, i) => timingBuf.writeBigUInt64BE(t, i * 8));
|
|
377
|
+
const keystrokeHash = sha512(Buffer.concat([charBuf, timingBuf]));
|
|
378
|
+
const osHash = sha512(randomBytes(64));
|
|
379
|
+
return createHash2("sha512").update(keystrokeHash).update(osHash).digest("hex");
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// src/components/EntropyCollector.tsx
|
|
383
|
+
import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
384
|
+
var TARGET = 20;
|
|
385
|
+
function EntropyCollector({ onComplete, onError }) {
|
|
386
|
+
const [count, setCount] = useState2(0);
|
|
387
|
+
const [done, setDone] = useState2(false);
|
|
388
|
+
const charsRef = useRef2([]);
|
|
389
|
+
const timingsRef = useRef2([]);
|
|
390
|
+
const lastRef = useRef2(process.hrtime.bigint());
|
|
391
|
+
const completedRef = useRef2(false);
|
|
392
|
+
useInput((input) => {
|
|
393
|
+
if (completedRef.current) return;
|
|
394
|
+
const now = process.hrtime.bigint();
|
|
395
|
+
timingsRef.current.push(now - lastRef.current);
|
|
396
|
+
lastRef.current = now;
|
|
397
|
+
for (const b of Buffer.from(input ?? "", "utf8")) charsRef.current.push(b);
|
|
398
|
+
const newCount = timingsRef.current.length;
|
|
399
|
+
setCount(newCount);
|
|
400
|
+
if (newCount >= TARGET) {
|
|
401
|
+
completedRef.current = true;
|
|
402
|
+
setDone(true);
|
|
403
|
+
try {
|
|
404
|
+
const entropy = buildEntropyFromKeystrokes(charsRef.current, timingsRef.current);
|
|
405
|
+
setTimeout(() => onComplete(entropy), 250);
|
|
406
|
+
} catch (e) {
|
|
407
|
+
onError(e instanceof Error ? e : new Error(String(e)));
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
const filled = Math.round(count / TARGET * 24);
|
|
412
|
+
const bar = "\u2588".repeat(filled) + "\u2591".repeat(24 - filled);
|
|
413
|
+
const pct = Math.round(count / TARGET * 100);
|
|
414
|
+
const stars = "*".repeat(Math.min(count, 32));
|
|
415
|
+
if (done) {
|
|
416
|
+
return /* @__PURE__ */ jsxs3(Box3, { gap: 2, children: [
|
|
417
|
+
/* @__PURE__ */ jsx3(Text3, { color: "green", bold: true, children: "\u2713" }),
|
|
418
|
+
/* @__PURE__ */ jsxs3(Text3, { children: [
|
|
419
|
+
"Entropy collected \u2014 [",
|
|
420
|
+
bar,
|
|
421
|
+
"] 100%"
|
|
422
|
+
] })
|
|
423
|
+
] });
|
|
424
|
+
}
|
|
425
|
+
return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", gap: 1, children: [
|
|
426
|
+
/* @__PURE__ */ jsx3(Text3, { bold: true, children: "Type anything to generate entropy:" }),
|
|
427
|
+
/* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "Keystroke timing (nanosecond intervals) is the randomness source." }),
|
|
428
|
+
/* @__PURE__ */ jsxs3(Box3, { marginTop: 1, children: [
|
|
429
|
+
/* @__PURE__ */ jsx3(Text3, { dimColor: true, children: " > " }),
|
|
430
|
+
/* @__PURE__ */ jsx3(Text3, { color: "green", children: stars }),
|
|
431
|
+
/* @__PURE__ */ jsx3(Text3, { color: "yellow", children: "\u2588" })
|
|
432
|
+
] }),
|
|
433
|
+
/* @__PURE__ */ jsxs3(Box3, { gap: 2, marginTop: 1, children: [
|
|
434
|
+
/* @__PURE__ */ jsxs3(Text3, { color: "cyan", children: [
|
|
435
|
+
"[",
|
|
436
|
+
bar,
|
|
437
|
+
"]"
|
|
438
|
+
] }),
|
|
439
|
+
/* @__PURE__ */ jsxs3(Text3, { children: [
|
|
440
|
+
/* @__PURE__ */ jsx3(Text3, { bold: true, color: count > 0 ? "green" : "yellow", children: count }),
|
|
441
|
+
/* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
|
|
442
|
+
"/",
|
|
443
|
+
TARGET,
|
|
444
|
+
" keystrokes (",
|
|
445
|
+
pct,
|
|
446
|
+
"%)"
|
|
447
|
+
] })
|
|
448
|
+
] })
|
|
449
|
+
] }),
|
|
450
|
+
/* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "Shown as * \u2014 your actual input is never revealed." })
|
|
451
|
+
] });
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// src/components/ContributeFlow.tsx
|
|
455
|
+
import { useEffect as useEffect3, useState as useState3 } from "react";
|
|
456
|
+
import { Box as Box4, Text as Text4 } from "ink";
|
|
457
|
+
import Spinner2 from "ink-spinner";
|
|
458
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
459
|
+
import { join as join4 } from "path";
|
|
460
|
+
|
|
461
|
+
// src/contribute.ts
|
|
462
|
+
import { tmpdir } from "os";
|
|
463
|
+
import { join as join3 } from "path";
|
|
464
|
+
import { unlink as unlink3 } from "fs/promises";
|
|
465
|
+
import { createRequire } from "module";
|
|
466
|
+
var require2 = createRequire(import.meta.url);
|
|
467
|
+
var snarkjs = require2("snarkjs");
|
|
468
|
+
async function runBellmanContribution(challengePath, entropy, contributorName = "anonymous") {
|
|
469
|
+
const responsePath = join3(tmpdir(), `ceremony-response-${Date.now()}.mpcparams`);
|
|
470
|
+
const curve = await snarkjs.curves.getCurveFromName("bn128");
|
|
471
|
+
try {
|
|
472
|
+
await snarkjs.zKey.bellmanContribute(curve, challengePath, responsePath, entropy);
|
|
473
|
+
} finally {
|
|
474
|
+
await curve.terminate();
|
|
475
|
+
}
|
|
476
|
+
return responsePath;
|
|
477
|
+
}
|
|
478
|
+
async function cleanupTemp(path) {
|
|
479
|
+
await unlink3(path).catch(() => void 0);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// src/components/ContributeFlow.tsx
|
|
483
|
+
import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
484
|
+
var STEP_LABELS = ["downloading", "computing", "uploading", "signalling", "verifying"];
|
|
485
|
+
var STEP_INDEX = {
|
|
486
|
+
downloading: 0,
|
|
487
|
+
computing: 1,
|
|
488
|
+
uploading: 2,
|
|
489
|
+
signalling: 3,
|
|
490
|
+
verifying: 4
|
|
491
|
+
};
|
|
492
|
+
function ContributeFlow(props) {
|
|
493
|
+
const { ceremonyId: ceremonyId2, trackId, token, slotStatus, entropy, displayName: displayName2 } = props;
|
|
494
|
+
const [step, setStep] = useState3({ name: "downloading", bytesReceived: 0, total: null });
|
|
495
|
+
useEffect3(() => {
|
|
496
|
+
let cancelled = false;
|
|
497
|
+
const challengePath = join4(tmpdir2(), `ceremony-challenge-${Date.now()}.mpcparams`);
|
|
498
|
+
let responsePath = null;
|
|
499
|
+
async function run() {
|
|
500
|
+
try {
|
|
501
|
+
const DOWNLOAD_RETRIES = 3;
|
|
502
|
+
let downloadErr = null;
|
|
503
|
+
for (let attempt = 1; attempt <= DOWNLOAD_RETRIES; attempt++) {
|
|
504
|
+
if (attempt > 1) {
|
|
505
|
+
await cleanupTemp(challengePath).catch(() => void 0);
|
|
506
|
+
const delaySecs = attempt - 1;
|
|
507
|
+
if (!cancelled) setStep({ name: "downloading", bytesReceived: 0, total: null });
|
|
508
|
+
await new Promise((r) => setTimeout(r, delaySecs * 1e3));
|
|
509
|
+
}
|
|
510
|
+
try {
|
|
511
|
+
await downloadFile(
|
|
512
|
+
slotStatus.download_url,
|
|
513
|
+
challengePath,
|
|
514
|
+
(received, total) => {
|
|
515
|
+
if (!cancelled) setStep({ name: "downloading", bytesReceived: received, total });
|
|
516
|
+
},
|
|
517
|
+
slotStatus.challenge_sha256 ?? null
|
|
518
|
+
);
|
|
519
|
+
downloadErr = null;
|
|
520
|
+
break;
|
|
521
|
+
} catch (err) {
|
|
522
|
+
if (err instanceof ChallengeIntegrityError) throw err;
|
|
523
|
+
downloadErr = err instanceof Error ? err : new Error(String(err));
|
|
524
|
+
if (attempt === DOWNLOAD_RETRIES) throw downloadErr;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (cancelled) return;
|
|
528
|
+
setStep({ name: "computing" });
|
|
529
|
+
responsePath = await runBellmanContribution(challengePath, entropy, displayName2);
|
|
530
|
+
await cleanupTemp(challengePath);
|
|
531
|
+
if (cancelled) return;
|
|
532
|
+
setStep({ name: "uploading" });
|
|
533
|
+
await uploadFile(slotStatus.upload_url, responsePath);
|
|
534
|
+
await cleanupTemp(responsePath);
|
|
535
|
+
responsePath = null;
|
|
536
|
+
if (cancelled) return;
|
|
537
|
+
setStep({ name: "signalling" });
|
|
538
|
+
await api.signalUploaded(ceremonyId2, trackId, slotStatus.contribution_id, token);
|
|
539
|
+
if (cancelled) return;
|
|
540
|
+
setStep({ name: "verifying", attempt: 1 });
|
|
541
|
+
let receipt = null;
|
|
542
|
+
let lastErr = null;
|
|
543
|
+
for (let i = 0; i < 30; i++) {
|
|
544
|
+
if (cancelled) return;
|
|
545
|
+
setStep({ name: "verifying", attempt: i + 1 });
|
|
546
|
+
try {
|
|
547
|
+
receipt = await api.getReceipt(ceremonyId2, slotStatus.contribution_id);
|
|
548
|
+
lastErr = null;
|
|
549
|
+
break;
|
|
550
|
+
} catch (err) {
|
|
551
|
+
lastErr = err instanceof Error ? err : new Error(String(err));
|
|
552
|
+
await new Promise((r) => setTimeout(r, 3e3));
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
if (cancelled) return;
|
|
556
|
+
if (!receipt && lastErr) {
|
|
557
|
+
props.onError(lastErr);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
await props.onComplete(slotStatus.contribution_id, receipt);
|
|
561
|
+
} catch (err) {
|
|
562
|
+
if (responsePath) await cleanupTemp(responsePath).catch(() => void 0);
|
|
563
|
+
await cleanupTemp(challengePath).catch(() => void 0);
|
|
564
|
+
if (!cancelled) props.onError(err instanceof Error ? err : new Error(String(err)));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
run();
|
|
568
|
+
return () => {
|
|
569
|
+
cancelled = true;
|
|
570
|
+
};
|
|
571
|
+
}, []);
|
|
572
|
+
const currentIdx = STEP_INDEX[step.name] ?? 0;
|
|
573
|
+
return /* @__PURE__ */ jsx4(Box4, { flexDirection: "column", gap: 1, children: STEP_LABELS.map((label, i) => {
|
|
574
|
+
const isDone = i < currentIdx;
|
|
575
|
+
const isActive = i === currentIdx;
|
|
576
|
+
const isPending = i > currentIdx;
|
|
577
|
+
let indicator;
|
|
578
|
+
if (isDone) indicator = /* @__PURE__ */ jsx4(Text4, { color: "green", children: "\u2713" });
|
|
579
|
+
else if (isActive) indicator = /* @__PURE__ */ jsx4(Spinner2, { type: "dots" });
|
|
580
|
+
else indicator = /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "\u25CB" });
|
|
581
|
+
let detail = null;
|
|
582
|
+
if (isActive && step.name === "downloading" && step.total) {
|
|
583
|
+
const pct = Math.round(step.bytesReceived / step.total * 100);
|
|
584
|
+
const filled = Math.round(pct / 5);
|
|
585
|
+
const bar = "\u2588".repeat(filled) + "\u2591".repeat(20 - filled);
|
|
586
|
+
detail = /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
|
|
587
|
+
" [",
|
|
588
|
+
bar,
|
|
589
|
+
"] ",
|
|
590
|
+
pct,
|
|
591
|
+
"%"
|
|
592
|
+
] });
|
|
593
|
+
}
|
|
594
|
+
if (isActive && step.name === "verifying") {
|
|
595
|
+
detail = /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
|
|
596
|
+
" worker verifying... (",
|
|
597
|
+
step.attempt,
|
|
598
|
+
"/30)"
|
|
599
|
+
] });
|
|
600
|
+
}
|
|
601
|
+
return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
|
|
602
|
+
/* @__PURE__ */ jsxs4(Box4, { gap: 2, children: [
|
|
603
|
+
indicator,
|
|
604
|
+
/* @__PURE__ */ jsxs4(
|
|
605
|
+
Text4,
|
|
606
|
+
{
|
|
607
|
+
color: isDone ? "green" : isActive ? "white" : void 0,
|
|
608
|
+
bold: isActive,
|
|
609
|
+
dimColor: isPending,
|
|
610
|
+
children: [
|
|
611
|
+
label.charAt(0).toUpperCase() + label.slice(1),
|
|
612
|
+
" ",
|
|
613
|
+
isActive && label === "computing" && /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "(entropy stays local)" }),
|
|
614
|
+
isActive && label === "uploading" && /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "(~544 bytes)" })
|
|
615
|
+
]
|
|
616
|
+
}
|
|
617
|
+
)
|
|
618
|
+
] }),
|
|
619
|
+
detail
|
|
620
|
+
] }, label);
|
|
621
|
+
}) });
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// src/components/Attestation.tsx
|
|
625
|
+
import { Box as Box5, Text as Text5 } from "ink";
|
|
626
|
+
import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
627
|
+
function Attestation({ contribution }) {
|
|
628
|
+
const hashShort = contribution.contributionHash ? `${contribution.contributionHash.slice(0, 16)}...${contribution.contributionHash.slice(-8)}` : "(pending verification)";
|
|
629
|
+
const verifiedAt = contribution.verifiedAt ? new Date(contribution.verifiedAt).toLocaleString() : null;
|
|
630
|
+
return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", gap: 1, children: [
|
|
631
|
+
/* @__PURE__ */ jsx5(Text5, { bold: true, color: "green", children: "\u2713 Contribution verified!" }),
|
|
632
|
+
/* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", gap: 0, children: [
|
|
633
|
+
/* @__PURE__ */ jsxs5(Box5, { gap: 2, children: [
|
|
634
|
+
/* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "Circuit" }),
|
|
635
|
+
/* @__PURE__ */ jsx5(Text5, { bold: true, children: contribution.circuitName })
|
|
636
|
+
] }),
|
|
637
|
+
/* @__PURE__ */ jsxs5(Box5, { gap: 2, children: [
|
|
638
|
+
/* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "Round " }),
|
|
639
|
+
/* @__PURE__ */ jsxs5(Text5, { bold: true, children: [
|
|
640
|
+
"#",
|
|
641
|
+
contribution.sequenceNumber
|
|
642
|
+
] })
|
|
643
|
+
] }),
|
|
644
|
+
/* @__PURE__ */ jsxs5(Box5, { gap: 2, children: [
|
|
645
|
+
/* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "Hash " }),
|
|
646
|
+
/* @__PURE__ */ jsx5(Text5, { color: "cyan", children: hashShort })
|
|
647
|
+
] }),
|
|
648
|
+
verifiedAt && /* @__PURE__ */ jsxs5(Box5, { gap: 2, children: [
|
|
649
|
+
/* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "Time " }),
|
|
650
|
+
/* @__PURE__ */ jsx5(Text5, { dimColor: true, children: verifiedAt })
|
|
651
|
+
] })
|
|
652
|
+
] }),
|
|
653
|
+
contribution.contributionHash && /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", marginTop: 1, children: [
|
|
654
|
+
/* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "Full hash (share this to prove participation):" }),
|
|
655
|
+
/* @__PURE__ */ jsx5(Text5, { color: "cyan", wrap: "wrap", children: contribution.contributionHash })
|
|
656
|
+
] })
|
|
657
|
+
] });
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// src/components/App.tsx
|
|
661
|
+
import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
662
|
+
function App({ ceremonyId: initialCeremonyId, displayName: displayName2 = "anonymous" }) {
|
|
663
|
+
const { exit } = useApp();
|
|
664
|
+
const [activeCeremonyId, setActiveCeremonyId] = useState4(initialCeremonyId);
|
|
665
|
+
const [screen, setScreen] = useState4(
|
|
666
|
+
initialCeremonyId ? { name: "loading" } : { name: "ceremony-picker", ceremonies: [], loading: true }
|
|
667
|
+
);
|
|
668
|
+
const [ceremony, setCeremony] = useState4(null);
|
|
669
|
+
const [session, setSession] = useState4(null);
|
|
670
|
+
const [contributed, setContributed] = useState4({});
|
|
671
|
+
const [selectedIdx, setSelectedIdx] = useState4(0);
|
|
672
|
+
const [tab, setTab] = useState4(0);
|
|
673
|
+
useEffect4(() => {
|
|
674
|
+
if (!initialCeremonyId) {
|
|
675
|
+
loadCeremonies();
|
|
676
|
+
} else {
|
|
677
|
+
boot(initialCeremonyId);
|
|
678
|
+
}
|
|
679
|
+
}, []);
|
|
680
|
+
async function loadCeremonies() {
|
|
681
|
+
try {
|
|
682
|
+
const { ceremonies } = await api.listCeremonies();
|
|
683
|
+
setScreen({ name: "ceremony-picker", ceremonies, loading: false });
|
|
684
|
+
setSelectedIdx(0);
|
|
685
|
+
} catch (e) {
|
|
686
|
+
setScreen({ name: "error", message: e.message ?? "Failed to load ceremonies.", recoverable: false });
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
async function boot(cId) {
|
|
690
|
+
try {
|
|
691
|
+
const [s, cs, contribs] = await Promise.all([
|
|
692
|
+
loadOrCreateSession(displayName2),
|
|
693
|
+
api.getCeremonyStatus(cId).catch(() => null),
|
|
694
|
+
getContributions()
|
|
695
|
+
]);
|
|
696
|
+
setSession(s);
|
|
697
|
+
setCeremony(cs);
|
|
698
|
+
setContributed(contribs);
|
|
699
|
+
await loadTracks(s, cId);
|
|
700
|
+
} catch (e) {
|
|
701
|
+
setScreen({ name: "error", message: e.message, recoverable: false });
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
async function loadTracks(s, cId) {
|
|
705
|
+
try {
|
|
706
|
+
const { tracks } = await api.listTracks(cId, s.session_token);
|
|
707
|
+
if (tracks.length === 0) {
|
|
708
|
+
setScreen({ name: "error", message: "No tracks found in this ceremony.", recoverable: false });
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
setScreen({ name: "tracks", tracks });
|
|
712
|
+
} catch (e) {
|
|
713
|
+
if (e.code === "INVALID_SESSION" || e.status === 401) {
|
|
714
|
+
try {
|
|
715
|
+
await clearSession();
|
|
716
|
+
const fresh = await loadOrCreateSession(displayName2);
|
|
717
|
+
setSession(fresh);
|
|
718
|
+
const { tracks } = await api.listTracks(cId, fresh.session_token);
|
|
719
|
+
if (tracks.length === 0) {
|
|
720
|
+
setScreen({ name: "error", message: "No tracks found in this ceremony.", recoverable: false });
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
setScreen({ name: "tracks", tracks });
|
|
724
|
+
} catch (e2) {
|
|
725
|
+
setScreen({ name: "error", message: e2.message ?? "Failed to load tracks.", recoverable: false });
|
|
726
|
+
}
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
setScreen({ name: "error", message: e.message ?? "Failed to load tracks.", recoverable: false });
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
async function joinTrack(track) {
|
|
733
|
+
if (!session) return;
|
|
734
|
+
setScreen({ name: "joining" });
|
|
735
|
+
try {
|
|
736
|
+
await api.joinQueue(activeCeremonyId, track.id, session.session_token);
|
|
737
|
+
} catch (e) {
|
|
738
|
+
if (e.code !== "ALREADY_IN_QUEUE") {
|
|
739
|
+
setScreen({ name: "error", message: e.message ?? "Failed to join queue.", recoverable: true });
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
setScreen({ name: "queue", trackId: track.id, circuitName: track.circuit_name });
|
|
744
|
+
}
|
|
745
|
+
function goHome() {
|
|
746
|
+
if (!session) return;
|
|
747
|
+
setScreen({ name: "loading" });
|
|
748
|
+
loadTracks(session, activeCeremonyId);
|
|
749
|
+
}
|
|
750
|
+
function goCeremonyPicker() {
|
|
751
|
+
setScreen({ name: "ceremony-picker", ceremonies: [], loading: true });
|
|
752
|
+
loadCeremonies();
|
|
753
|
+
}
|
|
754
|
+
useEffect4(() => {
|
|
755
|
+
if (screen.name === "queue" && session) {
|
|
756
|
+
const { trackId } = screen;
|
|
757
|
+
setQueueCleanup(() => {
|
|
758
|
+
api.leaveQueue(activeCeremonyId, trackId, session.session_token).catch(() => {
|
|
759
|
+
});
|
|
760
|
+
});
|
|
761
|
+
} else {
|
|
762
|
+
clearQueueCleanup();
|
|
763
|
+
}
|
|
764
|
+
}, [screen.name]);
|
|
765
|
+
useInput2((input, key) => {
|
|
766
|
+
const q = input.toLowerCase();
|
|
767
|
+
if (q === "q" && screen.name !== "entropy") {
|
|
768
|
+
if (screen.name === "queue" && session) {
|
|
769
|
+
clearQueueCleanup();
|
|
770
|
+
api.leaveQueue(activeCeremonyId, screen.trackId, session.session_token).catch(() => {
|
|
771
|
+
});
|
|
772
|
+
setTimeout(() => exit(), 500);
|
|
773
|
+
} else {
|
|
774
|
+
exit();
|
|
775
|
+
}
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
if (key.backspace || key.delete) {
|
|
779
|
+
if (!initialCeremonyId && (screen.name === "tracks" || screen.name === "error")) {
|
|
780
|
+
goCeremonyPicker();
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
if (screen.name !== "tracks" && screen.name !== "loading" && screen.name !== "joining" && screen.name !== "entropy" && screen.name !== "ceremony-picker") {
|
|
784
|
+
goHome();
|
|
785
|
+
}
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
if (screen.name === "ceremony-picker" && !screen.loading) {
|
|
789
|
+
const { ceremonies } = screen;
|
|
790
|
+
if (key.upArrow) {
|
|
791
|
+
setSelectedIdx((i) => Math.max(0, i - 1));
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (key.downArrow) {
|
|
795
|
+
setSelectedIdx((i) => Math.min(ceremonies.length - 1, i + 1));
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (key.return) {
|
|
799
|
+
const c = ceremonies[selectedIdx];
|
|
800
|
+
if (!c || c.status !== "open") return;
|
|
801
|
+
setActiveCeremonyId(c.id);
|
|
802
|
+
setScreen({ name: "loading" });
|
|
803
|
+
boot(c.id);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
if (screen.name === "tracks") {
|
|
809
|
+
const { tracks } = screen;
|
|
810
|
+
if (key.tab) {
|
|
811
|
+
setTab((t) => (t + 1) % 2);
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
if (tab === 0) {
|
|
815
|
+
if (key.upArrow) {
|
|
816
|
+
setSelectedIdx((i) => Math.max(0, i - 1));
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
if (key.downArrow) {
|
|
820
|
+
setSelectedIdx((i) => Math.min(tracks.length - 1, i + 1));
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (key.return) {
|
|
824
|
+
const t = tracks[selectedIdx];
|
|
825
|
+
if (t && t.status === "open" && !contributed[t.id]) joinTrack(t);
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
if (q === "r") {
|
|
830
|
+
goHome();
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
if (q === "b" && (screen.name === "error" || screen.name === "done" || screen.name === "queue")) {
|
|
835
|
+
goHome();
|
|
836
|
+
}
|
|
837
|
+
});
|
|
838
|
+
if (screen.name === "ceremony-picker") {
|
|
839
|
+
const { ceremonies, loading } = screen;
|
|
840
|
+
return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", children: [
|
|
841
|
+
/* @__PURE__ */ jsx6(Header, { ceremony: null }),
|
|
842
|
+
loading ? /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "Loading ceremonies..." }) : ceremonies.length === 0 ? /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", gap: 1, children: [
|
|
843
|
+
/* @__PURE__ */ jsx6(Text6, { color: "yellow", children: "No ceremonies found." }),
|
|
844
|
+
/* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "The server may not have any active ceremonies yet." }),
|
|
845
|
+
/* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "Q to quit" })
|
|
846
|
+
] }) : /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", children: [
|
|
847
|
+
/* @__PURE__ */ jsx6(Text6, { bold: true, children: "Select a ceremony:" }),
|
|
848
|
+
/* @__PURE__ */ jsx6(Text6, { dimColor: true, children: " " + "\u2500".repeat(60) }),
|
|
849
|
+
ceremonies.map((c, i) => {
|
|
850
|
+
const isSelected = i === selectedIdx;
|
|
851
|
+
const isOpen = c.status === "open";
|
|
852
|
+
const statusColor = c.status === "open" ? "green" : c.status === "completed" ? "cyan" : "yellow";
|
|
853
|
+
return /* @__PURE__ */ jsxs6(Box6, { gap: 2, children: [
|
|
854
|
+
/* @__PURE__ */ jsxs6(Text6, { color: isSelected ? "cyan" : isOpen ? void 0 : "gray", children: [
|
|
855
|
+
isSelected ? "\u25B6 " : " ",
|
|
856
|
+
c.name.padEnd(30)
|
|
857
|
+
] }),
|
|
858
|
+
/* @__PURE__ */ jsxs6(Text6, { color: statusColor, children: [
|
|
859
|
+
"[",
|
|
860
|
+
c.status,
|
|
861
|
+
"]"
|
|
862
|
+
] }),
|
|
863
|
+
/* @__PURE__ */ jsxs6(Text6, { dimColor: true, children: [
|
|
864
|
+
c.track_count,
|
|
865
|
+
" track",
|
|
866
|
+
c.track_count !== 1 ? "s" : "",
|
|
867
|
+
" \xB7 ",
|
|
868
|
+
String(c.total_contributions),
|
|
869
|
+
" contribution",
|
|
870
|
+
Number(c.total_contributions) !== 1 ? "s" : ""
|
|
871
|
+
] })
|
|
872
|
+
] }, c.id);
|
|
873
|
+
}),
|
|
874
|
+
/* @__PURE__ */ jsx6(Text6, { dimColor: true, children: " " + "\u2500".repeat(60) }),
|
|
875
|
+
(() => {
|
|
876
|
+
const c = ceremonies[selectedIdx];
|
|
877
|
+
if (!c) return null;
|
|
878
|
+
if (c.status === "initialized") {
|
|
879
|
+
return /* @__PURE__ */ jsxs6(Text6, { color: "yellow", children: [
|
|
880
|
+
" Not open yet \u2014 admin: run initialize ",
|
|
881
|
+
"<id>",
|
|
882
|
+
" then open ",
|
|
883
|
+
"<id>",
|
|
884
|
+
" to start contributions."
|
|
885
|
+
] });
|
|
886
|
+
}
|
|
887
|
+
if (c.status === "finalizing") {
|
|
888
|
+
return /* @__PURE__ */ jsx6(Text6, { color: "yellow", children: " Contribution phase is closed \u2014 ceremony is applying the final beacon." });
|
|
889
|
+
}
|
|
890
|
+
if (c.status === "completed") {
|
|
891
|
+
return /* @__PURE__ */ jsx6(Text6, { color: "cyan", children: " Ceremony complete \u2014 verification keys are available." });
|
|
892
|
+
}
|
|
893
|
+
return /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "\u2191/\u2193 select \xB7 Enter join \xB7 Q quit" });
|
|
894
|
+
})()
|
|
895
|
+
] })
|
|
896
|
+
] });
|
|
897
|
+
}
|
|
898
|
+
if (screen.name === "loading") {
|
|
899
|
+
return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", children: [
|
|
900
|
+
/* @__PURE__ */ jsx6(Header, { ceremony }),
|
|
901
|
+
/* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "Loading tracks..." })
|
|
902
|
+
] });
|
|
903
|
+
}
|
|
904
|
+
if (screen.name === "error") {
|
|
905
|
+
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";
|
|
906
|
+
return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", children: [
|
|
907
|
+
/* @__PURE__ */ jsx6(Header, { ceremony }),
|
|
908
|
+
/* @__PURE__ */ jsxs6(Text6, { color: "red", bold: true, children: [
|
|
909
|
+
"\u2717 ",
|
|
910
|
+
screen.message
|
|
911
|
+
] }),
|
|
912
|
+
/* @__PURE__ */ jsx6(Text6, { dimColor: true, children: backHint })
|
|
913
|
+
] });
|
|
914
|
+
}
|
|
915
|
+
if (screen.name === "tracks") {
|
|
916
|
+
const { tracks } = screen;
|
|
917
|
+
const openTracks = tracks.filter((t) => t.status === "open");
|
|
918
|
+
const myContributions = Object.values(contributed).filter((c) => c.ceremonyId === activeCeremonyId);
|
|
919
|
+
const TabBar = () => /* @__PURE__ */ jsxs6(Box6, { gap: 1, marginBottom: 1, children: [
|
|
920
|
+
/* @__PURE__ */ jsx6(Text6, { bold: tab === 0, color: tab === 0 ? "cyan" : void 0, dimColor: tab !== 0, children: tab === 0 ? "[ Dashboard ]" : " Dashboard " }),
|
|
921
|
+
/* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "|" }),
|
|
922
|
+
/* @__PURE__ */ jsx6(Text6, { bold: tab === 1, color: tab === 1 ? "cyan" : void 0, dimColor: tab !== 1, children: tab === 1 ? `[ My Contributions (${myContributions.length}) ]` : ` My Contributions (${myContributions.length}) ` }),
|
|
923
|
+
/* @__PURE__ */ jsx6(Text6, { dimColor: true, children: " Tab to switch" })
|
|
924
|
+
] });
|
|
925
|
+
return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", children: [
|
|
926
|
+
/* @__PURE__ */ jsx6(Header, { ceremony }),
|
|
927
|
+
/* @__PURE__ */ jsx6(TabBar, {}),
|
|
928
|
+
tab === 0 ? (
|
|
929
|
+
// ── Dashboard tab ────────────────────────────────────────────────
|
|
930
|
+
/* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", children: [
|
|
931
|
+
/* @__PURE__ */ jsxs6(Text6, { dimColor: true, children: [
|
|
932
|
+
" CIRCUIT".padEnd(30),
|
|
933
|
+
"TOTAL".padEnd(10),
|
|
934
|
+
"QUEUE".padEnd(8),
|
|
935
|
+
"STATUS".padEnd(14),
|
|
936
|
+
"MY CONTRIBUTIONS"
|
|
937
|
+
] }),
|
|
938
|
+
/* @__PURE__ */ jsx6(Text6, { dimColor: true, children: " " + "\u2500".repeat(68) }),
|
|
939
|
+
tracks.map((t, i) => {
|
|
940
|
+
const isSelected = i === selectedIdx;
|
|
941
|
+
const canContribute = t.status === "open";
|
|
942
|
+
const nameClipped = t.circuit_name.length > 26 ? t.circuit_name.slice(0, 24) + ".." : t.circuit_name;
|
|
943
|
+
const statusColor = t.status === "open" ? "green" : t.status === "finalized" ? "cyan" : "yellow";
|
|
944
|
+
const myContrib = contributed[t.id];
|
|
945
|
+
return /* @__PURE__ */ jsxs6(Box6, { children: [
|
|
946
|
+
/* @__PURE__ */ jsxs6(Text6, { color: isSelected ? "cyan" : canContribute ? void 0 : "gray", children: [
|
|
947
|
+
isSelected ? "\u25B6 " : " ",
|
|
948
|
+
nameClipped.padEnd(28),
|
|
949
|
+
String(t.contribution_count).padEnd(10),
|
|
950
|
+
String(t.queue_depth).padEnd(8)
|
|
951
|
+
] }),
|
|
952
|
+
/* @__PURE__ */ jsx6(Text6, { color: statusColor, children: t.status.padEnd(14) }),
|
|
953
|
+
myContrib ? isSelected ? /* @__PURE__ */ jsxs6(Text6, { color: "green", children: [
|
|
954
|
+
"\u2713 contributed (round #",
|
|
955
|
+
myContrib.sequenceNumber,
|
|
956
|
+
") \u2014 already done"
|
|
957
|
+
] }) : /* @__PURE__ */ jsxs6(Text6, { color: "green", children: [
|
|
958
|
+
"\u2713 contributed (round #",
|
|
959
|
+
myContrib.sequenceNumber,
|
|
960
|
+
")"
|
|
961
|
+
] }) : canContribute ? isSelected ? /* @__PURE__ */ jsx6(Text6, { color: "yellow", children: "\u2190 Enter to contribute" }) : /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "not contributed" }) : /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "\u2014" })
|
|
962
|
+
] }, t.id);
|
|
963
|
+
}),
|
|
964
|
+
/* @__PURE__ */ jsx6(Text6, { dimColor: true, children: " " + "\u2500".repeat(68) }),
|
|
965
|
+
openTracks.length === 0 ? /* @__PURE__ */ jsx6(Text6, { color: "yellow", children: "No open tracks \u2014 ceremony may be finalizing or complete." }) : /* @__PURE__ */ jsxs6(Text6, { dimColor: true, children: [
|
|
966
|
+
"\u2191/\u2193 select \xB7 Enter contribute \xB7 R refresh \xB7 Q quit",
|
|
967
|
+
!initialCeremonyId ? " \xB7 \u232B back to ceremony list" : ""
|
|
968
|
+
] })
|
|
969
|
+
] })
|
|
970
|
+
) : (
|
|
971
|
+
// ── My Contributions tab ─────────────────────────────────────────
|
|
972
|
+
/* @__PURE__ */ jsx6(Box6, { flexDirection: "column", children: myContributions.length === 0 ? /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", gap: 1, children: [
|
|
973
|
+
/* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "No contributions yet." }),
|
|
974
|
+
/* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "Switch to Dashboard tab and press Enter on a circuit to contribute." })
|
|
975
|
+
] }) : /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", children: [
|
|
976
|
+
/* @__PURE__ */ jsxs6(Text6, { dimColor: true, children: [
|
|
977
|
+
" CIRCUIT".padEnd(28),
|
|
978
|
+
"ROUND".padEnd(8),
|
|
979
|
+
"HASH".padEnd(20),
|
|
980
|
+
"TIME"
|
|
981
|
+
] }),
|
|
982
|
+
/* @__PURE__ */ jsx6(Text6, { dimColor: true, children: " " + "\u2500".repeat(68) }),
|
|
983
|
+
myContributions.map((c, i) => /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", children: [
|
|
984
|
+
/* @__PURE__ */ jsxs6(Box6, { children: [
|
|
985
|
+
/* @__PURE__ */ jsx6(Text6, { color: "green", children: " \u2713 " }),
|
|
986
|
+
/* @__PURE__ */ jsx6(Text6, { bold: true, children: c.circuitName.padEnd(24) }),
|
|
987
|
+
/* @__PURE__ */ jsx6(Text6, { color: "yellow", children: "#" + c.sequenceNumber + " " }),
|
|
988
|
+
/* @__PURE__ */ jsx6(Text6, { color: "cyan", children: c.contributionHash ? c.contributionHash.slice(0, 16) + "..." : "(pending)" })
|
|
989
|
+
] }),
|
|
990
|
+
/* @__PURE__ */ jsxs6(Text6, { dimColor: true, children: [
|
|
991
|
+
" ",
|
|
992
|
+
c.verifiedAt ? new Date(c.verifiedAt).toLocaleString() : ""
|
|
993
|
+
] })
|
|
994
|
+
] }, i)),
|
|
995
|
+
/* @__PURE__ */ jsx6(Text6, { dimColor: true, children: " " + "\u2500".repeat(68) }),
|
|
996
|
+
/* @__PURE__ */ jsxs6(Text6, { dimColor: true, children: [
|
|
997
|
+
"Total: ",
|
|
998
|
+
myContributions.length,
|
|
999
|
+
" contribution",
|
|
1000
|
+
myContributions.length !== 1 ? "s" : "",
|
|
1001
|
+
" \xB7 ",
|
|
1002
|
+
"Tab to switch \xB7 Q to quit"
|
|
1003
|
+
] })
|
|
1004
|
+
] }) })
|
|
1005
|
+
)
|
|
1006
|
+
] });
|
|
1007
|
+
}
|
|
1008
|
+
if (screen.name === "joining") {
|
|
1009
|
+
return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", children: [
|
|
1010
|
+
/* @__PURE__ */ jsx6(Header, { ceremony }),
|
|
1011
|
+
/* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "Joining queue..." })
|
|
1012
|
+
] });
|
|
1013
|
+
}
|
|
1014
|
+
if (screen.name === "queue") {
|
|
1015
|
+
const { trackId, circuitName } = screen;
|
|
1016
|
+
const priorContrib = contributed[trackId];
|
|
1017
|
+
return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", children: [
|
|
1018
|
+
/* @__PURE__ */ jsx6(Header, { ceremony, subtitle: `Circuit: ${circuitName}` }),
|
|
1019
|
+
priorContrib && /* @__PURE__ */ jsx6(Box6, { marginBottom: 1, paddingX: 1, children: /* @__PURE__ */ jsxs6(Text6, { color: "yellow", children: [
|
|
1020
|
+
"\u26A0 You already contributed to this circuit (round #",
|
|
1021
|
+
priorContrib.sequenceNumber,
|
|
1022
|
+
").",
|
|
1023
|
+
" ",
|
|
1024
|
+
"Contributing again is allowed and adds more entropy."
|
|
1025
|
+
] }) }),
|
|
1026
|
+
/* @__PURE__ */ jsx6(
|
|
1027
|
+
QueueView,
|
|
1028
|
+
{
|
|
1029
|
+
ceremonyId: activeCeremonyId,
|
|
1030
|
+
trackId,
|
|
1031
|
+
token: session.session_token,
|
|
1032
|
+
onReady: (status) => setScreen({ name: "entropy", trackId, circuitName, slotStatus: status }),
|
|
1033
|
+
onError: (e) => setScreen({ name: "error", message: e.message, recoverable: true })
|
|
1034
|
+
}
|
|
1035
|
+
),
|
|
1036
|
+
/* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "\u232B Backspace to go back \xB7 Q to quit" })
|
|
1037
|
+
] });
|
|
1038
|
+
}
|
|
1039
|
+
if (screen.name === "entropy") {
|
|
1040
|
+
const { trackId, circuitName, slotStatus } = screen;
|
|
1041
|
+
return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", children: [
|
|
1042
|
+
/* @__PURE__ */ jsx6(Header, { ceremony, subtitle: `Circuit: ${circuitName} \xB7 Your turn!` }),
|
|
1043
|
+
/* @__PURE__ */ jsx6(
|
|
1044
|
+
EntropyCollector,
|
|
1045
|
+
{
|
|
1046
|
+
onComplete: (entropy) => setScreen({ name: "contribute", trackId, circuitName, slotStatus, entropy }),
|
|
1047
|
+
onError: (e) => setScreen({ name: "error", message: e.message, recoverable: false })
|
|
1048
|
+
}
|
|
1049
|
+
)
|
|
1050
|
+
] });
|
|
1051
|
+
}
|
|
1052
|
+
if (screen.name === "contribute") {
|
|
1053
|
+
const { trackId, circuitName, slotStatus, entropy } = screen;
|
|
1054
|
+
return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", children: [
|
|
1055
|
+
/* @__PURE__ */ jsx6(Header, { ceremony, subtitle: `Circuit: ${circuitName} \xB7 Contributing` }),
|
|
1056
|
+
/* @__PURE__ */ jsx6(
|
|
1057
|
+
ContributeFlow,
|
|
1058
|
+
{
|
|
1059
|
+
ceremonyId: activeCeremonyId,
|
|
1060
|
+
trackId,
|
|
1061
|
+
token: session.session_token,
|
|
1062
|
+
slotStatus,
|
|
1063
|
+
entropy,
|
|
1064
|
+
displayName: displayName2,
|
|
1065
|
+
onComplete: async (contributionId, receipt) => {
|
|
1066
|
+
const local = {
|
|
1067
|
+
contributionId,
|
|
1068
|
+
sequenceNumber: receipt?.sequence_number ?? 0,
|
|
1069
|
+
contributionHash: receipt?.contribution_hash ?? "",
|
|
1070
|
+
circuitName,
|
|
1071
|
+
ceremonyId: activeCeremonyId,
|
|
1072
|
+
verifiedAt: receipt?.verified_at ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
1073
|
+
};
|
|
1074
|
+
await recordContribution(trackId, local);
|
|
1075
|
+
setContributed((prev) => ({ ...prev, [trackId]: local }));
|
|
1076
|
+
setScreen({ name: "done", contribution: local });
|
|
1077
|
+
},
|
|
1078
|
+
onError: (e) => setScreen({ name: "error", message: e.message, recoverable: false })
|
|
1079
|
+
}
|
|
1080
|
+
)
|
|
1081
|
+
] });
|
|
1082
|
+
}
|
|
1083
|
+
if (screen.name === "done") {
|
|
1084
|
+
const { contribution } = screen;
|
|
1085
|
+
return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", children: [
|
|
1086
|
+
/* @__PURE__ */ jsx6(Header, { ceremony }),
|
|
1087
|
+
/* @__PURE__ */ jsx6(Attestation, { contribution }),
|
|
1088
|
+
/* @__PURE__ */ jsx6(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "\u232B Backspace / B = contribute to another circuit \xB7 Q to quit" }) })
|
|
1089
|
+
] });
|
|
1090
|
+
}
|
|
1091
|
+
return null;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// src/index.tsx
|
|
1095
|
+
import { jsx as jsx7 } from "react/jsx-runtime";
|
|
1096
|
+
var ceremonyId = process.env["CEREMONY_ID"] ?? "";
|
|
1097
|
+
var displayName = process.env["CONTRIBUTOR_NAME"] ?? "anonymous";
|
|
1098
|
+
process.stdout.write("\x1B[?1049h\x1B[H");
|
|
1099
|
+
function restoreScreen() {
|
|
1100
|
+
process.stdout.write("\x1B[?1049l");
|
|
1101
|
+
}
|
|
1102
|
+
async function gracefulExit(code = 0) {
|
|
1103
|
+
restoreScreen();
|
|
1104
|
+
await runQueueCleanup();
|
|
1105
|
+
process.exit(code);
|
|
1106
|
+
}
|
|
1107
|
+
process.on("SIGINT", () => {
|
|
1108
|
+
gracefulExit(0).catch(() => {
|
|
1109
|
+
restoreScreen();
|
|
1110
|
+
process.exit(0);
|
|
1111
|
+
});
|
|
1112
|
+
});
|
|
1113
|
+
process.on("SIGTERM", () => {
|
|
1114
|
+
gracefulExit(0).catch(() => {
|
|
1115
|
+
restoreScreen();
|
|
1116
|
+
process.exit(0);
|
|
1117
|
+
});
|
|
1118
|
+
});
|
|
1119
|
+
var { waitUntilExit } = render(
|
|
1120
|
+
/* @__PURE__ */ jsx7(App, { ceremonyId, displayName }),
|
|
1121
|
+
{ exitOnCtrlC: false }
|
|
1122
|
+
);
|
|
1123
|
+
await waitUntilExit();
|
|
1124
|
+
restoreScreen();
|
|
1125
|
+
process.exit(0);
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@umbra-privacy/ceremony",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Terminal UI for the Umbra Phase 2 trusted setup ceremony",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"umbra-ceremony": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/umbra-privacy/umbra-core.git",
|
|
19
|
+
"directory": "ts-apps/ceremony-tui"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/umbra-privacy/umbra-core/tree/main/ts-apps/ceremony-tui",
|
|
22
|
+
"bugs": "https://github.com/umbra-privacy/umbra-core/issues",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18.0.0"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"ink": "^5.1.0",
|
|
29
|
+
"ink-spinner": "^5.0.0",
|
|
30
|
+
"react": "^18.3.1",
|
|
31
|
+
"snarkjs": "^0.7.6"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^22.0.0",
|
|
35
|
+
"@types/react": "^18.3.1",
|
|
36
|
+
"tsx": "^4.21.0",
|
|
37
|
+
"tsup": "^8.0.0",
|
|
38
|
+
"typescript": "^5.9.3"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"dev": "tsx src/index.tsx",
|
|
42
|
+
"build": "tsup",
|
|
43
|
+
"typecheck": "tsc --noEmit"
|
|
44
|
+
}
|
|
45
|
+
}
|