@bitsocial/pubsub-voting 0.2.0 → 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.
@@ -5,19 +5,27 @@ import { balanceOf, balancesOfBatched, canBatch, scoreOf } from "./nft-balance.j
5
5
  /**
6
6
  * Hold at least `min` of a **soulbound** ERC-721 (the 5chan Pass). The v1 gate.
7
7
  *
8
- * Same `balanceOf` scoring as `erc721-min-balance` — the wallet's holding at the bucket block
9
- * if it meets `min`, else `0n` — plus one assertion at the SAME pinned block: the contract must
8
+ * Same `balanceOf` scoring as `erc721-min-balance` — the wallet's holding at the sampled block
9
+ * if it meets `min`, else `0n` — plus one assertion at the SAME block: the contract must
10
10
  * declare ERC-5192 (`supportsInterface(0xb45a3c0e)`). A contract that does not declare it scores
11
11
  * `0n` for every wallet, so the contest admits nobody rather than gating on a transferable asset.
12
12
  *
13
+ * Unlike the other rules in the tree, this one scores at the verifier's CURRENT head rather than
14
+ * at the bundle's bucket boundary, falling back to that boundary only when the head read refuses
15
+ * — so a freshly-acquired Pass counts immediately. See `evaluateMany` for why both legs exist,
16
+ * and rules/types.ts for what the pipeline does with `penalize: false`.
17
+ *
13
18
  * **Why the assertion is the whole point.** The gate bounds Sybils only if the gating asset
14
- * cannot move (DESIGN.md "Does one Pass mean one vote?"): every bundle is verified at its OWN
15
- * pinned block, stays live for `voteExpiryBuckets`, and the winner set is LWW-keyed per wallet —
16
- * so one transferable token walked A → B → C inside a single expiry window backs three
17
- * concurrent live votes, each read true at its own block and none collapsed by LWW. Nothing in
18
- * the verify pipeline can see that: every ballot is individually correct. Requiring the asset to
19
- * be non-transferable AND to say so on-chain closes it with no wire change, no extra archive
20
- * depth, and no second read per wallet. Pinned by `src/crdt/amplification.test.ts`.
19
+ * cannot move (DESIGN.md "Does one Pass mean one vote?"). A vote is verified ONCE, when it is
20
+ * merged, and then stays live for `voteExpiryBuckets` in a winner set LWW-keyed per wallet — so
21
+ * one transferable token walked A → B → C inside a single expiry window backs three concurrent
22
+ * live votes, each read true when it was checked and none collapsed by LWW. Nothing in the
23
+ * verify pipeline can see that: every ballot is individually correct. (Reading pinned blocks,
24
+ * the three reads land at three different historical blocks; reading the head, at three
25
+ * different verification times. Transferability defeats both.) Requiring the asset to be
26
+ * non-transferable AND to say so on-chain closes it with no wire change — and it is the same
27
+ * property that makes scoring at the head sound at all. Pinned by
28
+ * `src/crdt/amplification.test.ts`.
21
29
  *
22
30
  * **What the assertion does and does not prove.** `supportsInterface(0xb45a3c0e)` asserts the
23
31
  * contract *reports* lock state — ERC-5192's only function is `locked(uint256)`. ERC-5192
@@ -63,49 +71,163 @@ function isContractRefusal(err) {
63
71
  return err.walk((cause) => cause instanceof ContractFunctionRevertedError || cause instanceof ContractFunctionZeroDataError) !== null;
64
72
  }
65
73
  /**
66
- * One `supportsInterface(0xb45a3c0e)` at the sampled block. Identical calldata at the same block
67
- * for every wallet in a batch, so the voter's read coalescer (src/chain/coalescer.ts) dedupes a
68
- * whole checkpoint's worth of wallets and every parallel contest on the same contract onto a
69
- * single extra read, folded into the same `aggregate3` as the balances.
74
+ * How coarsely a head-scored read is memoized, in blocks (~1 minute on Base's 2 s blocks).
75
+ *
76
+ * The read itself is at the freshest head this verifier has that is the whole point, a Pass
77
+ * acquired seconds ago must count now. Only the cache EPOCH is quantized, and it has to be:
78
+ * an epoch that moved every block would never hit, and the memo is what stops an ineligible
79
+ * wallet from costing every peer one chain read per fresh-signed bundle (see rules/cache.ts).
80
+ *
81
+ * The number to reason about is what a stale NEGATIVE costs: a wallet already checked and failed
82
+ * stays failed on this verifier until the epoch rolls, even if it acquires the Pass in between —
83
+ * so it is deliberately short. A positive is unaffected in practice (a holding that cannot move
84
+ * cannot stop being true), and re-reading one when the epoch rolls is a wasted read, never a
85
+ * wrong answer. Local resource policy, NOT consensus: two peers may quantize differently and
86
+ * still agree on every verdict, which is why it lives here and not in the criteria.
70
87
  */
