@bitsocial/pubsub-voting 0.4.0 → 0.5.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.
@@ -1,31 +1,20 @@
1
- import { tickerForRef } from "../chain/ticker.js";
2
- import { makeMemoryRuleCache } from "../rules/cache.js";
3
1
  import { GATE_GRACE_MS, GATE_RETRY_MS } from "./gate-grace.js";
4
- import { gateFailure, scoreOrZero } from "../rules/result.js";
5
- import { UnknownRuleError } from "../errors.js";
2
+ import { dedupeLeaves, evaluateGate, gateBlame, gatePenalize, gateReason, resolveGate } from "../rules/gate.js";
6
3
  import { resolveNameThroughCache } from "./name-resolution-cache.js";
7
4
  const RETRY_BASE_MS = 2_000;
8
5
  const RETRY_CAP_MS = 60_000;
9
6
  export function makeBackgroundVerifier(deps) {
10
- const { criteria, registry, chainFor, bucketMath, nameResolvers, nameResolutionCache, cache, limit } = deps;
7
+ const { criteria, registry, chain, bucketMath, nameResolvers, nameResolutionCache, cache, limit } = deps;
11
8
  const retryBaseMs = deps.retryBaseMs ?? RETRY_BASE_MS;
12
9
  const retryCapMs = deps.retryCapMs ?? RETRY_CAP_MS;
13
10
  const gateGraceMs = deps.gateGraceMs ?? GATE_GRACE_MS;
14
11
  const gateRetryMs = deps.gateRetryMs ?? GATE_RETRY_MS;
15
- // Resolve the gate `rule`, its options, and its chain once (same shape as verify/bundle.ts).
16
- // The re-binding after the guard keeps the non-undefined narrowing inside the closures below.
17
- const maybeRule = registry[criteria.rule.type];
18
- if (!maybeRule)
19
- throw new UnknownRuleError("rule", criteria.rule.type);
20
- const rule = maybeRule;
21
- const ruleOptions = rule.optionsSchema.parse(criteria.rule);
22
- const ruleChain = chainFor(tickerForRef(criteria, criteria.rule, ruleOptions));
23
- const readHead = deps.readHead ?? (async ({ chain }) => ({ block: Number(await chain.getBlockNumber()) }));
24
- const ctx = {
25
- chain: ruleChain,
26
- head: () => readHead({ chain: ruleChain }),
27
- cache: deps.ruleCache ?? makeMemoryRuleCache()
28
- };
12
+ // Resolve every gate leaf — rule, options, chain, memo once (same call as verify/bundle.ts,
13
+ // so the two verifiers cannot resolve a gate differently).
14
+ const readHead = deps.readHead ?? (async ({ chain: client }) => ({ block: Number(await client.getBlockNumber()) }));
15
+ const leaves = resolveGate({ criteria, registry, chain, readHead, caches: deps.ruleCaches });
16
+ // Which leaves ask the same question: a rule named twice in one gate is batched once.
17
+ const { representatives, ofLeaf } = dedupeLeaves(leaves);
29
18
  const queue = [];
30
19
  /** CIDs queued or in-flight, so a re-chased root cannot double-verify a bundle. */
31
20
  const inFlight = new Set();
@@ -49,15 +38,22 @@ export function makeBackgroundVerifier(deps) {
49
38
  return bucketMath.sampleBlockForBucket(bucketMath.bucketForBlock(bundle.blockNumber));
50
39
  }
51
40
  /**
52
- * Gate stage for one round's batch: hand every not-yet-gated wallet to the rule at once
53
- * `evaluateMany` when it has one, `limit`-bounded per-wallet `evaluate` otherwise — and
54
- * record each score with the rule's own verdict on whether a `0n` is attributable.
41
+ * Gate stage for one round's batch: hand every not-yet-gated wallet to EACH gate leaf at once
42
+ * `evaluateMany` when the rule has one, `limit`-bounded per-wallet `evaluate` otherwise —
43
+ * then fold each item's leaf answers into its verdict.
44
+ *
45
+ * Note this deliberately does NOT short-circuit the tree the way the inline gate does. Here
46
+ * the axis of batching is the rule, not the wallet: one `evaluateMany` per leaf covers the
47
+ * whole round, so scoring every leaf costs one round trip per leaf no matter how many wallets
48
+ * are pending, while short-circuiting per wallet would fragment those batches into
49
+ * per-wallet reads. Collecting all is the cheaper shape on this path, and it is also what
50
+ * gives every item a complete blame set.
55
51
  *
56
- * No grouping and no cache lookups here any more: which block each wallet is read at, and
57
- * what may be memoized under which key, are the rule's decisions (rules/types.ts,
58
- * rules/cache.ts). Duplicate wallets are still collapsed before the call, because that is a
59
- * property of THIS batch rather than of any rule. Throws on the FIRST infra failure: the
60
- * round's unfinished items are re-queued by the caller.
52
+ * No grouping and no cache lookups here: which block each wallet is read at, and what may be
53
+ * memoized under which key, are the rule's decisions (rules/types.ts, rules/cache.ts).
54
+ * Duplicate wallets are still collapsed before the calls, because that is a property of THIS
55
+ * batch rather than of any rule. Throws on the FIRST infra failure: the round's unfinished
56
+ * items are re-queued by the caller.
61
57
  */
62
58
  async function gateStage(items) {
63
59
  const pending = items.filter((item) => !item.gateDone);
@@ -73,14 +69,24 @@ export function makeBackgroundVerifier(deps) {
73
69
  at.set(key, wallets.length);
74
70
  wallets.push(wallet);
75
71
  }
76
- const results = rule.evaluateMany
77
- ? (await rule.evaluateMany({ options: ruleOptions, wallets, ctx })).results
78
- : await Promise.all(wallets.map((wallet) => limit(() => rule.evaluate({ options: ruleOptions, wallet, ctx }))));
72
+ // One batched call per distinct QUESTION, those in parallel: each is a single multicall
73
+ // for the whole round, and the voter's per-client in-flight budget still bounds what
74
+ // reaches an RPC. A rule named twice in one gate ("any two of these three") is asked once
75
+ // and its answer fanned back out to both positions.
76
+ const byQuestion = await Promise.all(representatives.map((leaf) => {
77
+ const { rule, options, ctx } = leaves[leaf];
78
+ return rule.evaluateMany
79
+ ? rule.evaluateMany({ options, wallets, ctx }).then(({ results }) => results)
80
+ : Promise.all(wallets.map((wallet) => limit(() => rule.evaluate({ options, wallet, ctx }))));
81
+ }));
79
82
  for (const item of pending) {
80
- const key = `${item.bundle.address.toLowerCase()}:${sampleBlockFor(item.bundle)}`;
81
- const result = results[at.get(key)];
82
- item.gateFailed = gateFailure(result);
83
- item.ruleScore = scoreOrZero(result);
83
+ const wallet = at.get(`${item.bundle.address.toLowerCase()}:${sampleBlockFor(item.bundle)}`);
84
+ // Every leaf is already scored, so this "evaluation" reads the batch — no chain work.
85
+ item.gate = await evaluateGate({
86
+ node: criteria.gate,
87
+ evaluate: async (leaf) => byQuestion[ofLeaf[leaf]][wallet],
88
+ collectAll: true
89
+ });
84
90
  item.gateDone = true;
85
91
  }
86
92
  }
@@ -144,20 +150,29 @@ export function makeBackgroundVerifier(deps) {
144
150
  requeue.push(item); // gate read never happened (infra) — retry the whole item
145
151
  continue;
146
152
  }
147
- if (item.gateFailed) {
148
- if (item.gateFailed.penalize) {
153
+ if (item.gate !== undefined && item.gate.satisfied !== true) {
154
+ // The rules' own wording, narrowed to the failures that explain the refusal, plus
155
+ // the recursive answer to "may this be blamed on the sender" (rules/gate.ts).
156
+ const failures = gateBlame(item.gate).map((leaf) => ({
157
+ type: leaves[leaf.leaf].ref.type,
158
+ error: leaf.error ?? ""
159
+ }));
160
+ const reason = gateReason(item.gate);
161
+ if (gatePenalize(item.gate)) {
149
162
  // Provable, deterministic reject — safe to cache so a re-publish short-circuits.
150
163
  const verdict = {
151
164
  valid: false,
152
165
  disposition: "reject",
153
- reason: `not admitted: ${item.gateFailed.error}`
166
+ reason: `not admitted: ${reason}`,
167
+ failures
154
168
  };
155
169
  cache.set(item.cid, verdict);
156
170
  deps.onEvict(item.cid, verdict);
157
171
  settle(item);
158
172
  continue;
159
173
  }
160
- // The rule declined to blame anyone (see rules/types.ts, RuleResult.penalize):
174
+ // No failure the gate will blame anyone for (see rules/types.ts, RuleResult.penalize,
175
+ // and rules/gate.ts for how that folds through `all`/`any`):
161
176
  // the failure means "not yet", not "no". The wallet may have acquired the asset in
162
177
  // a block this verifier has not seen, or may acquire it a moment from now — a
163
178
  // client that signs the instant it mints races its own transaction. Evicting here
@@ -174,7 +189,8 @@ export function makeBackgroundVerifier(deps) {
174
189
  const verdict = {
175
190
  valid: false,
176
191
  disposition: "ignore",
177
- reason: `not admitted: ${item.gateFailed.error} (still true after the grace window)`
192
+ reason: `not admitted: ${reason} (still true after the grace window)`,
193
+ failures
178
194
  };
179
195
  deps.onEvict(item.cid, verdict);
180
196
  settle(item);
@@ -198,7 +214,7 @@ export function makeBackgroundVerifier(deps) {
198
214
  if (item.bundle.votes.some((v) => v.community.name))
199
215
  deps.onNameResolved(item.cid);
200
216
  // Fully settled: store the terminal valid verdict (same shape the forward-gate caches).
201
- cache.set(item.cid, { valid: true, ruleScore: item.ruleScore, resolvedNames: item.resolvedNames });
217
+ cache.set(item.cid, { valid: true, resolvedNames: item.resolvedNames });
202
218
  settle(item);
203
219
  }
204
220
  if (requeue.length > 0) {
@@ -260,8 +276,7 @@ export function makeBackgroundVerifier(deps) {
260
276
  ...entry,
261
277
  gateDone: false,
262
278
  gateNotified: false,
263
- ruleScore: 0n,
264
- gateFailed: undefined,
279
+ gate: undefined,
265
280
  resolvedNames: {},
266
281
  queuedAt: Date.now()
267
282
  });
@@ -1,7 +1,7 @@
1
1
  import type { Criteria } from "../schema/criteria.js";
2
2
  import type { RuleRegistry } from "../rules/types.js";
3
3
  import type { ChainClient, BucketMath, NameResolver } from "../chain/types.js";
4
- import { type RuleCache } from "../rules/cache.js";
4
+ import type { RuleCache } from "../rules/cache.js";
5
5
  import { type NameResolutionCache } from "./name-resolution-cache.js";
6
6
  import type { BundleVerifier } from "./types.js";
7
7
  /**
@@ -32,12 +32,12 @@ export interface BundleVerifierDeps {
32
32
  criteria: Criteria;
33
33
  /** The criteria document's CID bytes (`(await criteriaCid(criteria)).bytes`) — signature binding. */
34
34
  criteriaCid: Uint8Array;
35
- /** The rule chain's numeric chainId (bound in the ballot domain). */
35
+ /** The contest's numeric chainId (`criteria.bucketChainId`, bound in the ballot domain). */
36
36
  chainId: number;
37
37
  /** Resolved rule registry (built-ins + host overrides). */
38
38
  registry: RuleRegistry;
39
- /** Resolve a chain ticker (e.g. "base") to its viem client. */
40
- chainFor: (ticker: string) => ChainClient;
39
+ /** The contest's one chain client — every rule reads it (DESIGN.md "One clock"). */
40
+ chain: ChainClient;
41
41
  /** Bucket math for `criteria.blocksPerBucket`. */
42
42
  bucketMath: BucketMath;
43
43
  /** Host-injected community-name resolvers (`PubsubVoterOptions.nameResolvers`). */
@@ -55,13 +55,15 @@ export interface BundleVerifierDeps {
55
55
  block: number;
56
56
  }>;
57
57
  /**
58
- * The gate rule's memo (see rules/cache.ts), handed to it as `ctx.cache`. This is what keeps
59
- * a wallet's gate read from repeating per bundle — an ineligible wallet minting fresh-signed
60
- * bundles, or an eligible one cycling choices, costs one read per key per rule epoch rather
61
- * than one per bundle. Defaults to a private in-memory cache (unit tests); the voter injects
62
- * the persistent, contest-shared one.
58
+ * One memo per gate leaf (see rules/cache.ts), in `gateLeaves` order, handed to each rule as
59
+ * its `ctx.cache`. This is what keeps a wallet's gate read from repeating per bundle — an
60
+ * ineligible wallet minting fresh-signed bundles, or an eligible one cycling choices, costs
61
+ * one read per key per rule epoch rather than one per bundle. Each leaf gets its OWN
62
+ * namespace, so two leaves of the same rule `type` on different options can never read each
63
+ * other's answers. Defaults to private in-memory caches (unit tests); the voter injects the
64
+ * persistent, contest-shared ones.
63
65
  */
64
- ruleCache?: RuleCache;
66
+ ruleCaches?: readonly RuleCache[];
65
67
  /**
66
68
  * Optional persistent cache of name resolutions (the pkc-js rule — see
67
69
  * verify/name-resolution-cache.ts). When present, a carried name is resolved live at most
@@ -1,26 +1,32 @@
1
- import { tickerForRef } from "../chain/ticker.js";
2
- import { makeMemoryRuleCache } from "../rules/cache.js";
3
- import { gateFailure, scoreOrZero } from "../rules/result.js";
4
- import { UnknownRuleError } from "../errors.js";
1
+ import { dedupeLeaves, evaluateGate, gateBlame, gatePenalize, gateReason, resolveGate } from "../rules/gate.js";
5
2
  import { verifyBundleSignature } from "./signature.js";
6
3
  import { checkBundleConstraints } from "./constraints.js";
7
4
  import { resolveNameThroughCache } from "./name-resolution-cache.js";
8
5
  export function makeBundleVerifier(deps) {
9
- const { criteria, criteriaCid, chainId, registry, chainFor, bucketMath, nameResolvers, nameResolutionCache } = deps;
10
- // Resolve the gate `rule`, its options and its chain client once: they are fixed by the
11
- // criteria, so none of it is recomputed per bundle.
12
- const rule = registry[criteria.rule.type];
13
- if (!rule)
14
- throw new UnknownRuleError("rule", criteria.rule.type);
15
- const ruleOptions = rule.optionsSchema.parse(criteria.rule);
16
- const ruleChain = chainFor(tickerForRef(criteria, criteria.rule, ruleOptions));
17
- const readHead = deps.readHead ?? (async ({ chain }) => ({ block: Number(await chain.getBlockNumber()) }));
18
- // The rule's whole world: its chain, this verifier's head (lazy never read for a rule that
19
- // does not want it), and its own memo. Which block it actually reads at is its business.
20
- const ctx = {
21
- chain: ruleChain,
22
- head: () => readHead({ chain: ruleChain }),
23
- cache: deps.ruleCache ?? makeMemoryRuleCache()
6
+ const { criteria, criteriaCid, chainId, registry, chain, bucketMath, nameResolvers, nameResolutionCache } = deps;
7
+ // Resolve every gate leaf — rule, options, memo once: they are fixed by the criteria, so
8
+ // none of it is recomputed per bundle. Same for which leaves ask the same question.
9
+ const readHead = deps.readHead ?? (async ({ chain: client }) => ({ block: Number(await client.getBlockNumber()) }));
10
+ const leaves = resolveGate({ criteria, registry, chain, readHead, caches: deps.ruleCaches });
11
+ const { ofLeaf } = dedupeLeaves(leaves);
12
+ /**
13
+ * Score one leaf for one wallet, sharing one answer between leaves that ask the same question
14
+ * (`dedupeLeaves`). The per-wallet map is what makes the sharing real: the fold evaluates
15
+ * leaves concurrently, so two positions of one rule would otherwise both miss its memo before
16
+ * either wrote to it.
17
+ */
18
+ const scoreLeaf = (wallet) => {
19
+ const asked = new Map();
20
+ return (leaf) => {
21
+ const question = ofLeaf[leaf];
22
+ const already = asked.get(question);
23
+ if (already)
24
+ return already;
25
+ const { rule, options, ctx } = leaves[leaf];
26
+ const answer = rule.evaluate({ options, wallet, ctx });
27
+ asked.set(question, answer);
28
+ return answer;
29
+ };
24
30
  };
25
31
  // Stage 1, shared by `verify` and `verifyOffline`: signature + constraints, local and µs.
26
32
  const verifyOffline = async (bundle) => {
@@ -33,25 +39,47 @@ export function makeBundleVerifier(deps) {
33
39
  };
34
40
  return {
35
41
  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 }),
42
+ // The gate step on its own, against the SAME leaves/options/ctxs `verify` uses below —
43
+ // which is the entire value of exposing it: a caller asking "would this wallet's vote
44
+ // count?" can never drift from what the gate actually does. Every leaf is scored here
45
+ // (`collectAll`), because naming each failure is what this call is for.
46
+ async checkGates({ address, sampleBlock }) {
47
+ // A leaf whose read FAILED is not a leaf that said no. Under an `any`, refusing the
48
+ // whole check because one rule's RPC timed out would tell a wallet that qualifies
49
+ // through another branch that it is ineligible — over an outage it cannot act on. So
50
+ // a failed read is folded as unknown, and only if the tree cannot be decided without
51
+ // it does the original error surface (never a verdict invented from a missing read).
52
+ let firstError;
53
+ let failed = false;
54
+ const gate = await evaluateGate({
55
+ node: criteria.gate,
56
+ evaluate: scoreLeaf({ address, sampleBlock }),
57
+ collectAll: true,
58
+ tolerateLeafErrors: true,
59
+ onLeafError: (_leaf, error) => {
60
+ if (!failed)
61
+ [failed, firstError] = [true, error];
62
+ }
63
+ });
64
+ if (gate.satisfied === undefined)
65
+ throw firstError;
66
+ return gate;
67
+ },
40
68
  async verify(bundle) {
41
69
  const offline = await verifyOffline(bundle);
42
70
  if (!offline.valid)
43
71
  return offline;
44
- // 3. Gate (chain) — the rule scores this wallet, reading and memoizing however it
45
- // sees fit. The bundle's bucketized sample block is handed over as the pinned
46
- // block the ballot names; a rule scoring current state ignores it for `ctx.head`.
72
+ // 3. Gate (chain) — each leaf rule scores this wallet, reading and memoizing however
73
+ // it sees fit, and `all`/`any` fold the answers (rules/gate.ts). The bundle's
74
+ // bucketized sample block is handed over as the pinned block the ballot names; a
75
+ // rule scoring current state ignores it for `ctx.head`. Lazy on this path: a
76
+ // determined tree stops, so a composite gate costs only the leaves it needed.
47
77
  const sampleBlock = bucketMath.sampleBlockForBucket(bucketMath.bucketForBlock(bundle.blockNumber));
48
- const gate = await rule.evaluate({
49
- options: ruleOptions,
50
- wallet: { address: bundle.address, sampleBlock },
51
- ctx
78
+ const gate = await evaluateGate({
79
+ node: criteria.gate,
80
+ evaluate: scoreLeaf({ address: bundle.address, sampleBlock })
52
81
  });
53
- const gateFailed = gateFailure(gate);
54
- if (gateFailed) {
82
+ if (gate.satisfied !== true) {
55
83
  // Disposition comes from the rule's own answer, never from the rule's identity.
56
84
  // A failure the rule stands behind is identical on every honest verifier, so it is
57
85
  // a `reject`: the sender is penalized and the verdict cached as terminal. One it
@@ -61,13 +89,16 @@ export function makeBundleVerifier(deps) {
61
89
  // uncached, so it is re-judged rather than frozen (the same treatment community
62
90
  // name resolution has always had, step 4 below).
63
91
  //
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`.
92
+ // The reason is the RULES' wording, verbatim: only they know whether this wallet
93
+ // holds too few, holds none, or faces a contract that gates nothing, and those
94
+ // sentences are what reach the voter through `VoteEvictedError` — narrowed to the
95
+ // failures that actually explain the refusal (`gateBlame`), so a wallet is never
96
+ // told to go acquire something a satisfied `any` branch never needed.
67
97
  return {
68
98
  valid: false,
69
- disposition: gateFailed.penalize ? "reject" : "ignore",
70
- reason: `not admitted: ${gateFailed.error}`
99
+ disposition: gatePenalize(gate) ? "reject" : "ignore",
100
+ reason: `not admitted: ${gateReason(gate)}`,
101
+ failures: gateBlame(gate).map((leaf) => ({ type: leaves[leaf.leaf].ref.type, error: leaf.error ?? "" }))
71
102
  };
72
103
  }
73
104
  // 4. Community-name resolution (network) — a carried name is a claim, verified against
@@ -102,7 +133,7 @@ export function makeBundleVerifier(deps) {
102
133
  }
103
134
  resolvedNames[name] = record.publicKey;
104
135
  }
105
- return { valid: true, ruleScore: scoreOrZero(gate), resolvedNames };
136
+ return { valid: true, resolvedNames };
106
137
  }
107
138
  };
108
139
  }
@@ -1,5 +1,5 @@
1
1
  import type { VotesBundle } from "../schema/votes.js";
2
- import type { RuleResult } from "../rules/types.js";
2
+ import type { GateResult } from "../rules/gate.js";
3
3
  /**
4
4
  * Verification interfaces, design only.
5
5
  *
@@ -36,6 +36,17 @@ export type VerifyFail = {
36
36
  valid: false;
37
37
  disposition: VerdictDisposition;
38
38
  reason: string;
39
+ /**
40
+ * Present when the GATE refused: one entry per rule whose failure explains the refusal
41
+ * (rules/gate.ts `gateBlame` — not every failed leaf, since one inside a satisfied `any`
42
+ * cost the wallet nothing). `reason` is these same sentences joined, kept so a caller that
43
+ * only renders a string needs no change; this is the structured form, so a client listing
44
+ * "what you are missing" never has to split one.
45
+ */
46
+ failures?: readonly {
47
+ type: string;
48
+ error: string;
49
+ }[];
39
50
  };
40
51
  export type VerifyResult = VerifyOk | VerifyFail;
41
52
  /** Stage 1: ballot signature only. No chain access. */
@@ -52,17 +63,20 @@ export interface OfflineBundleVerifier {
52
63
  }): Promise<VerifyResult>;
53
64
  }
54
65
  /**
55
- * A passing full-bundle verdict. Beyond `valid: true` it carries the work the gate already
56
- * did so downstream stages need not redo it:
57
- * - `ruleScore`: the gate `rule`'s score for the voting wallet at the bucket block
58
- * (always `> 0n` here `0n` would have failed the gate).
59
- * - `resolvedNames`: for each vote that carried a `community.name`, the `publicKey` the name
60
- * resolved to (equal to the claimed key, since a mismatch fails the gate). Votes with no
61
- * name are absent. Lets a UI show a verified name without re-resolving.
66
+ * A passing full-bundle verdict. Beyond `valid: true` it carries the work the gate already did so
67
+ * downstream stages need not redo it: `resolvedNames` maps each vote that carried a
68
+ * `community.name` to the `publicKey` the name resolved to (equal to the claimed key, since a
69
+ * mismatch fails the gate). Votes with no name are absent. Lets a UI show a verified name without
70
+ * re-resolving.
71
+ *
72
+ * The gate's folded SCORE is deliberately not here. Nothing downstream reads it — a vote's
73
+ * magnitude comes from `criteria.weight`, never from the gate — and a number no one consumes grows
74
+ * semantics by accident, which a min-across-`all` fold over unrelated rules ("holds 5" and "not
75
+ * banned = 1") cannot survive. A client that wants per-rule scores asks `checkEligibility`, which
76
+ * reports each leaf's own.
62
77
  */
63
78
  export interface BundleVerdictValid {
64
79
  valid: true;
65
- ruleScore: bigint;
66
80
  resolvedNames: Record<string, string>;
67
81
  }
68
82
  /**
@@ -85,25 +99,32 @@ export interface BundleVerifier {
85
99
  */
86
100
  verifyOffline(bundle: VotesBundle): Promise<VerifyResult>;
87
101
  /**
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.
102
+ * Step 3 alone, for a wallet rather than a bundle: score EVERY leaf of the gate tree and hand
103
+ * back the folded {@link GateResult}. Backs `Contest.checkEligibility`, so a client can ask
104
+ * "would this vote count?" through the very same rule instances, options, chain clients, head
105
+ * reader and memos the forward gate uses — never a reimplementation of them.
106
+ *
107
+ * Unlike `verify`, this never short-circuits: a caller asking which rules a wallet fails needs
108
+ * all of their answers, not just the first one that settled the outcome.
109
+ *
110
+ * Also unlike `verify`, a leaf whose chain read THROWS does not fail the call outright — it is
111
+ * folded as unknown, so a wallet admitted by a branch that did answer still gets its answer.
112
+ * The error is re-thrown only when the tree cannot be decided without that leaf.
92
113
  *
93
114
  * `sampleBlock` is the pinned block the prospective ballot would name (the caller's current
94
115
  * bucket). A head-scoring rule ignores it exactly as it does during verification.
95
116
  */
96
- checkGate(args: {
117
+ checkGates(args: {
97
118
  address: string;
98
119
  sampleBlock: number;
99
- }): Promise<RuleResult>;
120
+ }): Promise<GateResult>;
100
121
  }
101
122
  /**
102
123
  * The per-bundle record of the two deferred *network* checks. The offline checks (signature,
103
124
  * constraints) are never recorded here — they are synchronous preconditions for admission, so
104
125
  * an admitted bundle has always passed them.
105
126
  *
106
- * - `chainVerified`: the gate `rule` scored the wallet `> 0n` at the bucket block. `false`
127
+ * - `chainVerified`: the gate admitted the wallet at the bucket block. `false`
107
128
  * means "not yet read", never "failed" — a failed gate evicts the bundle instead.
108
129
  * - `nameResolved`: `undefined` when the bundle carries no `community.name`; `false` while
109
130
  * the carried name is unresolved; `true` once it resolved to the claimed `publicKey`. A
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitsocial/pubsub-voting",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",
@@ -1,15 +0,0 @@
1
- import type { Criteria, RuleRef } from "../schema/criteria.js";
2
- /**
3
- * Resolve which chain a rule reads. A rule's parsed options may name a
4
- * `chain` ticker (e.g. `erc5192-min-balance` -> "base"); a chainless rule (e.g.
5
- * `constant`) names none, so callers fall back to the first configured chain. Shared by the
6
- * verifier, the tally, and the facade so the fallback rule stays identical everywhere.
7
- */
8
- /** The `chain` ticker named in a rule's parsed options, or `undefined` if none. */
9
- export declare function chainTickerOf(options: unknown): string | undefined;
10
- /**
11
- * The chain ticker a rule ref uses: its own `chain` option, else the first chain in
12
- * `requires.chains`. Throws if neither exists (a chainless rule with no configured
13
- * chains cannot be read).
14
- */
15
- export declare function tickerForRef(criteria: Criteria, ref: RuleRef, options: unknown): string;
@@ -1,25 +0,0 @@
1
- import { z } from "zod";
2
- /**
3
- * Resolve which chain a rule reads. A rule's parsed options may name a
4
- * `chain` ticker (e.g. `erc5192-min-balance` -> "base"); a chainless rule (e.g.
5
- * `constant`) names none, so callers fall back to the first configured chain. Shared by the
6
- * verifier, the tally, and the facade so the fallback rule stays identical everywhere.
7
- */
8
- /** The `chain` ticker named in a rule's parsed options, or `undefined` if none. */
9
- export function chainTickerOf(options) {
10
- const parsed = z.object({ chain: z.string().min(1) }).safeParse(options);
11
- return parsed.success ? parsed.data.chain : undefined;
12
- }
13
- /**
14
- * The chain ticker a rule ref uses: its own `chain` option, else the first chain in
15
- * `requires.chains`. Throws if neither exists (a chainless rule with no configured
16
- * chains cannot be read).
17
- */
18
- export function tickerForRef(criteria, ref, options) {
19
- const ticker = chainTickerOf(options) ?? Object.keys(criteria.requires.chains)[0];
20
- if (!ticker) {
21
- throw new Error(`criteria rule "${ref.type}" names no chain and requires.chains is empty; ` +
22
- `cannot resolve a chain client to read it`);
23
- }
24
- return ticker;
25
- }