@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.
@@ -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
+ }
@@ -13,6 +13,8 @@ export const constant = {
13
13
  type: "constant",
14
14
  optionsSchema: ConstantOptionsSchema,
15
15
  async evaluate({ options }) {
16
- return { score: BigInt(options.value) };
16
+ // `value` is schema-constrained positive, so this rule has no failing branch at all —
17
+ // in the rule slot it is the no-op gate that admits everyone.
18
+ return { success: true, score: BigInt(options.value) };
17
19
  }
18
20
  };
@@ -1,4 +1,4 @@
1
- import { erc20Abi, getAddress, parseUnits } from "viem";
1
+ import { erc20Abi, formatUnits, getAddress, parseUnits } from "viem";
2
2
  import { z } from "zod";
3
3
  import { ChainTickerSchema } from "../schema/common.js";
4
4
  /**
@@ -37,15 +37,25 @@ 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
- return { score: raw >= minUnits ? raw : 0n };
53
+ if (raw >= minUnits)
54
+ return { success: true, score: raw };
55
+ return {
56
+ success: false,
57
+ error: `this wallet holds ${formatUnits(raw, options.decimals)} of the gate token ` +
58
+ `(${getAddress(options.contract)}), but ${options.min} is required`
59
+ };
50
60
  }
51
61
  };
@@ -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
@@ -1,23 +1,31 @@
1
1
  import { BaseError, ContractFunctionRevertedError, ContractFunctionZeroDataError, getAddress } from "viem";
2
2
  import { z } from "zod";
3
3
  import { ChainTickerSchema } from "../schema/common.js";
4
- import { balanceOf, balancesOfBatched, canBatch, scoreOf } from "./nft-balance.js";
4
+ import { balanceOf, balancesOfBatched, canBatch, scoreOf, shortfallError } from "./nft-balance.js";
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,195 @@ 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
+ /**
93
+ * The voter-facing wording for every way this rule reaches `0n` ({@link RuleResult.error}).
94
+ *
95
+ * Deliberately generic and self-contained: the rule knows a contract address and a threshold, not
96
+ * that the deployment calls this token a "5chan Pass". A client renders these verbatim, which is
97
+ * the point — it then needs to know nothing about which block the rule read or what `min` is.
98
+ */
99
+ const undeclaredError = (contract) => `the gate contract ${contract} does not declare ERC-5192, so it gates nothing and no wallet can qualify ` +
100
+ `— this contest's criteria name a contract that is not soulbound`;
101
+ /** One `supportsInterface(0xb45a3c0e)` at `block`. Revert/zero-data ⇒ "does not declare". */
102
+ async function declaresErc5192(args) {
72
103
  try {
73
- return await ctx.chain.readContract({
74
- address: contract,
104
+ const declares = await args.ctx.chain.readContract({
105
+ address: args.contract,
75
106
  abi: erc165Abi,
76
107
  functionName: "supportsInterface",
77
108
  args: [ERC5192_INTERFACE_ID],
78
- blockNumber: BigInt(ctx.blockNumber)
109
+ blockNumber: BigInt(args.block)
79
110
  });
111
+ return { declares };
80
112
  }
81
113
  catch (err) {
82
114
  if (isContractRefusal(err))
83
- return false;
115
+ return { declares: false };
84
116
  throw err;
85
117
  }
86
118
  }
119
+ /**
120
+ * Score every wallet at ONE block, memoized under one epoch — the whole rule body, run once per
121
+ * leg (see `evaluateMany`).
122
+ *
123
+ * Two memos, both through the rule's cache: the ERC-5192 declaration, keyed per CONTRACT (it is
124
+ * not a per-wallet fact, so a whole checkpoint's wallets share one probe — a key shape the old
125
+ * per-wallet gate cache could not express at all), and each wallet's raw balance. Balances are
126
+ * cached rather than scores so a change to `min` cannot be served a stale verdict; the batched
127
+ * read behind the misses is one multicall3 `aggregate3` per 200 wallets.
128
+ */
129
+ async function scoreAt(args) {
130
+ const { contract, min, wallets, block, epoch, prefix, ctx } = args;
131
+ if (wallets.length === 0)
132
+ return { scores: [], errors: [] };
133
+ const [declared] = (await ctx.cache.memoMany({
134
+ keys: [`${prefix}lock/${contract.toLowerCase()}`],
135
+ epoch,
136
+ read: async () => {
137
+ const { declares } = await declaresErc5192({ contract, block, ctx });
138
+ return { values: [declares ? "1" : "0"] };
139
+ }
140
+ })).values;
141
+ // A contract that does not claim its tokens are locked gates nothing: admit nobody rather
142
+ // than gate on something transferable (see the rule doc above).
143
+ if (declared !== "1")
144
+ return { scores: wallets.map(() => 0n), errors: wallets.map(() => undeclaredError(contract)) };
145
+ const { values } = await ctx.cache.memoMany({
146
+ keys: wallets.map((wallet) => `${prefix}bal/${wallet.toLowerCase()}`),
147
+ epoch,
148
+ read: async ({ keys }) => {
149
+ const missing = keys.map((key) => key.slice(key.lastIndexOf("/") + 1));
150
+ const { balances } = canBatch({ ctx }).batchable
151
+ ? await balancesOfBatched({ contract, wallets: missing, block, ctx })
152
+ : {
153
+ balances: await Promise.all(missing.map(async (wallet) => (await balanceOf({ contract, wallet, block, ctx })).balance))
154
+ };
155
+ return { values: balances.map((balance) => balance.toString()) };
156
+ }
157
+ });
158
+ const scores = values.map((balance) => scoreOf(BigInt(balance), min));
159
+ return {
160
+ scores,
161
+ errors: scores.map((score, i) => (score > 0n ? undefined : shortfallError(BigInt(values[i]), min, contract)))
162
+ };
163
+ }
164
+ /** `{ success: true, score }` when the leg admitted, else the failing branch with its reason. */
165
+ function resultOf(score, error) {
166
+ // `penalize: false` on every failure: neither leg makes one attributable. The peer that
167
+ // forwarded a vote verified it against ITS head, and any peer ahead of us may legitimately
168
+ // see an acquisition we have not — and with burning possible, holding at neither block does
169
+ // not even prove the wallet never held.
170
+ return score > 0n ? { success: true, score } : { success: false, error: error ?? UNKNOWN_ERROR, penalize: false };
171
+ }
172
+ /** Unreachable: `scoreAt` pairs every non-positive score with a reason. Kept total, not thrown. */
173
+ const UNKNOWN_ERROR = "this wallet does not qualify for this contest's gate";
87
174
  export const erc5192MinBalance = {
88
175
  type: "erc5192-min-balance",
89
176
  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 };
177
+ // One wallet is the batch of one: the two legs, the two epochs and the memos are identical,
178
+ // so there is one body. Called through `erc5192MinBalance.evaluateMany` rather than `this`,
179
+ // which a destructured or re-exported rule object would not carry (issue #29).
180
+ async evaluate({ options, wallet, ctx }) {
181
+ const { results } = await erc5192MinBalance.evaluateMany({ options, wallets: [wallet], ctx });
182
+ return results[0];
96
183
  },
97
- async evaluateMany({ options, walletAddresses, ctx }) {
184
+ /**
185
+ * Score at the HEAD first, falling back to each wallet's own pinned block.
186
+ *
187
+ * **Head first** is what lets a wallet vote in the block it acquires the Pass. Scoring only
188
+ * at the bundle's bucket boundary — the block a ballot names, floored — meant waiting up to
189
+ * a full bucket (an hour on 5chan's live manifest) before a fresh holding was visible, which
190
+ * was never a chain requirement: a read pinned at block N already sees a mint that happened
191
+ * in N. It is sound because a soulbound holding cannot move, so a peer whose head lags can
192
+ * only be LATE to admit a vote, never in lasting disagreement about it.
193
+ *
194
+ * **The pinned fallback** covers what that reasoning does not: ERC-5192 requires transfers to
195
+ * revert while locked, but says nothing about BURNING (a burn does not go through
196
+ * `transferFrom`), so a compliant Pass may be burnable and this rule cannot check otherwise.
197
+ * Without the fallback, a burn would make a peer that verified earlier keep a vote its
198
+ * checkpoint still serves while a cold joiner rejects it — divergence for up to the expiry
199
+ * window — and would hand whoever can burn a retroactive veto over votes already cast.
200
+ * Reading the wallet's own `sampleBlock` when the head says `0n` restores agreement for any
201
+ * vote that was legitimately held when it was cast. It admits nothing the pinned-only v1 gate
202
+ * did not, so it opens no new amplification; a burn-and-remint to a fresh wallet is a
203
+ * transfer by another name and remains a property of the deployment, not something any read
204
+ * can close. Its cost is that a verifier still needs archive depth for that leg.
205
+ *
206
+ * **Two epochs**, which is why this caching lives in the rule: the head leg expires with the
207
+ * head (a stale `0n` must not outlive {@link HEAD_EPOCH_BLOCKS}), while the pinned leg is a
208
+ * historical read that is true forever and is keyed by the block itself.
209
+ *
210
+ * **`penalize: false`** on every failure: neither leg makes one attributable. The peer that
211
+ * forwarded a vote verified it against ITS head, and any peer ahead of us may legitimately
212
+ * see an acquisition we have not — and with burning possible, not holding at either block
213
+ * does not even prove the wallet never held.
214
+ *
215
+ * **Three distinct failures**, each with its own {@link RuleResult.error}: the contract does
216
+ * not declare ERC-5192 (so it gates nothing and no wallet can ever qualify), the wallet holds
217
+ * none, or it holds some but fewer than `min`. The fallback leg has the last word on a
218
+ * wallet's score, so it owns that wallet's reason too.
219
+ */
220
+ async evaluateMany({ options, wallets, ctx }) {
98
221
  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) }));
