@bitsocial/pubsub-voting 0.0.9 → 0.1.0

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)
@@ -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
@@ -172,9 +172,13 @@ export interface PubsubVoterOptions {
172
172
  */
173
173
  helia: HeliaInstance;
174
174
  /**
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.
175
+ * Resolves a chain named by a contest's criteria (`requires.chains`, ticker + chainId)
176
+ * to a viem `PublicClient`. Which RPC gateway to use is THIS client's setting — RPC URLs
177
+ * are deliberately not part of the criteria document, so this factory is where the host
178
+ * maps chains to the endpoints it trusts. Return one shared client per chain (memoized),
179
+ * and `undefined` for a chain with no RPC configured — `createContest` /
180
+ * `createContestVote` then throws `MissingChainClientError` (recuse, don't miscount).
181
+ * See `ChainClientFactory` (src/chain/types.ts) for the full contract.
178
182
  */
179
183
  chains: ChainClientFactory;
180
184
  /** Identity. Omit for a read-only voter (renders tallies, cannot publish). */
@@ -30,7 +30,7 @@ import { CID } from "multiformats/cid";
30
30
  import { makeTally } from "../tally/tally.js";
31
31
  import { ballotTypedData } from "../signer/eip712.js";
32
32
  import { criteriaCid, TOPIC_PREFIX } from "../topic.js";
33
- import { ReadOnlyError, UnknownRuleError, VoterDestroyedError } from "../errors.js";
33
+ import { MissingChainClientError, ReadOnlyError, UnknownRuleError, VoterDestroyedError } from "../errors.js";
34
34
  /**
35
35
  * The recommended cadence, in buckets, at which a client should re-publish a live vote to keep
36
36
  * it alive: half its expiry window, rounded up. A bundle is valid for `voteExpiryBuckets` after
@@ -121,6 +121,14 @@ const COLD_START_FETCH_BACKOFF_CAP_MS = 4_000;
121
121
  * others idle.
122
122
  */
123
123
  const COLD_START_PEER_FETCH_LIMIT = 24;
124
+ /**
125
+ * How long after `join()` the cold-start pull stays armed on gossipsub's `subscription-change`
126
+ * (see `#armSubscriptionRepull`). One heartbeat interval: the window exists to close the
127
+ * join-races-subscription-gossip gap (issue #15), and past the first heartbeat the passive
128
+ * root-record heartbeat covers divergence detection anyway — an unbounded listener would
129
+ * instead pay one root fetch per churning subscriber for the node's whole lifetime.
130
+ */
131
+ const COLD_START_REPULL_WINDOW_MS = HEARTBEAT_INTERVAL_MS;
124
132
  /**
125
133
  * Debounce (ms) for persisting the checkpoint snapshot after a winner-set change, mirroring the
126
134
  * announcer's cadence on the same transition: a gossip burst costs one write per window, and
@@ -261,6 +269,11 @@ class ContestEngine {
261
269
  #heartbeatTimer;
262
270
  /** The armed (debounced) snapshot-write timer; flushed by `leave()`. See {@link #writeSnapshot}. */
263
271
  #snapshotTimer;
272
+ /**
273
+ * Tears down the per-join `subscription-change` re-pull (listener + window timer); set by
274
+ * {@link #armSubscriptionRepull}, cleared by `leave()` or by the window expiring.
275
+ */
276
+ #subscriptionRepullDisarm;
264
277
  /** True when a heartbeat matching our own root was heard this interval (suppression). */
265
278
  #heardMatchingRoot = false;
266
279
  /** True once we published our record this interval (heartbeat OR divergence response). */
@@ -271,7 +284,14 @@ class ContestEngine {
271
284
  this.readOnly = deps.signer === undefined;
272
285
  this.#deps = deps;
273
286
  this.#criteriaCid = criteriaCidBytes;
274
- this.#chainClients = Object.fromEntries(Object.entries(criteria.requires.chains).map(([chain, config]) => [chain, deps.chains({ chain, config })]));
287
+ // Resolve every chain the manifest requires, eagerly: a client with no RPC configured
288
+ // for one of them must find out at the create seam (recuse), not on its first verify.
289
+ this.#chainClients = Object.fromEntries(Object.entries(criteria.requires.chains).map(([chain, config]) => {
290
+ const client = deps.chains({ chain, chainId: config.chainId });
291
+ if (client === undefined)
292
+ throw new MissingChainClientError(chain, config.chainId);
293
+ return [chain, client];
294
+ }));
275
295
  // The gating (`rule`) chain fixes the ballot's chainId and the tie-break seed chain.
