@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.
@@ -1,12 +1,17 @@
1
1
  import { tickerForRef } from "../chain/ticker.js";
2
+ import { makeMemoryRuleCache } from "../rules/cache.js";
3
+ import { GATE_GRACE_MS, GATE_RETRY_MS } from "./gate-grace.js";
4
+ import { gateFailure, scoreOrZero } from "../rules/result.js";
2
5
  import { UnknownRuleError } from "../errors.js";
3
6
  import { resolveNameThroughCache } from "./name-resolution-cache.js";
4
7
  const RETRY_BASE_MS = 2_000;
5
8
  const RETRY_CAP_MS = 60_000;
6
9
  export function makeBackgroundVerifier(deps) {
7
- const { criteria, registry, chainFor, bucketMath, nameResolvers, gateResultCache, nameResolutionCache, cache, limit } = deps;
10
+ const { criteria, registry, chainFor, bucketMath, nameResolvers, nameResolutionCache, cache, limit } = deps;
8
11
  const retryBaseMs = deps.retryBaseMs ?? RETRY_BASE_MS;
9
12
  const retryCapMs = deps.retryCapMs ?? RETRY_CAP_MS;
13
+ const gateGraceMs = deps.gateGraceMs ?? GATE_GRACE_MS;
14
+ const gateRetryMs = deps.gateRetryMs ?? GATE_RETRY_MS;
10
15
  // Resolve the gate `rule`, its options, and its chain once (same shape as verify/bundle.ts).
11
16
  // The re-binding after the guard keeps the non-undefined narrowing inside the closures below.
12
17
  const maybeRule = registry[criteria.rule.type];
@@ -15,6 +20,12 @@ export function makeBackgroundVerifier(deps) {
15
20
  const rule = maybeRule;
16
21
  const ruleOptions = rule.optionsSchema.parse(criteria.rule);
17
22
  const ruleChain = chainFor(tickerForRef(criteria, criteria.rule, ruleOptions));
23
+ const readHead = deps.readHead ?? (async ({ chain }) => ({ block: Number(await chain.getBlockNumber()) }));
24
+ const ctx = {
25
+ chain: ruleChain,
26
+ head: () => readHead({ chain: ruleChain }),
27
+ cache: deps.ruleCache ?? makeMemoryRuleCache()
28
+ };
18
29
  const queue = [];
19
30
  /** CIDs queued or in-flight, so a re-chased root cannot double-verify a bundle. */
20
31
  const inFlight = new Set();
@@ -38,38 +49,39 @@ export function makeBackgroundVerifier(deps) {
38
49
  return bucketMath.sampleBlockForBucket(bucketMath.bucketForBlock(bundle.blockNumber));
39
50
  }
40
51
  /**
41
- * Gate stage for one round's batch: group the not-yet-gated items per sample block, read the
42
- * distinct uncached wallets — `evaluateMany` when the rule has it, `limit`-bounded singles
43
- * otherwise and feed every score into the shared gate-result cache. Throws on the FIRST
44
- * infra failure: the round's unfinished items are re-queued by the caller.
52
+ * Gate stage for one round's batch: hand every not-yet-gated wallet to the rule at once —
53
+ * `evaluateMany` when it has one, `limit`-bounded per-wallet `evaluate` otherwise — and
54
+ * record each score with the rule's own verdict on whether a `0n` is attributable.
55
+ *
56
+ * No grouping and no cache lookups here any more: which block each wallet is read at, and
57
+ * what may be memoized under which key, are the rule's decisions (rules/types.ts,
58
+ * rules/cache.ts). Duplicate wallets are still collapsed before the call, because that is a
59
+ * property of THIS batch rather than of any rule. Throws on the FIRST infra failure: the
60
+ * round's unfinished items are re-queued by the caller.
45
61
  */
46
62
  async function gateStage(items) {
47
- const groups = new Map();
48
- for (const item of items) {
49
- if (item.gateDone)
63
+ const pending = items.filter((item) => !item.gateDone);
64
+ if (pending.length === 0)
65
+ return;
66
+ const wallets = [];
67
+ const at = new Map();
68
+ for (const item of pending) {
69
+ const wallet = { address: item.bundle.address, sampleBlock: sampleBlockFor(item.bundle) };
70
+ const key = `${wallet.address.toLowerCase()}:${wallet.sampleBlock}`;
71
+ if (at.has(key))
50
72
  continue;
51
- const block = sampleBlockFor(item.bundle);
52
- groups.set(block, [...(groups.get(block) ?? []), item]);
73
+ at.set(key, wallets.length);
74
+ wallets.push(wallet);
53
75
  }
54
- for (const [sampleBlock, group] of groups) {
55
- const wallets = [];
56
- for (const item of group) {
57
- const wallet = item.bundle.address;
58
- if ((await gateResultCache.get(wallet, sampleBlock)) === undefined && !wallets.includes(wallet)) {
59
- wallets.push(wallet);
60
- }
61
- }
62
- if (wallets.length > 0) {
63
- const ctx = { chain: ruleChain, blockNumber: sampleBlock };
64
- const results = rule.evaluateMany
65
- ? await rule.evaluateMany({ options: ruleOptions, walletAddresses: wallets, ctx })
66
- : await Promise.all(wallets.map((walletAddress) => limit(() => rule.evaluate({ options: ruleOptions, walletAddress, ctx }))));
67
- wallets.forEach((wallet, i) => gateResultCache.set(wallet, sampleBlock, results[i].score));
68
- }
69
- for (const item of group) {
70
- item.ruleScore = (await gateResultCache.get(item.bundle.address, sampleBlock));
71
- item.gateDone = true;
72
- }
76
+ const results = rule.evaluateMany
77
+ ? (await rule.evaluateMany({ options: ruleOptions, wallets, ctx })).results
78
+ : await Promise.all(wallets.map((wallet) => limit(() => rule.evaluate({ options: ruleOptions, wallet, ctx }))));
79
+ for (const item of pending) {
80
+ const key = `${item.bundle.address.toLowerCase()}:${sampleBlockFor(item.bundle)}`;
81
+ const result = results[at.get(key)];
82
+ item.gateFailed = gateFailure(result);
83
+ item.ruleScore = scoreOrZero(result);
84
+ item.gateDone = true;
73
85
  }
74
86
  }
75
87
  /**
@@ -115,6 +127,8 @@ export function makeBackgroundVerifier(deps) {
115
127
  async function round() {
116
128
  const batch = queue.splice(0);
117
129
  const requeue = [];
130
+ /** Unattributable `0n` items still inside their grace window: re-examined, not a failure. */
131
+ const notYet = [];
118
132
  let infraError;
119
133
  // Gate stage first, whole batch: this is where batching wins (one multicall per sample
120
134
  // block instead of one read per wallet). An infra throw leaves every un-gated item intact.
@@ -130,14 +144,38 @@ export function makeBackgroundVerifier(deps) {
130
144
  requeue.push(item); // gate read never happened (infra) — retry the whole item
131
145
  continue;
132
146
  }
133
- if (item.ruleScore === 0n) {
134
- // Provable, deterministic reject — safe to cache so a re-publish short-circuits.
147
+ if (item.gateFailed) {
148
+ if (item.gateFailed.penalize) {
149
+ // Provable, deterministic reject — safe to cache so a re-publish short-circuits.
150
+ const verdict = {
151
+ valid: false,
152
+ disposition: "reject",
153
+ reason: `not admitted: ${item.gateFailed.error}`
154
+ };
155
+ cache.set(item.cid, verdict);
156
+ deps.onEvict(item.cid, verdict);
157
+ settle(item);
158
+ continue;
159
+ }
160
+ // The rule declined to blame anyone (see rules/types.ts, RuleResult.penalize):
161
+ // the failure means "not yet", not "no". The wallet may have acquired the asset in
162
+ // a block this verifier has not seen, or may acquire it a moment from now — a
163
+ // client that signs the instant it mints races its own transaction. Evicting here
164
+ // would make whether a vote counts depend on whose RPC was a few blocks ahead, so
165
+ // the item is re-examined until the grace window closes; only then is it dropped,
166
+ // `ignore`-class and UNCACHED, so a later re-publish is judged fresh.
167
+ // Re-examining is nearly free: while the rule's own memo holds, its re-read comes
168
+ // straight from that cache and touches no chain (see verify/gate-grace.ts).
169
+ if (Date.now() - item.queuedAt < gateGraceMs) {
170
+ item.gateDone = false;
171
+ notYet.push(item);
172
+ continue;
173
+ }
135
174
  const verdict = {
136
175
  valid: false,
137
- disposition: "reject",
138
- reason: `not admitted: rule score is 0n at block ${sampleBlockFor(item.bundle)}`
176
+ disposition: "ignore",
177
+ reason: `not admitted: ${item.gateFailed.error} (still true after the grace window)`
139
178
  };
140
- cache.set(item.cid, verdict);
141
179
  deps.onEvict(item.cid, verdict);
142
180
  settle(item);
143
181
  continue;
@@ -164,23 +202,32 @@ export function makeBackgroundVerifier(deps) {
164
202
  settle(item);
165
203
  }
166
204
  if (requeue.length > 0) {
167
- queue.push(...requeue);
205
+ queue.push(...requeue, ...notYet);
168
206
  failedRounds += 1;
169
207
  deps.onError(infraError);
170
208
  armRetry();
171
209
  }
210
+ else if (notYet.length > 0) {
211
+ // "Not yet" is nobody's failure: no `onError`, no backoff escalation, just a fixed
212
+ // re-examination interval until the grace window closes or the wallet's holding
213
+ // shows up. Counting it as a failed round would exponentially back off the ONE
214
+ // thing that needs a steady cadence.
215
+ queue.push(...notYet);
216
+ failedRounds = 0;
217
+ armRetry(gateRetryMs);
218
+ }
172
219
  else {
173
220
  failedRounds = 0;
174
221
  }
175
222
  }
176
- function armRetry() {
223
+ function armRetry(fixedDelayMs) {
177
224
  if (stopped || retryTimer !== undefined)
178
225
  return;
179
226
  const ceiling = Math.min(retryCapMs, retryBaseMs * 2 ** (failedRounds - 1));
180
227
  const timer = setTimeout(() => {
181
228
  retryTimer = undefined;
182
229
  kickDrain();
183
- }, Math.random() * ceiling);
230
+ }, fixedDelayMs ?? Math.random() * ceiling);
184
231
  timer.unref?.();
185
232
  retryTimer = timer;
186
233
  }
@@ -209,7 +256,15 @@ export function makeBackgroundVerifier(deps) {
209
256
  if (inFlight.has(key))
210
257
  continue;
211
258
  inFlight.add(key);
212
- queue.push({ ...entry, gateDone: false, gateNotified: false, ruleScore: 0n, resolvedNames: {} });
259
+ queue.push({
260
+ ...entry,
261
+ gateDone: false,
262
+ gateNotified: false,
263
+ ruleScore: 0n,
264
+ gateFailed: undefined,
265
+ resolvedNames: {},
266
+ queuedAt: Date.now()
267
+ });
213
268
  }
214
269
  kickDrain();
215
270
  },
@@ -1,7 +1,7 @@
1
1
  import type { Criteria } from "../schema/criteria.js";
2
2
  import type { RuleRegistry } from "../rules/types.js";
3
3
  import type { ChainClient, BucketMath, NameResolver } from "../chain/types.js";
4
- import type { GateResultCache } from "./gate-result-cache.js";
4
+ import { type RuleCache } from "../rules/cache.js";
5
5
  import { type NameResolutionCache } from "./name-resolution-cache.js";
6
6
  import type { BundleVerifier } from "./types.js";
7
7
  /**
@@ -11,8 +11,11 @@ import type { BundleVerifier } from "./types.js";
11
11
  *
12
12
  * 1. signature (local, µs): recover the EIP-712 signer, must equal `bundle.address`.
13
13
  * 2. constraints (local, µs): `votes.length <= maxVotesPerAddress`, each vote in range.
14
- * 3. gate (chain): the `rule` scores the wallet `> 0n` at the bucket block.
15
- * `0n` -> not admitted -> drop.
14
+ * 3. gate (chain): the `rule` must return `success: true`, at whichever block the
15
+ * rule itself reads (rules/types.ts). A failure -> not admitted ->
16
+ * drop, as a `reject` when the rule blames the sender for it and an
17
+ * `ignore` when it does not, carrying the rule's own `error` text
18
+ * as the verdict reason so the voter is told what actually failed.
16
19
  * 4. name (network): each vote's `community.name` (if any) must resolve to the
17
20
  * claimed `publicKey`; a squatted/absent name drops the bundle.
18
21
  *
@@ -40,13 +43,25 @@ export interface BundleVerifierDeps {
40
43
  /** Host-injected community-name resolvers (`PubsubVoterOptions.nameResolvers`). */
41
44
  nameResolvers: NameResolver[];
42
45
  /**
43
- * Optional cache of gate results, keyed by `(wallet, sampleBlock)`. When present, a wallet's
44
- * score at a bucket's sample block is read from chain at most once a `0n` miss short-circuits
45
- * a flood of fresh-signed bundles from an ineligible wallet, and a `> 0n` hit short-circuits an
46
- * *eligible* wallet re-signing or cycling choices within a bucket. Both are deterministic,
47
- * historical reads. Omitted ⇒ every novel bundle pays its own gate read (prior behaviour).
46
+ * This verifier's current head, handed to the rule as `ctx.head`. A rule scoring pinned
47
+ * historical state never calls it, so a pinned-only deployment does no head read at all.
48
+ * Defaults to a direct `getBlockNumber()` on the rule's own client; the voter injects its
49
+ * coalesced reader instead, so a directory-wide burst shares one read per chain (see
50
+ * client/voter.ts `makeHeadReader`).
48
51
  */
49
- gateResultCache?: GateResultCache;
52
+ readHead?: (args: {
53
+ chain: ChainClient;
54
+ }) => Promise<{
55
+ block: number;
56
+ }>;
57
+ /**
58
+ * The gate rule's memo (see rules/cache.ts), handed to it as `ctx.cache`. This is what keeps
59
+ * a wallet's gate read from repeating per bundle — an ineligible wallet minting fresh-signed
60
+ * bundles, or an eligible one cycling choices, costs one read per key per rule epoch rather
61
+ * than one per bundle. Defaults to a private in-memory cache (unit tests); the voter injects
62
+ * the persistent, contest-shared one.
63
+ */
64
+ ruleCache?: RuleCache;
50
65
  /**
51
66
  * Optional persistent cache of name resolutions (the pkc-js rule — see
52
67
  * verify/name-resolution-cache.ts). When present, a carried name is resolved live at most
@@ -1,18 +1,27 @@
1
1
  import { tickerForRef } from "../chain/ticker.js";
2
+ import { makeMemoryRuleCache } from "../rules/cache.js";
3
+ import { gateFailure, scoreOrZero } from "../rules/result.js";
2
4
  import { UnknownRuleError } from "../errors.js";
3
5
  import { verifyBundleSignature } from "./signature.js";
4
6
  import { checkBundleConstraints } from "./constraints.js";
5
7
  import { resolveNameThroughCache } from "./name-resolution-cache.js";
6
8
  export function makeBundleVerifier(deps) {
7
- const { criteria, criteriaCid, chainId, registry, chainFor, bucketMath, nameResolvers, gateResultCache, nameResolutionCache } = deps;
8
- // Resolve the gate `rule`, its options, and its chain client once. The rule reads at the
9
- // bundle's bucket block, but which rule/chain to use is fixed by the criteria, so it need
10
- // not be recomputed per bundle.
9
+ const { criteria, criteriaCid, chainId, registry, chainFor, bucketMath, nameResolvers, nameResolutionCache } = deps;
10
+ // Resolve the gate `rule`, its options and its chain client once: they are fixed by the
11
+ // criteria, so none of it is recomputed per bundle.
11
12
  const rule = registry[criteria.rule.type];
12
13
  if (!rule)
13
14
  throw new UnknownRuleError("rule", criteria.rule.type);
14
15
  const ruleOptions = rule.optionsSchema.parse(criteria.rule);
15
16
  const ruleChain = chainFor(tickerForRef(criteria, criteria.rule, ruleOptions));
17
+ const readHead = deps.readHead ?? (async ({ chain }) => ({ block: Number(await chain.getBlockNumber()) }));
18
+ // The rule's whole world: its chain, this verifier's head (lazy — never read for a rule that
19
+ // does not want it), and its own memo. Which block it actually reads at is its business.
20
+ const ctx = {
21
+ chain: ruleChain,
22
+ head: () => readHead({ chain: ruleChain }),
23
+ cache: deps.ruleCache ?? makeMemoryRuleCache()
24
+ };
16
25
  // Stage 1, shared by `verify` and `verifyOffline`: signature + constraints, local and µs.
17
26
  const verifyOffline = async (bundle) => {
18
27
  // 1. Signature (free) — a forged/tampered bundle drops before any chain/network read.
@@ -24,27 +33,42 @@ export function makeBundleVerifier(deps) {
24
33
  };
25
34
  return {
26
35
  verifyOffline,
36
+ // The gate step on its own, against the SAME rule/options/ctx `verify` uses below — which
37
+ // is the entire value of exposing it: a caller asking "would this wallet's vote count?"
38
+ // can never drift from what the gate actually does.
39
+ checkGate: ({ address, sampleBlock }) => rule.evaluate({ options: ruleOptions, wallet: { address, sampleBlock }, ctx }),
27
40
  async verify(bundle) {
28
41
  const offline = await verifyOffline(bundle);
29
42
  if (!offline.valid)
30
43
  return offline;
31
- // 3. Gate (chain) — read the `rule` at the bucket's sample block. The score is a pure
32
- // function of a pinned historical block, so it is memoized per `(wallet, sampleBlock)`:
33
- // a cache hit short-circuits the chain read for a flood of fresh-signed bundles from
34
- // the same wallet, whether it is ineligible (`0n`, a `reject`) or eligible (`> 0n`,
35
- // re-signing / cycling choices within one bucket).
44
+ // 3. Gate (chain) — the rule scores this wallet, reading and memoizing however it
45
+ // sees fit. The bundle's bucketized sample block is handed over as the pinned
46
+ // block the ballot names; a rule scoring current state ignores it for `ctx.head`.
36
47
  const sampleBlock = bucketMath.sampleBlockForBucket(bucketMath.bucketForBlock(bundle.blockNumber));
37
- let score = await gateResultCache?.get(bundle.address, sampleBlock);
38
- if (score === undefined) {
39
- ({ score } = await rule.evaluate({
40
- options: ruleOptions,
41
- walletAddress: bundle.address,
42
- ctx: { chain: ruleChain, blockNumber: sampleBlock }
43
- }));
44
- gateResultCache?.set(bundle.address, sampleBlock, score);
45
- }
46
- if (score === 0n) {
47
- return { valid: false, disposition: "reject", reason: `not admitted: rule score is 0n at block ${sampleBlock}` };
48
+ const gate = await rule.evaluate({
49
+ options: ruleOptions,
50
+ wallet: { address: bundle.address, sampleBlock },
51
+ ctx
52
+ });
53
+ const gateFailed = gateFailure(gate);
54
+ if (gateFailed) {
55
+ // Disposition comes from the rule's own answer, never from the rule's identity.
56
+ // A failure the rule stands behind is identical on every honest verifier, so it is
57
+ // a `reject`: the sender is penalized and the verdict cached as terminal. One it
58
+ // will not blame anyone for the rule read this verifier's head, where my view
59
+ // and yours legitimately differ — drops the bundle just the same but stays
60
+ // `ignore`-class: no penalty for a relayer that saw a fresher chain, and
61
+ // uncached, so it is re-judged rather than frozen (the same treatment community
62
+ // name resolution has always had, step 4 below).
63
+ //
64
+ // The reason is the RULE's wording, verbatim: only it knows whether this wallet
65
+ // holds too few, holds none, or faces a contract that gates nothing, and that
66
+ // sentence is what reaches the voter through `VoteEvictedError`.
67
+ return {
68
+ valid: false,
69
+ disposition: gateFailed.penalize ? "reject" : "ignore",
70
+ reason: `not admitted: ${gateFailed.error}`
71
+ };
48
72
  }
49
73
  // 4. Community-name resolution (network) — a carried name is a claim, verified against
50
74
  // the registry. A name that has no resolver, does not resolve, or resolves to a
@@ -78,7 +102,7 @@ export function makeBundleVerifier(deps) {
78
102
  }
79
103
  resolvedNames[name] = record.publicKey;
80
104
  }
81
- return { valid: true, ruleScore: score, resolvedNames };
105
+ return { valid: true, ruleScore: scoreOrZero(gate), resolvedNames };
82
106
  }
83
107
  };
84
108
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * What the pipeline does with a gate score of `0n` that the rule declined to blame on anyone
3
+ * (`RuleResult.penalize: false` — see rules/types.ts).
4
+ *
5
+ * These two numbers are pipeline policy, not rule policy, which is why they live here: they are
6
+ * about how long the library is willing to hold an unsettled bundle in its own working set, not
7
+ * about how any rule reads the chain. What the rule owns — which block it scores at and how long
8
+ * it memoizes the answer — lives in the rule (see rules/cache.ts).
9
+ */
10
+ /**
11
+ * How long the background verifier keeps a provisionally-admitted bundle whose gate scored `0n`
12
+ * without blaming anyone, before giving up on it.
13
+ *
14
+ * An unattributable `0n` means "not yet", not "no": the wallet may have acquired the gate asset
15
+ * in a block this verifier has not seen, or may acquire it seconds from now (a client that signs
16
+ * its ballot the instant it mints races its own transaction). Evicting immediately would make
17
+ * whether a vote counts depend on whose RPC was a few blocks ahead. Retrying forever is the other
18
+ * failure — a never-holder's bundles would pend until they expire, which is memory a spammer
19
+ * chooses. So: re-examine within a grace window, then evict `ignore`-class (uncached, so a later
20
+ * re-publish is judged fresh rather than inheriting this verdict).
21
+ */
22
+ export declare const GATE_GRACE_MS = 120000;
23
+ /**
24
+ * How often a still-`0n` bundle is re-examined inside the grace window.
25
+ *
26
+ * Cheap by design: the rule memoizes its own reads under its own epoch (rules/cache.ts), so a
27
+ * re-examination whose epoch has not rolled costs no chain work at all. The real cost is one
28
+ * batched read per rule epoch, no matter how many bundles are pending or how often this fires —
29
+ * which is also why the grace window must be comfortably longer than the rule's epoch, or the
30
+ * retries would never see a fresh read.
31
+ */
32
+ export declare const GATE_RETRY_MS = 10000;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * What the pipeline does with a gate score of `0n` that the rule declined to blame on anyone
3
+ * (`RuleResult.penalize: false` — see rules/types.ts).
4
+ *
5
+ * These two numbers are pipeline policy, not rule policy, which is why they live here: they are
6
+ * about how long the library is willing to hold an unsettled bundle in its own working set, not
7
+ * about how any rule reads the chain. What the rule owns — which block it scores at and how long
8
+ * it memoizes the answer — lives in the rule (see rules/cache.ts).
9
+ */
10
+ /**
11
+ * How long the background verifier keeps a provisionally-admitted bundle whose gate scored `0n`
12
+ * without blaming anyone, before giving up on it.
13
+ *
14
+ * An unattributable `0n` means "not yet", not "no": the wallet may have acquired the gate asset
15
+ * in a block this verifier has not seen, or may acquire it seconds from now (a client that signs
16
+ * its ballot the instant it mints races its own transaction). Evicting immediately would make
17
+ * whether a vote counts depend on whose RPC was a few blocks ahead. Retrying forever is the other
18
+ * failure — a never-holder's bundles would pend until they expire, which is memory a spammer
19
+ * chooses. So: re-examine within a grace window, then evict `ignore`-class (uncached, so a later
20
+ * re-publish is judged fresh rather than inheriting this verdict).
21
+ */
22
+ export const GATE_GRACE_MS = 120_000;
23
+ /**
24
+ * How often a still-`0n` bundle is re-examined inside the grace window.
25
+ *
26
+ * Cheap by design: the rule memoizes its own reads under its own epoch (rules/cache.ts), so a
27
+ * re-examination whose epoch has not rolled costs no chain work at all. The real cost is one
28
+ * batched read per rule epoch, no matter how many bundles are pending or how often this fires —
29
+ * which is also why the grace window must be comfortably longer than the rule's epoch, or the
30
+ * retries would never see a fresh read.
31
+ */
32
+ export const GATE_RETRY_MS = 10_000;
@@ -1,4 +1,5 @@
1
1
  import type { VotesBundle } from "../schema/votes.js";
2
+ import type { RuleResult } from "../rules/types.js";
2
3
  /**
3
4
  * Verification interfaces, design only.
4
5
  *
@@ -83,6 +84,19 @@ export interface BundleVerifier {
83
84
  * DESIGN.md "Background chain verification").
84
85
  */
85
86
  verifyOffline(bundle: VotesBundle): Promise<VerifyResult>;
87
+ /**
88
+ * Step 3 alone, for a wallet rather than a bundle: run the gate rule and hand back its raw
89
+ * {@link RuleResult}. Backs `Contest.checkEligibility`, so a client can ask "would this vote
90
+ * count?" through the very same rule instance, options, chain client, head reader and memo
91
+ * the forward gate uses — never a reimplementation of them.
92
+ *
93
+ * `sampleBlock` is the pinned block the prospective ballot would name (the caller's current
94
+ * bucket). A head-scoring rule ignores it exactly as it does during verification.
95
+ */
96
+ checkGate(args: {
97
+ address: string;
98
+ sampleBlock: number;
99
+ }): Promise<RuleResult>;
86
100
  }
87
101
  /**
88
102
  * The per-bundle record of the two deferred *network* checks. The offline checks (signature,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitsocial/pubsub-voting",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Trustless pubsub voting over a shared libp2p/Helia node.",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",
@@ -25,6 +25,7 @@
25
25
  "build": "tsc -p tsconfig.json",
26
26
  "typecheck": "tsc -p tsconfig.json --noEmit",
27
27
  "typecheck:examples": "tsc -p tsconfig.examples.json --noEmit",
28
+ "typecheck:bench": "npm run build && tsc -p benchmark/tsconfig.json --noEmit",
28
29
  "test": "vitest run",
29
30
  "test:coverage": "vitest run --coverage",
30
31
  "test:watch": "vitest",
@@ -1,65 +0,0 @@
1
- import type { LruStorage } from "../storage/types.js";
2
- /**
3
- * A bounded cache of gate `rule` results, keyed by `(wallet, sampleBlock)`.
4
- *
5
- * The gate score is a pure function of *historical* chain state — a past, pinned block that never
6
- * changes — so a novel bundle CID from the same wallet at the same sample block scores identically
7
- * and need not repeat the chain read. Memoizing the score (a `bigint`, where `0n` is the "not
8
- * admitted" case) bounds the "one gate read per unique bundle" RPC amplifier (see DESIGN.md
9
- * "Transport", resource-exhaustion residual) for BOTH directions:
10
- *
11
- * - an ineligible wallet minting fresh-signed bundles pays a single chain read per bucket, not
12
- * one per bundle (the `0n` case — the former negative cache); and
13
- * - an *eligible* wallet re-signing / cycling vote choices within a bucket likewise pays one read
14
- * per bucket, not one per fresh CID (the `> 0n` case).
15
- *
16
- * Keyed on `(wallet, sampleBlock)` — NOT wallet alone — so a wallet whose holding *changes* in a
17
- * later bucket is re-read at that bucket's sample block rather than being pinned to a stale score.
18
- *
19
- * `get` is async because the cache may be backed by the voter's persistent store (sqlite /
20
- * IndexedDB — see {@link makePersistentGateResultCache}); `set` returns immediately and lets any
21
- * persistence settle in the background, so the verify hot path never waits on a cache write.
22
- *
23
- * This is the per-CID verdict cache's complement: the verdict cache dedupes *re-announcements of the
24
- * same bundle*; this dedupes *distinct bundles that share a `(wallet, bucket)` gate result*.
25
- */
26
- export interface GateResultCache {
27
- /** The memoized gate score for `(wallet, sampleBlock)`, or `undefined` if not yet read. */
28
- get(wallet: string, sampleBlock: number): Promise<bigint | undefined>;
29
- /** Memoize `(wallet, sampleBlock) -> score` (idempotent; evicts oldest past the cap). */
30
- set(wallet: string, sampleBlock: number, score: bigint): void;
31
- }
32
- /**
33
- * An in-memory {@link GateResultCache} bounded to `maxEntries` with FIFO eviction. Eviction is
34
- * safe because a score is deterministic — an evicted entry only ever costs a re-read, never a
35
- * wrong answer; without a bound, a flood of fresh wallets is a memory-exhaustion vector (see
36
- * DESIGN.md "Can valid votes clog the topic?").
37
- */
38
- export declare function makeGateResultCache(maxEntries?: number): GateResultCache;
39
- /**
40
- * A {@link GateResultCache} layered over the voter's persistent store: an in-memory FIFO front
41
- * (the hot path — steady-state gossip hits it synchronously) with read-through to the store on
42
- * a miss and fire-and-forget write-through on `set`. Scores travel as decimal strings (JSON has
43
- * no bigint). A broken store read or write degrades to a live chain read — never an error into
44
- * the verify pipeline — because everything here is a pure function of pinned historical state.
45
- */
46
- export declare function makePersistentGateResultCache(opts: {
47
- store: LruStorage;
48
- /** Identifies the gate rule (hash of the canonical criteria `rule` + chainId — see voter.ts). */
49
- ruleHash: string;
50
- maxMemEntries?: number;
51
- }): GateResultCache;
52
- /**
53
- * Deterministic expiry purge for one rule's persisted gate results — better than LRU here
54
- * because staleness is *provable*: a score at bucket B's sample block is only ever consulted
55
- * while bundles from B are admissible (within `voteExpiryBuckets` of head), so anything older
56
- * than the oldest admissible sample block can never be read again. Run per contest whenever a
57
- * head read advances the expiry boundary (see the engine's `#maybePurgeGateResults`); the
58
- * store's LRU bound stays as the backstop for rules never purged.
59
- */
60
- export declare function purgeExpiredGateResults(opts: {
61
- store: LruStorage;
62
- ruleHash: string;
63
- /** The oldest admissible bucket's sample block; strictly older entries are dead. */
64
- oldestSampleBlock: number;
65
- }): Promise<void>;
@@ -1,91 +0,0 @@
1
- const memKeyFor = (wallet, sampleBlock) => `${wallet.toLowerCase()}:${sampleBlock}`;
2
- /**
3
- * An in-memory {@link GateResultCache} bounded to `maxEntries` with FIFO eviction. Eviction is
4
- * safe because a score is deterministic — an evicted entry only ever costs a re-read, never a
5
- * wrong answer; without a bound, a flood of fresh wallets is a memory-exhaustion vector (see
6
- * DESIGN.md "Can valid votes clog the topic?").
7
- */
8
- export function makeGateResultCache(maxEntries = 4096) {
9
- const byKey = new Map();
10
- const order = [];
11
- return {
12
- get: async (wallet, sampleBlock) => byKey.get(memKeyFor(wallet, sampleBlock)),
13
- set: (wallet, sampleBlock, score) => {
14
- const k = memKeyFor(wallet, sampleBlock);
15
- if (byKey.has(k))
16
- return; // idempotent: never refresh position or overwrite a pinned score
17
- byKey.set(k, score);
18
- order.push(k);
19
- if (order.length > maxEntries) {
20
- const evicted = order.shift();
21
- if (evicted !== undefined)
22
- byKey.delete(evicted);
23
- }
24
- }
25
- };
26
- }
27
- /** The persistent gate store's key. `ruleHash` disambiguates: the shared store spans every
28
- * contest on the voter, and one wallet can hold different scores under different gate rules
29
- * (or the same rule at different chainIds). Same score under the same rule is what lets two
30
- * contests over one gate (a 5chan-style directory) share each other's reads. */
31
- const storeKeyFor = (ruleHash, wallet, sampleBlock) => `${ruleHash}:${wallet.toLowerCase()}:${sampleBlock}`;
32
- /**
33
- * A {@link GateResultCache} layered over the voter's persistent store: an in-memory FIFO front
34
- * (the hot path — steady-state gossip hits it synchronously) with read-through to the store on
35
- * a miss and fire-and-forget write-through on `set`. Scores travel as decimal strings (JSON has
36
- * no bigint). A broken store read or write degrades to a live chain read — never an error into
37
- * the verify pipeline — because everything here is a pure function of pinned historical state.
38
- */
39
- export function makePersistentGateResultCache(opts) {
40
- const { store, ruleHash } = opts;
41
- const mem = makeGateResultCache(opts.maxMemEntries);
42
- return {
43
- async get(wallet, sampleBlock) {
44
- const cached = await mem.get(wallet, sampleBlock);
45
- if (cached !== undefined)
46
- return cached;
47
- let persisted;
48
- try {
49
- persisted = await store.getItem(storeKeyFor(ruleHash, wallet, sampleBlock));
50
- }
51
- catch {
52
- return undefined;
53
- }
54
- if (typeof persisted !== "string" || !/^\d+$/.test(persisted))
55
- return undefined;
56
- const score = BigInt(persisted);
57
- mem.set(wallet, sampleBlock, score);
58
- return score;
59
- },
60
- set(wallet, sampleBlock, score) {
61
- mem.set(wallet, sampleBlock, score);
62
- void store.setItem(storeKeyFor(ruleHash, wallet, sampleBlock), score.toString()).catch(() => {
63
- // a failed persist costs a future re-read, never a wrong answer
64
- });
65
- }
66
- };
67
- }
68
- /**
69
- * Deterministic expiry purge for one rule's persisted gate results — better than LRU here
70
- * because staleness is *provable*: a score at bucket B's sample block is only ever consulted
71
- * while bundles from B are admissible (within `voteExpiryBuckets` of head), so anything older
72
- * than the oldest admissible sample block can never be read again. Run per contest whenever a
73
- * head read advances the expiry boundary (see the engine's `#maybePurgeGateResults`); the
74
- * store's LRU bound stays as the backstop for rules never purged.
75
- */
76
- export async function purgeExpiredGateResults(opts) {
77
- const prefix = `${opts.ruleHash}:`;
78
- try {
79
- for (const key of await opts.store.keys()) {
80
- if (!key.startsWith(prefix))
81
- continue;
82
- const sampleBlock = Number(key.slice(key.lastIndexOf(":") + 1));
83
- if (Number.isFinite(sampleBlock) && sampleBlock < opts.oldestSampleBlock) {
84
- await opts.store.removeItem(key);
85
- }
86
- }
87
- }
88
- catch {
89
- // purge is best-effort; the store's LRU bound is the correctness-free backstop
90
- }
91
- }