@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
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ This library does not start its own node. It consumes the host's running Helia n
|
|
|
24
24
|
|
|
25
25
|
- **Settings live in the topic.** `topic = "bitsocial-votes/" + CID(dag-cbor(criteria))`. Two peers on the same topic provably ran identical rules, so the network validates itself with no intermediary.
|
|
26
26
|
- **Votes are a state-based grow-only CRDT.** A signed `Votes` bundle is a standalone dag-cbor block (no parent links); each wallet gossips its own bundle **inline as a live delta**, validated straight from the message bytes — no fetch toward the publisher. State is a last-write-wins set keyed by wallet, so aggregation is a monotonic union: a peer can omit a vote but can never subtract one that an honest peer serves. Cold start and gap-fill exchange a tiny **root record** (libp2p-fetch pull + a slow topic heartbeat) and pull the checkpoint blocks behind it via directed bitswap from its advertisers.
|
|
27
|
-
- **The gate and weight are data, not code.** A fixed rule registry (mirroring pkc-js's challenge registry) maps a `type` string to a verifier. v1 ships exactly the soulbound-NFT path — an `erc5192-min-balance` gate `rule` (the 5chan Pass: `balanceOf` **plus** an on-chain assertion that the contract declares its tokens locked) and `constant` weight (1 pass = 1 vote). A gate on a *transferable* asset would let one Pass back several concurrent votes, so the plain `erc721-min-balance` rule ships unregistered; balance-derived (token-weighted) voting is deferred. See [DESIGN.md, Does one Pass mean one vote?](./DESIGN.md#does-one-pass-mean-one-vote) and [ROADMAP.md](./ROADMAP.md).
|
|
27
|
+
- **The gate and weight are data, not code.** A fixed rule registry (mirroring pkc-js's challenge registry) maps a `type` string to a verifier. v1 ships exactly the soulbound-NFT path — an `erc5192-min-balance` gate `rule` (the 5chan Pass: `balanceOf` **plus** an on-chain assertion that the contract declares its tokens locked, read at the verifier's head so a freshly-acquired Pass votes immediately) and `constant` weight (1 pass = 1 vote). A gate on a *transferable* asset would let one Pass back several concurrent votes, so the plain `erc721-min-balance` rule ships unregistered; balance-derived (token-weighted) voting is deferred. See [DESIGN.md, Does one Pass mean one vote?](./DESIGN.md#does-one-pass-mean-one-vote) and [ROADMAP.md](./ROADMAP.md).
|
|
28
28
|
|
|
29
29
|
See [DESIGN.md](./DESIGN.md) for the full rationale, including how this resists vote-dropping and how criteria upgrades fork cleanly.
|
|
30
30
|
|
|
@@ -35,7 +35,7 @@ The library never starts a node and never takes a host SDK (there is no `pkc` ar
|
|
|
35
35
|
| Seam | Type | Required | Purpose |
|
|
36
36
|
|---|---|---|---|
|
|
37
37
|
| `helia` | `HeliaInstance` | yes | the host's running Helia node; must carry a gossipsub service at `libp2p.services.pubsub` (else `MissingPubsubError`), a `blockstore` (else `MissingBlockstoreError`), and a libp2p fetch service at `libp2p.services.fetch` (else `MissingFetchError`) |
|
|
38
|
-
| `chains` | `ChainClientFactory` | yes | resolves each chain a contest's criteria requires (`{ chain, chainId }`) to a viem `PublicClient`; rules read through it for the gate and weight. **RPC endpoints are this client's own settings, never part of the criteria document** — return one shared (memoized) client per chain, pointed at a gateway that serves historical state at least `voteExpiryBuckets × blocksPerBucket` blocks behind head
|
|
38
|
+
| `chains` | `ChainClientFactory` | yes | resolves each chain a contest's criteria requires (`{ chain, chainId }`) to a viem `PublicClient`; rules read through it for the gate and weight. **RPC endpoints are this client's own settings, never part of the criteria document** — return one shared (memoized) client per chain, pointed at a gateway that carries a multicall3 deployment in its viem `chain` config and serves **historical state at least `voteExpiryBuckets × blocksPerBucket` blocks behind head** (the v1 gate reads the head first, but falls back to the block a ballot names — see [Custom rules](#custom-rules)); return `undefined` for a chain with no RPC configured, and `createContest`/`createContestVote` throws `MissingChainClientError` (recuse, don't miscount) |
|
|
39
39
|
| `signer` | `VoteSigner` | no | the voting wallet's address + EIP-712 ballot signing; omit for a read-only voter |
|
|
40
40
|
| `nameResolvers` | `NameResolver[]` | no | community-name resolvers (same interface and instances as pkc-js's `nameResolvers`, e.g. `@bitsocial/bso-resolver` for `name.bso`); each vote's `community.name` claim is verified through them — inline at the forward-gate for live votes, in the background verifier for cold-join admits — and a bundle whose name resolves to a different `publicKey` than claimed is dropped/evicted |
|
|
41
41
|
| `dataPath` | `string \| false` | no | directory for the voter's persistent state (gate-result + name-resolution caches, and each joined contest's **checkpoint snapshot** — its last fully-verified winner-set, reloaded at join so a restart with no other peer online keeps the tally), the pkc-js `dataPath` equivalent. Node default: `{cwd}/.bitsocial-pubsub-voting` (better-sqlite3 under `{dataPath}/lru-storage/` + `{dataPath}/checkpoints.db`); in the browser the path is ignored and everything lives in IndexedDB. Pass `false` for in-memory-only (the pkc-js `noData` equivalent). A restart re-serves settled gate reads and fresh name resolutions from the store instead of the RPC, and restores each contest's checkpoint before the cold-start pull. A seeder should always set a stable path |
|
|
@@ -87,6 +87,31 @@ await contest.update(); // join the topic, col
|
|
|
87
87
|
// await contest.stop(); // leave the topic
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
+
### Will this wallet's vote count?
|
|
91
|
+
|
|
92
|
+
Ask the contest before signing. `checkEligibility` runs the contest's **real gate rule** through
|
|
93
|
+
the same chain client, head reader and memo the forward gate uses, so it reads at whatever block
|
|
94
|
+
that rule reads at and applies whatever threshold it applies. When it refuses, the `error` is the
|
|
95
|
+
rule's own wording — render it verbatim:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
const check = await contest.checkEligibility({ address: wallet });
|
|
99
|
+
if (check.eligible) {
|
|
100
|
+
show(`eligible — holds ${check.score}`);
|
|
101
|
+
} else {
|
|
102
|
+
show(check.error); // e.g. "this wallet holds none of the gate token (0x13d4…91b9)"
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Do **not** reimplement this by reading balances yourself: which block counts is the rule's
|
|
107
|
+
business and changes when the rule changes. A client that hard-codes "peers verify at the bucket
|
|
108
|
+
boundary" keeps telling voters to wait for a window that a head-reading gate no longer imposes.
|
|
109
|
+
|
|
110
|
+
It is a courtesy check, not a promise — eligibility can change between the check and the publish,
|
|
111
|
+
and each peer verifies against its own chain view. `publish()` deliberately does not call it: the
|
|
112
|
+
gate is the network's decision, and a rejection still surfaces after the fact as
|
|
113
|
+
`VoteEvictedError`, carrying the same kind of reason.
|
|
114
|
+
|
|
90
115
|
Each ranking row carries one flag **per deferred verification operation** (mirroring pkc-js's
|
|
91
116
|
`nameResolved`), and every background settlement re-fires `update` — so a leaderboard can render
|
|
92
117
|
provisional rows immediately and refine them in place:
|
|
@@ -196,13 +221,13 @@ Full, type-checked call patterns for a pkc-js host, a plebbit/seedit host, and a
|
|
|
196
221
|
|
|
197
222
|
### Custom rules
|
|
198
223
|
|
|
199
|
-
The gate and weight are a single flat registry of rules, one `type` per file, mirroring the pkc-js challenge registry. Each rule owns its option schema
|
|
224
|
+
The gate and weight are a single flat registry of rules, one `type` per file, mirroring the pkc-js challenge registry. Each rule owns its option schema, its own reads (`readContract`, `getBalance`, ... through `ctx.chain`, the viem `PublicClient` for its `options.chain`), which block it reads at, and what it memoizes — see [What a rule owns](#what-a-rule-owns-its-block-and-its-cache) below. There is **one kind**: `evaluate → RuleResult`, either `{ success: true, score }` with a positive score or `{ success: false, error }` — where `error` is the voter-facing reason the rule refused. The criteria has two *slots* drawing from the one registry — the **rule** slot treats the score as a gate (`> 0n` admits), the **weight** slot as the vote's magnitude. A wallet's vote counts as `rule.success ? weight.score : 0n`. A rule that needs a threshold fails below it (so `erc5192-min-balance`'s optional `min` gates), which lets the same rule serve either slot. A chain-reading rule may also implement the optional `evaluateMany({ options, wallets, ctx })` batch hook (`wallets` in place of `wallet`, returning `{ results }` — one per input wallet, in order) — its semantics MUST equal mapping `evaluate` — which the background verifier uses to batch a cold join's gate reads. The batch is simply everything pending, so its wallets need not share a `sampleBlock`: a rule reading the head scores them all at once, a rule reading pinned blocks groups them itself. (`erc5192-min-balance` implements it over multicall3, hoisting its one lock assertion out of the per-wallet reads; see [DESIGN.md, Background chain verification](./DESIGN.md#background-chain-verification).)
|
|
200
225
|
|
|
201
226
|
Built-ins: `erc5192-min-balance` (v1) and `constant` (v1).
|
|
202
227
|
|
|
203
228
|
Two chain-reading rules ship in the tree but are deliberately **not** built in, so a criteria naming either recuses via `UnknownRuleError` instead of silently gating on an asset that does not bound Sybils:
|
|
204
229
|
|
|
205
|
-
- **`erc721-min-balance`** (exported) — a bare `balanceOf` on a *transferable* token. One token walked A → B → C inside one expiry window backs three concurrent live votes, since
|
|
230
|
+
- **`erc721-min-balance`** (exported) — a bare `balanceOf` on a *transferable* token. One token walked A → B → C inside one expiry window backs three concurrent live votes, since each bundle is verified once, when it is merged, and the winner set is LWW-keyed per wallet. `erc5192-min-balance` is this rule plus `supportsInterface(0xb45a3c0e)`, which refuses a contract that does not declare its tokens locked.
|
|
206
231
|
- **`erc20-balance`** (not exported) — the same amplification, reopened by fungibility, plus the open lazy-tally ceiling question for balance-derived weight.
|
|
207
232
|
|
|
208
233
|
A host that wants a transferable gate anyway registers `erc721MinBalance` explicitly through the `rules` option below — the library stops blessing the configuration, it does not forbid it. `erc20-balance` is not exported at all, so a host that wants balance-weighting supplies its own rule of that `type`. See [DESIGN.md, Does one Pass mean one vote?](./DESIGN.md#does-one-pass-mean-one-vote).
|
|
@@ -216,8 +241,10 @@ import { z } from "zod";
|
|
|
216
241
|
const seeditModAllowlist: Rule<{ type: "seedit-mod-allowlist"; allow: string[] }> = {
|
|
217
242
|
type: "seedit-mod-allowlist",
|
|
218
243
|
optionsSchema: z.object({ type: z.literal("seedit-mod-allowlist"), allow: z.array(z.string()) }),
|
|
219
|
-
async evaluate({ options,
|
|
220
|
-
return
|
|
244
|
+
async evaluate({ options, wallet }) {
|
|
245
|
+
return options.allow.includes(wallet.address)
|
|
246
|
+
? { success: true, score: 1n }
|
|
247
|
+
: { success: false, error: "this wallet is not on the moderator allowlist" }; // shown to the voter
|
|
221
248
|
}
|
|
222
249
|
};
|
|
223
250
|
|
|
@@ -229,6 +256,46 @@ const voter = new PubsubVoter({
|
|
|
229
256
|
|
|
230
257
|
A custom `type` becomes part of `dag-cbor(criteria)`, so it is provably pinned to the topic it runs on, and a client that does not implement a `type` named in `criteria.requires.rules` throws `UnknownRuleError` and recuses itself rather than miscounting.
|
|
231
258
|
|
|
259
|
+
#### What a rule owns: its block, and its cache
|
|
260
|
+
|
|
261
|
+
A rule is handed the pinned block its ballot names, a lazy head reader, and its own memo, and decides for itself which to read and what to remember:
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
evaluate(args: { options: O; wallet: { address: string; sampleBlock: number }; ctx: ChainReadContext }): Promise<RuleResult>
|
|
265
|
+
|
|
266
|
+
interface ChainReadContext {
|
|
267
|
+
chain: ChainClient; // the viem PublicClient for options.chain
|
|
268
|
+
head: () => Promise<{ block: number }>; // this verifier's current block — lazy, coalesced
|
|
269
|
+
cache: RuleCache; // this rule's memo, persistent and shared across contests
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
- **`wallet.sampleBlock`** is the bundle's bucketized block, already floored — the historical block every verifier agrees the ballot names. Read there and your score is identical on every verifier forever. It is *not* a claim about when the ballot was signed (it trails by up to `blocksPerBucket`).
|
|
274
|
+
- **`ctx.head()`** is this verifier's current block. Read there and a wallet qualifies the moment it acquires the gating asset, instead of waiting for the next bucket boundary. Resolve it once per evaluation and pin your reads to that number — always pass an explicit `blockNumber: BigInt(...)`, or the read coalescer cannot batch you.
|
|
275
|
+
- **`ctx.cache`** memoizes under a `key` you choose and an `epoch` that says when the answer stops being true (a moved epoch is how an entry expires; `purgeBelow` drops what is behind). **Chain-reading rules must use it** — it is what turns "one chain read per unique bundle" into "one read per key per epoch", so without it an ineligible wallet can make every peer on the topic pay an RPC round trip per fresh-signed bundle. `memoMany` reads only the misses, in one batched call.
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
const { values } = await ctx.cache.memoMany({
|
|
279
|
+
keys: wallets.map((w) => `bal/${w.toLowerCase()}`),
|
|
280
|
+
epoch: block, // e.g. the pinned block, or a coarse head window
|
|
281
|
+
read: async ({ keys }) => ({ values: await readOnChain(keys) })
|
|
282
|
+
});
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
`RuleResult` is a discriminated union, shaped like pkc-js's `ChallengeResult`:
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
type RuleResult =
|
|
289
|
+
| { success: true; score: bigint } // score MUST be > 0n
|
|
290
|
+
| { success: false; error: string; penalize?: boolean }; // penalize default true
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
**`error` is required on the failing branch**, because only the rule knows why a wallet fell short — it holds none, it holds too few, the contract gates nothing. That sentence is what the library shows the voter: it becomes the verdict reason, so it reaches the publisher on `VoteEvictedError.verdict.reason`, and it is what `contest.checkEligibility()` returns. Write it about the wallet ("this wallet holds none of the gate token"), not about the library, and leave block numbers out unless a voter can act on them. Making it optional would mean every UI re-deriving the rule's thresholds and block choice to say anything useful — which is exactly the coupling `ctx` and `RuleCache` exist to remove.
|
|
294
|
+
|
|
295
|
+
**`penalize`** answers the one thing the library cannot: may the failure be blamed on the sender? `true` (the default) says every honest verifier computes this same failure — true of a read pinned to the block the bundle names — so the forward gate `reject`s the message (penalizing the delivering peer in gossipsub's scoring) and the verdict is cached as terminal. `false` says an honest peer could legitimately disagree, so the bundle is dropped `ignore`-class instead: no penalty, verdict uncached, and the background verifier re-examines it for a grace window before giving up.
|
|
296
|
+
|
|
297
|
+
`erc5192-min-balance` reads the head first — so a freshly-acquired Pass counts immediately — falls back to `wallet.sampleBlock` when the head refuses (ERC-5192 does not forbid burning, and without the fallback a burn would erase votes retroactively for peers that had not verified them yet), memoizes each leg under its own epoch, and returns `penalize: false`, because at validation time it cannot attribute a failure to anyone: the peer that forwarded the vote verified it against *its* head. Every other rule in the tree reads `wallet.sampleBlock` and leaves `penalize` at its default — a transferable or fungible balance can decrease, so reading it at the head would silently invalidate votes already counted. See [DESIGN.md, What a rule owns](./DESIGN.md#what-a-rule-owns-and-what-the-pipeline-owns).
|
|
298
|
+
|
|
232
299
|
### Weighted voting (deferred)
|
|
233
300
|
|
|
234
301
|
v1 ships `constant` weight (one Pass, one vote) **on purpose** — it resists whale dominance and downvote weaponization. Balance-derived, token-weighted voting (Pass gate + BSO weight via `erc20-balance`) is a designed-but-unshipped capability: the rule path and result shape leave room for it with no engine change, but it is not in the v1 built-ins and carries open governance/abuse and lazy-tally questions — plus the Sybil amplification a fungible gate reopens, which the soulbound gate's fix cannot close for a balance (it needs a hold-duration guard instead). See [ROADMAP.md](./ROADMAP.md), [DESIGN.md, Does one Pass mean one vote?](./DESIGN.md#does-one-pass-mean-one-vote), and [DESIGN.md, Future improvements](./DESIGN.md#future-improvements).
|
package/dist/client/voter.d.ts
CHANGED
|
@@ -65,6 +65,19 @@ export interface PublishOutcome {
|
|
|
65
65
|
readonly recipientCount: number;
|
|
66
66
|
}
|
|
67
67
|
/** One contest's reactive read view: subscribe, keep the tally in sync, read it. */
|
|
68
|
+
/**
|
|
69
|
+
* What {@link Contest.checkEligibility} found. Shaped like {@link RuleResult} on purpose — it is
|
|
70
|
+
* that result, surfaced — so the failing branch always carries a reason a client can display.
|
|
71
|
+
*/
|
|
72
|
+
export type EligibilityResult = {
|
|
73
|
+
eligible: true;
|
|
74
|
+
/** The wallet's gate score, `> 0n`. For a balance gate this is the holding itself. */
|
|
75
|
+
score: bigint;
|
|
76
|
+
} | {
|
|
77
|
+
eligible: false;
|
|
78
|
+
/** The rule's own explanation, written for the voter. Render it verbatim. */
|
|
79
|
+
error: string;
|
|
80
|
+
};
|
|
68
81
|
export interface Contest {
|
|
69
82
|
/** The criteria document this contest runs (already validated). */
|
|
70
83
|
readonly criteria: Criteria;
|
|
@@ -85,6 +98,30 @@ export interface Contest {
|
|
|
85
98
|
stop(): Promise<void>;
|
|
86
99
|
/** Compute the current contest ranking fresh, bypassing the cache. */
|
|
87
100
|
getTally(): Promise<ContestTally>;
|
|
101
|
+
/**
|
|
102
|
+
* Would this contest's gate admit `address` right now? Ask before signing, to tell a voter
|
|
103
|
+
* whether their ballot will count — and, when it will not, exactly why.
|
|
104
|
+
*
|
|
105
|
+
* This runs the contest's REAL gate rule through the same context the forward-gate and the
|
|
106
|
+
* background verifier use: the same chain client, the same coalesced head reader, the same
|
|
107
|
+
* memo. So it reads at whatever block the rule reads at, applies whatever threshold the rule
|
|
108
|
+
* applies, and returns the rule's own {@link RuleResult.error} wording verbatim. A client
|
|
109
|
+
* renders `error` and needs to know nothing about blocks, buckets or thresholds — which is
|
|
110
|
+
* the point: re-deriving any of that outside the rule is how a UI ends up confidently
|
|
111
|
+
* telling voters to wait for a window that no longer gates anything.
|
|
112
|
+
*
|
|
113
|
+
* It is a courtesy check, not a promise. Eligibility is a fact about the chain and can change
|
|
114
|
+
* between this call and the publish, and each peer verifies against its own view — so a
|
|
115
|
+
* `true` here can still be followed by a `VoteEvictedError` (which carries the same kind of
|
|
116
|
+
* reason). `publish()` deliberately does NOT call this: the gate is the network's decision,
|
|
117
|
+
* and refusing locally would only hide a vote the rest of the topic would have accepted.
|
|
118
|
+
*
|
|
119
|
+
* Costs one gate evaluation, usually served from the shared memo — the same read the verifier
|
|
120
|
+
* would do anyway, not an extra one.
|
|
121
|
+
*/
|
|
122
|
+
checkEligibility(args: {
|
|
123
|
+
address: string;
|
|
124
|
+
}): Promise<EligibilityResult>;
|
|
88
125
|
/**
|
|
89
126
|
* Fired when incoming votes change the state; `tally` carries the freshly recomputed
|
|
90
127
|
* ranking. Background check settlements fire it too: a cold join emits a first tally with
|
package/dist/client/voter.js
CHANGED
|
@@ -14,9 +14,10 @@ import { makeRootChaser, toChaseSession } from "../transport/chase.js";
|
|
|
14
14
|
import { encodeBundle, decodeBundle, bundleCidForBytes } from "../crdt/codec.js";
|
|
15
15
|
import { resolveRegistry, validateCriteriaRules } from "../rules/registry.js";
|
|
16
16
|
import { makeVoteCrdt } from "../crdt/crdt.js";
|
|
17
|
+
import { makePersistentRuleCache } from "../rules/cache.js";
|
|
18
|
+
import { gateFailure, scoreOrZero } from "../rules/result.js";
|
|
17
19
|
import { makeBundleVerifier } from "../verify/bundle.js";
|
|
18
20
|
import { makeVerdictCache } from "../verify/cache.js";
|
|
19
|
-
import { makePersistentGateResultCache, purgeExpiredGateResults } from "../verify/gate-result-cache.js";
|
|
20
21
|
import { makeNameResolutionCache } from "../verify/name-resolution-cache.js";
|
|
21
22
|
import { makeStorage } from "../storage/node.js";
|
|
22
23
|
import { makeAnnouncer } from "../transport/announce/node.js";
|
|
@@ -631,14 +632,31 @@ class ContestEngine {
|
|
|
631
632
|
voteExpiryBuckets: criteria.voteExpiryBuckets,
|
|
632
633
|
isProvisional: (cid) => this.#isPending(cid)
|
|
633
634
|
});
|
|
634
|
-
//
|
|
635
|
-
// background chain verifier
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
//
|
|
635
|
+
// The gate rule's memo (rules/cache.ts), shared between the inline forward-gate verifier
|
|
636
|
+
// and the background chain verifier so neither re-reads what the other settled — layered
|
|
637
|
+
// over the voter's persistent store and namespaced by the hash of the canonical rule
|
|
638
|
+
// reference + chainId. That hash is exactly the sharing boundary: two contests over one
|
|
639
|
+
// gate (a directory of boards on the same Pass) share each other's reads, while
|
|
640
|
+
// different gates, or one gate on different options, can never collide. What is stored
|
|
641
|
+
// under it, and for how long, is the rule's business, not the engine's.
|
|
640
642
|
this.#ruleHash = sha256(encodeDagCbor({ chainId: this.#chainId, rule: criteria.rule }));
|
|
641
|
-
const
|
|
643
|
+
const gateCache = makePersistentRuleCache({ store: deps.gateStore, namespace: this.#ruleHash });
|
|
644
|
+
// The weight rule gets its own namespace on the SAME terms — its canonical reference plus
|
|
645
|
+
// the id of the chain IT reads, which is not necessarily the gating chain. A ticker is
|
|
646
|
+
// just a name local to the criteria document, so two contests can spell the same weight
|
|
647
|
+
// ref while `requires.chains` binds that ticker to different chains; keying on the gate's
|
|
648
|
+
// chainId would let one serve the other's scores from the wrong chain.
|
|
649
|
+
const weight = deps.registry[criteria.weight.type];
|
|
650
|
+
if (!weight)
|
|
651
|
+
throw new UnknownRuleError("weight", criteria.weight.type);
|
|
652
|
+
const weightTicker = tickerForRef(criteria, criteria.weight, weight.optionsSchema.parse(criteria.weight));
|
|
653
|
+
const weightChainId = criteria.requires.chains[weightTicker]?.chainId;
|
|
654
|
+
if (weightChainId === undefined)
|
|
655
|
+
throw new Error(`no chain client for weight chain "${weightTicker}"`);
|
|
656
|
+
const weightCache = makePersistentRuleCache({
|
|
657
|
+
store: deps.gateStore,
|
|
658
|
+
namespace: sha256(encodeDagCbor({ chainId: weightChainId, rule: criteria.weight }))
|
|
659
|
+
});
|
|
642
660
|
const verifier = makeBundleVerifier({
|
|
643
661
|
criteria,
|
|
644
662
|
criteriaCid: criteriaCidBytes,
|
|
@@ -647,8 +665,9 @@ class ContestEngine {
|
|
|
647
665
|
chainFor: (ticker) => this.#chainFor(ticker),
|
|
648
666
|
bucketMath: this.#bucketMath,
|
|
649
667
|
nameResolvers: deps.nameResolvers,
|
|
650
|
-
|
|
651
|
-
nameResolutionCache: deps.nameResolutionCache
|
|
668
|
+
ruleCache: gateCache,
|
|
669
|
+
nameResolutionCache: deps.nameResolutionCache,
|
|
670
|
+
readHead: ({ chain }) => this.#readHead({ chain })
|
|
652
671
|
});
|
|
653
672
|
// The gate/transport are (re)built on join(); the store, crdt, caches, verifier, and
|
|
654
673
|
// background verifier are stable per contest, so they survive re-joins of the topic.
|
|
@@ -662,8 +681,9 @@ class ContestEngine {
|
|
|
662
681
|
chainFor: (ticker) => this.#chainFor(ticker),
|
|
663
682
|
bucketMath: this.#bucketMath,
|
|
664
683
|
nameResolvers: deps.nameResolvers,
|
|
665
|
-
|
|
684
|
+
ruleCache: gateCache,
|
|
666
685
|
nameResolutionCache: deps.nameResolutionCache,
|
|
686
|
+
readHead: ({ chain }) => this.#readHead({ chain }),
|
|
667
687
|
cache: this.#cache,
|
|
668
688
|
onGateVerified: (cid) => this.#settleCheck(cid, "chainVerified"),
|
|
669
689
|
onNameResolved: (cid) => this.#settleCheck(cid, "nameResolved"),
|
|
@@ -676,6 +696,8 @@ class ContestEngine {
|
|
|
676
696
|
registry: deps.registry,
|
|
677
697
|
chainFor: (ticker) => this.#chainFor(ticker),
|
|
678
698
|
bucketMath: this.#bucketMath,
|
|
699
|
+
readHead: ({ chain }) => this.#readHead({ chain }),
|
|
700
|
+
ruleCache: weightCache,
|
|
679
701
|
current: () => this.#crdt
|
|
680
702
|
.currentEntries(this.#currentBucketCache)
|
|
681
703
|
.map(({ cid, bundle }) => ({ bundle, checks: this.#checksFor(cid, bundle) })),
|
|
@@ -808,35 +830,27 @@ class ContestEngine {
|
|
|
808
830
|
const head = await this.#deps.readHead(this.#ruleChain);
|
|
809
831
|
this.#currentBucketCache = this.#bucketMath.bucketForBlock(Number(head));
|
|
810
832
|
this.#headReadMs = Date.now();
|
|
811
|
-
this.#maybePurgeGateResults();
|
|
812
833
|
return this.#currentBucketCache;
|
|
813
834
|
}
|
|
814
|
-
/** The last purge's expiry boundary (oldest admissible sample block); 0 = never purged. */
|
|
815
|
-
#purgedSampleBlock = 0;
|
|
816
835
|
/**
|
|
817
|
-
*
|
|
818
|
-
*
|
|
819
|
-
*
|
|
820
|
-
*
|
|
821
|
-
*
|
|
822
|
-
*
|
|
823
|
-
* long-lived engine still sheds entries as they expire instead of leaving them to the
|
|
824
|
-
* LRU backstop. Fire-and-forget by design.
|
|
836
|
+
* The current head on `chain`, handed to every rule as `ctx.head` (verify/bundle.ts,
|
|
837
|
+
* verify/background.ts, tally/tally.ts all take this seam). It goes through the voter-wide
|
|
838
|
+
* {@link ResolvedDeps.readHead} coalescer for the same reason {@link #refreshBucket} does:
|
|
839
|
+
* a rule that scores current state puts this on the verify path, so a directory-wide gossip
|
|
840
|
+
* burst would otherwise fire one `eth_blockNumber` per contest per message instead of
|
|
841
|
+
* sharing one in-flight read per chain.
|
|
825
842
|
*/
|
|
826
|
-
#
|
|
827
|
-
|
|
828
|
-
if (oldestBucket <= 0)
|
|
829
|
-
return;
|
|
830
|
-
const oldestSampleBlock = this.#bucketMath.sampleBlockForBucket(oldestBucket);
|
|
831
|
-
if (oldestSampleBlock <= this.#purgedSampleBlock)
|
|
832
|
-
return;
|
|
833
|
-
this.#purgedSampleBlock = oldestSampleBlock;
|
|
834
|
-
void purgeExpiredGateResults({
|
|
835
|
-
store: this.#deps.gateStore,
|
|
836
|
-
ruleHash: this.#ruleHash,
|
|
837
|
-
oldestSampleBlock
|
|
838
|
-
});
|
|
843
|
+
async #readHead(args) {
|
|
844
|
+
return { block: Number(await this.#deps.readHead(args.chain)) };
|
|
839
845
|
}
|
|
846
|
+
/**
|
|
847
|
+
* PURGE REMOVED — kept as a note because the old behaviour was load-bearing and its
|
|
848
|
+
* replacement lives elsewhere. Persisted gate entries used to be purged here, at the oldest
|
|
849
|
+
* admissible bucket sample block, because the engine knew every entry was keyed by one. It
|
|
850
|
+
* no longer knows: a rule chooses its own keys and epochs (rules/cache.ts), so only the rule
|
|
851
|
+
* can say what is dead — `erc5192-min-balance` purges its head-keyed entries as the head
|
|
852
|
+
* window rolls, and its pinned entries stay valid until the store's LRU bound reclaims them.
|
|
853
|
+
*/
|
|
840
854
|
/** `Date.now()` of the last gating-chain head read, memoizing {@link #nowBucket}. */
|
|
841
855
|
#headReadMs = 0;
|
|
842
856
|
/** The current gating-chain head bucket, memoized for {@link HEAD_BUCKET_TTL_MS}. */
|
|
@@ -858,6 +872,21 @@ class ContestEngine {
|
|
|
858
872
|
return true;
|
|
859
873
|
return sampleBucket <= (await this.#nowBucket());
|
|
860
874
|
}
|
|
875
|
+
/**
|
|
876
|
+
* Run the gate rule for one wallet, against the ballot block a vote published NOW would carry
|
|
877
|
+
* — the engine half of {@link Contest.checkEligibility}.
|
|
878
|
+
*
|
|
879
|
+
* All the interesting decisions belong to the rule: this resolves the current bucket's sample
|
|
880
|
+
* block, hands it over, and translates the rule's own answer. It never looks at what kind of
|
|
881
|
+
* rule it is holding, so it stays correct for a head-scoring gate, a pinned one, or anything
|
|
882
|
+
* a host registers later.
|
|
883
|
+
*/
|
|
884
|
+
async checkEligibility({ address }) {
|
|
885
|
+
const sampleBlock = this.#bucketMath.sampleBlockForBucket(await this.#nowBucket());
|
|
886
|
+
const result = await this.#verifier.checkGate({ address, sampleBlock });
|
|
887
|
+
const failed = gateFailure(result);
|
|
888
|
+
return failed ? { eligible: false, error: failed.error } : { eligible: true, score: scoreOrZero(result) };
|
|
889
|
+
}
|
|
861
890
|
/** Hash of the current bucket boundary block on the gating (`rule`) chain (rolling tie seed). */
|
|
862
891
|
async #bucketBlockHash() {
|
|
863
892
|
const head = await this.#ruleChain.getBlockNumber();
|
|
@@ -1683,6 +1712,9 @@ class ContestView {
|
|
|
1683
1712
|
getTally() {
|
|
1684
1713
|
return this.#engine.computeTally();
|
|
1685
1714
|
}
|
|
1715
|
+
checkEligibility(args) {
|
|
1716
|
+
return this.#engine.checkEligibility(args);
|
|
1717
|
+
}
|
|
1686
1718
|
/**
|
|
1687
1719
|
* Internal hook (not part of the {@link Contest} interface): this contest's current checkpoint
|
|
1688
1720
|
* root record, encoded on demand. The fetch responder and heartbeat use the engine directly;
|
package/dist/index.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export * from "./rules/erc5192-min-balance.js";
|
|
|
19
19
|
export * from "./rules/erc721-min-balance.js";
|
|
20
20
|
export * from "./rules/constant.js";
|
|
21
21
|
export * from "./rules/registry.js";
|
|
22
|
+
export { makeMemoryRuleCache, makePersistentRuleCache, type RuleCache } from "./rules/cache.js";
|
|
22
23
|
export * from "./encoding/canonical.js";
|
|
23
24
|
export * from "./topic.js";
|
|
24
25
|
export * from "./errors.js";
|
package/dist/index.js
CHANGED
|
@@ -34,6 +34,10 @@ export * from "./rules/erc5192-min-balance.js";
|
|
|
34
34
|
export * from "./rules/erc721-min-balance.js";
|
|
35
35
|
export * from "./rules/constant.js";
|
|
36
36
|
export * from "./rules/registry.js";
|
|
37
|
+
// The cache a rule computes through (`ctx.cache`): a rule owns its keys and epochs, the library
|
|
38
|
+
// owns the store, the bound and the purge. A custom chain-reading rule MUST memoize through it —
|
|
39
|
+
// see rules/cache.ts and README "Custom rules".
|
|
40
|
+
export { makeMemoryRuleCache, makePersistentRuleCache } from "./rules/cache.js";
|
|
37
41
|
// Implemented runtime: encoding, topic, errors, identity seam, facade.
|
|
38
42
|
export * from "./encoding/canonical.js";
|
|
39
43
|
export * from "./topic.js";
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { LruStorage } from "../storage/types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The cache a rule computes through.
|
|
4
|
+
*
|
|
5
|
+
* A rule decides *what* it reads and *when* the answer stops being true; the library decides
|
|
6
|
+
* where that answer is stored, how it is bounded, and how it is shared. This seam is that split.
|
|
7
|
+
* It exists because cache validity is the thing that genuinely differs between rules and cannot
|
|
8
|
+
* be expressed generically:
|
|
9
|
+
*
|
|
10
|
+
* - a score read at a pinned historical block is true forever, so it should never expire;
|
|
11
|
+
* - a score read at the chain head stops being true almost immediately — a wallet that failed
|
|
12
|
+
* a moment ago may hold the gate asset now — so it must expire, and how fast is a judgement
|
|
13
|
+
* only the rule can make;
|
|
14
|
+
* - some reads are not per-wallet at all (`erc5192-min-balance` probes `supportsInterface`
|
|
15
|
+
* once per contract), which no per-wallet key can express.
|
|
16
|
+
*
|
|
17
|
+
* The library still owns the mechanics, because they are not rule-specific and are easy to get
|
|
18
|
+
* wrong: persistence under the voter's `dataPath`, the bounded in-memory front, and the
|
|
19
|
+
* namespace. Every rule instance gets its own keyspace, namespaced by the rule's `type`, its
|
|
20
|
+
* canonical options and the id of the chain IT reads (which for the weight rule need not be the
|
|
21
|
+
* gating chain) — so two contests running the same gate share each other's reads (a 5chan-style
|
|
22
|
+
* directory of 63 boards on one Pass is one read per wallet, not 63), while two different gates,
|
|
23
|
+
* or the same gate on a different contract or chain, can never collide.
|
|
24
|
+
*
|
|
25
|
+
* **Caching is not optional for a chain-reading rule.** It is what bounds the "one chain read per
|
|
26
|
+
* unique bundle" amplifier: without it, an ineligible wallet can mint fresh-signed bundles and
|
|
27
|
+
* make every peer on the topic pay an RPC round trip for each one (DESIGN.md "Can valid votes
|
|
28
|
+
* clog the topic?"). {@link RuleCache.memoMany} exists so the correct behaviour is one call.
|
|
29
|
+
*
|
|
30
|
+
* Values are strings because the persistent tier is JSON-backed — a `bigint` score travels as a
|
|
31
|
+
* decimal string, a boolean as `"1"`/`"0"`.
|
|
32
|
+
*/
|
|
33
|
+
export interface RuleCache {
|
|
34
|
+
/** The memoized value for `key` within `epoch`, or `{ value: undefined }` on a miss. */
|
|
35
|
+
get(args: {
|
|
36
|
+
key: string;
|
|
37
|
+
epoch: number;
|
|
38
|
+
}): Promise<{
|
|
39
|
+
value: string | undefined;
|
|
40
|
+
}>;
|
|
41
|
+
/**
|
|
42
|
+
* Memoize `value` for `key` within `epoch`. Idempotent: an existing entry is never
|
|
43
|
+
* overwritten, so a score cannot be silently replaced under a key that still applies.
|
|
44
|
+
* Returns immediately — any persistence settles in the background, so the verify hot path
|
|
45
|
+
* never waits on a cache write.
|
|
46
|
+
*/
|
|
47
|
+
set(args: {
|
|
48
|
+
key: string;
|
|
49
|
+
epoch: number;
|
|
50
|
+
value: string;
|
|
51
|
+
}): void;
|
|
52
|
+
/**
|
|
53
|
+
* Look up many keys at once and read only the misses — the batched path a rule should use
|
|
54
|
+
* for a cold join's wallets, where `read` becomes one multicall instead of N round trips.
|
|
55
|
+
* `read` is called at most once, with the missing keys in order, and MUST return one value
|
|
56
|
+
* per key it was given. Skipped entirely when everything hits.
|
|
57
|
+
*/
|
|
58
|
+
memoMany(args: {
|
|
59
|
+
keys: string[];
|
|
60
|
+
epoch: number;
|
|
61
|
+
read: (args: {
|
|
62
|
+
keys: string[];
|
|
63
|
+
}) => Promise<{
|
|
64
|
+
values: string[];
|
|
65
|
+
}>;
|
|
66
|
+
}): Promise<{
|
|
67
|
+
values: string[];
|
|
68
|
+
}>;
|
|
69
|
+
/**
|
|
70
|
+
* Drop persisted entries below `epoch` — the rule's own statement that they are dead.
|
|
71
|
+
*
|
|
72
|
+
* This is on the rule because only the rule knows what an epoch means. A rule keying by the
|
|
73
|
+
* chain head knows everything behind the current window is unreachable; a rule keying by a
|
|
74
|
+
* bundle's pinned block knows nothing expires until the vote itself does. Optionally
|
|
75
|
+
* restricted to keys starting with `keyPrefix`, so a rule that mixes both — as the v1 gate
|
|
76
|
+
* does — can purge its head-keyed entries without touching its permanently-valid ones.
|
|
77
|
+
*
|
|
78
|
+
* Best-effort and fire-and-forget: the store's LRU bound is the backstop for a rule that
|
|
79
|
+
* never calls it. Repeat calls at or below the last purged epoch are free.
|
|
80
|
+
*/
|
|
81
|
+
purgeBelow(args: {
|
|
82
|
+
epoch: number;
|
|
83
|
+
keyPrefix?: string;
|
|
84
|
+
}): void;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* An in-memory {@link RuleCache}, FIFO-bounded. Used on its own by unit tests and as the hot
|
|
88
|
+
* front of the persistent cache. Eviction is safe: an evicted entry costs a re-read, never a
|
|
89
|
+
* wrong answer — and without a bound, a flood of fresh wallets would be a memory-exhaustion
|
|
90
|
+
* vector (DESIGN.md "Can valid votes clog the topic?").
|
|
91
|
+
*/
|
|
92
|
+
export declare function makeMemoryRuleCache(args?: {
|
|
93
|
+
maxEntries?: number;
|
|
94
|
+
}): RuleCache;
|
|
95
|
+
/**
|
|
96
|
+
* A {@link RuleCache} over the voter's persistent store: the in-memory FIFO front above, with
|
|
97
|
+
* read-through on a miss and fire-and-forget write-through. A broken store read or write
|
|
98
|
+
* degrades to a live chain read — never an error into the verify pipeline.
|
|
99
|
+
*
|
|
100
|
+
* `namespace` is the rule's keyspace (see {@link RuleCache}); the voter derives it from the
|
|
101
|
+
* canonical rule reference + chain id.
|
|
102
|
+
*/
|
|
103
|
+
export declare function makePersistentRuleCache(args: {
|
|
104
|
+
store: LruStorage;
|
|
105
|
+
namespace: string;
|
|
106
|
+
maxMemEntries?: number;
|
|
107
|
+
}): RuleCache;
|