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