@bitsocial/pubsub-voting 0.1.0 → 0.1.2

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 CHANGED
@@ -81,7 +81,7 @@ Construction throws `MissingPubsubError`, `MissingBlockstoreError`, or `MissingF
81
81
  ```ts
82
82
  const contest = await voter.createContest({ criteria }); // criteria: the contest's full document (strictly validated here)
83
83
  contest.on("update", () => render(contest.tally)); // tally rides the object; recomputed before each emit
84
- contest.on("error", (err) => showConnectivityWarning(err)); // tally chain read failed, or the background verifier's RPC/resolver is down (retrying)
84
+ contest.on("error", (err) => showConnectivityWarning(err)); // tally chain read failed, the background verifier's RPC/resolver is down (retrying), or a deferred check evicted THIS wallet's own vote (VoteEvictedError)
85
85
  await contest.update(); // join the topic, cold-start, begin emitting
86
86
  // const fresh = await contest.getTally(); // or force a fresh read, bypassing the cache
87
87
  // await contest.stop(); // leave the topic
@@ -128,6 +128,20 @@ A community's identity is its `publicKey`. The optional `name` is the community'
128
128
 
129
129
  `publish()` on a voter built without a `signer` throws `ReadOnlyError` (and emits an `error`).
130
130
 
131
+ #### Rejection feedback
132
+
133
+ Gossipsub gives a publisher **no acceptance or rejection feedback** — a peer that drops a bundle does so silently. Since every honest peer runs the same checks this node runs, the library turns its own local verdict into the feedback the protocol can't provide, in two places:
134
+
135
+ - **At `publish()`**: each vote's `community.name` is preflighted through the shared resolution cache first — a name that definitively fails (no resolver for its TLD, no record, or it resolves to a **different** `publicKey` than the vote claims) throws `InvalidCommunityNameError` before signing or joining the topic, since every verifier would silently drop that bundle anyway. A resolver that merely *throws* (registry outage) never blocks the publish — the check stays deferred to the background verifier.
136
+ - **After `publish()` resolved**: `"succeeded"` means signed and broadcast, **not** accepted by the network. The deferred checks (the on-chain gate read, and any name resolution a preflight outage skipped) run in the background; if one evicts the bundle, the vote emits a `VoteEvictedError` on its `error` event — carrying the evicted `bundle` and the exact `verdict` any verifier would produce — and its `publishingState` flips to `"failed"` post hoc. The same error fires on the contest's `error` event, for long-lived views.
137
+
138
+ ```ts
139
+ vote.on("error", (err) => {
140
+ if (err instanceof VoteEvictedError) console.log(err.verdict.reason); // e.g. "not admitted: rule score is 0n at block …"
141
+ });
142
+ await vote.publish(); // throws InvalidCommunityNameError if a carried name can't back the vote
143
+ ```
144
+
131
145
  ### Republishing is the client's job
132
146
 
133
147
  A vote is not permanent: a bundle is valid only for `voteExpiryBuckets` after its `blockNumber`, so a live vote must be re-published before it decays. **This library does not do that automatically** — it publishes each vote once and the consuming client decides when (or whether) to refresh. To refresh, just `createContestVote(...).publish()` again; a new bundle at the current bucket supersedes the old one. To stop, simply stop refreshing and let the vote lapse. The library gives you what you need to schedule it — all pure, no chain reads:
@@ -41,7 +41,14 @@ export declare function republishIntervalBuckets(criteria: Criteria): number;
41
41
  * publishes each vote once and the client decides when to refresh (see
42
42
  * {@link republishIntervalBuckets} and DESIGN.md "Republishing is the client's job").
43
43
  */
44
- /** A vote publication's lifecycle, walked by {@link ContestVote.publish}. */
44
+ /**
45
+ * A vote publication's lifecycle, walked by {@link ContestVote.publish}. `"succeeded"` means
46
+ * signed, admitted locally, and broadcast — NOT accepted by the network (gossipsub gives a
47
+ * publisher no acceptance/rejection feedback). It can therefore still flip to `"failed"`
48
+ * afterwards: if this node's own deferred checks — the same checks every peer runs — evict the
49
+ * bundle, the vote emits a `VoteEvictedError` and fails post hoc (see DESIGN.md "Background
50
+ * chain verification", publisher feedback).
51
+ */
45
52
  export type PublishingState = "stopped" | "signing" | "publishing" | "succeeded" | "failed";
46
53
  /** What {@link ContestVote.publish} resolves: the signed bundle plus a peer-reach hint. */
