@orangecheck/vote-cli 0.7.5 → 0.7.7
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 +14 -0
- package/dist/cli.js +435 -177
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
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,
|
|
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;
|
|
@@ -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(
|
|
26
|
-
return query({ kinds: [30080], "#d": [`oc-vote:poll:${
|
|
27
|
+
function fetchPollEvents(pollId5, relays) {
|
|
28
|
+
return query({ kinds: [30080], "#d": [`oc-vote:poll:${pollId5}`] }, relays);
|
|
27
29
|
}
|
|
28
|
-
function fetchBallotEvents(
|
|
29
|
-
return queryPaged({ kinds: [30081], "#t": [
|
|
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(
|
|
84
|
-
return queryPaged({ kinds: [30082], "#d": [`oc-vote:reveal:${
|
|
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,217 @@ 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
|
+
const unmade = r.stamp && "error" in r.stamp ? r.stamp : null;
|
|
447
|
+
if (!standingIsIn(r.main) || stamp2 && !standingIsIn(stamp2) || unmade) process.exitCode = NOT_IN_YET;
|
|
448
|
+
if (r.json) {
|
|
449
|
+
const stampJson = r.stamp ? "kind" in r.stamp ? { ...standingJson(r.stamp), event: r.stamp.event } : r.stamp : null;
|
|
450
|
+
process.stdout.write(
|
|
451
|
+
JSON.stringify(
|
|
452
|
+
{
|
|
453
|
+
[r.id[0]]: r.id[1],
|
|
454
|
+
...standingJson(r.main),
|
|
455
|
+
...stampJson ? { stamp: stampJson } : {},
|
|
456
|
+
...r.jsonExtra,
|
|
457
|
+
event: r.main.event
|
|
458
|
+
},
|
|
459
|
+
null,
|
|
460
|
+
2
|
|
461
|
+
) + "\n"
|
|
462
|
+
);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const lines = [` ${`${r.id[0]}:`.padEnd(12)}${r.id[1]}`];
|
|
466
|
+
for (const [k, v] of r.heading ?? []) lines.push(` ${`${k}:`.padEnd(12)}${v}`);
|
|
467
|
+
lines.push(...standingLines(r.main, r.file));
|
|
468
|
+
if (stamp2) lines.push(...standingLines(stamp2));
|
|
469
|
+
else if (unmade) {
|
|
470
|
+
lines.push(` stamp: not made here (${unmade.error}), so a final count can't see this ballot yet`);
|
|
471
|
+
lines.push(` vote.ochk.io's anchorer stamps every ballot it sees`);
|
|
472
|
+
}
|
|
473
|
+
lines.push(...r.tail ?? []);
|
|
474
|
+
process.stdout.write(`
|
|
475
|
+
${lines.join("\n")}
|
|
476
|
+
|
|
477
|
+
`);
|
|
478
|
+
}
|
|
266
479
|
async function promptForSignature(address, id, preset) {
|
|
267
480
|
if (preset && preset.trim()) return preset.trim();
|
|
268
481
|
process.stderr.write(
|
|
@@ -328,60 +541,28 @@ async function runCreate(opts) {
|
|
|
328
541
|
const sigValue = await promptForSignature(opts.creator, pid, opts.sig);
|
|
329
542
|
draft.sig.value = sigValue;
|
|
330
543
|
const event = buildPollEvent(draft);
|
|
331
|
-
const
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
`);
|
|
544
|
+
const relays = opts.relays ?? DEFAULT_RELAYS;
|
|
545
|
+
const serving = await castEvent(event, [], () => relaysServingPoll(draft, { relays }), { relays });
|
|
546
|
+
const url = `https://vote.ochk.io/p/${pid}`;
|
|
547
|
+
const tail = [` url: ${url}`];
|
|
363
548
|
if (reveal_sk) {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
`
|
|
369
|
-
|
|
370
|
-
`
|
|
371
|
-
|
|
372
|
-
`);
|
|
373
|
-
w(` oc-vote reveal --poll ${pid} --reveal-sk ${reveal_sk} --creator ${opts.creator}
|
|
374
|
-
`);
|
|
549
|
+
tail.push(
|
|
550
|
+
"",
|
|
551
|
+
" SECRET MODE \u2014 save these or the poll is abandoned at deadline:",
|
|
552
|
+
` reveal_pk: ${reveal_pk}`,
|
|
553
|
+
` reveal_sk: ${reveal_sk}`,
|
|
554
|
+
" to publish the reveal:",
|
|
555
|
+
` oc-vote reveal --poll ${pid} --reveal-sk ${reveal_sk} --creator ${opts.creator}`
|
|
556
|
+
);
|
|
375
557
|
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
return verifySignature(args.address, args.message, args.signature);
|
|
558
|
+
tail.push("", " canonical bytes:", canonicalBytes({ ...draft, sig: { ...draft.sig, value: "" } }).trimEnd());
|
|
559
|
+
report({
|
|
560
|
+
id: ["poll_id", pid],
|
|
561
|
+
main: { kind: "poll", event, relays, serving },
|
|
562
|
+
json: Boolean(opts.json),
|
|
563
|
+
jsonExtra: { poll: draft, ...reveal_sk ? { reveal_sk, reveal_pk } : {}, url },
|
|
564
|
+
tail
|
|
565
|
+
});
|
|
385
566
|
}
|
|
386
567
|
function parse(ev) {
|
|
387
568
|
try {
|
|
@@ -420,9 +601,12 @@ function ballotsToCount(fetched, anchored) {
|
|
|
420
601
|
throw new Error("no relay finished answering the ballot query; try again");
|
|
421
602
|
}
|
|
422
603
|
async function selectReveal(events, poll, verify, chain) {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
604
|
+
return (await findReveal(events, poll, verify, chain))?.reveal ?? null;
|
|
605
|
+
}
|
|
606
|
+
async function findReveal(events, poll, verify, chain) {
|
|
607
|
+
for (const event of events) {
|
|
608
|
+
const reveal = parse(event);
|
|
609
|
+
if (reveal && (await verifyReveal(reveal, poll, verify, Date.now(), chain)).ok) return { event, reveal };
|
|
426
610
|
}
|
|
427
611
|
return null;
|
|
428
612
|
}
|
|
@@ -495,6 +679,12 @@ function runGenRevealKey(opts) {
|
|
|
495
679
|
|
|
496
680
|
`);
|
|
497
681
|
}
|
|
682
|
+
async function revealChainFor(poll) {
|
|
683
|
+
if (!isAnchoredPoll(poll)) return void 0;
|
|
684
|
+
const close = await resolveCloseBlock(poll.deadline, mempoolBlockTimeSource());
|
|
685
|
+
if (!close.ok) throw new Error(`the block at the deadline could not be read: ${close.reason}`);
|
|
686
|
+
return { tipHeight: close.tip, closeHeight: close.height };
|
|
687
|
+
}
|
|
498
688
|
async function runReveal(opts) {
|
|
499
689
|
if (!/^[0-9a-f]{64}$/.test(opts.pollId)) {
|
|
500
690
|
throw new Error("poll id must be 64 hex chars");
|
|
@@ -511,14 +701,11 @@ async function runReveal(opts) {
|
|
|
511
701
|
`poll creator is ${poll.creator} but you provided ${opts.creator}`
|
|
512
702
|
);
|
|
513
703
|
}
|
|
514
|
-
|
|
515
|
-
if (
|
|
516
|
-
|
|
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) {
|
|
704
|
+
const chain = await revealChainFor(poll);
|
|
705
|
+
if (chain) {
|
|
706
|
+
if (!anchoredTallyIsFinal(chain.closeHeight, chain.tipHeight) && !opts.force) {
|
|
520
707
|
throw new Error(
|
|
521
|
-
|
|
708
|
+
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
709
|
);
|
|
523
710
|
}
|
|
524
711
|
} else if (Date.parse(poll.deadline) > Date.now() && !opts.force) {
|
|
@@ -526,14 +713,26 @@ async function runReveal(opts) {
|
|
|
526
713
|
`poll deadline is ${poll.deadline} \u2014 pass --force to publish before deadline`
|
|
527
714
|
);
|
|
528
715
|
}
|
|
529
|
-
const existing = await
|
|
530
|
-
(await fetchRevealEvents(opts.pollId, relays)).events,
|
|
531
|
-
poll,
|
|
532
|
-
bip322Verify,
|
|
533
|
-
chain
|
|
534
|
-
);
|
|
716
|
+
const existing = await findReveal((await fetchRevealEvents(opts.pollId, relays)).events, poll, bip322Verify, chain);
|
|
535
717
|
if (existing && !opts.force) {
|
|
536
|
-
|
|
718
|
+
const readBack = () => relaysServingReveal(existing.reveal, poll, { relays, chain });
|
|
719
|
+
const serving2 = await castEvent(existing.event, await readBack(), readBack, { relays });
|
|
720
|
+
report({
|
|
721
|
+
id: ["reveal_id", revealId(existing.reveal)],
|
|
722
|
+
heading: [
|
|
723
|
+
["poll", poll.question],
|
|
724
|
+
["revealed", existing.reveal.revealed_at]
|
|
725
|
+
],
|
|
726
|
+
main: { kind: "reveal", event: existing.event, relays, serving: serving2 },
|
|
727
|
+
json: Boolean(opts.json),
|
|
728
|
+
jsonExtra: { reveal: existing.reveal, signed_earlier: true },
|
|
729
|
+
tail: [
|
|
730
|
+
" note: this reveal was signed earlier and found on the relays; it was sent as it is",
|
|
731
|
+
"",
|
|
732
|
+
` tally with: oc-vote tally ${opts.pollId}`
|
|
733
|
+
]
|
|
734
|
+
});
|
|
735
|
+
return;
|
|
537
736
|
}
|
|
538
737
|
const draft = {
|
|
539
738
|
v: 0,
|
|
@@ -551,42 +750,114 @@ async function runReveal(opts) {
|
|
|
551
750
|
const sigValue = await promptForSignature(opts.creator, rid, opts.sig);
|
|
552
751
|
draft.sig.value = sigValue;
|
|
553
752
|
const event = buildRevealEvent(draft);
|
|
554
|
-
const
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
753
|
+
const serving = await castEvent(event, [], () => relaysServingReveal(draft, poll, { relays, chain }), { relays });
|
|
754
|
+
report({
|
|
755
|
+
id: ["reveal_id", rid],
|
|
756
|
+
heading: [
|
|
757
|
+
["poll", poll.question],
|
|
758
|
+
["revealed", draft.revealed_at]
|
|
759
|
+
],
|
|
760
|
+
main: { kind: "reveal", event, relays, serving },
|
|
761
|
+
json: Boolean(opts.json),
|
|
762
|
+
jsonExtra: { reveal: draft },
|
|
763
|
+
tail: ["", ` tally with: oc-vote tally ${opts.pollId}`]
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// src/commands/republish.ts
|
|
768
|
+
async function failure(p) {
|
|
769
|
+
const v = await p.catch((e) => ({ ok: false, reason: e.message }));
|
|
770
|
+
return v.ok ? null : "reason" in v && v.reason || "it does not verify";
|
|
771
|
+
}
|
|
772
|
+
async function readSigned(text, relays = DEFAULT_RELAYS) {
|
|
773
|
+
let obj;
|
|
774
|
+
try {
|
|
775
|
+
obj = JSON.parse(text);
|
|
776
|
+
} catch {
|
|
777
|
+
throw new Error("the file is not JSON");
|
|
571
778
|
}
|
|
572
|
-
const
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
779
|
+
const event = obj && typeof obj === "object" && "event" in obj ? obj.event : obj;
|
|
780
|
+
if (!verifyEvent(event)) throw new Error("no signed Nostr event in the file: its id or signature doesn't hold");
|
|
781
|
+
if (event.kind === 30080) {
|
|
782
|
+
const poll = pollOf(event);
|
|
783
|
+
if (!poll) throw new Error("the event doesn't carry a poll");
|
|
784
|
+
const why = await failure(verifyPoll(poll, bip322Verify));
|
|
785
|
+
if (why) throw new Error(`the poll's signature doesn't verify: ${why}`);
|
|
786
|
+
return {
|
|
787
|
+
kind: "poll",
|
|
788
|
+
event,
|
|
789
|
+
id: ["poll_id", pollId(poll)],
|
|
790
|
+
heading: [["poll", poll.question]],
|
|
791
|
+
jsonExtra: { poll },
|
|
792
|
+
readBack: (r) => relaysServingPoll(poll, { relays: r })
|
|
793
|
+
};
|
|
585
794
|
}
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
795
|
+
if (event.kind === 30081) {
|
|
796
|
+
const ballot = ballotOf(event);
|
|
797
|
+
if (!ballot) throw new Error("the event doesn't carry a ballot");
|
|
798
|
+
const why = await failure(verifyBallot(ballot, bip322Verify));
|
|
799
|
+
if (why) throw new Error(`the ballot's signature doesn't verify: ${why}`);
|
|
800
|
+
return {
|
|
801
|
+
kind: "ballot",
|
|
802
|
+
event,
|
|
803
|
+
id: ["ballot_id", ballotId(ballot)],
|
|
804
|
+
heading: [
|
|
805
|
+
["poll", ballot.poll_id],
|
|
806
|
+
["voter", ballot.voter]
|
|
807
|
+
],
|
|
808
|
+
jsonExtra: { ballot },
|
|
809
|
+
readBack: (r) => relaysServingBallot(ballot, { relays: r })
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
if (event.kind === 30082) {
|
|
813
|
+
const reveal = revealOf(event);
|
|
814
|
+
if (!reveal) throw new Error("the event doesn't carry a reveal");
|
|
815
|
+
const poll = (await findPoll(await fetchPollEvents(reveal.poll_id, relays), reveal.poll_id, bip322Verify)).poll;
|
|
816
|
+
const chain = await revealChainFor(poll);
|
|
817
|
+
const why = await failure(verifyReveal(reveal, poll, bip322Verify, Date.now(), chain));
|
|
818
|
+
if (why) throw new Error(`the reveal doesn't verify against its poll: ${why}`);
|
|
819
|
+
return {
|
|
820
|
+
kind: "reveal",
|
|
821
|
+
event,
|
|
822
|
+
id: ["reveal_id", revealId(reveal)],
|
|
823
|
+
heading: [["poll", poll.question]],
|
|
824
|
+
jsonExtra: { reveal },
|
|
825
|
+
readBack: (r) => relaysServingReveal(reveal, poll, { relays: r, chain })
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
if (event.kind === 30083) {
|
|
829
|
+
const env = stampOf(event);
|
|
830
|
+
if (!env) throw new Error("the event doesn't carry an OC Stamp");
|
|
831
|
+
const why = await failure(
|
|
832
|
+
verify({
|
|
833
|
+
envelope: env,
|
|
834
|
+
verifyBip322: async (message, signature, address) => bip322Verify({ address, message, signature })
|
|
835
|
+
}).then((v) => v.ok ? { ok: true } : { ok: false, reason: v.message ?? v.code })
|
|
836
|
+
);
|
|
837
|
+
if (why) throw new Error(`the stamp's signature doesn't verify: ${why}`);
|
|
838
|
+
return {
|
|
839
|
+
kind: "stamp",
|
|
840
|
+
event,
|
|
841
|
+
id: ["stamp_id", env.id],
|
|
842
|
+
heading: [["over", env.content.hash]],
|
|
843
|
+
jsonExtra: { stamp: env },
|
|
844
|
+
readBack: (r) => relaysServingStamp(env, { relays: r })
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
throw new Error(`the event is kind ${event.kind}: not a poll, ballot, reveal or stamp (30080 to 30083)`);
|
|
848
|
+
}
|
|
849
|
+
async function runRepublish(opts) {
|
|
850
|
+
const relays = opts.relays ?? DEFAULT_RELAYS;
|
|
851
|
+
const signed = await readSigned(readFileSync(opts.file, "utf8"), relays);
|
|
852
|
+
const serving = await castEvent(signed.event, [], () => signed.readBack(relays), { relays });
|
|
853
|
+
report({
|
|
854
|
+
id: signed.id,
|
|
855
|
+
heading: signed.heading,
|
|
856
|
+
main: { kind: signed.kind, event: signed.event, relays, serving },
|
|
857
|
+
json: Boolean(opts.json),
|
|
858
|
+
jsonExtra: signed.jsonExtra,
|
|
859
|
+
file: opts.file
|
|
860
|
+
});
|
|
590
861
|
}
|
|
591
862
|
|
|
592
863
|
// src/commands/show.ts
|
|
@@ -641,15 +912,15 @@ async function runShow(opts) {
|
|
|
641
912
|
`);
|
|
642
913
|
}
|
|
643
914
|
function ballotCopiesFromEvents(events, pid) {
|
|
644
|
-
const
|
|
915
|
+
const parsed2 = [];
|
|
645
916
|
for (const ev of events) {
|
|
646
917
|
try {
|
|
647
918
|
const b = JSON.parse(ev.content);
|
|
648
|
-
if (b && b.poll_id === pid)
|
|
919
|
+
if (b && b.poll_id === pid) parsed2.push(b);
|
|
649
920
|
} catch {
|
|
650
921
|
}
|
|
651
922
|
}
|
|
652
|
-
return distinctBallotCopies(
|
|
923
|
+
return distinctBallotCopies(parsed2);
|
|
653
924
|
}
|
|
654
925
|
function ballotVerdicts(copies, verify = verifySignature) {
|
|
655
926
|
const byId = /* @__PURE__ */ new Map();
|
|
@@ -978,66 +1249,38 @@ async function runVote(opts) {
|
|
|
978
1249
|
const sigValue = await promptForSignature(opts.voter, bid, opts.sig);
|
|
979
1250
|
draft.sig.value = sigValue;
|
|
980
1251
|
const pub = buildBallotEvent(draft);
|
|
981
|
-
const
|
|
982
|
-
const
|
|
983
|
-
let
|
|
984
|
-
if (
|
|
1252
|
+
const relays = opts.relays ?? DEFAULT_RELAYS;
|
|
1253
|
+
const serving = await castBallot(pub, draft, [], { relays });
|
|
1254
|
+
let stamp2 = null;
|
|
1255
|
+
if (serving.length > 0 && isAnchoredPoll(poll)) {
|
|
985
1256
|
try {
|
|
986
1257
|
const env = await stampBallot(draft);
|
|
987
|
-
const
|
|
988
|
-
|
|
1258
|
+
const event = buildStampEvent(env);
|
|
1259
|
+
const on = await castEvent(event, [], () => relaysServingStamp(env, { relays }), { relays });
|
|
1260
|
+
stamp2 = { kind: "stamp", event, relays, serving: on };
|
|
989
1261
|
} catch (e) {
|
|
990
|
-
|
|
1262
|
+
stamp2 = { error: e.message };
|
|
991
1263
|
}
|
|
992
1264
|
}
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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
|
-
`);
|
|
1265
|
+
report({
|
|
1266
|
+
id: ["ballot_id", bid],
|
|
1267
|
+
heading: [
|
|
1268
|
+
["poll", poll.question],
|
|
1269
|
+
["voter", opts.voter],
|
|
1270
|
+
["option", `${opts.option}${poll.mode === "secret" ? " (sealed)" : ""}`]
|
|
1271
|
+
],
|
|
1272
|
+
main: { kind: "ballot", event: pub, relays, serving },
|
|
1273
|
+
stamp: stamp2,
|
|
1274
|
+
json: Boolean(opts.json),
|
|
1275
|
+
jsonExtra: { ballot: draft }
|
|
1276
|
+
});
|
|
1035
1277
|
}
|
|
1036
1278
|
|
|
1037
1279
|
// src/cli.ts
|
|
1038
1280
|
if (typeof globalThis.WebSocket === "undefined") {
|
|
1039
1281
|
globalThis.WebSocket = WebSocket;
|
|
1040
1282
|
}
|
|
1283
|
+
var { version } = createRequire(import.meta.url)("../package.json");
|
|
1041
1284
|
var program = new Command();
|
|
1042
1285
|
function collectRelay(v, prev = []) {
|
|
1043
1286
|
return [...prev, v];
|
|
@@ -1057,9 +1300,9 @@ function parseDeadline(raw) {
|
|
|
1057
1300
|
const d = new Date(Date.now() + ms);
|
|
1058
1301
|
return d.toISOString().replace(/\.\d+Z$/, "Z");
|
|
1059
1302
|
}
|
|
1060
|
-
const
|
|
1061
|
-
if (Number.isNaN(
|
|
1062
|
-
return
|
|
1303
|
+
const parsed2 = new Date(raw);
|
|
1304
|
+
if (Number.isNaN(parsed2.getTime())) throw new Error(`invalid deadline: ${raw}`);
|
|
1305
|
+
return parsed2.toISOString().replace(/\.\d+Z$/, "Z");
|
|
1063
1306
|
}
|
|
1064
1307
|
function parseSnapshot(raw) {
|
|
1065
1308
|
if (raw === "deadline") return "deadline";
|
|
@@ -1067,10 +1310,10 @@ function parseSnapshot(raw) {
|
|
|
1067
1310
|
if (!Number.isFinite(n) || n <= 0) throw new Error(`invalid snapshot block: ${raw}`);
|
|
1068
1311
|
return n;
|
|
1069
1312
|
}
|
|
1070
|
-
program.name("oc-vote").description("OC Vote \u2014 sovereign signaling for the open web").version(
|
|
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 (
|
|
1313
|
+
program.name("oc-vote").description("OC Vote \u2014 sovereign signaling for the open web").version(version);
|
|
1314
|
+
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
1315
|
await runTally({
|
|
1073
|
-
pollId:
|
|
1316
|
+
pollId: pollId5,
|
|
1074
1317
|
relays: opts.relay?.length ? opts.relay : void 0,
|
|
1075
1318
|
mempoolBase: opts.mempoolBase,
|
|
1076
1319
|
json: Boolean(opts.json),
|
|
@@ -1078,16 +1321,16 @@ program.command("tally <poll_id>").description("Fetch the poll + ballots and run
|
|
|
1078
1321
|
snapshotBlock: opts.snapshot ? Number.parseInt(String(opts.snapshot), 10) : void 0
|
|
1079
1322
|
});
|
|
1080
1323
|
});
|
|
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 (
|
|
1324
|
+
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
1325
|
await runVerify({
|
|
1083
|
-
pollId:
|
|
1326
|
+
pollId: pollId5,
|
|
1084
1327
|
relays: opts.relay?.length ? opts.relay : void 0,
|
|
1085
1328
|
json: Boolean(opts.json)
|
|
1086
1329
|
});
|
|
1087
1330
|
});
|
|
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 (
|
|
1331
|
+
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
1332
|
await runShow({
|
|
1090
|
-
pollId:
|
|
1333
|
+
pollId: pollId5,
|
|
1091
1334
|
relays: opts.relay?.length ? opts.relay : void 0,
|
|
1092
1335
|
json: Boolean(opts.json)
|
|
1093
1336
|
});
|
|
@@ -1100,7 +1343,9 @@ program.command("discover").description("List recent polls across the default re
|
|
|
1100
1343
|
openOnly: Boolean(opts.openOnly)
|
|
1101
1344
|
});
|
|
1102
1345
|
});
|
|
1103
|
-
program.command("create").description(
|
|
1346
|
+
program.command("create").description(
|
|
1347
|
+
"Build + sign + publish a poll. It is published once 2 relays, each asked alone, serve it back; below that it exits 2 (see republish)"
|
|
1348
|
+
).requiredOption("--creator <btc-addr>", "Bitcoin address of the poll creator").requiredOption("--question <text>", "poll question").requiredOption(
|
|
1104
1349
|
"--option <id:label>",
|
|
1105
1350
|
'poll option; repeatable; "id" or "id:label"',
|
|
1106
1351
|
collectOption,
|
|
@@ -1133,7 +1378,9 @@ program.command("create").description("Build + sign + publish a poll").requiredO
|
|
|
1133
1378
|
json: Boolean(opts.json)
|
|
1134
1379
|
});
|
|
1135
1380
|
});
|
|
1136
|
-
program.command("vote").description(
|
|
1381
|
+
program.command("vote").description(
|
|
1382
|
+
"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)"
|
|
1383
|
+
).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
1384
|
await runVote({
|
|
1138
1385
|
pollId: String(opts.poll),
|
|
1139
1386
|
voter: String(opts.voter),
|
|
@@ -1144,7 +1391,18 @@ program.command("vote").description("Cast a ballot on a poll").requiredOption("-
|
|
|
1144
1391
|
json: Boolean(opts.json)
|
|
1145
1392
|
});
|
|
1146
1393
|
});
|
|
1147
|
-
program.command("
|
|
1394
|
+
program.command("republish <file>").description(
|
|
1395
|
+
"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"
|
|
1396
|
+
).option("--relay <url>", "Nostr relay (repeatable)", collectRelay, []).option("--json", "Emit JSON").action(async (file, opts) => {
|
|
1397
|
+
await runRepublish({
|
|
1398
|
+
file,
|
|
1399
|
+
relays: opts.relay?.length ? opts.relay : void 0,
|
|
1400
|
+
json: Boolean(opts.json)
|
|
1401
|
+
});
|
|
1402
|
+
});
|
|
1403
|
+
program.command("reveal").description(
|
|
1404
|
+
"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"
|
|
1405
|
+
).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
1406
|
await runReveal({
|
|
1149
1407
|
pollId: String(opts.poll),
|
|
1150
1408
|
revealSk: String(opts.revealSk),
|