@bitsocial/pubsub-votes 0.0.2

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