@orangecheck/vote-cli 0.7.5 → 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/dist/cli.js CHANGED
@@ -1,16 +1,18 @@
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 { isAnchoredPoll, ballotId, lastAnchorHeight, ANCHOR_FINAL_CONFIRMATIONS, pollId, verifyPoll, canonicalBytes, commit, resolveCloseBlock, mempoolBlockTimeSource, anchoredTallyIsFinal, revealId, distinctBallotCopies, anchorHeightFromStamps, verifyReveal, resolvePollSnapshot, tally, ballotStampContent } from '@orangecheck/vote-core';
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, queryEvents } from '@orangecheck/nostr-core';
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;
@@ -22,11 +24,11 @@ async function query(filter, relays) {
22
24
  const { events, relayStatus } = await queryEvents(filter, relays, QUERY_TIMEOUT_MS);
23
25
  return { events, complete: reachedEose(relayStatus) };
24
26
  }
25
- function fetchPollEvents(pollId3, relays) {
26
- return query({ kinds: [30080], "#d": [`oc-vote:poll:${pollId3}`] }, relays);
27
+ function fetchPollEvents(pollId5, relays) {
28
+ return query({ kinds: [30080], "#d": [`oc-vote:poll:${pollId5}`] }, relays);
27
29
  }
28
- function fetchBallotEvents(pollId3, relays, queryFn) {
29
- return queryPaged({ kinds: [30081], "#t": [pollId3] }, relays, queryFn);
30
+ function fetchBallotEvents(pollId5, relays, queryFn) {
31
+ return queryPaged({ kinds: [30081], "#t": [pollId5] }, relays, queryFn);
30
32
  }
31
33
  var PAGE_SIZE = 100;
32
34
  var MAX_PAGES = 20;
@@ -80,8 +82,8 @@ async function queryPaged(filter, relays = DEFAULT_RELAYS, queryFn = queryEvents
80
82
  relays: results.map((r) => r.read)
81
83
  };
82
84
  }
83
- function fetchRevealEvents(pollId3, relays, queryFn) {
84
- return queryPaged({ kinds: [30082], "#d": [`oc-vote:reveal:${pollId3}`] }, relays, queryFn);
85
+ function fetchRevealEvents(pollId5, relays, queryFn) {
86
+ return queryPaged({ kinds: [30082], "#d": [`oc-vote:reveal:${pollId5}`] }, relays, queryFn);
85
87
  }
86
88
  function fetchRecentPolls(limit = 30, relays) {
87
89
  return query({ kinds: [30080], "#t": ["oc-vote-poll"], limit }, relays);
@@ -263,6 +265,215 @@ function buildStampEvent(env) {
263
265
  const { tags, content } = stampEventParts(env);
264
266
  return sign(STAMP_KIND, tags, content);
265
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
+ }
266
477
  async function promptForSignature(address, id, preset) {
267
478
  if (preset && preset.trim()) return preset.trim();
268
479
  process.stderr.write(
@@ -328,60 +539,28 @@ async function runCreate(opts) {
328
539
  const sigValue = await promptForSignature(opts.creator, pid, opts.sig);
329
540
  draft.sig.value = sigValue;
330
541
  const event = buildPollEvent(draft);
331
- const results = await publishEvent(event, opts.relays ?? DEFAULT_RELAYS);
332
- const okCount = results.filter((r) => r.ok).length;
333
- if (opts.json) {
334
- process.stdout.write(
335
- JSON.stringify(
336
- {
337
- poll_id: pid,
338
- published: okCount,
339
- total: results.length,
340
- relays: results,
341
- poll: draft,
342
- ...reveal_sk ? { reveal_sk, reveal_pk } : {},
343
- url: `https://vote.ochk.io/p/${pid}`
344
- },
345
- null,
346
- 2
347
- ) + "\n"
348
- );
349
- return;
350
- }
351
- const w = (s) => process.stdout.write(s);
352
- w(`
353
- poll_id: ${pid}
354
- `);
355
- w(` published: ${okCount}/${results.length} relays
356
- `);
357
- for (const r of results) {
358
- w(` ${r.ok ? "\u2713" : "\u2717"} ${r.relay}${r.reason ? ` (${r.reason})` : ""}
359
- `);
360
- }
361
- w(` url: https://vote.ochk.io/p/${pid}
362
- `);
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}`];
363
546
  if (reveal_sk) {
364
- w(`
365
- SECRET MODE \u2014 save these or the poll is abandoned at deadline:
366
- `);
367
- w(` reveal_pk: ${reveal_pk}
368
- `);
369
- w(` reveal_sk: ${reveal_sk}
370
- `);
371
- w(` to publish the reveal:
372
- `);
373
- w(` oc-vote reveal --poll ${pid} --reveal-sk ${reveal_sk} --creator ${opts.creator}
374
- `);
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
+ );
375
555
  }
376
- w(`
377
- canonical bytes:
378
- `);
379
- w(canonicalBytes({ ...draft, sig: { ...draft.sig, value: "" } }));
380
- w(`
381
- `);
382
- }
383
- async function bip322Verify(args) {
384
- 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
+ });
385
564
  }
386
565
  function parse(ev) {
387
566
  try {
@@ -420,9 +599,12 @@ function ballotsToCount(fetched, anchored) {
420
599
  throw new Error("no relay finished answering the ballot query; try again");
421
600
  }
422
601
  async function selectReveal(events, poll, verify, chain) {
423
- for (const ev of events) {
424
- const r = parse(ev);
425
- if (r && (await verifyReveal(r, poll, verify, Date.now(), chain)).ok) return r;
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 };
426
608
  }
427
609
  return null;
428
610
  }
@@ -495,6 +677,12 @@ function runGenRevealKey(opts) {
495
677
 
496
678
  `);
497
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
+ }
498
686
  async function runReveal(opts) {
499
687
  if (!/^[0-9a-f]{64}$/.test(opts.pollId)) {
500
688
  throw new Error("poll id must be 64 hex chars");
@@ -511,14 +699,11 @@ async function runReveal(opts) {
511
699
  `poll creator is ${poll.creator} but you provided ${opts.creator}`
512
700
  );
513
701
  }
514
- let chain;
515
- if (isAnchoredPoll(poll)) {
516
- const close = await resolveCloseBlock(poll.deadline, mempoolBlockTimeSource());
517
- if (!close.ok) throw new Error(`the block at the deadline could not be read: ${close.reason}`);
518
- chain = { tipHeight: close.tip, closeHeight: close.height };
519
- 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) {
520
705
  throw new Error(
521
- close.height === 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(close.height) + ANCHOR_FINAL_CONFIRMATIONS}; the tip is ${close.tip} (pass --force to publish anyway)`
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)`
522
707
  );
523
708
  }
524
709
  } else if (Date.parse(poll.deadline) > Date.now() && !opts.force) {
@@ -526,14 +711,26 @@ async function runReveal(opts) {
526
711
  `poll deadline is ${poll.deadline} \u2014 pass --force to publish before deadline`
527
712
  );
528
713
  }
529
- const existing = await selectReveal(
530
- (await fetchRevealEvents(opts.pollId, relays)).events,
531
- poll,
532
- bip322Verify,
533
- chain
534
- );
714
+ const existing = await findReveal((await fetchRevealEvents(opts.pollId, relays)).events, poll, bip322Verify, chain);
535
715
  if (existing && !opts.force) {
536
- throw new Error("a reveal event already exists for this poll (pass --force to replace)");
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;
537
734
  }
538
735
  const draft = {
539
736
  v: 0,
@@ -551,42 +748,114 @@ async function runReveal(opts) {
551
748
  const sigValue = await promptForSignature(opts.creator, rid, opts.sig);
552
749
  draft.sig.value = sigValue;
553
750
  const event = buildRevealEvent(draft);
554
- const results = await publishEvent(event, relays);
555
- const okCount = results.filter((r) => r.ok).length;
556
- if (opts.json) {
557
- process.stdout.write(
558
- JSON.stringify(
559
- {
560
- reveal_id: rid,
561
- published: okCount,
562
- total: results.length,
563
- relays: results,
564
- reveal: draft
565
- },
566
- null,
567
- 2
568
- ) + "\n"
569
- );
570
- return;
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");
571
776
  }
572
- const w = (s) => process.stdout.write(s);
573
- w(`
574
- reveal_id: ${rid}
575
- `);
576
- w(` poll: ${poll.question}
577
- `);
578
- w(` revealed: ${draft.revealed_at}
579
- `);
580
- w(` published: ${okCount}/${results.length} relays
581
- `);
582
- for (const r of results) {
583
- w(` ${r.ok ? "\u2713" : "\u2717"} ${r.relay}${r.reason ? ` (${r.reason})` : ""}
584
- `);
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
+ };
585
792
  }
586
- w(`
587
- tally with: oc-vote tally ${opts.pollId}
588
-
589
- `);
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
+ });
590
859
  }
