@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 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 and carries a multicall3 deployment in its viem `chain` config; return `undefined` for a chain with no RPC configured, and `createContest`/`createContestVote` throws `MissingChainClientError` (recuse, don't miscount) |
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 |
@@ -196,13 +196,13 @@ Full, type-checked call patterns for a pkc-js host, a plebbit/seedit host, and a
196
196
 
197
197
  ### Custom rules
198
198
 
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 and is evaluated at the bundle's bucket block. Chain-reading rules get `ctx.chain` the viem `PublicClient` for their `options.chain` and write their own reads (`readContract`, `getBalance`, ...), pinning each call to the sampled block with `blockNumber: BigInt(ctx.blockNumber)`. There is **one kind**: `evaluate → { score: bigint }`, a non-negative score where `0n` means "does not qualify" (a result object, not a bare `bigint`, so slot-specific fields can be added later). 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.score > 0n ? weight.score : 0n`. A rule that needs a threshold returns `0n` 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, walletAddresses, ctx })` batch hook (same argument object as `evaluate`, with `walletAddresses` in place of `walletAddress`, returning one `RuleResult` per input wallet in order) — its semantics MUST equal mapping `evaluate` — which the background verifier uses to batch a cold join's gate reads (`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)).
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, 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 → { score: bigint }`, a non-negative score where `0n` means "does not qualify" (a result object, not a bare `bigint`, so slot-specific fields can be added later). 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.score > 0n ? weight.score : 0n`. A rule that needs a threshold returns `0n` 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
200
 
201
201
  Built-ins: `erc5192-min-balance` (v1) and `constant` (v1).
202
202
 
203
203
  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
204
 
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 every bundle is verified at its own pinned block 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.
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 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
206
  - **`erc20-balance`** (not exported) — the same amplification, reopened by fungibility, plus the open lazy-tally ceiling question for balance-derived weight.
207
207
 
208
208
  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 +216,8 @@ import { z } from "zod";
216
216
  const seeditModAllowlist: Rule<{ type: "seedit-mod-allowlist"; allow: string[] }> = {
217
217
  type: "seedit-mod-allowlist",
218
218
  optionsSchema: z.object({ type: z.literal("seedit-mod-allowlist"), allow: z.array(z.string()) }),
219
- async evaluate({ options, walletAddress }) {
220
- return { score: options.allow.includes(walletAddress) ? 1n : 0n }; // gate: 1n admits, 0n rejects
219
+ async evaluate({ options, wallet }) {
220
+ return { score: options.allow.includes(wallet.address) ? 1n : 0n }; // gate: 1n admits, 0n rejects
221
221
  }
222
222
  };
223
223
 
@@ -229,6 +229,42 @@ const voter = new PubsubVoter({
229
229
 
230
230
  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
231
 
232
+ #### What a rule owns: its block, and its cache
233
+
234
+ 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:
235
+
236
+ ```ts
237
+ evaluate(args: { options: O; wallet: { address: string; sampleBlock: number }; ctx: ChainReadContext }): Promise<RuleResult>
238
+
239
+ interface ChainReadContext {
240
+ chain: ChainClient; // the viem PublicClient for options.chain
241
+ head: () => Promise<{ block: number }>; // this verifier's current block — lazy, coalesced
242
+ cache: RuleCache; // this rule's memo, persistent and shared across contests
243
+ }
244
+ ```
245
+
246
+ - **`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`).
247
+ - **`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.
248
+ - **`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.
249
+
250
+ ```ts
251
+ const { values } = await ctx.cache.memoMany({
252
+ keys: wallets.map((w) => `bal/${w.toLowerCase()}`),
253
+ epoch: block, // e.g. the pinned block, or a coarse head window
254
+ read: async ({ keys }) => ({ values: await readOnChain(keys) })
255
+ });
256
+ ```
257
+
258
+ `RuleResult` carries one field beyond the score:
259
+
260
+ ```ts
261
+ interface RuleResult { score: bigint; penalize?: boolean } // default true
262
+ ```
263
+
264
+ **`penalize`** answers the one thing the library cannot: may a `0n` be blamed on the sender? `true` (the default) says every honest verifier computes this same `0n` — 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.
265
+
266
+ `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 `0n` 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).
267
+
232
268
  ### Weighted voting (deferred)
233
269
 
234
270
  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).
