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