@orangecheck/vote-cli 0.7.4 → 0.7.6
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/README.md +17 -1
- package/dist/cli.js +482 -197
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from 'module';
|
|
2
3
|
import { Command, Option } from 'commander';
|
|
3
4
|
import WebSocket from 'ws';
|
|
4
5
|
import { generateX25519KeyPair, hexEncode, utf8Encode, hexDecode, utf8Decode } from '@orangecheck/lock-crypto';
|
|
5
|
-
import { ballotId, lastAnchorHeight, ANCHOR_FINAL_CONFIRMATIONS, pollId, verifyPoll, canonicalBytes, commit,
|
|
6
|
+
import { isAnchoredPoll, ballotId, lastAnchorHeight, ANCHOR_FINAL_CONFIRMATIONS, pollId, verifyPoll, canonicalBytes, commit, anchoredTallyIsFinal, revealId, distinctBallotCopies, resolveCloseBlock, mempoolBlockTimeSource, anchorHeightFromStamps, resolvePollSnapshot, tally, ballotStampContent, verifyBallot, verifyReveal } from '@orangecheck/vote-core';
|
|
6
7
|
import { schnorr } from '@noble/curves/secp256k1';
|
|
7
8
|
import { sha256 } from '@noble/hashes/sha256';
|
|
8
9
|
import { randomBytes, bytesToHex, hexToBytes } from '@noble/hashes/utils';
|
|
9
10
|
import { verifySignature, addressFromPrivateKey, signMessage } from '@orangecheck/bip322';
|
|
10
|
-
import { stamp, canonicalizeEnvelope, parseStamp, computeEnvelopeId } from '@orangecheck/stamp-core';
|
|
11
|
+
import { stamp, verify, canonicalizeEnvelope, parseStamp, computeEnvelopeId } from '@orangecheck/stamp-core';
|
|
11
12
|
import { mempoolHeaderSource, submitToCalendars, toStampOts, verifiedAnchor } from '@orangecheck/stamp-ots';
|
|
12
|
-
import { DEFAULT_RELAYS, publishEvent,
|
|
13
|
+
import { DEFAULT_RELAYS, queryEvents, publishEvent, verifyEvent, matchFilter } from '@orangecheck/nostr-core';
|
|
13
14
|
import { createInterface } from 'readline/promises';
|
|
15
|
+
import { readFileSync } from 'fs';
|
|
14
16
|
import { seal, unseal } from '@orangecheck/lock-core';
|
|
15
17
|
|
|
16
18
|
var QUERY_TIMEOUT_MS = 8e3;
|
|
19
|
+
var PAGED_READ_DEADLINE_MS = 2 * QUERY_TIMEOUT_MS;
|
|
17
20
|
function reachedEose(relayStatus) {
|
|
18
21
|
return relayStatus.some((r) => r.eose);
|
|
19
22
|
}
|
|
@@ -21,14 +24,25 @@ async function query(filter, relays) {
|
|
|
21
24
|
const { events, relayStatus } = await queryEvents(filter, relays, QUERY_TIMEOUT_MS);
|
|
22
25
|
return { events, complete: reachedEose(relayStatus) };
|
|
23
26
|
}
|
|
24
|
-
function fetchPollEvents(
|
|
25
|
-
return query({ kinds: [30080], "#d": [`oc-vote:poll:${
|
|
27
|
+
function fetchPollEvents(pollId5, relays) {
|
|
28
|
+
return query({ kinds: [30080], "#d": [`oc-vote:poll:${pollId5}`] }, relays);
|
|
26
29
|
}
|
|
27
|
-
function fetchBallotEvents(
|
|
28
|
-
return queryPaged({ kinds: [30081], "#t": [
|
|
30
|
+
function fetchBallotEvents(pollId5, relays, queryFn) {
|
|
31
|
+
return queryPaged({ kinds: [30081], "#t": [pollId5] }, relays, queryFn);
|
|
29
32
|
}
|
|
30
33
|
var PAGE_SIZE = 100;
|
|
31
34
|
var MAX_PAGES = 20;
|
|
35
|
+
var WRITE_QUORUM = 2;
|
|
36
|
+
function readFloor(asked) {
|
|
37
|
+
return Math.max(1, asked - WRITE_QUORUM + 1);
|
|
38
|
+
}
|
|
39
|
+
function readShortfall(reads) {
|
|
40
|
+
const cut = reads.filter((r) => r.truncated).map((r) => r.relay);
|
|
41
|
+
if (cut.length) return `was cut short on ${cut.join(", ")}`;
|
|
42
|
+
const finished = reads.filter((r) => r.answered).length;
|
|
43
|
+
const floor = readFloor(reads.length);
|
|
44
|
+
return finished < floor ? `finished on ${finished} of ${reads.length} relays, fewer than the ${floor} a count needs` : null;
|
|
45
|
+
}
|
|
32
46
|
async function pageRelay(filter, relay, queryFn) {
|
|
33
47
|
const byId = /* @__PURE__ */ new Map();
|
|
34
48
|
const done = (answered, truncated = false) => ({
|
|
@@ -36,21 +50,27 @@ async function pageRelay(filter, relay, queryFn) {
|
|
|
36
50
|
read: { relay, answered, truncated }
|
|
37
51
|
});
|
|
38
52
|
let until;
|
|
53
|
+
const deadlineAt = Date.now() + PAGED_READ_DEADLINE_MS;
|
|
39
54
|
for (let page = 0; page < MAX_PAGES; page++) {
|
|
55
|
+
const budget = Math.min(QUERY_TIMEOUT_MS, deadlineAt - Date.now());
|
|
56
|
+
if (budget <= 0) return done(false, true);
|
|
40
57
|
const { events, relayStatus } = await queryFn(
|
|
41
58
|
{ ...filter, limit: PAGE_SIZE, ...until !== void 0 ? { until } : {} },
|
|
42
59
|
[relay],
|
|
43
|
-
|
|
60
|
+
budget
|
|
44
61
|
);
|
|
45
62
|
const status = relayStatus.find((s) => s.relay === relay) ?? relayStatus[0];
|
|
46
|
-
|
|
63
|
+
const sent = (status?.events ?? 0) + (status?.rejected ?? 0);
|
|
64
|
+
const known = byId.size;
|
|
47
65
|
for (const e of events) byId.set(e.id, e);
|
|
48
|
-
if (
|
|
66
|
+
if (!status?.eose) return done(false, page > 0 || sent > 0);
|
|
67
|
+
if (sent < PAGE_SIZE) return done(true);
|
|
68
|
+
if (byId.size === known) return done(false, true);
|
|
49
69
|
const oldest = Math.min(...events.map((e) => e.created_at));
|
|
50
|
-
if (events.every((e) => e.created_at === oldest)) return done(
|
|
70
|
+
if (events.every((e) => e.created_at === oldest)) return done(false, true);
|
|
51
71
|
until = oldest;
|
|
52
72
|
}
|
|
53
|
-
return done(
|
|
73
|
+
return done(false, true);
|
|
54
74
|
}
|
|
55
75
|
async function queryPaged(filter, relays = DEFAULT_RELAYS, queryFn = queryEvents) {
|
|
56
76
|
const results = await Promise.all(relays.map((r) => pageRelay(filter, r, queryFn)));
|
|
@@ -58,12 +78,12 @@ async function queryPaged(filter, relays = DEFAULT_RELAYS, queryFn = queryEvents
|
|
|
58
78
|
for (const r of results) for (const e of r.events) byId.set(e.id, e);
|
|
59
79
|
return {
|
|
60
80
|
events: [...byId.values()].sort((a, b) => b.created_at - a.created_at),
|
|
61
|
-
complete: results.
|
|
81
|
+
complete: readShortfall(results.map((r) => r.read)) === null,
|
|
62
82
|
relays: results.map((r) => r.read)
|
|
63
83
|
};
|
|
64
84
|
}
|
|
65
|
-
function fetchRevealEvents(
|
|
66
|
-
return
|
|
85
|
+
function fetchRevealEvents(pollId5, relays, queryFn) {
|
|
86
|
+
return queryPaged({ kinds: [30082], "#d": [`oc-vote:reveal:${pollId5}`] }, relays, queryFn);
|
|
67
87
|
}
|
|
68
88
|
function fetchRecentPolls(limit = 30, relays) {
|
|
69
89
|
return query({ kinds: [30080], "#t": ["oc-vote-poll"], limit }, relays);
|
|
@@ -142,15 +162,17 @@ async function anchoredChain(poll, ballotIds, opts) {
|
|
|
142
162
|
);
|
|
143
163
|
if (!close.ok) throw new Error(`the block at the deadline could not be read (${close.code}): ${close.reason}`);
|
|
144
164
|
const read = await fetchBallotStamps(ballotIds, opts.relays ?? DEFAULT_RELAYS, opts.queryFn);
|
|
145
|
-
if (!read.relays.some((r) => r.answered))
|
|
165
|
+
if (!read.relays.some((r) => r.answered || r.truncated)) {
|
|
166
|
+
throw new Error("no relay finished answering the stamp query; try again");
|
|
167
|
+
}
|
|
146
168
|
const headers = opts.headers ?? cachedHeaders(mempoolHeaderSource(opts.mempoolBase ? { baseUrl: opts.mempoolBase } : {}));
|
|
147
169
|
const verify = stampAnchorVerifier(headers);
|
|
148
170
|
const heights = /* @__PURE__ */ new Map();
|
|
149
171
|
for (const [id, copies] of read.stamps) heights.set(id, await anchorHeightFromStamps(id, copies, (s) => verify(s)));
|
|
150
172
|
const stampsDone = read.relays.filter((r) => r.answered && !r.truncated).map((r) => r.relay);
|
|
151
|
-
const
|
|
173
|
+
const ballotsShort = readShortfall(opts.ballotRelays);
|
|
152
174
|
const stampsMissing = opts.ballotRelays.filter((r) => r.answered && !stampsDone.includes(r.relay)).map((r) => r.relay);
|
|
153
|
-
const incomplete =
|
|
175
|
+
const incomplete = ballotsShort ? `the ballot lookup ${ballotsShort}` : stampsMissing.length ? `the stamp lookup did not finish on ${stampsMissing.join(", ")}` : null;
|
|
154
176
|
const finalAt = close.height === null ? null : lastAnchorHeight(close.height) + ANCHOR_FINAL_CONFIRMATIONS;
|
|
155
177
|
const finalBlocked = incomplete && finalAt !== null && close.tip >= finalAt ? incomplete : null;
|
|
156
178
|
return {
|
|
@@ -243,6 +265,215 @@ function buildStampEvent(env) {
|
|
|
243
265
|
const { tags, content } = stampEventParts(env);
|
|
244
266
|
return sign(STAMP_KIND, tags, content);
|
|
245
267
|
}
|
|
268
|
+
async function bip322Verify(args) {
|
|
269
|
+
return verifySignature(args.address, args.message, args.signature);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// src/quorum.ts
|
|
273
|
+
function writeQuorum(asked) {
|
|
274
|
+
return Math.max(1, Math.min(WRITE_QUORUM, asked));
|
|
275
|
+
}
|
|
276
|
+
function isIn(serving, asked) {
|
|
277
|
+
return serving >= writeQuorum(asked);
|
|
278
|
+
}
|
|
279
|
+
function parsed(content, kind) {
|
|
280
|
+
try {
|
|
281
|
+
const o = JSON.parse(content);
|
|
282
|
+
return o && typeof o === "object" && o.kind === kind ? o : null;
|
|
283
|
+
} catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function ballotOf(ev) {
|
|
288
|
+
return parsed(ev.content, "oc-vote/ballot");
|
|
289
|
+
}
|
|
290
|
+
function pollOf(ev) {
|
|
291
|
+
return parsed(ev.content, "oc-vote/poll");
|
|
292
|
+
}
|
|
293
|
+
function revealOf(ev) {
|
|
294
|
+
return parsed(ev.content, "oc-vote/reveal");
|
|
295
|
+
}
|
|
296
|
+
function stampIn(content) {
|
|
297
|
+
try {
|
|
298
|
+
return parseStamp(JSON.parse(content));
|
|
299
|
+
} catch {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function stampOf(ev) {
|
|
304
|
+
return stampIn(ev.content);
|
|
305
|
+
}
|
|
306
|
+
function safely(id, x) {
|
|
307
|
+
try {
|
|
308
|
+
return id(x);
|
|
309
|
+
} catch {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
async function relaysServing(filter, accept, opts = {}) {
|
|
314
|
+
const relays = opts.relays ?? DEFAULT_RELAYS;
|
|
315
|
+
const query2 = opts.query ?? queryEvents;
|
|
316
|
+
const checked = /* @__PURE__ */ new Map();
|
|
317
|
+
const valid = (ev) => {
|
|
318
|
+
if (!verifyEvent(ev) || !matchFilter(ev, filter)) return Promise.resolve(false);
|
|
319
|
+
let check = checked.get(ev.content);
|
|
320
|
+
if (!check) {
|
|
321
|
+
check = accept(ev.content).catch(() => false);
|
|
322
|
+
checked.set(ev.content, check);
|
|
323
|
+
}
|
|
324
|
+
return check;
|
|
325
|
+
};
|
|
326
|
+
const held = await Promise.all(
|
|
327
|
+
relays.map(async (url) => {
|
|
328
|
+
const read = await pageRelay(filter, url, query2).catch(() => null);
|
|
329
|
+
for (const ev of read?.events ?? []) if (await valid(ev)) return url;
|
|
330
|
+
return null;
|
|
331
|
+
})
|
|
332
|
+
);
|
|
333
|
+
return held.filter((u) => u !== null);
|
|
334
|
+
}
|
|
335
|
+
function relaysServingBallot(ballot, opts = {}) {
|
|
336
|
+
const id = ballotId(ballot);
|
|
337
|
+
const verify = opts.verify ?? bip322Verify;
|
|
338
|
+
const filter = { kinds: [30081], "#d": [`oc-vote:ballot:${ballot.poll_id}:${ballot.voter}`], "#t": [ballot.poll_id] };
|
|
339
|
+
return relaysServing(
|
|
340
|
+
filter,
|
|
341
|
+
async (content) => {
|
|
342
|
+
const copy = parsed(content, "oc-vote/ballot");
|
|
343
|
+
return !!copy && safely(ballotId, copy) === id && (await verifyBallot(copy, verify)).ok;
|
|
344
|
+
},
|
|
345
|
+
opts
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
function relaysServingPoll(poll, opts = {}) {
|
|
349
|
+
const id = pollId(poll);
|
|
350
|
+
const verify = opts.verify ?? bip322Verify;
|
|
351
|
+
return relaysServing(
|
|
352
|
+
{ kinds: [30080], "#d": [`oc-vote:poll:${id}`] },
|
|
353
|
+
async (content) => {
|
|
354
|
+
const copy = parsed(content, "oc-vote/poll");
|
|
355
|
+
return !!copy && safely(pollId, copy) === id && (await verifyPoll(copy, verify)).ok;
|
|
356
|
+
},
|
|
357
|
+
opts
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
function relaysServingReveal(reveal, poll, opts = {}) {
|
|
361
|
+
const id = revealId(reveal);
|
|
362
|
+
const verify = opts.verify ?? bip322Verify;
|
|
363
|
+
return relaysServing(
|
|
364
|
+
{ kinds: [30082], "#d": [`oc-vote:reveal:${reveal.poll_id}`] },
|
|
365
|
+
async (content) => {
|
|
366
|
+
const copy = parsed(content, "oc-vote/reveal");
|
|
367
|
+
return !!copy && safely(revealId, copy) === id && (await verifyReveal(copy, poll, verify, Date.now(), opts.chain)).ok;
|
|
368
|
+
},
|
|
369
|
+
opts
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
var stampSigVerifier = (verify) => (message, signature, address) => Promise.resolve(verify({ address, message, signature }));
|
|
373
|
+
function relaysServingStamp(env, opts = {}) {
|
|
374
|
+
const verify$1 = opts.verify ?? bip322Verify;
|
|
375
|
+
return relaysServing(
|
|
376
|
+
{ kinds: [STAMP_KIND], "#d": [`oc-stamp:${env.id}`] },
|
|
377
|
+
async (content) => {
|
|
378
|
+
const copy = stampIn(content);
|
|
379
|
+
if (!copy || copy.id !== env.id) return false;
|
|
380
|
+
return (await verify({ envelope: copy, verifyBip322: stampSigVerifier(verify$1) })).ok;
|
|
381
|
+
},
|
|
382
|
+
opts
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
async function castEvent(event, serving, readBack, opts = {}) {
|
|
386
|
+
const relays = opts.relays ?? DEFAULT_RELAYS;
|
|
387
|
+
const missing = relays.filter((r) => !serving.includes(r));
|
|
388
|
+
if (missing.length > 0) await (opts.publish ?? publishEvent)(event, missing);
|
|
389
|
+
return readBack();
|
|
390
|
+
}
|
|
391
|
+
function castBallot(event, ballot, serving, opts = {}) {
|
|
392
|
+
return castEvent(event, serving, () => relaysServingBallot(ballot, opts), opts);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// src/report.ts
|
|
396
|
+
var NOT_IN_YET = 2;
|
|
397
|
+
function standingIsIn(s) {
|
|
398
|
+
return isIn(s.serving.length, s.relays.length);
|
|
399
|
+
}
|
|
400
|
+
function standingJson(s) {
|
|
401
|
+
return { in: standingIsIn(s), served_by: s.serving, asked: s.relays.length, quorum: writeQuorum(s.relays.length) };
|
|
402
|
+
}
|
|
403
|
+
var LABEL = { poll: "published:", ballot: "in:", reveal: "published:", stamp: "stamp:" };
|
|
404
|
+
var MISSED = {
|
|
405
|
+
poll: "so no one can find it",
|
|
406
|
+
ballot: "so a count would not see it",
|
|
407
|
+
reveal: "so a count would not see it",
|
|
408
|
+
stamp: "so a final count would not see it"
|
|
409
|
+
};
|
|
410
|
+
var SURE = {
|
|
411
|
+
poll: "it is sure to be found",
|
|
412
|
+
ballot: "a count is sure to see it",
|
|
413
|
+
reveal: "a count is sure to see it",
|
|
414
|
+
stamp: "a final count is sure to see it"
|
|
415
|
+
};
|
|
416
|
+
function standingLines(s, file) {
|
|
417
|
+
const asked = s.relays.length;
|
|
418
|
+
const n = s.serving.length;
|
|
419
|
+
const label = ` ${LABEL[s.kind].padEnd(12)}`;
|
|
420
|
+
const lines = [];
|
|
421
|
+
if (standingIsIn(s)) {
|
|
422
|
+
lines.push(`${label}yes, ${n} of ${asked} relays serve it back`);
|
|
423
|
+
if (s.kind === "stamp") lines.push(` a Bitcoin block usually anchors it within an hour or two`);
|
|
424
|
+
} else if (n === 0) {
|
|
425
|
+
lines.push(`${label}not yet: no relay serves it back, ${MISSED[s.kind]}`);
|
|
426
|
+
} else {
|
|
427
|
+
lines.push(
|
|
428
|
+
`${label}not yet: only ${n} of ${asked} relays serves it back, and ${SURE[s.kind]} only once ${writeQuorum(asked)} do`
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
for (const relay of s.relays) lines.push(` ${s.serving.includes(relay) ? "\u2713" : "\u2717"} ${relay}`);
|
|
432
|
+
if (standingIsIn(s)) return lines;
|
|
433
|
+
if (s.kind === "stamp") lines.push(` vote.ochk.io's anchorer also stamps every ballot it sees`);
|
|
434
|
+
if (file) {
|
|
435
|
+
lines.push(` again: oc-vote republish ${file}`);
|
|
436
|
+
lines.push(` sends the same signed ${s.kind}, with nothing new to sign`);
|
|
437
|
+
} else {
|
|
438
|
+
lines.push(` again: save the signed event below to a file and run oc-vote republish <file>;`);
|
|
439
|
+
lines.push(` it sends the same signed ${s.kind}, with nothing new to sign`);
|
|
440
|
+
lines.push(` event: ${JSON.stringify(s.event)}`);
|
|
441
|
+
}
|
|
442
|
+
return lines;
|
|
443
|
+
}
|
|
444
|
+
function report(r) {
|
|
445
|
+
const stamp2 = r.stamp && "kind" in r.stamp ? r.stamp : null;
|
|
446
|
+
if (!standingIsIn(r.main) || stamp2 && !standingIsIn(stamp2)) process.exitCode = NOT_IN_YET;
|
|
447
|
+
if (r.json) {
|
|
448
|
+
const stampJson = r.stamp ? "kind" in r.stamp ? { ...standingJson(r.stamp), event: r.stamp.event } : r.stamp : null;
|
|
449
|
+
process.stdout.write(
|
|
450
|
+
JSON.stringify(
|
|
451
|
+
{
|
|
452
|
+
[r.id[0]]: r.id[1],
|
|
453
|
+
...standingJson(r.main),
|
|
454
|
+
...stampJson ? { stamp: stampJson } : {},
|
|
455
|
+
...r.jsonExtra,
|
|
456
|
+
event: r.main.event
|
|
457
|
+
},
|
|
458
|
+
null,
|
|
459
|
+
2
|
|
460
|
+
) + "\n"
|
|
461
|
+
);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
const lines = [` ${`${r.id[0]}:`.padEnd(12)}${r.id[1]}`];
|
|
465
|
+
for (const [k, v] of r.heading ?? []) lines.push(` ${`${k}:`.padEnd(12)}${v}`);
|
|
466
|
+
lines.push(...standingLines(r.main, r.file));
|
|
467
|
+
if (stamp2) lines.push(...standingLines(stamp2));
|
|
468
|
+
else if (r.stamp && "error" in r.stamp) {
|
|
469
|
+
lines.push(` stamp: not made here (${r.stamp.error}); vote.ochk.io's anchorer stamps every ballot it sees`);
|
|
470
|
+
}
|
|
471
|
+
lines.push(...r.tail ?? []);
|
|
472
|
+
process.stdout.write(`
|
|
473
|
+
${lines.join("\n")}
|
|
474
|
+
|
|
475
|
+
`);
|
|
476
|
+
}
|
|
246
477
|
async function promptForSignature(address, id, preset) {
|
|
247
478
|
if (preset && preset.trim()) return preset.trim();
|
|
248
479
|
process.stderr.write(
|
|
@@ -308,60 +539,28 @@ async function runCreate(opts) {
|
|
|
308
539
|
const sigValue = await promptForSignature(opts.creator, pid, opts.sig);
|
|
309
540
|
draft.sig.value = sigValue;
|
|
310
541
|
const event = buildPollEvent(draft);
|
|
311
|
-
const
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
JSON.stringify(
|
|
316
|
-
{
|
|
317
|
-
poll_id: pid,
|
|
318
|
-
published: okCount,
|
|
319
|
-
total: results.length,
|
|
320
|
-
relays: results,
|
|
321
|
-
poll: draft,
|
|
322
|
-
...reveal_sk ? { reveal_sk, reveal_pk } : {},
|
|
323
|
-
url: `https://vote.ochk.io/p/${pid}`
|
|
324
|
-
},
|
|
325
|
-
null,
|
|
326
|
-
2
|
|
327
|
-
) + "\n"
|
|
328
|
-
);
|
|
329
|
-
return;
|
|
330
|
-
}
|
|
331
|
-
const w = (s) => process.stdout.write(s);
|
|
332
|
-
w(`
|
|
333
|
-
poll_id: ${pid}
|
|
334
|
-
`);
|
|
335
|
-
w(` published: ${okCount}/${results.length} relays
|
|
336
|
-
`);
|
|
337
|
-
for (const r of results) {
|
|
338
|
-
w(` ${r.ok ? "\u2713" : "\u2717"} ${r.relay}${r.reason ? ` (${r.reason})` : ""}
|
|
339
|
-
`);
|
|
340
|
-
}
|
|
341
|
-
w(` url: https://vote.ochk.io/p/${pid}
|
|
342
|
-
`);
|
|
542
|
+
const relays = opts.relays ?? DEFAULT_RELAYS;
|
|
543
|
+
const serving = await castEvent(event, [], () => relaysServingPoll(draft, { relays }), { relays });
|
|
544
|
+
const url = `https://vote.ochk.io/p/${pid}`;
|
|
545
|
+
const tail = [` url: ${url}`];
|
|
343
546
|
if (reveal_sk) {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
`
|
|
349
|
-
|
|
350
|
-
`
|
|
351
|
-
|
|
352
|
-
`);
|
|
353
|
-
w(` oc-vote reveal --poll ${pid} --reveal-sk ${reveal_sk} --creator ${opts.creator}
|
|
354
|
-
`);
|
|
547
|
+
tail.push(
|
|
548
|
+
"",
|
|
549
|
+
" SECRET MODE \u2014 save these or the poll is abandoned at deadline:",
|
|
550
|
+
` reveal_pk: ${reveal_pk}`,
|
|
551
|
+
` reveal_sk: ${reveal_sk}`,
|
|
552
|
+
" to publish the reveal:",
|
|
553
|
+
` oc-vote reveal --poll ${pid} --reveal-sk ${reveal_sk} --creator ${opts.creator}`
|
|
554
|
+
);
|
|
355
555
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
return verifySignature(args.address, args.message, args.signature);
|
|
556
|
+
tail.push("", " canonical bytes:", canonicalBytes({ ...draft, sig: { ...draft.sig, value: "" } }).trimEnd());
|
|
557
|
+
report({
|
|
558
|
+
id: ["poll_id", pid],
|
|
559
|
+
main: { kind: "poll", event, relays, serving },
|
|
560
|
+
json: Boolean(opts.json),
|
|
561
|
+
jsonExtra: { poll: draft, ...reveal_sk ? { reveal_sk, reveal_pk } : {}, url },
|
|
562
|
+
tail
|
|
563
|
+
});
|
|
365
564
|
}
|
|
366
565
|
function parse(ev) {
|
|
367
566
|
try {
|
|
@@ -388,13 +587,24 @@ async function findPoll(fetched, pid, verify) {
|
|
|
388
587
|
);
|
|
389
588
|
}
|
|
390
589
|
function requireComplete(fetched, what) {
|
|
391
|
-
if (
|
|
392
|
-
|
|
590
|
+
if (fetched.complete) return fetched.events;
|
|
591
|
+
const short = "relays" in fetched ? readShortfall(fetched.relays) : null;
|
|
592
|
+
throw new Error(
|
|
593
|
+
short ? `can't count: the ${what} lookup ${short}, so ${what}s may be missing; try again` : `no relay finished answering the ${what} query; try again`
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
function ballotsToCount(fetched, anchored) {
|
|
597
|
+
if (!anchored) return requireComplete(fetched, "ballot");
|
|
598
|
+
if (fetched.relays.some((r) => r.answered || r.truncated)) return fetched.events;
|
|
599
|
+
throw new Error("no relay finished answering the ballot query; try again");
|
|
393
600
|
}
|
|
394
601
|
async function selectReveal(events, poll, verify, chain) {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
602
|
+
return (await findReveal(events, poll, verify, chain))?.reveal ?? null;
|
|
603
|
+
}
|
|
604
|
+
async function findReveal(events, poll, verify, chain) {
|
|
605
|
+
for (const event of events) {
|
|
606
|
+
const reveal = parse(event);
|
|
607
|
+
if (reveal && (await verifyReveal(reveal, poll, verify, Date.now(), chain)).ok) return { event, reveal };
|
|
398
608
|
}
|
|
399
609
|
return null;
|
|
400
610
|
}
|
|
@@ -467,6 +677,12 @@ function runGenRevealKey(opts) {
|
|
|
467
677
|
|
|
468
678
|
`);
|
|
469
679
|
}
|
|
680
|
+
async function revealChainFor(poll) {
|
|
681
|
+
if (!isAnchoredPoll(poll)) return void 0;
|
|
682
|
+
const close = await resolveCloseBlock(poll.deadline, mempoolBlockTimeSource());
|
|
683
|
+
if (!close.ok) throw new Error(`the block at the deadline could not be read: ${close.reason}`);
|
|
684
|
+
return { tipHeight: close.tip, closeHeight: close.height };
|
|
685
|
+
}
|
|
470
686
|
async function runReveal(opts) {
|
|
471
687
|
if (!/^[0-9a-f]{64}$/.test(opts.pollId)) {
|
|
472
688
|
throw new Error("poll id must be 64 hex chars");
|
|
@@ -483,14 +699,11 @@ async function runReveal(opts) {
|
|
|
483
699
|
`poll creator is ${poll.creator} but you provided ${opts.creator}`
|
|
484
700
|
);
|
|
485
701
|
}
|
|
486
|
-
|
|
487
|
-
if (
|
|
488
|
-
|
|
489
|
-
if (!close.ok) throw new Error(`the block at the deadline could not be read: ${close.reason}`);
|
|
490
|
-
chain = { tipHeight: close.tip, closeHeight: close.height };
|
|
491
|
-
if (!anchoredTallyIsFinal(close.height, close.tip) && !opts.force) {
|
|
702
|
+
const chain = await revealChainFor(poll);
|
|
703
|
+
if (chain) {
|
|
704
|
+
if (!anchoredTallyIsFinal(chain.closeHeight, chain.tipHeight) && !opts.force) {
|
|
492
705
|
throw new Error(
|
|
493
|
-
|
|
706
|
+
chain.closeHeight === null ? "no block at the deadline yet: talliers would not accept a reveal now (pass --force to publish anyway)" : `talliers accept the reveal from block ${lastAnchorHeight(chain.closeHeight) + ANCHOR_FINAL_CONFIRMATIONS}; the tip is ${chain.tipHeight} (pass --force to publish anyway)`
|
|
494
707
|
);
|
|
495
708
|
}
|
|
496
709
|
} else if (Date.parse(poll.deadline) > Date.now() && !opts.force) {
|
|
@@ -498,14 +711,26 @@ async function runReveal(opts) {
|
|
|
498
711
|
`poll deadline is ${poll.deadline} \u2014 pass --force to publish before deadline`
|
|
499
712
|
);
|
|
500
713
|
}
|
|
501
|
-
const existing = await
|
|
502
|
-
(await fetchRevealEvents(opts.pollId, relays)).events,
|
|
503
|
-
poll,
|
|
504
|
-
bip322Verify,
|
|
505
|
-
chain
|
|
506
|
-
);
|
|
714
|
+
const existing = await findReveal((await fetchRevealEvents(opts.pollId, relays)).events, poll, bip322Verify, chain);
|
|
507
715
|
if (existing && !opts.force) {
|
|
508
|
-
|
|
716
|
+
const readBack = () => relaysServingReveal(existing.reveal, poll, { relays, chain });
|
|
717
|
+
const serving2 = await castEvent(existing.event, await readBack(), readBack, { relays });
|
|
718
|
+
report({
|
|
719
|
+
id: ["reveal_id", revealId(existing.reveal)],
|
|
720
|
+
heading: [
|
|
721
|
+
["poll", poll.question],
|
|
722
|
+
["revealed", existing.reveal.revealed_at]
|
|
723
|
+
],
|
|
724
|
+
main: { kind: "reveal", event: existing.event, relays, serving: serving2 },
|
|
725
|
+
json: Boolean(opts.json),
|
|
726
|
+
jsonExtra: { reveal: existing.reveal, signed_earlier: true },
|
|
727
|
+
tail: [
|
|
728
|
+
" note: this reveal was signed earlier and found on the relays; it was sent as it is",
|
|
729
|
+
"",
|
|
730
|
+
` tally with: oc-vote tally ${opts.pollId}`
|
|
731
|
+
]
|
|
732
|
+
});
|
|
733
|
+
return;
|
|
509
734
|
}
|
|
510
735
|
const draft = {
|
|
511
736
|
v: 0,
|
|
@@ -523,42 +748,114 @@ async function runReveal(opts) {
|
|
|
523
748
|
const sigValue = await promptForSignature(opts.creator, rid, opts.sig);
|
|
524
749
|
draft.sig.value = sigValue;
|
|
525
750
|
const event = buildRevealEvent(draft);
|
|
526
|
-
const
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
751
|
+
const serving = await castEvent(event, [], () => relaysServingReveal(draft, poll, { relays, chain }), { relays });
|
|
752
|
+
report({
|
|
753
|
+
id: ["reveal_id", rid],
|
|
754
|
+
heading: [
|
|
755
|
+
["poll", poll.question],
|
|
756
|
+
["revealed", draft.revealed_at]
|
|
757
|
+
],
|
|
758
|
+
main: { kind: "reveal", event, relays, serving },
|
|
759
|
+
json: Boolean(opts.json),
|
|
760
|
+
jsonExtra: { reveal: draft },
|
|
761
|
+
tail: ["", ` tally with: oc-vote tally ${opts.pollId}`]
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// src/commands/republish.ts
|
|
766
|
+
async function failure(p) {
|
|
767
|
+
const v = await p.catch((e) => ({ ok: false, reason: e.message }));
|
|
768
|
+
return v.ok ? null : "reason" in v && v.reason || "it does not verify";
|
|
769
|
+
}
|
|
770
|
+
async function readSigned(text, relays = DEFAULT_RELAYS) {
|
|
771
|
+
let obj;
|
|
772
|
+
try {
|
|
773
|
+
obj = JSON.parse(text);
|
|
774
|
+
} catch {
|
|
775
|
+
throw new Error("the file is not JSON");
|
|
543
776
|
}
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
777
|
+
const event = obj && typeof obj === "object" && "event" in obj ? obj.event : obj;
|
|
778
|
+
if (!verifyEvent(event)) throw new Error("no signed Nostr event in the file: its id or signature doesn't hold");
|
|
779
|
+
if (event.kind === 30080) {
|
|
780
|
+
const poll = pollOf(event);
|
|
781
|
+
if (!poll) throw new Error("the event doesn't carry a poll");
|
|
782
|
+
const why = await failure(verifyPoll(poll, bip322Verify));
|
|
783
|
+
if (why) throw new Error(`the poll's signature doesn't verify: ${why}`);
|
|
784
|
+
return {
|
|
785
|
+
kind: "poll",
|
|
786
|
+
event,
|
|
787
|
+
id: ["poll_id", pollId(poll)],
|
|
788
|
+
heading: [["poll", poll.question]],
|
|
789
|
+
jsonExtra: { poll },
|
|
790
|
+
readBack: (r) => relaysServingPoll(poll, { relays: r })
|
|
791
|
+
};
|
|
557
792
|
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
793
|
+
if (event.kind === 30081) {
|
|
794
|
+
const ballot = ballotOf(event);
|
|
795
|
+
if (!ballot) throw new Error("the event doesn't carry a ballot");
|
|
796
|
+
const why = await failure(verifyBallot(ballot, bip322Verify));
|
|
797
|
+
if (why) throw new Error(`the ballot's signature doesn't verify: ${why}`);
|
|
798
|
+
return {
|
|
799
|
+
kind: "ballot",
|
|
800
|
+
event,
|
|
801
|
+
id: ["ballot_id", ballotId(ballot)],
|
|
802
|
+
heading: [
|
|
803
|
+
["poll", ballot.poll_id],
|
|
804
|
+
["voter", ballot.voter]
|
|
805
|
+
],
|
|
806
|
+
jsonExtra: { ballot },
|
|
807
|
+
readBack: (r) => relaysServingBallot(ballot, { relays: r })
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
if (event.kind === 30082) {
|
|
811
|
+
const reveal = revealOf(event);
|
|
812
|
+
if (!reveal) throw new Error("the event doesn't carry a reveal");
|
|
813
|
+
const poll = (await findPoll(await fetchPollEvents(reveal.poll_id, relays), reveal.poll_id, bip322Verify)).poll;
|
|
814
|
+
const chain = await revealChainFor(poll);
|
|
815
|
+
const why = await failure(verifyReveal(reveal, poll, bip322Verify, Date.now(), chain));
|
|
816
|
+
if (why) throw new Error(`the reveal doesn't verify against its poll: ${why}`);
|
|
817
|
+
return {
|
|
818
|
+
kind: "reveal",
|
|
819
|
+
event,
|
|
820
|
+
id: ["reveal_id", revealId(reveal)],
|
|
821
|
+
heading: [["poll", poll.question]],
|
|
822
|
+
jsonExtra: { reveal },
|
|
823
|
+
readBack: (r) => relaysServingReveal(reveal, poll, { relays: r, chain })
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
if (event.kind === 30083) {
|
|
827
|
+
const env = stampOf(event);
|
|
828
|
+
if (!env) throw new Error("the event doesn't carry an OC Stamp");
|
|
829
|
+
const why = await failure(
|
|
830
|
+
verify({
|
|
831
|
+
envelope: env,
|
|
832
|
+
verifyBip322: async (message, signature, address) => bip322Verify({ address, message, signature })
|
|
833
|
+
}).then((v) => v.ok ? { ok: true } : { ok: false, reason: v.message ?? v.code })
|
|
834
|
+
);
|
|
835
|
+
if (why) throw new Error(`the stamp's signature doesn't verify: ${why}`);
|
|
836
|
+
return {
|
|
837
|
+
kind: "stamp",
|
|
838
|
+
event,
|
|
839
|
+
id: ["stamp_id", env.id],
|
|
840
|
+
heading: [["over", env.content.hash]],
|
|
841
|
+
jsonExtra: { stamp: env },
|
|
842
|
+
readBack: (r) => relaysServingStamp(env, { relays: r })
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
throw new Error(`the event is kind ${event.kind}: not a poll, ballot, reveal or stamp (30080 to 30083)`);
|
|
846
|
+
}
|
|
847
|
+
async function runRepublish(opts) {
|
|
848
|
+
const relays = opts.relays ?? DEFAULT_RELAYS;
|
|
849
|
+
const signed = await readSigned(readFileSync(opts.file, "utf8"), relays);
|
|
850
|
+
const serving = await castEvent(signed.event, [], () => signed.readBack(relays), { relays });
|
|
851
|
+
report({
|
|
852
|
+
id: signed.id,
|
|
853
|
+
heading: signed.heading,
|
|
854
|
+
main: { kind: signed.kind, event: signed.event, relays, serving },
|
|
855
|
+
json: Boolean(opts.json),
|
|
856
|
+
jsonExtra: signed.jsonExtra,
|
|
857
|
+
file: opts.file
|
|
858
|
+
});
|
|
562
859
|
}
|
|
563
860
|
|
|
564
861
|
// src/commands/show.ts
|
|
@@ -613,15 +910,15 @@ async function runShow(opts) {
|
|
|
613
910
|
`);
|
|
614
911
|
}
|
|
615
912
|
function ballotCopiesFromEvents(events, pid) {
|
|
616
|
-
const
|
|
913
|
+
const parsed2 = [];
|
|
617
914
|
for (const ev of events) {
|
|
618
915
|
try {
|
|
619
916
|
const b = JSON.parse(ev.content);
|
|
620
|
-
if (b && b.poll_id === pid)
|
|
917
|
+
if (b && b.poll_id === pid) parsed2.push(b);
|
|
621
918
|
} catch {
|
|
622
919
|
}
|
|
623
920
|
}
|
|
624
|
-
return distinctBallotCopies(
|
|
921
|
+
return distinctBallotCopies(parsed2);
|
|
625
922
|
}
|
|
626
923
|
function ballotVerdicts(copies, verify = verifySignature) {
|
|
627
924
|
const byId = /* @__PURE__ */ new Map();
|
|
@@ -718,24 +1015,25 @@ async function runTally(opts) {
|
|
|
718
1015
|
}
|
|
719
1016
|
const relays = opts.relays ?? DEFAULT_RELAYS;
|
|
720
1017
|
const verify = opts.verify !== false ? bip322Verify : null;
|
|
721
|
-
const
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
fetchRevealEvents(pid, relays)
|
|
725
|
-
]);
|
|
726
|
-
const selected = await findPoll(pollFetch, pid, verify ?? bip322Verify);
|
|
1018
|
+
const ballotRead = fetchBallotEvents(pid, relays);
|
|
1019
|
+
ballotRead.catch(() => void 0);
|
|
1020
|
+
const selected = await findPoll(await fetchPollEvents(pid, relays), pid, verify ?? bip322Verify);
|
|
727
1021
|
if (verify && !selected.signatureValid) {
|
|
728
1022
|
throw new Error("poll signature does not verify against its creator (SPEC \xA710.1)");
|
|
729
1023
|
}
|
|
730
1024
|
const poll = selected.poll;
|
|
731
|
-
const
|
|
1025
|
+
const [ballotFetch, revealFetch] = await Promise.all([
|
|
1026
|
+
ballotRead,
|
|
1027
|
+
poll.mode === "secret" ? fetchRevealEvents(pid, relays) : null
|
|
1028
|
+
]);
|
|
1029
|
+
const ballots = ballotCopiesFromEvents(ballotsToCount(ballotFetch, isAnchoredPoll(poll)), pid);
|
|
732
1030
|
const chain = await anchoredChain(poll, ballots.map(ballotId), {
|
|
733
1031
|
ballotRelays: ballotFetch.relays,
|
|
734
1032
|
relays,
|
|
735
1033
|
...opts.mempoolBase ? { mempoolBase: opts.mempoolBase } : {}
|
|
736
1034
|
});
|
|
737
|
-
const reveal =
|
|
738
|
-
if (
|
|
1035
|
+
const reveal = revealFetch ? await selectReveal(revealFetch.events, poll, bip322Verify, chain ?? void 0) : null;
|
|
1036
|
+
if (revealFetch && !reveal) requireComplete(revealFetch, "reveal");
|
|
739
1037
|
const source = mempoolSource(opts.mempoolBase);
|
|
740
1038
|
const utxosAt = buildLookup(source);
|
|
741
1039
|
const snapshot = chain ? void 0 : await resolveSnapshot(poll, opts);
|
|
@@ -949,66 +1247,38 @@ async function runVote(opts) {
|
|
|
949
1247
|
const sigValue = await promptForSignature(opts.voter, bid, opts.sig);
|
|
950
1248
|
draft.sig.value = sigValue;
|
|
951
1249
|
const pub = buildBallotEvent(draft);
|
|
952
|
-
const
|
|
953
|
-
const
|
|
954
|
-
let
|
|
955
|
-
if (
|
|
1250
|
+
const relays = opts.relays ?? DEFAULT_RELAYS;
|
|
1251
|
+
const serving = await castBallot(pub, draft, [], { relays });
|
|
1252
|
+
let stamp2 = null;
|
|
1253
|
+
if (serving.length > 0 && isAnchoredPoll(poll)) {
|
|
956
1254
|
try {
|
|
957
1255
|
const env = await stampBallot(draft);
|
|
958
|
-
const
|
|
959
|
-
|
|
1256
|
+
const event = buildStampEvent(env);
|
|
1257
|
+
const on = await castEvent(event, [], () => relaysServingStamp(env, { relays }), { relays });
|
|
1258
|
+
stamp2 = { kind: "stamp", event, relays, serving: on };
|
|
960
1259
|
} catch (e) {
|
|
961
|
-
|
|
1260
|
+
stamp2 = { error: e.message };
|
|
962
1261
|
}
|
|
963
1262
|
}
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
2
|
|
977
|
-
) + "\n"
|
|
978
|
-
);
|
|
979
|
-
return;
|
|
980
|
-
}
|
|
981
|
-
const w = (s) => process.stdout.write(s);
|
|
982
|
-
w(`
|
|
983
|
-
ballot_id: ${bid}
|
|
984
|
-
`);
|
|
985
|
-
w(` poll: ${poll.question}
|
|
986
|
-
`);
|
|
987
|
-
w(` voter: ${opts.voter}
|
|
988
|
-
`);
|
|
989
|
-
w(` option: ${opts.option}${poll.mode === "secret" ? " (sealed)" : ""}
|
|
990
|
-
`);
|
|
991
|
-
w(` published: ${okCount}/${results.length} relays
|
|
992
|
-
`);
|
|
993
|
-
for (const r of results) {
|
|
994
|
-
w(` ${r.ok ? "\u2713" : "\u2717"} ${r.relay}${r.reason ? ` (${r.reason})` : ""}
|
|
995
|
-
`);
|
|
996
|
-
}
|
|
997
|
-
if (stamped && "error" in stamped) {
|
|
998
|
-
w(` stamp: not made here (${stamped.error}); vote.ochk.io's anchorer stamps every ballot it sees
|
|
999
|
-
`);
|
|
1000
|
-
} else if (stamped) {
|
|
1001
|
-
w(` stamp: published to ${stamped.relays}/${stamped.total} relays; a Bitcoin block usually anchors it within an hour or two
|
|
1002
|
-
`);
|
|
1003
|
-
}
|
|
1004
|
-
w(`
|
|
1005
|
-
`);
|
|
1263
|
+
report({
|
|
1264
|
+
id: ["ballot_id", bid],
|
|
1265
|
+
heading: [
|
|
1266
|
+
["poll", poll.question],
|
|
1267
|
+
["voter", opts.voter],
|
|
1268
|
+
["option", `${opts.option}${poll.mode === "secret" ? " (sealed)" : ""}`]
|
|
1269
|
+
],
|
|
1270
|
+
main: { kind: "ballot", event: pub, relays, serving },
|
|
1271
|
+
stamp: stamp2,
|
|
1272
|
+
json: Boolean(opts.json),
|
|
1273
|
+
jsonExtra: { ballot: draft }
|
|
1274
|
+
});
|
|
1006
1275
|
}
|
|
1007
1276
|
|
|
1008
1277
|
// src/cli.ts
|
|
1009
1278
|
if (typeof globalThis.WebSocket === "undefined") {
|
|
1010
1279
|
globalThis.WebSocket = WebSocket;
|
|
1011
1280
|
}
|
|
1281
|
+
var { version } = createRequire(import.meta.url)("../package.json");
|
|
1012
1282
|
var program = new Command();
|
|
1013
1283
|
function collectRelay(v, prev = []) {
|
|
1014
1284
|
return [...prev, v];
|
|
@@ -1028,9 +1298,9 @@ function parseDeadline(raw) {
|
|
|
1028
1298
|
const d = new Date(Date.now() + ms);
|
|
1029
1299
|
return d.toISOString().replace(/\.\d+Z$/, "Z");
|
|
1030
1300
|
}
|
|
1031
|
-
const
|
|
1032
|
-
if (Number.isNaN(
|
|
1033
|
-
return
|
|
1301
|
+
const parsed2 = new Date(raw);
|
|
1302
|
+
if (Number.isNaN(parsed2.getTime())) throw new Error(`invalid deadline: ${raw}`);
|
|
1303
|
+
return parsed2.toISOString().replace(/\.\d+Z$/, "Z");
|
|
1034
1304
|
}
|
|
1035
1305
|
function parseSnapshot(raw) {
|
|
1036
1306
|
if (raw === "deadline") return "deadline";
|
|
@@ -1038,10 +1308,10 @@ function parseSnapshot(raw) {
|
|
|
1038
1308
|
if (!Number.isFinite(n) || n <= 0) throw new Error(`invalid snapshot block: ${raw}`);
|
|
1039
1309
|
return n;
|
|
1040
1310
|
}
|
|
1041
|
-
program.name("oc-vote").description("OC Vote \u2014 sovereign signaling for the open web").version(
|
|
1042
|
-
program.command("tally <poll_id>").description("Fetch the poll + ballots and run the pure tally function").option("--relay <url>", "Nostr relay (repeatable)", collectRelay, []).option("--mempool-base <url>", "mempool.space-compatible base URL", "https://mempool.space/api").option("--snapshot <height>", "Override snapshot block (integer)").option("--no-verify", "Skip BIP-322 signature verification").option("--json", "Emit JSON instead of human-readable output").action(async (
|
|
1311
|
+
program.name("oc-vote").description("OC Vote \u2014 sovereign signaling for the open web").version(version);
|
|
1312
|
+
program.command("tally <poll_id>").description("Fetch the poll + ballots and run the pure tally function").option("--relay <url>", "Nostr relay (repeatable)", collectRelay, []).option("--mempool-base <url>", "mempool.space-compatible base URL", "https://mempool.space/api").option("--snapshot <height>", "Override snapshot block (integer)").option("--no-verify", "Skip BIP-322 signature verification").option("--json", "Emit JSON instead of human-readable output").action(async (pollId5, opts) => {
|
|
1043
1313
|
await runTally({
|
|
1044
|
-
pollId:
|
|
1314
|
+
pollId: pollId5,
|
|
1045
1315
|
relays: opts.relay?.length ? opts.relay : void 0,
|
|
1046
1316
|
mempoolBase: opts.mempoolBase,
|
|
1047
1317
|
json: Boolean(opts.json),
|
|
@@ -1049,16 +1319,16 @@ program.command("tally <poll_id>").description("Fetch the poll + ballots and run
|
|
|
1049
1319
|
snapshotBlock: opts.snapshot ? Number.parseInt(String(opts.snapshot), 10) : void 0
|
|
1050
1320
|
});
|
|
1051
1321
|
});
|
|
1052
|
-
program.command("verify <poll_id>").description("Verify every BIP-322 signature on the poll + its ballots").option("--relay <url>", "Nostr relay (repeatable)", collectRelay, []).option("--json", "Emit JSON").action(async (
|
|
1322
|
+
program.command("verify <poll_id>").description("Verify every BIP-322 signature on the poll + its ballots").option("--relay <url>", "Nostr relay (repeatable)", collectRelay, []).option("--json", "Emit JSON").action(async (pollId5, opts) => {
|
|
1053
1323
|
await runVerify({
|
|
1054
|
-
pollId:
|
|
1324
|
+
pollId: pollId5,
|
|
1055
1325
|
relays: opts.relay?.length ? opts.relay : void 0,
|
|
1056
1326
|
json: Boolean(opts.json)
|
|
1057
1327
|
});
|
|
1058
1328
|
});
|
|
1059
|
-
program.command("show <poll_id>").description("Print the poll metadata + ballot count without running the tally").option("--relay <url>", "Nostr relay (repeatable)", collectRelay, []).option("--json", "Emit JSON").action(async (
|
|
1329
|
+
program.command("show <poll_id>").description("Print the poll metadata + ballot count without running the tally").option("--relay <url>", "Nostr relay (repeatable)", collectRelay, []).option("--json", "Emit JSON").action(async (pollId5, opts) => {
|
|
1060
1330
|
await runShow({
|
|
1061
|
-
pollId:
|
|
1331
|
+
pollId: pollId5,
|
|
1062
1332
|
relays: opts.relay?.length ? opts.relay : void 0,
|
|
1063
1333
|
json: Boolean(opts.json)
|
|
1064
1334
|
});
|
|
@@ -1071,7 +1341,9 @@ program.command("discover").description("List recent polls across the default re
|
|
|
1071
1341
|
openOnly: Boolean(opts.openOnly)
|
|
1072
1342
|
});
|
|
1073
1343
|
});
|
|
1074
|
-
program.command("create").description(
|
|
1344
|
+
program.command("create").description(
|
|
1345
|
+
"Build + sign + publish a poll. It is published once 2 relays, each asked alone, serve it back; below that it exits 2 (see republish)"
|
|
1346
|
+
).requiredOption("--creator <btc-addr>", "Bitcoin address of the poll creator").requiredOption("--question <text>", "poll question").requiredOption(
|
|
1075
1347
|
"--option <id:label>",
|
|
1076
1348
|
'poll option; repeatable; "id" or "id:label"',
|
|
1077
1349
|
collectOption,
|
|
@@ -1104,7 +1376,9 @@ program.command("create").description("Build + sign + publish a poll").requiredO
|
|
|
1104
1376
|
json: Boolean(opts.json)
|
|
1105
1377
|
});
|
|
1106
1378
|
});
|
|
1107
|
-
program.command("vote").description(
|
|
1379
|
+
program.command("vote").description(
|
|
1380
|
+
"Cast a ballot on a poll. It is in once 2 relays, each asked alone, serve it back; below that it exits 2 (see republish)"
|
|
1381
|
+
).requiredOption("--poll <poll_id>", "64-hex poll id").requiredOption("--voter <btc-addr>", "voter Bitcoin address").requiredOption("--option <id>", "chosen option id").option("--sig <base64>", "BIP-322 signature of ballot_id; prompts if omitted").option("--relay <url>", "Nostr relay (repeatable)", collectRelay, []).option("--dry-run", "print canonical + id; do not sign or publish").option("--json", "Emit JSON").action(async (opts) => {
|
|
1108
1382
|
await runVote({
|
|
1109
1383
|
pollId: String(opts.poll),
|
|
1110
1384
|
voter: String(opts.voter),
|
|
@@ -1115,7 +1389,18 @@ program.command("vote").description("Cast a ballot on a poll").requiredOption("-
|
|
|
1115
1389
|
json: Boolean(opts.json)
|
|
1116
1390
|
});
|
|
1117
1391
|
});
|
|
1118
|
-
program.command("
|
|
1392
|
+
program.command("republish <file>").description(
|
|
1393
|
+
"Send a signed poll, ballot, reveal or ballot stamp again, as it is (from --json output, or an event: line), and ask each relay for it back"
|
|
1394
|
+
).option("--relay <url>", "Nostr relay (repeatable)", collectRelay, []).option("--json", "Emit JSON").action(async (file, opts) => {
|
|
1395
|
+
await runRepublish({
|
|
1396
|
+
file,
|
|
1397
|
+
relays: opts.relay?.length ? opts.relay : void 0,
|
|
1398
|
+
json: Boolean(opts.json)
|
|
1399
|
+
});
|
|
1400
|
+
});
|
|
1401
|
+
program.command("reveal").description(
|
|
1402
|
+
"Publish the reveal for a secret-mode poll, or send the one already signed again. It is published once 2 relays serve it back; below that it exits 2"
|
|
1403
|
+
).requiredOption("--poll <poll_id>", "64-hex poll id").requiredOption("--reveal-sk <hex>", "64-hex X25519 secret key").requiredOption("--creator <btc-addr>", "poll creator address (must match the poll)").option("--sig <base64>", "BIP-322 signature of reveal_id; prompts if omitted").option("--relay <url>", "Nostr relay (repeatable)", collectRelay, []).option("--force", "publish before the deadline, or sign a new reveal when one is already on the relays").option("--dry-run", "print reveal + id; do not sign or publish").option("--json", "Emit JSON").action(async (opts) => {
|
|
1119
1404
|
await runReveal({
|
|
1120
1405
|
pollId: String(opts.poll),
|
|
1121
1406
|
revealSk: String(opts.revealSk),
|