47
54
  export interface PublishOutcome {
@@ -86,9 +93,11 @@ export interface Contest {
86
93
  */
87
94
  on(event: "update", cb: () => void): void;
88
95
  /**
89
- * Fired on a contest-level failure: the tally's chain read throws, or the background chain
96
+ * Fired on a contest-level failure: the tally's chain read throws, the background chain
90
97
  * verifier hits an infra-class failure (RPC/resolver down — its bundles stay pending and
91
- * retry, but the degradation is surfaced here instead of silently stalling).
98
+ * retry, but the degradation is surfaced here instead of silently stalling), or a deferred
99
+ * check evicts THIS wallet's own published vote (`VoteEvictedError` — the same error the
100
+ * publishing `ContestVote` emits; here so a long-lived view hears it too).
92
101
  */
93
102
  on(event: "error", cb: (error: unknown) => void): void;
94
103
  }
@@ -110,13 +119,23 @@ export interface ContestVote {
110
119
  * the `VotesBundle` (whose `blockNumber` the client uses to schedule its own refresh — see
111
120
  * {@link republishIntervalBuckets}) plus `recipientCount`, the number of peers gossipsub sent
112
121
  * the vote directly to. Emits `publishingstatechange` as it goes; throws (and emits `error`) on
113
- * failure, and `ReadOnlyError` with no signer. This library does not re-publish: to keep the
114
- * vote alive, call `publish()` again before it expires.
122
+ * failure: `ReadOnlyError` with no signer, and `InvalidCommunityNameError` when a vote's
123
+ * carried `community.name` definitively does not resolve to its claimed `publicKey` (checked
124
+ * BEFORE signing or joining — every verifier drops such a bundle silently, so it is refused
125
+ * here instead of published into a network-wide silent drop; a resolver outage does not block
126
+ * the publish). This library does not re-publish: to keep the vote alive, call `publish()`
127
+ * again before it expires.
115
128
  */
116
129
  publish(): Promise<PublishOutcome>;
117
130
  /** Fired on each `publishingState` transition. */
118
131
  on(event: "publishingstatechange", cb: (state: PublishingState) => void): void;
119
- /** Fired if publishing fails. */
132
+ /**
133
+ * Fired if publishing fails — including POST HOC, after `publish()` resolved: gossipsub
134
+ * peers reject a bad bundle silently, but this node runs the same deferred checks (gate
135
+ * chain read, name resolution) on its own publish, and if they evict it a `VoteEvictedError`
136
+ * fires here (and `publishingState` flips to `"failed"`) carrying the exact verdict every
137
+ * honest verifier would produce. See DESIGN.md "Background chain verification".
138
+ */
120
139
  on(event: "error", cb: (error: unknown) => void): void;
121
140
  }
122
141
  /** The factory: one set of injected dependencies, many contests. */
@@ -23,6 +23,7 @@ import { makeAnnouncer } from "../transport/announce/node.js";
23
23
  import { encode as encodeDagCbor } from "@ipld/dag-cbor";
24
24
  import { sha256 } from "viem";
25
25
  import { makeBackgroundVerifier } from "../verify/background.js";
26
+ import { preflightCommunityNames } from "../verify/name-preflight.js";
26
27
  import { makeAcceptedDedup } from "../transport/accepted-dedup.js";
27
28
  import { blockForBytes, decodeCheckpoint, encodeCheckpoint } from "../checkpoint/codec.js";
28
29
  import { decodeSnapshot, encodeSnapshot } from "../checkpoint/snapshot.js";
@@ -30,7 +31,7 @@ import { CID } from "multiformats/cid";
30
31
  import { makeTally } from "../tally/tally.js";
31
32
  import { ballotTypedData } from "../signer/eip712.js";
32
33
  import { criteriaCid, TOPIC_PREFIX } from "../topic.js";
33
- import { MissingChainClientError, ReadOnlyError, UnknownRuleError, VoterDestroyedError } from "../errors.js";
34
+ import { InvalidCommunityNameError, MissingChainClientError, ReadOnlyError, UnknownRuleError, VoteEvictedError, VoterDestroyedError } from "../errors.js";
34
35
  /**
35
36
  * The recommended cadence, in buckets, at which a client should re-publish a live vote to keep
36
37
  * it alive: half its expiry window, rounded up. A bundle is valid for `voteExpiryBuckets` after
@@ -348,7 +349,7 @@ class ContestEngine {
348
349
  cache: this.#cache,
349
350
  onGateVerified: (cid) => this.#settleCheck(cid, "chainVerified"),
350
351
  onNameResolved: (cid) => this.#settleCheck(cid, "nameResolved"),
351
- onEvict: (cid) => this.#evictBundle(cid),
352
+ onEvict: (cid, verdict) => this.#evictBundle(cid, verdict),
352
353
  onError: (error) => this.#emitError(error),
353
354
  limit: (fn) => this.#backgroundLimit(fn)
354
355
  });
@@ -382,13 +383,26 @@ class ContestEngine {
382
383
  * settled bundles (never re-serve what we have not verified).
383
384
  */
384
385
  #checks = new Map();
386
+ /**
387
+ * This wallet's own published bundles still awaiting their deferred checks, keyed by CID
388
+ * string — the bundles whose background EVICTION must be reported instead of silent (see
389
+ * {@link #evictBundle} / `VoteEvictedError`; remote evictions are normal operation). An
390
+ * entry is dropped once its checks settle, on evict (after reporting), and on expiry prune.
391
+ */
392
+ #ownPublishes = new Map();
393
+ /** Per-own-CID eviction callbacks: the publishing `ContestVote` registers one at sign time. */
394
+ #ownEvictionCbs = new Map();
385
395
  /** Does any vote in the bundle carry a `community.name` claim (needing resolution)? */
386
396
  #carriesName(bundle) {
387
397
  return bundle.votes.some((v) => v.community.name !== undefined);
388
398
  }