591
860
 
592
861
  // src/commands/show.ts
@@ -641,15 +910,15 @@ async function runShow(opts) {
641
910
  `);
642
911
  }
643
912
  function ballotCopiesFromEvents(events, pid) {
644
- const parsed = [];
913
+ const parsed2 = [];
645
914
  for (const ev of events) {
646
915
  try {
647
916
  const b = JSON.parse(ev.content);
648
- if (b && b.poll_id === pid) parsed.push(b);
917
+ if (b && b.poll_id === pid) parsed2.push(b);
649
918
  } catch {
650
919
  }
651
920
  }
652
- return distinctBallotCopies(parsed);
921
+ return distinctBallotCopies(parsed2);
653
922
  }
654
923
  function ballotVerdicts(copies, verify = verifySignature) {
655
924
  const byId = /* @__PURE__ */ new Map();
@@ -978,66 +1247,38 @@ async function runVote(opts) {
978
1247
  const sigValue = await promptForSignature(opts.voter, bid, opts.sig);
979
1248
  draft.sig.value = sigValue;
980
1249
  const pub = buildBallotEvent(draft);
981
- const results = await publishEvent(pub, opts.relays ?? DEFAULT_RELAYS);
982
- const okCount = results.filter((r) => r.ok).length;
983
- let stamped = null;
984
- if (okCount > 0 && isAnchoredPoll(poll)) {
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)) {
985
1254
  try {
986
1255
  const env = await stampBallot(draft);
987
- const sent = await publishEvent(buildStampEvent(env), opts.relays ?? DEFAULT_RELAYS);
988
- stamped = { relays: sent.filter((r) => r.ok).length, total: sent.length };
1256
+ const event = buildStampEvent(env);
1257
+ const on = await castEvent(event, [], () => relaysServingStamp(env, { relays }), { relays });
1258
+ stamp2 = { kind: "stamp", event, relays, serving: on };
989
1259
  } catch (e) {
990
- stamped = { error: e.message };
1260
+ stamp2 = { error: e.message };
991
1261
  }
992
1262
  }
993
- if (opts.json) {
994
- process.stdout.write(
995
- JSON.stringify(
996
- {
997
- ballot_id: bid,
998
- published: okCount,
999
- total: results.length,
1000
- relays: results,
1001
- ...stamped ? { stamp: stamped } : {},
1002
- ballot: draft
1003
- },
1004
- null,
1005
- 2
1006
- ) + "\n"
1007
- );
1008
- return;
1009
- }
1010
- const w = (s) => process.stdout.write(s);
1011
- w(`
1012
- ballot_id: ${bid}
1013
- `);
1014
- w(` poll: ${poll.question}
1015
- `);
1016
- w(` voter: ${opts.voter}
1017
- `);
1018
- w(` option: ${opts.option}${poll.mode === "secret" ? " (sealed)" : ""}
1019
- `);
1020
- w(` published: ${okCount}/${results.length} relays
1021
- `);
1022
- for (const r of results) {
1023
- w(` ${r.ok ? "\u2713" : "\u2717"} ${r.relay}${r.reason ? ` (${r.reason})` : ""}
1024
- `);
1025
- }
1026
- if (stamped && "error" in stamped) {
1027
- w(` stamp: not made here (${stamped.error}); vote.ochk.io's anchorer stamps every ballot it sees
1028
- `);
1029
- } else if (stamped) {
1030
- w(` stamp: published to ${stamped.relays}/${stamped.total} relays; a Bitcoin block usually anchors it within an hour or two
1031
- `);
1032
- }
1033
- w(`
1034
- `);
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
+ });
1035
1275
  }
1036
1276
 
1037
1277
  // src/cli.ts
1038
1278
  if (typeof globalThis.WebSocket === "undefined") {
1039
1279
  globalThis.WebSocket = WebSocket;
1040
1280
  }
1281
+ var { version } = createRequire(import.meta.url)("../package.json");
1041
1282
  var program = new Command();
1042
1283
  function collectRelay(v, prev = []) {
1043
1284
  return [...prev, v];
@@ -1057,9 +1298,9 @@ function parseDeadline(raw) {
1057
1298
  const d = new Date(Date.now() + ms);
1058
1299
  return d.toISOString().replace(/\.\d+Z$/, "Z");
1059
1300
  }
1060
- const parsed = new Date(raw);
1061
- if (Number.isNaN(parsed.getTime())) throw new Error(`invalid deadline: ${raw}`);
1062
- return parsed.toISOString().replace(/\.\d+Z$/, "Z");
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");
1063
1304
  }
1064
1305
  function parseSnapshot(raw) {
1065
1306
  if (raw === "deadline") return "deadline";
@@ -1067,10 +1308,10 @@ function parseSnapshot(raw) {
1067
1308
  if (!Number.isFinite(n) || n <= 0) throw new Error(`invalid snapshot block: ${raw}`);
1068
1309
  return n;
1069
1310
  }
1070
- program.name("oc-vote").description("OC Vote \u2014 sovereign signaling for the open web").version("0.2.0");
1071
- 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 (pollId3, opts) => {
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) => {
1072
1313
  await runTally({
1073
- pollId: pollId3,
1314
+ pollId: pollId5,
1074
1315
  relays: opts.relay?.length ? opts.relay : void 0,
1075
1316
  mempoolBase: opts.mempoolBase,
1076
1317
  json: Boolean(opts.json),
@@ -1078,16 +1319,16 @@ program.command("tally <poll_id>").description("Fetch the poll + ballots and run
1078
1319
  snapshotBlock: opts.snapshot ? Number.parseInt(String(opts.snapshot), 10) : void 0
1079
1320
  });
1080
1321
  });
1081
- 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 (pollId3, opts) => {
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) => {
1082
1323
  await runVerify({
1083
- pollId: pollId3,
1324
+ pollId: pollId5,
1084
1325
  relays: opts.relay?.length ? opts.relay : void 0,
1085
1326
  json: Boolean(opts.json)
1086
1327
  });
1087
1328
  });
1088
- 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 (pollId3, opts) => {
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) => {
1089
1330
  await runShow({
1090
- pollId: pollId3,
1331
+ pollId: pollId5,
1091
1332
  relays: opts.relay?.length ? opts.relay : void 0,
1092
1333
  json: Boolean(opts.json)
1093
1334
  });
@@ -1100,7 +1341,9 @@ program.command("discover").description("List recent polls across the default re
1100
1341
  openOnly: Boolean(opts.openOnly)
1101
1342
  });
1102
1343
  });
1103
- program.command("create").description("Build + sign + publish a poll").requiredOption("--creator <btc-addr>", "Bitcoin address of the poll creator").requiredOption("--question <text>", "poll question").requiredOption(
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(
1104
1347
  "--option <id:label>",
1105
1348
  'poll option; repeatable; "id" or "id:label"',
1106
1349
  collectOption,
@@ -1133,7 +1376,9 @@ program.command("create").description("Build + sign + publish a poll").requiredO
1133
1376
  json: Boolean(opts.json)
1134
1377
  });
1135
1378
  });
1136
- program.command("vote").description("Cast a ballot on a poll").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) => {
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) => {
1137
1382
  await runVote({
1138
1383
  pollId: String(opts.poll),
1139
1384
  voter: String(opts.voter),
@@ -1144,7 +1389,18 @@ program.command("vote").description("Cast a ballot on a poll").requiredOption("-
1144
1389
  json: Boolean(opts.json)
1145
1390
  });
1146
1391
  });
1147
- program.command("reveal").description("Publish the reveal event for a secret-mode poll").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 even before deadline / even if a reveal exists").option("--dry-run", "print reveal + id; do not sign or publish").option("--json", "Emit JSON").action(async (opts) => {
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) => {
1148
1404
  await runReveal({
1149
1405
  pollId: String(opts.poll),
1150
1406
  revealSk: String(opts.revealSk),