@bitsocial/pubsub-voting 0.2.1 → 0.3.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.
@@ -3,7 +3,7 @@ import type { VotesBundle } from "../schema/votes.js";
3
3
  import type { Criteria } from "../schema/criteria.js";
4
4
  import type { RuleRegistry } from "../rules/types.js";
5
5
  import type { ChainClient, BucketMath, NameResolver } from "../chain/types.js";
6
- import type { GateResultCache } from "./gate-result-cache.js";
6
+ import { type RuleCache } from "../rules/cache.js";
7
7
  import { type NameResolutionCache } from "./name-resolution-cache.js";
8
8
  import type { VerdictCache } from "./cache.js";
9
9
  import type { VerifyFail } from "./types.js";
@@ -16,16 +16,25 @@ import type { VerifyFail } from "./types.js";
16
16
  * `chainVerified: false` rows, and this verifier confirms or evicts in the background (see
17
17
  * DESIGN.md "Background chain verification").
18
18
  *
19
- * Batched, not sequential: a checkpoint's bundles share one bucket sample block, so the gate
20
- * stage groups pending wallets per sample block and prefers the rule's `evaluateMany` (one
21
- * multicall3 round trip for N wallets) over N serial `readContract` calls, falling back to
22
- * `limit`-bounded per-wallet reads for rules without a batched form. Results feed the shared
23
- * `(wallet, sampleBlock)` gate-result cache, and a settled bundle's terminal verdict feeds the
24
- * shared per-CID verdict cache so a later re-publish of the same bundle short-circuits at
25
- * the gossip gate with zero chain work.
19
+ * Batched, not sequential: the gate stage hands a whole round's pending wallets to the rule's
20
+ * `evaluateMany` (one multicall3 round trip for N wallets) rather than making N serial
21
+ * `readContract` calls, falling back to `limit`-bounded per-wallet `evaluate` for rules without
22
+ * a batched form. It does NOT group them by block which block each wallet is read at is the
23
+ * rule's business now, and a rule that needs grouping does it itself (see rules/types.ts).
24
+ * Deduping and memoizing reads is likewise the rule's, through the cache it is handed
25
+ * (rules/cache.ts), so a wallet settled by the forward gate costs nothing here; what this stage
26
+ * still owns is the per-CID verdict cache, so a later re-publish of a settled bundle
27
+ * short-circuits at the gossip gate with zero chain work.
26
28
  *
27
29
  * Failure classes are kept apart, mirroring the forward-gate's `reject`/`ignore` split:
28
- * - gate scores `0n` → EVICT + cache the provable `reject` (deterministic).
30
+ * - gate `0n`, blamed on the sender → EVICT + cache the `reject` (the rule stands behind it:
31
+ * every honest verifier computes the same score, so terminal).
32
+ * - gate `0n`, blamed on nobody → "not yet", NOT "no": the item is re-examined until a grace
33
+ * window closes (verify/gate-grace.ts), then evicted
34
+ * `ignore`-class and uncached. A wallet that acquired the gate
35
+ * asset seconds ago scores `0n` only for whoever's head lags,
36
+ * so evicting on the spot would let RPC lag decide whether a
37
+ * vote counts.
29
38
  * - name missing/mismatched → EVICT, NOT cached (view-dependent `ignore`-class: v1
30
39
  * resolves at head — see verify/bundle.ts step 4).
31
40
  * - RPC / resolver THREW → infra, nobody's verdict: the bundle STAYS pending, the