389
- /** Record a bundle's deferred-check state at admit: fully settled, or pending both checks. */
390
- #recordChecks(cid, bundle, settled) {
391
- this.#checks.set(cid.toString(), this.#carriesName(bundle) ? { chainVerified: settled, nameResolved: settled } : { chainVerified: settled });
399
+ /**
400
+ * Record a bundle's deferred-check state at admit: fully settled, pending both checks, or —
401
+ * for an own publish whose name preflight already resolved every carried name — pending the
402
+ * gate read only (`nameSettled`).
403
+ */
404
+ #recordChecks(cid, bundle, settled, nameSettled = settled) {
405
+ this.#checks.set(cid.toString(), this.#carriesName(bundle) ? { chainVerified: settled, nameResolved: nameSettled } : { chainVerified: settled });
392
406
  }
393
407
  /** The bundle's check state, pessimistic (all pending) if somehow unrecorded. */
394
408
  #checksFor(cid, bundle) {
@@ -411,14 +425,38 @@ class ContestEngine {
411
425
  if (!checks)
412
426
  return; // evicted or pruned while its check was in flight
413
427
  checks[key] = true;
428
+ // A fully settled own publish can no longer be evicted — its verdict is terminal — so
429
+ // its eviction-reporting entries are done (see #ownPublishes).
430
+ if (this.#isFullyVerified(cid))
431
+ this.#dropOwnTracking(cid.toString());
414
432
  this.#onStateChanged();
415
433
  }
416
- /** A deferred check failed: drop the bundle (its verified predecessor, if any, wins again). */
417
- #evictBundle(cid) {
434
+ /**
435
+ * A deferred check failed: drop the bundle (its verified predecessor, if any, wins again).
436
+ * Evicting this wallet's OWN publish is the one rejection a publisher can ever hear about —
437
+ * gossipsub peers drop a bad bundle silently, but this node runs the same checks (see
438
+ * DESIGN.md "Background chain verification") — so it is reported as a `VoteEvictedError`
439
+ * through the publishing `ContestVote` and the contest `error` event instead of silent.
440
+ */
441
+ #evictBundle(cid, verdict) {
418
442
  this.#crdt.remove(cid);
419
- this.#checks.delete(cid.toString());
443
+ const key = cid.toString();
444
+ this.#checks.delete(key);
445
+ const own = this.#ownPublishes.get(key);
446
+ if (own) {
447
+ const error = new VoteEvictedError(own, verdict);
448
+ const notifyVote = this.#ownEvictionCbs.get(key);
449
+ this.#dropOwnTracking(key);
450
+ notifyVote?.(error);
451
+ this.#emitError(error);
452
+ }
420
453
  this.#onStateChanged();
421
454
  }
455
+ /** Forget an own publish's eviction-reporting entries (settled, evicted, or expired). */
456
+ #dropOwnTracking(key) {
457
+ this.#ownPublishes.delete(key);
458
+ this.#ownEvictionCbs.delete(key);
459
+ }
422
460
  #emitError(error) {
