@bitsocial/pubsub-voting 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -87,6 +87,31 @@ await contest.update(); // join the topic, col
87
87
  // await contest.stop(); // leave the topic
88
88
  ```
89
89
 
90
+ ### Will this wallet's vote count?
91
+
92
+ Ask the contest before signing. `checkEligibility` runs the contest's **real gate rule** through
93
+ the same chain client, head reader and memo the forward gate uses, so it reads at whatever block
94
+ that rule reads at and applies whatever threshold it applies. When it refuses, the `error` is the
95
+ rule's own wording — render it verbatim:
96
+
97
+ ```ts
98
+ const check = await contest.checkEligibility({ address: wallet });
99
+ if (check.eligible) {
100
+ show(`eligible — holds ${check.score}`);
101
+ } else {
102
+ show(check.error); // e.g. "this wallet holds none of the gate token (0x13d4…91b9)"
103
+ }
104
+ ```
105
+
106
+ Do **not** reimplement this by reading balances yourself: which block counts is the rule's
107
+ business and changes when the rule changes. A client that hard-codes "peers verify at the bucket
108
+ boundary" keeps telling voters to wait for a window that a head-reading gate no longer imposes.
109
+
110
+ It is a courtesy check, not a promise — eligibility can change between the check and the publish,
111
+ and each peer verifies against its own chain view. `publish()` deliberately does not call it: the
112
+ gate is the network's decision, and a rejection still surfaces after the fact as
113
+ `VoteEvictedError`, carrying the same kind of reason.
114
+
90
115
  Each ranking row carries one flag **per deferred verification operation** (mirroring pkc-js's
91
116
  `nameResolved`), and every background settlement re-fires `update` — so a leaderboard can render
92
117
  provisional rows immediately and refine them in place:
@@ -196,7 +221,7 @@ Full, type-checked call patterns for a pkc-js host, a plebbit/seedit host, and a
196
221
 
197
222
  ### Custom rules
198
223
 
199
- The gate and weight are a single flat registry of rules, one `type` per file, mirroring the pkc-js challenge registry. Each rule owns its option schema, 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).)
224
+ The gate and weight are a single flat registry of rules, one `type` per file, mirroring the pkc-js challenge registry. Each rule owns its option schema, its own reads (`readContract`, `getBalance`, ... through `ctx.chain`, the viem `PublicClient` for its `options.chain`), which block it reads at, and what it memoizes — see [What a rule owns](#what-a-rule-owns-its-block-and-its-cache) below. There is **one kind**: `evaluate → RuleResult`, either `{ success: true, score }` with a positive score or `{ success: false, error }` where `error` is the voter-facing reason the rule refused. The criteria has two *slots* drawing from the one registry — the **rule** slot treats the score as a gate (`> 0n` admits), the **weight** slot as the vote's magnitude. A wallet's vote counts as `rule.success ? weight.score : 0n`. A rule that needs a threshold fails below it (so `erc5192-min-balance`'s optional `min` gates), which lets the same rule serve either slot. A chain-reading rule may also implement the optional `evaluateMany({ options, wallets, ctx })` batch hook (`wallets` in place of `wallet`, returning `{ results }` — one per input wallet, in order) — its semantics MUST equal mapping `evaluate` — which the background verifier uses to batch a cold join's gate reads. The batch is simply everything pending, so its wallets need not share a `sampleBlock`: a rule reading the head scores them all at once, a rule reading pinned blocks groups them itself. (`erc5192-min-balance` implements it over multicall3, hoisting its one lock assertion out of the per-wallet reads; see [DESIGN.md, Background chain verification](./DESIGN.md#background-chain-verification).)
200
225
 
201
226
  Built-ins: `erc5192-min-balance` (v1) and `constant` (v1).
202
227
 
@@ -217,7 +242,9 @@ const seeditModAllowlist: Rule<{ type: "seedit-mod-allowlist"; allow: string[] }
217
242
  type: "seedit-mod-allowlist",
218
243
  optionsSchema: z.object({ type: z.literal("seedit-mod-allowlist"), allow: z.array(z.string()) }),
219
244
  async evaluate({ options, wallet }) {
220
- return { score: options.allow.includes(wallet.address) ? 1n : 0n }; // gate: 1n admits, 0n rejects
245
+ return options.allow.includes(wallet.address)
246
+ ? { success: true, score: 1n }
247
+ : { success: false, error: "this wallet is not on the moderator allowlist" }; // shown to the voter
221
248
  }
222
249
  };
223
250
 
@@ -255,15 +282,19 @@ const { values } = await ctx.cache.memoMany({
255
282
  });
256
283
  ```
257
284
 
258
- `RuleResult` carries one field beyond the score:
285
+ `RuleResult` is a discriminated union, shaped like pkc-js's `ChallengeResult`:
259
286
 
260
287
  ```ts
261
- interface RuleResult { score: bigint; penalize?: boolean } // default true
288
+ type RuleResult =
289
+ | { success: true; score: bigint } // score MUST be > 0n
290
+ | { success: false; error: string; penalize?: boolean }; // penalize default true
262
291
  ```
