@bitsocial/pubsub-voting 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +223 -0
  3. package/dist/chain/bucket.d.ts +13 -0
  4. package/dist/chain/bucket.js +24 -0
  5. package/dist/chain/coalescer.d.ts +56 -0
  6. package/dist/chain/coalescer.js +217 -0
  7. package/dist/chain/ticker.d.ts +15 -0
  8. package/dist/chain/ticker.js +25 -0
  9. package/dist/chain/types.d.ts +90 -0
  10. package/dist/chain/types.js +1 -0
  11. package/dist/checkpoint/codec.d.ts +54 -0
  12. package/dist/checkpoint/codec.js +99 -0
  13. package/dist/client/root-puller.d.ts +49 -0
  14. package/dist/client/root-puller.js +140 -0
  15. package/dist/client/voter.d.ts +239 -0
  16. package/dist/client/voter.js +1343 -0
  17. package/dist/crdt/codec.d.ts +41 -0
  18. package/dist/crdt/codec.js +137 -0
  19. package/dist/crdt/crdt.d.ts +22 -0
  20. package/dist/crdt/crdt.js +127 -0
  21. package/dist/crdt/store.d.ts +8 -0
  22. package/dist/crdt/store.js +23 -0
  23. package/dist/crdt/types.d.ts +87 -0
  24. package/dist/crdt/types.js +1 -0
  25. package/dist/encoding/canonical.d.ts +22 -0
  26. package/dist/encoding/canonical.js +26 -0
  27. package/dist/errors.d.ts +81 -0
  28. package/dist/errors.js +126 -0
  29. package/dist/index.d.ts +32 -0
  30. package/dist/index.js +43 -0
  31. package/dist/rules/constant.d.ts +14 -0
  32. package/dist/rules/constant.js +18 -0
  33. package/dist/rules/erc20-balance.d.ts +30 -0
  34. package/dist/rules/erc20-balance.js +44 -0
  35. package/dist/rules/erc721-min-balance.d.ts +18 -0
  36. package/dist/rules/erc721-min-balance.js +100 -0
  37. package/dist/rules/registry.d.ts +42 -0
  38. package/dist/rules/registry.js +61 -0
  39. package/dist/rules/types.d.ts +74 -0
  40. package/dist/rules/types.js +1 -0
  41. package/dist/schema/common.d.ts +25 -0
  42. package/dist/schema/common.js +24 -0
  43. package/dist/schema/criteria.d.ts +78 -0
  44. package/dist/schema/criteria.js +76 -0
  45. package/dist/schema/directory.d.ts +42 -0
  46. package/dist/schema/directory.js +52 -0
  47. package/dist/schema/votes.d.ts +55 -0
  48. package/dist/schema/votes.js +116 -0
  49. package/dist/signer/eip712.d.ts +103 -0
  50. package/dist/signer/eip712.js +85 -0
  51. package/dist/signer/types.d.ts +31 -0
  52. package/dist/signer/types.js +1 -0
  53. package/dist/storage/browser.d.ts +3 -0
  54. package/dist/storage/browser.js +110 -0
  55. package/dist/storage/memory.d.ts +10 -0
  56. package/dist/storage/memory.js +56 -0
  57. package/dist/storage/node.d.ts +5 -0
  58. package/dist/storage/node.js +106 -0
  59. package/dist/storage/types.d.ts +46 -0
  60. package/dist/storage/types.js +1 -0
  61. package/dist/store/indexeddb.d.ts +9 -0
  62. package/dist/store/indexeddb.js +72 -0
  63. package/dist/store/memory.d.ts +15 -0
  64. package/dist/store/memory.js +22 -0
  65. package/dist/store/select.d.ts +15 -0
  66. package/dist/store/select.js +64 -0
  67. package/dist/store/sqlite.d.ts +11 -0
  68. package/dist/store/sqlite.js +68 -0
  69. package/dist/store/types.d.ts +57 -0
  70. package/dist/store/types.js +1 -0
  71. package/dist/tally/tally.d.ts +44 -0
  72. package/dist/tally/tally.js +89 -0
  73. package/dist/tally/types.d.ts +51 -0
  74. package/dist/tally/types.js +13 -0
  75. package/dist/topic.d.ts +20 -0
  76. package/dist/topic.js +28 -0
  77. package/dist/transport/accepted-dedup.d.ts +30 -0
  78. package/dist/transport/accepted-dedup.js +34 -0
  79. package/dist/transport/announce/browser.d.ts +9 -0
  80. package/dist/transport/announce/browser.js +14 -0
  81. package/dist/transport/announce/node.d.ts +38 -0
  82. package/dist/transport/announce/node.js +162 -0
  83. package/dist/transport/announce/types.d.ts +74 -0
  84. package/dist/transport/announce/types.js +16 -0
  85. package/dist/transport/bundle-store.d.ts +11 -0
  86. package/dist/transport/bundle-store.js +34 -0
  87. package/dist/transport/chase.d.ts +125 -0
  88. package/dist/transport/chase.js +182 -0
  89. package/dist/transport/gossip-validator.d.ts +107 -0
  90. package/dist/transport/gossip-validator.js +99 -0
  91. package/dist/transport/helia.d.ts +55 -0
  92. package/dist/transport/helia.js +114 -0
  93. package/dist/transport/integration/harness.d.ts +65 -0
  94. package/dist/transport/integration/harness.js +278 -0
  95. package/dist/transport/messages.d.ts +97 -0
  96. package/dist/transport/messages.js +117 -0
  97. package/dist/transport/rate-limit.d.ts +11 -0
  98. package/dist/transport/rate-limit.js +20 -0
  99. package/dist/transport/transport.d.ts +20 -0
  100. package/dist/transport/transport.js +35 -0
  101. package/dist/transport/types.d.ts +189 -0
  102. package/dist/transport/types.js +1 -0
  103. package/dist/verify/background.d.ts +81 -0
  104. package/dist/verify/background.js +236 -0
  105. package/dist/verify/bundle.d.ts +58 -0
  106. package/dist/verify/bundle.js +84 -0
  107. package/dist/verify/cache.d.ts +48 -0
  108. package/dist/verify/cache.js +62 -0
  109. package/dist/verify/constraints.d.ts +16 -0
  110. package/dist/verify/constraints.js +35 -0
  111. package/dist/verify/gate-result-cache.d.ts +65 -0
  112. package/dist/verify/gate-result-cache.js +91 -0
  113. package/dist/verify/name-resolution-cache.d.ts +59 -0
  114. package/dist/verify/name-resolution-cache.js +64 -0
  115. package/dist/verify/signature.d.ts +9 -0
  116. package/dist/verify/signature.js +55 -0
  117. package/dist/verify/types.d.ts +101 -0
  118. package/dist/verify/types.js +1 -0
  119. package/package.json +78 -0
