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