222
+ const { block: head } = await ctx.head();
223
+ const epoch = Math.floor(head / HEAD_EPOCH_BLOCKS) * HEAD_EPOCH_BLOCKS;
224
+ // Everything behind the current window is unreachable — nothing will look it up again.
225
+ // Scoped to the head keys, so the permanently-valid pinned entries are left alone.
226
+ ctx.cache.purgeBelow({ epoch, keyPrefix: HEAD_PREFIX });
227
+ const { scores, errors } = await scoreAt({
228
+ contract,
229
+ min: options.min,
230
+ wallets: wallets.map((wallet) => wallet.address),
231
+ block: head,
232
+ epoch,
233
+ prefix: HEAD_PREFIX,
234
+ ctx
235
+ });
236
+ // Only wallets the head leg refused reach the fallback, so a holder costs one read path.
237
+ // Grouped by sample block: bundles from different buckets name different pinned blocks.
238
+ const byBlock = new Map();
239
+ scores.forEach((score, i) => {
240
+ if (score > 0n)
241
+ return;
242
+ const block = wallets[i].sampleBlock;
243
+ byBlock.set(block, [...(byBlock.get(block) ?? []), i]);
244
+ });
245
+ await Promise.all([...byBlock].map(async ([block, indexes]) => {
246
+ const fallback = await scoreAt({
247
+ contract,
248
+ min: options.min,
249
+ wallets: indexes.map((i) => wallets[i].address),
250
+ block,
251
+ epoch: block,
252
+ prefix: PINNED_PREFIX,
253
+ ctx
254
+ });
255
+ indexes.forEach((at, i) => {
256
+ scores[at] = fallback.scores[i];
257
+ // The fallback leg had the last word on the score, so it owns the reason too:
258
+ // a wallet that holds none at the head but held some at its ballot's block is
259
+ // admitted, and one that holds none at either gets the pinned leg's wording.
260
+ errors[at] = fallback.errors[i];
261
+ });
262
+ }));
263
+ return { results: scores.map((score, i) => resultOf(score, errors[i])) };
110
264
  }