@@ -14,9 +14,9 @@ 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";
17
18
  import { makeBundleVerifier } from "../verify/bundle.js";
18
19
  import { makeVerdictCache } from "../verify/cache.js";
19
- import { makePersistentGateResultCache, purgeExpiredGateResults } from "../verify/gate-result-cache.js";
20
20
  import { makeNameResolutionCache } from "../verify/name-resolution-cache.js";
21
21
  import { makeStorage } from "../storage/node.js";
22
22
  import { makeAnnouncer } from "../transport/announce/node.js";
@@ -631,14 +631,31 @@ class ContestEngine {
631
631
  voteExpiryBuckets: criteria.voteExpiryBuckets,
632
632
  isProvisional: (cid) => this.#isPending(cid)
633
633
  });
634
- // One gate-result cache shared between the inline forward-gate verifier and the
635
- // background chain verifier, so neither re-reads a (wallet, sampleBlock) the other
636
- // settled — layered over the voter's persistent store, keyed under this contest's rule
637
- // hash: the gate score is a pure function of (rule, chainId, wallet, sampleBlock), so
638
- // hashing the canonical rule document + chainId is exactly the sharing boundary (two
639
- // contests over one gate share reads; different gates cannot collide).
634
+ // The gate rule's memo (rules/cache.ts), shared between the inline forward-gate verifier
635
+ // and the background chain verifier so neither re-reads what the other settled — layered
636
+ // over the voter's persistent store and namespaced by the hash of the canonical rule
637
+ // reference + chainId. That hash is exactly the sharing boundary: two contests over one
638
+ // gate (a directory of boards on the same Pass) share each other's reads, while
639
+ // different gates, or one gate on different options, can never collide. What is stored
640
+ // under it, and for how long, is the rule's business, not the engine's.
640
641
  this.#ruleHash = sha256(encodeDagCbor({ chainId: this.#chainId, rule: criteria.rule }));
