@bitsocial/pubsub-voting 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,7 +24,7 @@ This library does not start its own node. It consumes the host's running Helia n
24
24
 
25
25
  - **Settings live in the topic.** `topic = "bitsocial-votes/" + CID(dag-cbor(criteria))`. Two peers on the same topic provably ran identical rules, so the network validates itself with no intermediary.
26
26
  - **Votes are a state-based grow-only CRDT.** A signed `Votes` bundle is a standalone dag-cbor block (no parent links); each wallet gossips its own bundle **inline as a live delta**, validated straight from the message bytes — no fetch toward the publisher. State is a last-write-wins set keyed by wallet, so aggregation is a monotonic union: a peer can omit a vote but can never subtract one that an honest peer serves. Cold start and gap-fill exchange a tiny **root record** (libp2p-fetch pull + a slow topic heartbeat) and pull the checkpoint blocks behind it via directed bitswap from its advertisers.
27
- - **The gate and weight are data, not code.** A fixed rule registry (mirroring pkc-js's challenge registry) maps a `type` string to a verifier. v1 ships exactly the soulbound-NFT path — an `erc5192-min-balance` gate `rule` (the 5chan Pass: `balanceOf` **plus** an on-chain assertion that the contract declares its tokens locked) and `constant` weight (1 pass = 1 vote). A gate on a *transferable* asset would let one Pass back several concurrent votes, so the plain `erc721-min-balance` rule ships unregistered; balance-derived (token-weighted) voting is deferred. See [DESIGN.md, Does one Pass mean one vote?](./DESIGN.md#does-one-pass-mean-one-vote) and [ROADMAP.md](./ROADMAP.md).
27
+ - **The gate and weight are data, not code.** A fixed rule registry (mirroring pkc-js's challenge registry) maps a `type` string to a verifier. v1 ships exactly the soulbound-NFT path — an `erc5192-min-balance` gate `rule` (the 5chan Pass: `balanceOf` **plus** an on-chain assertion that the contract declares its tokens locked, read at the verifier's head so a freshly-acquired Pass votes immediately) and `constant` weight (1 pass = 1 vote). A gate on a *transferable* asset would let one Pass back several concurrent votes, so the plain `erc721-min-balance` rule ships unregistered; balance-derived (token-weighted) voting is deferred. See [DESIGN.md, Does one Pass mean one vote?](./DESIGN.md#does-one-pass-mean-one-vote) and [ROADMAP.md](./ROADMAP.md).
28
28
 
29
29
  See [DESIGN.md](./DESIGN.md) for the full rationale, including how this resists vote-dropping and how criteria upgrades fork cleanly.
30
30
 
@@ -35,7 +35,7 @@ The library never starts a node and never takes a host SDK (there is no `pkc` ar
35
35
  | Seam | Type | Required | Purpose |
36
36
  |---|---|---|---|
37
37
  | `helia` | `HeliaInstance` | yes | the host's running Helia node; must carry a gossipsub service at `libp2p.services.pubsub` (else `MissingPubsubError`), a `blockstore` (else `MissingBlockstoreError`), and a libp2p fetch service at `libp2p.services.fetch` (else `MissingFetchError`) |
38
- | `chains` | `ChainClientFactory` | yes | resolves each chain a contest's criteria requires (`{ chain, chainId }`) to a viem `PublicClient`; rules read through it for the gate and weight. **RPC endpoints are this client's own settings, never part of the criteria document** — return one shared (memoized) client per chain, pointed at a gateway that serves historical state at least `voteExpiryBuckets × blocksPerBucket` blocks behind head and carries a multicall3 deployment in its viem `chain` config; return `undefined` for a chain with no RPC configured, and `createContest`/`createContestVote` throws `MissingChainClientError` (recuse, don't miscount) |
38
+ | `chains` | `ChainClientFactory` | yes | resolves each chain a contest's criteria requires (`{ chain, chainId }`) to a viem `PublicClient`; rules read through it for the gate and weight. **RPC endpoints are this client's own settings, never part of the criteria document** — return one shared (memoized) client per chain, pointed at a gateway that carries a multicall3 deployment in its viem `chain` config and serves **historical state at least `voteExpiryBuckets × blocksPerBucket` blocks behind head** (the v1 gate reads the head first, but falls back to the block a ballot names see [Custom rules](#custom-rules)); return `undefined` for a chain with no RPC configured, and `createContest`/`createContestVote` throws `MissingChainClientError` (recuse, don't miscount) |
39
39
  | `signer` | `VoteSigner` | no | the voting wallet's address + EIP-712 ballot signing; omit for a read-only voter |
40
40
  | `nameResolvers` | `NameResolver[]` | no | community-name resolvers (same interface and instances as pkc-js's `nameResolvers`, e.g. `@bitsocial/bso-resolver` for `name.bso`); each vote's `community.name` claim is verified through them — inline at the forward-gate for live votes, in the background verifier for cold-join admits — and a bundle whose name resolves to a different `publicKey` than claimed is dropped/evicted |
41
41
  | `dataPath` | `string \| false` | no | directory for the voter's persistent state (gate-result + name-resolution caches, and each joined contest's **checkpoint snapshot** — its last fully-verified winner-set, reloaded at join so a restart with no other peer online keeps the tally), the pkc-js `dataPath` equivalent. Node default: `{cwd}/.bitsocial-pubsub-voting` (better-sqlite3 under `{dataPath}/lru-storage/` + `{dataPath}/checkpoints.db`); in the browser the path is ignored and everything lives in IndexedDB. Pass `false` for in-memory-only (the pkc-js `noData` equivalent). A restart re-serves settled gate reads and fresh name resolutions from the store instead of the RPC, and restores each contest's checkpoint before the cold-start pull. A seeder should always set a stable path |
@@ -87,6 +87,31 @@ await contest.update(); // join the topic, col
87
87
  // await contest.stop(); // leave the topic
88
88
  ```
89
89
 
90
+ ### Will this wallet's vote count?
91
+
92
+ Ask the contest before signing. `checkEligibility` runs the contest's **real gate rule** through
93
+ the same chain client, head reader and memo the forward gate uses, so it reads at whatever block
94
+ that rule reads at and applies whatever threshold it applies. When it refuses, the `error` is the
95
+ rule's own wording — render it verbatim:
96
+
97
+ ```ts
98
+ const check = await contest.checkEligibility({ address: wallet });
99
+ if (check.eligible) {
100
+ show(`eligible — holds ${check.score}`);
101
+ } else {
102
+ show(check.error); // e.g. "this wallet holds none of the gate token (0x13d4…91b9)"
103
+ }
104
+ ```
105
+
106
+ Do **not** reimplement this by reading balances yourself: which block counts is the rule's
107
+ business and changes when the rule changes. A client that hard-codes "peers verify at the bucket
108
+ boundary" keeps telling voters to wait for a window that a head-reading gate no longer imposes.
109
+
110
+ It is a courtesy check, not a promise — eligibility can change between the check and the publish,
111
+ and each peer verifies against its own chain view. `publish()` deliberately does not call it: the
112
+ gate is the network's decision, and a rejection still surfaces after the fact as
113
+ `VoteEvictedError`, carrying the same kind of reason.
114
+
90
115
  Each ranking row carries one flag **per deferred verification operation** (mirroring pkc-js's
91
116
  `nameResolved`), and every background settlement re-fires `update` — so a leaderboard can render
92
117
  provisional rows immediately and refine them in place:
@@ -196,13 +221,13 @@ Full, type-checked call patterns for a pkc-js host, a plebbit/seedit host, and a
196
221
 
197
222
  ### Custom rules
198
223
 
199
- The gate and weight are a single flat registry of rules, one `type` per file, mirroring the pkc-js challenge registry. Each rule owns its option schema and is evaluated at the bundle's bucket block. Chain-reading rules get `ctx.chain` the viem `PublicClient` for their `options.chain` and write their own reads (`readContract`, `getBalance`, ...), pinning each call to the sampled block with `blockNumber: BigInt(ctx.blockNumber)`. There is **one kind**: `evaluate → { score: bigint }`, a non-negative score where `0n` means "does not qualify" (a result object, not a bare `bigint`, so slot-specific fields can be added later). The criteria has two *slots* drawing from the one registry — the **rule** slot treats the score as a gate (`> 0n` admits), the **weight** slot as the vote's magnitude. A wallet's vote counts as `rule.score > 0n ? weight.score : 0n`. A rule that needs a threshold returns `0n` below it (so `erc5192-min-balance`'s optional `min` gates), which lets the same rule serve either slot. A chain-reading rule may also implement the optional `evaluateMany({ options, walletAddresses, ctx })` batch hook (same argument object as `evaluate`, with `walletAddresses` in place of `walletAddress`, returning one `RuleResult` per input wallet in order) — its semantics MUST equal mapping `evaluate` — which the background verifier uses to batch a cold join's gate reads (`erc5192-min-balance` implements it over multicall3, hoisting its one lock assertion out of the per-wallet reads; see [DESIGN.md, Background chain verification](./DESIGN.md#background-chain-verification)).
224
+ The gate and weight are a single flat registry of rules, one `type` per file, mirroring the pkc-js challenge registry. Each rule owns its option schema, its own reads (`readContract`, `getBalance`, ... through `ctx.chain`, the viem `PublicClient` for its `options.chain`), which block it reads at, and what it memoizes see [What a rule owns](#what-a-rule-owns-its-block-and-its-cache) below. There is **one kind**: `evaluate → RuleResult`, either `{ success: true, score }` with a positive score or `{ success: false, error }` where `error` is the voter-facing reason the rule refused. The criteria has two *slots* drawing from the one registry — the **rule** slot treats the score as a gate (`> 0n` admits), the **weight** slot as the vote's magnitude. A wallet's vote counts as `rule.success ? weight.score : 0n`. A rule that needs a threshold fails below it (so `erc5192-min-balance`'s optional `min` gates), which lets the same rule serve either slot. A chain-reading rule may also implement the optional `evaluateMany({ options, wallets, ctx })` batch hook (`wallets` in place of `wallet`, returning `{ results }` — one per input wallet, in order) — its semantics MUST equal mapping `evaluate` — which the background verifier uses to batch a cold join's gate reads. The batch is simply everything pending, so its wallets need not share a `sampleBlock`: a rule reading the head scores them all at once, a rule reading pinned blocks groups them itself. (`erc5192-min-balance` implements it over multicall3, hoisting its one lock assertion out of the per-wallet reads; see [DESIGN.md, Background chain verification](./DESIGN.md#background-chain-verification).)
200
225
 
201
226
  Built-ins: `erc5192-min-balance` (v1) and `constant` (v1).
202
227
 
203
228
  Two chain-reading rules ship in the tree but are deliberately **not** built in, so a criteria naming either recuses via `UnknownRuleError` instead of silently gating on an asset that does not bound Sybils:
204
229
 
205
- - **`erc721-min-balance`** (exported) — a bare `balanceOf` on a *transferable* token. One token walked A → B → C inside one expiry window backs three concurrent live votes, since every bundle is verified at its own pinned block and the winner set is LWW-keyed per wallet. `erc5192-min-balance` is this rule plus `supportsInterface(0xb45a3c0e)`, which refuses a contract that does not declare its tokens locked.
230
+ - **`erc721-min-balance`** (exported) — a bare `balanceOf` on a *transferable* token. One token walked A → B → C inside one expiry window backs three concurrent live votes, since each bundle is verified once, when it is merged, and the winner set is LWW-keyed per wallet. `erc5192-min-balance` is this rule plus `supportsInterface(0xb45a3c0e)`, which refuses a contract that does not declare its tokens locked.
206
231
  - **`erc20-balance`** (not exported) — the same amplification, reopened by fungibility, plus the open lazy-tally ceiling question for balance-derived weight.
207
232
 
208
233
  A host that wants a transferable gate anyway registers `erc721MinBalance` explicitly through the `rules` option below — the library stops blessing the configuration, it does not forbid it. `erc20-balance` is not exported at all, so a host that wants balance-weighting supplies its own rule of that `type`. See [DESIGN.md, Does one Pass mean one vote?](./DESIGN.md#does-one-pass-mean-one-vote).
@@ -216,8 +241,10 @@ import { z } from "zod";
216
241
  const seeditModAllowlist: Rule<{ type: "seedit-mod-allowlist"; allow: string[] }> = {
217
242
  type: "seedit-mod-allowlist",
218
243
  optionsSchema: z.object({ type: z.literal("seedit-mod-allowlist"), allow: z.array(z.string()) }),
219
- async evaluate({ options, walletAddress }) {
220
- return { score: options.allow.includes(walletAddress) ? 1n : 0n }; // gate: 1n admits, 0n rejects
244
+ async evaluate({ options, wallet }) {
245
+ return options.allow.includes(wallet.address)
246
+ ? { success: true, score: 1n }
247
+ : { success: false, error: "this wallet is not on the moderator allowlist" }; // shown to the voter
221
248
  }
222
249
  };
223
250
 
@@ -229,6 +256,46 @@ const voter = new PubsubVoter({
229
256
 
230
257
  A custom `type` becomes part of `dag-cbor(criteria)`, so it is provably pinned to the topic it runs on, and a client that does not implement a `type` named in `criteria.requires.rules` throws `UnknownRuleError` and recuses itself rather than miscounting.
231
258
 
259
+ #### What a rule owns: its block, and its cache
260
+
261
+ A rule is handed the pinned block its ballot names, a lazy head reader, and its own memo, and decides for itself which to read and what to remember:
262
+
263
+ ```ts
264
+ evaluate(args: { options: O; wallet: { address: string; sampleBlock: number }; ctx: ChainReadContext }): Promise<RuleResult>
265
+
266
+ interface ChainReadContext {
267
+ chain: ChainClient; // the viem PublicClient for options.chain
268
+ head: () => Promise<{ block: number }>; // this verifier's current block — lazy, coalesced
269
+ cache: RuleCache; // this rule's memo, persistent and shared across contests
270
+ }
271
+ ```
272
+
273
+ - **`wallet.sampleBlock`** is the bundle's bucketized block, already floored — the historical block every verifier agrees the ballot names. Read there and your score is identical on every verifier forever. It is *not* a claim about when the ballot was signed (it trails by up to `blocksPerBucket`).
274
+ - **`ctx.head()`** is this verifier's current block. Read there and a wallet qualifies the moment it acquires the gating asset, instead of waiting for the next bucket boundary. Resolve it once per evaluation and pin your reads to that number — always pass an explicit `blockNumber: BigInt(...)`, or the read coalescer cannot batch you.
275
+ - **`ctx.cache`** memoizes under a `key` you choose and an `epoch` that says when the answer stops being true (a moved epoch is how an entry expires; `purgeBelow` drops what is behind). **Chain-reading rules must use it** — it is what turns "one chain read per unique bundle" into "one read per key per epoch", so without it an ineligible wallet can make every peer on the topic pay an RPC round trip per fresh-signed bundle. `memoMany` reads only the misses, in one batched call.
276
+
277
+ ```ts
278
+ const { values } = await ctx.cache.memoMany({
279
+ keys: wallets.map((w) => `bal/${w.toLowerCase()}`),
280
+ epoch: block, // e.g. the pinned block, or a coarse head window
281
+ read: async ({ keys }) => ({ values: await readOnChain(keys) })
282
+ });
283
+ ```
284
+
285
+ `RuleResult` is a discriminated union, shaped like pkc-js's `ChallengeResult`:
286
+
287
+ ```ts
288
+ type RuleResult =
289
+ | { success: true; score: bigint } // score MUST be > 0n
290
+ | { success: false; error: string; penalize?: boolean }; // penalize default true
291
+ ```
292
+
293
+ **`error` is required on the failing branch**, because only the rule knows why a wallet fell short — it holds none, it holds too few, the contract gates nothing. That sentence is what the library shows the voter: it becomes the verdict reason, so it reaches the publisher on `VoteEvictedError.verdict.reason`, and it is what `contest.checkEligibility()` returns. Write it about the wallet ("this wallet holds none of the gate token"), not about the library, and leave block numbers out unless a voter can act on them. Making it optional would mean every UI re-deriving the rule's thresholds and block choice to say anything useful — which is exactly the coupling `ctx` and `RuleCache` exist to remove.
294
+
295
+ **`penalize`** answers the one thing the library cannot: may the failure be blamed on the sender? `true` (the default) says every honest verifier computes this same failure — true of a read pinned to the block the bundle names — so the forward gate `reject`s the message (penalizing the delivering peer in gossipsub's scoring) and the verdict is cached as terminal. `false` says an honest peer could legitimately disagree, so the bundle is dropped `ignore`-class instead: no penalty, verdict uncached, and the background verifier re-examines it for a grace window before giving up.
296
+
297
+ `erc5192-min-balance` reads the head first — so a freshly-acquired Pass counts immediately — falls back to `wallet.sampleBlock` when the head refuses (ERC-5192 does not forbid burning, and without the fallback a burn would erase votes retroactively for peers that had not verified them yet), memoizes each leg under its own epoch, and returns `penalize: false`, because at validation time it cannot attribute a failure to anyone: the peer that forwarded the vote verified it against *its* head. Every other rule in the tree reads `wallet.sampleBlock` and leaves `penalize` at its default — a transferable or fungible balance can decrease, so reading it at the head would silently invalidate votes already counted. See [DESIGN.md, What a rule owns](./DESIGN.md#what-a-rule-owns-and-what-the-pipeline-owns).
298
+
232
299
  ### Weighted voting (deferred)
233
300
 
234
301
  v1 ships `constant` weight (one Pass, one vote) **on purpose** — it resists whale dominance and downvote weaponization. Balance-derived, token-weighted voting (Pass gate + BSO weight via `erc20-balance`) is a designed-but-unshipped capability: the rule path and result shape leave room for it with no engine change, but it is not in the v1 built-ins and carries open governance/abuse and lazy-tally questions — plus the Sybil amplification a fungible gate reopens, which the soulbound gate's fix cannot close for a balance (it needs a hold-duration guard instead). See [ROADMAP.md](./ROADMAP.md), [DESIGN.md, Does one Pass mean one vote?](./DESIGN.md#does-one-pass-mean-one-vote), and [DESIGN.md, Future improvements](./DESIGN.md#future-improvements).
@@ -65,6 +65,19 @@ export interface PublishOutcome {
65
65
  readonly recipientCount: number;
66
66
  }
67
67
  /** One contest's reactive read view: subscribe, keep the tally in sync, read it. */
68
+ /**
69
+ * What {@link Contest.checkEligibility} found. Shaped like {@link RuleResult} on purpose — it is
70
+ * that result, surfaced — so the failing branch always carries a reason a client can display.
71
+ */
72
+ export type EligibilityResult = {
73
+ eligible: true;
74
+ /** The wallet's gate score, `> 0n`. For a balance gate this is the holding itself. */
75
+ score: bigint;
76
+ } | {
77
+ eligible: false;
78
+ /** The rule's own explanation, written for the voter. Render it verbatim. */
79
+ error: string;
80
+ };
68
81
  export interface Contest {
69
82
  /** The criteria document this contest runs (already validated). */
70
83
  readonly criteria: Criteria;
@@ -85,6 +98,30 @@ export interface Contest {
85
98
  stop(): Promise<void>;
86
99
  /** Compute the current contest ranking fresh, bypassing the cache. */
87
100
  getTally(): Promise<ContestTally>;
101
+ /**
102
+ * Would this contest's gate admit `address` right now? Ask before signing, to tell a voter
103
+ * whether their ballot will count — and, when it will not, exactly why.
104
+ *
105
+ * This runs the contest's REAL gate rule through the same context the forward-gate and the
106
+ * background verifier use: the same chain client, the same coalesced head reader, the same
107
+ * memo. So it reads at whatever block the rule reads at, applies whatever threshold the rule
108
+ * applies, and returns the rule's own {@link RuleResult.error} wording verbatim. A client
109
+ * renders `error` and needs to know nothing about blocks, buckets or thresholds — which is
110
+ * the point: re-deriving any of that outside the rule is how a UI ends up confidently
111
+ * telling voters to wait for a window that no longer gates anything.
112
+ *
113
+ * It is a courtesy check, not a promise. Eligibility is a fact about the chain and can change
114
+ * between this call and the publish, and each peer verifies against its own view — so a
115
+ * `true` here can still be followed by a `VoteEvictedError` (which carries the same kind of
116
+ * reason). `publish()` deliberately does NOT call this: the gate is the network's decision,
117
+ * and refusing locally would only hide a vote the rest of the topic would have accepted.
118
+ *
119
+ * Costs one gate evaluation, usually served from the shared memo — the same read the verifier
120
+ * would do anyway, not an extra one.
121
+ */
122
+ checkEligibility(args: {
123
+ address: string;
124
+ }): Promise<EligibilityResult>;
88
125
  /**
89
126
  * Fired when incoming votes change the state; `tally` carries the freshly recomputed
90
127
  * ranking. Background check settlements fire it too: a cold join emits a first tally with
@@ -14,9 +14,10 @@ import { makeRootChaser, toChaseSession } from "../transport/chase.js";
14
14
  import { encodeBundle, decodeBundle, bundleCidForBytes } from "../crdt/codec.js";
15
15
  import { resolveRegistry, validateCriteriaRules } from "../rules/registry.js";
16
16
  import { makeVoteCrdt } from "../crdt/crdt.js";
17
+ import { makePersistentRuleCache } from "../rules/cache.js";
18
+ import { gateFailure, scoreOrZero } from "../rules/result.js";
17
19
  import { makeBundleVerifier } from "../verify/bundle.js";
18
20
  import { makeVerdictCache } from "../verify/cache.js";
19
- import { makePersistentGateResultCache, purgeExpiredGateResults } from "../verify/gate-result-cache.js";
20
21
  import { makeNameResolutionCache } from "../verify/name-resolution-cache.js";
21
22
  import { makeStorage } from "../storage/node.js";
22
23
  import { makeAnnouncer } from "../transport/announce/node.js";
@@ -631,14 +632,31 @@ class ContestEngine {
631
632
  voteExpiryBuckets: criteria.voteExpiryBuckets,
632
633
  isProvisional: (cid) => this.#isPending(cid)
633
634
  });
634
- // One gate-result cache shared between the inline forward-gate verifier and the
635
- // background chain verifier, so neither re-reads a (wallet, sampleBlock) the other
636
- // settled — layered over the voter's persistent store, keyed under this contest's rule
637
- // hash: the gate score is a pure function of (rule, chainId, wallet, sampleBlock), so
638
- // hashing the canonical rule document + chainId is exactly the sharing boundary (two
639
- // contests over one gate share reads; different gates cannot collide).
635
+ // The gate rule's memo (rules/cache.ts), shared between the inline forward-gate verifier
636
+ // and the background chain verifier so neither re-reads what the other settled — layered
637
+ // over the voter's persistent store and namespaced by the hash of the canonical rule
638
+ // reference + chainId. That hash is exactly the sharing boundary: two contests over one
639
+ // gate (a directory of boards on the same Pass) share each other's reads, while
640
+ // different gates, or one gate on different options, can never collide. What is stored
641
+ // under it, and for how long, is the rule's business, not the engine's.
640
642
  this.#ruleHash = sha256(encodeDagCbor({ chainId: this.#chainId, rule: criteria.rule }));
641
- const gateResultCache = makePersistentGateResultCache({ store: deps.gateStore, ruleHash: this.#ruleHash });
643
+ const gateCache = makePersistentRuleCache({ store: deps.gateStore, namespace: this.#ruleHash });
644
+ // The weight rule gets its own namespace on the SAME terms — its canonical reference plus
645
+ // the id of the chain IT reads, which is not necessarily the gating chain. A ticker is
646
+ // just a name local to the criteria document, so two contests can spell the same weight
647
+ // ref while `requires.chains` binds that ticker to different chains; keying on the gate's
648
+ // chainId would let one serve the other's scores from the wrong chain.
649
+ const weight = deps.registry[criteria.weight.type];
650
+ if (!weight)
651
+ throw new UnknownRuleError("weight", criteria.weight.type);
652
+ const weightTicker = tickerForRef(criteria, criteria.weight, weight.optionsSchema.parse(criteria.weight));
653
+ const weightChainId = criteria.requires.chains[weightTicker]?.chainId;
654
+ if (weightChainId === undefined)
655
+ throw new Error(`no chain client for weight chain "${weightTicker}"`);
656
+ const weightCache = makePersistentRuleCache({
657
+ store: deps.gateStore,
658
+ namespace: sha256(encodeDagCbor({ chainId: weightChainId, rule: criteria.weight }))
659
+ });
642
660
  const verifier = makeBundleVerifier({
643
661
  criteria,
644
662
  criteriaCid: criteriaCidBytes,
@@ -647,8 +665,9 @@ class ContestEngine {
647
665
  chainFor: (ticker) => this.#chainFor(ticker),
648
666
  bucketMath: this.#bucketMath,
649
667
  nameResolvers: deps.nameResolvers,
650
- gateResultCache,
651
- nameResolutionCache: deps.nameResolutionCache
668
+ ruleCache: gateCache,
669
+ nameResolutionCache: deps.nameResolutionCache,
670
+ readHead: ({ chain }) => this.#readHead({ chain })
652
671
  });
653
672
  // The gate/transport are (re)built on join(); the store, crdt, caches, verifier, and
654
673
  // background verifier are stable per contest, so they survive re-joins of the topic.
@@ -662,8 +681,9 @@ class ContestEngine {
662
681
  chainFor: (ticker) => this.#chainFor(ticker),
663
682
  bucketMath: this.#bucketMath,
664
683
  nameResolvers: deps.nameResolvers,
665
- gateResultCache,
684
+ ruleCache: gateCache,
666
685
  nameResolutionCache: deps.nameResolutionCache,
686
+ readHead: ({ chain }) => this.#readHead({ chain }),
667
687
  cache: this.#cache,
668
688
  onGateVerified: (cid) => this.#settleCheck(cid, "chainVerified"),
669
689
  onNameResolved: (cid) => this.#settleCheck(cid, "nameResolved"),
@@ -676,6 +696,8 @@ class ContestEngine {
676
696
  registry: deps.registry,
677
697
  chainFor: (ticker) => this.#chainFor(ticker),
678
698
  bucketMath: this.#bucketMath,
699
+ readHead: ({ chain }) => this.#readHead({ chain }),
700
+ ruleCache: weightCache,
679
701
  current: () => this.#crdt
680
702
  .currentEntries(this.#currentBucketCache)
681
703
  .map(({ cid, bundle }) => ({ bundle, checks: this.#checksFor(cid, bundle) })),
@@ -808,35 +830,27 @@ class ContestEngine {
808
830
  const head = await this.#deps.readHead(this.#ruleChain);
809
831
  this.#currentBucketCache = this.#bucketMath.bucketForBlock(Number(head));
810
832
  this.#headReadMs = Date.now();
811
- this.#maybePurgeGateResults();
812
833
  return this.#currentBucketCache;
813
834
  }
814
- /** The last purge's expiry boundary (oldest admissible sample block); 0 = never purged. */
815
- #purgedSampleBlock = 0;
816
835
  /**
817
- * Drop this rule's persisted gate results older than the oldest admissible sample block —
818
- * provably dead: a score at bucket B is only ever consulted while bundles from B are within
819
- * `voteExpiryBuckets` of head (see verify/gate-result-cache.ts `purgeExpiredGateResults`).
820
- * Piggybacks on the head reads the engine does anyway (join-with-state, publish, tally)
821
- * and re-runs only when the boundary advances past the last purged one — so an idle
822
- * engine costs no chain read and no purge, and a steady head costs no key scan, but a
823
- * long-lived engine still sheds entries as they expire instead of leaving them to the
824
- * LRU backstop. Fire-and-forget by design.
836
+ * The current head on `chain`, handed to every rule as `ctx.head` (verify/bundle.ts,
837
+ * verify/background.ts, tally/tally.ts all take this seam). It goes through the voter-wide
838
+ * {@link ResolvedDeps.readHead} coalescer for the same reason {@link #refreshBucket} does:
839
+ * a rule that scores current state puts this on the verify path, so a directory-wide gossip
840
+ * burst would otherwise fire one `eth_blockNumber` per contest per message instead of
841
+ * sharing one in-flight read per chain.
825
842
  */
826
- #maybePurgeGateResults() {
827
- const oldestBucket = this.#currentBucketCache - this.criteria.voteExpiryBuckets;
828
- if (oldestBucket <= 0)
829
- return;
830
- const oldestSampleBlock = this.#bucketMath.sampleBlockForBucket(oldestBucket);
831
- if (oldestSampleBlock <= this.#purgedSampleBlock)
832
- return;
833
- this.#purgedSampleBlock = oldestSampleBlock;
834
- void purgeExpiredGateResults({
835
- store: this.#deps.gateStore,
836
- ruleHash: this.#ruleHash,
837
- oldestSampleBlock
838
- });
843
+ async #readHead(args) {
844
+ return { block: Number(await this.#deps.readHead(args.chain)) };
839
845
  }
846
+ /**
847
+ * PURGE REMOVED — kept as a note because the old behaviour was load-bearing and its
848
+ * replacement lives elsewhere. Persisted gate entries used to be purged here, at the oldest
849
+ * admissible bucket sample block, because the engine knew every entry was keyed by one. It
850
+ * no longer knows: a rule chooses its own keys and epochs (rules/cache.ts), so only the rule
851
+ * can say what is dead — `erc5192-min-balance` purges its head-keyed entries as the head
852
+ * window rolls, and its pinned entries stay valid until the store's LRU bound reclaims them.
853
+ */
840
854
  /** `Date.now()` of the last gating-chain head read, memoizing {@link #nowBucket}. */
841
855
  #headReadMs = 0;
842
856
  /** The current gating-chain head bucket, memoized for {@link HEAD_BUCKET_TTL_MS}. */
@@ -858,6 +872,21 @@ class ContestEngine {
858
872
  return true;
859
873
  return sampleBucket <= (await this.#nowBucket());
860
874
  }
875
+ /**
876
+ * Run the gate rule for one wallet, against the ballot block a vote published NOW would carry
877
+ * — the engine half of {@link Contest.checkEligibility}.
878
+ *
879
+ * All the interesting decisions belong to the rule: this resolves the current bucket's sample
880
+ * block, hands it over, and translates the rule's own answer. It never looks at what kind of
881
+ * rule it is holding, so it stays correct for a head-scoring gate, a pinned one, or anything
882
+ * a host registers later.
883
+ */
884
+ async checkEligibility({ address }) {
885
+ const sampleBlock = this.#bucketMath.sampleBlockForBucket(await this.#nowBucket());
886
+ const result = await this.#verifier.checkGate({ address, sampleBlock });
887
+ const failed = gateFailure(result);
888
+ return failed ? { eligible: false, error: failed.error } : { eligible: true, score: scoreOrZero(result) };
889
+ }
861
890
  /** Hash of the current bucket boundary block on the gating (`rule`) chain (rolling tie seed). */
862
891
  async #bucketBlockHash() {
863
892
  const head = await this.#ruleChain.getBlockNumber();
@@ -1683,6 +1712,9 @@ class ContestView {
1683
1712
  getTally() {
1684
1713
  return this.#engine.computeTally();
1685
1714
  }
1715
+ checkEligibility(args) {
1716
+ return this.#engine.checkEligibility(args);
1717
+ }
1686
1718
  /**
1687
1719
  * Internal hook (not part of the {@link Contest} interface): this contest's current checkpoint
1688
1720
  * root record, encoded on demand. The fetch responder and heartbeat use the engine directly;
package/dist/index.d.ts CHANGED
@@ -19,6 +19,7 @@ export * from "./rules/erc5192-min-balance.js";
19
19
  export * from "./rules/erc721-min-balance.js";
20
20
  export * from "./rules/constant.js";
21
21
  export * from "./rules/registry.js";
22
+ export { makeMemoryRuleCache, makePersistentRuleCache, type RuleCache } from "./rules/cache.js";
22
23
  export * from "./encoding/canonical.js";
23
24
  export * from "./topic.js";
24
25
  export * from "./errors.js";
package/dist/index.js CHANGED
@@ -34,6 +34,10 @@ export * from "./rules/erc5192-min-balance.js";
34
34
  export * from "./rules/erc721-min-balance.js";
35
35
  export * from "./rules/constant.js";
36
36
  export * from "./rules/registry.js";
37
+ // The cache a rule computes through (`ctx.cache`): a rule owns its keys and epochs, the library
38
+ // owns the store, the bound and the purge. A custom chain-reading rule MUST memoize through it —
39
+ // see rules/cache.ts and README "Custom rules".
40
+ export { makeMemoryRuleCache, makePersistentRuleCache } from "./rules/cache.js";
37
41
  // Implemented runtime: encoding, topic, errors, identity seam, facade.
38
42
  export * from "./encoding/canonical.js";
39
43
  export * from "./topic.js";
@@ -0,0 +1,107 @@
1
+ import type { LruStorage } from "../storage/types.js";
2
+ /**
3
+ * The cache a rule computes through.
4
+ *
5
+ * A rule decides *what* it reads and *when* the answer stops being true; the library decides
6
+ * where that answer is stored, how it is bounded, and how it is shared. This seam is that split.
7
+ * It exists because cache validity is the thing that genuinely differs between rules and cannot
8
+ * be expressed generically:
9
+ *
10
+ * - a score read at a pinned historical block is true forever, so it should never expire;
11
+ * - a score read at the chain head stops being true almost immediately — a wallet that failed
12
+ * a moment ago may hold the gate asset now — so it must expire, and how fast is a judgement
13
+ * only the rule can make;
14
+ * - some reads are not per-wallet at all (`erc5192-min-balance` probes `supportsInterface`
15
+ * once per contract), which no per-wallet key can express.
16
+ *
17
+ * The library still owns the mechanics, because they are not rule-specific and are easy to get
18
+ * wrong: persistence under the voter's `dataPath`, the bounded in-memory front, and the
19
+ * namespace. Every rule instance gets its own keyspace, namespaced by the rule's `type`, its
20
+ * canonical options and the id of the chain IT reads (which for the weight rule need not be the
21
+ * gating chain) — so two contests running the same gate share each other's reads (a 5chan-style
22
+ * directory of 63 boards on one Pass is one read per wallet, not 63), while two different gates,
23
+ * or the same gate on a different contract or chain, can never collide.
24
+ *
25
+ * **Caching is not optional for a chain-reading rule.** It is what bounds the "one chain read per
26
+ * unique bundle" amplifier: without it, an ineligible wallet can mint fresh-signed bundles and
27
+ * make every peer on the topic pay an RPC round trip for each one (DESIGN.md "Can valid votes
28
+ * clog the topic?"). {@link RuleCache.memoMany} exists so the correct behaviour is one call.
29
+ *
30
+ * Values are strings because the persistent tier is JSON-backed — a `bigint` score travels as a
31
+ * decimal string, a boolean as `"1"`/`"0"`.
32
+ */
33
+ export interface RuleCache {
34
+ /** The memoized value for `key` within `epoch`, or `{ value: undefined }` on a miss. */
35
+ get(args: {
36
+ key: string;
37
+ epoch: number;
38
+ }): Promise<{
39
+ value: string | undefined;
40
+ }>;
41
+ /**
42
+ * Memoize `value` for `key` within `epoch`. Idempotent: an existing entry is never
43
+ * overwritten, so a score cannot be silently replaced under a key that still applies.
44
+ * Returns immediately — any persistence settles in the background, so the verify hot path
45
+ * never waits on a cache write.
46
+ */
47
+ set(args: {
48
+ key: string;
49
+ epoch: number;
50
+ value: string;
51
+ }): void;
52
+ /**
53
+ * Look up many keys at once and read only the misses — the batched path a rule should use
54
+ * for a cold join's wallets, where `read` becomes one multicall instead of N round trips.
55
+ * `read` is called at most once, with the missing keys in order, and MUST return one value
56
+ * per key it was given. Skipped entirely when everything hits.
57
+ */
58
+ memoMany(args: {
59
+ keys: string[];
60
+ epoch: number;
61
+ read: (args: {
62
+ keys: string[];
63
+ }) => Promise<{
64
+ values: string[];
65
+ }>;
66
+ }): Promise<{
67
+ values: string[];
68
+ }>;
69
+ /**
70
+ * Drop persisted entries below `epoch` — the rule's own statement that they are dead.
71
+ *
72
+ * This is on the rule because only the rule knows what an epoch means. A rule keying by the
73
+ * chain head knows everything behind the current window is unreachable; a rule keying by a
74
+ * bundle's pinned block knows nothing expires until the vote itself does. Optionally
75
+ * restricted to keys starting with `keyPrefix`, so a rule that mixes both — as the v1 gate
76
+ * does — can purge its head-keyed entries without touching its permanently-valid ones.
77
+ *
78
+ * Best-effort and fire-and-forget: the store's LRU bound is the backstop for a rule that
79
+ * never calls it. Repeat calls at or below the last purged epoch are free.
80
+ */
81
+ purgeBelow(args: {
82
+ epoch: number;
83
+ keyPrefix?: string;
84
+ }): void;
85
+ }
86
+ /**
87
+ * An in-memory {@link RuleCache}, FIFO-bounded. Used on its own by unit tests and as the hot
88
+ * front of the persistent cache. Eviction is safe: an evicted entry costs a re-read, never a
89
+ * wrong answer — and without a bound, a flood of fresh wallets would be a memory-exhaustion
90
+ * vector (DESIGN.md "Can valid votes clog the topic?").
91
+ */
92
+ export declare function makeMemoryRuleCache(args?: {
93
+ maxEntries?: number;
94
+ }): RuleCache;
95
+ /**
96
+ * A {@link RuleCache} over the voter's persistent store: the in-memory FIFO front above, with
97
+ * read-through on a miss and fire-and-forget write-through. A broken store read or write
98
+ * degrades to a live chain read — never an error into the verify pipeline.
99
+ *
100
+ * `namespace` is the rule's keyspace (see {@link RuleCache}); the voter derives it from the
101
+ * canonical rule reference + chain id.
102
+ */
103
+ export declare function makePersistentRuleCache(args: {
104
+ store: LruStorage;
105
+ namespace: string;
106
+ maxMemEntries?: number;
107
+ }): RuleCache;