@bitsocial/pubsub-voting 0.0.10 → 0.1.1

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
@@ -35,22 +35,34 @@ The library never starts a node and never takes a host SDK (there is no `pkc` ar
35
35
  | Seam | Type | Required | Purpose |
36
36
  |---|---|---|---|
37
37
  | `helia` | `HeliaInstance` | yes | the host's running Helia node; must carry a gossipsub service at `libp2p.services.pubsub` (else `MissingPubsubError`), a `blockstore` (else `MissingBlockstoreError`), and a libp2p fetch service at `libp2p.services.fetch` (else `MissingFetchError`) |
38
- | `chains` | `ChainClientFactory` | yes | builds a viem `PublicClient` per chain; rules read through it for the gate and weight |
38
+ | `chains` | `ChainClientFactory` | yes | resolves each chain a contest's criteria requires (`{ chain, chainId }`) to a viem `PublicClient`; rules read through it for the gate and weight. **RPC endpoints are this client's own settings, never part of the criteria document** — return one shared (memoized) client per chain, pointed at a gateway that serves historical state at least `voteExpiryBuckets × blocksPerBucket` blocks behind head and carries a multicall3 deployment in its viem `chain` config; return `undefined` for a chain with no RPC configured, and `createContest`/`createContestVote` throws `MissingChainClientError` (recuse, don't miscount) |
39
39
  | `signer` | `VoteSigner` | no | the voting wallet's address + EIP-712 ballot signing; omit for a read-only voter |
40
40
  | `nameResolvers` | `NameResolver[]` | no | community-name resolvers (same interface and instances as pkc-js's `nameResolvers`, e.g. `@bitsocial/bso-resolver` for `name.bso`); each vote's `community.name` claim is verified through them — inline at the forward-gate for live votes, in the background verifier for cold-join admits — and a bundle whose name resolves to a different `publicKey` than claimed is dropped/evicted |
41
41
  | `dataPath` | `string \| false` | no | directory for the voter's persistent state (gate-result + name-resolution caches, and each joined contest's **checkpoint snapshot** — its last fully-verified winner-set, reloaded at join so a restart with no other peer online keeps the tally), the pkc-js `dataPath` equivalent. Node default: `{cwd}/.bitsocial-pubsub-voting` (better-sqlite3 under `{dataPath}/lru-storage/` + `{dataPath}/checkpoints.db`); in the browser the path is ignored and everything lives in IndexedDB. Pass `false` for in-memory-only (the pkc-js `noData` equivalent). A restart re-serves settled gate reads and fresh name resolutions from the store instead of the RPC, and restores each contest's checkpoint before the cold-start pull. A seeder should always set a stable path |
42
42
  | `httpRouterUrls` | `string[]` | no | Delegated Routing V1 router base URLs to **announce provider records to** (one unsigned `PUT /routing/v1/providers` per router; `Keys` batches every joined contest's criteria CID + current checkpoint root + chunk CIDs — hourly, debounced on root changes, and on address changes). **Seeders only**: absent/empty means never announce (the default — plain clients are not dialable), and the browser build never announces regardless. The node must be publicly **reachable** (its listening port open/forwarded/published), but it does not need to know its own public IP: private, loopback, and link-local addrs are filtered client-side, and when nothing survives — the normal zero-config case behind NAT or a Docker bridge, and even on public-IP hosts, since libp2p withholds unconfirmed public addrs pending AutoNAT — the announcer sends the wildcard sentinels (`/ip4/0.0.0.0/...`, `/ip6/::/...`) that the router rewrites to the PUT's observed source IP, exactly as kubo announces work. Configured `addresses.announce` values (concrete public addrs, DNS/AutoTLS, or a kubo-style wildcard) are used as-is. Only a loopback-only node announces nothing. *Querying* needs no URLs here — cold-join discovery uses the injected node's `libp2p.contentRouting`, which the host wires its routers into |
43
43
 
44
- A contest is addressed by its **full criteria document**, passed to `createContest` / `createContestVote`. The document is strictly validated there (`CriteriaSchema` + the rule registry), and its canonical bytes derive the topic — so the exact document every participant shares is the only contest configuration that exists.
44
+ A contest is addressed by its **full criteria document**, passed to `createContest` / `createContestVote`. The document is strictly validated there (`CriteriaSchema` + the rule registry + the `chains` factory: an unimplemented rule throws `UnknownRuleError`, an unresolvable required chain throws `MissingChainClientError` — recuse, don't miscount), and its canonical bytes derive the topic — so the exact document every participant shares is the only contest configuration that exists. The document names each required chain only by ticker + `chainId`; RPC endpoints stay out of it, so operators can swap gateways without forking the topic.
45
45
 
46
46
  ### Construct a voter
47
47
 
48
48
  ```ts
49
- import { PubsubVoter } from "@bitsocial/pubsub-voting";
49
+ import { PubsubVoter, type ChainClientFactory } from "@bitsocial/pubsub-voting";
50
+ import { createPublicClient, http } from "viem";
51
+ import { base } from "viem/chains";
52
+
53
+ // The host's chain settings: which RPC gateway to trust per chain is THIS client's choice
54
+ // (never part of a criteria document). One shared client per chain, memoized — sharing is
55
+ // what lets parallel contests' pinned-block reads coalesce into shared multicalls.
56
+ const viemChainFactory = (): ChainClientFactory => {
57
+ const clients: Record<number, ReturnType<typeof createPublicClient>> = {
58
+ [base.id]: createPublicClient({ chain: base, transport: http("https://my-trusted-base-rpc.example") })
59
+ };
60
+ return ({ chainId }) => clients[chainId]; // undefined → recuse contests requiring that chain
61
+ };
50
62
 
51
63
  const voter = new PubsubVoter({
52
64
  helia, // the host's Helia node; needs a gossipsub service at libp2p.services.pubsub + a blockstore
53
- chains: viemChainFactory(), // ({ chain, config }) => viem PublicClient
65
+ chains: viemChainFactory(), // ({ chain, chainId }) => viem PublicClient | undefined
54
66
  signer: mySigner, // optional; omit → read-only voter
55
67
  nameResolvers: [bsoResolver], // optional; verifies community-name claims (e.g. @bitsocial/bso-resolver)
56
68
  dataPath: "/path/to/data", // optional; persistent state: caches + checkpoint snapshots (default {cwd}/.bitsocial-pubsub-voting; false → in-memory)
@@ -69,7 +81,7 @@ Construction throws `MissingPubsubError`, `MissingBlockstoreError`, or `MissingF
69
81
  ```ts
70
82
  const contest = await voter.createContest({ criteria }); // criteria: the contest's full document (strictly validated here)
71
83
  contest.on("update", () => render(contest.tally)); // tally rides the object; recomputed before each emit
72
- 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)
73
85
  await contest.update(); // join the topic, cold-start, begin emitting
74
86
  // const fresh = await contest.getTally(); // or force a fresh read, bypassing the cache
75
87
  // await contest.stop(); // leave the topic
@@ -116,6 +128,20 @@ A community's identity is its `publicKey`. The optional `name` is the community'
116
128
 
117
129
  `publish()` on a voter built without a `signer` throws `ReadOnlyError` (and emits an `error`).
118
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
+
119
145
  ### Republishing is the client's job
120
146
 
121
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:
@@ -207,6 +207,10 @@ export function coalescingChainFactory(factory, options) {
207
207
  const wrapped = new WeakMap();
208
208
  return (args) => {
209
209
  const client = factory(args);
210
+ // No client for this chain — the host has no RPC configured; the voter turns this
211
+ // into MissingChainClientError at the create seam.
212
+ if (client === undefined)
213
+ return undefined;
210
214
  let coalesced = wrapped.get(client);
211
215
  if (!coalesced) {
212
216
  coalesced = coalescingChainClient(client, options);
@@ -1,5 +1,4 @@
1
1
  import type { PublicClient } from "viem";
2
- import type { ChainConfig } from "../schema/criteria.js";
3
2
  /**
4
3
  * Chain access.
5
4
  *
@@ -22,14 +21,27 @@ export type ChainClient = PublicClient;
22
21
  /** chainTicker -> client, built from `criteria.requires.chains`. */
23
22
  export type ChainClients = Record<string, ChainClient>;
24
23
  /**
25
- * Factory the host provides: turn a chain config into a viem `PublicClient`
26
- * (typically `createPublicClient({ transport: http(config.rpcUrls[0]) })`).
27
- * Declared here so the public API can describe how chain clients are supplied.
24
+ * Factory the host provides: resolve a chain named by the criteria (`requires.chains`,
25
+ * ticker + chainId) to a viem `PublicClient`. The RPC endpoint is the HOST's setting —
26
+ * deliberately not part of the criteria document (see schema/criteria.ts,
27
+ * `ChainConfigSchema`) — so this factory is where ticker/chainId meets the gateways this
28
+ * client trusts (typically `createPublicClient({ chain, transport: http(myRpcUrl) })`).
29
+ *
30
+ * Return `undefined` (or throw) when no RPC is configured for the named chain: the voter
31
+ * then throws `MissingChainClientError` at the create seam (`createContest` /
32
+ * `createContestVote`) — this client must recuse the contest rather than miscount.
33
+ *
34
+ * Return ONE shared client per chain (memoize on `chainId`), not a fresh client per call:
35
+ * the voter wraps each distinct client with the cross-contest read coalescer
36
+ * (src/chain/coalescer.ts), so sharing is what merges parallel contests' pinned-block reads
37
+ * into shared multicalls under one in-flight budget. Pick a gateway that serves historical
38
+ * state at least `voteExpiryBuckets × blocksPerBucket` blocks behind head (gate reads pin
39
+ * to bucket sample blocks) and carries a multicall3 deployment in its viem `chain` config.
28
40
  */
29
41
  export type ChainClientFactory = (args: {
30
42
  chain: string;
31
- config: ChainConfig;
32
- }) => ChainClient;
43
+ chainId: number;
44
+ }) => ChainClient | undefined;
33
45
  /**
34
46
  * A community-name resolver the host injects (`PubsubVoterOptions.nameResolvers`). The
35
47
  * shape is structurally identical to pkc-js's `NameResolverInterface`, so a host passes
@@ -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. */
@@ -172,9 +191,13 @@ export interface PubsubVoterOptions {
172
191
  */
173
192
  helia: HeliaInstance;
174
193
  /**
175
- * Builds a chain client from a `{ chain, config }` pair. Each contest builds its
176
- * own clients from `criteria.requires.chains` via this factory, so chains are
177
- * per-contest data, not a global injection.
194
+ * Resolves a chain named by a contest's criteria (`requires.chains`, ticker + chainId)
195
+ * to a viem `PublicClient`. Which RPC gateway to use is THIS client's setting — RPC URLs
196
+ * are deliberately not part of the criteria document, so this factory is where the host
197
+ * maps chains to the endpoints it trusts. Return one shared client per chain (memoized),
198
+ * and `undefined` for a chain with no RPC configured — `createContest` /
199
+ * `createContestVote` then throws `MissingChainClientError` (recuse, don't miscount).
200
+ * See `ChainClientFactory` (src/chain/types.ts) for the full contract.
178
201
  */
179
202
  chains: ChainClientFactory;
180
203
  /** Identity. Omit for a read-only voter (renders tallies, cannot publish). */
@@ -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 { 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
@@ -284,7 +285,14 @@ class ContestEngine {
284
285
  this.readOnly = deps.signer === undefined;
285
286
  this.#deps = deps;
286
287
  this.#criteriaCid = criteriaCidBytes;
287
- this.#chainClients = Object.fromEntries(Object.entries(criteria.requires.chains).map(([chain, config]) => [chain, deps.chains({ chain, config })]));
288
+ // Resolve every chain the manifest requires, eagerly: a client with no RPC configured
289
+ // for one of them must find out at the create seam (recuse), not on its first verify.
290
+ this.#chainClients = Object.fromEntries(Object.entries(criteria.requires.chains).map(([chain, config]) => {
291
+ const client = deps.chains({ chain, chainId: config.chainId });
292
+ if (client === undefined)
293
+ throw new MissingChainClientError(chain, config.chainId);
294
+ return [chain, client];
295
+ }));
288
296
  // The gating (`rule`) chain fixes the ballot's chainId and the tie-break seed chain.
289
297
  const rule = deps.registry[criteria.rule.type];
290
298
  if (!rule)
@@ -341,7 +349,7 @@ class ContestEngine {
341
349
  cache: this.#cache,
342
350
  onGateVerified: (cid) => this.#settleCheck(cid, "chainVerified"),
343
351
  onNameResolved: (cid) => this.#settleCheck(cid, "nameResolved"),
344
- onEvict: (cid) => this.#evictBundle(cid),
352
+ onEvict: (cid, verdict) => this.#evictBundle(cid, verdict),
345
353
  onError: (error) => this.#emitError(error),
346
354
  limit: (fn) => this.#backgroundLimit(fn)
347
355
  });
@@ -375,13 +383,26 @@ class ContestEngine {
375
383
  * settled bundles (never re-serve what we have not verified).
376
384
  */
377
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();
378
395
  /** Does any vote in the bundle carry a `community.name` claim (needing resolution)? */
379
396
  #carriesName(bundle) {
380
397
  return bundle.votes.some((v) => v.community.name !== undefined);
381
398
  }
382
- /** Record a bundle's deferred-check state at admit: fully settled, or pending both checks. */
383
- #recordChecks(cid, bundle, settled) {
384
- 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 });
385
406
  }
386
407
  /** The bundle's check state, pessimistic (all pending) if somehow unrecorded. */
387
408
  #checksFor(cid, bundle) {
@@ -404,14 +425,38 @@ class ContestEngine {
404
425
  if (!checks)
405
426
  return; // evicted or pruned while its check was in flight
406
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());
407
432
  this.#onStateChanged();
408
433
  }
409
- /** A deferred check failed: drop the bundle (its verified predecessor, if any, wins again). */
410
- #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) {
411
442
  this.#crdt.remove(cid);
412
- 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
+ }
413
453
  this.#onStateChanged();
414
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
+ }
415
460
  #emitError(error) {
416
461
  for (const cb of [...this.#errorListeners])
417
462
  cb(error);
@@ -520,7 +565,9 @@ class ContestEngine {
520
565
  if (this.#crdt.nodeCount() > 0) {
521
566
  await this.#refreshBucket();
522
567
  for (const removed of await this.#crdt.prune(this.#currentBucketCache)) {
523
- 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
524
571
  }
525
572
  }
526
573
  return this.#tally.compute();
@@ -890,12 +937,33 @@ class ContestEngine {
890
937
  if (wasJoined)
891
938
  this.#deps.onTopicLeft();
892
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
+ }
893
958
  /**
894
959
  * Sign the votes into a bundle for the current bucket boundary block (the block every verifier
895
960
  * reads at), add it to the CRDT, and return the bundle plus its encoded block bytes for
896
- * 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.
897
965
  */
898
- async signVote(votes) {
966
+ async signVote(votes, opts = {}) {
899
967
  const signer = this.#deps.signer;
900
968
  if (signer === undefined)
901
969
  throw new ReadOnlyError();
@@ -911,8 +979,12 @@ class ContestEngine {
911
979
  // Own bundles take the same deferred path as a chased checkpoint's: admitted
912
980
  // provisionally, then confirmed (or evicted) by the background gate read — so an
913
981
  // ineligible wallet's local tally does not silently disagree with the network's, and
914
- // our checkpoint never serves a vote we have not verified (even our own).
915
- 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);
916
988
  this.#background.enqueue([{ cid, bundle }]);
917
989
  this.#onStateChanged();
918
990
  return { bundle, encoded: encodeBundle(bundle) };
@@ -1259,6 +1331,13 @@ class ContestVotePublication {
1259
1331
  #errorCbs = [];
1260
1332
  #state = "stopped";
1261
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;
1262
1341
  constructor(engine, votes) {
1263
1342
  this.#engine = engine;
1264
1343
  this.contestId = engine.criteria.contestId;
@@ -1291,13 +1370,33 @@ class ContestVotePublication {
1291
1370
  throw error;
1292
1371
  }
1293
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);
1294
1379
  await this.#engine.join();
1380
+ this.#evicted = false;
1295
1381
  this.#setState("signing");
1296
- 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
+ });
1297
1392
  this.#bundle = bundle;
1298
- this.#setState("publishing");
1393
+ if (!this.#evicted)
1394
+ this.#setState("publishing");
1299
1395
  const { recipientCount } = await this.#engine.broadcastBundle(encoded);
1300
- 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");
1301
1400
  return { bundle, recipientCount };
1302
1401
  }
1303
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
  *
@@ -22,6 +24,18 @@ export declare class UnknownRuleError extends Error {
22
24
  readonly type: string;
23
25
  constructor(slot: "rule" | "weight" | "requires", type: string);
24
26
  }
27
+ /**
28
+ * Thrown by `createContest` / `createContestVote` when the criteria's dependency manifest
29
+ * names a chain (`requires.chains`) the host's `ChainClientFactory` cannot resolve to a
30
+ * client (it returned `undefined`). RPC endpoints are client-local settings, not part of
31
+ * the criteria document, so a client with no gateway configured for a required chain must
32
+ * recuse the contest rather than miscount — the chain-side twin of `UnknownRuleError`.
33
+ */
34
+ export declare class MissingChainClientError extends Error {
35
+ readonly chain: string;
36
+ readonly chainId: number;
37
+ constructor(chain: string, chainId: number);
38
+ }
25
39
  /**
26
40
  * Thrown at construction when the injected Helia node's libp2p has no usable pubsub
27
41
  * (gossipsub) service at `libp2p.services.pubsub`. The library broadcasts and receives
@@ -79,3 +93,51 @@ export declare class DuplicateContestIdError extends Error {
79
93
  export declare class ReadOnlyError extends Error {
80
94
  constructor();
81
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
@@ -32,6 +32,26 @@ export class UnknownRuleError extends Error {
32
32
  this.name = "UnknownRuleError";
33
33
  }
34
34
  }
35
+ /**
36
+ * Thrown by `createContest` / `createContestVote` when the criteria's dependency manifest
37
+ * names a chain (`requires.chains`) the host's `ChainClientFactory` cannot resolve to a
38
+ * client (it returned `undefined`). RPC endpoints are client-local settings, not part of
39
+ * the criteria document, so a client with no gateway configured for a required chain must
40
+ * recuse the contest rather than miscount — the chain-side twin of `UnknownRuleError`.
41
+ */
42
+ export class MissingChainClientError extends Error {
43
+ chain;
44
+ chainId;
45
+ constructor(chain, chainId) {
46
+ super(`No chain client for "${chain}" (chainId ${chainId}), which this contest's criteria ` +
47
+ `requires. RPC endpoints are client settings, not part of the criteria document: ` +
48
+ `configure the \`chains\` factory (PubsubVoterOptions.chains) to return a viem ` +
49
+ `PublicClient for this chain, or recuse this contest.`);
50
+ this.chain = chain;
51
+ this.chainId = chainId;
52
+ this.name = "MissingChainClientError";
53
+ }
54
+ }
35
55
  /**
36
56
  * Thrown at construction when the injected Helia node's libp2p has no usable pubsub
37
57
  * (gossipsub) service at `libp2p.services.pubsub`. The library broadcasts and receives
@@ -124,3 +144,64 @@ export class ReadOnlyError extends Error {
124
144
  this.name = "ReadOnlyError";
125
145
  }
126
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
+ }
@@ -30,11 +30,25 @@ export declare const VoteRangeSchema: z.ZodObject<{
30
30
  export declare const RuleRefSchema: z.ZodObject<{
31
31
  type: z.ZodString;
32
32
  }, z.core.$loose>;
33
- /** RPC configuration for one chain ticker. Part of the dependency manifest. */
33
+ /**
34
+ * One chain the contest reads, by ticker. Part of the dependency manifest.
35
+ *
36
+ * Only the `chainId` is here — it is consensus-critical (bound into every EIP-712 ballot
37
+ * domain and defining which chain the rules read). RPC endpoints are deliberately NOT part
38
+ * of the criteria: which gateway a client trusts is client-local transport configuration
39
+ * (`PubsubVoterOptions.chains` maps ticker/chainId to a client), and two honest verifiers
40
+ * reading the same pinned block through different gateways compute identical results. Keeping
41
+ * URLs out means an operator can swap a dead RPC provider without changing the document's
42
+ * bytes — i.e. without forking the topic and orphaning the contest's votes.
43
+ *
44
+ * Strict on purpose: the topic is derived from the PARSED document, so an unknown key must
45
+ * fail loudly here — a plain (stripping) object would silently drop it and derive a different
46
+ * topic than the author's raw document implies. This also makes pre-v1 documents that still
47
+ * carry `rpcUrls` a loud error instead of a silent re-topic.
48
+ */
34
49
  export declare const ChainConfigSchema: z.ZodObject<{
35
50
  chainId: z.ZodNumber;
36
- rpcUrls: z.ZodArray<z.ZodString>;
37
- }, z.core.$strip>;
51
+ }, z.core.$strict>;
38
52
  /**
39
53
  * The dependency manifest. A client reads this on join and checks that it
40
54
  * implements every named rule; if not, it is too old and must recuse
@@ -44,8 +58,7 @@ export declare const RequiresSchema: z.ZodObject<{
44
58
  rules: z.ZodArray<z.ZodString>;
45
59
  chains: z.ZodRecord<z.ZodString, z.ZodObject<{
46
60
  chainId: z.ZodNumber;
47
- rpcUrls: z.ZodArray<z.ZodString>;
48
- }, z.core.$strip>>;
61
+ }, z.core.$strict>>;
49
62
  }, z.core.$strip>;
50
63
  export declare const CriteriaSchema: z.ZodObject<{
51
64
  name: z.ZodString;
@@ -67,8 +80,7 @@ export declare const CriteriaSchema: z.ZodObject<{
67
80
  rules: z.ZodArray<z.ZodString>;
68
81
  chains: z.ZodRecord<z.ZodString, z.ZodObject<{
69
82
  chainId: z.ZodNumber;
70
- rpcUrls: z.ZodArray<z.ZodString>;
71
- }, z.core.$strip>>;
83
+ }, z.core.$strict>>;
72
84
  }, z.core.$strip>;
73
85
  }, z.core.$strict>;
74
86
  export type VoteRange = z.infer<typeof VoteRangeSchema>;
@@ -31,10 +31,24 @@ export const VoteRangeSchema = z.object({
31
31
  export const RuleRefSchema = z.looseObject({
32
32
  type: z.string().min(1)
33
33
  });
34
- /** RPC configuration for one chain ticker. Part of the dependency manifest. */
35
- export const ChainConfigSchema = z.object({
36
- chainId: z.number().int().positive(),
37
- rpcUrls: z.array(z.string().min(1)).nonempty()
34
+ /**
35
+ * One chain the contest reads, by ticker. Part of the dependency manifest.
36
+ *
37
+ * Only the `chainId` is here — it is consensus-critical (bound into every EIP-712 ballot
38
+ * domain and defining which chain the rules read). RPC endpoints are deliberately NOT part
39
+ * of the criteria: which gateway a client trusts is client-local transport configuration
40
+ * (`PubsubVoterOptions.chains` maps ticker/chainId to a client), and two honest verifiers
41
+ * reading the same pinned block through different gateways compute identical results. Keeping
42
+ * URLs out means an operator can swap a dead RPC provider without changing the document's
43
+ * bytes — i.e. without forking the topic and orphaning the contest's votes.
44
+ *
45
+ * Strict on purpose: the topic is derived from the PARSED document, so an unknown key must
46
+ * fail loudly here — a plain (stripping) object would silently drop it and derive a different
47
+ * topic than the author's raw document implies. This also makes pre-v1 documents that still
48
+ * carry `rpcUrls` a loud error instead of a silent re-topic.
49
+ */
50
+ export const ChainConfigSchema = z.strictObject({
51
+ chainId: z.number().int().positive()
38
52
  });
39
53
  /**
40
54
  * The dependency manifest. A client reads this on join and checks that it
@@ -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.0.10",
3
+ "version": "0.1.1",
4
4
  "description": "Trustless pubsub voting over a shared libp2p/Helia node.",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",