641
- const gateResultCache = makePersistentGateResultCache({ store: deps.gateStore, ruleHash: this.#ruleHash });
642
+ const gateCache = makePersistentRuleCache({ store: deps.gateStore, namespace: this.#ruleHash });
643
+ // The weight rule gets its own namespace on the SAME terms — its canonical reference plus
644
+ // the id of the chain IT reads, which is not necessarily the gating chain. A ticker is
645
+ // just a name local to the criteria document, so two contests can spell the same weight
646
+ // ref while `requires.chains` binds that ticker to different chains; keying on the gate's
647
+ // chainId would let one serve the other's scores from the wrong chain.
648
+ const weight = deps.registry[criteria.weight.type];
649
+ if (!weight)
650
+ throw new UnknownRuleError("weight", criteria.weight.type);
651
+ const weightTicker = tickerForRef(criteria, criteria.weight, weight.optionsSchema.parse(criteria.weight));
652
+ const weightChainId = criteria.requires.chains[weightTicker]?.chainId;
653
+ if (weightChainId === undefined)
654
+ throw new Error(`no chain client for weight chain "${weightTicker}"`);
655
+ const weightCache = makePersistentRuleCache({
656
+ store: deps.gateStore,
657
+ namespace: sha256(encodeDagCbor({ chainId: weightChainId, rule: criteria.weight }))
658
+ });
642
659
  const verifier = makeBundleVerifier({
643
660
  criteria,
644
661
  criteriaCid: criteriaCidBytes,
@@ -647,8 +664,9 @@ class ContestEngine {
647
664
  chainFor: (ticker) => this.#chainFor(ticker),
648
665
  bucketMath: this.#bucketMath,
649
666
  nameResolvers: deps.nameResolvers,
650
- gateResultCache,
651
- nameResolutionCache: deps.nameResolutionCache
667
+ ruleCache: gateCache,
668
+ nameResolutionCache: deps.nameResolutionCache,
669
+ readHead: ({ chain }) => this.#readHead({ chain })
652
670
  });
653
671
  // The gate/transport are (re)built on join(); the store, crdt, caches, verifier, and
654
672
  // background verifier are stable per contest, so they survive re-joins of the topic.
@@ -662,8 +680,9 @@ class ContestEngine {
662
680
  chainFor: (ticker) => this.#chainFor(ticker),
663
681
  bucketMath: this.#bucketMath,
664
682
  nameResolvers: deps.nameResolvers,
665
- gateResultCache,
683
+ ruleCache: gateCache,
666
684
  nameResolutionCache: deps.nameResolutionCache,
685
+ readHead: ({ chain }) => this.#readHead({ chain }),
667
686
  cache: this.#cache,
668
687
  onGateVerified: (cid) => this.#settleCheck(cid, "chainVerified"),
669
688
  onNameResolved: (cid) => this.#settleCheck(cid, "nameResolved"),
@@ -676,6 +695,8 @@ class ContestEngine {
676
695
  registry: deps.registry,
677
696
  chainFor: (ticker) => this.#chainFor(ticker),
678
697
  bucketMath: this.#bucketMath,
698
+ readHead: ({ chain }) => this.#readHead({ chain }),
699
+ ruleCache: weightCache,
679
700
  current: () => this.#crdt
680
701
  .currentEntries(this.#currentBucketCache)
681
702
  .map(({ cid, bundle }) => ({ bundle, checks: this.#checksFor(cid, bundle) })),
@@ -808,35 +829,27 @@ class ContestEngine {
808
829
  const head = await this.#deps.readHead(this.#ruleChain);
809
830
  this.#currentBucketCache = this.#bucketMath.bucketForBlock(Number(head));
810
831
  this.#headReadMs = Date.now();
811
- this.#maybePurgeGateResults();
812
832
  return this.#currentBucketCache;
813
833
  }
814
- /** The last purge's expiry boundary (oldest admissible sample block); 0 = never purged. */
815
- #purgedSampleBlock = 0;
816
834
  /**
817
- * Drop this rule's persisted gate results older than the oldest admissible sample block —
818
- * provably dead: a score at bucket B is only ever consulted while bundles from B are within
819
- * `voteExpiryBuckets` of head (see verify/gate-result-cache.ts `purgeExpiredGateResults`).
820
- * Piggybacks on the head reads the engine does anyway (join-with-state, publish, tally)
821
- * and re-runs only when the boundary advances past the last purged one — so an idle
822
- * engine costs no chain read and no purge, and a steady head costs no key scan, but a
823
- * long-lived engine still sheds entries as they expire instead of leaving them to the
824
- * LRU backstop. Fire-and-forget by design.
835
+ * The current head on `chain`, handed to every rule as `ctx.head` (verify/bundle.ts,
836
+ * verify/background.ts, tally/tally.ts all take this seam). It goes through the voter-wide
837
+ * {@link ResolvedDeps.readHead} coalescer for the same reason {@link #refreshBucket} does:
838
+ * a rule that scores current state puts this on the verify path, so a directory-wide gossip
839
+ * burst would otherwise fire one `eth_blockNumber` per contest per message instead of
840
+ * sharing one in-flight read per chain.
825
841
  */
826
- #maybePurgeGateResults() {
827
- const oldestBucket = this.#currentBucketCache - this.criteria.voteExpiryBuckets;
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
- });
842
+ async #readHead(args) {
843
+ return { block: Number(await this.#deps.readHead(args.chain)) };
839
844
  }
845
+ /**
846
+ * PURGE REMOVED — kept as a note because the old behaviour was load-bearing and its
847
+ * replacement lives elsewhere. Persisted gate entries used to be purged here, at the oldest
848
+ * admissible bucket sample block, because the engine knew every entry was keyed by one. It
849
+ * no longer knows: a rule chooses its own keys and epochs (rules/cache.ts), so only the rule
850
+ * can say what is dead — `erc5192-min-balance` purges its head-keyed entries as the head
851
+ * window rolls, and its pinned entries stay valid until the store's LRU bound reclaims them.
852
+ */
840
853
  /** `Date.now()` of the last gating-chain head read, memoizing {@link #nowBucket}. */
841
854
  #headReadMs = 0;
842
855
  /** The current gating-chain head bucket, memoized for {@link HEAD_BUCKET_TTL_MS}. */
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;
@@ -0,0 +1,160 @@
1
+ /** `${key}:${epoch}` — the epoch trails, so a purge can parse it back off the end. */
2
+ const entryKey = (key, epoch) => `${key}:${epoch}`;
3
+ /**
4
+ * An in-memory {@link RuleCache}, FIFO-bounded. Used on its own by unit tests and as the hot
5
+ * front of the persistent cache. Eviction is safe: an evicted entry costs a re-read, never a
6
+ * wrong answer — and without a bound, a flood of fresh wallets would be a memory-exhaustion
7
+ * vector (DESIGN.md "Can valid votes clog the topic?").
8
+ */
9
+ export function makeMemoryRuleCache(args = {}) {
10
+ const maxEntries = args.maxEntries ?? 4096;
11
+ const byKey = new Map();
12
+ const order = [];
13
+ const cache = {
14
+ async get({ key, epoch }) {
15
+ return { value: byKey.get(entryKey(key, epoch)) };
16
+ },
17
+ set({ key, epoch, value }) {
18
+ const k = entryKey(key, epoch);
19
+ if (byKey.has(k))
20
+ return; // idempotent: never refresh position or overwrite
21
+ byKey.set(k, value);
22
+ order.push(k);
23
+ if (order.length > maxEntries) {
24
+ const evicted = order.shift();
25
+ if (evicted !== undefined)
26
+ byKey.delete(evicted);
27
+ }
28
+ },
29
+ memoMany: (memoArgs) => memoManyOver(cache, memoArgs),
30
+ purgeBelow({ epoch, keyPrefix }) {
31
+ for (const k of [...byKey.keys()]) {
32
+ if (keyPrefix !== undefined && !k.startsWith(keyPrefix))
33
+ continue;
34
+ const at = Number(k.slice(k.lastIndexOf(":") + 1));
35
+ if (Number.isFinite(at) && at < epoch) {
36
+ byKey.delete(k);
37
+ const i = order.indexOf(k);
38
+ if (i >= 0)
39
+ order.splice(i, 1);
40
+ }
41
+ }
42
+ }
43
+ };
44
+ return cache;
45
+ }
46
+ /**
47
+ * A {@link RuleCache} over the voter's persistent store: the in-memory FIFO front above, with
48
+ * read-through on a miss and fire-and-forget write-through. A broken store read or write
49
+ * degrades to a live chain read — never an error into the verify pipeline.
50
+ *
51
+ * `namespace` is the rule's keyspace (see {@link RuleCache}); the voter derives it from the
52
+ * canonical rule reference + chain id.
53
+ */
54
+ export function makePersistentRuleCache(args) {
55
+ const { store, namespace } = args;
56
+ const mem = makeMemoryRuleCache(args.maxMemEntries === undefined ? {} : { maxEntries: args.maxMemEntries });
57
+ const storeKey = (key, epoch) => `${namespace}:${entryKey(key, epoch)}`;
58
+ /** The highest epoch already purged per prefix, so a steady head costs no key scan. */
59
+ const purged = new Map();
60
+ const cache = {
61
+ async get({ key, epoch }) {
62
+ const hit = await mem.get({ key, epoch });
63
+ if (hit.value !== undefined)
64
+ return hit;
65
+ let persisted;
66
+ try {
67
+ persisted = await store.getItem(storeKey(key, epoch));
68
+ }
69
+ catch {
70
+ return { value: undefined };
71
+ }
72
+ if (typeof persisted !== "string")
73
+ return { value: undefined };
74
+ mem.set({ key, epoch, value: persisted });
75
+ return { value: persisted };
76
+ },
77
+ set({ key, epoch, value }) {
78
+ mem.set({ key, epoch, value });
79
+ void store.setItem(storeKey(key, epoch), value).catch(() => {
80
+ // a failed persist costs a future re-read, never a wrong answer
81
+ });
82
+ },
83
+ memoMany: (memoArgs) => memoManyOver(cache, memoArgs),
84
+ purgeBelow({ epoch, keyPrefix }) {
85
+ const prefix = `${namespace}:${keyPrefix ?? ""}`;
86
+ if ((purged.get(prefix) ?? 0) >= epoch)
87
+ return;
88
+ purged.set(prefix, epoch);
89
+ mem.purgeBelow(keyPrefix === undefined ? { epoch } : { epoch, keyPrefix });
90
+ void (async () => {
91
+ try {
92
+ for (const key of await store.keys()) {
93
+ if (!key.startsWith(prefix))
94
+ continue;
95
+ const at = Number(key.slice(key.lastIndexOf(":") + 1));
96
+ if (Number.isFinite(at) && at < epoch)
97
+ await store.removeItem(key);
98
+ }
99
+ }
100
+ catch {
101
+ // purge is best-effort; the store's LRU bound is the correctness-free backstop
102
+ }
103
+ })();
104
+ }
105
+ };
106
+ return cache;
107
+ }
108
+ /**
109
+ * The shared {@link RuleCache.memoMany} body: read the misses once, in order, then memoize.
110
+ *
111
+ * The lookups are issued CONCURRENTLY, not one at a time. In the persistent cache a miss on the
112
+ * in-memory front falls through to a store round trip, so awaiting each key in turn would cost a
113
+ * cold join one serial store hit per wallet before its single batched chain read even starts —
114
+ * exactly the shape of the first `memoMany` after a restart, when the front is empty by
115
+ * definition. Order is preserved explicitly instead of by loop sequencing.
116
+ */
117
+ async function memoManyOver(cache, args) {
118
+ const { keys, epoch, read } = args;
119
+ const values = new Array(keys.length);
120
+ const missing = [];
121
+ const missingAt = [];
122
+ // Unique keys first, each remembering every position it occupies, so a duplicate key is
123
+ // looked up once and read once.
124
+ const positions = new Map();
125
+ for (let i = 0; i < keys.length; i++) {
126
+ const key = keys[i];
127
+ const at = positions.get(key);
128
+ if (at)
129
+ at.push(i);
130
+ else
131
+ positions.set(key, [i]);
132
+ }
133
+ const unique = [...positions.keys()];
134
+ const hits = await Promise.all(unique.map((key) => cache.get({ key, epoch })));
135
+ for (let u = 0; u < unique.length; u++) {
136
+ const key = unique[u];
137
+ const at = positions.get(key);
138
+ const { value } = hits[u];
139
+ if (value !== undefined) {
140
+ for (const i of at)
141
+ values[i] = value;
142
+ continue;
143
+ }
144
+ missingAt.push(at);
145
+ missing.push(key);
146
+ }
147
+ if (missing.length > 0) {
148
+ const read_ = await read({ keys: missing });
149
+ if (read_.values.length !== missing.length) {
150
+ throw new Error(`RuleCache.memoMany: read returned ${read_.values.length} values for ${missing.length} keys`);
151
+ }
152
+ missing.forEach((key, i) => {
153
+ const value = read_.values[i];
154
+ cache.set({ key, epoch, value });
155
+ for (const at of missingAt[i])
156
+ values[at] = value;
157
+ });
158
+ }
159
+ return { values: values.map((value) => value) };
160
+ }
@@ -37,13 +37,17 @@ export const Erc20BalanceOptionsSchema = z.object({
37
37
  export const erc20Balance = {
38
38
  type: "erc20-balance",
39
39
  optionsSchema: Erc20BalanceOptionsSchema,
40
- async evaluate({ options, walletAddress, ctx }) {
40
+ // Scores at the bundle's OWN pinned block: a fungible balance is the least stable score
41
+ // there is — it moves in both directions with every transfer — so it may not be read at the
42
+ // head, and a `0n` here is attributable (`penalize` stays at its default). See registry.ts
43
+ // for why this rule is unregistered regardless.
44
+ async evaluate({ options, wallet, ctx }) {
41
45
  const raw = await ctx.chain.readContract({
42
46
  address: getAddress(options.contract),
43
47
  abi: erc20Abi,
44
48
  functionName: "balanceOf",
45
- args: [getAddress(walletAddress)],
46
- blockNumber: BigInt(ctx.blockNumber)
49
+ args: [getAddress(wallet.address)],
50
+ blockNumber: BigInt(wallet.sampleBlock)
47
51
  });
48
52
  const minUnits = parseUnits(options.min.toString(), options.decimals);
49
53
  return { score: raw >= minUnits ? raw : 0n };
@@ -3,19 +3,27 @@ import type { Rule } from "./types.js";
3
3
  /**
4
4
  * Hold at least `min` of a **soulbound** ERC-721 (the 5chan Pass). The v1 gate.
5
5
  *
6
- * Same `balanceOf` scoring as `erc721-min-balance` — the wallet's holding at the bucket block
7
- * if it meets `min`, else `0n` — plus one assertion at the SAME pinned block: the contract must
6
+ * Same `balanceOf` scoring as `erc721-min-balance` — the wallet's holding at the sampled block
7
+ * if it meets `min`, else `0n` — plus one assertion at the SAME block: the contract must
8
8
  * declare ERC-5192 (`supportsInterface(0xb45a3c0e)`). A contract that does not declare it scores
9
9
  * `0n` for every wallet, so the contest admits nobody rather than gating on a transferable asset.
10
10
  *
11
+ * Unlike the other rules in the tree, this one scores at the verifier's CURRENT head rather than
12
+ * at the bundle's bucket boundary, falling back to that boundary only when the head read refuses
13
+ * — so a freshly-acquired Pass counts immediately. See `evaluateMany` for why both legs exist,
14
+ * and rules/types.ts for what the pipeline does with `penalize: false`.
15
+ *
11
16
  * **Why the assertion is the whole point.** The gate bounds Sybils only if the gating asset
12
- * cannot move (DESIGN.md "Does one Pass mean one vote?"): every bundle is verified at its OWN
13
- * pinned block, stays live for `voteExpiryBuckets`, and the winner set is LWW-keyed per wallet —
14
- * so one transferable token walked A → B → C inside a single expiry window backs three
15
- * concurrent live votes, each read true at its own block and none collapsed by LWW. Nothing in
16
- * the verify pipeline can see that: every ballot is individually correct. Requiring the asset to
17
- * be non-transferable AND to say so on-chain closes it with no wire change, no extra archive
18
- * depth, and no second read per wallet. Pinned by `src/crdt/amplification.test.ts`.
17
+ * cannot move (DESIGN.md "Does one Pass mean one vote?"). A vote is verified ONCE, when it is
18
+ * merged, and then stays live for `voteExpiryBuckets` in a winner set LWW-keyed per wallet — so
19
+ * one transferable token walked A → B → C inside a single expiry window backs three concurrent
20
+ * live votes, each read true when it was checked and none collapsed by LWW. Nothing in the
21
+ * verify pipeline can see that: every ballot is individually correct. (Reading pinned blocks,
22
+ * the three reads land at three different historical blocks; reading the head, at three
23
+ * different verification times. Transferability defeats both.) Requiring the asset to be
24
+ * non-transferable AND to say so on-chain closes it with no wire change — and it is the same
25
+ * property that makes scoring at the head sound at all. Pinned by
26
+ * `src/crdt/amplification.test.ts`.
19
27
  *
20
28
  * **What the assertion does and does not prove.** `supportsInterface(0xb45a3c0e)` asserts the
21
29
  * contract *reports* lock state — ERC-5192's only function is `locked(uint256)`. ERC-5192