@@ -46,8 +55,22 @@ export interface BackgroundVerifierDeps {
46
55
  chainFor: (ticker: string) => ChainClient;
47
56
  bucketMath: BucketMath;
48
57
  nameResolvers: NameResolver[];
49
- /** Shared `(wallet, sampleBlock)` gate scores — batch results land here, hits skip the read. */
50
- gateResultCache: GateResultCache;
58
+ /**
59
+ * The gate rule's memo, handed to it as `ctx.cache` (rules/cache.ts). Shared with the inline
60
+ * forward-gate verifier, so neither re-reads what the other settled.
61
+ */
62
+ ruleCache?: RuleCache;
63
+ /**
64
+ * This verifier's current head, handed to the rule as `ctx.head`. Resolved by the rule at
65
+ * most once per batch, so a round stays batchable. Never called by a rule that scores pinned
66
+ * historical state. Defaults to the rule chain's own `getBlockNumber()`; the voter injects
67
+ * its coalesced reader.
68
+ */
69
+ readHead?: (args: {
70
+ chain: ChainClient;
71
+ }) => Promise<{
72
+ block: number;
73
+ }>;
51
74
  /** Shared persistent name-resolution cache (pkc-js rule, 1h max-age); omitted ⇒ resolve live. */
52
75
  nameResolutionCache?: NameResolutionCache;
53
76
  /** The gate's per-CID verdict cache — a settled bundle's terminal verdict is stored here. */
@@ -65,6 +88,14 @@ export interface BackgroundVerifierDeps {
65
88
  /** Infra-retry backoff base / cap (ms). Full-jittered exponential between rounds. */
66
89
  retryBaseMs?: number;
67
90
  retryCapMs?: number;
91
+ /**
92
+ * Grace / re-examination interval (ms) — how long a `0n` the rule blamed on nobody is
93
+ * treated as "not yet" before the bundle is dropped, and how often it is looked at in the
94
+ * meantime. Defaults to {@link GATE_GRACE_MS} / {@link GATE_RETRY_MS}; overridable so tests
95
+ * do not sit through the real window. Unused when the rule blames the sender.
96
+ */
97
+ gateGraceMs?: number;
98
+ gateRetryMs?: number;
68
99
  }
69
100
  export interface BackgroundChainVerifier {
70
101
  /** Queue provisionally admitted bundles and return immediately; the drain runs detached. */
@@ -1,12 +1,16 @@
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";
2
4
  import { UnknownRuleError } from "../errors.js";
3
5
  import { resolveNameThroughCache } from "./name-resolution-cache.js";
4
6
  const RETRY_BASE_MS = 2_000;
5
7
  const RETRY_CAP_MS = 60_000;
6
8
  export function makeBackgroundVerifier(deps) {
7
- const { criteria, registry, chainFor, bucketMath, nameResolvers, gateResultCache, nameResolutionCache, cache, limit } = deps;
9
+ const { criteria, registry, chainFor, bucketMath, nameResolvers, nameResolutionCache, cache, limit } = deps;
8
10
  const retryBaseMs = deps.retryBaseMs ?? RETRY_BASE_MS;
9
11
  const retryCapMs = deps.retryCapMs ?? RETRY_CAP_MS;
12
+ const gateGraceMs = deps.gateGraceMs ?? GATE_GRACE_MS;
13
+ const gateRetryMs = deps.gateRetryMs ?? GATE_RETRY_MS;
10
14
  // Resolve the gate `rule`, its options, and its chain once (same shape as verify/bundle.ts).
11
15
  // The re-binding after the guard keeps the non-undefined narrowing inside the closures below.
12
16
  const maybeRule = registry[criteria.rule.type];
@@ -15,6 +19,12 @@ export function makeBackgroundVerifier(deps) {
15
19
  const rule = maybeRule;
16
20
  const ruleOptions = rule.optionsSchema.parse(criteria.rule);
17
21
  const ruleChain = chainFor(tickerForRef(criteria, criteria.rule, ruleOptions));
22
+ const readHead = deps.readHead ?? (async ({ chain }) => ({ block: Number(await chain.getBlockNumber()) }));
23
+ const ctx = {
24
+ chain: ruleChain,
25
+ head: () => readHead({ chain: ruleChain }),
26
+ cache: deps.ruleCache ?? makeMemoryRuleCache()
27
+ };
18
28
  const queue = [];
19
29
  /** CIDs queued or in-flight, so a re-chased root cannot double-verify a bundle. */
20
30
  const inFlight = new Set();
@@ -38,38 +48,39 @@ export function makeBackgroundVerifier(deps) {
38
48
  return bucketMath.sampleBlockForBucket(bucketMath.bucketForBlock(bundle.blockNumber));
39
49
  }
40
50
  /**
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.
51
+ * Gate stage for one round's batch: hand every not-yet-gated wallet to the rule at once —
52
+ * `evaluateMany` when it has one, `limit`-bounded per-wallet `evaluate` otherwise — and
53
+ * record each score with the rule's own verdict on whether a `0n` is attributable.
54
+ *
55
+ * No grouping and no cache lookups here any more: which block each wallet is read at, and
56
+ * what may be memoized under which key, are the rule's decisions (rules/types.ts,
57
+ * rules/cache.ts). Duplicate wallets are still collapsed before the call, because that is a
58
+ * property of THIS batch rather than of any rule. Throws on the FIRST infra failure: the
59
+ * round's unfinished items are re-queued by the caller.
45
60
  */
46
61
  async function gateStage(items) {
47
- const groups = new Map();
48
- for (const item of items) {
49
- if (item.gateDone)
62
+ const pending = items.filter((item) => !item.gateDone);
63
+ if (pending.length === 0)
64
+ return;
65
+ const wallets = [];
66
+ const at = new Map();
67
+ for (const item of pending) {
68
+ const wallet = { address: item.bundle.address, sampleBlock: sampleBlockFor(item.bundle) };
69
+ const key = `${wallet.address.toLowerCase()}:${wallet.sampleBlock}`;
70
+ if (at.has(key))
50
71
  continue;
51
- const block = sampleBlockFor(item.bundle);
52
- groups.set(block, [...(groups.get(block) ?? []), item]);
72
+ at.set(key, wallets.length);
73
+ wallets.push(wallet);
53
74
  }
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
- }
75
+ const results = rule.evaluateMany
76
+ ? (await rule.evaluateMany({ options: ruleOptions, wallets, ctx })).results
77
+ : await Promise.all(wallets.map((wallet) => limit(() => rule.evaluate({ options: ruleOptions, wallet, ctx }))));
78
+ for (const item of pending) {
79
+ const key = `${item.bundle.address.toLowerCase()}:${sampleBlockFor(item.bundle)}`;
80
+ const result = results[at.get(key)];
81
+ item.ruleScore = result.score;
82
+ item.gatePenalize = result.penalize !== false;
83
+ item.gateDone = true;
73
84
  }
74
85
  }
75
86
  /**
@@ -115,6 +126,8 @@ export function makeBackgroundVerifier(deps) {
115
126
  async function round() {
116
127
  const batch = queue.splice(0);
117
128
  const requeue = [];
129
+ /** Unattributable `0n` items still inside their grace window: re-examined, not a failure. */
130
+ const notYet = [];
118
131
  let infraError;
119
132
  // Gate stage first, whole batch: this is where batching wins (one multicall per sample
120
133
  // block instead of one read per wallet). An infra throw leaves every un-gated item intact.
@@ -131,13 +144,37 @@ export function makeBackgroundVerifier(deps) {
131
144
  continue;
132
145
  }
133
146
  if (item.ruleScore === 0n) {
134
- // Provable, deterministic reject — safe to cache so a re-publish short-circuits.
147
+ if (item.gatePenalize) {
148
+ // Provable, deterministic reject — safe to cache so a re-publish short-circuits.
149
+ const verdict = {
150
+ valid: false,
151
+ disposition: "reject",
152
+ reason: `not admitted: rule score is 0n`
153
+ };
154
+ cache.set(item.cid, verdict);
155
+ deps.onEvict(item.cid, verdict);
156
+ settle(item);
157
+ continue;
158
+ }
159
+ // The rule declined to blame anyone (see rules/types.ts, RuleResult.penalize):
160
+ // `0n` means "not yet", not "no". The wallet may have acquired the gate asset in
161
+ // a block this verifier has not seen, or may acquire it a moment from now — a
162
+ // client that signs the instant it mints races its own transaction. Evicting here
163
+ // would make whether a vote counts depend on whose RPC was a few blocks ahead, so
164
+ // the item is re-examined until the grace window closes; only then is it dropped,
165
+ // `ignore`-class and UNCACHED, so a later re-publish is judged fresh.
166
+ // Re-examining is nearly free: while the rule's own memo holds, its re-read comes
167
+ // straight from that cache and touches no chain (see verify/gate-grace.ts).
168
+ if (Date.now() - item.queuedAt < gateGraceMs) {
169
+ item.gateDone = false;
170
+ notYet.push(item);
171
+ continue;
172
+ }
135
173
  const verdict = {
136
174
  valid: false,
137
- disposition: "reject",
138
- reason: `not admitted: rule score is 0n at block ${sampleBlockFor(item.bundle)}`
175
+ disposition: "ignore",
176
+ reason: `not admitted: rule score is 0n, and still 0n after the grace window`
139
177
  };
140
- cache.set(item.cid, verdict);
141
178
  deps.onEvict(item.cid, verdict);
142
179
  settle(item);
143
180
  continue;
@@ -164,23 +201,32 @@ export function makeBackgroundVerifier(deps) {
164
201
  settle(item);
165
202
  }
166
203
  if (requeue.length > 0) {
167
- queue.push(...requeue);
204
+ queue.push(...requeue, ...notYet);
168
205
  failedRounds += 1;
169
206
  deps.onError(infraError);
170
207
  armRetry();
171
208
  }
209
+ else if (notYet.length > 0) {
210
+ // "Not yet" is nobody's failure: no `onError`, no backoff escalation, just a fixed
211
+ // re-examination interval until the grace window closes or the wallet's holding
212
+ // shows up. Counting it as a failed round would exponentially back off the ONE
213
+ // thing that needs a steady cadence.
214
+ queue.push(...notYet);
215
+ failedRounds = 0;
216
+ armRetry(gateRetryMs);
217
+ }
172
218
  else {
173
219
  failedRounds = 0;
174
220
  }
175
221
  }
176
- function armRetry() {
222
+ function armRetry(fixedDelayMs) {
177
223
  if (stopped || retryTimer !== undefined)
178
224
  return;
179
225
  const ceiling = Math.min(retryCapMs, retryBaseMs * 2 ** (failedRounds - 1));
180
226
  const timer = setTimeout(() => {
181
227
  retryTimer = undefined;
182
228
  kickDrain();
183
- }, Math.random() * ceiling);
229
+ }, fixedDelayMs ?? Math.random() * ceiling);
184
230
  timer.unref?.();
185
231
  retryTimer = timer;
186
232
  }
@@ -209,7 +255,15 @@ export function makeBackgroundVerifier(deps) {
209
255
  if (inFlight.has(key))
210
256
  continue;
211
257
  inFlight.add(key);
212
- queue.push({ ...entry, gateDone: false, gateNotified: false, ruleScore: 0n, resolvedNames: {} });
258
+ queue.push({
259
+ ...entry,
260
+ gateDone: false,
261
+ gateNotified: false,
262
+ ruleScore: 0n,
263
+ gatePenalize: true,
264
+ resolvedNames: {},
265
+ queuedAt: Date.now()
266
+ });
213
267
  }
214
268
  kickDrain();
215
269
  },
@@ -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,10 @@ 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` scores the wallet `> 0n`, at whichever block the rule
15
+ * itself reads (rules/types.ts). `0n` -> not admitted -> drop, as
16
+ * a `reject` when the rule blames the sender for it and an
17
+ * `ignore` when it does not.
16
18
  * 4. name (network): each vote's `community.name` (if any) must resolve to the
17
19
  * claimed `publicKey`; a squatted/absent name drops the bundle.
18
20
  *
@@ -40,13 +42,25 @@ export interface BundleVerifierDeps {
40
42
  /** Host-injected community-name resolvers (`PubsubVoterOptions.nameResolvers`). */
41
43
  nameResolvers: NameResolver[];
42
44
  /**
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).
45
+ * This verifier's current head, handed to the rule as `ctx.head`. A rule scoring pinned
46
+ * historical state never calls it, so a pinned-only deployment does no head read at all.
47
+ * Defaults to a direct `getBlockNumber()` on the rule's own client; the voter injects its
48
+ * coalesced reader instead, so a directory-wide burst shares one read per chain (see
49
+ * client/voter.ts `makeHeadReader`).
48
50
  */
49
- gateResultCache?: GateResultCache;
51
+ readHead?: (args: {
52
+ chain: ChainClient;
53
+ }) => Promise<{
54
+ block: number;
55
+ }>;
56
+ /**
57
+ * The gate rule's memo (see rules/cache.ts), handed to it as `ctx.cache`. This is what keeps
58
+ * a wallet's gate read from repeating per bundle — an ineligible wallet minting fresh-signed
59
+ * bundles, or an eligible one cycling choices, costs one read per key per rule epoch rather
60
+ * than one per bundle. Defaults to a private in-memory cache (unit tests); the voter injects
61
+ * the persistent, contest-shared one.
62
+ */
63
+ ruleCache?: RuleCache;
50
64
  /**
51
65
  * Optional persistent cache of name resolutions (the pkc-js rule — see
52
66
  * verify/name-resolution-cache.ts). When present, a carried name is resolved live at most
@@ -1,18 +1,26 @@
1
1
  import { tickerForRef } from "../chain/ticker.js";
2
+ import { makeMemoryRuleCache } from "../rules/cache.js";
2
3
  import { UnknownRuleError } from "../errors.js";
3
4
  import { verifyBundleSignature } from "./signature.js";
4
5
  import { checkBundleConstraints } from "./constraints.js";
5
6
  import { resolveNameThroughCache } from "./name-resolution-cache.js";
6
7
  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.
8
+ const { criteria, criteriaCid, chainId, registry, chainFor, bucketMath, nameResolvers, nameResolutionCache } = deps;
9
+ // Resolve the gate `rule`, its options and its chain client once: they are fixed by the
10
+ // criteria, so none of it is recomputed per bundle.
11
11
  const rule = registry[criteria.rule.type];
12
12
  if (!rule)
13
13
  throw new UnknownRuleError("rule", criteria.rule.type);
14
14
  const ruleOptions = rule.optionsSchema.parse(criteria.rule);
15
15
  const ruleChain = chainFor(tickerForRef(criteria, criteria.rule, ruleOptions));
16
+ const readHead = deps.readHead ?? (async ({ chain }) => ({ block: Number(await chain.getBlockNumber()) }));
17
+ // The rule's whole world: its chain, this verifier's head (lazy — never read for a rule that
18
+ // does not want it), and its own memo. Which block it actually reads at is its business.
19
+ const ctx = {
20
+ chain: ruleChain,
21
+ head: () => readHead({ chain: ruleChain }),
22
+ cache: deps.ruleCache ?? makeMemoryRuleCache()
23
+ };
16
24
  // Stage 1, shared by `verify` and `verifyOffline`: signature + constraints, local and µs.
17
25
  const verifyOffline = async (bundle) => {
18
26
  // 1. Signature (free) — a forged/tampered bundle drops before any chain/network read.
@@ -28,23 +36,29 @@ export function makeBundleVerifier(deps) {
28
36
  const offline = await verifyOffline(bundle);
29
37
  if (!offline.valid)
30
38
  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).
39
+ // 3. Gate (chain) — the rule scores this wallet, reading and memoizing however it
40
+ // sees fit. The bundle's bucketized sample block is handed over as the pinned
41
+ // block the ballot names; a rule scoring current state ignores it for `ctx.head`.
36
42
  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
- }
43
+ const { score, penalize } = await rule.evaluate({
44
+ options: ruleOptions,
45
+ wallet: { address: bundle.address, sampleBlock },
46
+ ctx
47
+ });
46
48
  if (score === 0n) {
47
- return { valid: false, disposition: "reject", reason: `not admitted: rule score is 0n at block ${sampleBlock}` };
49
+ // Disposition comes from the rule's own answer, never from the rule's identity.
50
+ // A `0n` the rule stands behind is identical on every honest verifier, so it is a
51
+ // `reject`: the sender is penalized and the verdict cached as terminal. A `0n` it
52
+ // will not blame anyone for — the rule read this verifier's head, where my view
53
+ // and yours legitimately differ — drops the bundle just the same but stays
54
+ // `ignore`-class: no penalty for a relayer that saw a fresher chain, and
55
+ // uncached, so it is re-judged rather than frozen (the same treatment community
56
+ // name resolution has always had, step 4 below).
57
+ return {
58
+ valid: false,
59
+ disposition: penalize === false ? "ignore" : "reject",
60
+ reason: `not admitted: rule score is 0n`
61
+ };
48
62
  }
49
63
  // 4. Community-name resolution (network) — a carried name is a claim, verified against
50
64
  // the registry. A name that has no resolver, does not resolve, or resolves to a
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitsocial/pubsub-voting",
3
- "version": "0.2.1",
3
+ "version": "0.3.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>;