423
461
  for (const cb of [...this.#errorListeners])
424
462
  cb(error);
@@ -527,7 +565,9 @@ class ContestEngine {
527
565
  if (this.#crdt.nodeCount() > 0) {
528
566
  await this.#refreshBucket();
529
567
  for (const removed of await this.#crdt.prune(this.#currentBucketCache)) {
530
- this.#checks.delete(removed.toString());
568
+ const key = removed.toString();
569
+ this.#checks.delete(key);
570
+ this.#dropOwnTracking(key); // expiry is decay, not an eviction — no error
531
571
  }
532
572
  }
533
573
  return this.#tally.compute();
@@ -897,12 +937,33 @@ class ContestEngine {
897
937
  if (wasJoined)
898
938
  this.#deps.onTopicLeft();
899
939
  }
940
+ /**
941
+ * Publish-time community-name preflight (see verify/name-preflight.ts): throws
942
+ * `InvalidCommunityNameError` when a carried name definitively fails to resolve to its
943
+ * vote's claimed key — before signing, before the caller joins the topic — because every
944
+ * verifier would silently drop such a bundle. Returns whether every carried name settled
945
+ * (false = a resolver outage skipped one; the background verifier still owns that check).
946
+ */
947
+ async preflightNames(votes) {
948
+ const result = await preflightCommunityNames({
949
+ votes,
950
+ nameResolvers: this.#deps.nameResolvers,
951
+ cache: this.#deps.nameResolutionCache
952
+ });
953
+ if (!result.ok) {
954
+ throw new InvalidCommunityNameError(result.communityName, result.claimedPublicKey, result.resolvedPublicKey, result.reason);
955
+ }
956
+ return result.settled;
957
+ }
900
958
  /**
901
959
  * Sign the votes into a bundle for the current bucket boundary block (the block every verifier
902
960
  * reads at), add it to the CRDT, and return the bundle plus its encoded block bytes for
903
- * broadcast. Throws `ReadOnlyError` with no signer.
961
+ * broadcast. Throws `ReadOnlyError` with no signer. `namesSettled` carries the
962
+ * {@link preflightNames} outcome (default false: name checks still owed to the background
963
+ * verifier); `onEvicted` is told if a deferred check later evicts THIS bundle — registered
964
+ * here, before the background verifier can possibly settle, so the report cannot be missed.
904
965
  */
905
- async signVote(votes) {
966
+ async signVote(votes, opts = {}) {
906
967
  const signer = this.#deps.signer;
907
968
  if (signer === undefined)
908
969
  throw new ReadOnlyError();
@@ -918,8 +979,12 @@ class ContestEngine {
918
979
  // Own bundles take the same deferred path as a chased checkpoint's: admitted
919
980
  // provisionally, then confirmed (or evicted) by the background gate read — so an
920
981
  // ineligible wallet's local tally does not silently disagree with the network's, and
921
- // our checkpoint never serves a vote we have not verified (even our own).
922
- this.#recordChecks(cid, bundle, false);
982
+ // our checkpoint never serves a vote we have not verified (even our own). A name the
983
+ // preflight already resolved is recorded settled, so it renders verified immediately.
984
+ this.#recordChecks(cid, bundle, false, opts.namesSettled ?? false);
985
+ this.#ownPublishes.set(cid.toString(), bundle);
986
+ if (opts.onEvicted)
987
+ this.#ownEvictionCbs.set(cid.toString(), opts.onEvicted);
923
988
  this.#background.enqueue([{ cid, bundle }]);
924
989
  this.#onStateChanged();
925
990
  return { bundle, encoded: encodeBundle(bundle) };
@@ -1266,6 +1331,13 @@ class ContestVotePublication {
1266
1331
  #errorCbs = [];
1267
1332
  #state = "stopped";
1268
1333
  #bundle;
1334
+ /**
1335
+ * True once the background verifier evicted the current publish's bundle. The eviction can
1336
+ * land WHILE `publish()` is still broadcasting (the deferred checks run concurrently), and
1337
+ * its `"failed"` is terminal for this attempt — the in-flight publish must not stomp it
1338
+ * with `"publishing"`/`"succeeded"`. Reset by the next `publish()` call.
1339
+ */
1340
+ #evicted = false;
1269
1341
  constructor(engine, votes) {
1270
1342
  this.#engine = engine;
1271
1343
  this.contestId = engine.criteria.contestId;
@@ -1298,13 +1370,33 @@ class ContestVotePublication {
1298
1370
  throw error;
1299
1371
  }
1300
1372
  try {
1373
+ // Name preflight, also before joining: a vote whose carried community name
1374
+ // definitively fails to resolve to its claimed key would be silently dropped by
1375
+ // every verifier, so it is refused here (`InvalidCommunityNameError`) instead of
1376
+ // broadcast. A resolver outage does not block the publish (namesSettled: false —
1377
+ // the background verifier owns the deferred check).
1378
+ const namesSettled = await this.#engine.preflightNames(this.votes);
1301
1379
  await this.#engine.join();
1380
+ this.#evicted = false;
1302
1381
  this.#setState("signing");
1303
- const { bundle, encoded } = await this.#engine.signVote([...this.votes]);
1382
+ const { bundle, encoded } = await this.#engine.signVote([...this.votes], {
1383
+ namesSettled,
1384
+ // The one rejection a publisher can hear about (peers drop silently): our own
1385
+ // node's deferred checks evicting this bundle. Usually post hoc — publish() has
1386
+ // already resolved — so it surfaces as `error` + `publishingState: "failed"`.
1387
+ onEvicted: (error) => {
1388
+ this.#evicted = true;
1389
+ this.#fail(error);
1390
+ }
1391
+ });
1304
1392
  this.#bundle = bundle;
1305
- this.#setState("publishing");
1393
+ if (!this.#evicted)
1394
+ this.#setState("publishing");
1306
1395
  const { recipientCount } = await this.#engine.broadcastBundle(encoded);
1307
- this.#setState("succeeded");
1396
+ // An eviction that landed mid-broadcast already failed this attempt; the outcome
1397
+ // still resolves (the bundle DID hit the wire) but the state stays "failed".
1398
+ if (!this.#evicted)
1399
+ this.#setState("succeeded");
1308
1400
  return { bundle, recipientCount };
1309
1401
  }
1310
1402
  catch (error) {
package/dist/errors.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { VotesBundle } from "./schema/votes.js";
2
+ import type { VerifyFail } from "./verify/types.js";
1
3
  /**
2
4
  * Library error types.
3
5
  *
@@ -91,3 +93,51 @@ export declare class DuplicateContestIdError extends Error {
91
93
  export declare class ReadOnlyError extends Error {
92
94
  constructor();
93
95
  }
96
+ /**
97
+ * Thrown by `ContestVote.publish()` when a vote carries a `community.name` that definitively
98
+ * fails the publish-time preflight (see verify/name-preflight.ts): no configured resolver
99
+ * handles it, it resolves to no record, or it resolves to a DIFFERENT key than the vote
100
+ * claims. Every honest verifier runs the same check and drops such a bundle without telling
101
+ * the publisher (gossipsub has no rejection feedback), so the vote is refused here — before
102
+ * signing, before joining the topic — instead of being published into a silent network-wide
103
+ * drop. Fix the name (or the claimed `publicKey`) and publish again. A resolver that merely
104
+ * THREW (registry outage) does not throw this: the vote publishes and the background verifier
105
+ * settles the check, surfacing a `VoteEvictedError` if it turns out bad.
106
+ */
107
+ export declare class InvalidCommunityNameError extends Error {
108
+ /** The offending `community.name`. */
109
+ readonly communityName: string;
110
+ /** The `community.publicKey` the vote claims the name points at. */
111
+ readonly claimedPublicKey: string;
112
+ /** What the registry resolved the name to; `undefined` for no-resolver / no-record. */
113
+ readonly resolvedPublicKey: string | undefined;
114
+ constructor(
115
+ /** The offending `community.name`. */
116
+ communityName: string,
117
+ /** The `community.publicKey` the vote claims the name points at. */
118
+ claimedPublicKey: string,
119
+ /** What the registry resolved the name to; `undefined` for no-resolver / no-record. */
120
+ resolvedPublicKey: string | undefined, reason: string);
121
+ }
122
+ /**
123
+ * Emitted (never thrown) when a deferred network check EVICTS this wallet's own published
124
+ * vote: the background verifier read the gate rule as `0n` at the vote's sample block, or its
125
+ * carried community name did not check out (see DESIGN.md "Background chain verification").
126
+ * `publish()` resolves on the offline checks, so an own vote that fails a deferred check
127
+ * would otherwise just silently vanish from the local tally — while every honest peer,
128
+ * running the same checks, drops it with no feedback (gossipsub has no rejection channel).
129
+ * This error is that missing feedback, built from the local verdict: it fires on the
130
+ * publishing `ContestVote`'s `error` event (flipping its `publishingState` to `"failed"`)
131
+ * and on the contest's `error` event. Carries the evicted bundle and the exact verdict.
132
+ */
133
+ export declare class VoteEvictedError extends Error {
134
+ /** The signed bundle that was evicted (the one `publish()` resolved with). */
135
+ readonly bundle: VotesBundle;
136
+ /** The failing verdict, with the same `reason` wording every verifier produces. */
137
+ readonly verdict: VerifyFail;
138
+ constructor(
139
+ /** The signed bundle that was evicted (the one `publish()` resolved with). */
140
+ bundle: VotesBundle,
141
+ /** The failing verdict, with the same `reason` wording every verifier produces. */
142
+ verdict: VerifyFail);
143
+ }
package/dist/errors.js CHANGED
@@ -144,3 +144,64 @@ export class ReadOnlyError extends Error {
144
144
  this.name = "ReadOnlyError";
145
145
  }
146
146
  }
147
+ /**
148
+ * Thrown by `ContestVote.publish()` when a vote carries a `community.name` that definitively
149
+ * fails the publish-time preflight (see verify/name-preflight.ts): no configured resolver
150
+ * handles it, it resolves to no record, or it resolves to a DIFFERENT key than the vote
151
+ * claims. Every honest verifier runs the same check and drops such a bundle without telling
152
+ * the publisher (gossipsub has no rejection feedback), so the vote is refused here — before
153
+ * signing, before joining the topic — instead of being published into a silent network-wide
154
+ * drop. Fix the name (or the claimed `publicKey`) and publish again. A resolver that merely
155
+ * THREW (registry outage) does not throw this: the vote publishes and the background verifier
156
+ * settles the check, surfacing a `VoteEvictedError` if it turns out bad.
157
+ */
158
+ export class InvalidCommunityNameError extends Error {
159
+ communityName;
160
+ claimedPublicKey;
161
+ resolvedPublicKey;
162
+ constructor(
163
+ /** The offending `community.name`. */
164
+ communityName,
165
+ /** The `community.publicKey` the vote claims the name points at. */
166
+ claimedPublicKey,
167
+ /** What the registry resolved the name to; `undefined` for no-resolver / no-record. */
168
+ resolvedPublicKey, reason) {
169
+ super(`Cannot publish this vote: ${reason}. Every verifier checks a carried community ` +
170
+ `name against its registry and silently drops a bundle whose name does not ` +
171
+ `resolve to the claimed publicKey, so this vote would never be counted. Fix ` +
172
+ `the name (or the claimed publicKey), or drop the name from the vote, and ` +
173
+ `publish again.`);
174
+ this.communityName = communityName;
175
+ this.claimedPublicKey = claimedPublicKey;
176
+ this.resolvedPublicKey = resolvedPublicKey;
177
+ this.name = "InvalidCommunityNameError";
178
+ }
179
+ }
180
+ /**
181
+ * Emitted (never thrown) when a deferred network check EVICTS this wallet's own published
182
+ * vote: the background verifier read the gate rule as `0n` at the vote's sample block, or its
183
+ * carried community name did not check out (see DESIGN.md "Background chain verification").
184
+ * `publish()` resolves on the offline checks, so an own vote that fails a deferred check
185
+ * would otherwise just silently vanish from the local tally — while every honest peer,
186
+ * running the same checks, drops it with no feedback (gossipsub has no rejection channel).
187
+ * This error is that missing feedback, built from the local verdict: it fires on the
188
+ * publishing `ContestVote`'s `error` event (flipping its `publishingState` to `"failed"`)
189
+ * and on the contest's `error` event. Carries the evicted bundle and the exact verdict.
190
+ */
191
+ export class VoteEvictedError extends Error {
192
+ bundle;
193
+ verdict;
194
+ constructor(
195
+ /** The signed bundle that was evicted (the one `publish()` resolved with). */
196
+ bundle,
197
+ /** The failing verdict, with the same `reason` wording every verifier produces. */
198
+ verdict) {
199
+ super(`This wallet's published vote failed a deferred verification check and was ` +
200
+ `evicted from the local tally: ${verdict.reason}. Honest peers run the same ` +
201
+ `checks, so the network will not count this vote either. Fix the cause and ` +
202
+ `publish again.`);
203
+ this.bundle = bundle;
204
+ this.verdict = verdict;
205
+ this.name = "VoteEvictedError";
206
+ }
207
+ }
@@ -6,7 +6,7 @@ import type { VoteCrdt } from "../../crdt/types.js";
6
6
  import { type VerdictCache } from "../../verify/cache.js";
7
7
  import { type RootChaser } from "../chase.js";
8
8
  import type { PubsubService, VoteTransport } from "../types.js";
9
- import { type RootRecord } from "../messages.js";
9
+ import { type FetchRootRecord, type RootRecord } from "../messages.js";
10
10
  /** The gossipsub peer-score methods used for assertions — on the concrete class, not the interface. */
11
11
  interface ScoreOps {
12
12
  getScore(peer: string): number;
@@ -41,7 +41,13 @@ export interface VoteNode {
41
41
  /** Replace the injected verifier (e.g. a rejecter, or a slow one, for a specific assertion). */
42
42
  setVerifier(verify: (bundle: VotesBundle) => Promise<BundleVerdict>): void;
43
43
  /** Encode this node's current winner-set to a checkpoint (blocks written to its blockstore). */
44
- checkpointRootRecord(): Promise<RootRecord>;
44
+ checkpointRootRecord(): Promise<FetchRootRecord>;
45
+ /**
46
+ * Pull `from`'s root record over the REAL libp2p fetch protocol — the cold-start pull a
47
+ * joiner makes before chasing (voter.ts `#fetchRootWithRetry`). `undefined` when the peer
48
+ * serves nothing for the key.
49
+ */
50
+ fetchRootRecord(from: VoteNode): Promise<FetchRootRecord | undefined>;
45
51
  /** Publish this node's own root record on the topic (a heartbeat). */
46
52
  publishOwnRoot(): Promise<void>;
47
53
  /** Seed a bundle straight into this node's state (store block + CRDT merge), no network. */
@@ -17,7 +17,7 @@ import { encodeCheckpoint } from "../../checkpoint/codec.js";
17
17
  import { makeGossipGate } from "../gossip-validator.js";
18
18
  import { makeRootChaser, toChaseSession } from "../chase.js";
19
19
  import { makeVoteTransport } from "../transport.js";
20
- import { decodeVoteMessage, maxBundleMessageBytes, MAX_ROOT_MESSAGE_BYTES, ROOT_RECORD_VERSION } from "../messages.js";
20
+ import { decodeVoteMessage, decodeRootRecord, encodeRootRecord, maxBundleMessageBytes, rootFetchKey, MAX_ROOT_MESSAGE_BYTES, ROOT_RECORD_VERSION } from "../messages.js";
21
21
  /**
22
22
  * Test harness for the two-node gossipsub integration test. It stands up ONE real libp2p +
23
23
  * Helia node carrying `@libp2p/gossipsub` (>= 15.0.23, the CVE-2026-46679 floor) and
@@ -117,16 +117,30 @@ export async function makeVoteNode(topic, options = {}) {
117
117
  let matchedOwnRoot = false;
118
118
  async function checkpointRootRecord() {
119
119
  const winners = crdt.current(CURRENT_BUCKET);
120
- const { root, blocks } = await encodeCheckpoint(winners);
120
+ const { root, chunks, blocks } = await encodeCheckpoint(winners);
121
121
  for (const block of blocks)
122
122
  await blockstore.put(block.cid, block.bytes);
123
123
  return {
124
124
  version: ROOT_RECORD_VERSION,
125
125
  root,
126
+ chunks,
126
127
  count: winners.length,
127
128
  sizeBytes: blocks.reduce((total, block) => total + block.bytes.length, 0)
128
129
  };
129
130
  }
131
+ // Mirror the production fetch responder (voter.ts `#rootLookup`): answer `<topic>/root` with
132
+ // this node's current root record, encoded on demand — the surface a cold joiner pulls
133
+ // before chasing. `@libp2p/fetch` hands the lookup the requested key as raw bytes.
134
+ libp2p.services.fetch.registerLookupFunction(topic, async (keyBytes) => {
135
+ if (new TextDecoder().decode(keyBytes) !== rootFetchKey(topic))
136
+ return undefined;
137
+ try {
138
+ return encodeRootRecord(await checkpointRootRecord());
139
+ }
140
+ catch {
141
+ return undefined;
142
+ }
143
+ });
130
144
  const openedSessions = [];
131
145
  const chaseLimit = pLimit(2);
132
146
  const chaser = makeRootChaser({
@@ -228,6 +242,10 @@ export async function makeVoteNode(topic, options = {}) {
228
242
  verifyImpl = verify;
229
243
  },
230
244
  checkpointRootRecord,
245
+ fetchRootRecord: async (from) => {
246
+ const bytes = await libp2p.services.fetch.fetch(from.libp2p.peerId, rootFetchKey(topic));
247
+ return bytes == null ? undefined : decodeRootRecord(bytes);
248
+ },
231
249
  publishOwnRoot: async () => {
232
250
  await transport.publishRootRecord(await checkpointRootRecord());
233
251
  },
@@ -0,0 +1,60 @@
1
+ import type { Vote } from "../schema/votes.js";
2
+ import type { NameResolver } from "../chain/types.js";
3
+ import { type NameResolutionCache } from "./name-resolution-cache.js";
4
+ /**
5
+ * Publish-time community-name preflight — the publisher-side twin of the verify pipeline's
6
+ * step 4 (see bundle.ts). Gossipsub has no rejection-feedback channel: a peer that drops a
7
+ * bundle never tells the publisher why (or that it dropped it at all), so the only "clear
8
+ * error" a publisher can ever get is from running the same checks locally BEFORE the vote
9
+ * hits the wire. A vote naming a community whose name does not check out is guaranteed to be
10
+ * dropped by every honest verifier, so publishing it is pure waste; failing fast here turns
11
+ * that silent network-wide drop into an immediate, explainable publish error.
12
+ *
13
+ * The failure split mirrors the pipeline's `reject`/`ignore` philosophy, adapted to the
14
+ * publisher (who, unlike a relayer, can always just fix the vote and retry):
15
+ * - definitive from this node's view — no resolver handles the TLD, the name has no record,
16
+ * or it resolves to a DIFFERENT key than the vote claims — fails the preflight; every
17
+ * verifier sharing this node's view would drop the bundle the same way.
18
+ * - transient — the resolver THREW (registry RPC down) — passes the preflight with
19
+ * `settled: false`: a resolver outage must not block voting (the same reason the
20
+ * background verifier retries instead of evicting on a throw), and the deferred check
21
+ * settles or evicts the bundle once the resolver recovers.
22
+ *
23
+ * Resolutions ride the shared {@link NameResolutionCache} (the pkc-js rule, 1-hour max-age),
24
+ * so a preflight-resolved name is a cache hit for the background verifier's own pass — the
25
+ * preflight adds at most one live registry read per name per hour, not a second read path.
26
+ */
27
+ /** One name that definitively failed the preflight (the first failure aborts the scan). */
28
+ export interface NamePreflightFailure {
29
+ ok: false;
30
+ communityName: string;
31
+ /** The `community.publicKey` the vote claims the name points at. */
32
+ claimedPublicKey: string;
33
+ /** What the registry actually resolved the name to; absent for no-resolver / no-record. */
34
+ resolvedPublicKey?: string;
35
+ /** Human-readable cause, same wording as the verify pipeline's step-4 verdicts. */
36
+ reason: string;
37
+ }
38
+ export type NamePreflightResult = {
39
+ ok: true;
40
+ /**
41
+ * True when every carried name resolved to its claimed key right now; false when at
42
+ * least one resolution was SKIPPED on a resolver throw (transient outage) and is
43
+ * still owed to the background verifier. Feeds the publisher's own
44
+ * `nameResolved` check state, so a preflight-settled vote renders verified
45
+ * immediately instead of flashing a pending row.
46
+ */
47
+ settled: boolean;
48
+ } | NamePreflightFailure;
49
+ /**
50
+ * Resolve every distinct `community.name` carried by `votes` and check each against its
51
+ * vote's claimed `publicKey`. Returns the first definitive failure, or `ok` with whether
52
+ * every name settled (see module doc for the definitive/transient split). Votes carrying no
53
+ * name are free: no resolver is consulted and `{ ok: true, settled: true }` returns
54
+ * synchronously.
55
+ */
56
+ export declare function preflightCommunityNames(opts: {
57
+ votes: readonly Vote[];
58
+ nameResolvers: NameResolver[];
59
+ cache: NameResolutionCache | undefined;
60
+ }): Promise<NamePreflightResult>;
@@ -0,0 +1,52 @@
1
+ import { resolveNameThroughCache } from "./name-resolution-cache.js";
2
+ /**
3
+ * Resolve every distinct `community.name` carried by `votes` and check each against its
4
+ * vote's claimed `publicKey`. Returns the first definitive failure, or `ok` with whether
5
+ * every name settled (see module doc for the definitive/transient split). Votes carrying no
6
+ * name are free: no resolver is consulted and `{ ok: true, settled: true }` returns
7
+ * synchronously.
8
+ */
9
+ export async function preflightCommunityNames(opts) {
10
+ const { votes, nameResolvers, cache } = opts;
11
+ let settled = true;
12
+ for (const v of votes) {
13
+ const name = v.community.name;
14
+ if (!name)
15
+ continue;
16
+ const resolver = nameResolvers.find((r) => r.canResolve({ name }));
17
+ if (!resolver) {
18
+ return {
19
+ ok: false,
20
+ communityName: name,
21
+ claimedPublicKey: v.community.publicKey,
22
+ reason: `no resolver handles community name "${name}"`
23
+ };
24
+ }
25
+ let record;
26
+ try {
27
+ record = await resolveNameThroughCache({ resolver, name, cache });
28
+ }
29
+ catch {
30
+ settled = false; // transient registry outage — never blocks a publish
31
+ continue;
32
+ }
33
+ if (!record) {
34
+ return {
35
+ ok: false,
36
+ communityName: name,
37
+ claimedPublicKey: v.community.publicKey,
38
+ reason: `community name "${name}" does not resolve`
39
+ };
40
+ }
41
+ if (record.publicKey !== v.community.publicKey) {
42
+ return {
43
+ ok: false,
44
+ communityName: name,
45
+ claimedPublicKey: v.community.publicKey,
46
+ resolvedPublicKey: record.publicKey,
47
+ reason: `community name "${name}" resolves to ${record.publicKey}, not the claimed ${v.community.publicKey}`
48
+ };
49
+ }
50
+ }
51
+ return { ok: true, settled };
52
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitsocial/pubsub-voting",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Trustless pubsub voting over a shared libp2p/Helia node.",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",