111
265
  };
@@ -1,7 +1,7 @@
1
1
  import { getAddress } from "viem";
2
2
  import { z } from "zod";
3
3
  import { ChainTickerSchema } from "../schema/common.js";
4
- import { balanceOf, balancesOfBatched, canBatch, scoreOf } from "./nft-balance.js";
4
+ import { balanceOf, balancesOfBatched, canBatch, scoreOf, shortfallError } from "./nft-balance.js";
5
5
  /**
6
6
  * Hold at least `min` of a **plain** ERC-721.
7
7
  *
@@ -28,21 +28,55 @@ export const Erc721MinBalanceOptionsSchema = z.object({
28
28
  contract: z.string(),
29
29
  min: z.number().int().positive().default(1)
30
30
  });
31
+ /**
32
+ * One pinned-block balance as a {@link RuleResult}. `penalize` is left at its default `true`:
33
+ * this rule reads the block the bundle itself names, so every honest verifier computes the same
34
+ * answer forever and a failure IS attributable to whoever sent it.
35
+ */
36
+ function pinnedResult(balance, min, contract) {
37
+ const score = scoreOf(balance, min);
38
+ return score > 0n ? { success: true, score } : { success: false, error: shortfallError(balance, min, contract) };
39
+ }
31
40
  export const erc721MinBalance = {
32
41
  type: "erc721-min-balance",
33
42
  optionsSchema: Erc721MinBalanceOptionsSchema,
34
- async evaluate({ options, walletAddress, ctx }) {
35
- const balance = await balanceOf(getAddress(options.contract), walletAddress, ctx);
36
- return { score: scoreOf(balance, options.min) };
43
+ // Scores at the bundle's OWN pinned block, and deliberately not at the head: a transferable
44
+ // balance can go DOWN, so a vote admitted today would silently become invalid the moment the
45
+ // token moved, and whether it still counted would depend on when each peer last looked. A
46
+ // pinned read is identical on every verifier forever, which is what leaves `penalize` at its
47
+ // default — a `0n` here IS attributable to the sender. That difference is a second,
48
+ // independent reason this rule stays out of `builtinRegistry`, on top of the Sybil
49
+ // amplification described in registry.ts.
50
+ async evaluate({ options, wallet, ctx }) {
51
+ const { balance } = await balanceOf({
52
+ contract: getAddress(options.contract),
53
+ wallet: wallet.address,
54
+ block: wallet.sampleBlock,
55
+ ctx
56
+ });
57
+ return pinnedResult(balance, options.min, getAddress(options.contract));
37
58
  },
38
- async evaluateMany({ options, walletAddresses, ctx }) {
59
+ async evaluateMany({ options, wallets, ctx }) {
39
60
  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) }));