263
292
 
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.
293
+ **`error` is required on the failing branch**, because only the rule knows why a wallet fell short it holds none, it holds too few, the contract gates nothing. That sentence is what the library shows the voter: it becomes the verdict reason, so it reaches the publisher on `VoteEvictedError.verdict.reason`, and it is what `contest.checkEligibility()` returns. Write it about the wallet ("this wallet holds none of the gate token"), not about the library, and leave block numbers out unless a voter can act on them. Making it optional would mean every UI re-deriving the rule's thresholds and block choice to say anything useful which is exactly the coupling `ctx` and `RuleCache` exist to remove.
294
+
295
+ **`penalize`** answers the one thing the library cannot: may the failure be blamed on the sender? `true` (the default) says every honest verifier computes this same failure — true of a read pinned to the block the bundle names — so the forward gate `reject`s the message (penalizing the delivering peer in gossipsub's scoring) and the verdict is cached as terminal. `false` says an honest peer could legitimately disagree, so the bundle is dropped `ignore`-class instead: no penalty, verdict uncached, and the background verifier re-examines it for a grace window before giving up.
265
296
 
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).
297
+ `erc5192-min-balance` reads the head first — so a freshly-acquired Pass counts immediately — falls back to `wallet.sampleBlock` when the head refuses (ERC-5192 does not forbid burning, and without the fallback a burn would erase votes retroactively for peers that had not verified them yet), memoizes each leg under its own epoch, and returns `penalize: false`, because at validation time it cannot attribute a failure to anyone: the peer that forwarded the vote verified it against *its* head. Every other rule in the tree reads `wallet.sampleBlock` and leaves `penalize` at its default — a transferable or fungible balance can decrease, so reading it at the head would silently invalidate votes already counted. See [DESIGN.md, What a rule owns](./DESIGN.md#what-a-rule-owns-and-what-the-pipeline-owns).
267
298
 
268
299
  ### Weighted voting (deferred)
269
300
 
@@ -65,6 +65,19 @@ export interface PublishOutcome {
65
65
  readonly recipientCount: number;
66
66
  }
67
67
  /** One contest's reactive read view: subscribe, keep the tally in sync, read it. */
68
+ /**
69
+ * What {@link Contest.checkEligibility} found. Shaped like {@link RuleResult} on purpose — it is
70
+ * that result, surfaced — so the failing branch always carries a reason a client can display.
71
+ */
72
+ export type EligibilityResult = {
73
+ eligible: true;
74
+ /** The wallet's gate score, `> 0n`. For a balance gate this is the holding itself. */
75
+ score: bigint;
76
+ } | {
77
+ eligible: false;
78
+ /** The rule's own explanation, written for the voter. Render it verbatim. */
79
+ error: string;
80
+ };
68
81
  export interface Contest {
69
82
  /** The criteria document this contest runs (already validated). */
70
83
  readonly criteria: Criteria;
@@ -85,6 +98,30 @@ export interface Contest {
85
98
  stop(): Promise<void>;
86
99
  /** Compute the current contest ranking fresh, bypassing the cache. */
87
100
  getTally(): Promise<ContestTally>;
101
+ /**
102
+ * Would this contest's gate admit `address` right now? Ask before signing, to tell a voter
103
+ * whether their ballot will count — and, when it will not, exactly why.
104
+ *
105
+ * This runs the contest's REAL gate rule through the same context the forward-gate and the
106
+ * background verifier use: the same chain client, the same coalesced head reader, the same
107
+ * memo. So it reads at whatever block the rule reads at, applies whatever threshold the rule
108
+ * applies, and returns the rule's own {@link RuleResult.error} wording verbatim. A client
109
+ * renders `error` and needs to know nothing about blocks, buckets or thresholds — which is
110
+ * the point: re-deriving any of that outside the rule is how a UI ends up confidently
111
+ * telling voters to wait for a window that no longer gates anything.
112
+ *
113
+ * It is a courtesy check, not a promise. Eligibility is a fact about the chain and can change
114
+ * between this call and the publish, and each peer verifies against its own view — so a
115
+ * `true` here can still be followed by a `VoteEvictedError` (which carries the same kind of
116
+ * reason). `publish()` deliberately does NOT call this: the gate is the network's decision,
117
+ * and refusing locally would only hide a vote the rest of the topic would have accepted.
118
+ *
119
+ * Costs one gate evaluation, usually served from the shared memo — the same read the verifier
120
+ * would do anyway, not an extra one.
121
+ */
122
+ checkEligibility(args: {
123
+ address: string;
124
+ }): Promise<EligibilityResult>;
88
125
  /**
89
126
  * Fired when incoming votes change the state; `tally` carries the freshly recomputed
90
127
  * ranking. Background check settlements fire it too: a cold join emits a first tally with
@@ -15,6 +15,7 @@ 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
17
  import { makePersistentRuleCache } from "../rules/cache.js";
18
+ import { gateFailure, scoreOrZero } from "../rules/result.js";
18
19
  import { makeBundleVerifier } from "../verify/bundle.js";
19
20
  import { makeVerdictCache } from "../verify/cache.js";
20
21
  import { makeNameResolutionCache } from "../verify/name-resolution-cache.js";
@@ -871,6 +872,21 @@ class ContestEngine {
871
872
  return true;
872
873
  return sampleBucket <= (await this.#nowBucket());
873
874
  }
875
+ /**
876
+ * Run the gate rule for one wallet, against the ballot block a vote published NOW would carry
877
+ * — the engine half of {@link Contest.checkEligibility}.
878
+ *
879
+ * All the interesting decisions belong to the rule: this resolves the current bucket's sample
880
+ * block, hands it over, and translates the rule's own answer. It never looks at what kind of
881
+ * rule it is holding, so it stays correct for a head-scoring gate, a pinned one, or anything
882
+ * a host registers later.
883
+ */
884
+ async checkEligibility({ address }) {
885
+ const sampleBlock = this.#bucketMath.sampleBlockForBucket(await this.#nowBucket());
886
+ const result = await this.#verifier.checkGate({ address, sampleBlock });
887
+ const failed = gateFailure(result);
888
+ return failed ? { eligible: false, error: failed.error } : { eligible: true, score: scoreOrZero(result) };
889
+ }
874
890
  /** Hash of the current bucket boundary block on the gating (`rule`) chain (rolling tie seed). */
875
891
  async #bucketBlockHash() {
876
892
  const head = await this.#ruleChain.getBlockNumber();
@@ -1696,6 +1712,9 @@ class ContestView {
1696
1712
  getTally() {
1697
1713
  return this.#engine.computeTally();
1698
1714
  }
1715
+ checkEligibility(args) {
1716
+ return this.#engine.checkEligibility(args);
1717
+ }
1699
1718
  /**
1700
1719
  * Internal hook (not part of the {@link Contest} interface): this contest's current checkpoint
1701
1720
  * root record, encoded on demand. The fetch responder and heartbeat use the engine directly;
@@ -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
  /**
@@ -50,6 +50,12 @@ export const erc20Balance = {
50
50
  blockNumber: BigInt(wallet.sampleBlock)
51
51
  });
52
52
  const minUnits = parseUnits(options.min.toString(), options.decimals);
53
- 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
+ };
54
60
  }
55
61
  };
@@ -1,7 +1,7 @@
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
  *
@@ -89,6 +89,15 @@ const HEAD_EPOCH_BLOCKS = 30;
89
89
  /** Cache-key prefixes: head-scored entries expire with the head, pinned ones never do. */
90
90
  const HEAD_PREFIX = "head/";
91
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`;
92
101
  /** One `supportsInterface(0xb45a3c0e)` at `block`. Revert/zero-data ⇒ "does not declare". */
93
102
  async function declaresErc5192(args) {
94
103
  try {
@@ -120,7 +129,7 @@ async function declaresErc5192(args) {
120
129
  async function scoreAt(args) {
121
130
  const { contract, min, wallets, block, epoch, prefix, ctx } = args;
122
131
  if (wallets.length === 0)
123
- return { scores: [] };
132
+ return { scores: [], errors: [] };
124
133
  const [declared] = (await ctx.cache.memoMany({
125
134
  keys: [`${prefix}lock/${contract.toLowerCase()}`],
126
135
  epoch,
@@ -132,7 +141,7 @@ async function scoreAt(args) {
132
141
  // A contract that does not claim its tokens are locked gates nothing: admit nobody rather
133
142
  // than gate on something transferable (see the rule doc above).
134
143
  if (declared !== "1")
135
- return { scores: wallets.map(() => 0n) };
144
+ return { scores: wallets.map(() => 0n), errors: wallets.map(() => undeclaredError(contract)) };
136
145
  const { values } = await ctx.cache.memoMany({
137
146
  keys: wallets.map((wallet) => `${prefix}bal/${wallet.toLowerCase()}`),
138
147
  epoch,
@@ -146,8 +155,22 @@ async function scoreAt(args) {
146
155
  return { values: balances.map((balance) => balance.toString()) };
147
156
  }
148
157
  });
149
- return { scores: values.map((balance) => scoreOf(BigInt(balance), min)) };
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
+ };
150
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";
151
174
  export const erc5192MinBalance = {
152
175
  type: "erc5192-min-balance",
153
176
  optionsSchema: Erc5192MinBalanceOptionsSchema,
@@ -184,10 +207,15 @@ export const erc5192MinBalance = {
184
207
  * head (a stale `0n` must not outlive {@link HEAD_EPOCH_BLOCKS}), while the pinned leg is a
185
208
  * historical read that is true forever and is keyed by the block itself.
186
209
  *
187
- * **`penalize: false`** on every result: neither leg makes a `0n` attributable. The peer that
210
+ * **`penalize: false`** on every failure: neither leg makes one attributable. The peer that
188
211
  * forwarded a vote verified it against ITS head, and any peer ahead of us may legitimately
189
212
  * see an acquisition we have not — and with burning possible, not holding at either block
190
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.
191
219
  */
192
220
  async evaluateMany({ options, wallets, ctx }) {
193
221
  const contract = getAddress(options.contract);
@@ -196,7 +224,7 @@ export const erc5192MinBalance = {
196
224
  // Everything behind the current window is unreachable — nothing will look it up again.
197
225
  // Scoped to the head keys, so the permanently-valid pinned entries are left alone.
198
226
  ctx.cache.purgeBelow({ epoch, keyPrefix: HEAD_PREFIX });
199
- const { scores } = await scoreAt({
227
+ const { scores, errors } = await scoreAt({
200
228
  contract,
201
229
  min: options.min,
202
230
  wallets: wallets.map((wallet) => wallet.address),
@@ -226,8 +254,12 @@ export const erc5192MinBalance = {
226
254
  });
227
255
  indexes.forEach((at, i) => {
228
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];
229
261
  });
230
262
  }));
231
- return { results: scores.map((score) => ({ score, penalize: false })) };
263
+ return { results: scores.map((score, i) => resultOf(score, errors[i])) };
232
264
  }
233
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,6 +28,15 @@ 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,
@@ -45,7 +54,7 @@ export const erc721MinBalance = {
45
54
  block: wallet.sampleBlock,
46
55
  ctx
47
56
  });
48
- return { score: scoreOf(balance, options.min) };
57
+ return pinnedResult(balance, options.min, getAddress(options.contract));
49
58
  },
50
59
  async evaluateMany({ options, wallets, ctx }) {
51
60
  const contract = getAddress(options.contract);
@@ -54,7 +63,7 @@ export const erc721MinBalance = {
54
63
  // a group it is multicall3 `aggregate3` batching (chunking policy in nft-balance.ts) —
55
64
  // the path the background chain verifier rides on a cold join; a client that cannot
56
65
  // batch takes the per-wallet fallback.
57
- const scores = new Array(wallets.length);
66
+ const results = new Array(wallets.length);
58
67
  const byBlock = new Map();
59
68
  wallets.forEach((wallet, i) => byBlock.set(wallet.sampleBlock, [...(byBlock.get(wallet.sampleBlock) ?? []), i]));
60
69
  await Promise.all([...byBlock].map(async ([block, indexes]) => {
@@ -65,9 +74,9 @@ export const erc721MinBalance = {
65
74
  balances: await Promise.all(group.map(async (wallet) => (await balanceOf({ contract, wallet, block, ctx })).balance))
66
75
  };
67
76
  indexes.forEach((at, i) => {
68
- scores[at] = scoreOf(balances[i], options.min);
77
+ results[at] = pinnedResult(balances[i], options.min, contract);
69
78
  });
70
79
  }));
71
- return { results: scores.map((score) => ({ score })) };
80
+ return { results: results.map((result) => result) };
72
81
  }
73
82
  };
@@ -1,6 +1,15 @@
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
@@ -28,6 +28,19 @@ const CHUNK_RETRY_DELAY_MS = CHAIN_CHUNK_RETRY_DELAY_MS;
28
28
  export function scoreOf(balance, min) {
29
29
  return balance >= BigInt(min) ? balance : 0n;
30
30
  }
31
+ /**
32
+ * The voter-facing wording for a token-count shortfall ({@link RuleResult.error}), shared by
33
+ * every balance-scored rule so they explain themselves identically.
34
+ *
35
+ * Deliberately generic: a rule knows a contract address and a threshold, not that the deployment
36
+ * calls this token a "5chan Pass". A client renders it verbatim, which is the point — it then
37
+ * needs to know nothing about which block the rule read or what `min` is.
38
+ */
39
+ export function shortfallError(balance, min, contract) {
40
+ return balance === 0n
41
+ ? `this wallet holds none of the gate token (${contract})`
42
+ : `this wallet holds ${balance} of the gate token (${contract}), but ${min} are required`;
43
+ }
31
44
  /**
32
45
  * True when the client can run multicall3 `aggregate3` batches — it needs both the action and
33
46
  * its chain's multicall3 deployment. A client built without a `chain` (or on a chain without
@@ -0,0 +1,16 @@
1
+ import type { RuleResult } from "./types.js";
2
+ /**
3
+ * `undefined` when the wallet is admitted, else why it was not and whether the sender may be
4
+ * blamed for it.
5
+ *
6
+ * `penalize` is normalised here (defaulting to `true`) so callers never repeat the
7
+ * `!== false` dance, and a `success: true, score: 0n` — impossible per the contract, but not
8
+ * expressible in the type — is treated as a failure rather than silently admitting a
9
+ * zero-weight vote.
10
+ */
11
+ export declare function gateFailure(result: RuleResult): {
12
+ error: string;
13
+ penalize: boolean;
14
+ } | undefined;
15
+ /** The weight slot's reading: a failure contributes nothing rather than dropping the vote. */
16
+ export declare function scoreOrZero(result: RuleResult): bigint;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The two things the pipeline does with a {@link RuleResult}, in one place so the gate path and
3
+ * the background verifier cannot drift apart.
4
+ *
5
+ * Neither helper looks at which rule produced the result — that is the standing rule of this
6
+ * codebase (AGENTS.md, "What a rule owns, and what the pipeline owns"). They read the discriminant
7
+ * and nothing else.
8
+ */
9
+ /** A rule that says `success: true` but scores nothing has a bug; refuse rather than admit. */
10
+ const NON_POSITIVE_SUCCESS = "the contest's rule reported success without a score, which is a bug in that rule";
11
+ /**
12
+ * `undefined` when the wallet is admitted, else why it was not and whether the sender may be
13
+ * blamed for it.
14
+ *
15
+ * `penalize` is normalised here (defaulting to `true`) so callers never repeat the
16
+ * `!== false` dance, and a `success: true, score: 0n` — impossible per the contract, but not
17
+ * expressible in the type — is treated as a failure rather than silently admitting a
18
+ * zero-weight vote.
19
+ */
20
+ export function gateFailure(result) {
21
+ if (!result.success)
22
+ return { error: result.error, penalize: result.penalize !== false };
23
+ if (result.score <= 0n)
24
+ return { error: NON_POSITIVE_SUCCESS, penalize: true };
25
+ return undefined;
26
+ }
27
+ /** The weight slot's reading: a failure contributes nothing rather than dropping the vote. */
28
+ export function scoreOrZero(result) {
29
+ return result.success && result.score > 0n ? result.score : 0n;
30
+ }
@@ -12,49 +12,79 @@ import type { RuleCache } from "./cache.js";
12
12
  * entries shadow builtins). The criteria still has two slots that draw from this one
13
13
  * registry:
14
14
  *
15
- * - rule slot: the score is a GATE. `> 0n` admits the wallet, `0n` rejects it.
16
- * - weight slot: the score is the vote's MAGNITUDE.
15
+ * - rule slot: the result is a GATE. `success: true` admits the wallet, `success: false`
16
+ * rejects it and must say why ({@link RuleResult.error}).
17
+ * - weight slot: a successful result's `score` is the vote's MAGNITUDE; a failure is zero
18
+ * weight.
17
19
  *
18
- * Final vote value = `evaluate(rule).score === 0n ? 0n : evaluate(weight).score`.
19
- *
20
- * A single numeric return covers both roles: a rule that needs a threshold
21
- * (min Passes, min balance) bakes it in by returning 0n when the wallet falls short.
22
- * That is why the gate slot does not need a separate boolean kind.
20
+ * One return shape covers both roles: a rule that needs a threshold (min Passes, min balance)
21
+ * bakes it in by failing when the wallet falls short, so the gate slot needs no separate kind.
23
22
  */
24
23
  /**
25
- * The result of one evaluation. `score` is a non-negative `bigint`; `0n` means "does not
26
- * qualify" (rejected in the rule slot, no weight in the weight slot). It is an
27
- * object, not a bare `bigint`, so slot-specific fields can be added without changing the
28
- * signature again e.g. a self-declared `ceiling` for balance-derived weight, which the
29
- * lazy tally needs as a wire-side upper bound (see DESIGN.md "Open questions").
24
+ * The result of one evaluation: a wallet either qualifies with a score, or it does not and the
25
+ * rule says why.
26
+ *
27
+ * Modelled on pkc-js's `ChallengeResult` (community/schema.ts) a `success` discriminant, with
28
+ * `error` REQUIRED on the failing branch. Making it required is the whole point: only the rule
29
+ * knows why a wallet fell short, and if that reason is optional it will simply be omitted, which
30
+ * leaves the pipeline with one undifferentiated "score is 0n" and forces every UI to re-derive
31
+ * the rule's thresholds and block choice to say anything useful. That duplication is exactly what
32
+ * {@link ChainReadContext} and {@link RuleCache} exist to prevent everywhere else, and it is what
33
+ * broke when the gate moved from a pinned block to the head: a client's hand-rolled explanation
34
+ * kept telling voters to wait for a window that no longer gated anything.
35
+ *
36
+ * - rule slot: `success: true` admits the wallet; `success: false` rejects it.
37
+ * - weight slot: `success: true` carries the vote's MAGNITUDE; `success: false` is zero weight.
38
+ *
39
+ * Final vote value = gate `success: false` ? `0n` : weight `success` ? weight `score` : `0n`.
40
+ *
41
+ * `score` MUST be `> 0n` on the success branch — "qualifies, with no weight" is not a state this
42
+ * models, and the pipeline treats a non-positive success score as a rule bug and refuses the
43
+ * wallet rather than silently admitting a zero-weight vote.
30
44
  */
31
- export interface RuleResult {
45
+ export type RuleResult = {
46
+ success: true;
47
+ /** The wallet's magnitude. MUST be `> 0n`. */
32
48
  score: bigint;
49
+ } | {
50
+ success: false;
33
51
  /**
34
- * May a `0n` be blamed on the sender? Default `true`.
52
+ * Why this wallet does not qualify, phrased for the person who cast (or is about to
53
+ * cast) the vote. Surfaces on `VerifyFail.reason`, on `VoteEvictedError.verdict`, and
54
+ * from `Contest.checkEligibility`, so a client renders it verbatim and stays correct
55
+ * across rule changes it knows nothing about.
35
56
  *
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`
57
+ * Write it as a statement about the wallet, not about the library: "this wallet holds
58
+ * none of the gate token", not "gate rule returned 0". Do not cite block numbers unless
59
+ * they are actionable a voter can do nothing with a sample block.
60
+ */
61
+ error: string;
62
+ /**
63
+ * May this failure be blamed on the peer that sent the vote? Default `true`.
64
+ *
65
+ * The pipeline has two decisions the rule cannot make for it — whether the gossip
66
+ * forward-gate `reject`s the message (penalizing the delivering peer through
67
+ * gossipsub's invalid-message score, eventually pruning and graylisting it) or merely
68
+ * `ignore`s it, and whether the background verifier evicts the bundle at once or holds
69
+ * it for a grace window. Both hinge on one thing only the rule knows: is this failure
41
70
  * attributable?
42
71
  *
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.
72
+ * `true` (the default) says every honest verifier necessarily computes this same
73
+ * failure — true of a read pinned to a historical block, since the block is named by
74
+ * the bundle and the chain's history does not move. The bundle is dropped, the sender
75
+ * penalized, the verdict cached as terminal.
47
76
  *
48
77
  * `false` says an honest peer could legitimately disagree, so nobody may be blamed. The
49
78
  * 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.
79
+ * background verifier re-examines it for a grace window before giving up. This is what
80
+ * a rule scoring the chain head must return: the peer that forwarded the vote verified
81
+ * it against ITS head, and any peer ahead of us can legitimately see an acquisition we
82
+ * have not. Penalizing there punishes honest relayers for being current — and does so
83
+ * exactly when a wallet has just acquired the gate asset, which is when peer heads
84
+ * straddle.
55
85
  */
56
86
  penalize?: boolean;
57
- }
87
+ };
58
88
  /** Everything a rule needs to read chain state and remember what it read. */
59
89
  export interface ChainReadContext {
60
90
  /**
@@ -101,8 +131,8 @@ export interface RuleWallet {
101
131
  }
102
132
  /**
103
133
  * The one rule kind. `O` is the validated options type (from its `optionsSchema`).
104
- * `evaluate` returns a `RuleResult` whose `score` is a non-negative `bigint`; `0n`
105
- * means "does not qualify" (rejected in the rule slot, no weight in the weight slot).
134
+ * `evaluate` returns a {@link RuleResult}: either `{ success: true, score }` with `score > 0n`,
135
+ * or `{ success: false, error }` — "does not qualify", with the reason the voter is shown.
106
136
  */
107
137
  export interface Rule<O = unknown> {
108
138
  readonly type: string;
@@ -2,6 +2,7 @@ import { base58btc } from "multiformats/bases/base58";
2
2
  import { sha256 } from "multiformats/hashes/sha2";
3
3
  import { tickerForRef } from "../chain/ticker.js";
4
4
  import { makeMemoryRuleCache } from "../rules/cache.js";
5
+ import { scoreOrZero } from "../rules/result.js";
5
6
  import { UnknownRuleError } from "../errors.js";
6
7
  /** Byte-lexicographic compare of two byte arrays (returns <0, 0, >0). */
7
8
  function compareBytes(x, y) {
@@ -37,12 +38,13 @@ export function makeTally(deps) {
37
38
  };
38
39
  const weightFor = async (wallet, blockNumber) => {
39
40
  const sampleBlock = bucketMath.sampleBlockForBucket(bucketMath.bucketForBlock(blockNumber));
40
- const { score } = await weight.evaluate({
41
+ // A weight rule that fails a wallet contributes nothing; it does NOT drop the vote —
42
+ // admission was the gate's decision, already made (see rules/result.ts).
43
+ return scoreOrZero(await weight.evaluate({
41
44
  options: weightOptions,
42
45
  wallet: { address: wallet, sampleBlock },
43
46
  ctx: weightCtx
44
- });
45
- return score;
47
+ }));
46
48
  };
47
49
  /** The rolling tie seed for a community: sha256(bucketBlockHash ‖ publicKey bytes). */
48
50
  const tieSeed = async (blockHash, publicKey) => {
@@ -116,7 +116,10 @@ export async function makeVoteNode(topic, options = {}) {
116
116
  let verifyImpl = async () => okVerifier();
117
117
  const verifier = {
118
118
  verify: (bundle) => verifyImpl(bundle),
119
- verifyOffline: (bundle) => verifyImpl(bundle)
119
+ verifyOffline: (bundle) => verifyImpl(bundle),
120
+ // These harness nodes drive the transport, not the eligibility surface; nothing here
121
+ // calls checkGate, so it admits rather than pretending to model a gate.
122
+ checkGate: async () => ({ success: true, score: 1n })
120
123
  };
121
124
  const admit = async ({ cid, bytes }) => {
122
125
  await blockstore.put(cid, bytes);
@@ -1,6 +1,7 @@
1
1
  import { tickerForRef } from "../chain/ticker.js";
2
2
  import { makeMemoryRuleCache } from "../rules/cache.js";
3
3
  import { GATE_GRACE_MS, GATE_RETRY_MS } from "./gate-grace.js";
4
+ import { gateFailure, scoreOrZero } from "../rules/result.js";
4
5
  import { UnknownRuleError } from "../errors.js";
5
6
  import { resolveNameThroughCache } from "./name-resolution-cache.js";
6
7
  const RETRY_BASE_MS = 2_000;
@@ -78,8 +79,8 @@ export function makeBackgroundVerifier(deps) {
78
79
  for (const item of pending) {
79
80
  const key = `${item.bundle.address.toLowerCase()}:${sampleBlockFor(item.bundle)}`;
80
81
  const result = results[at.get(key)];
81
- item.ruleScore = result.score;
82
- item.gatePenalize = result.penalize !== false;
82
+ item.gateFailed = gateFailure(result);
83
+ item.ruleScore = scoreOrZero(result);
83
84
  item.gateDone = true;
84
85
  }
85
86
  }
@@ -143,13 +144,13 @@ export function makeBackgroundVerifier(deps) {
143
144
  requeue.push(item); // gate read never happened (infra) — retry the whole item
144
145
  continue;
145
146
  }
146
- if (item.ruleScore === 0n) {
147
- if (item.gatePenalize) {
147
+ if (item.gateFailed) {
148
+ if (item.gateFailed.penalize) {
148
149
  // Provable, deterministic reject — safe to cache so a re-publish short-circuits.
149
150
  const verdict = {
150
151
  valid: false,
151
152
  disposition: "reject",
152
- reason: `not admitted: rule score is 0n`
153
+ reason: `not admitted: ${item.gateFailed.error}`
153
154
  };
154
155
  cache.set(item.cid, verdict);
155
156
  deps.onEvict(item.cid, verdict);
@@ -157,7 +158,7 @@ export function makeBackgroundVerifier(deps) {
157
158
  continue;
158
159
  }
159
160
  // The rule declined to blame anyone (see rules/types.ts, RuleResult.penalize):
160
- // `0n` means "not yet", not "no". The wallet may have acquired the gate asset in
161
+ // the failure means "not yet", not "no". The wallet may have acquired the asset in
161
162
  // a block this verifier has not seen, or may acquire it a moment from now — a
162
163
  // client that signs the instant it mints races its own transaction. Evicting here
163
164
  // would make whether a vote counts depend on whose RPC was a few blocks ahead, so
@@ -173,7 +174,7 @@ export function makeBackgroundVerifier(deps) {
173
174
  const verdict = {
174
175
  valid: false,
175
176
  disposition: "ignore",
176
- reason: `not admitted: rule score is 0n, and still 0n after the grace window`
177
+ reason: `not admitted: ${item.gateFailed.error} (still true after the grace window)`
177
178
  };
178
179
  deps.onEvict(item.cid, verdict);
179
180
  settle(item);
@@ -260,7 +261,7 @@ export function makeBackgroundVerifier(deps) {
260
261
  gateDone: false,
261
262
  gateNotified: false,
262
263
  ruleScore: 0n,
263
- gatePenalize: true,
264
+ gateFailed: undefined,
264
265
  resolvedNames: {},
265
266
  queuedAt: Date.now()
266
267
  });
@@ -11,10 +11,11 @@ import type { BundleVerifier } from "./types.js";
11
11
  *
12
12
  * 1. signature (local, µs): recover the EIP-712 signer, must equal `bundle.address`.
13
13
  * 2. constraints (local, µs): `votes.length <= maxVotesPerAddress`, each vote in range.
14
- * 3. gate (chain): the `rule` scores the wallet `> 0n`, at whichever block the rule
15
- * itself reads (rules/types.ts). `0n` -> not admitted -> drop, as
16
- * a `reject` when the rule blames the sender for it and an
17
- * `ignore` when it does not.
14
+ * 3. gate (chain): the `rule` must return `success: true`, at whichever block the
15
+ * rule itself reads (rules/types.ts). A failure -> not admitted ->
16
+ * drop, as a `reject` when the rule blames the sender for it and an
17
+ * `ignore` when it does not, carrying the rule's own `error` text
18
+ * as the verdict reason so the voter is told what actually failed.
18
19
  * 4. name (network): each vote's `community.name` (if any) must resolve to the
19
20
  * claimed `publicKey`; a squatted/absent name drops the bundle.
20
21
  *
@@ -1,5 +1,6 @@
1
1
  import { tickerForRef } from "../chain/ticker.js";
2
2
  import { makeMemoryRuleCache } from "../rules/cache.js";
3
+ import { gateFailure, scoreOrZero } from "../rules/result.js";
3
4
  import { UnknownRuleError } from "../errors.js";
4
5
  import { verifyBundleSignature } from "./signature.js";
5
6
  import { checkBundleConstraints } from "./constraints.js";
@@ -32,6 +33,10 @@ export function makeBundleVerifier(deps) {
32
33
  };
33
34
  return {
34
35
  verifyOffline,
36
+ // The gate step on its own, against the SAME rule/options/ctx `verify` uses below — which
37
+ // is the entire value of exposing it: a caller asking "would this wallet's vote count?"
38
+ // can never drift from what the gate actually does.
39
+ checkGate: ({ address, sampleBlock }) => rule.evaluate({ options: ruleOptions, wallet: { address, sampleBlock }, ctx }),
35
40
  async verify(bundle) {
36
41
  const offline = await verifyOffline(bundle);
37
42
  if (!offline.valid)
@@ -40,24 +45,29 @@ export function makeBundleVerifier(deps) {
40
45
  // sees fit. The bundle's bucketized sample block is handed over as the pinned
41
46
  // block the ballot names; a rule scoring current state ignores it for `ctx.head`.
42
47
  const sampleBlock = bucketMath.sampleBlockForBucket(bucketMath.bucketForBlock(bundle.blockNumber));
43
- const { score, penalize } = await rule.evaluate({
48
+ const gate = await rule.evaluate({
44
49
  options: ruleOptions,
45
50
  wallet: { address: bundle.address, sampleBlock },
46
51
  ctx
47
52
  });
48
- if (score === 0n) {
53
+ const gateFailed = gateFailure(gate);
54
+ if (gateFailed) {
49
55
  // Disposition comes from the rule's own answer, never from the rule's identity.
50
- // A `0n` the rule stands behind is identical on every honest verifier, so it is a
51
- // `reject`: the sender is penalized and the verdict cached as terminal. A `0n` it
56
+ // A failure the rule stands behind is identical on every honest verifier, so it is
57
+ // a `reject`: the sender is penalized and the verdict cached as terminal. One it
52
58
  // will not blame anyone for — the rule read this verifier's head, where my view
53
59
  // and yours legitimately differ — drops the bundle just the same but stays
54
60
  // `ignore`-class: no penalty for a relayer that saw a fresher chain, and
55
61
  // uncached, so it is re-judged rather than frozen (the same treatment community
56
62
  // name resolution has always had, step 4 below).
63
+ //
64
+ // The reason is the RULE's wording, verbatim: only it knows whether this wallet
65
+ // holds too few, holds none, or faces a contract that gates nothing, and that
66
+ // sentence is what reaches the voter through `VoteEvictedError`.
57
67
  return {
58
68
  valid: false,
59
- disposition: penalize === false ? "ignore" : "reject",
60
- reason: `not admitted: rule score is 0n`
69
+ disposition: gateFailed.penalize ? "reject" : "ignore",
70
+ reason: `not admitted: ${gateFailed.error}`
61
71
  };
62
72
  }
63
73
  // 4. Community-name resolution (network) — a carried name is a claim, verified against
@@ -92,7 +102,7 @@ export function makeBundleVerifier(deps) {
92
102
  }
93
103
  resolvedNames[name] = record.publicKey;
94
104
  }
95
- return { valid: true, ruleScore: score, resolvedNames };
105
+ return { valid: true, ruleScore: scoreOrZero(gate), resolvedNames };
96
106
  }
97
107
  };
98
108
  }
@@ -1,4 +1,5 @@
1
1
  import type { VotesBundle } from "../schema/votes.js";
2
+ import type { RuleResult } from "../rules/types.js";
2
3
  /**
3
4
  * Verification interfaces, design only.
4
5
  *
@@ -83,6 +84,19 @@ export interface BundleVerifier {
83
84
  * DESIGN.md "Background chain verification").
84
85
  */
85
86
  verifyOffline(bundle: VotesBundle): Promise<VerifyResult>;
87
+ /**
88
+ * Step 3 alone, for a wallet rather than a bundle: run the gate rule and hand back its raw
89
+ * {@link RuleResult}. Backs `Contest.checkEligibility`, so a client can ask "would this vote
90
+ * count?" through the very same rule instance, options, chain client, head reader and memo
91
+ * the forward gate uses — never a reimplementation of them.
92
+ *
93
+ * `sampleBlock` is the pinned block the prospective ballot would name (the caller's current
94
+ * bucket). A head-scoring rule ignores it exactly as it does during verification.
95
+ */
96
+ checkGate(args: {
97
+ address: string;
98
+ sampleBlock: number;
99
+ }): Promise<RuleResult>;
86
100
  }
87
101
  /**
88
102
  * The per-bundle record of the two deferred *network* checks. The offline checks (signature,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitsocial/pubsub-voting",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Trustless pubsub voting over a shared libp2p/Helia node.",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",