@bitsocial/pubsub-voting 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +223 -0
  3. package/dist/chain/bucket.d.ts +13 -0
  4. package/dist/chain/bucket.js +24 -0
  5. package/dist/chain/coalescer.d.ts +56 -0
  6. package/dist/chain/coalescer.js +217 -0
  7. package/dist/chain/ticker.d.ts +15 -0
  8. package/dist/chain/ticker.js +25 -0
  9. package/dist/chain/types.d.ts +90 -0
  10. package/dist/chain/types.js +1 -0
  11. package/dist/checkpoint/codec.d.ts +54 -0
  12. package/dist/checkpoint/codec.js +99 -0
  13. package/dist/client/root-puller.d.ts +49 -0
  14. package/dist/client/root-puller.js +140 -0
  15. package/dist/client/voter.d.ts +239 -0
  16. package/dist/client/voter.js +1343 -0
  17. package/dist/crdt/codec.d.ts +41 -0
  18. package/dist/crdt/codec.js +137 -0
  19. package/dist/crdt/crdt.d.ts +22 -0
  20. package/dist/crdt/crdt.js +127 -0
  21. package/dist/crdt/store.d.ts +8 -0
  22. package/dist/crdt/store.js +23 -0
  23. package/dist/crdt/types.d.ts +87 -0
  24. package/dist/crdt/types.js +1 -0
  25. package/dist/encoding/canonical.d.ts +22 -0
  26. package/dist/encoding/canonical.js +26 -0
  27. package/dist/errors.d.ts +81 -0
  28. package/dist/errors.js +126 -0
  29. package/dist/index.d.ts +32 -0
  30. package/dist/index.js +43 -0
  31. package/dist/rules/constant.d.ts +14 -0
  32. package/dist/rules/constant.js +18 -0
  33. package/dist/rules/erc20-balance.d.ts +30 -0
  34. package/dist/rules/erc20-balance.js +44 -0
  35. package/dist/rules/erc721-min-balance.d.ts +18 -0
  36. package/dist/rules/erc721-min-balance.js +100 -0
  37. package/dist/rules/registry.d.ts +42 -0
  38. package/dist/rules/registry.js +61 -0
  39. package/dist/rules/types.d.ts +74 -0
  40. package/dist/rules/types.js +1 -0
  41. package/dist/schema/common.d.ts +25 -0
  42. package/dist/schema/common.js +24 -0
  43. package/dist/schema/criteria.d.ts +78 -0
  44. package/dist/schema/criteria.js +76 -0
  45. package/dist/schema/directory.d.ts +42 -0
  46. package/dist/schema/directory.js +52 -0
  47. package/dist/schema/votes.d.ts +55 -0
  48. package/dist/schema/votes.js +116 -0
  49. package/dist/signer/eip712.d.ts +103 -0
  50. package/dist/signer/eip712.js +85 -0
  51. package/dist/signer/types.d.ts +31 -0
  52. package/dist/signer/types.js +1 -0
  53. package/dist/storage/browser.d.ts +3 -0
  54. package/dist/storage/browser.js +110 -0
  55. package/dist/storage/memory.d.ts +10 -0
  56. package/dist/storage/memory.js +56 -0
  57. package/dist/storage/node.d.ts +5 -0
  58. package/dist/storage/node.js +106 -0
  59. package/dist/storage/types.d.ts +46 -0
  60. package/dist/storage/types.js +1 -0
  61. package/dist/store/indexeddb.d.ts +9 -0
  62. package/dist/store/indexeddb.js +72 -0
  63. package/dist/store/memory.d.ts +15 -0
  64. package/dist/store/memory.js +22 -0
  65. package/dist/store/select.d.ts +15 -0
  66. package/dist/store/select.js +64 -0
  67. package/dist/store/sqlite.d.ts +11 -0
  68. package/dist/store/sqlite.js +68 -0
  69. package/dist/store/types.d.ts +57 -0
  70. package/dist/store/types.js +1 -0
  71. package/dist/tally/tally.d.ts +44 -0
  72. package/dist/tally/tally.js +89 -0
  73. package/dist/tally/types.d.ts +51 -0
  74. package/dist/tally/types.js +13 -0
  75. package/dist/topic.d.ts +20 -0
  76. package/dist/topic.js +28 -0
  77. package/dist/transport/accepted-dedup.d.ts +30 -0
  78. package/dist/transport/accepted-dedup.js +34 -0
  79. package/dist/transport/announce/browser.d.ts +9 -0
  80. package/dist/transport/announce/browser.js +14 -0
  81. package/dist/transport/announce/node.d.ts +38 -0
  82. package/dist/transport/announce/node.js +162 -0
  83. package/dist/transport/announce/types.d.ts +74 -0
  84. package/dist/transport/announce/types.js +16 -0
  85. package/dist/transport/bundle-store.d.ts +11 -0
  86. package/dist/transport/bundle-store.js +34 -0
  87. package/dist/transport/chase.d.ts +125 -0
  88. package/dist/transport/chase.js +182 -0
  89. package/dist/transport/gossip-validator.d.ts +107 -0
  90. package/dist/transport/gossip-validator.js +99 -0
  91. package/dist/transport/helia.d.ts +55 -0
  92. package/dist/transport/helia.js +114 -0
  93. package/dist/transport/integration/harness.d.ts +65 -0
  94. package/dist/transport/integration/harness.js +278 -0
  95. package/dist/transport/messages.d.ts +97 -0
  96. package/dist/transport/messages.js +117 -0
  97. package/dist/transport/rate-limit.d.ts +11 -0
  98. package/dist/transport/rate-limit.js +20 -0
  99. package/dist/transport/transport.d.ts +20 -0
  100. package/dist/transport/transport.js +35 -0
  101. package/dist/transport/types.d.ts +189 -0
  102. package/dist/transport/types.js +1 -0
  103. package/dist/verify/background.d.ts +81 -0
  104. package/dist/verify/background.js +236 -0
  105. package/dist/verify/bundle.d.ts +58 -0
  106. package/dist/verify/bundle.js +84 -0
  107. package/dist/verify/cache.d.ts +48 -0
  108. package/dist/verify/cache.js +62 -0
  109. package/dist/verify/constraints.d.ts +16 -0
  110. package/dist/verify/constraints.js +35 -0
  111. package/dist/verify/gate-result-cache.d.ts +65 -0
  112. package/dist/verify/gate-result-cache.js +91 -0
  113. package/dist/verify/name-resolution-cache.d.ts +59 -0
  114. package/dist/verify/name-resolution-cache.js +64 -0
  115. package/dist/verify/signature.d.ts +9 -0
  116. package/dist/verify/signature.js +55 -0
  117. package/dist/verify/types.d.ts +101 -0
  118. package/dist/verify/types.js +1 -0
  119. package/package.json +78 -0