61
+ // Grouped by sample block, because a batch is no longer guaranteed to share one: the
62
+ // pipeline hands over whatever is pending and each rule groups the way it reads. Within
63
+ // a group it is multicall3 `aggregate3` batching (chunking policy in nft-balance.ts)
64
+ // the path the background chain verifier rides on a cold join; a client that cannot
65
+ // batch takes the per-wallet fallback.
66
+ const results = new Array(wallets.length);
67
+ const byBlock = new Map();
68
+ wallets.forEach((wallet, i) => byBlock.set(wallet.sampleBlock, [...(byBlock.get(wallet.sampleBlock) ?? []), i]));
69
+ await Promise.all([...byBlock].map(async ([block, indexes]) => {
70
+ const group = indexes.map((i) => wallets[i].address);
71
+ const { balances } = canBatch({ ctx }).batchable
72
+ ? await balancesOfBatched({ contract, wallets: group, block, ctx })
73
+ : {
74
+ balances: await Promise.all(group.map(async (wallet) => (await balanceOf({ contract, wallet, block, ctx })).balance))
75
+ };
76
+ indexes.forEach((at, i) => {
77
+ results[at] = pinnedResult(balances[i], options.min, contract);
78
+ });
79
+ }));
80
+ return { results: results.map((result) => result) };
47
81
  }
48
82
  };
@@ -1,17 +1,37 @@
1
1
  import type { ChainReadContext } from "./types.js";
2
2
  /** Score from one balance: the holding when it meets `min`, else `0n` (does not qualify). */
3
3
  export declare function scoreOf(balance: bigint, min: number): bigint;
4
+ /**
5
+ * The voter-facing wording for a token-count shortfall ({@link RuleResult.error}), shared by
6
+ * every balance-scored rule so they explain themselves identically.
7
+ *
8
+ * Deliberately generic: a rule knows a contract address and a threshold, not that the deployment
9
+ * calls this token a "5chan Pass". A client renders it verbatim, which is the point — it then
10
+ * needs to know nothing about which block the rule read or what `min` is.
11
+ */
12
+ export declare function shortfallError(balance: bigint, min: number, contract: string): string;
4
13
  /**
5
14
  * True when the client can run multicall3 `aggregate3` batches — it needs both the action and
6
15
  * its chain's multicall3 deployment. A client built without a `chain` (or on a chain without
7
16
  * multicall3) takes the per-wallet path instead.
8
17
  */
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>;
18
+ export declare function canBatch(args: {
19
+ ctx: ChainReadContext;
20
+ }): {
21
+ batchable: boolean;
22
+ };
23
+ /** One wallet's `balanceOf` at `block` (the caller's choice — a pinned block or the head). */
24
+ export declare function balanceOf(args: {
25
+ contract: `0x${string}`;
26
+ wallet: string;
27
+ block: number;
28
+ ctx: ChainReadContext;
29
+ }): Promise<{
30
+ balance: bigint;
31
+ }>;
12
32
  /**
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}.
33
+ * Many wallets' `balanceOf` at ONE block — the path the background chain verifier rides on a
34
+ * cold join. Requires {@link canBatch}; callers fall back to mapping {@link balanceOf}.
15
35
  *
16
36
  * The wallets are chunked HERE (`READS_PER_MULTICALL` per aggregate3, `batchSize: 0` disables
17
37
  * viem's own 1KB re-chunking) and the chunks are sent with bounded concurrency plus one retry
@@ -20,4 +40,11 @@ export declare function balanceOf(contract: `0x${string}`, walletAddress: string
20
40
  * completed one (viem's own whole-batch retry re-fired the entire burst). A chunk that fails
21
41
  * twice still fails the whole call: the caller gets one rejection, not partial results.
22
42
  */
23
- export declare function balancesOfBatched(contract: `0x${string}`, walletAddresses: string[], ctx: ChainReadContext): Promise<bigint[]>;
43
+ export declare function balancesOfBatched(args: {
44
+ contract: `0x${string}`;
45
+ wallets: string[];
46
+ block: number;
47
+ ctx: ChainReadContext;
48
+ }): Promise<{
49
+ balances: bigint[];
50
+ }>;