@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.
- package/LICENSE +674 -0
- package/README.md +212 -0
- package/dist/chain/bucket.d.ts +13 -0
- package/dist/chain/bucket.js +24 -0
- package/dist/chain/ticker.d.ts +15 -0
- package/dist/chain/ticker.js +25 -0
- package/dist/chain/types.d.ts +90 -0
- package/dist/chain/types.js +1 -0
- package/dist/checkpoint/codec.d.ts +54 -0
- package/dist/checkpoint/codec.js +99 -0
- package/dist/client/root-puller.d.ts +49 -0
- package/dist/client/root-puller.js +140 -0
- package/dist/client/voter.d.ts +225 -0
- package/dist/client/voter.js +1195 -0
- package/dist/crdt/codec.d.ts +41 -0
- package/dist/crdt/codec.js +137 -0
- package/dist/crdt/crdt.d.ts +22 -0
- package/dist/crdt/crdt.js +127 -0
- package/dist/crdt/store.d.ts +8 -0
- package/dist/crdt/store.js +23 -0
- package/dist/crdt/types.d.ts +87 -0
- package/dist/crdt/types.js +1 -0
- package/dist/encoding/canonical.d.ts +22 -0
- package/dist/encoding/canonical.js +26 -0
- package/dist/errors.d.ts +72 -0
- package/dist/errors.js +111 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +39 -0
- package/dist/rules/constant.d.ts +14 -0
- package/dist/rules/constant.js +18 -0
- package/dist/rules/erc20-balance.d.ts +30 -0
- package/dist/rules/erc20-balance.js +44 -0
- package/dist/rules/erc721-min-balance.d.ts +18 -0
- package/dist/rules/erc721-min-balance.js +56 -0
- package/dist/rules/registry.d.ts +42 -0
- package/dist/rules/registry.js +61 -0
- package/dist/rules/types.d.ts +74 -0
- package/dist/rules/types.js +1 -0
- package/dist/schema/common.d.ts +25 -0
- package/dist/schema/common.js +24 -0
- package/dist/schema/criteria.d.ts +78 -0
- package/dist/schema/criteria.js +76 -0
- package/dist/schema/directory.d.ts +42 -0
- package/dist/schema/directory.js +52 -0
- package/dist/schema/votes.d.ts +55 -0
- package/dist/schema/votes.js +116 -0
- package/dist/signer/eip712.d.ts +103 -0
- package/dist/signer/eip712.js +85 -0
- package/dist/signer/types.d.ts +31 -0
- package/dist/signer/types.js +1 -0
- package/dist/storage/browser.d.ts +3 -0
- package/dist/storage/browser.js +110 -0
- package/dist/storage/memory.d.ts +10 -0
- package/dist/storage/memory.js +56 -0
- package/dist/storage/node.d.ts +5 -0
- package/dist/storage/node.js +106 -0
- package/dist/storage/types.d.ts +46 -0
- package/dist/storage/types.js +1 -0
- package/dist/store/indexeddb.d.ts +9 -0
- package/dist/store/indexeddb.js +72 -0
- package/dist/store/memory.d.ts +15 -0
- package/dist/store/memory.js +22 -0
- package/dist/store/select.d.ts +15 -0
- package/dist/store/select.js +64 -0
- package/dist/store/sqlite.d.ts +11 -0
- package/dist/store/sqlite.js +68 -0
- package/dist/store/types.d.ts +57 -0
- package/dist/store/types.js +1 -0
- package/dist/tally/tally.d.ts +44 -0
- package/dist/tally/tally.js +89 -0
- package/dist/tally/types.d.ts +51 -0
- package/dist/tally/types.js +13 -0
- package/dist/topic.d.ts +20 -0
- package/dist/topic.js +28 -0
- package/dist/transport/accepted-dedup.d.ts +30 -0
- package/dist/transport/accepted-dedup.js +34 -0
- package/dist/transport/announce/browser.d.ts +9 -0
- package/dist/transport/announce/browser.js +14 -0
- package/dist/transport/announce/node.d.ts +38 -0
- package/dist/transport/announce/node.js +162 -0
- package/dist/transport/announce/types.d.ts +74 -0
- package/dist/transport/announce/types.js +16 -0
- package/dist/transport/bundle-store.d.ts +11 -0
- package/dist/transport/bundle-store.js +34 -0
- package/dist/transport/chase.d.ts +83 -0
- package/dist/transport/chase.js +88 -0
- package/dist/transport/gossip-validator.d.ts +107 -0
- package/dist/transport/gossip-validator.js +99 -0
- package/dist/transport/helia.d.ts +41 -0
- package/dist/transport/helia.js +98 -0
- package/dist/transport/integration/harness.d.ts +60 -0
- package/dist/transport/integration/harness.js +262 -0
- package/dist/transport/messages.d.ts +97 -0
- package/dist/transport/messages.js +117 -0
- package/dist/transport/rate-limit.d.ts +11 -0
- package/dist/transport/rate-limit.js +20 -0
- package/dist/transport/transport.d.ts +20 -0
- package/dist/transport/transport.js +35 -0
- package/dist/transport/types.d.ts +165 -0
- package/dist/transport/types.js +1 -0
- package/dist/verify/background.d.ts +81 -0
- package/dist/verify/background.js +236 -0
- package/dist/verify/bundle.d.ts +58 -0
- package/dist/verify/bundle.js +84 -0
- package/dist/verify/cache.d.ts +48 -0
- package/dist/verify/cache.js +62 -0
- package/dist/verify/constraints.d.ts +16 -0
- package/dist/verify/constraints.js +35 -0
- package/dist/verify/gate-result-cache.d.ts +65 -0
- package/dist/verify/gate-result-cache.js +91 -0
- package/dist/verify/name-resolution-cache.d.ts +59 -0
- package/dist/verify/name-resolution-cache.js +64 -0
- package/dist/verify/signature.d.ts +9 -0
- package/dist/verify/signature.js +55 -0
- package/dist/verify/types.d.ts +101 -0
- package/dist/verify/types.js +1 -0
- 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
|
+
}
|