276
296
  const rule = deps.registry[criteria.rule.type];
277
297
  if (!rule)
@@ -688,6 +708,14 @@ class ContestEngine {
688
708
  * the providers of the criteria CID from the host's HTTP content router. Roots are **unioned,
689
709
  * never quorum'd** — a record served by a single peer is still chased, so a colluding majority
690
710
  * cannot hide a vote.
711
+ *
712
+ * The `getSubscribers` source is an instantaneous snapshot, and a joiner that dials a seeder
713
+ * and joins immediately (the normal browser boot order) races subscription gossip: at the
714
+ * instant of `join()` it sees zero subscribers, pulls nothing, and would idle until the topic
715
+ * heartbeat (issue #15 — measured 90+ s vs ~4 s). So the pull closure (and its `seen` dedup)
716
+ * stays armed on gossipsub's `subscription-change` for {@link COLD_START_REPULL_WINDOW_MS}:
717
+ * each peer whose subscription to this topic becomes visible inside the window is asked once,
718
+ * closing the race for router-less clients too. See {@link #armSubscriptionRepull}.
691
719
  */
692
720
  async #coldStart() {
693
721
  const seen = new Set();
@@ -719,12 +747,45 @@ class ContestEngine {
719
747
  // live gossip still converge.
720
748
  }
721
749
  };
750
+ // Arm the re-pull BEFORE the initial fan-out so no subscriber can land in the gap
751
+ // between the snapshot below and the listener; `seen` dedups any overlap.
752
+ this.#armSubscriptionRepull(pull);
722
753
  // Shuffle before slicing: a deterministic first-N pick would funnel a whole directory
723
754
  // join through the same peers' stream caps while other subscribers idle; a random N
724
755
  // spreads contests across the topic's serving peers (see COLD_START_PEER_FETCH_LIMIT).
725
756
  const fromSubscribers = shuffled(this.#deps.pubsub.getSubscribers(this.topic)).slice(0, COLD_START_PEERS).map(pull);
726
757
  await Promise.allSettled([...fromSubscribers, this.#discoverProviders(pull)]);
727
758
  }
759
+ /**
760
+ * Keep the cold-start pull live on gossipsub's `subscription-change` for one re-pull window:
761
+ * a peer whose subscription to this topic becomes visible after `join()`'s instantaneous
762
+ * `getSubscribers` snapshot is pulled the moment it appears (once — the shared `seen` set
763
+ * dedups), instead of waiting for the heartbeat. Bounded by {@link COLD_START_REPULL_WINDOW_MS}:
764
+ * past the first heartbeat interval the passive heartbeat already covers divergence detection,
765
+ * so a long-lived node does not pay one fetch per churning subscriber forever. Disarmed by
766
+ * `leave()`; a re-join arms a fresh window.
767
+ */
768
+ #armSubscriptionRepull(pull) {
769
+ this.#disarmSubscriptionRepull(); // a stale listener from a prior join must not leak
770
+ const pubsub = this.#deps.pubsub;
771
+ const listener = (evt) => {
772
+ if (!evt.detail.subscriptions.some((s) => s.topic === this.topic && s.subscribe))
773
+ return;
774
+ void pull(evt.detail.peerId).catch(() => { });
775
+ };
776
+ pubsub.addEventListener("subscription-change", listener);
777
+ const timer = setTimeout(() => this.#disarmSubscriptionRepull(), COLD_START_REPULL_WINDOW_MS);
778
+ // Don't hold a Node process open; no-op in the browser.
779
+ timer.unref?.();
780
+ this.#subscriptionRepullDisarm = () => {
781
+ clearTimeout(timer);
782
+ pubsub.removeEventListener("subscription-change", listener);
783
+ };
784
+ }
785
+ #disarmSubscriptionRepull() {
786
+ this.#subscriptionRepullDisarm?.();
787
+ this.#subscriptionRepullDisarm = undefined;
788
+ }
728
789
  /**
729
790
  * Pull one peer's root record over the fetch protocol, retrying a THROWN fetch with full-jittered
730
791
  * exponential backoff until {@link COLD_START_FETCH_DEADLINE_MS} (see the constant's note for the
@@ -821,6 +882,7 @@ class ContestEngine {
821
882
  }
822
883
  // Pause the background verifier's retry timer; pending state survives for a re-join.
823
884
  this.#background.stop();
885
+ this.#disarmSubscriptionRepull();
824
886
  this.#heardMatchingRoot = false;
825
887
  this.#publishedRootThisInterval = false;
826
888
  this.#chaser = undefined;
package/dist/errors.d.ts CHANGED
@@ -22,6 +22,18 @@ export declare class UnknownRuleError extends Error {
22
22
  readonly type: string;
23
23
  constructor(slot: "rule" | "weight" | "requires", type: string);
24
24
  }
25
+ /**
26
+ * Thrown by `createContest` / `createContestVote` when the criteria's dependency manifest
27
+ * names a chain (`requires.chains`) the host's `ChainClientFactory` cannot resolve to a
28
+ * client (it returned `undefined`). RPC endpoints are client-local settings, not part of
29
+ * the criteria document, so a client with no gateway configured for a required chain must
30
+ * recuse the contest rather than miscount — the chain-side twin of `UnknownRuleError`.
31
+ */
32
+ export declare class MissingChainClientError extends Error {
33
+ readonly chain: string;
34
+ readonly chainId: number;
35
+ constructor(chain: string, chainId: number);
36
+ }
25
37
  /**
26
38
  * Thrown at construction when the injected Helia node's libp2p has no usable pubsub
27
39
  * (gossipsub) service at `libp2p.services.pubsub`. The library broadcasts and receives
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
@@ -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
@@ -75,6 +75,15 @@ export interface PubsubService {
75
75
  from?: PeerId;
76
76
  };
77
77
  }) => void): void;
78
+ /**
79
+ * gossipsub's "a peer's subscription set changed" notification. The cold-start pull
80
+ * re-runs off it (see client/voter.ts `#armSubscriptionRepull`): a joiner that dials a
81
+ * seeder and joins immediately sees zero subscribers at the instant of `join()` — the
82
+ * seeder only appears here once subscription gossip lands — so without this trigger it
83
+ * would idle until the topic heartbeat.
84
+ */
85
+ addEventListener(type: "subscription-change", listener: SubscriptionChangeListener): void;
86
+ removeEventListener(type: "subscription-change", listener: SubscriptionChangeListener): void;
78
87
  /**
79
88
  * gossipsub's per-topic validator map. The transport installs the async forward-gate
80
89
  * here (`topicValidators.set(topic, gate)`); gossipsub awaits the returned promise
@@ -85,6 +94,16 @@ export interface PubsubService {
85
94
  */
86
95
  topicValidators?: Map<string, GossipTopicValidator>;
87
96
  }
97
+ /** Listener for gossipsub's `subscription-change` (the libp2p `SubscriptionChangeData` shape). */
98
+ export type SubscriptionChangeListener = (evt: {
99
+ detail: {
100
+ peerId: PeerId;
101
+ subscriptions: Array<{
102
+ topic: string;
103
+ subscribe: boolean;
104
+ }>;
105
+ };
106
+ }) => void;
88
107
  /** A received pubsub message, as passed to a gossipsub topic validator. */
89
108
  export interface GossipMessage {
90
109
  topic: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitsocial/pubsub-voting",
3
- "version": "0.0.9",
3
+ "version": "0.1.0",
4
4
  "description": "Trustless pubsub voting over a shared libp2p/Helia node.",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",