@@ -0,0 +1,236 @@
1
+ import { tickerForRef } from "../chain/ticker.js";
2
+ import { UnknownRuleError } from "../errors.js";
3
+ import { resolveNameThroughCache } from "./name-resolution-cache.js";
4
+ const RETRY_BASE_MS = 2_000;
5
+ const RETRY_CAP_MS = 60_000;
6
+ export function makeBackgroundVerifier(deps) {
7
+ const { criteria, registry, chainFor, bucketMath, nameResolvers, gateResultCache, nameResolutionCache, cache, limit } = deps;
8
+ const retryBaseMs = deps.retryBaseMs ?? RETRY_BASE_MS;
9
+ const retryCapMs = deps.retryCapMs ?? RETRY_CAP_MS;
10
+ // Resolve the gate `rule`, its options, and its chain once (same shape as verify/bundle.ts).
11
+ // The re-binding after the guard keeps the non-undefined narrowing inside the closures below.
12
+ const maybeRule = registry[criteria.rule.type];
13
+ if (!maybeRule)
14
+ throw new UnknownRuleError("rule", criteria.rule.type);
15
+ const rule = maybeRule;
16
+ const ruleOptions = rule.optionsSchema.parse(criteria.rule);
17
+ const ruleChain = chainFor(tickerForRef(criteria, criteria.rule, ruleOptions));
18
+ const queue = [];
19
+ /** CIDs queued or in-flight, so a re-chased root cannot double-verify a bundle. */
20
+ const inFlight = new Set();
21
+ let draining = false;
22
+ let stopped = false;
23
+ /** Consecutive infra-failed rounds, driving the backoff exponent. */
24
+ let failedRounds = 0;
25
+ let retryTimer;
26
+ const idleResolvers = [];
27
+ function settle(item) {
28
+ inFlight.delete(item.cid.toString());
29
+ }
30
+ function maybeResolveIdle() {
31
+ if (queue.length === 0 && !draining && retryTimer === undefined) {
32
+ for (const resolve of idleResolvers.splice(0))
33
+ resolve();
34
+ }
35
+ }
36
+ /** The wallet's gate score at its bundle's bucket sample block. */
37
+ function sampleBlockFor(bundle) {
38
+ return bucketMath.sampleBlockForBucket(bucketMath.bucketForBlock(bundle.blockNumber));
39
+ }
40
+ /**
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.
45
+ */
46
+ async function gateStage(items) {
47
+ const groups = new Map();
48
+ for (const item of items) {
49
+ if (item.gateDone)
50
+ continue;
51
+ const block = sampleBlockFor(item.bundle);
52
+ groups.set(block, [...(groups.get(block) ?? []), item]);
53
+ }
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
+ }
73
+ }
74
+ }
75
+ /**
76
+ * Settle one gate-passed item's name checks. Resolutions are deduped per round via
77
+ * `resolutions`. Returns "verified" | "evicted"; throws on a resolver infra failure
78
+ * (the caller re-queues the item — its `gateDone` survives, so only names re-run).
79
+ */
80
+ async function nameStage(item, resolutions) {
81
+ for (const v of item.bundle.votes) {
82
+ const name = v.community.name;
83
+ if (!name || item.resolvedNames[name])
84
+ continue;
85
+ const resolver = nameResolvers.find((r) => r.canResolve({ name }));
86
+ if (!resolver) {
87
+ // `ignore`-class, view-dependent (a missing resolver differs per verifier) — evict,
88
+ // never cache (see verify/bundle.ts step 4).
89
+ deps.onEvict(item.cid, { valid: false, disposition: "ignore", reason: `no resolver handles community name "${name}"` });
90
+ return "evicted";
91
+ }
92
+ let resolution = resolutions.get(name);
93
+ if (!resolution) {
94
+ resolution = limit(() => resolveNameThroughCache({ resolver, name, cache: nameResolutionCache }));
95
+ resolutions.set(name, resolution);
96
+ }
97
+ const record = await resolution;
98
+ if (!record) {
99
+ deps.onEvict(item.cid, { valid: false, disposition: "ignore", reason: `community name "${name}" does not resolve` });
100
+ return "evicted";
101
+ }
102
+ if (record.publicKey !== v.community.publicKey) {
103
+ deps.onEvict(item.cid, {
104
+ valid: false,
105
+ disposition: "ignore",
106
+ reason: `community name "${name}" resolves to ${record.publicKey}, not the claimed ${v.community.publicKey}`
107
+ });
108
+ return "evicted";
109
+ }
110
+ item.resolvedNames[name] = record.publicKey;
111
+ }
112
+ return "verified";
113
+ }
114
+ /** One drain round over everything currently queued. Re-queues + backs off on infra failure. */
115
+ async function round() {
116
+ const batch = queue.splice(0);
117
+ const requeue = [];
118
+ let infraError;
119
+ // Gate stage first, whole batch: this is where batching wins (one multicall per sample
120
+ // block instead of one read per wallet). An infra throw leaves every un-gated item intact.
121
+ try {
122
+ await gateStage(batch);
123
+ }
124
+ catch (error) {
125
+ infraError = error;
126
+ }
127
+ const resolutions = new Map();
128
+ for (const item of batch) {
129
+ if (!item.gateDone) {
130
+ requeue.push(item); // gate read never happened (infra) — retry the whole item
131
+ continue;
132
+ }
133
+ if (item.ruleScore === 0n) {
134
+ // Provable, deterministic reject — safe to cache so a re-publish short-circuits.
135
+ const verdict = {
136
+ valid: false,
137
+ disposition: "reject",
138
+ reason: `not admitted: rule score is 0n at block ${sampleBlockFor(item.bundle)}`
139
+ };
140
+ cache.set(item.cid, verdict);
141
+ deps.onEvict(item.cid, verdict);
142
+ settle(item);
143
+ continue;
144
+ }
145
+ if (!item.gateNotified) {
146
+ item.gateNotified = true;
147
+ deps.onGateVerified(item.cid);
148
+ }
149
+ try {
150
+ if ((await nameStage(item, resolutions)) === "evicted") {
151
+ settle(item);
152
+ continue;
153
+ }
154
+ }
155
+ catch (error) {
156
+ infraError = error;
157
+ requeue.push(item); // gateDone survives — the retry only re-runs names
158
+ continue;
159
+ }
160
+ if (item.bundle.votes.some((v) => v.community.name))
161
+ deps.onNameResolved(item.cid);
162
+ // Fully settled: store the terminal valid verdict (same shape the forward-gate caches).
163
+ cache.set(item.cid, { valid: true, ruleScore: item.ruleScore, resolvedNames: item.resolvedNames });
164
+ settle(item);
165
+ }
166
+ if (requeue.length > 0) {
167
+ queue.push(...requeue);
168
+ failedRounds += 1;
169
+ deps.onError(infraError);
170
+ armRetry();
171
+ }
172
+ else {
173
+ failedRounds = 0;
174
+ }
175
+ }
176
+ function armRetry() {
177
+ if (stopped || retryTimer !== undefined)
178
+ return;
179
+ const ceiling = Math.min(retryCapMs, retryBaseMs * 2 ** (failedRounds - 1));
180
+ const timer = setTimeout(() => {
181
+ retryTimer = undefined;
182
+ kickDrain();
183
+ }, Math.random() * ceiling);
184
+ timer.unref?.();
185
+ retryTimer = timer;
186
+ }
187
+ function kickDrain() {
188
+ if (draining || stopped || queue.length === 0) {
189
+ maybeResolveIdle();
190
+ return;
191
+ }
192
+ draining = true;
193
+ void (async () => {
194
+ try {
195
+ // A round that infra-fails re-queues and arms the retry timer instead of spinning.
196
+ while (queue.length > 0 && !stopped && retryTimer === undefined)
197
+ await round();
198
+ }
199
+ finally {
200
+ draining = false;
201
+ maybeResolveIdle();
202
+ }
203
+ })();
204
+ }
205
+ return {
206
+ enqueue(entries) {
207
+ for (const entry of entries) {
208
+ const key = entry.cid.toString();
209
+ if (inFlight.has(key))
210
+ continue;
211
+ inFlight.add(key);
212
+ queue.push({ ...entry, gateDone: false, gateNotified: false, ruleScore: 0n, resolvedNames: {} });
213
+ }
214
+ kickDrain();
215
+ },
216
+ pendingCount() {
217
+ return inFlight.size;
218
+ },
219
+ idle() {
220
+ if (queue.length === 0 && !draining && retryTimer === undefined)
221
+ return Promise.resolve();
222
+ return new Promise((resolve) => idleResolvers.push(resolve));
223
+ },
224
+ stop() {
225
+ stopped = true;
226
+ if (retryTimer !== undefined)
227
+ clearTimeout(retryTimer);
228
+ retryTimer = undefined;
229
+ maybeResolveIdle();
230
+ },
231
+ resume() {
232
+ stopped = false;
233
+ kickDrain();
234
+ }
235
+ };
236
+ }
@@ -0,0 +1,58 @@
1
+ import type { Criteria } from "../schema/criteria.js";
2
+ import type { RuleRegistry } from "../rules/types.js";
3
+ import type { ChainClient, BucketMath, NameResolver } from "../chain/types.js";
4
+ import type { GateResultCache } from "./gate-result-cache.js";
5
+ import { type NameResolutionCache } from "./name-resolution-cache.js";
6
+ import type { BundleVerifier } from "./types.js";
7
+ /**
8
+ * The full validity pipeline for one bundle — the work the gossip forward-gate runs before
9
+ * re-forwarding (see DESIGN.md "Transport"). Cheap-to-expensive with early exit so the
10
+ * costly network/chain steps only run for genuinely-new, signature-valid bundles:
11
+ *
12
+ * 1. signature (local, µs): recover the EIP-712 signer, must equal `bundle.address`.
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.
16
+ * 4. name (network): each vote's `community.name` (if any) must resolve to the
17
+ * claimed `publicKey`; a squatted/absent name drops the bundle.
18
+ *
19
+ * Every step only ever SUBTRACTS trust (a bundle is valid or dropped), which is what lets the
20
+ * gate reject without forwarding. Weight *magnitude* is not computed here — it is a ranking
21
+ * concern the tally derives lazily, not a validity concern (see DESIGN.md "Tally").
22
+ *
23
+ * Expiry is deliberately out of scope here: it depends on the current bucket (a clock), so it
24
+ * is enforced by the CRDT's read-time filter (`current` drops decayed votes given the
25
+ * current bucket; `prune` bounds memory), not by this time-independent verifier.
26
+ */
27
+ /** Everything the verifier needs, resolved once per contest. */
28
+ export interface BundleVerifierDeps {
29
+ criteria: Criteria;
30
+ /** The criteria document's CID bytes (`(await criteriaCid(criteria)).bytes`) — signature binding. */
31
+ criteriaCid: Uint8Array;
32
+ /** The rule chain's numeric chainId (bound in the ballot domain). */
33
+ chainId: number;
34
+ /** Resolved rule registry (built-ins + host overrides). */
35
+ registry: RuleRegistry;
36
+ /** Resolve a chain ticker (e.g. "base") to its viem client. */
37
+ chainFor: (ticker: string) => ChainClient;
38
+ /** Bucket math for `criteria.blocksPerBucket`. */
39
+ bucketMath: BucketMath;
40
+ /** Host-injected community-name resolvers (`PubsubVoterOptions.nameResolvers`). */
41
+ nameResolvers: NameResolver[];
42
+ /**
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).
48
+ */
49
+ gateResultCache?: GateResultCache;
50
+ /**
51
+ * Optional persistent cache of name resolutions (the pkc-js rule — see
52
+ * verify/name-resolution-cache.ts). When present, a carried name is resolved live at most
53
+ * once per {@link NAME_RESOLUTION_MAX_AGE_SECONDS} per resolver. Omitted ⇒ every verify
54
+ * resolves live (prior behaviour; unit tests).
55
+ */
56
+ nameResolutionCache?: NameResolutionCache;
57
+ }
58
+ export declare function makeBundleVerifier(deps: BundleVerifierDeps): BundleVerifier;
@@ -0,0 +1,84 @@
1
+ import { tickerForRef } from "../chain/ticker.js";
2
+ import { UnknownRuleError } from "../errors.js";
3
+ import { verifyBundleSignature } from "./signature.js";
4
+ import { checkBundleConstraints } from "./constraints.js";
5
+ import { resolveNameThroughCache } from "./name-resolution-cache.js";
6
+ 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.
11
+ const rule = registry[criteria.rule.type];
12
+ if (!rule)
13
+ throw new UnknownRuleError("rule", criteria.rule.type);
14
+ const ruleOptions = rule.optionsSchema.parse(criteria.rule);
15
+ const ruleChain = chainFor(tickerForRef(criteria, criteria.rule, ruleOptions));
16
+ // Stage 1, shared by `verify` and `verifyOffline`: signature + constraints, local and µs.
17
+ const verifyOffline = async (bundle) => {
18
+ // 1. Signature (free) — a forged/tampered bundle drops before any chain/network read.
19
+ const signature = await verifyBundleSignature({ bundle, criteriaCid, chainId });
20
+ if (!signature.valid)
21
+ return signature;
22
+ // 2. Criteria constraints (free) — cap + vote range.
23
+ return checkBundleConstraints(bundle, criteria);
24
+ };
25
+ return {
26
+ verifyOffline,
27
+ async verify(bundle) {
28
+ const offline = await verifyOffline(bundle);
29
+ if (!offline.valid)
30
+ 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).
36
+ 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
+ }
49
+ // 4. Community-name resolution (network) — a carried name is a claim, verified against
50
+ // the registry. A name that has no resolver, does not resolve, or resolves to a
51
+ // different publicKey than the vote claims drops the whole bundle. These failures
52
+ // are `ignore`, not `reject`: v1 resolves at head, so they are view-/clock-dependent
53
+ // (a missing resolver differs per verifier; a re-point produces a transient window
54
+ // where honest peers disagree — see DESIGN.md "Tally"/"Open questions"). Penalizing
55
+ // the sender for that would punish honest relayers; the drop still stops propagation.
56
+ // (Once pinned-block resolution lands, a steady-state mismatch becomes provable
57
+ // `reject`.) The gossip gate therefore does NOT cache these verdicts. Successful
58
+ // resolutions DO go through the shared name-resolution cache (the pkc-js rule,
59
+ // 1-hour max-age) — bounding the RPC cost, while a re-point is still honored
60
+ // within the hour and a failed resolution is never negatively cached.
61
+ const resolvedNames = {};
62
+ for (const v of bundle.votes) {
63
+ const name = v.community.name;
64
+ if (!name)
65
+ continue;
66
+ const resolver = nameResolvers.find((r) => r.canResolve({ name }));
67
+ if (!resolver)
68
+ return { valid: false, disposition: "ignore", reason: `no resolver handles community name "${name}"` };
69
+ const record = await resolveNameThroughCache({ resolver, name, cache: nameResolutionCache });
70
+ if (!record)
71
+ return { valid: false, disposition: "ignore", reason: `community name "${name}" does not resolve` };
72
+ if (record.publicKey !== v.community.publicKey) {
73
+ return {
74
+ valid: false,
75
+ disposition: "ignore",
76
+ reason: `community name "${name}" resolves to ${record.publicKey}, not the claimed ${v.community.publicKey}`
77
+ };
78
+ }
79
+ resolvedNames[name] = record.publicKey;
80
+ }
81
+ return { valid: true, ruleScore: score, resolvedNames };
82
+ }
83
+ };
84
+ }
@@ -0,0 +1,48 @@
1
+ import type { CID } from "multiformats/cid";
2
+ import type { VotesBundle } from "../schema/votes.js";
3
+ import type { BundleVerifier, BundleVerdict } from "./types.js";
4
+ /**
5
+ * Per-CID verdict cache. A bundle is immutable and content-addressed, and its verdict
6
+ * (signature + constraints + gate-at-bucket-block + name resolution) is deterministic,
7
+ * so it is computed once and reused. This is the lever that keeps the forward-gate cheap:
8
+ * steady-state gossip re-announces already-known bundle CIDs, so the same bundle CID would
9
+ * otherwise be re-verified once per gossiping peer. See DESIGN.md "Transport" ("verdict cache").
10
+ */
11
+ export interface VerdictCache {
12
+ get(cid: CID): BundleVerdict | undefined;
13
+ set(cid: CID, verdict: BundleVerdict): void;
14
+ has(cid: CID): boolean;
15
+ }
16
+ /**
17
+ * Only *terminal* verdicts may be cached: an `accept`, or a `reject` that is a pure function
18
+ * of the bundle bytes + pinned historical chain state (bad signature, gate miss, ...). A
19
+ * transient `ignore` (name resolved at head during a re-point window, a `blockNumber` bucket
20
+ * ahead of this verifier's head) is view-/clock-dependent and can change as heads/records
21
+ * converge, so caching it would wrongly pin a stale verdict. See DESIGN.md "Transport"
22
+ * ("verdict cache") and verify/types.ts `VerdictDisposition`.
23
+ */
24
+ export declare function isCacheableVerdict(verdict: BundleVerdict): boolean;
25
+ /**
26
+ * An in-memory verdict cache keyed by the CID's canonical string, bounded to `maxEntries` with
27
+ * FIFO eviction (same pattern as `makeGateResultCache` / `makeAcceptedDedup`). Without a bound the
28
+ * `Map` grows one entry per novel CID forever: an ineligible wallet (or a flood of fresh wallets)
29
+ * minting fresh-signed bundles yields a distinct provable-`reject` per CID, so an unbounded cache is
30
+ * a memory-exhaustion vector (see DESIGN.md "Can valid votes clog the topic?"). Eviction is safe
31
+ * because a cached verdict is *terminal* and deterministic — an evicted entry only costs a re-fetch
32
+ * + re-verify on the next re-announce, never a wrong answer, and the `(wallet, sampleBlock)`
33
+ * gate-result cache still short-circuits the chain read so that recomputation stays cheap.
34
+ */
35
+ export declare function makeVerdictCache(maxEntries?: number): VerdictCache;
36
+ /** A verifier addressed by CID, memoizing verdicts through a {@link VerdictCache}. */
37
+ export interface CachingBundleVerifier {
38
+ verify(cid: CID, bundle: VotesBundle): Promise<BundleVerdict>;
39
+ }
40
+ /**
41
+ * Wrap a {@link BundleVerifier} with a {@link VerdictCache}: a CID already seen returns its
42
+ * cached verdict without touching the chain or the network; a new CID runs the full pipeline
43
+ * once and stores the result — but only if it is terminal ({@link isCacheableVerdict}): a
44
+ * valid bundle or a provable `reject` is remembered (a known-bad bundle is not re-fetched or
45
+ * re-checked), while a transient `ignore` is re-evaluated next time so a stale head/record
46
+ * cannot pin it.
47
+ */
48
+ export declare function makeCachingVerifier(verifier: BundleVerifier, cache: VerdictCache): CachingBundleVerifier;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Only *terminal* verdicts may be cached: an `accept`, or a `reject` that is a pure function
3
+ * of the bundle bytes + pinned historical chain state (bad signature, gate miss, ...). A
4
+ * transient `ignore` (name resolved at head during a re-point window, a `blockNumber` bucket
5
+ * ahead of this verifier's head) is view-/clock-dependent and can change as heads/records
6
+ * converge, so caching it would wrongly pin a stale verdict. See DESIGN.md "Transport"
7
+ * ("verdict cache") and verify/types.ts `VerdictDisposition`.
8
+ */
9
+ export function isCacheableVerdict(verdict) {
10
+ return verdict.valid || verdict.disposition === "reject";
11
+ }
12
+ /**
13
+ * An in-memory verdict cache keyed by the CID's canonical string, bounded to `maxEntries` with
14
+ * FIFO eviction (same pattern as `makeGateResultCache` / `makeAcceptedDedup`). Without a bound the
15
+ * `Map` grows one entry per novel CID forever: an ineligible wallet (or a flood of fresh wallets)
16
+ * minting fresh-signed bundles yields a distinct provable-`reject` per CID, so an unbounded cache is
17
+ * a memory-exhaustion vector (see DESIGN.md "Can valid votes clog the topic?"). Eviction is safe
18
+ * because a cached verdict is *terminal* and deterministic — an evicted entry only costs a re-fetch
19
+ * + re-verify on the next re-announce, never a wrong answer, and the `(wallet, sampleBlock)`
20
+ * gate-result cache still short-circuits the chain read so that recomputation stays cheap.
21
+ */
22
+ export function makeVerdictCache(maxEntries = 4096) {
23
+ const byCid = new Map();
24
+ const order = [];
25
+ return {
26
+ get: (cid) => byCid.get(cid.toString()),
27
+ set: (cid, verdict) => {
28
+ const k = cid.toString();
29
+ if (byCid.has(k))
30
+ return; // idempotent: a terminal verdict never changes, so keep FIFO position
31
+ byCid.set(k, verdict);
32
+ order.push(k);
33
+ if (order.length > maxEntries) {
34
+ const evicted = order.shift();
35
+ if (evicted !== undefined)
36
+ byCid.delete(evicted);
37
+ }
38
+ },
39
+ has: (cid) => byCid.has(cid.toString())
40
+ };
41
+ }
42
+ /**
43
+ * Wrap a {@link BundleVerifier} with a {@link VerdictCache}: a CID already seen returns its
44
+ * cached verdict without touching the chain or the network; a new CID runs the full pipeline
45
+ * once and stores the result — but only if it is terminal ({@link isCacheableVerdict}): a
46
+ * valid bundle or a provable `reject` is remembered (a known-bad bundle is not re-fetched or
47
+ * re-checked), while a transient `ignore` is re-evaluated next time so a stale head/record
48
+ * cannot pin it.
49
+ */
50
+ export function makeCachingVerifier(verifier, cache) {
51
+ return {
52
+ async verify(cid, bundle) {
53
+ const cached = cache.get(cid);
54
+ if (cached)
55
+ return cached;
56
+ const verdict = await verifier.verify(bundle);
57
+ if (isCacheableVerdict(verdict))
58
+ cache.set(cid, verdict);
59
+ return verdict;
60
+ }
61
+ };
62
+ }
@@ -0,0 +1,16 @@
1
+ import type { VotesBundle } from "../schema/votes.js";
2
+ import type { Criteria } from "../schema/criteria.js";
3
+ import type { VerifyResult } from "./types.js";
4
+ /**
5
+ * Offline verify stage 1, constraints half: the criteria-bound checks that gate a bundle
6
+ * before any signature recovery or chain read — `votes.length <= criteria.maxVotesPerAddress`
7
+ * and each `vote` within `criteria.voteSchema` (see verify/types.ts stage 1).
8
+ *
9
+ * Pure and synchronous: no signature recovery, no chain. These bounds live in the
10
+ * *criteria*, not in the wire type, which is exactly why `VotesBundleSchema` does NOT
11
+ * enforce them — the schema owns only criteria-independent wire shape (pairwise-distinct
12
+ * communities). This runtime check is where the cap belongs. See DESIGN.md "Votes wire".
13
+ *
14
+ * An empty `votes` array (withdrawal/abstention) is always valid regardless of the cap.
15
+ */
16
+ export declare function checkBundleConstraints(bundle: VotesBundle, criteria: Criteria): VerifyResult;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Offline verify stage 1, constraints half: the criteria-bound checks that gate a bundle
3
+ * before any signature recovery or chain read — `votes.length <= criteria.maxVotesPerAddress`
4
+ * and each `vote` within `criteria.voteSchema` (see verify/types.ts stage 1).
5
+ *
6
+ * Pure and synchronous: no signature recovery, no chain. These bounds live in the
7
+ * *criteria*, not in the wire type, which is exactly why `VotesBundleSchema` does NOT
8
+ * enforce them — the schema owns only criteria-independent wire shape (pairwise-distinct
9
+ * communities). This runtime check is where the cap belongs. See DESIGN.md "Votes wire".
10
+ *
11
+ * An empty `votes` array (withdrawal/abstention) is always valid regardless of the cap.
12
+ */
13
+ export function checkBundleConstraints(bundle, criteria) {
14
+ // Withdrawal/abstention: the empty bundle is always legal, cap and range notwithstanding.
15
+ if (bundle.votes.length === 0)
16
+ return { valid: true };
17
+ if (bundle.votes.length > criteria.maxVotesPerAddress) {
18
+ return {
19
+ valid: false,
20
+ disposition: "reject",
21
+ reason: `votes.length ${bundle.votes.length} exceeds maxVotesPerAddress ${criteria.maxVotesPerAddress}`
22
+ };
23
+ }
24
+ const { min, max } = criteria.voteSchema;
25
+ for (const v of bundle.votes) {
26
+ if (v.vote < min || v.vote > max) {
27
+ return {
28
+ valid: false,
29
+ disposition: "reject",
30
+ reason: `vote ${v.vote} for community ${v.community.publicKey} is outside voteSchema [${min}, ${max}]`
31
+ };
32
+ }
33
+ }
34
+ return { valid: true };
35
+ }
@@ -0,0 +1,65 @@
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>;