71
- async function declaresErc5192(contract, ctx) {
88
+ const HEAD_EPOCH_BLOCKS = 30;
89
+ /** Cache-key prefixes: head-scored entries expire with the head, pinned ones never do. */
90
+ const HEAD_PREFIX = "head/";
91
+ const PINNED_PREFIX = "pin/";
92
+ /** One `supportsInterface(0xb45a3c0e)` at `block`. Revert/zero-data ⇒ "does not declare". */
93
+ async function declaresErc5192(args) {
72
94
  try {
73
- return await ctx.chain.readContract({
74
- address: contract,
95
+ const declares = await args.ctx.chain.readContract({
96
+ address: args.contract,
75
97
  abi: erc165Abi,
76
98
  functionName: "supportsInterface",
77
99
  args: [ERC5192_INTERFACE_ID],
78
- blockNumber: BigInt(ctx.blockNumber)
100
+ blockNumber: BigInt(args.block)
79
101
  });
102
+ return { declares };
80
103
  }
81
104
  catch (err) {
82
105
  if (isContractRefusal(err))
83
- return false;
106
+ return { declares: false };
84
107
  throw err;
85
108
  }
86
109
  }
110
+ /**
111
+ * Score every wallet at ONE block, memoized under one epoch — the whole rule body, run once per
112
+ * leg (see `evaluateMany`).
113
+ *
114
+ * Two memos, both through the rule's cache: the ERC-5192 declaration, keyed per CONTRACT (it is
115
+ * not a per-wallet fact, so a whole checkpoint's wallets share one probe — a key shape the old
116
+ * per-wallet gate cache could not express at all), and each wallet's raw balance. Balances are
117
+ * cached rather than scores so a change to `min` cannot be served a stale verdict; the batched
118
+ * read behind the misses is one multicall3 `aggregate3` per 200 wallets.
119
+ */
120
+ async function scoreAt(args) {
121
+ const { contract, min, wallets, block, epoch, prefix, ctx } = args;
122
+ if (wallets.length === 0)
123
+ return { scores: [] };
124
+ const [declared] = (await ctx.cache.memoMany({
125
+ keys: [`${prefix}lock/${contract.toLowerCase()}`],
126
+ epoch,
127
+ read: async () => {
128
+ const { declares } = await declaresErc5192({ contract, block, ctx });
129
+ return { values: [declares ? "1" : "0"] };
130
+ }
131
+ })).values;
132
+ // A contract that does not claim its tokens are locked gates nothing: admit nobody rather
133
+ // than gate on something transferable (see the rule doc above).
134
+ if (declared !== "1")
135
+ return { scores: wallets.map(() => 0n) };
136
+ const { values } = await ctx.cache.memoMany({
137
+ keys: wallets.map((wallet) => `${prefix}bal/${wallet.toLowerCase()}`),
138
+ epoch,
139
+ read: async ({ keys }) => {
140
+ const missing = keys.map((key) => key.slice(key.lastIndexOf("/") + 1));
141
+ const { balances } = canBatch({ ctx }).batchable
142
+ ? await balancesOfBatched({ contract, wallets: missing, block, ctx })
143
+ : {
144
+ balances: await Promise.all(missing.map(async (wallet) => (await balanceOf({ contract, wallet, block, ctx })).balance))
145
+ };
146
+ return { values: balances.map((balance) => balance.toString()) };
147
+ }
148
+ });
149
+ return { scores: values.map((balance) => scoreOf(BigInt(balance), min)) };
150
+ }
87
151
  export const erc5192MinBalance = {
88
152
  type: "erc5192-min-balance",
89
153
  optionsSchema: Erc5192MinBalanceOptionsSchema,
90
- async evaluate({ options, walletAddress, ctx }) {
91
- const contract = getAddress(options.contract);
92
- // Issued together, not sequenced: same block, so the coalescer folds both into one
93
- // aggregate3 the lock assertion costs no extra round trip.
94
- const [declares, balance] = await Promise.all([declaresErc5192(contract, ctx), balanceOf(contract, walletAddress, ctx)]);
95
- return { score: declares ? scoreOf(balance, options.min) : 0n };
154
+ // One wallet is the batch of one: the two legs, the two epochs and the memos are identical,
155
+ // so there is one body. Called through `erc5192MinBalance.evaluateMany` rather than `this`,
156
+ // which a destructured or re-exported rule object would not carry (issue #29).
157
+ async evaluate({ options, wallet, ctx }) {
158
+ const { results } = await erc5192MinBalance.evaluateMany({ options, wallets: [wallet], ctx });
159
+ return results[0];
96
160
  },
97
- async evaluateMany({ options, walletAddresses, ctx }) {
161
+ /**
162
+ * Score at the HEAD first, falling back to each wallet's own pinned block.
163
+ *
164
+ * **Head first** is what lets a wallet vote in the block it acquires the Pass. Scoring only
165
+ * at the bundle's bucket boundary — the block a ballot names, floored — meant waiting up to
166
+ * a full bucket (an hour on 5chan's live manifest) before a fresh holding was visible, which
167
+ * was never a chain requirement: a read pinned at block N already sees a mint that happened
168
+ * in N. It is sound because a soulbound holding cannot move, so a peer whose head lags can
169
+ * only be LATE to admit a vote, never in lasting disagreement about it.
170
+ *
171
+ * **The pinned fallback** covers what that reasoning does not: ERC-5192 requires transfers to
172
+ * revert while locked, but says nothing about BURNING (a burn does not go through
173
+ * `transferFrom`), so a compliant Pass may be burnable and this rule cannot check otherwise.
174
+ * Without the fallback, a burn would make a peer that verified earlier keep a vote its
175
+ * checkpoint still serves while a cold joiner rejects it — divergence for up to the expiry
176
+ * window — and would hand whoever can burn a retroactive veto over votes already cast.
177
+ * Reading the wallet's own `sampleBlock` when the head says `0n` restores agreement for any
178
+ * vote that was legitimately held when it was cast. It admits nothing the pinned-only v1 gate
179
+ * did not, so it opens no new amplification; a burn-and-remint to a fresh wallet is a
180
+ * transfer by another name and remains a property of the deployment, not something any read
181
+ * can close. Its cost is that a verifier still needs archive depth for that leg.
182
+ *
183
+ * **Two epochs**, which is why this caching lives in the rule: the head leg expires with the
184
+ * head (a stale `0n` must not outlive {@link HEAD_EPOCH_BLOCKS}), while the pinned leg is a
185
+ * historical read that is true forever and is keyed by the block itself.
186
+ *
187
+ * **`penalize: false`** on every result: neither leg makes a `0n` attributable. The peer that
188
+ * forwarded a vote verified it against ITS head, and any peer ahead of us may legitimately
189
+ * see an acquisition we have not — and with burning possible, not holding at either block
190
+ * does not even prove the wallet never held.
191
+ */
192
+ async evaluateMany({ options, wallets, ctx }) {
98
193
  const contract = getAddress(options.contract);
99
- // ONE lock assertion for the whole batch (hoisted out of the per-wallet reads), in
100
- // parallel with the balances so both share the coalescing window.
101
- const [declares, balances] = await Promise.all([
102
- declaresErc5192(contract, ctx),
103
- canBatch(ctx)
104
- ? balancesOfBatched(contract, walletAddresses, ctx)
105
- : Promise.all(walletAddresses.map((wallet) => balanceOf(contract, wallet, ctx)))
106
- ]);
107
- if (!declares)
108
- return walletAddresses.map(() => ({ score: 0n }));
109
- return balances.map((balance) => ({ score: scoreOf(balance, options.min) }));
194
+ const { block: head } = await ctx.head();
195
+ const epoch = Math.floor(head / HEAD_EPOCH_BLOCKS) * HEAD_EPOCH_BLOCKS;
196
+ // Everything behind the current window is unreachable — nothing will look it up again.
197
+ // Scoped to the head keys, so the permanently-valid pinned entries are left alone.
198
+ ctx.cache.purgeBelow({ epoch, keyPrefix: HEAD_PREFIX });
199
+ const { scores } = await scoreAt({
200
+ contract,
201
+ min: options.min,
202
+ wallets: wallets.map((wallet) => wallet.address),
203
+ block: head,
204
+ epoch,
205
+ prefix: HEAD_PREFIX,
206
+ ctx
207
+ });
208
+ // Only wallets the head leg refused reach the fallback, so a holder costs one read path.
209
+ // Grouped by sample block: bundles from different buckets name different pinned blocks.
210
+ const byBlock = new Map();
211
+ scores.forEach((score, i) => {
212
+ if (score > 0n)
213
+ return;
214
+ const block = wallets[i].sampleBlock;
215
+ byBlock.set(block, [...(byBlock.get(block) ?? []), i]);
216
+ });
217
+ await Promise.all([...byBlock].map(async ([block, indexes]) => {
218
+ const fallback = await scoreAt({
219
+ contract,
220
+ min: options.min,
221
+ wallets: indexes.map((i) => wallets[i].address),
222
+ block,
223
+ epoch: block,
224
+ prefix: PINNED_PREFIX,
225
+ ctx
226
+ });
227
+ indexes.forEach((at, i) => {
228
+ scores[at] = fallback.scores[i];
229
+ });
230
+ }));
231
+ return { results: scores.map((score) => ({ score, penalize: false })) };
110
232
  }
111
233
  };
@@ -31,18 +31,43 @@ export const Erc721MinBalanceOptionsSchema = z.object({
31
31
  export const erc721MinBalance = {
32
32
  type: "erc721-min-balance",
33
33
  optionsSchema: Erc721MinBalanceOptionsSchema,
34
- async evaluate({ options, walletAddress, ctx }) {
35
- const balance = await balanceOf(getAddress(options.contract), walletAddress, ctx);
34
+ // Scores at the bundle's OWN pinned block, and deliberately not at the head: a transferable
35
+ // balance can go DOWN, so a vote admitted today would silently become invalid the moment the
36
+ // token moved, and whether it still counted would depend on when each peer last looked. A
37
+ // pinned read is identical on every verifier forever, which is what leaves `penalize` at its
38
+ // default — a `0n` here IS attributable to the sender. That difference is a second,
39
+ // independent reason this rule stays out of `builtinRegistry`, on top of the Sybil
40
+ // amplification described in registry.ts.
41
+ async evaluate({ options, wallet, ctx }) {
42
+ const { balance } = await balanceOf({
43
+ contract: getAddress(options.contract),
44
+ wallet: wallet.address,
45
+ block: wallet.sampleBlock,
46
+ ctx
47
+ });
36
48
  return { score: scoreOf(balance, options.min) };
37
49
  },
38
- async evaluateMany({ options, walletAddresses, ctx }) {
50
+ async evaluateMany({ options, wallets, ctx }) {
39
51
  const contract = getAddress(options.contract);
40
- // Multicall3 `aggregate3` batching (see nft-balance.ts for the chunking policy) the
41
- // path the background chain verifier rides on a cold join. A client that cannot batch
42
- // takes the per-wallet fallback.
43
- const balances = canBatch(ctx)
44
- ? await balancesOfBatched(contract, walletAddresses, ctx)
45
- : await Promise.all(walletAddresses.map((wallet) => balanceOf(contract, wallet, ctx)));
46
- return balances.map((balance) => ({ score: scoreOf(balance, options.min) }));
52
+ // Grouped by sample block, because a batch is no longer guaranteed to share one: the
53
+ // pipeline hands over whatever is pending and each rule groups the way it reads. Within
54
+ // a group it is multicall3 `aggregate3` batching (chunking policy in nft-balance.ts)
55
+ // the path the background chain verifier rides on a cold join; a client that cannot
56
+ // batch takes the per-wallet fallback.
57
+ const scores = new Array(wallets.length);
58
+ const byBlock = new Map();
59
+ wallets.forEach((wallet, i) => byBlock.set(wallet.sampleBlock, [...(byBlock.get(wallet.sampleBlock) ?? []), i]));
60
+ await Promise.all([...byBlock].map(async ([block, indexes]) => {
61
+ const group = indexes.map((i) => wallets[i].address);
62
+ const { balances } = canBatch({ ctx }).batchable
63
+ ? await balancesOfBatched({ contract, wallets: group, block, ctx })
64
+ : {
65
+ balances: await Promise.all(group.map(async (wallet) => (await balanceOf({ contract, wallet, block, ctx })).balance))
66
+ };
67
+ indexes.forEach((at, i) => {
68
+ scores[at] = scoreOf(balances[i], options.min);
69
+ });
70
+ }));
71
+ return { results: scores.map((score) => ({ score })) };
47
72
  }
48
73
  };
@@ -6,12 +6,23 @@ export declare function scoreOf(balance: bigint, min: number): bigint;
6
6
  * its chain's multicall3 deployment. A client built without a `chain` (or on a chain without
7
7
  * multicall3) takes the per-wallet path instead.
8
8
  */
9
- export declare function canBatch(ctx: ChainReadContext): boolean;
10
- /** One wallet's `balanceOf` at the bundle's sampled block. */
11
- export declare function balanceOf(contract: `0x${string}`, walletAddress: string, ctx: ChainReadContext): Promise<bigint>;
9
+ export declare function canBatch(args: {
10
+ ctx: ChainReadContext;
11
+ }): {
12
+ batchable: boolean;
13
+ };
14
+ /** One wallet's `balanceOf` at `block` (the caller's choice — a pinned block or the head). */
15
+ export declare function balanceOf(args: {
16
+ contract: `0x${string}`;
17
+ wallet: string;
18
+ block: number;
19
+ ctx: ChainReadContext;
20
+ }): Promise<{
21
+ balance: bigint;
22
+ }>;
12
23
  /**
13
- * Many wallets' `balanceOf` at ONE sampled block — the path the background chain verifier rides
14
- * on a cold join. Requires {@link canBatch}; callers fall back to mapping {@link balanceOf}.
24
+ * Many wallets' `balanceOf` at ONE block — the path the background chain verifier rides on a
25
+ * cold join. Requires {@link canBatch}; callers fall back to mapping {@link balanceOf}.
15
26
  *
16
27
  * The wallets are chunked HERE (`READS_PER_MULTICALL` per aggregate3, `batchSize: 0` disables
17
28
  * viem's own 1KB re-chunking) and the chunks are sent with bounded concurrency plus one retry
@@ -20,4 +31,11 @@ export declare function balanceOf(contract: `0x${string}`, walletAddress: string
20
31
  * completed one (viem's own whole-batch retry re-fired the entire burst). A chunk that fails
21
32
  * twice still fails the whole call: the caller gets one rejection, not partial results.
22
33
  */
23
- export declare function balancesOfBatched(contract: `0x${string}`, walletAddresses: string[], ctx: ChainReadContext): Promise<bigint[]>;
34
+ export declare function balancesOfBatched(args: {
35
+ contract: `0x${string}`;
36
+ wallets: string[];
37
+ block: number;
38
+ ctx: ChainReadContext;
39
+ }): Promise<{
40
+ balances: bigint[];
41
+ }>;
@@ -33,22 +33,24 @@ export function scoreOf(balance, min) {
33
33
  * its chain's multicall3 deployment. A client built without a `chain` (or on a chain without
34
34
  * multicall3) takes the per-wallet path instead.
35
35
  */
36
- export function canBatch(ctx) {
37
- return typeof ctx.chain.multicall === "function" && Boolean(ctx.chain.chain?.contracts?.multicall3);
36
+ export function canBatch(args) {
37
+ const { ctx } = args;
38
+ return { batchable: typeof ctx.chain.multicall === "function" && Boolean(ctx.chain.chain?.contracts?.multicall3) };
38
39
  }
39
- /** One wallet's `balanceOf` at the bundle's sampled block. */
40
- export function balanceOf(contract, walletAddress, ctx) {
41
- return ctx.chain.readContract({
42
- address: contract,
40
+ /** One wallet's `balanceOf` at `block` (the caller's choice — a pinned block or the head). */
41
+ export async function balanceOf(args) {
42
+ const balance = await args.ctx.chain.readContract({
43
+ address: args.contract,
43
44
  abi: erc721Abi,
44
45
  functionName: "balanceOf",
45
- args: [getAddress(walletAddress)],
46
- blockNumber: BigInt(ctx.blockNumber)
46
+ args: [getAddress(args.wallet)],
47
+ blockNumber: BigInt(args.block)
47
48
  });
49
+ return { balance };
48
50
  }
49
51
  /**
50
- * Many wallets' `balanceOf` at ONE sampled block — the path the background chain verifier rides
51
- * on a cold join. Requires {@link canBatch}; callers fall back to mapping {@link balanceOf}.
52
+ * Many wallets' `balanceOf` at ONE block — the path the background chain verifier rides on a
53
+ * cold join. Requires {@link canBatch}; callers fall back to mapping {@link balanceOf}.
52
54
  *
53
55
  * The wallets are chunked HERE (`READS_PER_MULTICALL` per aggregate3, `batchSize: 0` disables
54
56
  * viem's own 1KB re-chunking) and the chunks are sent with bounded concurrency plus one retry
@@ -57,7 +59,8 @@ export function balanceOf(contract, walletAddress, ctx) {
57
59
  * completed one (viem's own whole-batch retry re-fired the entire burst). A chunk that fails
58
60
  * twice still fails the whole call: the caller gets one rejection, not partial results.
59
61
  */
60
- export async function balancesOfBatched(contract, walletAddresses, ctx) {
62
+ export async function balancesOfBatched(args) {
63
+ const { contract, wallets: walletAddresses, block, ctx } = args;
61
64
  const chunks = [];
62
65
  for (let at = 0; at < walletAddresses.length; at += READS_PER_MULTICALL) {
63
66
  chunks.push(walletAddresses.slice(at, at + READS_PER_MULTICALL));
@@ -72,7 +75,7 @@ export async function balancesOfBatched(contract, walletAddresses, ctx) {
72
75
  })),
73
76
  allowFailure: false,
74
77
  batchSize: 0,
75
- blockNumber: BigInt(ctx.blockNumber)
78
+ blockNumber: BigInt(block)
76
79
  });
77
80
  let nextChunk = 0;
78
81
  const worker = async () => {
@@ -93,5 +96,5 @@ export async function balancesOfBatched(contract, walletAddresses, ctx) {
93
96
  }
94
97
  };
95
98
  await Promise.all(Array.from({ length: Math.min(MULTICALL_CONCURRENCY, chunks.length) }, worker));
96
- return balances;
99
+ return { balances };
97
100
  }
@@ -1,10 +1,13 @@
1
1
  import type { z } from "zod";
2
2
  import type { ChainClient } from "../chain/types.js";
3
+ import type { RuleCache } from "./cache.js";
3
4
  /**
4
5
  * Rule interface, design + leaves implemented.
5
6
  *
6
7
  * A rule turns a criteria `{ type, ...options }` reference into a non-negative
7
- * score for one wallet, evaluated at the bundle's bucket block. There is a SINGLE kind
8
+ * score for one wallet, at whichever block the rule itself decides to read the pinned block
9
+ * the bundle names ({@link RuleWallet.sampleBlock}) or this verifier's current head
10
+ * ({@link ChainReadContext.head}). There is a SINGLE kind
8
11
  * (mirroring the flat pkc-js challenge registry: `Record<string, rule>`, user
9
12
  * entries shadow builtins). The criteria still has two slots that draw from this one
10
13
  * registry:
@@ -27,17 +30,74 @@ import type { ChainClient } from "../chain/types.js";
27
30
  */
28
31
  export interface RuleResult {
29
32
  score: bigint;
33
+ /**
34
+ * May a `0n` be blamed on the sender? Default `true`.
35
+ *
36
+ * The pipeline has two decisions to make about a `0n` that the rule cannot make for it —
37
+ * whether the gossip forward-gate `reject`s the message (which penalizes the delivering peer
38
+ * through gossipsub's invalid-message score, eventually pruning and graylisting it) or
39
+ * merely `ignore`s it, and whether the background verifier evicts the bundle at once or
40
+ * holds it for a grace window. Both hinge on one thing only the rule knows: is this `0n`
41
+ * attributable?
42
+ *
43
+ * `true` (the default) says every honest verifier necessarily computes this same `0n` — true
44
+ * of a read pinned to a historical block, since the block is named by the bundle and the
45
+ * chain's history does not move. The bundle is dropped, the sender penalized, the verdict
46
+ * cached as terminal.
47
+ *
48
+ * `false` says an honest peer could legitimately disagree, so nobody may be blamed. The
49
+ * bundle is still dropped, but `ignore`-class — no penalty, verdict uncached, and the
50
+ * background verifier re-examines it for a grace window before giving up. This is what a
51
+ * rule scoring the chain head must return: the peer that forwarded the vote verified it
52
+ * against ITS head, and any peer ahead of us can legitimately see an acquisition we have
53
+ * not. Penalizing there punishes honest relayers for being current — and does so exactly
54
+ * when a wallet has just acquired the gate asset, which is when peer heads straddle.
55
+ */
56
+ penalize?: boolean;
30
57
  }
31
- /** Everything a rule needs to read chain state for one evaluation. */
58
+ /** Everything a rule needs to read chain state and remember what it read. */
32
59
  export interface ChainReadContext {
33
60
  /**
34
- * The viem `PublicClient` for the rule's `options.chain`. Use the full viem
35
- * read surface directly (`readContract`, `getBalance`, ...), pinning every call to
36
- * the sampled block with `blockNumber: BigInt(ctx.blockNumber)`.
61
+ * The viem `PublicClient` for the rule's `options.chain`. Use the full viem read surface
62
+ * directly (`readContract`, `getBalance`, ...). ALWAYS pin each call to an explicit
63
+ * `blockNumber: BigInt(...)`: the read coalescer (src/chain/coalescer.ts) only folds reads
64
+ * carrying an explicit block into one multicall3, so a `blockTag` or an omitted block
65
+ * silently drops out of batching and back to one HTTP round trip per read.
37
66
  */
38
67
  chain: ChainClient;
39
- /** The single sampled block for the bundle's bucket. */
40
- blockNumber: number;
68
+ /**
69
+ * This verifier's current head on {@link chain}. A rule scoring historical state never calls
70
+ * it and never pays for it; a rule scoring "now" resolves it ONCE per evaluation and pins
71
+ * its reads to that number, so a batch still lands in a single multicall.
72
+ *
73
+ * Shared and coalesced by the voter, because the gate runs on the verify path — one call per
74
+ * incoming vote — so an unshared head read would be one `eth_blockNumber` per bundle per
75
+ * contest.
76
+ */
77
+ head: () => Promise<{
78
+ block: number;
79
+ }>;
80
+ /**
81
+ * This rule's memo (see rules/cache.ts). A chain-reading rule MUST compute through it: it is
82
+ * what turns "one chain read per unique bundle" into "one read per key per epoch", which is
83
+ * the bound that stops an ineligible wallet from making every peer on the topic pay an RPC
84
+ * round trip per fresh-signed bundle.
85
+ */
86
+ cache: RuleCache;
87
+ }
88
+ /** One wallet to score, and the pinned block the bundle it came from names. */
89
+ export interface RuleWallet {
90
+ /** The voting wallet (the address recovered from the bundle's signature). */
91
+ address: string;
92
+ /**
93
+ * The bundle's bucketized sample block, already floored to the bucket boundary — the
94
+ * historical block every verifier agrees this ballot names. A rule scoring pinned state
95
+ * reads here (and its answer is then identical on every verifier, forever); a rule scoring
96
+ * current state ignores it and uses {@link ChainReadContext.head} instead. It is NOT a
97
+ * claim about when the ballot was signed: it is floored to the bucket, so it can trail the
98
+ * actual signing moment by up to `blocksPerBucket`.
99
+ */
100
+ sampleBlock: number;
41
101
  }
42
102
  /**
43
103
  * The one rule kind. `O` is the validated options type (from its `optionsSchema`).
@@ -49,22 +109,28 @@ export interface Rule<O = unknown> {
49
109
  readonly optionsSchema: z.ZodType<O>;
50
110
  evaluate(args: {
51
111
  options: O;
52
- walletAddress: string;
112
+ wallet: RuleWallet;
53
113
  ctx: ChainReadContext;
54
114
  }): Promise<RuleResult>;
55
115
  /**
56
- * Optional batched form of {@link evaluate}: score many wallets at the SAME sampled block in
57
- * as few RPC round trips as the rule can manage (e.g. one multicall3 `aggregate3` for a whole
58
- * checkpoint's wallets). Returns one result per input wallet, in order. Semantics MUST equal
59
- * mapping `evaluate` over the wallets — this is a transport optimization, never a different
60
- * answer. The background chain verifier prefers it when present and falls back to per-wallet
61
- * `evaluate` calls otherwise (see verify/background.ts).
116
+ * Optional batched form of {@link evaluate}: score many wallets in as few RPC round trips as
117
+ * the rule can manage (e.g. one multicall3 `aggregate3` for a whole checkpoint's wallets).
118
+ * Returns one result per input wallet, in order. Semantics MUST equal mapping `evaluate` over
119
+ * the wallets — this is a transport optimization, never a different answer. The background
120
+ * chain verifier prefers it when present and falls back to per-wallet `evaluate` otherwise.
121
+ *
122
+ * The wallets are NOT guaranteed to share a `sampleBlock`: the pipeline no longer groups them
123
+ * (it cannot, since which block a rule reads at is the rule's own business), so a batch is
124
+ * whatever was pending. A rule scoring the head reads once for the whole batch; a rule
125
+ * scoring pinned state groups by `sampleBlock` itself.
62
126
  */
63
127
  evaluateMany?(args: {
64
128
  options: O;
65
- walletAddresses: string[];
129
+ wallets: RuleWallet[];
66
130
  ctx: ChainReadContext;
67
- }): Promise<RuleResult[]>;
131
+ }): Promise<{
132
+ results: RuleResult[];
133
+ }>;
68
134
  }
69
135
  /**
70
136
  * The registry: a flat `type -> rule` map. Built-ins are provided by this
@@ -3,6 +3,7 @@ import type { VotesBundle } from "../schema/votes.js";
3
3
  import type { RuleRegistry } from "../rules/types.js";
4
4
  import type { ChainClient, BucketMath } from "../chain/types.js";
5
5
  import type { BundleChecks } from "../verify/types.js";
6
+ import { type RuleCache } from "../rules/cache.js";
6
7
  import type { Tally } from "./types.js";
7
8
  /**
8
9
  * Deterministic per-contest aggregation over the CRDT's current bundles. Every aggregated
@@ -34,6 +35,19 @@ export interface TallyDeps {
34
35
  bundle: VotesBundle;
35
36
  checks: BundleChecks;
36
37
  }>;
38
+ /**
39
+ * This verifier's current head, handed to the weight rule as `ctx.head`. Never called by a
40
+ * weight rule that scores pinned historical state — including `constant`, which reads no
41
+ * chain at all — so the "an idle contest does zero chain reads" property is untouched.
42
+ * Defaults to the weight chain's own `getBlockNumber()`.
43
+ */
44
+ readHead?: (args: {
45
+ chain: ChainClient;
46
+ }) => Promise<{
47
+ block: number;
48
+ }>;
49
+ /** The weight rule's memo, handed to it as `ctx.cache` (rules/cache.ts). */
50
+ ruleCache?: RuleCache;
37
51
  /**
38
52
  * Hash of the current bucket boundary block on the criteria's chain, for the rolling tie
39
53
  * seed. Invoked at most once per `compute`, and only when a tie must actually be broken —
@@ -1,6 +1,7 @@
1
1
  import { base58btc } from "multiformats/bases/base58";
2
2
  import { sha256 } from "multiformats/hashes/sha2";
3
3
  import { tickerForRef } from "../chain/ticker.js";
4
+ import { makeMemoryRuleCache } from "../rules/cache.js";
4
5
  import { UnknownRuleError } from "../errors.js";
5
6
  /** Byte-lexicographic compare of two byte arrays (returns <0, 0, >0). */
6
7
  function compareBytes(x, y) {
@@ -15,18 +16,31 @@ function compareBytes(x, y) {
15
16
  }
16
17
  export function makeTally(deps) {
17
18
  const { criteria, registry, chainFor, bucketMath, current, bucketBlockHash } = deps;
19
+ const readHead = deps.readHead ?? (async ({ chain }) => ({ block: Number(await chain.getBlockNumber()) }));
18
20
  // Resolve the weight rule, its options, and its chain once (see verify/bundle.ts).
19
21
  const weight = registry[criteria.weight.type];
20
22
  if (!weight)
21
23
  throw new UnknownRuleError("weight", criteria.weight.type);
22
24
  const weightOptions = weight.optionsSchema.parse(criteria.weight);
23
25
  const weightChain = chainFor(tickerForRef(criteria, criteria.weight, weightOptions));
26
+ // The weight rule picks its own block exactly as the gate rule does (see rules/types.ts):
27
+ // it is handed the bundle's pinned sample block and this verifier's head, and reads whichever
28
+ // it needs. The tally never asks what kind of rule it is holding.
29
+ //
30
+ // `deps.ruleCache` MUST be namespaced by the weight rule's OWN chain, not the gating chain —
31
+ // `weightChain` here resolves through `criteria.weight`'s ticker, which may name a different
32
+ // entry of `requires.chains` (the voter derives it that way; see client/voter.ts).
33
+ const weightCtx = {
34
+ chain: weightChain,
35
+ head: () => readHead({ chain: weightChain }),
36
+ cache: deps.ruleCache ?? makeMemoryRuleCache()
37
+ };
24
38
  const weightFor = async (wallet, blockNumber) => {
25
39
  const sampleBlock = bucketMath.sampleBlockForBucket(bucketMath.bucketForBlock(blockNumber));
26
40
  const { score } = await weight.evaluate({
27
41
  options: weightOptions,
28
- walletAddress: wallet,
29
- ctx: { chain: weightChain, blockNumber: sampleBlock }
42
+ wallet: { address: wallet, sampleBlock },
43
+ ctx: weightCtx
30
44
  });
31
45
  return score;
32
46
  };