@@ -0,0 +1,1343 @@
1
+ import pLimit from "p-limit";
2
+ import { CriteriaSchema } from "../schema/criteria.js";
3
+ import { VotesBundleSchema } from "../schema/votes.js";
4
+ import { coalescingChainFactory } from "../chain/coalescer.js";
5
+ import { makeBucketMath } from "../chain/bucket.js";
6
+ import { tickerForRef } from "../chain/ticker.js";
7
+ import { requireHeliaServices } from "../transport/helia.js";
8
+ import { makeBlockstoreBundleStore } from "../transport/bundle-store.js";
9
+ import { makeRateLimiter } from "../transport/rate-limit.js";
10
+ import { makeGossipGate } from "../transport/gossip-validator.js";
11
+ import { makeVoteTransport } from "../transport/transport.js";
12
+ import { decodeVoteMessage, decodeRootRecord, encodeRootRecord, maxBundleMessageBytes, rootFetchKey, MAX_ROOT_MESSAGE_BYTES, ROOT_FETCH_KEY_SUFFIX, ROOT_RECORD_VERSION } from "../transport/messages.js";
13
+ import { makeRootChaser, toChaseSession } from "../transport/chase.js";
14
+ import { encodeBundle, decodeBundle, bundleCidForBytes } from "../crdt/codec.js";
15
+ import { resolveRegistry, validateCriteriaRules } from "../rules/registry.js";
16
+ import { makeVoteCrdt } from "../crdt/crdt.js";
17
+ import { makeBundleVerifier } from "../verify/bundle.js";
18
+ import { makeVerdictCache } from "../verify/cache.js";
19
+ import { makePersistentGateResultCache, purgeExpiredGateResults } from "../verify/gate-result-cache.js";
20
+ import { makeNameResolutionCache } from "../verify/name-resolution-cache.js";
21
+ import { makeStorage } from "../storage/node.js";
22
+ import { makeAnnouncer } from "../transport/announce/node.js";
23
+ import { encode as encodeDagCbor } from "@ipld/dag-cbor";
24
+ import { sha256 } from "viem";
25
+ import { makeBackgroundVerifier } from "../verify/background.js";
26
+ import { makeAcceptedDedup } from "../transport/accepted-dedup.js";
27
+ import { encodeCheckpoint } from "../checkpoint/codec.js";
28
+ import { CID } from "multiformats/cid";
29
+ import { makeTally } from "../tally/tally.js";
30
+ import { ballotTypedData } from "../signer/eip712.js";
31
+ import { criteriaCid, TOPIC_PREFIX } from "../topic.js";
32
+ import { ReadOnlyError, UnknownRuleError, VoterDestroyedError } from "../errors.js";
33
+ /**
34
+ * The recommended cadence, in buckets, at which a client should re-publish a live vote to keep
35
+ * it alive: half its expiry window, rounded up. A bundle is valid for `voteExpiryBuckets` after
36
+ * its `blockNumber` (see DESIGN.md "Passive expiry"), so re-signing at the halfway point leaves
37
+ * one full missed cycle of slack before the vote decays. Derived per-contest from the criteria —
38
+ * there is no global interval.
39
+ *
40
+ * This library does NOT re-publish on its own: deciding when (or whether) to refresh a vote is
41
+ * the consuming client's job (see DESIGN.md "Republishing is the client's job"). This helper, the
42
+ * `blockNumber` on a published `VotesBundle`, and `criteria.voteExpiryBuckets` /
43
+ * `criteria.blocksPerBucket` are what a client uses to schedule its own refreshes: a vote sampled
44
+ * at bucket `b` expires once the current bucket exceeds `b + voteExpiryBuckets`; refresh by
45
+ * calling `createContestVote({ criteria, votes }).publish()` again before then.
46
+ */
47
+ export function republishIntervalBuckets(criteria) {
48
+ return Math.ceil(criteria.voteExpiryBuckets / 2);
49
+ }
50
+ /**
51
+ * LRU bound for the voter's persisted gate results (all contests share the store; entries are a
52
+ * short key + a decimal score, so this is single-digit MB at worst). Deliberately far above the
53
+ * name cache's pkc-js-parity 5000: one directory join can write `boards × wallets` entries in a
54
+ * bucket, and the deterministic sample-block purge (not this bound) is the intended eviction.
55
+ */
56
+ const GATE_RESULTS_MAX_ITEMS = 50_000;
57
+ /** LRU bound for persisted name resolutions — pkc-js's `CACHE_MAX_ITEMS` for the same cache. */
58
+ const NAME_RESOLUTIONS_MAX_ITEMS = 5_000;
59
+ /** Hard per-message validation deadline (ms): the 10s budget for the verify pipeline in the gate. */
60
+ const GATE_TIMEOUT_MS = 10_000;
61
+ /** Concurrent in-flight verifications across the gate (chain reads + name resolution are RPC). */
62
+ const GATE_CONCURRENCY = 8;
63
+ /** Per-peer rate window for bundle-kind messages: how many one peer may make us validate per interval. */
64
+ const GATE_RATE = { limit: 256, intervalMs: 10_000 };
65
+ /**
66
+ * Per-peer rate window for root-kind messages. An honest heartbeat is ~1 per 10 minutes (plus a
67
+ * divergence response), so 4/min is ≫ any honest rate while flattening a root spray.
68
+ */
69
+ const GATE_ROOT_RATE = { limit: 4, intervalMs: 60_000 };
70
+ /** Cold-join fan-out: how many peers (per discovery source) we ask for their root record on start. */
71
+ const COLD_START_PEERS = 4;
72
+ /**
73
+ * Deadline (ms) for the cold-join HTTP content-router lookup. `findProviders` over a delegated
74
+ * router is a network call that can stall; on expiry the abort ends it and cold-start falls back to
75
+ * whatever the gossipsub-subscriber source and live gossip provide.
76
+ */
77
+ const COLD_START_ROUTER_TIMEOUT_MS = 10_000;
78
+ /**
79
+ * Root-record heartbeat interval (ms): 10 minutes — the IPNS-over-pubsub rebroadcast default
80
+ * (`go-libp2p-pubsub-router`) — jittered ±25% per firing, with suppression on top (skip when a
81
+ * matching root was heard this interval) so a converged topic stays near-silent. See DESIGN.md
82
+ * "Checkpoints" and "Transport constants (v1)".
83
+ */
84
+ const HEARTBEAT_INTERVAL_MS = 600_000;
85
+ /**
86
+ * Cold-join fetch retry. A shared seeder registers `@libp2p/fetch` with libp2p's default per-protocol
87
+ * `maxInboundStreams` (32 in libp2p 3.3.4), so when a cold peer joins a whole directory at once — one
88
+ * root-record fetch per contest, fired concurrently — the node serves the first 32 and *resets* the
89
+ * rest, and libp2p surfaces the reset as a thrown fetch. Without a retry those boards silently never
90
+ * pull their checkpoint (measured: a naive 63-board join converges only 32/63).
91
+ *
92
+ * The cap is not just briefly exceeded — while more boards want to fetch than the node has slots, it
93
+ * stays *saturated*: every freed slot is instantly retaken, so a fixed handful of retries can lose the
94
+ * race and still strand a board (measured: 5 attempts → 53/63). So we retry a THROWN fetch until a
95
+ * **deadline**, with full-jittered exponential backoff: the jitter spreads retries across the freeing
96
+ * slots and the deadline guarantees a board keeps trying until it wins one, while still bounding a
97
+ * genuinely unreachable peer (cold-start is best-effort — live gossip and the heartbeat converge it
98
+ * regardless). Only a throw retries; a value or a definitive `undefined`/`null` ("no record") is
99
+ * returned as-is. See DESIGN.md "Deferred pkc-js work".
100
+ */
101
+ const COLD_START_FETCH_DEADLINE_MS = 30_000;
102
+ const COLD_START_FETCH_BACKOFF_MS = 400;
103
+ const COLD_START_FETCH_BACKOFF_CAP_MS = 4_000;
104
+ /**
105
+ * Per-peer budget for concurrent cold-start root fetches, shared across ALL contests on one
106
+ * voter (see {@link ResolvedDeps.fetchBudget}). The retry above rides out a saturated responder,
107
+ * but a directory-wide join should not be the one saturating it: this budget caps how many fetch
108
+ * streams *we* hold open to any single peer, under libp2p's default per-protocol caps (32 inbound
109
+ * on the responder, 64 outbound on us — both enforced PER CONNECTION per direction, so one
110
+ * connection's budget is exactly the scope of the remote cap; other users of a shared seeder
111
+ * arrive on their own connections and do not eat these slots). 24 rather than the full 32
112
+ * because running at the cliff still resets: our slot frees when the response lands, but the
113
+ * responder only decrements its count when it sees the stream *close*, so back-to-back reuse
114
+ * races that bookkeeping — and the same connection can carry fetch streams this budget cannot
115
+ * see (the host's own IPNS-over-pubsub record fetches ride the same protocol; so would a second
116
+ * voter on the shared node). The retry covers those residuals. Excess contests queue per peer
117
+ * instead of getting reset — and because cold-start also shuffles which peers it asks (see the
118
+ * shuffle in `#coldStart`), a multi-peer topic spreads a directory join across serving peers
119
+ * instead of funnelling every contest through the same first-listed peer's cap while the
120
+ * others idle.
121
+ */
122
+ const COLD_START_PEER_FETCH_LIMIT = 24;
123
+ /** Per-root chase deadline (ms): a multi-block directed-bitswap pull, coarser than one message. */
124
+ const CHASE_TIMEOUT_MS = 30_000;
125
+ /** Concurrent root chases; a spray of divergent roots queues, never floods. */
126
+ const CHASE_CONCURRENCY = 2;
127
+ /**
128
+ * Chase-session provider slots above the advertiser seeds: the headroom keeps the session's
129
+ * background provider discovery running, so the HTTP routers are queried ONCE per chased root —
130
+ * in parallel with the seeded wants, instead of once per block — and a provider found there
131
+ * (a seeder announcing root/chunk records need not be a topic subscriber) joins the pull if the
132
+ * advertiser drops mid-chase. See DESIGN.md "Block pull".
133
+ */
134
+ const CHASE_SESSION_PROVIDER_HEADROOM = 1;
135
+ /**
136
+ * Bound on the per-contest peer→last-advertised-root map that seeds chase sessions (insertion-
137
+ * refreshed, oldest evicted): enough for any real topic mesh, small enough that a peer-id spray
138
+ * cannot grow memory.
139
+ */
140
+ const PEER_ROOTS_MAX = 256;
141
+ /**
142
+ * How long a gating-chain head read stays fresh (ms) for the gate's freshness guard. Steady-
143
+ * state votes cost no read (they resolve against the cached bucket); only a look-ahead bundle
144
+ * consults the head, and this TTL caps that to ≤1 `getBlockNumber` per window under a flood of
145
+ * future-dated bundles.
146
+ */
147
+ const HEAD_BUCKET_TTL_MS = 1_000;
148
+ /**
149
+ * The {@link ResolvedDeps.fetchBudget} factory: one `pLimit(limitPerPeer)` per peer id, created on
150
+ * first use and dropped once its queue drains, so a long-lived voter does not accumulate limiters
151
+ * for every peer it ever cold-started against.
152
+ */
153
+ function makePerPeerBudget(limitPerPeer) {
154
+ const limiters = new Map();
155
+ return async (peerId, task) => {
156
+ let limiter = limiters.get(peerId);
157
+ if (limiter === undefined) {
158
+ limiter = pLimit(limitPerPeer);
159
+ limiters.set(peerId, limiter);
160
+ }
161
+ try {
162
+ return await limiter(task);
163
+ }
164
+ finally {
165
+ if (limiter.activeCount === 0 && limiter.pendingCount === 0)
166
+ limiters.delete(peerId);
167
+ }
168
+ };
169
+ }
170
+ /** Fisher–Yates copy-shuffle (cold-start peer selection; see `#coldStart`). */
171
+ function shuffled(items) {
172
+ const out = [...items];
173
+ for (let i = out.length - 1; i > 0; i--) {
174
+ const j = Math.floor(Math.random() * (i + 1));
175
+ const swap = out[i];
176
+ out[i] = out[j];
177
+ out[j] = swap;
178
+ }
179
+ return out;
180
+ }
181
+ /** Lowercase `0x`-hex to bytes (for the bucket boundary block hash). */
182
+ function hexToBytes(hex) {
183
+ const body = hex.startsWith("0x") ? hex.slice(2) : hex;
184
+ const out = new Uint8Array(body.length / 2);
185
+ for (let i = 0; i < out.length; i++)
186
+ out[i] = parseInt(body.slice(i * 2, i * 2 + 2), 16);
187
+ return out;
188
+ }
189
+ /**
190
+ * One contest's engine: joins the topic behind the validate-before-forward gate, keeps the CRDT in
191
+ * sync (live gossip + cold-start + chase + heartbeat), computes the tally, and signs/broadcasts this
192
+ * wallet's ballots. Internal — the public {@link Contest} and {@link ContestVote} are thin views
193
+ * over one shared engine per topic. It does NOT keep votes alive: publishing is one-shot and the
194
+ * client decides when to refresh (see DESIGN.md "Republishing is the client's job").
195
+ */
196
+ class ContestEngine {
197
+ criteria;
198
+ topic;
199
+ readOnly;
200
+ #deps;
201
+ /** Chain clients for this contest, built from `criteria.requires.chains` via the factory. */
202
+ #chainClients;
203
+ #criteriaCid;
204
+ /** The gating (`rule`) chain's numeric chainId, bound into every ballot signature. */
205
+ #chainId;
206
+ /** The gating (`rule`) chain client, also the seed chain for the tally's tie-break block hash. */
207
+ #ruleChain;
208
+ #bucketMath;
209
+ #crdt;
210
+ #tally;
211
+ /** Live once joined; the gate + gossip wiring for this topic. */
212
+ #transport;
213
+ /** True between `join()` and `leave()`; makes both idempotent so views and ballots compose. */
214
+ #joined = false;
215
+ /**
216
+ * True once the owning voter was `destroy()`ed. Terminal: {@link join} then throws, so no view
217
+ * or publication over this engine can go live again (see DESIGN.md / `VoterDestroyedError`).
218
+ */
219
+ #destroyed = false;
220
+ /** Subscribers to state changes; a Contest view registers one of each. Tally recompute is gated on these. */
221
+ #updateListeners = [];
222
+ #errorListeners = [];
223
+ /** The last computed tally, exposed as `Contest.tally`. */
224
+ #cachedTally;
225
+ /** Coalescing flags for the background tally recompute (one recompute per gossip burst). */
226
+ #tallyDirty = true;
227
+ #tallyRefreshing = false;
228
+ /**
229
+ * Last-known current bucket on the gating chain, refreshed by {@link #refreshBucket} on
230
+ * every chain read the engine already does (join, publish, tally). The CRDT's read-time
231
+ * expiry filter (`current`) reads it, so decayed votes drop without a per-read chain call.
232
+ */
233
+ #currentBucketCache = 0;
234
+ /**
235
+ * The on-demand checkpoint cache: the last encoded root record and the bucket it was encoded
236
+ * at. Invalidated by {@link #markStateChanged} (any merge/publish/chase admit) and by a bucket
237
+ * advance (expiry changes the winner set without any message). See DESIGN.md "Checkpoints".
238
+ */
239
+ #rootRecordCache = undefined;
240
+ /** True when the winner-set changed since the last encode; the next `rootRecord()` re-encodes. */
241
+ #checkpointDirty = true;
242
+ /** Live once joined; chases advertised roots that differ from our own. */
243
+ #chaser;
244
+ /**
245
+ * Peer id → last root that peer advertised (heartbeat, divergence response, or cold-start
246
+ * pull), bounded by {@link PEER_ROOTS_MAX}. Chasing root R seeds its bitswap session with
247
+ * every still-connected peer whose entry is R — the subscribers who provably converged on
248
+ * the state being pulled — not just the hint's sender. See DESIGN.md "Block pull".
249
+ */
250
+ #peerRoots = new Map();
251
+ /** The armed heartbeat timer (jittered; see {@link #armHeartbeat}), cleared by `leave()`. */
252
+ #heartbeatTimer;
253
+ /** True when a heartbeat matching our own root was heard this interval (suppression). */
254
+ #heardMatchingRoot = false;
255
+ /** True once we published our record this interval (heartbeat OR divergence response). */
256
+ #publishedRootThisInterval = false;
257
+ constructor(criteria, topic, criteriaCidBytes, deps) {
258
+ this.criteria = criteria;
259
+ this.topic = topic;
260
+ this.readOnly = deps.signer === undefined;
261
+ this.#deps = deps;
262
+ this.#criteriaCid = criteriaCidBytes;
263
+ this.#chainClients = Object.fromEntries(Object.entries(criteria.requires.chains).map(([chain, config]) => [chain, deps.chains({ chain, config })]));
264
+ // The gating (`rule`) chain fixes the ballot's chainId and the tie-break seed chain.
265
+ const rule = deps.registry[criteria.rule.type];
266
+ if (!rule)
267
+ throw new UnknownRuleError("rule", criteria.rule.type);
268
+ const ruleTicker = tickerForRef(criteria, criteria.rule, rule.optionsSchema.parse(criteria.rule));
269
+ const ruleChain = this.#chainClients[ruleTicker];
270
+ if (!ruleChain)
271
+ throw new Error(`no chain client for gating (\`rule\`) chain "${ruleTicker}"`);
272
+ this.#ruleChain = ruleChain;
273
+ this.#chainId = criteria.requires.chains[ruleTicker].chainId;
274
+ this.#bucketMath = makeBucketMath(criteria.blocksPerBucket);
275
+ const store = makeBlockstoreBundleStore(deps.blockstore);
276
+ // The CRDT keeps a superseded bundle alive while its superseder's deferred checks are
277
+ // pending — the fallback winner if the background verifier evicts the newer bundle.
278
+ this.#crdt = makeVoteCrdt({
279
+ store,
280
+ bucketMath: this.#bucketMath,
281
+ voteExpiryBuckets: criteria.voteExpiryBuckets,
282
+ isProvisional: (cid) => this.#isPending(cid)
283
+ });
284
+ // One gate-result cache shared between the inline forward-gate verifier and the
285
+ // background chain verifier, so neither re-reads a (wallet, sampleBlock) the other
286
+ // settled — layered over the voter's persistent store, keyed under this contest's rule
287
+ // hash: the gate score is a pure function of (rule, chainId, wallet, sampleBlock), so
288
+ // hashing the canonical rule document + chainId is exactly the sharing boundary (two
289
+ // contests over one gate share reads; different gates cannot collide).
290
+ this.#ruleHash = sha256(encodeDagCbor({ chainId: this.#chainId, rule: criteria.rule }));
291
+ const gateResultCache = makePersistentGateResultCache({ store: deps.gateStore, ruleHash: this.#ruleHash });
292
+ const verifier = makeBundleVerifier({
293
+ criteria,
294
+ criteriaCid: criteriaCidBytes,
295
+ chainId: this.#chainId,
296
+ registry: deps.registry,
297
+ chainFor: (ticker) => this.#chainFor(ticker),
298
+ bucketMath: this.#bucketMath,
299
+ nameResolvers: deps.nameResolvers,
300
+ gateResultCache,
301
+ nameResolutionCache: deps.nameResolutionCache
302
+ });
303
+ // The gate/transport are (re)built on join(); the store, crdt, caches, verifier, and
304
+ // background verifier are stable per contest, so they survive re-joins of the topic.
305
+ this.#store = store;
306
+ this.#cache = makeVerdictCache();
307
+ this.#acceptedDedup = makeAcceptedDedup(this.#bucketMath);
308
+ this.#verifier = verifier;
309
+ this.#background = makeBackgroundVerifier({
310
+ criteria,
311
+ registry: deps.registry,
312
+ chainFor: (ticker) => this.#chainFor(ticker),
313
+ bucketMath: this.#bucketMath,
314
+ nameResolvers: deps.nameResolvers,
315
+ gateResultCache,
316
+ nameResolutionCache: deps.nameResolutionCache,
317
+ cache: this.#cache,
318
+ onGateVerified: (cid) => this.#settleCheck(cid, "chainVerified"),
319
+ onNameResolved: (cid) => this.#settleCheck(cid, "nameResolved"),
320
+ onEvict: (cid) => this.#evictBundle(cid),
321
+ onError: (error) => this.#emitError(error),
322
+ limit: (fn) => this.#backgroundLimit(fn)
323
+ });
324
+ this.#tally = makeTally({
325
+ criteria,
326
+ registry: deps.registry,
327
+ chainFor: (ticker) => this.#chainFor(ticker),
328
+ bucketMath: this.#bucketMath,
329
+ current: () => this.#crdt
330
+ .currentEntries(this.#currentBucketCache)
331
+ .map(({ cid, bundle }) => ({ bundle, checks: this.#checksFor(cid, bundle) })),
332
+ bucketBlockHash: () => this.#bucketBlockHash()
333
+ });
334
+ }
335
+ #store;
336
+ /** Hash of the canonical gate rule + chainId — this contest's keyspace in the shared gate store. */
337
+ #ruleHash;
338
+ #cache;
339
+ #acceptedDedup;
340
+ #verifier;
341
+ /** Deferred network checks for provisionally admitted bundles (see verify/background.ts). */
342
+ #background;
343
+ /** Bounds the background verifier's un-batched RPC fallbacks (per-wallet reads, name lookups). */
344
+ #backgroundLimit = pLimit(GATE_CONCURRENCY);
345
+ /**
346
+ * Per-bundle deferred-check state, keyed by bundle CID string. Written at every admit
347
+ * (settled for a gate-verified live bundle or a cached-verdict hit; pending for a chased
348
+ * checkpoint bundle or this wallet's own publish), flipped by the background verifier's
349
+ * settlements, dropped on evict/prune. The tally folds it into each row's
350
+ * `chainVerified` / `nameResolved`, and the checkpoint encoder serves only fully
351
+ * settled bundles (never re-serve what we have not verified).
352
+ */
353
+ #checks = new Map();
354
+ /** Does any vote in the bundle carry a `community.name` claim (needing resolution)? */
355
+ #carriesName(bundle) {
356
+ return bundle.votes.some((v) => v.community.name !== undefined);
357
+ }
358
+ /** Record a bundle's deferred-check state at admit: fully settled, or pending both checks. */
359
+ #recordChecks(cid, bundle, settled) {
360
+ this.#checks.set(cid.toString(), this.#carriesName(bundle) ? { chainVerified: settled, nameResolved: settled } : { chainVerified: settled });
361
+ }
362
+ /** The bundle's check state, pessimistic (all pending) if somehow unrecorded. */
363
+ #checksFor(cid, bundle) {
364
+ return (this.#checks.get(cid.toString()) ??
365
+ (this.#carriesName(bundle) ? { chainVerified: false, nameResolved: false } : { chainVerified: false }));
366
+ }
367
+ /** Admitted but at least one deferred network check unsettled (the CRDT's prune shield). */
368
+ #isPending(cid) {
369
+ const checks = this.#checks.get(cid.toString());
370
+ return checks !== undefined && (!checks.chainVerified || checks.nameResolved === false);
371
+ }
372
+ /** Every deferred check settled — the bundle may be served in our checkpoint. */
373
+ #isFullyVerified(cid) {
374
+ const checks = this.#checks.get(cid.toString());
375
+ return checks !== undefined && checks.chainVerified && checks.nameResolved !== false;
376
+ }
377
+ /** A background check confirmed: flip the flag, re-encode the checkpoint, recount the tally. */
378
+ #settleCheck(cid, key) {
379
+ const checks = this.#checks.get(cid.toString());
380
+ if (!checks)
381
+ return; // evicted or pruned while its check was in flight
382
+ checks[key] = true;
383
+ this.#onStateChanged();
384
+ }
385
+ /** A deferred check failed: drop the bundle (its verified predecessor, if any, wins again). */
386
+ #evictBundle(cid) {
387
+ this.#crdt.remove(cid);
388
+ this.#checks.delete(cid.toString());
389
+ this.#onStateChanged();
390
+ }
391
+ #emitError(error) {
392
+ for (const cb of [...this.#errorListeners])
393
+ cb(error);
394
+ }
395
+ #chainFor(ticker) {
396
+ const client = this.#chainClients[ticker];
397
+ if (!client)
398
+ throw new Error(`no chain client configured for chain "${ticker}"`);
399
+ return client;
400
+ }
401
+ /** Read the gating-chain head and update {@link #currentBucketCache}; returns the bucket. */
402
+ async #refreshBucket() {
403
+ const head = await this.#ruleChain.getBlockNumber();
404
+ this.#currentBucketCache = this.#bucketMath.bucketForBlock(Number(head));
405
+ this.#headReadMs = Date.now();
406
+ this.#maybePurgeGateResults();
407
+ return this.#currentBucketCache;
408
+ }
409
+ /** The last purge's expiry boundary (oldest admissible sample block); 0 = never purged. */
410
+ #purgedSampleBlock = 0;
411
+ /**
412
+ * Drop this rule's persisted gate results older than the oldest admissible sample block —
413
+ * provably dead: a score at bucket B is only ever consulted while bundles from B are within
414
+ * `voteExpiryBuckets` of head (see verify/gate-result-cache.ts `purgeExpiredGateResults`).
415
+ * Piggybacks on the head reads the engine does anyway (join-with-state, publish, tally)
416
+ * and re-runs only when the boundary advances past the last purged one — so an idle
417
+ * engine costs no chain read and no purge, and a steady head costs no key scan, but a
418
+ * long-lived engine still sheds entries as they expire instead of leaving them to the
419
+ * LRU backstop. Fire-and-forget by design.
420
+ */
421
+ #maybePurgeGateResults() {
422
+ const oldestBucket = this.#currentBucketCache - this.criteria.voteExpiryBuckets;
423
+ if (oldestBucket <= 0)
424
+ return;
425
+ const oldestSampleBlock = this.#bucketMath.sampleBlockForBucket(oldestBucket);
426
+ if (oldestSampleBlock <= this.#purgedSampleBlock)
427
+ return;
428
+ this.#purgedSampleBlock = oldestSampleBlock;
429
+ void purgeExpiredGateResults({
430
+ store: this.#deps.gateStore,
431
+ ruleHash: this.#ruleHash,
432
+ oldestSampleBlock
433
+ });
434
+ }
435
+ /** `Date.now()` of the last gating-chain head read, memoizing {@link #nowBucket}. */
436
+ #headReadMs = 0;
437
+ /** The current gating-chain head bucket, memoized for {@link HEAD_BUCKET_TTL_MS}. */
438
+ async #nowBucket() {
439
+ if (this.#headReadMs !== 0 && Date.now() - this.#headReadMs < HEAD_BUCKET_TTL_MS) {
440
+ return this.#currentBucketCache;
441
+ }
442
+ return this.#refreshBucket();
443
+ }
444
+ /**
445
+ * Is this bundle's bucket sample block already reachable from our gating-chain head? A
446
+ * bundle dated to a future bucket (the voter's head ahead of ours, clock skew, or an absurd
447
+ * `blockNumber`) is transiently not-yet-evaluable, so the gate `ignore`s it (no penalty,
448
+ * uncached) until our head advances.
449
+ */
450
+ async #isEvaluableNow(bundle) {
451
+ const sampleBucket = this.#bucketMath.bucketForBlock(bundle.blockNumber);
452
+ if (sampleBucket <= this.#currentBucketCache)
453
+ return true;
454
+ return sampleBucket <= (await this.#nowBucket());
455
+ }
456
+ /** Hash of the current bucket boundary block on the gating (`rule`) chain (rolling tie seed). */
457
+ async #bucketBlockHash() {
458
+ const head = await this.#ruleChain.getBlockNumber();
459
+ const boundary = this.#bucketMath.sampleBlockForBucket(this.#bucketMath.bucketForBlock(Number(head)));
460
+ const block = await this.#ruleChain.getBlock({ blockNumber: BigInt(boundary) });
461
+ if (!block.hash)
462
+ throw new Error(`bucket boundary block ${boundary} has no hash`);
463
+ return hexToBytes(block.hash);
464
+ }
465
+ // ---- tally cache + reactive update/error listeners (drive the Contest view) ----
466
+ /** The last computed tally, or `undefined` before the first compute. */
467
+ get cachedTally() {
468
+ return this.#cachedTally;
469
+ }
470
+ addUpdateListener(cb) {
471
+ this.#updateListeners.push(cb);
472
+ }
473
+ removeUpdateListener(cb) {
474
+ const i = this.#updateListeners.indexOf(cb);
475
+ if (i >= 0)
476
+ this.#updateListeners.splice(i, 1);
477
+ }
478
+ addErrorListener(cb) {
479
+ this.#errorListeners.push(cb);
480
+ }
481
+ removeErrorListener(cb) {
482
+ const i = this.#errorListeners.indexOf(cb);
483
+ if (i >= 0)
484
+ this.#errorListeners.splice(i, 1);
485
+ }
486
+ /** Compute the current ranking fresh (refreshing the bucket + pruning when state is present). */
487
+ async computeTally() {
488
+ // With state present, refresh the bucket so the tally's `current()` filters expiry against
489
+ // the live block, then prune the now-decayed nodes (dropping their check state with them).
490
+ // Empty state needs neither, so an empty tally reads no chain (the constant-weight "zero
491
+ // chain reads" property).
492
+ if (this.#crdt.nodeCount() > 0) {
493
+ await this.#refreshBucket();
494
+ for (const removed of await this.#crdt.prune(this.#currentBucketCache)) {
495
+ this.#checks.delete(removed.toString());
496
+ }
497
+ }
498
+ return this.#tally.compute();
499
+ }
500
+ /** Compute the tally once, cache it, and emit `update`; on failure emit `error` instead. */
501
+ async #computeAndEmit() {
502
+ this.#tallyDirty = false;
503
+ let tally;
504
+ try {
505
+ tally = await this.computeTally();
506
+ }
507
+ catch (error) {
508
+ this.#emitError(error);
509
+ return;
510
+ }
511
+ this.#cachedTally = tally;
512
+ for (const cb of [...this.#updateListeners])
513
+ cb();
514
+ }
515
+ /** Force one recompute + emit now (used by a view's `update()` for the initial tally). */
516
+ async refreshTallyNow() {
517
+ await this.#computeAndEmit();
518
+ }
519
+ /**
520
+ * Kick a coalesced background tally recompute. Only runs while a Contest view is subscribed, so
521
+ * an unobserved contest (or `start()` with no read views) does no tally chain reads; the burst
522
+ * loop collapses many state changes into as few recomputes as the compute latency allows.
523
+ */
524
+ #kickTallyRefresh() {
525
+ this.#tallyDirty = true;
526
+ if (this.#tallyRefreshing || this.#updateListeners.length === 0)
527
+ return;
528
+ this.#tallyRefreshing = true;
529
+ void (async () => {
530
+ try {
531
+ while (this.#tallyDirty && this.#updateListeners.length > 0)
532
+ await this.#computeAndEmit();
533
+ }
534
+ finally {
535
+ this.#tallyRefreshing = false;
536
+ }
537
+ })();
538
+ }
539
+ /** True while joined to the topic (between `join()` and `leave()`); gates the fetch responder. */
540
+ get joined() {
541
+ return this.#joined;
542
+ }
543
+ /** Any admit (gossip accept, chase merge, local publish) changed the winner set. */
544
+ #onStateChanged() {
545
+ this.#markStateChanged();
546
+ this.#kickTallyRefresh();
547
+ }
548
+ /** Mark this engine terminal: a subsequent {@link join} (update/publish) throws. Sync; leave separately. */
549
+ markDestroyed() {
550
+ this.#destroyed = true;
551
+ }
552
+ async join() {
553
+ if (this.#destroyed)
554
+ throw new VoterDestroyedError();
555
+ if (this.#joined)
556
+ return;
557
+ const limit = pLimit(GATE_CONCURRENCY);
558
+ const gate = makeGossipGate({
559
+ decodeMessage: decodeVoteMessage,
560
+ parseBundle: async (blockBytes) => ({
561
+ cid: await bundleCidForBytes(blockBytes),
562
+ bundle: decodeBundle(blockBytes)
563
+ }),
564
+ verifier: this.#verifier,
565
+ isEvaluableNow: (bundle) => this.#isEvaluableNow(bundle),
566
+ cache: this.#cache,
567
+ acceptedDedup: this.#acceptedDedup,
568
+ // Store the sender's exact block bytes (byte-identity with its CID), then merge.
569
+ // The forward-gate ran the FULL pipeline inline, so the checks arrive settled.
570
+ admit: async ({ cid, bytes, bundle }) => {
571
+ await this.#deps.blockstore.put(cid, bytes);
572
+ await this.#crdt.merge([cid]);
573
+ this.#recordChecks(cid, bundle, true);
574
+ },
575
+ limit: (fn) => limit(fn),
576
+ allowBundlePeer: makeRateLimiter(GATE_RATE),
577
+ allowRootPeer: makeRateLimiter(GATE_ROOT_RATE),
578
+ onAccept: () => this.#onStateChanged(),
579
+ // Root records surface as unverifiable hints: compare to our own root, chase a
580
+ // divergence lazily, answer it once per interval. Never awaited by the validator.
581
+ // `from` seeds the chase's bitswap session — the sender provably holds the
582
+ // advertised root's blocks (see DESIGN.md "Block pull").
583
+ onRootRecord: (record, from) => {
584
+ void this.#handleRootRecord(record, from).catch(() => { });
585
+ },
586
+ maxBundleMessageBytes: maxBundleMessageBytes(this.criteria),
587
+ maxRootMessageBytes: MAX_ROOT_MESSAGE_BYTES,
588
+ timeoutMs: GATE_TIMEOUT_MS
589
+ });
590
+ const chaseLimit = pLimit(CHASE_CONCURRENCY);
591
+ this.#chaser = makeRootChaser({
592
+ // The broadcast fallback: a plain want that any connected topic peer can answer.
593
+ // The seeded session below is tried first when the blockstore can make one.
594
+ getBlock: async (cid, signal) => {
595
+ try {
596
+ return await this.#deps.blockstore.get(cid, { signal });
597
+ }
598
+ catch {
599
+ return undefined;
600
+ }
601
+ },
602
+ // A directed session per chased root, seeded with its advertisers: wants go to the
603
+ // peers that provably hold the blocks instead of every connection, and the routers
604
+ // are queried once per root (the headroom slot) instead of once per block. Absent
605
+ // `createSession` (a plain blockstore) declines, and the chase broadcasts as before.
606
+ openSession: (root, providers) => {
607
+ const createSession = this.#deps.blockstore.createSession?.bind(this.#deps.blockstore);
608
+ if (createSession === undefined)
609
+ return undefined;
610
+ // `toChaseSession` enforces the ChaseSession never-throw contracts on the raw session.
611
+ return toChaseSession(createSession(root, {
612
+ providers,
613
+ maxProviders: providers.length + CHASE_SESSION_PROVIDER_HEADROOM
614
+ }));
615
+ },
616
+ verifyOffline: (bundle) => this.#verifier.verifyOffline(bundle),
617
+ cache: this.#cache,
618
+ isEvaluableNow: (bundle) => this.#isEvaluableNow(bundle),
619
+ hasBundle: (cid) => this.#store.has(cid),
620
+ // `verified: false` is a provisional admit (offline checks only) whose deferred gate
621
+ // read + name resolution ride `deferVerify`; `true` means a cached terminal verdict
622
+ // already covers the full pipeline.
623
+ admit: async ({ cid, bytes, bundle, verified }) => {
624
+ await this.#deps.blockstore.put(cid, bytes);
625
+ await this.#crdt.merge([cid]);
626
+ this.#recordChecks(cid, bundle, verified);
627
+ this.#markStateChanged();
628
+ },
629
+ deferVerify: (entries) => this.#background.enqueue(entries),
630
+ onMerged: () => this.#onStateChanged(),
631
+ limit: (fn) => chaseLimit(fn),
632
+ timeoutMs: CHASE_TIMEOUT_MS
633
+ });
634
+ this.#transport = makeVoteTransport({
635
+ pubsub: this.#deps.pubsub,
636
+ topic: this.topic,
637
+ gate
638
+ });
639
+ await this.#transport.start();
640
+ this.#joined = true;
641
+ // Re-kick any deferred checks a previous leave() paused (their bundles are still pending).
642
+ this.#background.resume();
643
+ // Notify the voter of the real join transition (drives lazy responder registration):
644
+ // a node that participates in a topic serves its root record, symmetric with the
645
+ // heartbeat it broadcasts there.
646
+ this.#deps.onTopicJoined();
647
+ this.#armHeartbeat();
648
+ // Cold-start / reconnect pull: ask connected topic peers for their root records and
649
+ // chase any divergence. Fire-and-forget — joining must not block on slow peers, and
650
+ // live gossip plus the heartbeat converge regardless; this only shortens the gap.
651
+ void this.#coldStart().catch(() => { });
652
+ // If a re-join left state behind, refresh the bucket and prune the decayed nodes. Gated
653
+ // on non-empty state so an empty join stays network-free (no getBlockNumber read),
654
+ // preserving the "zero chain reads for a constant-weight tally" property.
655
+ if (this.#crdt.nodeCount() > 0) {
656
+ await this.#refreshBucket();
657
+ await this.#crdt.prune(this.#currentBucketCache);
658
+ }
659
+ }
660
+ /**
661
+ * The cold-join pull (DESIGN.md "Checkpoints"): ask up to {@link COLD_START_PEERS} peers, over
662
+ * the libp2p **fetch protocol**, for their current root record, and chase every root that
663
+ * differs from our own. Peers come from **two sources raced concurrently, neither blocking the
664
+ * other**: a random {@link COLD_START_PEERS} of the gossipsub subscribers of this topic, and
665
+ * the providers of the criteria CID from the host's HTTP content router. Roots are **unioned,
666
+ * never quorum'd** — a record served by a single peer is still chased, so a colluding majority
667
+ * cannot hide a vote.
668
+ */
669
+ async #coldStart() {
670
+ const seen = new Set();
671
+ const selfId = this.#deps.helia.libp2p.peerId?.toString();
672
+ // Encode our own root only when a peer actually returns one to compare against, so an empty
673
+ // join (no subscribers, no providers) does no checkpoint work.
674
+ let ownRoot;
675
+ const pull = async (peer) => {
676
+ const id = peer.toString();
677
+ if (id === selfId || seen.has(id))
678
+ return; // skip self and any peer already asked
679
+ seen.add(id);
680
+ try {
681
+ const value = await this.#fetchRootWithRetry(peer);
682
+ if (value === undefined || value === null)
683
+ return;
684
+ const record = decodeRootRecord(value); // throws on garbage — caught, contributes nothing
685
+ const own = await (ownRoot ??= this.rootRecord());
686
+ this.#notePeerRoot(id, record.root);
687
+ // Hand the piggybacked chunk index to the chase: verified against `record.root`, it
688
+ // skips the root-manifest bitswap round-trip (see DESIGN.md "Block pull"). The
689
+ // pulled peer seeds the chase's session — it provably holds what it just served.
690
+ if (!record.root.equals(own.root)) {
691
+ this.#chaser?.chase(record.root, record.chunks, this.#sessionProvidersFor(record.root, peer));
692
+ }
693
+ }
694
+ catch {
695
+ // Peer offline, no record, or malformed answer — best-effort; the other source and
696
+ // live gossip still converge.
697
+ }
698
+ };
699
+ // Shuffle before slicing: a deterministic first-N pick would funnel a whole directory
700
+ // join through the same peers' stream caps while other subscribers idle; a random N
701
+ // spreads contests across the topic's serving peers (see COLD_START_PEER_FETCH_LIMIT).
702
+ const fromSubscribers = shuffled(this.#deps.pubsub.getSubscribers(this.topic)).slice(0, COLD_START_PEERS).map(pull);
703
+ await Promise.allSettled([...fromSubscribers, this.#discoverProviders(pull)]);
704
+ }
705
+ /**
706
+ * Pull one peer's root record over the fetch protocol, retrying a THROWN fetch with full-jittered
707
+ * exponential backoff until {@link COLD_START_FETCH_DEADLINE_MS} (see the constant's note for the
708
+ * measured seeder-reset failure this rides out). A *definitive* answer never retries; bails if the
709
+ * contest was left mid-retry (`#chaser` is cleared by `leave()`). Each attempt — not the whole
710
+ * retry loop, so a backoff sleep never holds a slot — passes through the voter-wide per-peer
711
+ * budget, which keeps our own concurrent streams to this peer under its inbound cap; queue wait
712
+ * counts against the same deadline.
713
+ */
714
+ async #fetchRootWithRetry(peer) {
715
+ const deadline = Date.now() + COLD_START_FETCH_DEADLINE_MS;
716
+ let lastError;
717
+ for (let attempt = 0;; attempt++) {
718
+ if (attempt > 0) {
719
+ if (this.#chaser === undefined || Date.now() >= deadline)
720
+ break; // left or out of time
721
+ const ceiling = Math.min(COLD_START_FETCH_BACKOFF_CAP_MS, COLD_START_FETCH_BACKOFF_MS * 2 ** (attempt - 1));
722
+ await new Promise((resolve) => setTimeout(resolve, Math.random() * ceiling));
723
+ if (this.#chaser === undefined)
724
+ return undefined; // left mid-backoff — abandon quietly
725
+ }
726
+ try {
727
+ return await this.#deps.fetchBudget(peer.toString(), () => this.#deps.fetch.fetch(peer, rootFetchKey(this.topic)));
728
+ }
729
+ catch (error) {
730
+ lastError = error; // transient (e.g. seeder over its inbound-stream cap) — back off and retry
731
+ }
732
+ }
733
+ throw lastError;
734
+ }
735
+ /**
736
+ * Cold-join discovery source 2: ask the injected node's HTTP content router(s) who provides the
737
+ * criteria CID (`libp2p.contentRouting.findProviders`), dial each provider, and hand it to
738
+ * `pull`. This is the pkc-js peer-discovery pattern — delegated Routing V1 over HTTP, no DHT.
739
+ * Best-effort and bounded: a node with no content router, a router error, or an undialable
740
+ * provider contributes nothing and never throws.
741
+ */
742
+ async #discoverProviders(pull) {
743
+ const libp2p = this.#deps.helia.libp2p;
744
+ const contentRouting = libp2p.contentRouting;
745
+ if (contentRouting === undefined)
746
+ return; // the injected node carries no content router
747
+ let cid;
748
+ try {
749
+ cid = CID.decode(this.#criteriaCid);
750
+ }
751
+ catch {
752
+ return;
753
+ }
754
+ const controller = new AbortController();
755
+ const timer = setTimeout(() => controller.abort(), COLD_START_ROUTER_TIMEOUT_MS);
756
+ timer.unref?.();
757
+ // `@libp2p/interface` bundles its own multiformats copy, so its `CID` is nominally distinct
758
+ // from ours despite identical bytes; bridge the two at this one boundary call.
759
+ const routingCid = cid;
760
+ try {
761
+ const dials = [];
762
+ let count = 0;
763
+ for await (const provider of contentRouting.findProviders(routingCid, { signal: controller.signal })) {
764
+ if (count >= COLD_START_PEERS)
765
+ break;
766
+ count += 1;
767
+ dials.push((async () => {
768
+ try {
769
+ if (provider.multiaddrs.length > 0) {
770
+ await libp2p.dial(provider.multiaddrs, { signal: controller.signal });
771
+ }
772
+ }
773
+ catch {
774
+ // Undialable via its advertised addrs — `pull` still tries (it may be connected).
775
+ }
776
+ await pull(provider.id);
777
+ })());
778
+ }
779
+ await Promise.allSettled(dials);
780
+ }
781
+ catch {
782
+ // Router error or abort — treated as "no providers", mirroring pkc-js's findProviders wrap.
783
+ }
784
+ finally {
785
+ clearTimeout(timer);
786
+ }
787
+ }
788
+ async leave() {
789
+ if (this.#heartbeatTimer !== undefined)
790
+ clearTimeout(this.#heartbeatTimer);
791
+ this.#heartbeatTimer = undefined;
792
+ // Pause the background verifier's retry timer; pending state survives for a re-join.
793
+ this.#background.stop();
794
+ this.#heardMatchingRoot = false;
795
+ this.#publishedRootThisInterval = false;
796
+ this.#chaser = undefined;
797
+ // Advertised roots go stale the moment we stop hearing heartbeats; a re-join re-learns.
798
+ this.#peerRoots.clear();
799
+ const wasJoined = this.#joined;
800
+ this.#joined = false;
801
+ await this.#transport?.stop();
802
+ this.#transport = undefined;
803
+ // Fire once per real transition only: `leave()` is idempotent and also runs on engines
804
+ // that never joined, so the voter's joined-count must not underflow.
805
+ if (wasJoined)
806
+ this.#deps.onTopicLeft();
807
+ }
808
+ /**
809
+ * Sign the votes into a bundle for the current bucket boundary block (the block every verifier
810
+ * reads at), add it to the CRDT, and return the bundle plus its encoded block bytes for
811
+ * broadcast. Throws `ReadOnlyError` with no signer.
812
+ */
813
+ async signVote(votes) {
814
+ const signer = this.#deps.signer;
815
+ if (signer === undefined)
816
+ throw new ReadOnlyError();
817
+ const head = await this.#ruleChain.getBlockNumber();
818
+ const bucket = this.#bucketMath.bucketForBlock(Number(head));
819
+ this.#currentBucketCache = bucket;
820
+ const blockNumber = this.#bucketMath.sampleBlockForBucket(bucket);
821
+ const typedData = ballotTypedData({ criteriaCid: this.#criteriaCid, chainId: this.#chainId, votes, blockNumber });
822
+ const signature = await signer.signBallot(typedData);
823
+ const address = await signer.address();
824
+ const bundle = VotesBundleSchema.parse({ address, votes, blockNumber, signature });
825
+ const cid = await this.#crdt.add(bundle);
826
+ // Own bundles take the same deferred path as a chased checkpoint's: admitted
827
+ // provisionally, then confirmed (or evicted) by the background gate read — so an
828
+ // ineligible wallet's local tally does not silently disagree with the network's, and
829
+ // our checkpoint never serves a vote we have not verified (even our own).
830
+ this.#recordChecks(cid, bundle, false);
831
+ this.#background.enqueue([{ cid, bundle }]);
832
+ this.#onStateChanged();
833
+ return { bundle, encoded: encodeBundle(bundle) };
834
+ }
835
+ /**
836
+ * Broadcast an encoded bundle inline as a live delta (this wallet's own delta, never the set).
837
+ * Returns how many peers gossipsub sent it directly to (0 if there is no live transport).
838
+ */
839
+ async broadcastBundle(encoded) {
840
+ return (await this.#transport?.publishBundle(encoded)) ?? { recipientCount: 0 };
841
+ }
842
+ /** Invalidate the on-demand checkpoint cache: the winner-set changed (publish/merge/chase). */
843
+ #markStateChanged() {
844
+ this.#checkpointDirty = true;
845
+ // The same transition the encode cache invalidates on is what makes router provider
846
+ // records stale, so the announcer's debounced re-announce rides it (see ResolvedDeps).
847
+ this.#deps.onCheckpointChanged();
848
+ }
849
+ /**
850
+ * The contest's current root record, encoded **on demand** and cached until the winner-set
851
+ * changes ({@link #markStateChanged}) or the bucket advances (expiry changes the set with no
852
+ * message). Each encode writes its blocks to the blockstore — content-addressed and
853
+ * idempotent — so directed bitswap can serve them to a chasing peer. Served by the fetch
854
+ * responder and heartbeated on the topic; there is NO cut cadence (see DESIGN.md
855
+ * "Checkpoints", "On-demand encode").
856
+ */
857
+ async rootRecord() {
858
+ const bucket = this.#currentBucketCache;
859
+ const cached = this.#rootRecordCache;
860
+ if (!this.#checkpointDirty && cached !== undefined && cached.bucket === bucket)
861
+ return cached.record;
862
+ // Serve only fully verified bundles: a provisional admit must not propagate through our
863
+ // checkpoint, and the eligibility-filtered LWW reduction falls back to a wallet's newest
864
+ // VERIFIED bundle when its newest overall is still pending (see crdt/types.ts).
865
+ //
866
+ // Consume the dirty flag HERE, in the same synchronous window as the winner snapshot —
867
+ // not after the encode. The encode below awaits, and a state change landing mid-encode
868
+ // (e.g. a background settlement) re-dirties the flag; clearing it after the awaits would
869
+ // silently clobber that invalidation and pin this record until the next state change.
870
+ this.#checkpointDirty = false;
871
+ const winners = this.#crdt.currentEntries(bucket, (cid) => this.#isFullyVerified(cid)).map((e) => e.bundle);
872
+ const { root, chunks, blocks } = await encodeCheckpoint(winners);
873
+ for (const block of blocks)
874
+ await this.#deps.blockstore.put(block.cid, block.bytes);
875
+ const record = {
876
+ version: ROOT_RECORD_VERSION,
877
+ root,
878
+ // The chunk-CID index rides the fetch-protocol response so a cold joiner skips the
879
+ // root-manifest bitswap round-trip (see DESIGN.md "Block pull"). Stripped from the
880
+ // pubsub heartbeat by `encodeRootMessage`, which keeps that message constant-size.
881
+ chunks,
882
+ count: winners.length,
883
+ sizeBytes: blocks.reduce((total, block) => total + block.bytes.length, 0)
884
+ };
885
+ this.#rootRecordCache = { record, bucket };
886
+ return record;
887
+ }
888
+ /**
889
+ * The root CID of this contest's current checkpoint, or `undefined` before the first
890
+ * encode. The blocks it references are in the blockstore.
891
+ */
892
+ latestCheckpointRoot() {
893
+ return this.#rootRecordCache?.record.root;
894
+ }
895
+ /**
896
+ * Handle a heard root record (an unverifiable hint, surfaced by the gate at layer 1):
897
+ * matching our own root ⇒ note it for heartbeat suppression; differing ⇒ chase it lazily and
898
+ * answer with our own record at most once per interval. See DESIGN.md "Checkpoints".
899
+ */
900
+ async #handleRootRecord(record, from) {
901
+ if (from !== undefined)
902
+ this.#notePeerRoot(from, record.root);
903
+ const own = await this.rootRecord();
904
+ if (own.root.equals(record.root)) {
905
+ this.#heardMatchingRoot = true;
906
+ return;
907
+ }
908
+ this.#chaser?.chase(record.root, undefined, this.#sessionProvidersFor(record.root));
909
+ if (!this.#publishedRootThisInterval) {
910
+ this.#publishedRootThisInterval = true;
911
+ await this.#transport?.publishRootRecord(own);
912
+ }
913
+ }
914
+ /** Note `peerId`'s latest advertised root (see {@link #peerRoots}); refreshes its eviction slot. */
915
+ #notePeerRoot(peerId, root) {
916
+ if (this.#peerRoots.has(peerId)) {
917
+ this.#peerRoots.delete(peerId); // re-insert to refresh insertion order
918
+ }
919
+ else if (this.#peerRoots.size >= PEER_ROOTS_MAX) {
920
+ const oldest = this.#peerRoots.keys().next().value;
921
+ if (oldest !== undefined)
922
+ this.#peerRoots.delete(oldest);
923
+ }
924
+ this.#peerRoots.set(peerId, root.toString());
925
+ }
926
+ /**
927
+ * The session seeds for chasing `root`: every still-connected peer whose last advertised
928
+ * root is exactly `root` (see {@link #peerRoots}) — resolved against the node's live
929
+ * connections both to recover the `PeerId` handle (the gate surfaces senders as strings)
930
+ * and because seeding a gone peer is a wasted dial. `known` (the cold-start pull peer,
931
+ * already in hand as a `PeerId`) is included unconditionally. Order is deterministic; the
932
+ * session fans wants across its providers itself.
933
+ */
934
+ #sessionProvidersFor(root, known) {
935
+ const rootKey = root.toString();
936
+ const providers = known !== undefined ? [known] : [];
937
+ const wanted = new Set();
938
+ for (const [peer, advertised] of this.#peerRoots) {
939
+ if (advertised === rootKey && peer !== known?.toString())
940
+ wanted.add(peer);
941
+ }
942
+ if (wanted.size > 0) {
943
+ // Optional call: unit-test fakes inject a bare `libp2p` without connection APIs.
944
+ for (const connection of this.#deps.helia.libp2p.getConnections?.() ?? []) {
945
+ if (!wanted.delete(connection.remotePeer.toString()))
946
+ continue;
947
+ providers.push(connection.remotePeer);
948
+ if (wanted.size === 0)
949
+ break;
950
+ }
951
+ }
952
+ return providers;
953
+ }
954
+ /**
955
+ * Arm the next heartbeat firing: {@link HEARTBEAT_INTERVAL_MS} jittered ±25%, re-armed
956
+ * after each tick. The tick publishes our root record UNLESS this interval already carried
957
+ * it — either a matching heartbeat was heard (suppression) or we already published (the
958
+ * one-per-interval cap, shared with the divergence response).
959
+ */
960
+ #armHeartbeat() {
961
+ const delay = HEARTBEAT_INTERVAL_MS * (0.75 + Math.random() * 0.5);
962
+ const timer = setTimeout(() => {
963
+ void this.#heartbeatTick()
964
+ .catch(() => { }) // transient publish/encode failure — next interval retries
965
+ .finally(() => {
966
+ if (this.#heartbeatTimer !== undefined)
967
+ this.#armHeartbeat();
968
+ });
969
+ }, delay);
970
+ // Don't hold a Node process open; no-op in the browser.
971
+ timer.unref?.();
972
+ this.#heartbeatTimer = timer;
973
+ }
974
+ async #heartbeatTick() {
975
+ const suppressed = this.#heardMatchingRoot || this.#publishedRootThisInterval;
976
+ this.#heardMatchingRoot = false;
977
+ this.#publishedRootThisInterval = false;
978
+ if (suppressed)
979
+ return;
980
+ await this.#transport?.publishRootRecord(await this.rootRecord());
981
+ }
982
+ }
983
+ /** The reactive read view over one contest engine ({@link Contest}). */
984
+ class ContestView {
985
+ #engine;
986
+ #updateCbs = [];
987
+ #errorCbs = [];
988
+ /** True between `update()` and `stop()`: our engine listeners are registered. */
989
+ #subscribed = false;
990
+ #onEngineUpdate = () => {
991
+ for (const cb of [...this.#updateCbs])
992
+ cb();
993
+ };
994
+ #onEngineError = (error) => {
995
+ for (const cb of [...this.#errorCbs])
996
+ cb(error);
997
+ };
998
+ constructor(engine) {
999
+ this.#engine = engine;
1000
+ }
1001
+ get criteria() {
1002
+ return this.#engine.criteria;
1003
+ }
1004
+ get topic() {
1005
+ return this.#engine.topic;
1006
+ }
1007
+ get tally() {
1008
+ return this.#engine.cachedTally;
1009
+ }
1010
+ async update() {
1011
+ if (this.#subscribed)
1012
+ return;
1013
+ await this.#engine.join();
1014
+ this.#engine.addUpdateListener(this.#onEngineUpdate);
1015
+ this.#engine.addErrorListener(this.#onEngineError);
1016
+ this.#subscribed = true;
1017
+ // Populate `tally` and fire an initial `update` for the current state.
1018
+ await this.#engine.refreshTallyNow();
1019
+ }
1020
+ async stop() {
1021
+ if (this.#subscribed) {
1022
+ this.#engine.removeUpdateListener(this.#onEngineUpdate);
1023
+ this.#engine.removeErrorListener(this.#onEngineError);
1024
+ this.#subscribed = false;
1025
+ }
1026
+ await this.#engine.leave();
1027
+ }
1028
+ getTally() {
1029
+ return this.#engine.computeTally();
1030
+ }
1031
+ /**
1032
+ * Internal hook (not part of the {@link Contest} interface): this contest's current checkpoint
1033
+ * root record, encoded on demand. The fetch responder and heartbeat use the engine directly;
1034
+ * this delegate exists so hosts/tests can inspect the checkpoint through the view.
1035
+ */
1036
+ rootRecord() {
1037
+ return this.#engine.rootRecord();
1038
+ }
1039
+ /** Internal hook: the root CID of the last-encoded checkpoint, or `undefined` before the first. */
1040
+ latestCheckpointRoot() {
1041
+ return this.#engine.latestCheckpointRoot();
1042
+ }
1043
+ on(event, cb) {
1044
+ if (event === "update")
1045
+ this.#updateCbs.push(cb);
1046
+ else if (event === "error")
1047
+ this.#errorCbs.push(cb);
1048
+ }
1049
+ }
1050
+ /** One publishable ballot over a contest engine ({@link ContestVote}). */
1051
+ class ContestVotePublication {
1052
+ contestId;
1053
+ votes;
1054
+ #engine;
1055
+ #stateCbs = [];
1056
+ #errorCbs = [];
1057
+ #state = "stopped";
1058
+ #bundle;
1059
+ constructor(engine, votes) {
1060
+ this.#engine = engine;
1061
+ this.contestId = engine.criteria.contestId;
1062
+ this.votes = votes;
1063
+ }
1064
+ get topic() {
1065
+ return this.#engine.topic;
1066
+ }
1067
+ get publishingState() {
1068
+ return this.#state;
1069
+ }
1070
+ get bundle() {
1071
+ return this.#bundle;
1072
+ }
1073
+ #setState(state) {
1074
+ this.#state = state;
1075
+ for (const cb of [...this.#stateCbs])
1076
+ cb(state);
1077
+ }
1078
+ #fail(error) {
1079
+ this.#setState("failed");
1080
+ for (const cb of [...this.#errorCbs])
1081
+ cb(error);
1082
+ }
1083
+ async publish() {
1084
+ // Fail before joining a read-only voter needlessly to the topic.
1085
+ if (this.#engine.readOnly) {
1086
+ const error = new ReadOnlyError();
1087
+ this.#fail(error);
1088
+ throw error;
1089
+ }
1090
+ try {
1091
+ await this.#engine.join();
1092
+ this.#setState("signing");
1093
+ const { bundle, encoded } = await this.#engine.signVote([...this.votes]);
1094
+ this.#bundle = bundle;
1095
+ this.#setState("publishing");
1096
+ const { recipientCount } = await this.#engine.broadcastBundle(encoded);
1097
+ this.#setState("succeeded");
1098
+ return { bundle, recipientCount };
1099
+ }
1100
+ catch (error) {
1101
+ this.#fail(error);
1102
+ throw error;
1103
+ }
1104
+ }
1105
+ on(event, cb) {
1106
+ if (event === "publishingstatechange")
1107
+ this.#stateCbs.push(cb);
1108
+ else if (event === "error")
1109
+ this.#errorCbs.push(cb);
1110
+ }
1111
+ }
1112
+ /**
1113
+ * The default `VoteClient`. Construct with the host-injected seams: a `helia` node (must carry a
1114
+ * gossipsub service, a blockstore, and a libp2p fetch service), a `chains` factory, an optional
1115
+ * `signer`, and optional `nameResolvers` (needed once votes carry community names). Contests are
1116
+ * addressed by their full criteria document at `createContest` / `createContestVote`; the library
1117
+ * has no knowledge of pkc-js or any other host: a host passes its own running Helia node in
1118
+ * directly. Republishing a live vote is the client's concern — see
1119
+ * {@link republishIntervalBuckets} and DESIGN.md "Republishing is the client's job".
1120
+ */
1121
+ export class PubsubVoter {
1122
+ #deps;
1123
+ /** One engine per contest, keyed by topic so byte-identical criteria share one CRDT/transport. */
1124
+ #engines = new Map();
1125
+ /** Cached read views, one per topic (stable per-contest object). */
1126
+ #views = new Map();
1127
+ /** True while the fetch responder is registered, making register/unregister idempotent. */
1128
+ #responderRegistered = false;
1129
+ /**
1130
+ * How many engines are currently joined to their topic. The fetch responder is registered
1131
+ * lazily while this is > 0 (see {@link ResolvedDeps.onTopicJoined}): no constructor work, no
1132
+ * public API — a node serves root records for exactly the contests it participates in.
1133
+ */
1134
+ #joinedEngines = 0;
1135
+ /** True once `destroy()` ran. Terminal: every create path then throws (mirrors pkc-js). */
1136
+ #destroyed = false;
1137
+ /** The voter's persistent caches (see {@link PubsubVoterOptions.dataPath}); closed on destroy. */
1138
+ #storage;
1139
+ /**
1140
+ * The provider-record announcer, present only when {@link PubsubVoterOptions.httpRouterUrls}
1141
+ * names at least one router (and inert in the browser build regardless — see
1142
+ * `src/transport/announce/`). Started with the first joined topic and stopped with the last,
1143
+ * the same transitions that drive the lazy fetch responder: a node announces records for
1144
+ * exactly the contests it participates in, for exactly as long as it participates.
1145
+ */
1146
+ #announcer;
1147
+ constructor(options) {
1148
+ // Fail fast: the node must expose a gossipsub service, a blockstore, and a libp2p
1149
+ // fetch service. Throws MissingPubsubError / MissingBlockstoreError / MissingFetchError
1150
+ // at construction (not a lazy failure on the first publish/fetch) and narrows the handles.
1151
+ const { pubsub, blockstore, fetch } = requireHeliaServices(options.helia);
1152
+ // Persistent caches, opened lazily (no disk is touched until a contest verifies): the
1153
+ // gate-result store and the name-resolution cache, both shared across every contest on
1154
+ // this voter. sqlite under dataPath on Node, IndexedDB in the browser, in-memory for
1155
+ // `dataPath: false` — see src/storage/.
1156
+ this.#storage = makeStorage({ dataPath: options.dataPath });
1157
+ this.#deps = {
1158
+ helia: options.helia,
1159
+ pubsub,
1160
+ blockstore,
1161
+ fetch,
1162
+ // Every chain client is handed out through the read coalescer: pinned-block
1163
+ // `readContract` calls from ALL consumers (every contest, the gossip forward-gate,
1164
+ // the background verifier) merge into shared multicall3 round trips under one
1165
+ // per-client in-flight budget — see src/chain/coalescer.ts.
1166
+ chains: coalescingChainFactory(options.chains),
1167
+ signer: options.signer,
1168
+ registry: resolveRegistry(options.rules),
1169
+ nameResolvers: options.nameResolvers ?? [],
1170
+ onTopicJoined: this.#onTopicJoined,
1171
+ onTopicLeft: this.#onTopicLeft,
1172
+ onCheckpointChanged: () => this.#announcer?.notifyChange(),
1173
+ fetchBudget: makePerPeerBudget(COLD_START_PEER_FETCH_LIMIT),
1174
+ gateStore: this.#storage.openLru({ cacheName: "gate-results", maxItems: GATE_RESULTS_MAX_ITEMS }),
1175
+ nameResolutionCache: makeNameResolutionCache(this.#storage.openLru({ cacheName: "name-resolutions", maxItems: NAME_RESOLUTIONS_MAX_ITEMS }))
1176
+ };
1177
+ // The announcer touches libp2p (peer id, addresses, address events) only when routers are
1178
+ // configured, so a host that never announces pays nothing and injects nothing extra.
1179
+ if (options.httpRouterUrls !== undefined && options.httpRouterUrls.length > 0) {
1180
+ this.#announcer = makeAnnouncer({
1181
+ routerUrls: [...options.httpRouterUrls],
1182
+ libp2p: options.helia.libp2p,
1183
+ keys: this.#announceKeys
1184
+ });
1185
+ }
1186
+ }
1187
+ /**
1188
+ * The CIDs the announcer publishes, collected fresh per tick and batched into one record:
1189
+ * every JOINED contest's criteria CID (the discovery key — a provider record for it means
1190
+ * "I run this contest") plus its current checkpoint root + chunk CIDs (what the chase-time
1191
+ * parallel router lookup finds; converged seeders share identical chunk CIDs, so any of
1192
+ * them can serve a block). `rootRecord()` is the same on-demand, cached encode the fetch
1193
+ * responder serves — an encode failure still announces the criteria key alone.
1194
+ */
1195
+ #announceKeys = async () => {
1196
+ const keys = [];
1197
+ for (const engine of this.#engines.values()) {
1198
+ if (!engine.joined)
1199
+ continue;
1200
+ keys.push(engine.topic.slice(TOPIC_PREFIX.length));
1201
+ try {
1202
+ const record = await engine.rootRecord();
1203
+ keys.push(record.root.toString());
1204
+ for (const chunk of record.chunks)
1205
+ keys.push(chunk.toString());
1206
+ }
1207
+ catch {
1208
+ // Encode failure — best-effort: the criteria CID (the discovery key) still goes out.
1209
+ }
1210
+ }
1211
+ // Contests can share CIDs (e.g. two vote-less contests share the empty-checkpoint root).
1212
+ return [...new Set(keys)];
1213
+ };
1214
+ get readOnly() {
1215
+ return this.#deps.signer === undefined;
1216
+ }
1217
+ /** Guard the create paths after {@link destroy}: a destroyed voter is terminal. */
1218
+ #assertLive() {
1219
+ if (this.#destroyed)
1220
+ throw new VoterDestroyedError();
1221
+ }
1222
+ async createContest(args) {
1223
+ this.#assertLive();
1224
+ const engine = await this.#engineFor(this.#validateCriteria(args.criteria));
1225
+ const existing = this.#views.get(engine.topic);
1226
+ if (existing)
1227
+ return existing;
1228
+ const view = new ContestView(engine);
1229
+ this.#views.set(engine.topic, view);
1230
+ return view;
1231
+ }
1232
+ async createContestVote(args) {
1233
+ this.#assertLive();
1234
+ const engine = await this.#engineFor(this.#validateCriteria(args.criteria));
1235
+ return new ContestVotePublication(engine, args.votes);
1236
+ }
1237
+ /**
1238
+ * Strictly validate one criteria document at the create seam: `CriteriaSchema` (shape,
1239
+ * canonical encodability) plus the rule registry (an unimplemented rule must recuse, not
1240
+ * miscount — `UnknownRuleError`). The parsed result is what gets encoded, so the engine and
1241
+ * the topic always derive from a schema-clean document.
1242
+ */
1243
+ #validateCriteria(input) {
1244
+ const criteria = CriteriaSchema.parse(input);
1245
+ validateCriteriaRules(criteria, this.#deps.registry);
1246
+ return criteria;
1247
+ }
1248
+ /** Build (or return the cached) engine for one already-validated criteria, keyed by topic. */
1249
+ async #engineFor(criteria) {
1250
+ const cid = await criteriaCid(criteria);
1251
+ const topic = TOPIC_PREFIX + cid.toString();
1252
+ const existing = this.#engines.get(topic);
1253
+ if (existing)
1254
+ return existing;
1255
+ const engine = new ContestEngine(criteria, topic, cid.bytes, this.#deps);
1256
+ this.#engines.set(topic, engine);
1257
+ return engine;
1258
+ }
1259
+ /**
1260
+ * The fetch-protocol responder: answer `"<topic>/root"` with that contest's current root
1261
+ * record, encoded on demand (see DESIGN.md "Checkpoints"). Unauthenticated and tiny by
1262
+ * design — a request can never compel blocks; those travel over directed bitswap. An
1263
+ * unknown topic, foreign key shape, or encode failure answers nothing. So does a contest
1264
+ * whose engine exists but is not joined (e.g. a ballot created but never published): this
1265
+ * node holds no view of that contest, and an empty record would masquerade as one.
1266
+ */
1267
+ #rootLookup = async (keyBytes) => {
1268
+ // `@libp2p/fetch` hands the lookup the requested key as raw bytes; decode the utf8 topic
1269
+ // string the requester sent (`rootFetchKey(topic)`) before matching.
1270
+ const key = new TextDecoder().decode(keyBytes);
1271
+ if (!key.endsWith(ROOT_FETCH_KEY_SUFFIX))
1272
+ return undefined;
1273
+ const engine = this.#engines.get(key.slice(0, -ROOT_FETCH_KEY_SUFFIX.length));
1274
+ if (!engine?.joined)
1275
+ return undefined;
1276
+ try {
1277
+ return encodeRootRecord(await engine.rootRecord());
1278
+ }
1279
+ catch {
1280
+ return undefined;
1281
+ }
1282
+ };
1283
+ /**
1284
+ * Lazy responder lifecycle, driven by the engines' real join/leave transitions: the first
1285
+ * joined topic registers the fetch responder, the last left topic unregisters it. This is an
1286
+ * invariant, not an opt-in — any node participating in a topic answers root-record fetches
1287
+ * there, symmetric with the heartbeat it already broadcasts.
1288
+ */
1289
+ #onTopicJoined = () => {
1290
+ this.#joinedEngines += 1;
1291
+ // A join adds a criteria CID to the announced key set (and the first join starts the
1292
+ // announcer's timers/listeners) — the debounce coalesces a directory-wide join into one
1293
+ // announce per router.
1294
+ this.#announcer?.start();
1295
+ this.#announcer?.notifyChange();
1296
+ if (this.#responderRegistered)
1297
+ return;
1298
+ this.#deps.fetch.registerLookupFunction(TOPIC_PREFIX, this.#rootLookup);
1299
+ this.#responderRegistered = true;
1300
+ };
1301
+ #onTopicLeft = () => {
1302
+ this.#joinedEngines -= 1;
1303
+ // Last topic left: stop refreshing records; what is already written ages out by the
1304
+ // router's TTL (there is no un-announce), symmetric with the responder unregistering.
1305
+ if (this.#joinedEngines <= 0) {
1306
+ this.#announcer?.stop();
1307
+ this.#unregisterResponder();
1308
+ }
1309
+ };
1310
+ async stop() {
1311
+ // Reset each read view (detach its engine listeners, clear `#subscribed`) so it can
1312
+ // `update()` again — this is what keeps `stop()` reusable. Then leave any engine with no
1313
+ // view (created via `createContestVote`); `leave()` is idempotent, so double-leaving a
1314
+ // view's engine is a no-op. Each real leave notifies `#onTopicLeft`, so the responder
1315
+ // unregisters exactly when the last joined topic is left.
1316
+ await Promise.all([...this.#views.values()].map((view) => view.stop()));
1317
+ await Promise.all([...this.#engines.values()].map((engine) => engine.leave()));
1318
+ }
1319
+ async destroy() {
1320
+ // Terminal, mirroring pkc-js: mark the voter and every engine destroyed BEFORE tearing
1321
+ // down, so any create path and any pre-existing view/publication (whose `update()` /
1322
+ // `publish()` funnels through `engine.join()`) now throws `VoterDestroyedError`. `stop()`
1323
+ // then resets the views and leaves every topic (unregistering the responder via the
1324
+ // counted release); the explicit unregister first is the terminal-path safety net.
1325
+ // Unlike `stop()`, the client does not come back.
1326
+ this.#destroyed = true;
1327
+ for (const engine of this.#engines.values())
1328
+ engine.markDestroyed();
1329
+ this.#announcer?.stop();
1330
+ this.#unregisterResponder();
1331
+ await this.stop();
1332
+ // Close the persistent caches last (Node: the sqlite handles) — engines are already
1333
+ // terminal, so nothing can race a write. `stop()` deliberately leaves them open: a
1334
+ // stopped voter is reusable and its caches stay warm.
1335
+ await this.#storage.destroy();
1336
+ }
1337
+ #unregisterResponder() {
1338
+ if (!this.#responderRegistered)
1339
+ return;
1340
+ this.#deps.fetch.unregisterLookupFunction(TOPIC_PREFIX, this.#rootLookup);
1341
+ this.#responderRegistered = false;
1342
+ }
1343
+ }